The problemThe use case, the storage and the incoming call spread across layers, and what each of them knows about the domain gets discovered in production: where the transaction started, who sent the fact, and why a subscriber learned about something that is not in the database.
The domain is described and knows nothing about the outside: it has an
aggregate, values, facts and two ports.
What follows is everything around it: the use cases, the port implementations,
the incoming call and the wiring of the application.
Step 1 of 10
Use case: issue an invoice
The issuing module finally has code. The use case takes three steps: build the aggregate, save it, publish the facts.
Not a single domain rule is here — they stayed in Issue. Here there is only order, and that is what application is: it hands out work and keeps the operation on course.
The use case takes what the domain needs as plain parameters. Whatever arrives over HTTP or from the bus does not reach this far: those are transport DTOs, and the transport translates them.
The dependencies are two ports and no implementations. That is why the use case has one reason to change — the order of the operation: swap the database or the bus, and it stays as it is.
Publishing comes after saving, not before: a subscriber should not learn about something that never reached storage. There is still no atomicity between those two steps — that is what a transactional outbox is for, and it is a separate conversation.
Step 2 of 10
Use case: take a payment
The second module, the same order, a different story: load the aggregate, call the method, save, publish.
The "already paid" check is not written here — it is in MarkPaid. The use case only passes the error up: were it checking on its own, the rule would live in two places, and one day one of them would fall behind.
It also shows why the repository hands out the whole aggregate: state is changed by its methods, and for that you need the aggregate itself, not a row from the database.
Step 3 of 10
The ports are there, the implementation is not
On the right is the storage port from the second chapter. The domain declared what it needs: take an invoice by number and save it whole. Who does that is not its business.
Time to do it. And the first question of the implementation is not about SQL: how do you return an aggregate whose fields are all private? Issue will not do — it creates a new invoice and records the fact "invoice issued", while we are loading one that already exists.
Step 4 of 10
How the aggregate comes back from storage
Load is the second way to come into being: not "create" but "bring back what was already there". It records no facts and checks no invariants: the data was already in storage, and checking it again means not trusting your own write.
It lives in the domain, because only the domain can rebuild its own aggregate. Handing that job to the infrastructure means opening the fields to it — and with them the ability to assemble an invoice the domain does not allow.
Step 5 of 10
The port implementation
Infrastructure: database/sql, two tables, queries. The domain knows nothing about this file — knowledge goes from the outside in.
Save writes the invoice and its lines with two queries. That already needs a transaction: anything can fail in between, and storage would keep an invoice with no lines — a state the domain does not allow.
The connection comes from a conn(ctx, db) function rather than a field. Why is the subject of the next steps.
Step 6 of 10
Saved and published
Back to the use case. It makes two calls: save and publish. Separately both are right; together they are not.
If Publish fails, the invoice is in storage and nobody knows about it: delivery does not start packing, accounting sees no charge. Swapping the calls is worse: Save fails after a successful Publish, and subscribers know about an invoice that does not exist.
I left a note about this back in the first step. Time to close it: there must be one operation, not two.
Step 7 of 10
Unit of work
A unit of work is a port that says one thing: do the work whole or do not do it at all. There is no Begin, no Commit and not even the word "transaction" in the domain — there is a function someone will run on their own responsibility.
That is why the port stands next to the others: the domain does not know there is a Postgres transaction behind it and would work over the same interface on anything else. The only thing it demands is that "whole" really means whole.
Step 8 of 10
One operation instead of two
The use case changed in one place: the save and the publish moved inside Do. The order of the steps is the same; the boundary changed — it is one operation now. Payment and overdue moved into Do the same way: the tree marks them.
The implementation puts the transaction into ctx, and the adapters take it from there with that same conn function. So the use case passes no transaction around and never sees one: what it needs is a boundary, not a connection.
It is easy to overdo this. A unit of work is not "a transaction per query" and not a way to change two aggregates at once: the aggregate is still the consistency boundary, while Do holds the boundary of the operation.
Step 9 of 10
A bus does not join a transaction
One lie is left. A bus is not a database: it cannot be rolled back with the transaction. Send a fact inside Do, and a failure at commit leaves subscribers with a fact about an invoice that does not exist.
So the publishing implementation sends nothing. It writes the fact as a row in the same transaction, next to the invoice: either both are there or neither is.
A separate process sends them — it reads the table, puts the facts on the bus and marks what it sent. It may send twice, so a subscriber has to be idempotent — but it cannot send what never happened.
Step 10 of 10
What we ended up with around the domain
The tree shows the whole layout: the domain in domains/invoice, the use cases in applications, one module per slice. Click a file to read it.
applications/issuing/issue.go — issue an invoice, all inside one unit of work.
applications/payment/pay.go — take a payment.
domains/invoice/load.go — how the aggregate comes back from storage.
domains/invoice/uow.go — the unit of work port.
infrastructure/postgres/invoices.go — the storage implementation.
infrastructure/postgres/uow.go — the transaction and conn(ctx, db).
infrastructure/postgres/outbox.go — facts as a row in the same transaction.
Over the whole chapter the domain grew by two files: load.go, which is about rebuilding its own aggregate, and uow.go, one more port. Everything else landed outside, behind the ports the domain declared itself.
What is still missing: the incoming call and the wiring of the application. Those come next.