Skip to main content

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
};
  • EventTypeMajor is required. The client rejects 0 with ArgumentException before sending.
  • EventTimestamp is required. It goes over the wire as unsigned epoch milliseconds, so an unset (DateTimeOffset.MinValue) or pre-1970 value is rejected client-side.
  • EventValue is opaque bytes. The server validates it only if a schema is registered for that org, aggregate type, major and minor.
  • ClientSeq is your sequence number. With idempotency enforced, the server tracks the highest one per (aggregate, clientId).
  • EventId is 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.