Aggregates vs. embedded fields
In plain words
Section titled “In plain words”There are two different ways to put one piece of information “inside” another in Kumiko, and they solve completely different problems, even though both look like nesting from the outside. The first way is an embedded field: a small, fixed group of values that always belongs to one record and is always saved together with it — like a shipping address on an order, which has a street, a city, and a postal code, but never exists on its own and never gets edited separately from the order. The second way is an aggregate, which in Kumiko means giving something its own record with its own history — its own diary of changes — because it has a life of its own: it can be created, changed, and looked up independently of whatever first caused it to exist. A shipping address is naturally an embedded field, because nobody asks “show me the history of this address” separately from the order. A line item on an invoice, by contrast, is usually its own aggregate, because it might get shipped, returned, or disputed on its own timeline, with its own status, independent of the invoice as a whole. The rule of thumb: if the nested thing has no identity, no history, and no independent lifecycle of its own — it is decoration on the parent — embed it. If it does, give it its own aggregate.
shipping address on an order --[no life of its own]--> embedded fieldline item on an invoice --[its own status, own history]--> its own aggregateWhy it exists
Section titled “Why it exists”Kumiko is event-sourced: every write to an entity is recorded on that entity’s
aggregate stream, identified as <tenantId>:<entityType>:<entityId>. That
stream is the append-only diary this framework is built around — see
Events and projections for the full picture. Anything
that has its own stream can be time-traveled, replayed, and queried at a point
in the past, independently of everything else. Anything that does not have its
own stream — because it lives as a field on someone else’s entity — inherits
that entity’s lifecycle completely: it is created when the parent is created,
changed when the parent changes, and deleted when the parent is deleted. There
is no in-between.
Embedded fields: nesting with no life of its own
Section titled “Embedded fields: nesting with no life of its own”An embedded field is a typed sub-schema living inside one field of an entity — not a free-form blob, a fixed shape with its own field types, checked at write-time the same way top-level fields are.
import { createEmbeddedField, createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
export const orderEntity = createEntity({ fields: { reference: createTextField({ required: true }), shippingAddress: createEmbeddedField({ street: { type: "text", required: true }, city: { type: "text", required: true }, postalCode: { type: "text", required: true }, }), },});shippingAddress has no aggregate ID, no event stream, no independent write
handler. It is validated, stored, and returned as part of order’s row — one
Postgres jsonb column, NOT NULL, defaulting to {}. You cannot query “all
shipping addresses in Berlin” as a first-class thing; you query orders and read
the field. That is the trade-off: embedding buys you simplicity exactly because
it refuses to give the nested data an identity of its own.
Embedded fields are close cousins of jsonb fields (createJsonbField) — both
store as Postgres jsonb, both default to {}. The difference is that
embedded enforces the sub-schema you declared (text, number, boolean,
date sub-fields only) at write-time, while jsonb accepts any JSON-shaped
value with no validation. Use embedded when you know the shape in advance —
an address, a set of dimensions, a currency-and-amount pair that is not quite a
money field. Use jsonb when the shape is genuinely dynamic — tenant-defined
extension data, provider-dependent AI metadata.
Aggregates: nesting with its own lifecycle
Section titled “Aggregates: nesting with its own lifecycle”An aggregate is not a field type — it is a full entity, with its own
r.entity(...) declaration, its own aggregate stream, and (usually) its own
relation back to the parent.
import { createEntity, createReferenceField, createMoneyField, createTextField, createSelectField } from "@cosmicdrift/kumiko-framework/engine";
export const invoiceLineItemEntity = createEntity({ fields: { invoice: createReferenceField({ entity: "invoice", required: true }), description: createTextField({ required: true }), amount: createMoneyField({ required: true }), status: createSelectField({ options: ["pending", "shipped", "disputed"] as const }), },});
r.entity("invoiceLineItem", invoiceLineItemEntity);r.relation("invoice", "hasMany", "invoiceLineItem");Each invoiceLineItem gets its own aggregate stream
(<tenantId>:invoiceLineItem:<id>), its own created/updated/deleted
events, and can be written to, queried, and replayed independently of the
invoice it belongs to. ctx.loadAggregate(aggregateId, tenantId) and
ctx.loadAggregateAsOf(...) (used by write handlers and time-travel queries)
operate one stream at a time — that only makes sense for things that have a
stream of their own.
The decision rule
Section titled “The decision rule”Ask one question: does this nested thing ever change, get queried, or get disputed on its own timeline, separately from its parent?
- No — it is created with the parent, changes only when the parent changes, and is deleted with the parent. Embed it. A shipping address, a set of physical dimensions, a currency preference block.
- Yes — it has its own status, its own history worth replaying, or other entities need to reference it directly. Give it its own aggregate. A line item that can be individually shipped or disputed, a comment that can be edited after the fact, a task that moves through its own state machine.
When in doubt, start embedded — it is one field, reversible with a migration — and promote to a full aggregate the moment you catch yourself wanting to query, version, or reference the nested thing independently of its parent.
See also
Section titled “See also”- Events and projections — what an aggregate stream actually is and what gets written to it.
- Schemas as data — how entity field definitions,
including
embedded, become tables, validation, and UI. - Schema evolution — upcasters and snapshots, relevant once an aggregate’s shape needs to change.