Why DDD: domain

The problemThe domain has been named in words, but in code it is still a struct with exported fields and `float64` for money. The domain rules live in the callers: each holds its own half, and one day one of them goes stale.

The first chapter ended with a tree of three empty directories: the boundaries are drawn, the glossary sits at the root of the context, the domains are named.

What follows is what lives inside the domain. In Go, file by file.

  1. Step 1 of 11

    The domain in plain types

    Everything below goes through issuing: it creates the invoice, so it has to name everything an invoice is made of.

    On the right is the invoice as an anemic model — the way a domain most often gets written: a struct of strings, numbers and time. It reads at a glance, nothing extra.

    It sits in domains/invoice/, next to the rest of the domain code. The modules — issuing, payment, overdue — use it but do not own it: each needs the same invoice, and none of them can have a copy of its own.

    And it is already the domain: the invoice has a number, a customer, lines, a total, a due date and a state — the same words as in the glossary. The main thing here is done right.

    The price this code pays for that comes next.

    Step 2 of 11

    What is wrong with it

    This code holds not a single rule of the domain. The callers hold them, each on their own.

    • The total comes in as a parameter. Issue does not compute it from the lines, it accepts it. So an invoice whose total is not the sum of its lines can exist, and it will quietly reach the database.
    • float64 for money. 0.1 + 0.2 is not 0.3, and an invoice has to add up to the cent. And nothing stops you from adding a price to a quantity: to the compiler they are both just numbers.
    • State is a string. "issued", "issued ", "pain" — the typo compiles and ships.
    • The fields are exported. Any code outside changes a line and the total without asking the invoice.

    The usual answer to this is validation at the entry point. But there is one validator and the fields get changed from anywhere: one place that forgot about it is enough. The rule belongs on the data itself.

    Step 3 of 11

    Value object: money

    Money is a value object. It has no identity: two amounts of the same value are the same money, and they are compared by value, not by reference.

    It lives in vo inside the domain: a value is part of the model, and the domain is what comes up with it. The package itself knows neither the aggregate nor the modules — the dependency goes one way only, towards it.

    Inside it are whole cents. No fractional units get into the type: money is built from an int64 only, and 0.1 + 0.2 never shows up in a bank statement again.

    Money can only be added through Add, so the rule holds everywhere money is added — not only where someone remembered to check. And a price can no longer be added to a quantity: different types, the compiler says no.

    Step 4 of 11

    Back to the invoice

    The same file as three steps ago — and vo.Money is in it instead of float64. The price and the total are money now, and there is no way to compute them with anything else.

    The other values follow the same pattern: Number, CustomerID and Status are types now, not string. They are declared in their own files of the package and simply used here — the number can no longer be swapped with the customer in a call, and the typo "pain" does not compile.

    A due date, an address, a tax id are done the same way — anything that has its own rules and no identity.

    Step 5 of 11

    Aggregate: the invoice

    The second thing that changed in the file: the fields are closed. The outside world holds the invoice whole — a line cannot be changed behind its back. An object like that is called an aggregate.

    Issue is the only way an invoice comes into being, so every invariant it promises is checked there. There cannot be zero lines. The total is not passed in as a parameter — it is computed from the lines: pass it from outside, and the first call with a wrong number stores an invoice the domain does not allow.

    The lines are copied, not referenced. Otherwise the caller keeps the slice and changes it later — and the invoice becomes a different invoice without knowing.

    Status is changed by methods, not by assignment from outside. Which ones is for the next steps: for now Issue is all the invoice has.

    Step 6 of 11

    Changing state

    The invoice can do more than come into being. Payment is a state transition, and a method makes it — not an assignment from outside.

    It sits in a file of its own: one file per method. The directory then shows what the invoice can do without opening the struct.

    The check inside the method is an invariant, not input validation: a paid invoice cannot be paid twice, and the outside learns about it only from the error the invoice itself returns.

    What the file does not have: a write to storage, an event being sent, a transaction. The method changes state and that is all. Who saves it and who tells the outside is decided by the levels above.

    Step 7 of 11

    The port: repository

    The invoice has to be kept somewhere. The domain declares the interface itself — it decides what it needs: take an invoice by its number and save it whole.

    There is no implementation here, and context is the only thing this file knows about the outside world. No SQL, no driver, no table name.

    That is the dependency inversion: the infrastructure knows about the aggregate, the aggregate knows nothing about the infrastructure. Postgres can be swapped for anything without opening a single domain file.

    The invoice is saved whole, not line by line: the aggregate is the transaction boundary, and it has no halves.

    Step 8 of 11

    Domain events

    The invoice has been issued — someone else has to learn about it: delivery, accounting, notifications. Who exactly, the domain neither knows nor cares.

    So it calls no one and names a fact instead: what happened, in the past tense. Issued — the invoice has been issued, Paid — it has been paid. Inside is only what describes the fact: no reference to the aggregate, no behaviour.

    The one thing a fact must be able to do is name itself. FactName does that: subscribers see the message under this name, so the name is part of the contract, not an implementation detail. The Event interface is made of exactly that.

    The facts live in a package of their own — domains/invoice/events. The aggregate is not their only reader: infrastructure publishes them and an adapter translates them outward, and a separate package makes that dependency visible. That is why the fields are plain strings rather than the invoice's own types: the facts package knows nothing about the aggregate, so the two never reference each other in a circle.

    The past tense is not a style but a check. If it cannot be named that way, it is not a fact but a request — and it should have arrived as a command from the other side.

    Step 9 of 11

    The aggregate records facts

    The aggregate records the fact itself, right where it changes state. It cannot send it: it has neither a bus nor a transaction, and it has no business knowing about them.

    So the facts pile up inside and are handed out as a list — Events(). Taking them and sending them is up to the level above.

    The point is that the state and the fact about it change in one place and in one operation. Send the event from inside the method, and one day you get an event about something that never made it to storage.

    Step 10 of 11

    The second port: publisher

    Someone has to send the facts. Here too the domain declares a port instead of taking a ready-made bus: who gets them and over what is the infrastructure's business.

    One method, taking a list: the facts leave in the same batch the aggregate recorded them in. Sending half of them would be a lie about the operation.

  2. Step 11 of 11

    What ended up in the domain

    The whole domain is six files. The tree and the contents are side by side now: click any file and see what is in it.

    • invoice.go — the aggregate: fields closed, Issue builds the invoice and sums up the lines.
    • paid.go — the state change, one file per method.
    • events/event.go — the facts and their names.
    • repository.go — the storage port.
    • publisher.go — the publishing port.
    • vo/money.go — money: whole cents and its own arithmetic.

    None of them knows about a database, about HTTP or about a bus. From the outside there is only context and two interfaces the domain declared itself.

    That is why the next part changes nothing in the domain: it writes what implements those interfaces and what calls them.

Use ← and → to move between steps.