Skip to content

Consistency Patterns

Event-sourced systems are made of two consistency regimes that look the same from a distance but behave very differently:

  • Inside an aggregate: strongly consistent, because the aggregate's stream is single-writer and the event store enforces version checks on append.
  • Across aggregates and across projections: eventually consistent, because subscriptions catch up asynchronously.

Most production bugs in event-sourced systems live at the boundary between these two regimes. This page is the toolkit for handling that boundary deliberately. For the within-aggregate story (optimistic concurrency, retry on conflict), see Concurrency.

Why cross-aggregate validation is hard

The aggregate is your unit of strong consistency. By design, an aggregate cannot atomically check facts about other aggregates' streams. That would mean a distributed transaction across streams, which negates most of event sourcing's benefits.

So when a command's validity depends on data outside the aggregate (the canonical example: "no two customers can have the same email"), you have a problem the aggregate alone cannot solve. There are four good answers and one bad one.

Pattern 1: Stream-ID uniqueness

The cheapest cross-aggregate uniqueness check is to encode the unique key directly into the aggregate stream identifier.

csharp
// Stream ID encodes the date — at most one Booking per (room, date)
var booking = await repo.Load(new AggregateId($"booking-{roomId}-{date:yyyy-MM-dd}"));
await booking.Ask(new MakeBooking(customerId));

If two concurrent requests try to book the same room on the same date, they both target the same stream, so the event store's optimistic-concurrency check serialises them. Whoever appends second sees the first booking already in state and rejects the command.

When it fits

The unique key is immutable, naturally part of the aggregate's identity, and won't ever need to change shape. A date, an ISBN, a room number.

When it doesn't fit:

  • The key is mutable (e.g., customer email; they can change it).
  • The same aggregate sometimes needs lookups by a different key.
  • Privacy requirements (GDPR) require renaming or anonymising the identifier.

Stream-ID uniqueness is the first answer to reach for. If it fits, use it.

Pattern 2: Reservation pattern

When the unique key is mutable or the uniqueness has to span an unbounded set, claim the resource in a side store with a unique constraint before running the aggregate logic. Release it on success or failure.

1. Claim: INSERT INTO email_reservations (email, owner_id) VALUES (...)
   → If the unique constraint rejects, fail fast: "email already taken"
2. Decide: load aggregate, run command, append events
3. Confirm: keep the reservation (or move "owner_id" to the new aggregate id)
4. On failure: DELETE the reservation
5. Background sweep: cancel reservations older than N minutes that never confirmed
csharp
public async Task<IReply> Register(RegisterCustomer cmd)
{
    var claimed = await reservations.TryClaim(cmd.Email, cmd.CustomerId);
    if (!claimed) return Reply.Reject("Email already in use");

    try
    {
        var customer = await repo.Load(new AggregateId($"customer-{cmd.CustomerId}"));
        var reply = await customer.Ask(cmd);

        if (reply.IsRejected) await reservations.Release(cmd.Email);
        return reply;
    }
    catch
    {
        await reservations.Release(cmd.Email);
        throw;
    }
}

The reservation table lives outside Nagare. It's an application-level construct, usually a small table with a unique index, sometimes Redis with a SETNX. Nagare doesn't ship one because the right shape varies.

When to use:

  • Mutable unique keys (email, username, phone).
  • Cross-aggregate constraints that the read model can't enforce safely.
  • Anywhere you want a "tell, don't ask" guarantee. You didn't ask "is this email taken?", you tried to claim it. Asking is racy; claiming is not.

Trade-offs:

  • Orphan reservations on crash between claim and confirm. You need a sweeper.
  • One more piece of operational state.
  • For key updates (email change), you need a swap operation: claim the new key, run the change, release the old key.

Pattern 3: Version tokens (read-then-act with check)

The hardest case is when a command's validity depends on data the aggregate doesn't own, typically a projection that has joined facts from several aggregates. The pattern: carry the projection's checkpoint or version into the command, and have the aggregate (or a pre-flight check) reject if the projection has moved on in a way that invalidates the read.

csharp
// The query returns data plus the checkpoint at which it was computed.
public record AvailableInventoryView(
    string Sku,
    int Available,
    long ReadAtCheckpoint);

// The command carries the checkpoint forward.
public record ReserveInventory(
    string Sku,
    int Quantity,
    long ExpectedCheckpoint) : InventoryCommand;

// Pre-flight check verifies the projection hasn't drifted.
public async Task<IReply> ReserveAsync(ReserveInventory cmd)
{
    var current = await projection.GetCheckpointForSku(cmd.Sku);
    if (current > cmd.ExpectedCheckpoint)
        return Reply.Reject("Stale read — refresh and retry");

    var inventory = await repo.Load(new AggregateId($"inventory-{cmd.Sku}"));
    return await inventory.Ask(cmd);
}

The check has to be narrow: "did anything happen in this projection that would invalidate this specific decision?" Comparing only the global checkpoint is overly strict and produces false rejections. Comparing the relevant subset (the SKU here) is better.

When to use:

  • The decision depends on a projection that joins data across aggregates.
  • The cost of a stale-read decision is high enough that the caller needs to know to retry.

This pattern goes by several names in the literature: consistency token, causal token, contextual consistency. They're all the same idea.

Pattern 4: Database unique index on a snapshot projection

Pragmatic, breaks event-sourcing purity, often the right answer for low-stakes uniqueness checks.

You maintain a projection that is one row per aggregate, with a unique index on the column you care about. When the projection writer tries to upsert a row that violates uniqueness, the database rejects the write, and your subscription handler sees the failure.

sql
CREATE TABLE customer_snapshot (
    customer_id TEXT PRIMARY KEY,
    email TEXT NOT NULL,
    UNIQUE (email)
);

If two customers somehow get registered with the same email (the reservation pattern was bypassed), the projection write fails. Visible alarm, you can clean up.

The reservation pattern prevents; the unique index detects. They're complementary.

Detection, not prevention

Don't rely on a unique-index projection as your only uniqueness mechanism. By the time the projection sees the duplicate, the conflicting events are already in the store. Belt-and-braces on top of a reservation, fine. As the only line of defence, no.

The anti-pattern: naive read-then-act

The pattern this all avoids:

csharp
// ❌ Bad. Racy. Two requests can both pass the check, then both append.
public async Task<IReply> Register(RegisterCustomer cmd)
{
    var existing = await readModel.FindByEmail(cmd.Email);
    if (existing is not null) return Reply.Reject("Email taken");

    var customer = await repo.Load(new AggregateId($"customer-{cmd.CustomerId}"));
    return await customer.Ask(cmd);
}

The window between the read and the append is unbounded. Two concurrent registrations for the same email both see "no existing customer," both append, and the read model now has two records that should not coexist. The aggregate's optimistic concurrency cannot help; they're different streams.

This is the bug version-tokens and reservations exist to fix.

Idempotency tokens vs version tokens

These are two different concepts that often get conflated:

  • Idempotency token: a unique ID generated per command attempt. The server records "I have processed token T" so a duplicate retry of the same command is a no-op. Protects against duplicate command delivery (network retry, double-click, at-least-once message).
  • Version token / consistency token: the projection version a command depends on. The server checks "is the world still at version V or earlier?" and rejects if it has moved on. Protects against stale-read decisions (caller read at version V, world has moved).

Both can coexist on the same command. Idempotency tokens are usually handled at the boundary (a middleware that records seen keys); version tokens are handled in the aggregate's command handler or a pre-flight check.

Read-after-write

After dispatching a command, the read model is briefly behind. Nagare's State is protected on Aggregate<> for a reason: there's no "load the aggregate and read its state" escape hatch for production code. Pick from these patterns instead, in roughly preferred order.

Return data in the command response

The aggregate's reply already says "accepted". Extend that response to carry the small set of fields the caller needs, sourced from the command input plus the decision the aggregate just made. No projection round-trip, no stale-read risk.

csharp
app.MapPost("/orders", async (PlaceOrderRequest req, IAggregateRepository<...> repo) =>
{
    var aggregate = await repo.Load(new AggregateId(req.OrderId));
    var reply = await aggregate.Ask(new PlaceOrder(req.ProductId, req.Quantity));

    return reply.Match(
        onAccepted: () => Results.Ok(new {
            OrderId   = req.OrderId,
            Status    = "placed",
            ProductId = req.ProductId,
            Quantity  = req.Quantity }),
        onRejected: ex => Results.Conflict(new { Error = ex.Message }),
        onIgnored: ()   => Results.Ok(new { Status = "already placed" }));
});

Optimistic UI

The client renders the expected outcome from the command input and reconciles on next read. Works well for forms; awkward for list views and multi-user data.

Poll the read model with a version

The command response carries the aggregate's new version; the GET endpoint accepts it as a parameter and returns 202 ("not yet") until the projection catches up. Polling needs a deadline, cancellation, and exponential backoff.

csharp
// Server: command returns the new version
app.MapPost("/orders/{id}/ship", async (string id, ShipRequest req, ...) =>
{
    var aggregate = await repo.Load(new AggregateId(id));
    var reply = await aggregate.Ask(new ShipOrder(req.Tracking));
    return Results.Ok(new { Version = aggregate.Version.Value });
});

// Server: read endpoint honours afterVersion
app.MapGet("/orders/{id}", async (string id, int? afterVersion, IOrderRepository repo) =>
{
    var order = await repo.GetById(id);
    if (afterVersion.HasValue && order?.Version < afterVersion)
        return Results.StatusCode(202);
    return Results.Ok(order);
});

For the projection to know its current version, write the aggregate's Version from the EventEnvelope into the read model on every Apply.

Push when latency really matters

For paths where polling is too coarse, use AddLiveTap to broadcast projection commits via SSE or WebSockets. On PostgreSQL, AddPostgresLiveSubscription combines the live runner with LISTEN/NOTIFY for sub-10ms event-to-notification latency.

csharp
builder.Services.AddPostgresLiveSubscription<OrderUpdateBroadcaster, OrderEvent>();

Accept the lag

The simplest answer when latency tolerance is high. The read model catches up in milliseconds. If the UI doesn't show stale data for longer than the user's perception threshold, do nothing.

What aggregate.State is for

State is protected on Aggregate<> and inaccessible from production code. The AggregateTestHarness reads it via reflection so test code can write harness.State.Should().Be(...) after a When. That's its full job description. There's no production read path through aggregate.State, by design.

Cross-stream coordination

When a workflow spans multiple aggregates (cart confirmed → seats reserved → payment captured → order shipped), Nagare provides first-class process managers for the orchestration. A process manager is a Process<TCommand, TEvent, TState> that looks like an aggregate from the outside but additionally:

  • Command handlers are async with a ctx parameter for service access. Process managers can read projections to make decisions; aggregates cannot.
  • Decisions can dispatch commands to other aggregates via Then.Persist(...).AndDispatch(Dispatch.To(targetId, cmd).WithProcessId(processId)).
  • External events route back via RegisterEventRoutes() using the ProcessId in metadata.

See Process Managers for the full pattern.

The coordination is eventually consistent. The receiving aggregate sees the dispatched command some milliseconds after the process appends. If a workflow step must hold strongly with the next, they belong inside the same aggregate. Splitting them was a sizing mistake.

For workflows that span bounded contexts (different services, different event stores), use the messaging layer instead. IMessageMapper translates domain events into integration events, MessageProducer publishes them through IMessageChannel, and the consuming context subscribes via IMessageHandler. See Messaging.

Choosing the right pattern

Need to check uniqueness?
├── Is the key immutable + part of identity?  → Stream-ID uniqueness
├── Otherwise                                  → Reservation pattern (+ optional unique-index detection)

Need to validate against cross-aggregate state?
├── Aggregate stream alone is enough?          → Built-in optimistic concurrency
├── Decision depends on a projection?          → Version tokens
├── Decision spans many aggregates atomically? → Reconsider aggregate boundaries

Need read-after-write consistency for a UI?
├── Caller just needs confirmation + a few fields?  → Return data in the command response
├── Forms / single-user paths?                       → Optimistic UI
├── General case?                                    → Poll with version
├── Sub-second push needed?                          → AddLiveTap / LISTEN/NOTIFY
├── Latency tolerant?                                → Accept the lag

Need to coordinate workflow across aggregates inside one context? → Process<>
Need to coordinate workflow across bounded contexts?              → MessageProducer / IMessageMapper / IMessageChannel

When in doubt, prefer the pattern that fails loud (reservation, version token) over the one that fails silent (read-then-act, eventually-consistent uniqueness). A loud failure with a "retry" signal is cheap to handle. A silent corruption of the event log is not.

流れ — flow.