Application Services Should Orchestrate, Not Own Everything

An application service should make the use case readable, not become the only place where all system behavior is understood.

1. Introduction: the application layer is easy to overload

In a layered architecture, the application layer often begins with a clear purpose. It coordinates use cases, calls repositories, invokes domain behavior, and returns results that the user interface or API can use.

Over time, however, application services can become overloaded. A new condition is added here, a small validation rule there, then some mapping logic, then a storage-specific detail, then a decision that really belongs in the domain or repository.

Each individual change may seem reasonable. The problem appears gradually.

The application service becomes the place where everything happens.

At that point, the system may still have layers, abstractions, and separate projects, but the responsibility has become unclear. Instead of making the use case easier to understand, the application service becomes a large procedural script that knows too much about too many parts of the system.

An application service should make the flow of a use case clear. It should not become the only place where the system’s behavior is understandable.

2. What an application service is responsible for

An application service should normally describe what the application is trying to do in a specific use case.

It may load data, ask the domain model or a policy to make a decision, persist changes, call another service, and return a result. Its role is coordination.

For example, an application service may:

  1. Receive a request.
  2. Validate that the request is structurally usable.
  3. Load the required data.
  4. Delegate business decisions to the appropriate domain object, policy, or service.
  5. Persist the result.
  6. Return an application-level response.

That is a useful responsibility. It gives the use case a clear place in the system.

The problem starts when the application service not only coordinates these steps, but also owns all the decisions inside them.

There is an important difference between this:

public async Task PublishArticleAsync(string articleId, CancellationToken ct)
{
    var article = await _articleRepository.GetByIdAsync(articleId, ct);

    if (article is null)
    {
        throw new ArticleNotFoundException(articleId);
    }

    article.Publish(_clock.UtcNow);

    await _articleRepository.SaveAsync(article, ct);
}

Compare that with a version where the application service owns more of the decision-making:

public async Task PublishArticleAsync(string articleId, CancellationToken ct)
{
    var article = await _articleRepository.GetByIdAsync(articleId, ct);

    if (article is null)
    {
        throw new ArticleNotFoundException(articleId);
    }

    if (string.IsNullOrWhiteSpace(article.Title))
    {
        throw new InvalidOperationException("The article must have a title.");
    }

    if (string.IsNullOrWhiteSpace(article.ContentMarkdown))
    {
        throw new InvalidOperationException("The article must have content.");
    }

    article.IsPublished = true;
    article.PublishedUtc = DateTime.UtcNow;

    await _articleRepository.SaveAsync(article, ct);
}

The second version may work, but the application service now owns publishing rules, state changes, and time handling. If those rules grow, the service will grow with them.

A better design is usually to let the application service coordinate the publishing use case, while the article or a dedicated policy owns the rules for whether publishing is allowed.

3. What happens when the application service owns too much

When application services own too much, several problems tend to appear.

First, the service becomes difficult to read. Instead of showing the flow of the use case, it becomes a long sequence of unrelated details.

Second, the service gains too many reasons to change. A single class may need to change when the user interface changes, when a business rule changes, when persistence changes, when validation changes, or when an external integration changes.

Third, the boundaries of the system become less clear. Developers begin to ask: should this rule go in the domain model, the repository, the UI, or the application service? If the application service has already become the place where most decisions are placed, the easiest answer is often to add even more logic there.

That is how the application layer gradually becomes a dumping ground.

The result is not always obvious at first. The code may still be testable. The project structure may still look clean. The dependencies may still point in the right direction.

But the system becomes harder to reason about because the application service no longer has one clear role.

It coordinates the use case, owns business rules, understands persistence details, performs mapping, handles technical exceptions, shapes UI-specific responses, and contains defensive checks for behavior that should have been enforced elsewhere.

That is too much responsibility for one layer, and often too much responsibility for one class.

4. Example: one service with too many reasons to change

Consider an application service that updates an article.

public async Task<UpdateArticleResult> UpdateArticleAsync(
    UpdateArticleRequest request,
    CancellationToken ct)
{
    if (string.IsNullOrWhiteSpace(request.Title))
    {
        return UpdateArticleResult.Failed("Title is required.");
    }

    if (request.Title.Length > 120)
    {
        return UpdateArticleResult.Failed("Title is too long.");
    }

    var article = await _articleRepository.GetByIdAsync(request.Id, ct);

    if (article is null)
    {
        return UpdateArticleResult.Failed("Article not found.");
    }

    article.Title = request.Title.Trim();
    article.Summary = request.Summary?.Trim();
    article.ContentMarkdown = request.ContentMarkdown?.Trim() ?? string.Empty;
    article.UpdatedUtc = DateTime.UtcNow;

    if (request.PublishNow &&
        !string.IsNullOrWhiteSpace(article.ContentMarkdown))
    {
        article.IsPublished = true;
        article.PublishedUtc ??= DateTime.UtcNow;
    }

    await _articleRepository.SaveAsync(article, ct);

    return UpdateArticleResult.Success(article.Id);
}

There is nothing unusual about this code. Many systems contain services like this.

But several different kinds of decisions are now mixed together:

  1. Request validation.
  2. Article lookup.
  3. Text normalization.
  4. Business rules for publishing.
  5. Time handling.
  6. Persistence.
  7. Result formatting.

The application service has become responsible for all of them.

A clearer version does not necessarily require a large design change. It can begin by moving decisions to more appropriate places.

For example, the article itself may own how it is updated:

public void UpdateContent(
    string title,
    string? summary,
    string contentMarkdown,
    DateTime updatedUtc)
{
    if (string.IsNullOrWhiteSpace(title))
    {
        throw new InvalidArticleStateException("Title is required.");
    }

    Title = title.Trim();
    Summary = summary?.Trim();
    ContentMarkdown = contentMarkdown.Trim();
    UpdatedUtc = updatedUtc;
}

Publishing rules may belong in the domain object or in a policy:

public bool CanPublish()
{
    return !string.IsNullOrWhiteSpace(Title) &&
           !string.IsNullOrWhiteSpace(ContentMarkdown);
}

The application service can then become easier to read:

public async Task<UpdateArticleResult> UpdateArticleAsync(
    UpdateArticleRequest request,
    CancellationToken ct)
{
    var article = await _articleRepository.GetByIdAsync(request.Id, ct);

    if (article is null)
    {
        return UpdateArticleResult.Failed("Article not found.");
    }

    var now = _clock.UtcNow;

    article.UpdateContent(
        request.Title,
        request.Summary,
        request.ContentMarkdown ?? string.Empty,
        now);

    if (request.PublishNow)
    {
        article.Publish(now);
    }

    await _articleRepository.SaveAsync(article, ct);

    return UpdateArticleResult.Success(article.Id);
}

This version still coordinates the use case. It still loads the article, applies the requested operation, saves the result, and returns an application-level response.

But it no longer owns every detail.

That is the important difference.

5. Moving decisions to clearer homes

Improving an overloaded application service does not mean moving logic randomly out of the class. The goal is not simply to make the service shorter. The goal is to make responsibility clearer.

A useful question is:

What kind of decision is this?

If the decision is about whether an object is in a valid business state, it may belong in the domain model.

If the decision is about which records should be retrieved from storage, it may belong in the repository.

If the decision is about a use case sequence, it may belong in the application service.

If the decision is about how something is displayed, it belongs in the UI.

If the decision varies independently from the use case, it may deserve its own policy or strategy.

For example, an application service may need to publish an article. The sequence belongs in the application service:

  1. Load the article.
  2. Ask the article to publish itself.
  3. Save the article.
  4. Return the result.

But the rule for whether the article can be published should not necessarily be written inline in the application service. If that rule is part of the article’s valid state, the article should own it. If the rule depends on external configuration or editorial workflow, a dedicated policy may be more appropriate.

The same applies to filtering.

If a use case needs published articles, the application service may call:

var articles = await _articleRepository.GetPublishedArticlesAsync(ct);

That is usually clearer than loading all articles and filtering them in the application layer:

var articles = await _articleRepository.GetAllAsync(ct);

var visibleArticles = articles
    .Where(article => article.IsPublished)
    .ToList();

The second version exposes too much responsibility to the application service. It now knows that publication status is the storage-level visibility rule.

The first version makes the use case more readable and keeps the repository responsible for retrieval behavior.

Clearer homes do not remove judgment. They make the judgment visible.

These examples are not about moving code for the sake of moving code. They are about making the reason for each piece of code easier to see.

That is also why the topic connects naturally to several established design principles.

6. Relation to SOLID and common patterns

This is closely related to the Single Responsibility Principle. The issue is not simply that an application service becomes long. The issue is that it starts to have too many reasons to change.

If the same service changes when the user interface changes, when a business rule changes, when storage changes, and when an external integration changes, it is no longer only coordinating a use case. It has become a container for unrelated decisions.

The Dependency Inversion Principle is also relevant. Application services can coordinate persistence, notifications, file handling, or external APIs, but they should normally do so through abstractions. The use case should not depend directly on the technical implementation used to carry it out.

Common patterns such as Repository, Policy, Strategy, and Command Handler can help, but only when they clarify responsibility. Patterns are not valuable because they add structure. They are valuable when they give important decisions a better home.

7. Warning signs of an overloaded application service

An application service may be overloaded if several of these signs appear:

  1. The method is difficult to summarize in one sentence.
  2. The service changes for many unrelated reasons.
  3. The service contains business rules that are repeated elsewhere.
  4. The service knows details about database queries, file paths, HTTP calls, or framework-specific behavior.
  5. The service performs several kinds of mapping in addition to the use case itself.
  6. The service contains UI-specific decisions, such as display labels, formatting, or visibility rules.
  7. Tests for the service require many unrelated mocks.
  8. Adding a small feature usually means adding another condition to the same method.
  9. Developers are unsure whether new logic belongs in the application service or somewhere else.
  10. The service is the only place where the complete behavior can be understood.

The last point is especially important.

An application service should help make the use case understandable, but it should not be the only place where all rules are hidden. If every important decision is embedded inside one large method, the code becomes difficult to change safely.

A long method is not always a problem by itself. A method can be long because the use case has several steps. The more important question is whether the method contains several different kinds of responsibility.

Length is a symptom.

Mixed responsibility is the problem.

8. Improving application services gradually

Application services usually become overloaded gradually, so they should usually be improved gradually as well.

A large rewrite is rarely the safest first step. It can mix too many decisions together and make it difficult to verify that behavior has remained the same.

A safer approach is to identify one responsibility at a time.

Start by choosing one application service method that feels difficult to change. Then look for one decision that clearly has a better home.

For example:

  1. Move domain state rules into the domain object.
  2. Move retrieval rules into repository methods.
  3. Move variable decisions into policies.
  4. Move technical details behind abstractions.
  5. Move UI formatting back to the UI.
  6. Add tests around the behavior before and after the change.

The goal is not to make every service tiny. The goal is to make each service easier to understand.

After the change, the application service should read more like a use case and less like a collection of implementation details.

A useful test is to read the method from top to bottom and ask:

Does this describe what the application is doing, or does it explain too many details about how every part works?

If the method mostly describes the use case, the application service is probably doing its job.

If the method contains validation policy, business decisions, persistence mechanics, mapping rules, UI assumptions, and technical integration details, then the service is doing too much.

9. Conclusion

Application services are valuable because they give use cases a clear place in the system.

But that role becomes less useful when the application service starts to own every important decision. Instead of coordinating the system, it becomes the system.

A good application service should make the use case readable. It should show the flow of the operation and delegate specialized decisions to the parts of the system that own them.

That does not require a perfect architecture. It requires deliberate placement of responsibility.

When application services orchestrate rather than own everything, the system becomes easier to change, easier to test, and easier to explain.