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
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:
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:
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:
builder.Services.AddCommandMiddleware<RetryOnConflictMiddleware>();Idempotency and concurrency
Idempotent commands and optimistic concurrency work together. Consider this scenario:
- User clicks "Borrow" twice (network retry)
- First request: loads version 5, appends
BookBorrowed, version becomes 6 - Second request: loads version 6, state shows
IsBorrowed = true - 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:
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:
// 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
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.
Make commands idempotent where possible. It eliminates an entire class of concurrency concerns.
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.
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