Skip to content

Embedded fields and lists

Keep owned, fixed-shape data with its parent entity instead of giving every nested value its own aggregate. This recipe covers both a single embedded object and an embedded list rendered by a declarative entityEdit screen.

  • Embedded objects, address and optional billingAddress are validated sub-schemas stored in the contact row.
  • Embedded lists, invoice lines repeat a fixed row shape without their own identity or event stream.
  • Structured cells, a line uses a reference, select, number, and money cell, with amount derived from quantity × unitPrice and a totals row.
  • Server-authoritative derived cells, the write schema recomputes derived values before validation, so a client cannot supply a stale amount; the edit view model exposes the definitions as embeddedListDerived.
  • Declarative editing, the invoice-edit screen uses createEmbeddedListField; its renderer receives embeddedListCells, so no custom screen component is required.

Use an embedded field or list when the nested data is created, changed, and deleted with its parent. Promote a row to its own entity when it needs an independent status, history, or handler.

The feature entry point is src/feature.ts. The embedded object and invoice line-item schemas live under src/entities/; integration tests cover schema validation, derived values, totals, references, and the declarative edit screen.


The feature entry point, embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):

// Embedded Object Sample
// Shows: Embedded objects stored as JSONB, searchable sub-fields,
// field access on sub-fields, required vs optional embedded objects
//
// Pattern: Address belongs 1:1 to contact — never shared, always read together.
// Use embedded when: data is owned by the parent entity, not referenced elsewhere.
//
// Tables:
// contact — with embedded address (required) and billingAddress (optional)
// address.street and address.city are searchable
// billingAddress.vatId has restricted field access
// product — minimal entity, only exists so `invoice.lines` has something
// to reference
// invoice — embedded list with select/reference/derived/totals metadata
// on its `lines` sub-fields (Issue #1835), plus a declarative
// entityEdit screen that renders the list end-to-end via
// EmbeddedListField, no custom-screen code required
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
import { contactEntity } from "./entities/contact";
import { invoiceEntity } from "./entities/invoice";
import { productEntity } from "./entities/product";
import { contactCreate } from "./handlers/contact-create.write";
import { contactDetail } from "./handlers/contact-detail.query";
export { contactEntity } from "./entities/contact";
export { invoiceEntity } from "./entities/invoice";
export { productEntity } from "./entities/product";
const adminWrite = { access: { roles: ["Admin"] } } as const;
const openRead = { access: { openToAll: true } } as const;
export const embeddedFeature = defineFeature("contacts", (r) => {
r.entity("contact", contactEntity);
r.writeHandler(contactCreate);
r.queryHandler(contactDetail);
r.crud("product", productEntity, { write: adminWrite, read: openRead });
r.crud("invoice", invoiceEntity, { write: adminWrite, read: openRead });
r.screen({
id: "invoice-edit",
type: "entityEdit",
entity: "invoice",
layout: {
sections: [{ columns: 1, fields: ["customer", "lines"] }],
},
});
});

📄 On GitHub: samples/recipes/embedded/src/feature.ts