Appending events
Every write appends one batch to each aggregate it names, and bumps that aggregate's version by one. The events inside a batch keep the order you sent them in.
One event
using Celeriant.Client;
using Celeriant.Client.Requests;
using Celeriant.Client.Serialization;
await using var pool = new CeleriantPool(new CeleriantPoolOptions
{
Address = "localhost:10000",
});
var key = new AggregateKey(orgId, orderTypeId, orderId); // three Guids
var response = await pool.WriteAsync(
key,
[AggregateEventExtensions.Create(1, new OrderPlaced("A-1", 2), JsonEventSerializer.Default)],
clientId: writerId);
long version = response.MaxAggregateVersion!.Value; // the aggregate's version after this write
clientId is required and names the writer. Keep it stable per writer across restarts; a fresh Guid.NewGuid() per call silently disables idempotency.
AggregateEventExtensions.Create serialises the payload through any IEventSerializer and fills the rest: clientSeq defaults to 1, eventTypeMinor to 0, the timestamp to DateTimeOffset.UtcNow.
The overload's allowCreate defaults to true, so the first write creates the aggregate. Pass allowCreate: false to require that it already exists; a missing aggregate then throws AggregateNotFoundException (2005).
MaxAggregateVersion on the response is the version your next conditional write guards on. It is set only for single-aggregate writes.
What the fields mean
Building an AggregateEvent by hand shows every field you own:
using System.Text;
using Celeriant.Client.Responses;
var evt = new AggregateEvent
{
ClientSeq = 1,
EventTypeMajor = 1,
EventTypeMinor = 0,
EventTimestamp = DateTimeOffset.UtcNow,
EventValue = Encoding.UTF8.GetBytes("""{ "sku": "A-1", "qty": 2 }"""),
EventId = requestId, // optional
};
EventTypeMajoris required. The client rejects 0 withArgumentExceptionbefore sending.EventTimestampis required. It goes over the wire as unsigned epoch milliseconds, so an unset (DateTimeOffset.MinValue) or pre-1970 value is rejected client-side.EventValueis opaque bytes. The server validates it only if a schema is registered for that org, aggregate type, major and minor.ClientSeqis your sequence number. With idempotency enforced, the server tracks the highest one per(aggregate, clientId).EventIdis an optional id of your own, stored and returned on read. Use it to recognise your own events after an ambiguous failure.
The server fills in the rest on commit: EventSeq per event, and AggregateVersion, ClientId and ServerTimestamp on the batch.
Several events at once
Pass more than one event. They land as one batch at one version, all or nothing:
await pool.WriteAsync(key,
[
AggregateEventExtensions.Create(1, new OrderPlaced("A-1", 2), JsonEventSerializer.Default, clientSeq: 1),
AggregateEventExtensions.Create(2, new LineAdded("B-7", 1), JsonEventSerializer.Default, clientSeq: 2),
],
clientId: writerId);
Give each event its own increasing clientSeq. Left at the default, every Create call produces seq 1. The server does not dedupe within one write; the client catches the collision with ArgumentException when idempotency is enforced, and lets it through when it is not.
Make it conditional, make it idempotent
Two more parameters turn the safety on:
await pool.WriteAsync(key, events,
clientId: writerId,
expectedVersion: version, // commit only if the aggregate is still at this version
enforceClientIdempotency: true); // reject a ClientSeq at or below the last one accepted
expectedVersion: 0 means the aggregate must not exist yet. A stale guard throws WriteOccException (2003); a replayed sequence throws IdempotencyViolationException (2002). See Handling concurrency conflicts and Implementing idempotent writes.