DEV Community

Cover image for Two Requests Edited the Same Order. Optimistic Locking in a Go Repository Adapter
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Two Requests Edited the Same Order. Optimistic Locking in a Go Repository Adapter


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`
Enter fullscreen mode Exit fullscreen mode

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.

Two hands writing on the same paper order form, one erasing what the other just wrote
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
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

A row of identical order cards with numbered tabs; the card whose tab number doesn't match is pushed back by a gate
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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (1)

Collapse
 
officialmailkr profile image
오피셜메일 •

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