Skip to content

Concurrency

When two requests try to update the same aggregate at the same time, one of them has to lose. Nagare uses optimistic concurrency to make sure neither silently overwrites the other.

How it works

Every aggregate has a version, which is the number of events in its stream. When Nagare appends new events, it passes the expected version to the store:

"I last saw version 5. Here are my new events, which should become version 6 and 7."

If someone else already appended events after version 5, the store rejects the write with WrongExpectedVersionException. The first writer wins.

This is the same principle as HTTP ETags or Git's fast-forward merge. No locks, no blocking. Just a check at write time.

What happens on conflict

csharp
try
{
    var aggregate = await repo.Load(new AggregateId("book-1"));
    var reply = await aggregate.Ask(new BorrowBook("user-42"));
}
catch (WrongExpectedVersionException ex)
{
    // Someone else modified this aggregate between our load and our write.
    // ex.AggregateId — which aggregate
    // ex.ExpectedVersion — what we thought the version was
    // ex.ActualVersion — what it actually was
}

In practice, conflicts are rare. They only happen when two requests target the same aggregate instance within the same few milliseconds. If your aggregates are small and well-bounded (as they should be), this is uncommon.

Retry strategies

When a conflict does happen, the correct response is to reload the aggregate and retry:

csharp
async Task<IReply> ExecuteWithRetry(
    IAggregateRepository<BookAggregate, BookCommand, BookEvent, BookState> repo,
    AggregateId id,
    BookCommand command,
    int maxRetries = 3)
{
    for (var attempt = 0; attempt < maxRetries; attempt++)
    {
        try
        {
            var aggregate = await repo.Load(id);
            return await aggregate.Ask(command);
        }
        catch (WrongExpectedVersionException) when (attempt < maxRetries - 1)
        {
            // Reload and try again. The aggregate will have the new events
            // and the command handler will re-evaluate against the latest state.
        }
    }

    throw new InvalidOperationException("Max retries exceeded");
}

Reloading the aggregate replays the new events, rebuilds the state, and runs the command handler again against the latest truth. If the command is still valid, it succeeds. If the new state makes the command invalid (someone else already borrowed the book), it rejects normally.

Middleware approach

You can also handle retries as command middleware:

csharp
public class RetryOnConflictMiddleware : ICommandMiddleware
{
    public async Task<IReply> InvokeAsync(AskContext context, AskDelegate next)
    {
        for (var attempt = 0; attempt < 3; attempt++)
        {
            try
            {
                return await next(context);
            }
            catch (WrongExpectedVersionException) when (attempt < 2)
            {
                // The aggregate will be reloaded on the next attempt
            }
        }

        throw new InvalidOperationException("Concurrency conflict after 3 attempts");
    }
}

Register it once and every command gets automatic retry:

csharp
builder.Services.AddCommandMiddleware<RetryOnConflictMiddleware>();

Idempotency and concurrency

Idempotent commands and optimistic concurrency work together. Consider this scenario:

  1. User clicks "Borrow" twice (network retry)
  2. First request: loads version 5, appends BookBorrowed, version becomes 6
  3. Second request: loads version 6, state shows IsBorrowed = true
  4. Command handler returns Then.Reject("Book is already borrowed")

The aggregate's business rules handle the duplicate naturally. No special concurrency logic needed.

For commands where you explicitly want idempotency without rejection:

csharp
commands.On<ReportLost>((state, _) =>
    state.IsLost
        ? Then.Accept()  // already lost, acknowledge silently
        : Then.Persist(new BookLost(DateTimeOffset.UtcNow)));

The EventStoreSemaphore

Internally, Nagare uses EventStoreSemaphore to serialize writes to the same event store table within a single process. This prevents in-process race conditions when multiple threads write simultaneously.

The semaphore is registered as a singleton and injected into the event store automatically. You don't need to configure it.

Two layers of protection

The semaphore and the version check guard against different failure modes:

  • Semaphore — prevents in-process races. Without it, two concurrent threads could both read version 5, both pass the version check, and both attempt to insert version 6. The semaphore ensures only one write enters the append path at a time.
  • Version check — prevents cross-process races. In multi-instance deployments, the semaphore is process-local and can't help. The version check inside a serializable transaction catches conflicts at the database level.

Together they provide defence in depth: the semaphore catches the common case cheaply (no round-trip to the database), while the version check is the ultimate safety net that works regardless of deployment topology.

Multi-instance deployments

When running multiple instances of the same service, the database itself enforces concurrency. Each instance loads from and writes to the same event store. The version check happens at the SQL level, so conflicts are detected correctly regardless of which instance issued the write.

For subscriptions running on multiple nodes, use a lock provider to ensure only one instance processes events at a time:

csharp
// SQL Server
services.AddSingleton<ILockProvider>(sp =>
    new MsSqlLockProvider(sp.GetRequiredService<DbConnection>()));

// PostgreSQL
services.AddSingleton<ILockProvider>(sp =>
    new PostgresLockProvider(sp.GetRequiredService<DbConnection>()));

Without a lock provider, Nagare uses NoopLockProvider, which is fine for single-instance deployments.

Design guidelines

  1. Keep aggregates small. Fewer events per aggregate means fewer conflicts. If two users can independently operate on different parts of your domain, those parts should be separate aggregates.

  2. Make commands idempotent where possible. It eliminates an entire class of concurrency concerns.

  3. Don't retry indefinitely. Three attempts is usually enough. If conflicts persist, the aggregate is too hot (too many concurrent writes) and should be redesigned.

  4. Let the domain handle it. Most "concurrency problems" are actually business rule questions. The aggregate's command handler, running against the latest state, naturally resolves most conflicts.


Next: Archive & Purge

流れ — flow.