Where Does Application Logic Go?
Application Services Hold No Rules
Last week we moved the verbs back into the entities. Order.cancel(). Account.withdraw(). The rules came home to the data they manage.
We left a thread hanging. A transfer between two accounts wasn’t a good fit for either Account alone, so we said “domain service” and moved on. A footnote promised the question would get its own post.
So here we are. Where does cross-aggregate behavior actually live, and what stops the so-called “domain service” from quietly turning into the anemic-model trap we just escaped, but one layer higher?
By the end we’ll have one rule that tells domain services and application services apart, and a smell test we can run against any service class in our codebase.
The Layering Question We Deferred
Most production codebases collapse two distinct responsibilities into one class with Service in its name.
One responsibility is a business rule that spans entities. A transfer can’t leave either account negative. An order needs inventory reserved before it ships. The rule mentions two (or more) aggregates, so it doesn’t live comfortably in either one alone.
The other responsibility is orchestration. Opening a transaction. Loading the aggregates from their repositories. Passing them to whatever decides the outcome. Saving the results. Publishing an event when it’s done.
These two seem like one job until we start splitting the lines. Last week’s TransferService shows the problem: every line of transfer(fromId, toId, amount) is either a rule or plumbing, and nothing in the method tells us where the boundary is.
That ambiguity is the layering trap. When “domain service” becomes the place where we put anything that doesn’t fit inside an entity, we’ve re-invented the anemic model one layer up. The rules are still floating loose. They’ve just moved from OrderService to TransferService and changed jackets.
Enough theory. Let’s see some code.
When the Service Class Hides a Rule
public class TransferService {
private static final BigDecimal FEE_RATE = BigDecimal.ONE;
private final AccountRepository accounts;
private final EventPublisher events;
public void transfer(AccountId fromId, AccountId toId, Money amount) {
Account from = accounts.findById(fromId);
Account to = accounts.findById(toId);
Money fee = amount.percent(FEE_RATE);
Money totalDeduction = amount.plus(fee);
if (from.balance().isLessThan(totalDeduction)) {
throw new InsufficientFundsException();
}
from.debit(totalDeduction);
to.credit(amount);
accounts.save(from);
accounts.save(to);
events.publish(new TransferCompleted(fromId, toId, amount, fee));
}
}The first instinct: this looks fine. It’s a service method, it does the transfer.
After reading it again, we see two categories of code.
The first kind is business rules. The overdraft check. The fee calculation. The debit-by-amount-plus-fee. These lines would still mean the same thing if we switched databases, switched transports, or ran the entire transfer in-memory for a test.
The second kind is orchestration. Loading both accounts. Saving them. Publishing the event. These lines only exist because there’s a database on one side and a message bus on the other. If we pull the infrastructure out, they have nothing to do.
The diagnosis: when rule and orchestration share a method, anyone who writes a second orchestration path can bypass the rule. A batch import. A retry handler. An admin script. Each one carries its own copy of “check overdraft, compute fee, debit-plus-fee,” and the first one that gets it wrong is the one nobody notices.
It’s the same failure mode as the anemic setBalance() from last week, just one layer up.
Two Services, Two Jobs
Split the code by what each line is doing, and two classes fall out:
public class TransferPolicy {
private static final BigDecimal FEE_RATE = BigDecimal.ONE;
public TransferResult apply(Account from, Account to, Money amount) {
Money fee = amount.percent(FEE_RATE);
Money totalDeduction = amount.plus(fee);
if (from.balance().isLessThan(totalDeduction)) {
throw new InsufficientFundsException();
}
from.debit(totalDeduction);
to.credit(amount);
return new TransferResult(amount, fee);
}
}
public class Transfer {
private final AccountRepository accounts;
private final TransferPolicy policy;
private final EventPublisher events;
public TransferReceipt handle(TransferCommand command) {
Account from = accounts.findById(command.fromId());
Account to = accounts.findById(command.toId());
TransferResult result = policy.apply(from, to, command.amount());
accounts.save(from);
accounts.save(to);
events.publish(new TransferCompleted(command.fromId(), command.toId(), result));
return TransferReceipt.from(result);
}
}TransferPolicy has no repositories, no events, no transactions. Every line is a rule. The class doesn’t know a database exists. It survives every infrastructure swap we can name. The decisions show up as calls on the aggregates (from.debit, to.credit): the policy picks the outcome and the entities implement it. Persistence stays one layer out.
Transfer has no ifs and no calculations. Every line is a step in a sequence. The class doesn’t know what a transfer means. It knows the order to call things in and the moment to commit.
Domain services are decisions1. Application services are sequences2.
Application services hold no rules. If we see a conditional or a calculation in one, it belongs one layer down.
We call this the no-rules line.
The rule survives the obvious objection. Application services do hold conditionals: authorization, idempotency, input validation, and feature-flag routing. Those are about who can run a use case and whether to run it at all, not about what the business outcome is. The no-rules line bans business rules. Cross-cutting checks at the orchestration boundary stay on the orchestration side.
Why the Split Pays Off
The migration consists of two cuts:
Cut one. Every rule moves out of the use case: the overdraft check, the fee calculation, the debit-by-amount-plus-fee.
Cut two. Every plumbing call moves out of the policy: repository loads, saves, event publishing, transaction boundary.
Separating the rule from the orchestration buys three things at once. The same rule can run under a different orchestration (a batch import, a retry handler, a CLI script) without being re-implemented. It can be tested in isolation with in-memory
Accountobjects, no database, no message bus, no test doubles. And it carries zero infrastructure dependencies, so the storage or transport can change without dragging the rule along.
TransferPolicy runs in a unit test with two in-memory Account objects and no test doubles. The test reads like the rule itself. Transfer is verified by walking the call sequence with mocked collaborators. We’re checking the choreography, not the rules, because the rules aren’t in there.
Naming the Two Layers
Naming is the second half of the work. The Service suffix hides the intent3. Use it for both responsibilities, and the class can’t tell us from the outside which one it is.
For domain services, a few names with a job suffix: XPolicy, XCalculator, XValidator, XSpecification. The name TransferPolicy is telling us what’s inside before we open the file.
For application services, names should come from the use case itself: XUseCase, XCommand, or bare domain verbs (OpenAccount, ApproveLoan, Transfer). The class is named after what the user is doing.
These aren’t style preferences. Policy, Calculator, and UseCase are domain words and use-case words. Service is a technical suffix borrowed from framework conventions. The ubiquitous language belongs in the code, not just in conversations with domain experts. When the class is named after the business concept it implements, the next reader (or the LLM working alongside us) doesn’t have to translate between two vocabularies.
Why Hex and DDD Agree Here
Hexagonal architecture says4 dependencies point inward and the infrastructure implements ports. DDD says the domain layer is pure, the application layer coordinates, rules live in entities, values, and domain services.
These look like different views, until we draw the no-rules line. Once we draw that line, the hex application layer and the DDD application layer turn out to be the same set of classes, sitting between the adapters and the pure domain, doing nothing but use-case orchestration. The hex pure interior and the DDD domain layer collapse onto the same set of classes, too.
Hex and DDD aren’t competing recipes. They’re two views of the same dependency direction. The no-rules line is what makes them snap into alignment.
Without that line, we can pass the hex dependency check and still fail the DDD modeling check. The framework is out of the domain. The domain is still hollow because the rules are sitting in TransferService between the two. Exactly the trap that last week’s post opened with.
When We Don’t Need a Domain Service
Not every use case needs both layers. When a rule fits inside one aggregate, the domain service becomes overhead: the entity already owns the rule, so there is nothing left for a Policy class to hold.
Take a deposit. The rule lives in Account.deposit(amount). The application service has no decision to make. It loads the account, calls the method, and saves:
public class Deposit {
private final AccountRepository accounts;
public void handle(AccountId accountId, Money amount) {
Account account = accounts.findById(accountId);
account.deposit(amount);
accounts.save(account);
}
}No DepositPolicy, no if, no arithmetic. The application service is three lines of orchestration. Domain services exist for rules that span aggregates or do not belong to any aggregate. Single-aggregate rules belong to the entity itself, as last week’s rule5 states.
The split is justified by the rules, not by symmetry.
One anti-pattern worth naming: “Service per Action”. CreateAccountService, UpdateAccountService, DeleteAccountService. That’s not having application services. That’s procedural code wearing a noun.
The shape can fool you. Transfer, Deposit, OpenAccount are also one class per use case, and they’re not the anti-pattern. The difference is what’s inside. The endorsed handlers orchestrate around a rule-bearing aggregate. CreateAccountService and its “friends” CRUD a passive entity with the rules inlined.
The Takeaway
Application services hold no rules. The smell test: would this line still mean something if the infrastructure disappeared? If yes, it’s a rule. If no, it’s orchestration. Every other layering question gets easier once we draw that line.
TLDR
Domain services hold rules that span entities. Application services orchestrate use cases. Same word, different jobs.
Separating the rule from the orchestration buys three things: reuse across orchestrations, isolation in tests, and zero infrastructure dependencies.
Two shapes: domain services are
ifs and arithmetic; application services are loads, calls, saves, publishes. Anything that doesn’t fit this rule is a layering bug.“Service” alone is the name that hides the layering. Use
XPolicy,XUseCase, or domain verbs instead.Hex and DDD agree on this once we apply the “no rules” test. The two diagrams collapse into the same dependency direction.
Not every use case needs both layers. Justify the split with rules, not symmetry.
Try This Week
Pick one XService class in your codebase. Walk through its methods. For each line, ask: would this still mean something if the database disappeared? The lines that survive are rules and belong in the domain. The lines that don’t are orchestration and belong in the application service. If a single method has both, that’s a candidate to refactor first.
Drop the name of a service in your codebase that’s secretly carrying a rule in the comments. Bonus points if you can quote the line that gives it away.
Media attributions:
Cover image by the author (generated with Gemini)
Eric Evans, Domain-Driven Design (2003), Chapter 5 on services. Evans’ frame: “When a significant process or transformation in the domain is not a natural responsibility of an entity or a value object, add an operation to the model as a standalone interface declared as a service.” Evans calls these significant operations on the model. This post compresses them to “decisions” to contrast with the sequence shape of application services.
Vaughn Vernon, Implementing Domain-Driven Design (2013), Chapter 14 on application services. Vernon's framing: "Keep Application Services thin, using them only to coordinate tasks on the model." Any business logic that creeps in is a smell.
I’ve used XService throughout this post and last week’s to stay close to the naming most Java teams will recognize from Spring or Spring-influenced codebases. The argument for moving past it is the rest of this section. A longer treatment of the ubiquitous-language thread is forthcoming in Why Your Service Class Knows Too Much.
Alistair Cockburn, Hexagonal Architecture (2005). The original framing: “[...] the application can be equally driven by an automated, system-level regression test suite, by a human user, by a remote http application, or by another local application. On the data side, the application can be configured to run decoupled from external databases using an in-memory oracle, or mock, database replacement; or it can run against the test- or run-time database.”
The opposite pressure (a single aggregate growing heavy enough that all its rules no longer fit comfortably inside one class) is its own question. The fix isn’t “promote everything to a domain service”. It’s about decomposing the aggregate’s responsibilities (decorator, strategy, composition) while keeping the rules on the entity that owns the state. Forthcoming: When the Aggregate Gets Heavy.

