Why aggregate versioning

The problemTwo calls read the same invoice, each applied its own rule, both saved. What stayed in the database is whatever was written second — the first change is gone, and neither transaction noticed: both `UPDATE`s succeeded.

The same service as in the DDD chapters: the invoice, its modules, ports and adapters. The domain barely changes here — the invoice gains one field, and with it an answer to the question of who wins when two calls arrive at once.

  1. Step 1 of 8

    Two calls, one invoice

    On the right is the payment use case as the infrastructure chapter left it: a unit of work, a repository, published facts.

    While it is called one at a time, it is correct. Now two calls at once: one takes a payment, the other charges a late fee — the overdue scheduler woke up a second earlier.

    Both loaded the invoice in the same state. Both applied their rule to their own copy in memory. Both saved. What stayed in the database is whatever was written second: the late fee wiped the payment, or the payment wiped the late fee — as luck would have it.

    This is a lost update, and a transaction does not cure it. The transactions did not get in each other's way: each did exactly what it was asked to do, and both committed. Nothing failed, the logs are clean.

    Step 2 of 8

    Why not lock the row

    The first idea is to add FOR UPDATE to the SELECT and make the second call wait for the first. That is pessimistic locking, and it does work.

    Here is what it costs. The row stays locked until the transaction ends: while the use case computes a late fee or calls the payment gateway, everyone else on that invoice waits in line. Two use cases that take rows in a different order give you a deadlock.

    And the main thing: you can only lock what happens inside one transaction. A person opened the invoice, changed the due date and pressed save a minute later — you cannot hold a transaction for a minute, and at the moment of their edit there is nothing to lock.

    So the answer is not to forbid the state from changing, but to notice that it did.

    Step 3 of 8

    The invoice gets a version

    A version is the number of the state that was read. When we write, we tell the database which number we saw; if the number there is different, someone got in between our read and our write.

    The field belongs to the aggregate rather than to a table on its own: the aggregate is the consistency boundary, so it has one counter for everything inside it. Change a line of the invoice, and the invoice's version goes up.

    The version adds no domain rule: Issue creates the invoice with zero, Version() only returns it, and there is no SetVersion — the number is raised by whoever writes. load.go changed too: the invoice comes back from the database with the number that was there.

    Step 4 of 8

    The conflict is part of the port's contract

    ErrConflict is declared next to the port, and that matters: the use case has to tell "it did not save because the invoice was changed" from "the database is down". The first is an ordinary turn of events, the second is an outage.

    The domain learned nothing about SQL or RowsAffected along the way. It said exactly the part that belongs to the domain: the invoice may have changed since it was read, and then the write does not happen.

    Checking the version by hand — comparing it in the use case before Save — buys nothing: the same gap of time passes between the comparison and the write. The check only has force where the write happens.

    Step 5 of 8

    The check inside the write

    UPDATE invoices SET ..., version = version + 1 WHERE number = $1 AND version = $2 — that is all of optimistic locking. The check and the write are one statement, so nothing can slip in between them.

    The interesting line is the next one: RowsAffected() == 0. Zero updated rows means there is no invoice with that version. For an invoice we have just read, that means one thing: the version is already different — so ErrConflict.

    Version zero means the invoice is new and is inserted. A second creation with the same number is caught by the unique index; no version is needed for that.

    The read now pulls the version from the same row as the rest of the fields: it is part of the state that was read, not a separate query.

    Step 6 of 8

    A retry instead of an error

    A conflict says: the data you decided on is stale. So the right answer is to decide again on fresh data, not to hand an error back to the caller.

    That is why the whole attempt, the read included, is repeated. Repeating only the write would write the very same stale data we were trying to leave behind.

    The retry is safe precisely because the whole attempt sits inside one unit of work. If the save failed, what went into the outbox rolled back too, so subscribers never heard about the cancelled attempt. Without that, every attempt would send the fact again and idempotency would become someone else's problem.

    Three attempts, not an endless loop: a conflict arriving for the third time is no longer a race between two calls but load — and load is answered differently.

    Step 7 of 8

    When a retry is the wrong answer

    A retry fits when the code decides: MarkPaid will look at the fresh invoice again and itself say that a paid invoice is not paid twice.

    It does not fit when a person decided, looking at the old state. Then the version travels out with the data — a field in the projection or an ETag header — and comes back with the request to change it. If it does not match, the person is told "the invoice has changed, take another look". That is the right answer, not an error.

    The difference is not in the mechanics but in who decided. The mechanics are the same: the number of the state that was read, checked at the moment of the write.

    And the boundary: a version detects a conflict on one aggregate. A rule that ties two of them together cannot be held this way — there it is either one aggregate, or consistency stretched over time.

  2. Step 8 of 8

    What we ended up with

    Click a file to read it.

    • domains/invoice/invoice.go — the version field and Version().
    • domains/invoice/load.go — the version arrives from the database with the data.
    • domains/invoice/repository.goErrConflict next to the port.
    • infrastructure/postgres/invoices.go — the version check and RowsAffected.
    • applications/payment/pay.go — the whole attempt retried.

    The domain grew by one field and one error. Everything else is a write that checks, and a use case that knows how to start over.

Use ← and → to move between steps.