Why DDD: domain services
The domain from the second chapter: the aggregate, the values, the facts and two ports. Here a rule joins it that belongs to no aggregate — along with the place where such a rule is supposed to live.
Step 1 of 6
The rule has nowhere to live
On the right is the tree, and
overduein it is still empty. Time to work out why.The late fee is calculated from the due date, the daily rate and the working calendar: weekends do not count, holidays do not either. That is a domain rule — it is discussed with accounting, not with developers.
It cannot go into the aggregate: the invoice would start knowing about calendars and rates, and neither belongs to it — the rate is changed by a directive, the calendar comes from a reference table. It cannot go into the use case either: the rule would leave the domain for application, and the next caller — from another module — would calculate it differently.
So the rule needs a place of its own inside the domain.
Step 2 of 6
The domain service
A domain service is a rule that belongs in no aggregate. Here it is a function: invoice, time, rate, calendar → an amount.
The invoice arrives as an aggregate rather than a handful of fields, and that is not a detail of the signature. The rule asks the invoice for what it needs —
overdue.DueOn()— so the caller does not have to know what the amount is computed from. Pass a date instead of the invoice, and the rule becomes a calculator while the knowledge "the late fee is counted from the due date" moves out to whoever called it.Why not a method on the invoice, then? A fair question: there is one aggregate here, and
ChargePenaltyalready exists. But the method would have to take the rate and the calendar — things the invoice does not have and should not know about: the rate comes from a tariff, the calendar from another service with its holidays and shifted days. The invoice would start depending on both of them to compute a single number.There are exactly two reasons for a service, and the second is the more obvious one: a rule about several aggregates, where a method will not do at all — none of them may change the others. This is the first case: a rule about one invoice, but from data the invoice does not hold.
It does not leave the domain, though: the late fee is a rule about the invoice — just not about what the invoice knows on its own. That is why it sits inside the invoice domain rather than beside all the domains:
domains/invoice/services/penalty. A package of its own rather than a file next to the aggregate — the rule has its own inputs and its own set of calendar implementations, and putting it into the shared domain package would mix "what an invoice is" with "how a late fee is computed". It is named after the package:penalty.Amount(...).There is deliberately no
services/at the root of the domains. A rule is either about one model — and then it lives in it — or about several domains at once, and then it is not a domain service but coordination: its place is application, which already holds the order of the operation and the transaction. A shared folder for "rules in general" ends up a dumping ground with no domain visible in it.What the service does not have:
context, a repository, infrastructure errors. That is why it can be called in one line and answer without starting a database or a bus.Calendarstayed in the domain, incalendar.go: the late fee is not its only user — a chapter later the overdue rule will rely on it too. It is an interface but not a port: it has neitherctxnor an error, and it speaks about the domain, not about storage. The test is simple: a port describes where to get data from, while this is part of the model that may have several implementations, because a business has several calendars.Step 3 of 6
The service calculates, the aggregate applies
The service calculated the amount — from the very invoice it was handed — but only the invoice itself can put it on the invoice. So
ChargePenaltyis a method of the aggregate, and it keeps its own invariants: a paid invoice takes no fee, a zero fee changes nothing.Hence the line between them: the service reads the aggregate and cannot change it — it has neither access to the fields nor the right to record a fact.
The invoice line and the total change here, in one operation, and the fact —
PenaltyCharged— is recorded here too. The service publishes no facts and changes no state: it calculates.That is the division of labour inside the domain: the rule in the service, the state in the aggregate. Swap them and either the aggregate grows reference tables, or the service starts writing into fields that are not its own.
Step 4 of 6
Who calls it
The first of the empty modules is filled:
applications/overdue— a scheduled use case. It loads the invoice, calls the service, applies the result through the method, saves and publishes the facts.Compare it with the service next to it. The use case has
ctx, two ports and the order of the operation — and not a single rule. The service has a rule — and not a single port. The test that keeps them apart: oncectxor an interface to storage appears, it is not a domain service any more but application.The use case does not touch the due date at all: it hands the rule the invoice, and the rule takes the date itself. The invoice knows the date because the invoice set it itself; the rate and the calendar are not kept on the invoice — the use case brings those. That is the difference between a field of the aggregate and an input of the rule.
Step 5 of 6
When a service is not needed
A domain service is the most abused tool in DDD, so here is the other side of it, honestly.
- A rule about one aggregate, from its own data only, is a method of the aggregate.
MarkPaidmust not be a service: it changes the state of one invoice and needs nothing from outside to do it. - A rule about one value is a method of a value object. Adding money is the job of
Money, not of a service next to it. - A rule that needs a repository or a bus is not a domain service. It is a use case, and it lives in
application.
If services appear by default for everything, you get an anemic domain: structs with fields, procedures around them and DDD words in the directory names. That differs from the original "everything in one file" only by the number of files.
- A rule about one aggregate, from its own data only, is a method of the aggregate.
Step 6 of 6
What we ended up with
Click a file to read it.
domains/invoice/services/penalty/penalty.go— the rule: due date, rate, calendar → an amount.domains/invoice/calendar.go— the working calendar: part of the model. No ports, noctx.domains/invoice/charged.go— the aggregate method: applies the amount, keeps the invariants, records the fact.applications/overdue/charge.go— the scheduled use case: load, calculate, apply, save, tell.
The domain grew by three files and still knows nothing about a database or a schedule. Of the three modules only one has code so far:
issuingandpaymentare waiting for the infrastructure chapter.
Use ← and → to move between steps.