Your first aggregate
The Quickstart appended one event with no guards. Real writers need two: a conditional write, so a stale decision cannot land, and idempotency, so a retry cannot land twice. The examples are .NET; the other clients take the same fields under their own names.
Setup, as in the quickstart:
using System.Text;
using Celeriant.Client;
using Celeriant.Client.Errors;
using Celeriant.Client.Requests;
using Celeriant.Client.Responses;
await using var pool = new CeleriantPool(new CeleriantPoolOptions { Address = "localhost:10000" });
var key = new AggregateKey(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid());
var writerId = Guid.Parse("44444444-4444-4444-4444-444444444444"); // stable per writer, across restarts
Create, and only create
An aggregate is addressed by org / type / id and exists from its first write. Its version counts writes: each successful write adds one batch and bumps the version by 1, however many events it carries. A new aggregate is at version 0.
So expectedVersion: 0 means "this aggregate must not exist yet":
var created = await pool.WriteAsync(key,
[new AggregateEvent
{
ClientSeq = 1,
EventTypeMajor = 1,
EventTimestamp = DateTimeOffset.UtcNow,
EventValue = Encoding.UTF8.GetBytes("""{ "sku": "A-1", "qty": 2 }"""),
}],
clientId: writerId,
expectedVersion: 0,
enforceClientIdempotency: true);
long version = created.MaxAggregateVersion!.Value; // 1
Two writers racing to create the same order: one wins, the other gets a conflict. Without expectedVersion, both append.
Append conditionally
Pass the version your decision was based on. The server appends only if the aggregate is still there:
try
{
var appended = await pool.WriteAsync(key,
[new AggregateEvent
{
ClientSeq = 2,
EventTypeMajor = 2,
EventTimestamp = DateTimeOffset.UtcNow,
EventValue = Encoding.UTF8.GetBytes("""{ "event": "shipped" }"""),
}],
clientId: writerId,
expectedVersion: version,
enforceClientIdempotency: true);
version = appended.MaxAggregateVersion!.Value; // 2
}
catch (WriteOccException ex)
{
// someone else wrote first; nothing was appended.
// read from version + 1 up to ex.CurrentAggregateVersion, re-decide, retry.
}
WriteOccException is error 2003. It carries ExpectedVersion and CurrentAggregateVersion. Holding no version? AggregateDetailsAsync returns it as MaxAggregateVersion.
Idempotency
A timeout does not tell you whether the write landed. Retrying blind can double-append; giving up can lose it. ClientSeq settles it.
With enforceClientIdempotency: true, the server keeps the highest ClientSeq it has accepted per (aggregate, clientId) and rejects a write whose lowest ClientSeq is at or below it. So:
- Number your events in increasing order, per aggregate, and never reuse a number.
- Retry with the same
ClientSeq. The retry either lands once or is rejected as a duplicate. - Keep
clientIdstable across restarts. A newclientIdis a new sequence space, and the old sequences stop protecting you.
| Code | .NET exception | What it means | Do |
|---|---|---|---|
| 2003 | WriteOccException | the aggregate moved past expectedVersion | re-read, re-decide |
| 2002 | IdempotencyViolationException | this ClientSeq was already accepted for this clientId | read back and check it was yours |
| 2013 | InflightDuplicateWriteException | the earlier attempt with this ClientSeq is still in flight, not yet durable | back off, retry with the same ClientSeq |
Two traps:
- The version check runs first. Retry a conditional write that already landed and you get 2003, not 2002: the aggregate is now past your
expectedVersion. Read fromversion + 1and look for yourClientSeqbefore treating it as a conflict. - A 2002 proves the sequence was used, not that your event used it. Concurrent requests sharing a
clientIdcan take each other's numbers. Read the sequence back and compare.
Implementing idempotent writes has the full retry loop that handles both.
Next
- Handling concurrency conflicts: the read-decide-write loop.
- Implementing idempotent writes: deriving
ClientSeqfrom the stream and resolving ambiguous retries. - Building a read model: how you query, since Celeriant is the write side.