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 objectsaddress 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