- Book: Hexagonal Architecture in Go
- Also by me: Pocket Guides for Developers — the 8-book series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Order o-42 says pending. Five minutes ago the warehouse service marked it shipped, and the API call that did it returned 200 OK. So did the one a support agent made a few milliseconds later to add a delivery note. Both handlers loaded the order, changed one field, and saved the whole row back. The note is there. The shipment is gone from the record, and nobody saw an error.
That is the lost-update bug. Nothing throws and nothing logs. The second save wrote a stale copy of the order over the first, and the database did exactly what it was told.
The fix is old and has a name. Martin Fowler's catalog calls it Optimistic Offline Lock: "Prevents conflicts between concurrent business transactions by detecting a conflict and rolling back the transaction." This post puts each piece of it where it belongs in a Go service built with ports and adapters, so the domain owns the rule and every adapter, real or fake, has to honor it.
The bug, in one UPDATE
Here is the save that causes it. It is the repository method most of us write on day one:
const naiveSave = `
UPDATE orders
SET status = $2, note = $3
WHERE id = $1`
Handler A reads {status: pending, note: ""}, sets the status to shipped, saves. Handler B read the same row before A saved, sets the note, and saves {status: pending, note: "leave at door"}. B's pending is the stale value it loaded, and it overwrites A's shipped.
Postgres is behaving as documented. Its default isolation level is Read Committed, and the Postgres docs spell out what a second updater does when it hits a row another transaction just changed: it waits for that transaction to commit, then "the search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition." WHERE id = $1 still matches, so B's write lands. That re-evaluation is also the hook for the fix: add a condition that stops matching once someone else has written.

Both writers read the same row. The second save quietly undoes the first.
Put the version on the aggregate, and the error in the domain
The version is part of the order's state, so it lives on the domain type. The conflict is a business fact ("someone changed this order while you were working on it"), so the error lives in the domain package too. No adapter types leak in.
package order
import (
"context"
"errors"
)
var (
ErrNotFound = errors.New("order: not found")
ErrConcurrentUpdate = errors.New("order: concurrent update")
)
type Order struct {
ID string
Status string
Note string
Version int64
}
// Repository is the port.
//
// Save succeeds only if the stored version equals o.Version.
// On success the stored version becomes o.Version + 1.
// If another write got there first it returns
// ErrConcurrentUpdate. If the order does not exist it
// returns ErrNotFound.
type Repository interface {
Get(ctx context.Context, id string) (Order, error)
Save(ctx context.Context, o Order) error
}
The doc comment is the contract. Every adapter you write will be tested against those four rules, which is the whole point of having a port.
The pgx adapter: zero rows affected means you lost
The table needs one extra column:
CREATE TABLE orders (
id text PRIMARY KEY,
status text NOT NULL,
note text NOT NULL DEFAULT '',
version bigint NOT NULL DEFAULT 1
);
The adapter uses pgx v5. Pool.Exec returns a pgconn.CommandTag, and CommandTag.RowsAffected() tells you how many rows the UPDATE touched.
package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"example.com/shop/order"
)
type OrderRepo struct{ pool *pgxpool.Pool }
func NewOrderRepo(p *pgxpool.Pool) *OrderRepo {
return &OrderRepo{pool: p}
}
func (r *OrderRepo) Get(
ctx context.Context, id string,
) (order.Order, error) {
var o order.Order
err := r.pool.QueryRow(ctx, `
SELECT id, status, note, version
FROM orders WHERE id = $1`, id,
).Scan(&o.ID, &o.Status, &o.Note, &o.Version)
if errors.Is(err, pgx.ErrNoRows) {
return order.Order{}, order.ErrNotFound
}
if err != nil {
return order.Order{}, fmt.Errorf("get %s: %w", id, err)
}
return o, nil
}
func (r *OrderRepo) Save(
ctx context.Context, o order.Order,
) error {
tag, err := r.pool.Exec(ctx, `
UPDATE orders
SET status = $3, note = $4,
version = version + 1
WHERE id = $1 AND version = $2`,
o.ID, o.Version, o.Status, o.Note)
if err != nil {
return fmt.Errorf("save %s: %w", o.ID, err)
}
if tag.RowsAffected() == 1 {
return nil
}
return r.missOrConflict(ctx, o.ID)
}
func (r *OrderRepo) missOrConflict(
ctx context.Context, id string,
) error {
var exists bool
err := r.pool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM orders WHERE id = $1)`,
id,
).Scan(&exists)
if err != nil {
return fmt.Errorf("check %s: %w", id, err)
}
if !exists {
return order.ErrNotFound
}
return order.ErrConcurrentUpdate
}
Walk the race again with this UPDATE. A and B both loaded version 1. A's update commits and the row is now version 2. B's update waits on A's row lock, then Postgres re-evaluates WHERE id = $1 AND version = 1 against the new row. It no longer matches, so B updates zero rows and gets ErrConcurrentUpdate instead of a silent overwrite.
The missOrConflict lookup is there because zero rows has two causes, and the port promised to tell them apart. It only runs on the unhappy path, so the common case stays a single round trip. If a deleted order and a conflict mean the same thing to your callers, you can drop it, but then change the port's doc comment too.

The version in the WHERE clause is the gate. A stale copy carries the wrong number and updates nothing.
The in-memory fake honors the same contract
Your use-case tests should not need a database. They also should not run against a fake that is more forgiving than production, because then the tests pass on code that loses updates. The fake implements the same four rules:
package memory
import (
"context"
"sync"
"example.com/shop/order"
)
type OrderRepo struct {
mu sync.Mutex
orders map[string]order.Order
}
func NewOrderRepo(seed ...order.Order) *OrderRepo {
m := make(map[string]order.Order, len(seed))
for _, o := range seed {
m[o.ID] = o
}
return &OrderRepo{orders: m}
}
func (r *OrderRepo) Get(
_ context.Context, id string,
) (order.Order, error) {
r.mu.Lock()
defer r.mu.Unlock()
o, ok := r.orders[id]
if !ok {
return order.Order{}, order.ErrNotFound
}
return o, nil
}
func (r *OrderRepo) Save(
_ context.Context, o order.Order,
) error {
r.mu.Lock()
defer r.mu.Unlock()
cur, ok := r.orders[o.ID]
if !ok {
return order.ErrNotFound
}
if cur.Version != o.Version {
return order.ErrConcurrentUpdate
}
o.Version++
r.orders[o.ID] = o
return nil
}
The check and the write happen under one lock, which is the in-memory equivalent of the conditional UPDATE. A fake that compared versions, released the lock, and then wrote would have the same race you are trying to test for.
A use case that retries, a bounded number of times
A conflict is not always a failure. In the opening story, the note and the status don't contradict each other. If B reloads the order and applies its change to the fresh copy, both edits survive. The use case does that, and gives up after a few attempts so a hot row can't spin a request forever:
package app
import (
"context"
"errors"
"example.com/shop/order"
)
const maxAttempts = 3
type Orders struct{ repo order.Repository }
func NewOrders(r order.Repository) *Orders {
return &Orders{repo: r}
}
func (s *Orders) Update(
ctx context.Context,
id string,
change func(*order.Order) error,
) error {
var err error
for range maxAttempts {
var o order.Order
o, err = s.repo.Get(ctx, id)
if err != nil {
return err
}
if err = change(&o); err != nil {
return err
}
err = s.repo.Save(ctx, o)
if !errors.Is(err, order.ErrConcurrentUpdate) {
return err
}
}
return err
}
for range maxAttempts needs Go 1.22 or later, which added range over integers. The change function runs again on every attempt, against freshly loaded state, so it must be a pure edit: "set status to shipped," not "set status to whatever the user saw plus one."
Retrying is only right when the change still makes sense on newer data. If a person made a decision by looking at the old version (a price, a stock count), retrying behind their back is its own lost update. Return the conflict to the edge instead. Over HTTP that maps cleanly onto conditional requests: send the version as an ETag, and per MDN's If-Match page, a mismatched precondition gets a 412 Precondition Failed.
One test, both adapters
The contract test takes a constructor, so the fake and the Postgres adapter run the exact same assertions. Both goroutines load the order before either saves, which forces the conflict instead of hoping for it.
package ordertest
import (
"context"
"errors"
"sync"
"testing"
"example.com/shop/order"
)
func OneWriterWins(t *testing.T, repo order.Repository) {
ctx := context.Background()
a, err := repo.Get(ctx, "o-42")
if err != nil {
t.Fatal(err)
}
b := a
a.Status = "shipped"
b.Note = "leave at door"
errs := make(chan error, 2)
var wg sync.WaitGroup
for _, o := range []order.Order{a, b} {
wg.Add(1)
go func() {
defer wg.Done()
errs <- repo.Save(ctx, o)
}()
}
wg.Wait()
close(errs)
var won, lost int
for err := range errs {
switch {
case err == nil:
won++
case errors.Is(err, order.ErrConcurrentUpdate):
lost++
default:
t.Fatalf("unexpected error: %v", err)
}
}
if won != 1 || lost != 1 {
t.Fatalf("won=%d lost=%d, want 1 and 1", won, lost)
}
got, err := repo.Get(ctx, "o-42")
if err != nil {
t.Fatal(err)
}
if got.Version != a.Version+1 {
t.Fatalf("version=%d, want %d",
got.Version, a.Version+1)
}
}
The goroutine captures o directly, which is safe because Go 1.22 made loop variables per-iteration. Wiring the test up for the fake takes one call:
func TestMemoryOneWriterWins(t *testing.T) {
seed := order.Order{
ID: "o-42", Status: "pending", Version: 1,
}
ordertest.OneWriterWins(t, memory.NewOrderRepo(seed))
}
The Postgres version does the same against a real pool: read a connection string from an environment variable, t.Skip when it is empty, create the table, insert the seed row, and pass postgres.NewOrderRepo(pool) to OneWriterWins. Run both with go test -race ./...; the race detector will also catch a fake that forgot its mutex.
A second test belongs next to it: fire two Orders.Update calls at the fake concurrently, one setting the status and one setting the note, and assert that the final order has both. That one proves the retry turns the conflict into two surviving edits instead of one.
If this was useful
The version column is the easy part. The design work is deciding that the conflict is a domain error, writing the port's contract down, and making the fake as strict as the database so your fast tests mean something. Hexagonal Architecture in Go is the book I wrote about exactly that layer: where repositories, transactions and adapters sit in a Go service, and how to test each side of the port without a pile of mocks. If you also want the language underneath, The Complete Guide to Go Programming, the companion book in the Thinking in Go series, covers Go itself.

Top comments (1)
실제 DB 어댑터와 메모리 페이크에 같은 계약 테스트를 적용하는 구성이 인상적입니다. 재시도 때 change가 여러 번 실행될 수 있다는 조건을 테스트 이름에도 드러내면, 나중에 메일 발송 같은 부수효과가 섞이는 회귀를 점검하기 좋겠네요. 외부 알림이 최종 성공 뒤 한 번만 실행되는지도 별도 시나리오로 확인해볼 수 있겠습니다.