Domain Services
For the operations that genuinely belong to no single entity or value object. A small, useful category — and a dumping ground the moment it stops being small.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
This behaviour is domain logic and does not fit on any one object. Where does it go, and how do I stop that place becoming everything's home?
Transferring funds between two accounts must debit one and credit the other atomically, and reject the transfer if it would take the source below its overdraft limit. It is domain logic and it is not about one account.
Put it on one of the entities. source.transferTo(target, amount) reads well and keeps behaviour with data, which is what everyone says to do.
The source account now knows about target accounts, exchange rates and audit records — three dependencies it acquired because a method was put in a convenient place rather than a correct one (Feature Envy).
- The source account now knows about target accounts, exchange rates and audit records — three dependencies it acquired because a method was put in a convenient place rather than a correct one (Feature Envy).
- The rule is symmetric and the method is not.
a.transferTo(b)andb.transferFrom(a)are the same operation, so either there are two implementations or the model has an arbitrary asymmetry it has to explain forever. - The next requirement — transfers between an account and an external IBAN, which is not an account entity at all — has nowhere to go, because the operation was defined as a method on the type it happens not to involve.
- Testing the source account now requires a target account, a rate provider and an audit sink, so the cheapest test in the model becomes one of the most expensive (Testing as Design Feedback).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- Both accounts may be in different currencies, so the transfer involves a rate that is fetched from an external provider.
- The team already has a convention of naming every application class
SomethingService, so the word carries no information. - Transfers must be auditable, and the audit record must include which rate was used.
- A transfer either debits and credits both accounts or does neither.
- The rule about overdraft limits is evaluated exactly once per transfer, against the state at the time of the transfer.
- A domain service holds no state of its own between calls.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The domain service owns the operation that spans several objects, and owns nothing else — no persistence, no transaction management, no transport.
- The entities keep owning their own invariants: the account still refuses a debit that breaks its overdraft rule, and the service does not reimplement that check.
- The application layer owns the transaction, the retries and the wiring; conflating it with the domain service is the most common way this idea decays (Effect Boundaries).
- The rate provider is an external dependency passed in, not reached for — which is what keeps the service testable (Volatile Dependencies).
- The line between domain service and application service is whether the logic would still exist if the system had no database and no HTTP. Overdraft rules would; transaction management would not.
- The service sits beside the entities, inside the model boundary, and depends only inward on them plus abstractions of what it needs from outside.
- It is not a layer. Introducing "the service layer" as a place for anything that is not an entity is how the category becomes meaningless (The Utility Dumping Ground).
What the service is responsible for, and what stays on the entity
The mistake that produces an anemic model is not creating a service. It is moving the entity's own rules into it. In a healthy split, the account still refuses an illegal debit; the service only sequences the two moves and applies the rule that neither account could know on its own.
One changesWhen entry is the sign this is the right shape. Compare it with the six-entry list on the accumulating Order in Entities: the difference is whether the unit has one reason to exist.
- — That a transfer is a debit and a credit of equal value
- — That cross-currency transfers need a rate and that the rate used must be recorded
- — Converts the amount if the currencies differ
- — Asks the source to debit and the target to credit
- — Produces the audit record describing what happened
- — Account (entity)
- — Money (value object)
- — RateProvider (a one-method interface)
- — The rules of what a transfer is change — a fee, a limit, a rounding rule
Correctly scoped. It does not know about databases, transactions or HTTP, so it can be tested with two in-memory accounts and a stubbed rate. Crucially it does not contain the overdraft rule — that stays on Account, where it also protects every other way an account can be debited.
Deciding where behaviour goes
Four places, in the order you should try them. The order matters: taking them out of order is how a model becomes anemic, because the service option is always available and never forces you to think about what the code is about.
The most useful question, which is not on the list, is "what would this be called if the business described it?" A transfer is a thing the business says out loud. An AccountService is not.
Whose rule is this, and what state does it need to evaluate?
when The rule is entirely about a value: rounding, allocation, validity, formatting of a domain quantity.
cost Almost none, and it is the cheapest thing to test. Try this first — a surprising amount of what looks like service logic is Money.allocate (Value Objects).
when The rule reads and changes one thing's state: an order refusing a line, an account refusing a debit.
cost The entity grows. Acceptable while the reasons to change stay coherent; a warning sign once they do not (Single Responsibility, Carefully).
when The rule spans state inside one consistency boundary.
cost The root grows and every operation loads the boundary (The Aggregate Root).
when The operation genuinely involves several aggregates, or none — a transfer, a route, a quotation.
cost Behaviour lives away from data. Keep it stateless, keep it named after the operation, and keep the entities' own rules on the entities.
when It is about transactions, retries, transport, authorization or orchestration rather than about the business rule.
cost It becomes untestable without infrastructure, which is fine for wiring and wrong for rules — so be certain the logic is genuinely not domain logic (Effect Boundaries).
The smell: a service that is a place rather than a thing
The category degrades in a specific and recognisable way. A service that started as one operation acquires unrelated ones, because it is named after a noun and every operation involving that noun looks like it belongs.
The diagnostic is not size. It is whether the operations share a reason to change; a five-hundred-line service implementing one complicated rule is healthier than a fifty-line one implementing four unrelated ones (Long Functions makes the same argument about functions).
looks like A class called AccountService with transfer, closeAccount, exportStatementPdf, recalculateInterest and syncWithFinanceSystem on it, taking six constructor dependencies, and imported by nine modules that each use one method.
suggests The class is named after a data type rather than an operation, so it attracts anything that mentions accounts. Its dependency list is the union of five unrelated needs, which means every test of any one operation drags in all of them, and every change to any of them risks all five.
fix Split by operation, not by size: FundsTransfer, AccountClosure, StatementExport. Each takes only what it needs, which usually reveals that two of them were application concerns and one belonged on the entity all along (Move Responsibility).
PricingService containing six methods that all implement one pricing policy is cohesive, and splitting it into six classes would scatter one rule across six files, which is strictly worse (Over-Decomposition). The test is the dependency list: if every method needs most of the dependencies, the grouping is real.How to build it
Most important first.
- First, try hard to put it on an object. Most behaviour that looks like it needs a service belongs on an entity or a value object, and reaching for a service too early produces an anemic model by accident (The Anemic Domain Model).
- Use a service when the operation is genuinely about several things, or about none — a transfer, a route calculation, a pricing decision spanning catalogue and customer.
- Name it for the operation, not the noun-plus-Service reflex:
FundsTransfer,RouteCalculator,PriceQuotation. If the only name you can find isAccountService, it is probably several operations that have not been separated (Naming). - Make it stateless and its inputs explicit. A domain service that takes everything it needs as parameters is a pure function with a name, and it is the easiest thing in the codebase to test (Purity and Testing).
- Pass volatile dependencies in as narrow interfaces — a
RateProviderwith one method, not the whole payments client (Interface Segregation, Critically). - Keep them few. A model with thirty domain services and five entities has put its behaviour in the wrong place, and the diagnosis is in the next lesson (The Anemic Domain Model).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: adding "transfers to external IBANs" means either a second method on the account entity with duplicated rules, or a new class that copies the transfer logic. Either way the rule about overdrafts now exists twice.
- After: the transfer service takes a debit source and a credit target behind small interfaces, so the new case is one implementation and no change to the rule.
- The next change that is cheap: adding a fee to transfers. It is one operation with one home, and the audit record is already produced there.
- The next change that is not: making transfers asynchronous. That moves the atomicity requirement out of a transaction and into a saga, and the domain service's "both or neither" guarantee stops being something a method can promise (Partial Failure).
- Behaviour lives away from data, which is exactly the thing object-oriented design usually warns against, and the warning is legitimate — every service is a small step toward an anemic model.
- A service adds a name and a file for something that could be a function, and in codebases where the class is only ever instantiated once that is ceremony.
- Narrow dependency interfaces mean more types and more wiring, which is real cost paid for testability (Wiring and the Composition Root).
What can go wrong
- The service becomes the place all logic goes because it is easier than deciding where behaviour belongs, and the entities end up as data holders (The Anemic Domain Model).
- The service acquires state — a cached rate, a partially built transfer — and becomes accidentally stateful, which makes it unsafe to share and produces bugs that only appear under concurrency (Hidden Global State).
- The service reaches out for its dependencies through a locator or a static, which makes it untestable and hides the coupling from every reader (Service Locator).
- The mitigation fails too: splitting one large service into ten small ones without changing where the logic lives gives ten files with the same problem and a longer import list (Over-Decomposition).
- The service depends on entities and value objects; they never depend on it. That direction is what keeps the entities testable in isolation (Dependency Direction).
- It depends on abstractions of anything external — rate provider, clock — supplied by the caller (Constructor Injection).
- The application layer depends on the service. Nothing in the domain depends on the application layer, which is the rule that makes the model reusable in a job, a CLI and an HTTP handler alike.
- "Anything with more than one entity is a domain service." Most multi-entity operations still have a natural owner. The test is whether one of the objects would be a strange place for the rule to live, not merely whether several objects are involved.
- "Domain service and application service are the same thing." They are not: one contains rules that would exist without a computer, the other contains transactions, retries and transport. Merging them puts business rules inside code that cannot be tested without infrastructure (Functional Core, Imperative Shell).
- "Services should be injected everywhere." A stateless domain service is a function. Passing it as a dependency is often useful for substitution in tests; making it mandatory for everything is DI ceremony with no benefit (Dependency Injection).
- "Every noun deserves a
SomethingService." That convention is how a codebase ends up withOrderService,OrderManagerandOrderHelperand no idea which one has the rules (The Common Module).
- feature-envy
- utility-dumping-ground
Testing it, and how it ages
- Test the service as a function: two account states in, two account states and an audit record out, with a stub rate provider. No database, no transaction.
- Test that the entity's own rule is still enforced by the entity — construct an account near its overdraft limit and assert it refuses the debit directly, so the rule is not silently living only in the service.
- Test the symmetric and degenerate cases explicitly: transfer to self, zero amount, same-currency versus cross-currency (Property-Based Testing is useful here for conservation of money).
- At the application boundary, one integration test that the whole transfer is atomic under a forced failure between debit and credit.
- A healthy domain service stays small and stable, because the operation it names does not change often even as its rules do.
- When a service grows past a handful of related operations, it is usually two services that share a noun, and splitting by operation rather than by entity is what works.
- The pressure that eventually forces change is distribution: when the two accounts live in different services, "both or neither" becomes a distributed-transaction problem and the domain service becomes an orchestrator with compensations (Saga Pattern in Architecture).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat some operations relate several things and belong to none of them is a property of domains, not of languages — the same operation in a functional codebase is simply a module-level function, which is the same design with less ceremony.
- PARADIGM-SPECIFICIn object-oriented codebases the domain service is a distinct concept because the default is that behaviour lives on objects. In functional codebases every operation is already a function over values, so "domain service" describes the normal case and the interesting question inverts: which functions should be grouped into a module with a shared invariant.
- CONTESTEDThe strongest opposing view: the whole category is a symptom of trying to force procedural logic into an object model, and a codebase would be clearer with plain functions in a well-named module and no notion of "service" at all. Advocates point out that the naming convention has done measurable harm — an ecosystem where every class ends in
Serviceconveys nothing — and that the useful part of the idea survives entirely without the label.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — a transfer that spans two services cannot be "both or neither" in one transaction, and the redesign into a saga with compensations is a system-level decision that changes what this service is allowed to promise.