8 min left
1609 words
8 minutes

Repository Updates in TypeScript: Locks and Optimistic Concurrency

Series: TDD & Software Design Part 4 of 4

TL;DR

  • save() is not the problem. An unconditional overwrite is.
  • A transactional update closure is a good default when a repository owns a short database transaction. The Three Dots Labs implementation uses SELECT ... FOR UPDATE: pessimistic locking, not optimistic versioning.
  • If callers work with detached aggregates, use a version-aware save() instead.
  • A fake repository must give the update function a detached copy. Otherwise a failed update can mutate stored state before the fake decides to reject it.

The earlier Aggregates & Repositories article deliberately kept persistence small. This article builds on its Order, Result, and snapshot examples to handle two requests changing the same aggregate.

This article assumes a modular monolith with PostgreSQL and short transactions. The trade-offs change when the aggregate lives behind a remote service or a long-running workflow.


The Actual Lost-Update Bug#

A lost update occurs when two requests read the same aggregate state and an unconditional later write overwrites the earlier change. This repository is fine for a demo and unsafe under concurrent updates:

async save(order: Order): Promise<Result<void, string>> {
this.orders.set(order.getId().getValue(), order);
return ok(undefined);
}

Two requests can load version 4 of the same order. One adds a line, the other cancels it. Both call save(). The second write replaces the first without either request learning that it worked from stale state.

The bug is the missing concurrency contract. A version-aware write is commonly called save().

There are two common repository contracts:

ContractBest fitCost
Version-aware save(order)Detached aggregates and low contentionThe caller must retry a conflict
Transactional update(id, fn)Short, local mutationsWriters wait on a row lock

A serializable transaction is an isolation level, not a third repository API. Use it when an invariant depends on conflicting reads beyond the locked aggregate; it can abort transactions, so callers must retry.

I use the update closure when the application service can express the change as one short operation on one aggregate. It makes the repository own the read, lock, mutation, and write as one unit.


A Transactional Update Closure#

Three Dots Labs uses this shape in Go:

type Repository interface {
UpdateHour(
ctx context.Context,
hourTime time.Time,
updateFn func(h *Hour) (*Hour, error),
) error
}
UpdateHour(ctx, hourTime, func(hour *Hour) (*Hour, error) {
return hour.MakeAvailable(), nil
})

Its MySQL adapter starts a transaction, loads the row with SELECT ... FOR UPDATE, runs the function, saves the result, then commits. The row lock is pessimistic: a competing writer waits until the first transaction commits or rolls back.

Here is the TypeScript contract:

type UpdateOrder = (order: Order) => Result<Order, OrderError>;
interface OrderRepository {
add(order: Order): Promise<Result<void, OrderError>>;
getById(id: OrderId): Promise<Result<Order, OrderError>>;
update(id: OrderId, change: UpdateOrder): Promise<Result<Order, OrderError>>;
}

The application service states the business operation. It neither opens a transaction nor persists an intermediate aggregate.

class SubmitOrder {
constructor(private readonly orders: OrderRepository) {}
execute(orderId: OrderId): Promise<Result<Order, OrderError>> {
return this.orders.update(orderId, (order) => {
const submitted = order.submit();
if (!submitted.ok) return submitted;
return ok(order);
});
}
}

The closure must remain short and local. Do not call payment APIs, publish messages, or acquire another aggregate lock inside it.


How Should a PostgreSQL Repository Update an Aggregate Safely?#

The adapter starts a transaction, loads the order with SELECT ... FOR UPDATE, rehydrates the order and its lines, runs the closure, persists the changed aggregate, and commits. The lock is the detail that matters; the interface name is incidental.

Returning an error from a transaction callback does not necessarily roll back; the transaction library decides that. When a closure returns Result.err, the repository must signal rollback to its transaction helper, then convert that rollback signal back to the same error for the caller.

Production code also needs a transaction helper whose rollback behavior is tested. Do not assume a wrapper rolls back because its callback returned { ok: false }.

The Locking Trade-off#

FOR UPDATE is a useful default when updates are fast and contention is limited. It has costs:

  • A blocked request waits for the lock.
  • Locks taken in inconsistent order can deadlock.
  • Long closures hold locks longer than the business operation needs.

For a hot aggregate or a detached editing workflow, optimistic concurrency is usually better.


Optimistic Concurrency with a Version-Aware Save#

When a request loads an aggregate, lets a user think for ten minutes, then submits a form, holding a row lock is the wrong design. Optimistic concurrency is the match for detached aggregates. Load a version with the aggregate and condition the write on that version instead:

This scalar-only sketch updates fields stored on orders. A complete save writes changed order lines in the same transaction before it advances the version.

UPDATE orders
SET status = $2,
total_cents = $3,
version = version + 1
WHERE id = $1
AND version = $4

If the affected-row count is zero, the repository returns ConcurrencyConflict. The UI can reload the order and ask the user to reconcile the change.

async save(order: Order): Promise<Result<Order, OrderError>> {
const updated = await this.database.execute(
`UPDATE orders
SET status = $2,
total_cents = $3,
version = version + 1
WHERE id = $1 AND version = $4`,
[
order.getId().getValue(),
order.getStatus(),
order.getTotalCents(),
order.getVersion(),
]
);
if (updated.rowCount === 0) {
return err({ type: "concurrency-conflict", id: order.getId().getValue() });
}
return ok(order.withVersion(order.getVersion() + 1));
}

This is a valid save() method. The caller owns the load-modify-save window; the database detects whether that window became stale.

Pick one model per use case. A closure that locks the row does not need a version check to prevent concurrent writes. A detached aggregate needs one because it has no lock.


A Fake That Can Prove Rollback#

The Three Dots in-memory repository stores values rather than pointers. That detail matters. A fake that gives the callback its stored object lets mutations escape before the update succeeds.

The smallest honest fake stores snapshots and reconstitutes a new aggregate for every operation:

class InMemoryOrderRepository implements OrderRepository {
private readonly orders = new Map<string, OrderSnapshot>();
async add(order: Order): Promise<Result<void, OrderError>> {
const id = order.getId().getValue();
if (this.orders.has(id)) return err({ type: "already-exists", id });
this.orders.set(id, structuredClone(order.toSnapshot()));
return ok(undefined);
}
async getById(id: OrderId): Promise<Result<Order, OrderError>> {
const snapshot = this.orders.get(id.getValue());
if (!snapshot) return err({ type: "not-found", id: id.getValue() });
return Order.reconstitute(structuredClone(snapshot));
}
async update(
id: OrderId,
change: UpdateOrder
): Promise<Result<Order, OrderError>> {
const snapshot = this.orders.get(id.getValue());
if (!snapshot) return err({ type: "not-found", id: id.getValue() });
const loaded = Order.reconstitute(structuredClone(snapshot));
if (!loaded.ok) return loaded;
const changed = change(loaded.value);
if (!changed.ok) return changed;
this.orders.set(id.getValue(), structuredClone(changed.value.toSnapshot()));
return changed;
}
}

This fake’s read-modify-write block does not yield. JavaScript does not need a mutex around it. If production locking matters, test that behavior against PostgreSQL, where locks and transaction isolation actually exist.

The fake proves domain behavior and rollback. The integration test proves database concurrency.

Rollback-Safe Fakes and the Tests They Cannot Replace#

Rollback-safe repository fakes: snapshots for domain tests, PostgreSQL for real concurrency

The fake’s job is to prove the aggregate behaves correctly when a closure mutates it and then rejects. The integration test’s job is to prove the database serializes concurrent writers. Mixing the two tests creates false confidence.


How Do You Test Rollback in an In-Memory Repository?#

This test only proves rollback if the aggregate changes before the closure rejects:

it("does not commit changes when the closure rejects after mutating", async () => {
const result = await orders.update(orderId, (order) => {
const added = order.addLine("widget", 1, 5_000);
if (!added.ok) return added;
return err({ type: "cannot-submit-cancelled-order" });
});
expect(result).toEqual(err({ type: "cannot-submit-cancelled-order" }));
const reloaded = await orders.getById(orderId);
expect(reloaded.ok && reloaded.value.getLineCount()).toBe(0);
});

The error is deliberately arbitrary. The contract under test is mutate, reject, then reload from storage. If the fake stores references, this test catches it.

What Concurrency Tests Require Real PostgreSQL?#

Run this one against real PostgreSQL:

it("serializes two updates without losing either", async () => {
const first = repository.update(orderId, (order) => {
const result = order.submit();
return result.ok ? ok(order) : result;
});
const second = repository.update(orderId, (order) => {
const result = order.cancel();
return result.ok ? ok(order) : result;
});
const [left, right] = await Promise.all([first, second]);
// Pessimistic FOR UPDATE serializes the closures. Both observe the
// latest committed state; one wins submit, the other sees the
// submitted order and accepts the cancel.
expect(left.ok).toBe(true);
expect(right.ok).toBe(true);
const reloaded = await repository.getById(orderId);
expect(reloaded.ok && reloaded.value.getStatus()).toBe("cancelled");
});

Under PostgreSQL’s default READ COMMITTED isolation, the pessimistic SELECT ... FOR UPDATE lock serializes the closures. Both writers succeed against the order’s evolving state, and the database records the second commit. Run the same test against an optimistic version-aware save() instead and the second call must fail with concurrency-conflict. The assertion that distinguishes the two contracts is the contract under test.


What Happens When the Use Case Touches More Than One Aggregate?#

An aggregate is a transactional consistency boundary. The boundary guides the design while still allowing a local transaction that deliberately writes several tables.

Start with the business requirement:

  • If Order and Inventory must be atomically consistent and share one database, a local transaction may be the right cost.
  • If they can converge later, record an integration event in an outbox in the same transaction as Order; publish it after commit.
  • If they live in separate services or databases, a database transaction cannot coordinate them. Use an asynchronous process and make its failure modes explicit.

The next article covers aggregate boundaries, mappings, database foreign keys, and the workflow choices for work that crosses those boundaries.

Sources#

  • Eric Evans, Domain-Driven Design, chapters on entities, aggregates, and repositories for the consistency-boundary framing throughout.
  • Vaughn Vernon, Implementing Domain-Driven Design, on optimistic concurrency and detached-aggregate persistence.
  • Martin Fowler, Repository, for the domain-access-versus-storage-mechanics separation.
  • Three Dots Labs, Repository pattern in Go, for the transactional update closure shape adapted in this article.

Continue the TDD & Software Design Series#

  1. TDD Isn’t Just for Catching Bugs - It’s Your Permission Slip
  2. Value Objects & Entities - Building Blocks That Can’t Break
  3. Aggregates & Repositories in TypeScript - Refactoring Without Fear
  4. Repository Updates in TypeScript: Locks and Optimistic Concurrency
  5. DDD Aggregate Boundaries and Persistence in TypeScript
  6. I Stopped Mocking Everything in TDD
  7. The Result Pattern: Why I Stopped Throwing Exceptions
  8. TDD in the Age of AI: Who Tests the Tests?
  9. TDD for Legacy Code: Start With Seams, Not Ideology
Repository Updates in TypeScript: Locks and Optimistic Concurrency
https://corentings.dev/blog/tdd-repository-bonus/
Author
Corentin Giaufer Saubert
Published at
2026-07-16
License
CC BY-NC-SA 4.0
Share this post