Embeddables
An embeddable is a named, entity-shaped group of fields with no table of its own, a field that references one stores the whole structure inside a single column. The column type is not implied by the embeddable, see Storage.
Use an embeddable when a structure repeats across entities and you never need to query, index or join its parts: an address block, a money amount, a contact block.
Declaring one
Section titled “Declaring one”embeddables: - name: pincode description: Postal code block fields: - name: code type: string required: true
- name: address description: Reusable address structure fields: - name: line1 type: string required: true - name: line2 type: string nullable: true - name: city type: string required: true - name: country type: string required: true - name: pincode type: pincode| Key | Type | Required | Meaning |
|---|---|---|---|
embeddables[].name | string | yes | The name a field references, by type: or embeddable:. |
embeddables[].description | string | no | Free text. Kept on the definition; not exported anywhere. |
embeddables[].fields[] | list | yes | Field definitions, same YAML shape as entity fields. |
A sub-field with no name: is dropped and the load continues. No error, no diagnostic.
Embeddables are declared per datastore, alongside entities:, and can sit in any *.yaml or *.yml
file under the datastore folder.
Attaching one to an entity
Section titled “Attaching one to an entity”Two spellings, one result:
entities: - name: person inherits: kisai.id fields: - name: firstname type: string - name: home type: address # the embeddable's name used as the field type - name: work embeddable: address # explicit key nullable: true| Key | Effect |
|---|---|
fields[].type | An unrecognised type name is kept as a type reference, an embeddable or a custom type. |
fields[].embeddable | Overwrites the field’s type name with the named embeddable. It does not change the resolved base type: with both keys present the column type still comes from type:. |
fields[].subfields | Parsed by the YAML shape and then dropped. Declaring sub-fields inline does nothing. |
Nothing checks that the name resolves, a field typed addres loads cleanly and becomes an ordinary
string column.
The parent column is NOT NULL unless the parent field carries nullable: true. required: and
nullable: on the sub-fields have no effect on the emitted DDL.
Storage
Section titled “Storage”The embeddable declaration alone does not produce a JSON column. Nothing in the type resolver maps an embeddable name to a JSON type, the field falls through as an unresolved type reference and is narrowed to string.
| Field declaration | PostgreSQL | SQLite | DuckDB | ClickHouse |
|---|---|---|---|---|
type: <embeddable> on its own | VARCHAR(255) | TEXT | VARCHAR(255) | String |
type: <embeddable> plus a matching customtypes: entry | JSONB | TEXT | JSON | String |
type: jsonb (aliases json, object) | JSONB | TEXT | JSON | String |
To get a JSON column, declare a custom type of the same name. kind: defaults to composite, and a
composite type resolves to the backend’s JSON type:
embeddables: - name: address fields: - name: line1 type: string - name: city type: string
customtypes: - name: addressThe customtypes: entry decides the column type; the embeddables: entry documents the shape. See
Types for the full custom-type vocabulary.
A structured defaultvalue:, a YAML map or list, produces no DEFAULT clause at all: the DDL
writer treats map, slice and struct defaults as non-literals and skips them, a string
defaultvalue: such as "{}" is emitted as a bare SQL literal (DEFAULT '{}'); the explicit
::jsonb cast is added only when the field’s resolved type is jsonb or array, which an
embeddable-typed field is not.
What runs inside an embeddable
Section titled “What runs inside an embeddable”An embeddable is not a nested entity. The engine treats the column as one opaque value: it is written whole and read whole. Sub-field declarations parse, and almost none of them do anything.
| Declared on a sub-field | Effect at runtime |
|---|---|
validations: | None. The validation hook walks the entity’s own fields only. |
defaultvalue: | None. Defaults are applied per entity field. |
computed: / compute: | None. Computed fields are resolved per entity field. |
references: | None. No foreign key is emitted for a sub-field. |
enum: | None. A sub-field creates no reference to the enum’s table; the embedded document is one JSON column. |
compliances: | Read by a masking hook that data.svc does not register. No effect on responses. |
type: | Recorded on the definition; used by nothing at runtime. |
Compliance annotations behave the same way for the block’s own service: masking, redaction and
hiding of embeddable sub-fields is implemented, but the hook that applies it is not in the chain that
data.svc builds. Do not rely on an embeddable to hide a sub-field. Use
access rules on the entity instead.
Nesting
Section titled “Nesting”An embeddable may name another embeddable as a sub-field type, as address.pincode does above. The
reference is recorded on the definition and nothing resolves it, nesting is documentation, not
enforcement, and it has no effect on the emitted column.
Querying into one
Section titled “Querying into one”The value is a single column. Filters in the query DSL address that column, not its contents.
| Expression | Result |
|---|---|
home = <value>, $eq, $ne | Compares the whole column value. |
$contains with a JSON string needle | Compiles to "home" @> $1 on PostgreSQL: containment against the JSON value. |
$contains with a JSON object needle | Rejected by the decoder: invalid where clause: field "home": unknown value type. |
home.city = "Pune" | Compiles to WHERE "home.city" = $1, the whole dotted path is quoted as one identifier, and PostgreSQL rejects the column. |
The containment form is the only route into the stored structure, and only against a JSON-typed column:
{ "from": "person", "where": { "home": { "$contains": "{\"city\":\"Pune\"}" } }}POST /rest/:entity/search takes an equality-only body filter ({"filter": {"field": "value"}}), so
it cannot reach sub-fields either, the full HTTP surface is at Data API reference.
Reading a value back
Section titled “Reading a value back”The value comes back in whatever shape the driver produces for that column:
- a parsed object,
- raw JSON text,
- on PostgreSQL’s JSONB scan path, a one-element array wrapping the object.
The data is preserved in every case. A client that reads embeddables must tolerate all three.
Merging across files
Section titled “Merging across files”Embeddables merge by name across every *.yaml / *.yml under the datastore folder, walked
recursively in lexical order. The last file wins, and it replaces the definition wholesale: no field-level
merge, and no diagnostic. This differs from entities and traits, which keep the first definition and
log the duplicate. Renaming a file can change which definition survives.
Generated schema documents
Section titled “Generated schema documents”The schema exports describe an embeddable-typed field by its underlying type, a plain string, whether or not a composite custom type gives it a JSON column. The embeddable is not emitted as a named object type in JSON Schema, Zod, CUE, OpenAPI or the GraphQL SDL.
// GET /schema/zod/personexport const PersonSchema = z.object({ id: z.string().ulid(), home: z.string()...});Generated clients therefore type the field as a string. Treat the embeddable’s shape as a contract you enforce yourself.