The aggregate hierarchy
An aggregate is one event stream and the unit that ordering, optimistic concurrency and addressing all hang off. Every event belongs to exactly one aggregate.
The three-part key
An aggregate is addressed by three 128-bit ids:
org_id / aggregate_type_id / aggregate_id
- org is the top-level namespace, usually a tenant. Schemas are registered per org and aggregate type, so two orgs can use the same type id with different schemas. It is not an access-control boundary: an API key grants read-write or read-only on the whole server, not on one org.
- aggregate type groups streams of one kind: orders, accounts, devices.
- aggregate id names the individual stream.
All three are integers, not strings. The clients take UUIDs (Guid in .NET, Uuid::as_u128() in Rust) and send the UUID's 128-bit value. "Acme / Orders / order-4821" is a way to talk about a key; on the wire it is three numbers. Derive them deterministically from names with UUIDv5 if you need stable ids.
Ordering is per aggregate
Within an aggregate, writes are strictly ordered. Each committed batch gets the next aggregate version (1, 2, 3, ...) and each event the next event sequence number. No gaps, no reordering. Optimistic concurrency and reads build on that.
Under the hood each shard numbers its WAL entries with a contiguous wal_seq. That order covers everything on the shard, but it is not something you read by. There is no order across aggregates you can rely on, and you do not want one; a global sequence is a global bottleneck.
Aggregates map to shards
Each aggregate lives on one shard: a partition of the keyspace run by one executor pinned to a CPU core. Shard count defaults to the number of CPUs (--num-shards).
Placement is a plain modulo, not a hash. The routing rule (--routing-rule) picks one part of the key, and the shard is routing_id % num_shards. With --reserve-coordinator-shard, shard 0 is kept for cluster coordination and data goes to routing_id % (num_shards - 1) + 1. num_shards, the routing rule and the reserve flag are written to server_meta.toml on first start; a node refuses to start if they change later.
% over an id you control is deliberate. A hash smears aggregates evenly and you could never put two specific aggregates on the same shard on purpose. With % you can. aggregate_id = 1000 and aggregate_id = 1004 on a 4-shard cluster both land on shard 0. As UUIDs those are 00000000-0000-0000-0000-0000000003e8 and ...03ec. Random UUIDs (v4, v5, v7) spread evenly.
Co-location is the only way to write more than one aggregate atomically. A write whose aggregates route to different shards is rejected before anything is appended (ShardRoutingMultipleShards, error 9001).
The three rules:
org_id: every aggregate in an org shares a shard. Per-tenant invariants are easy; one tenant's writes never spread across cores.aggregate_type_id: every aggregate of a type shares a shard. For co-commits within a type.aggregate_id(default): even spread, full parallelism. Multi-aggregate atomic writes only work between ids you placed together (id_a % shards == id_b % shards). Plan the id allocation or those writes fail.
Cardinality is not your problem
Model one stream per whatever your domain has: per user, per device, per order, per match. There is no cap on the number of aggregates. Memory is a fixed per-shard budget of LRU caches, so it tracks the hot working set, not the total count. See Durability and safety, and Modeling aggregates for choosing the boundary.