The problemA domain fact went onto the bus as it was, with its own fields and types. A neighbouring service parsed it and built on it: renaming a field inside the domain now breaks somebody else's service, and we learn about it from their alerts.
The same service as in the DDD chapters: the invoice, its modules, ports and
adapters. Here the boundary of the context appears — the one beyond which other
people’s services begin. On that boundary a fact has a different name, a
different shape and different rules for changing it.
Step 1 of 9
The domain went onto the bus
On the right is the publishing from the infrastructure chapter. The fact goes into the outbox written the way the domain writes it: json.Marshal takes the whole Issued structure, and the message type is the internal name invoice.issued.
While we are the only readers, that is fair. But a neighbour read the message — delivery subscribed and started packing parcels. From that moment the internal structure of the domain became a contract: rename the Total field and you break somebody's parser; add a field for yourself and you have told the neighbours something they have no business knowing.
It works the other way round too. Delivery needs the customer's address, which the fact does not carry — and the request to add a field arrives in the domain.
A domain fact and a message sent outside are different things, and they change at different speeds. We edit the first whenever we like; the second only after agreeing with everyone who reads it.
Step 2 of 9
The integration event
An integration event is a separate type in a separate package, and the version sits right in its path: infrastructure/broker/contracts/v1.
It lives in the bus infrastructure rather than at the root of the service, and that is no small thing. The shape of a message belongs to the transport that carries it: on the bus it is JSON in an envelope with a partition key, over gRPC it would be protobuf with its own compatibility rules, over HTTP a response body and a status. A service often has several transports, each with a contract of its own; a shared contracts folder pretends there is only one.
The types inside are primitive: strings and int64. Neither vo.Money nor invoice.Number crosses the boundary — out there it is JSON, and the fields are named the way a foreign service will read them. This is a boundary DTO and nothing more.
The message name is a constant with the version at the end: billing.invoice.issued.v1. From it the reader can see where the message came from, what it is about and which schema it follows. The internal FactName() stayed internal: that one we rename freely, this one we do not.
The set of fields here is a decision, not a reflection of the fact. What the neighbours actually need goes into the contract: a field can be added later, but it cannot be taken away.
Step 3 of 9
The envelope
The data of the fact is one thing, the metadata of the message is another, which is why they sit apart: the body in Payload, and around it an identifier, a type, a time and a key.
ID is for the receiver: at-least-once delivery promises the message will arrive, not that it will arrive once. A repeat is recognised by that identifier — we will come back to this on the receiving side.
Key is about order. A broker keeps order inside a partition, not across the whole topic; a key equal to the invoice number puts every message about one invoice into one partition, so "issued" arrives before "paid". Between different invoices there is no order, and none is needed.
Type sits in the envelope rather than only in the body: a subscriber decides whether to parse the body before having parsed it.
Step 4 of 9
Who does the translating
The translation lives in the infrastructure: the domain knows nothing about the contract and should not. Contract takes a domain fact and returns a finished envelope.
The switch on the fact type looks dull — that is the point. It is explicit: for a new message to go out, somebody has to add it here. Until they do, the fact stays internal and publishing skips it — ErrInternalFact.
This is the part people forget. Publish everything indiscriminately, and every new domain fact becomes public on the day it was written, silently and without anyone deciding so.
Next to it is whoever calls Contract: broker.Publisher. It implements the invoice.Publisher port the domain declared back in the second chapter — translate the fact, serialise it and hand the row over for writing. It sends nothing itself: the bus is not part of the transaction, as the infrastructure chapter showed.
The row is written not by it but by Appender — a narrow one-method port declared right here, by the one who uses it. Behind it stands postgres.Outbox, and after this chapter it does exactly one thing: puts a row into a table. Neither the domain nor the contract is in its imports any more — it used to know both.
That straightens out the direction of the dependencies: the one that works with the bus knows the contract, and storage knows only rows. The assembly connects them: wire.Bind(new(broker.Appender), new(*postgres.Outbox)).
Step 5 of 9
They send to us as well
The other side of the boundary. The payment gateway publishes payments.payment.received.v1, and that message is what makes an invoice paid — so somebody has to take it and call the use case on the right.
There are two ways to do this badly.
The first: parse the foreign JSON inside the use case. Then application starts knowing the neighbour's format, and renaming a field in someone else's service travels all the way into our domain.
The second: call the use case as it is. The same message will arrive a second time — after a broker retry, after a consumer restart, after a partition moves. The second time MarkPaid returns ErrAlreadyPaid, the consumer treats that as a handling error and puts the message back on the queue. And round it goes, until someone looks at the logs.
Step 6 of 9
Receiving: translate, remember, forgive
The handler lives in the payment slice, next to its use case, and does three things.
It translates.PaymentReceived is our copy of a foreign schema, and it sits on the boundary. The neighbour changes their schema — we edit this file; the domain does not notice.
It remembers.Remember answers "first time" or "seen it", and the call sits inside the same unit of work as the use case. Otherwise a gap is left between the mark and the change, and that gap is exactly where a repeat lands. That is why the Do implementation gained one line: the transaction is already in ctx, so run the work inside it instead of starting a second one.
It forgives.ErrAlreadyPaid is not a handling error: the invoice is already in the state we wanted, so we acknowledge the message and move on. That is idempotency on the inbound side — two identical messages give one result.
Step 7 of 9
The memory of messages
The implementation is plain: a table with a unique identifier and INSERT ... ON CONFLICT DO NOTHING. A row was inserted — the message is new; zero rows — we have handled this message already.
The connection comes from the same conn(ctx, db) as in every other adapter, so the mark lands in the transaction of the operation. Either the invoice is paid and the message is marked, or neither.
One thing not to forget: the table grows. Identifiers older than the broker's retention are of no use — they are cleaned out by time, in partitions, like ordinary logs.
Step 8 of 9
How to change a contract
There is one rule, and it is not about code: outsiders read this schema, so change it in a way the reader will not notice.
Adding an optional field is fine. An old reader ignores it.
Renaming, removing, changing the meaning or the type is not. That is v2: a new message type living next to v1 until the last reader has moved.
Which means you have to know who reads it. Without that, v1 can never be switched off — and versions pile up instead of replacing each other.
And the line not to cross: an event says what happened, not what to do. The moment ChargePenalty appears in the contract it is no longer a fact but a command — and a neighbour starts driving our domain over the bus.
Step 9 of 9
What we ended up with
Click a file to read it.
infrastructure/broker/contracts/v1/events.go — what the neighbours see.
infrastructure/broker/contracts/v1/envelope.go — the envelope: id, type, time, key.
infrastructure/broker/outbound.go — the fact translated into a message.
infrastructure/broker/publisher.go — publishing: translates and hands the row over for writing.
infrastructure/postgres/outbox.go — one row into a table, and nothing else.
applications/payment/transport/consumer/received.go — receiving a foreign message.
infrastructure/postgres/inbox.go — the memory of what was handled.
cmd/api/wire.go — wiring: the publishing port now sits behind broker.Publisher.
The domain did not change by a single line: it still has its own facts and its own name for each of them. Everything new landed on the boundary — on both of its sides.