Aggregates vs. embedded fields
In plain words
Section titled “In plain words”Kumiko has two ways to represent nested data. An embedded field is a fixed group of values stored with its parent, such as an order’s shipping address. It has no identity or event stream of its own.
An aggregate has its own record, history, and lifecycle. Use one when the nested object can be created, changed, queried, or tracked independently. An embedded list is for repeated values that still belong entirely to the parent, such as booking lines that are replaced with the order. If the nested data needs its own identity or history, model it as an aggregate.
shipping address on an order --[no life of its own]--> embedded fieldbooking lines on an order --[rows die with the document]--> embedded listline 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.
Embedded lists: several rows, still no life of its own
Section titled “Embedded lists: several rows, still no life of its own”When the same fixed shape repeats, a contact’s phone numbers, an order’s
booking lines, use createEmbeddedListField(schema). It is the list
counterpart of createEmbeddedField: every element is validated against the
same sub-schema, and the whole list is still one field on the parent row.
import { createEmbeddedListField, createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
export const contactEntity = createEntity({ fields: { name: createTextField({ required: true }), phoneNumbers: createEmbeddedListField({ label: { type: "text", required: true }, number: { type: "text", required: true }, }), },});The boundary is the same as for aggregates, applied one level down: use an embedded list when the rows come into being together with their head and stay immutable with it, booking lines, invoice positions. The moment a row begins or ends on its own (its own from/to dates, its own history, a status that changes independently of the document), it belongs in its own entity referencing the head, an embedded array is rewritten whole on every change and has no per-row history.
Mechanically it is still one jsonb column, NOT NULL, but the default is
[] instead of {}. required: true means “the key must be present AND
carry at least one row”, the same reading as multiSelect. Searchable
sub-fields index one value per row, so a searchable phoneNumbers.number
matches a contact if any row’s number matches.
Derived cells are server-owned
Section titled “Derived cells are server-owned”An embedded list may declare a derived map for arithmetic that belongs to
the row, such as amount = quantity × unitPrice. The list widget recomputes
the value for live display, but the write schema recomputes it again before
validation. The server overwrites a stale client value and fills an omitted
derived value when its sources are complete; an incomplete multiplication is
left unset. Treat the client value as a convenience, never as authority.
Unsupported structural fields stay read-only
Section titled “Unsupported structural fields stay read-only”An embedded field without a dedicated list editor, a jsonb field, and a
multiSelect field carry objects or arrays. When no dedicated editor is
available, the renderer shows an informational read-only Banner instead of
an editable text input. This avoids coercing values into "[object Object]"
or "a,b" and writing the damaged representation back to the entity. A list
declared with createEmbeddedListField is rendered from embeddedListCells;
derived-cell definitions are exposed as embeddedListDerived. Do not treat
the fallback Banner as an editor.
A complete, runnable example lives in the
samples/recipes/embedded
recipe, contact.phoneNumbers beside the single-valued address, including
per-row field access.
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 single value via
createEmbeddedField(a shipping address, a set of physical dimensions), or several like-shaped rows viacreateEmbeddedListField(booking lines, invoice positions) as long as the rows come into being with the head and stay immutable with it. - 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, or one of its rows, 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.