Skip to content

Guides

Write-path consistency

How UnitOfWork, optimistic concurrency control and idempotency work together in go-sequent. What each layer protects against, what it does not, and how to wire and handle them.

Write-path consistency

Inside one successful CommandService.Execute, three mechanisms answer three different questions:

  • Which in-process instance of an aggregate am I changing, and which events did it produce? (UnitOfWork)
  • Has this stream moved since I read it? (Optimistic concurrency control, OCC)
  • Did I already process this request? (Idempotency)

They complement each other. None of them replaces another.

What each one does

Mechanism Where Role
UnitOfWork (UoW) aggregate, stored on Execute’s context Per-Execute identity map. Add and Load reuse one instance. Commit collects UncommittedEvents in load order.
OCC eventstore.StreamAppend.ExpectedVersion Append requires the stream tip to equal the expected version, otherwise ErrOptimisticConcurrency. The repository sets ExpectedVersion: agg.Version().
Idempotency idempotency middleware, sequent.WithIdempotency Key space is (TenantID, IdempotencyKey). After a success, the same key is a no-op by default. With Strict, it returns ErrReplay.

The UoW does not span Execute calls or goroutines. OCC covers concurrent appends to one aggregate stream. It does not tell you whether an HTTP client reused a business key. Idempotency covers keyed replay of a command. It does not stop two different actors from racing on the same account version.

How they layer

Layer Protects against Does not cover
UoW Two mutable copies from repeated Load in one Execute. Unordered multi-aggregate commit. Parallel Execute calls. Retries after a process crash.
OCC Appends based on a stale Version() (lost update). “User clicked pay twice” when both attempts see a free new version.
Idempotency Successful replay of the same (tenant, key). A missing IdempotencyKey. Distinct keys for the same business intent. OCC conflicts themselves.

For payments, pick a stable IdempotencyKey (a merchant order id works well) to guard against replays. Keep OCC for concurrent ledger updates. Keys are tenant-scoped; see Tenancy.

Wiring

Put tenancy before idempotency so Meta.TenantID is set when idempotency runs.

import (
    "github.com/go-sequent/go-sequent/command"
    "github.com/go-sequent/go-sequent/idempotency"
    "github.com/go-sequent/go-sequent/sequent"
    "github.com/go-sequent/go-sequent/tenancy"
)

app := sequent.New(sequent.Config{},
    sequent.WithIdempotency(idempotency.NewMemory() /* or NewPostgres(pool) */, false),
)
app.Service.Middleware = append(
    []command.Middleware{tenancy.Middleware},
    app.Service.Middleware...,
)

Chain runs middleware outer-first, so the first element runs first.

Handlers touch aggregates only through the repository

func (h *Handler) Handle(ctx context.Context, cmd command.Command) error {
    c := cmd.(*Deposit)
    acc, err := h.Repo.Load(ctx, c.AccountID, AggregateAccount)
    if err != nil {
        return err
    }
    return acc.(*Account).Deposit(c.Amount) // Apply → uncommitted events on the UoW
}

Never call EventStore.Append from a handler. The repository does it at commit time.

Handling OCC conflicts

Repo.Commit sets ExpectedVersion to agg.Version(), the committed tip at load time. If another writer appended first, the store rejects the append:

err := app.Service.Execute(ctx, cmd)
if errors.Is(err, eventstore.ErrOptimisticConcurrency) {
    // Execute again. Load again. Do not reuse the old balance you read.
}

Retry with backoff. Conflicts cluster around hot aggregates and around database failovers; see Failover. The OpenTelemetry counter sequent.optimistic_conflict.total shows how often this happens (OpenTelemetry).

Using idempotency keys

An idempotency key requires a TenantID.

ctx = tenancy.WithTenant(ctx, tenantID)
cmd.Meta().IdempotencyKey = "merchant-order:42"
err := app.Service.Execute(ctx, cmd)
// Same key after success: nil (no-op) by default.
// With Strict: idempotency.ErrReplay.

On Postgres, idempotency rows are written in the same transaction as the command. idempotency.Postgres joins the connection from WithinTx, so the foreign key to command_id can see the uncommitted command row. If the transaction rolls back, the key is not recorded and a retry will run normally.

A worked scenario

A merchant calls Withdraw for 30.00, and the network drops the response. The client retries with the same key.

  1. First attempt commits. Balance goes from 100.00 to 70.00. The idempotency row (tenant, key) is stored.
  2. Retry arrives with the same key. The middleware finds the row and returns nil without running the handler. Balance stays at 70.00.

Now two different requests, with different keys, withdraw from the same account at once.

  1. Both load the account at version 5.
  2. The first commits events at sequence 6.
  3. The second append expects the tip to be 5, finds 6, and fails with ErrOptimisticConcurrency.
  4. The caller retries. The retry loads version 6, sees the new balance, and decides again. If funds are no longer enough, the aggregate rejects the command.

Idempotency would not help in the second case, and OCC would not help in the first. You need both.

See also

Docs track the go-sequent library. When APIs drift, the source wins.