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