The problemThe rule "the invoice is overdue" lives in three places and is named in none: a `WHERE` clause in the scheduler, a field in the read projection and the report for accounting. The first change to the rule reaches only one of the three.
A rule that answers yes or no. In the previous chapter the domain learned to
calculate what belongs to no single aggregate; here it learns to ask about it.
Step 1 of 6
The predicate that is not in the code
On the right is the overdue use case from the previous chapter. It charges the fee — and nowhere does it ask whether the invoice is overdue: it assumes whoever called it has already worked that out.
And it is worked out in three places. The scheduler picks invoices with a WHERE clause. The read projection keeps an Overdue field. The report for accounting counts it its own way, because it knew about neither of the first two.
One rule, three records of it, and the word "overdue" is written in none of them. Add holidays to it and only the record someone remembered will change.
Step 2 of 6
The specification
A specification is a domain predicate extracted into an object. Here it has a name — Overdue — and it answers one question: is this invoice overdue?
Inside is what the rule is made of: the status of the invoice, the due date and the working calendar. No ctx, no repository — like a domain service, only the output is not an amount but yes or no.
The rule lives in domains/invoice/rules — a package of its own, next to services. The reason is the same as for the late fee: a rule about the invoice but not from its data — and a separate package keeps it from spreading across the domain as files named *_spec.go. You call it through the package: rules.NewOverdue(...).
What that already buys: the rule has become a separate thing you can call. To see how it behaves on holidays, one call is enough — no database and no scheduler are needed for it.
Step 3 of 6
The use case asks instead of assuming
The same use case file with one new check in it. Now it does not take the caller's word: it asks the specification and leaves without changes if the invoice is not overdue.
That costs one line and closes a whole class of mistakes: the scheduler picked an extra invoice, a retry arrived a day after the payment, someone triggered the wrong number by hand — in all three cases no fee is charged.
Note that the use case still holds no rule. It calls one: the rule is in the domain, the order of the operation in application.
Step 4 of 6
Combining — and when it is redundant
Specifications compose: AllOf takes any predicates and requires all of them. A Specification interface has appeared — and it is the whole contract: one method answering about one aggregate.
Next to it is a second predicate, OverBudget, about an invoice above a limit. Together with the first it gives "overdue and large" — the rule by which someone on duty calls the customer instead of waiting.
And right away, the flip side: combinators are not always needed. If two predicates always travel together, it is more honest to give them a third name — RequiresCall — than to assemble AllOf in four places and later work out which assembly was right. I deliberately did not add Or: it almost always means there are really two rules.
Step 5 of 6
What it costs
Now the price, honestly. A specification cannot select overdue invoices from the database: it works with a loaded aggregate, and nobody will load everything to filter it down to a hundred.
So the selecting will be done by a query anyway. There are two ways out of that.
The specification builds the query. The rule is written once, but the domain starts knowing about SQL: the object gets a method returning a clause and, with it, a dependency on how storage is arranged.
The query picks candidates, the specification decides. The clause is deliberately coarse — the due date and the status, no calendar and no holidays. It narrows the selection, and "is it overdue" is asked of the rule once the invoice is loaded.
The second way is usually cheaper, and here is why: the query and the rule have different jobs. The query is allowed to err in one direction only — to bring in extra invoices; the rule will weed them out afterwards. Nothing is duplicated, because the query is not trying to be the rule.
The price of the second way is the wasted loads: the coarser the clause, the more invoices arrive for nothing. That is a question of volume, and it is answered by narrowing the clause, not by moving the rule into SQL.
Step 6 of 6
What we ended up with
Click a file to read it.
domains/invoice/rules/overdue.go — the predicates: "overdue", "large" and their composition.
applications/overdue/charge.go — the use case that asks instead of assuming.
The rule is finally named and lives in the domain. Reading still answers its own question — "which invoices look overdue" — and that is fine: the projection narrows, the domain decides. That is exactly why no second rule appears here.