Skip to main content

Modeling aggregates and event types

Two decisions shape everything downstream: where the aggregate boundary sits, and how event types are numbered. Both are expensive to change once events are in the log.

What the engine gives you to work with

Design against what Celeriant actually enforces, not what you wish it enforced:

  • Order within an aggregate. Each write appends one batch and bumps the aggregate's version by one. A reader sees batches in version order.
  • A version guard per aggregate. expectedVersion rejects a write if anything else landed first. This is the only concurrency control there is.
  • Atomicity up to one shard. A write can name several aggregates and commit them all or none, but only if they route to the same shard. Anything wider is rejected with 9001.
  • Nothing across shards. No cross-shard transaction, no ordering between aggregates. Two aggregates on different shards are independent streams.

Drawing the aggregate boundary

An aggregate is the unit of ordering and the unit of the version guard. Pick it so that the common write is one conditional write to one aggregate.

  • Too big (one aggregate per tenant): every write in the tenant contends on one version, so unrelated work serialises and conflicts. Every projection that needs any of it replays all of it.
  • Too small (one logical thing split across aggregates): an invariant that should be one guarded write now spans aggregates, and you depend on them sharing a shard or on coordination outside the store.

Size also matters on the read side. A projection catches up per aggregate from its last version, so an aggregate that grows without bound makes every cold rebuild longer. Trim or snapshot long-lived ones; see Reading and replaying.

Plan co-location before you allocate ids

If two aggregates must sometimes change together (the two accounts in a transfer, a hold and an order for one customer), they must share a shard. --routing-rule picks which part of the key decides placement, and shard = id % num_shards on that Guid:

  • org_id: all of a tenant's aggregates share a shard. Right when invariants are per tenant. That tenant's throughput is one shard's.
  • aggregate_type_id: all aggregates of one type share a shard. Right when co-commits stay within a type.
  • aggregate_id (default): even spread. Co-commits work only between ids you allocated onto the same shard on purpose.

The rule, the shard count and the coordinator-shard setting are fixed when the cluster is initialised. The common trap: keep the default rule, mint ids with Guid.NewGuid(), and find out in production that no transfer ever lands. See Atomic multi-aggregate writes for the id allocation that fixes it.

Numbering event types

An event carries EventTypeMajor (required, non-zero) and EventTypeMinor (defaults to 0). A schema is registered per org, aggregate type, major and minor.

The server attaches no meaning to the pair beyond that. The convention in the client docs:

  • Minor bump for a backward-compatible change, such as an optional field old readers can ignore.
  • Major for a breaking change: a removed or retyped field.

Major also identifies what kind of event it is, and it is the only part IncludeEventTypes filters on. So a breaking change to OrderPlaced takes a new major number, and every reader that filters by type needs both the old and the new one. Allocate majors with gaps, or keep a registry, before the log fills up.

Schemas validate new writes only. Events already in the log never change; you evolve forward by appending the new shape and teaching readers both.

Keep payloads as facts

Model events as things that happened (OrderShipped), not commands or setters (SetStatus). A log of facts replays into any read model you decide you need later. A log of setters locks you into the one projection you imagined on day one.