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.