Optimistic concurrency control, not locking, is the secret sauce that keeps CQRS systems from eating their own updates.

Let’s watch this play out. Imagine two users, Alice and Bob, both looking at the same Product aggregate in our e-commerce system.

// Initial state of Product aggregate
{
  "id": "prod-123",
  "name": "Wireless Mouse",
  "version": 5,
  "price": 25.00,
  "stock": 100
}

Alice wants to lower the price to $22.00. Bob wants to increase stock to 120. They both fetch the product at version: 5.

Alice’s command:

{
  "productId": "prod-123",
  "newPrice": 22.00,
  "expectedVersion": 5
}

Bob’s command:

{
  "productId": "prod-123",
  "newStock": 120,
  "expectedVersion": 5
}

Now, the order they hit the command handler and event store matters.

Scenario 1: Bob gets there first

  1. Bob’s command handler receives his request.
  2. It checks the Product aggregate in the event store: version: 5.
  3. It matches expectedVersion: 5 with the current version. Success!
  4. A PriceReduced event is generated (or in Bob’s case, StockIncreased).
  5. The event store appends the StockIncreased event. The aggregate is now at version: 6.
// Event stream for prod-123
...
{ "type": "ProductCreated", "version": 1, ... }
{ "type": "ProductNameChanged", "version": 2, ... }
{ "type": "PriceReduced", "version": 3, ... }
{ "type": "StockIncreased", "version": 4, ... }
{ "type": "PriceReduced", "version": 5, ... }
{ "type": "StockIncreased", "version": 6, payload: { "newStock": 120 } } // Bob's event

Scenario 2: Alice gets there second (after Bob)

  1. Alice’s command handler receives her request.
  2. It fetches the Product aggregate from the event store. It reads all events up to version: 6.
  3. It reconstructs the aggregate state: version: 6.
  4. It compares Alice’s expectedVersion: 5 with the current aggregate version 6. They do not match.
  5. The command handler throws an error: ConcurrencyConflictException or similar. Alice’s update is rejected. Her price reduction is not lost.

This is the core idea: check the version before you commit. If the version has changed since you read it, someone else has updated it, and your change might conflict.

The "optimistic" part comes from the assumption that conflicts are rare. We don’t lock resources upfront (like pessimistic concurrency). Instead, we proceed assuming no one else will touch it, and only check for conflicts at the very end, when we try to save our changes.

How it works under the hood:

Most event stores (like EventStoreDB, Kafka with specific configurations, or even custom SQL/NoSQL solutions) support versioning. When you append an event, you typically provide the expected version of the aggregate. The store then performs an atomic operation: it checks if the current version matches your expected version, and only if they match, it appends your event and increments the version. If they don’t match, the append fails.

This means the EventStore itself is the arbiter of truth. Your aggregate code generates events, but the EventStore decides if those events can be applied based on the version.

The critical lever you control: the expectedVersion in your commands. When your command handler loads an aggregate, it must load it up to a specific version. This version is then passed along with the command. The EventStore (or your persistence layer) will use this expectedVersion to ensure atomicity.

Here’s a simplified look at how a persistence layer might handle this:

// Conceptual C# example
public void AppendEvent(Guid streamId, object @event, int expectedVersion)
{
    // Fetch current version from DB
    var currentVersion = GetCurrentVersion(streamId);

    if (currentVersion != expectedVersion)
    {
        throw new ConcurrencyException($"Expected version {expectedVersion}, but found {currentVersion}");
    }

    // Atomically:
    // 1. Insert the new event with streamId, version = currentVersion + 1
    // 2. Update the stream's current version to currentVersion + 1
    // If both succeed, return success. If either fails, rollback.
    PersistEventAndIncrementVersion(streamId, @event, currentVersion + 1);
}

The version number is the key. It’s not just a sequence; it’s a checksum for the aggregate’s state at a point in time. Every event that modifies the aggregate increments this version.

What if Alice and Bob are trying to modify the same field? Say, both want to change the price. Alice wants to set it to $22.00, Bob to $23.00. If Alice’s command (expecting version 5) succeeds first, the aggregate becomes version 6. Bob’s command (also expecting version 5) will then fail because the version is now 6. Bob’s price change is rejected. This is correct behavior because his command was based on stale data. He would then need to re-fetch the product (now at version 6), decide if he still wants to change the price (perhaps to $24.00, now expecting version 6), and re-submit.

The biggest pitfall is often forgetting to include the expectedVersion in your command or failing to load the aggregate to a specific version in your command handler. If your command handler just loads the latest aggregate without regard to the version it read it at, you lose the optimistic concurrency check.

The next hurdle is designing your retry strategies when a ConcurrencyConflictException occurs.

Want structured learning?

Take the full Cqrs course →