Skip to content

Aggregates vs. embedded fields

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 field
booking lines on an order --[rows die with the document]--> embedded list
line item on an invoice --[its own status, own history]--> its own aggregate

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.

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.

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 via createEmbeddedListField (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.

  • 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.