Custom Fields
Operators can add fields to core entities at deployment time — as data, never a
schema migration or code deploy.
The Custom Fields Layer is a generic, entity-agnostic module that reuses the
design of the catalog's product_attributes (typed definitions, option lists,
per-locale labels) as a cross-cutting capability — without embedding any
host-specific concern in its core.
Supported host entities
Custom fields are available on Category, Order, Organization, CustomerAccount,
QuoteRequest, and Product. Each host entity carries an additive JSONB value
bag ({ [definitionKey]: value }); the host owns that column and its writes.
For most hosts the bag is the customFieldValues column; the product host binds
to the pre-existing products.attribute_values column instead (see the value-probe
binding below). Product attributes converged onto this layer as an adapter, not a
rewrite: the generic layer owns each attribute's identity
(key, per-locale labels, value type, required, options), while the catalog keeps
its behaviour flags on its own 1:1 extension table (product_attributes) and
remains the only write surface (see "Host-managed entity types" below).
How it works
- Definitions are data. A field is a row in
custom_field_definitions(@GlobalEntity): anentityType, akey, a localizedlabel(with default fallback), avalueType, arequiredflag, and — for select types — a list ofcustom_field_optionsrows. Adding, editing, or removing a field is a data change through the admin surface; no migration, no deploy. - Six value types:
text,number,boolean,date,select(one option),multiselect(many options). - Validated on every write. When a host record is created or edited, the
generic layer validates the incoming bag against the definitions and rejects
per field on any violation (wrong type, missing required, unknown option,
out of range) with a field-specific error. The host then persists the values
in its own
customFieldValuescolumn. - Read alongside native fields. Values are returned wherever the record is read (admin detail + the relevant API responses).
Division of responsibility
The generic layer owns definitions + validation; the host owns **persistence
- audit**:
- The custom-fields module never writes into a host table and never audits a host write. The host persists its own record and audits its own write, calling the generic layer only to validate values and read definitions — so module boundaries stay intact and there is no double-auditing.
- Definition / option mutations are themselves sensitive writes and run through
the Command Bus; the module registers its permissions
(
custom_fields:read,custom_fields:write) and participates in module lifecycle.
Host-managed entity types (managedBy)
The entity registry (custom-field-registry.ts) supports a generic
managedBy capability on a host entry: { moduleId, labelKey, route }. When
set, that host's definitions are authored by the named module through its own
surface, and the generic admin surface becomes read-only for that entity
type: POST / PATCH / DELETE on /api/v1/admin/custom-fields/definitions*
are refused with 409 host_managed, and the admin Custom Fields page renders
the entity read-only with a notice linking to the managing surface. The refusal
is registry-driven — the generic core checks only for the marker's presence,
never which module manages (no host identifier in core logic).
The product host is the first user: managedBy points at the catalog module's
/catalog/attributes page, which stays the single write surface for product
attributes.
Value-probe binding ({table, column})
The generic layer's change guards (hasStoredValues, isOptionInUse — backing
value_type_locked and option_in_use refusals) probe the host's value bag with
read-only JSONB introspection. The probe target is a per-entity storage
binding { table, column }: most hosts bind to their custom_field_values
column, while the product host binds to products.attribute_values. The binding
is purely storage metadata — no host logic lives in the generic module.
Transactional apply seam (host commands)
Host modules that manage their entity's definitions (per managedBy) mutate
them through the exported CustomFieldDefinitionApplyApi
(applyCreate / applyUpdate / applyDelete + the option variants). Each
apply function runs on a caller-provided EntityManager, enforces the generic
invariants (duplicate key, options rules, value-type lock, option-in-use), and
performs no audit and no cache publish — the calling host command owns the
transaction, writes the single audit row, and publishes the definitions-cache
invalidation after commit. This keeps custom_fields the sole writer of its
tables while letting a host command keep its definition + its own
rows consistent atomically (the Command Bus does not nest).
Convergence note: product attributes
Convergence happens as an adapter, not a rewrite: product attributes became
Custom Field definitions on the product host, with the catalog keeping a 1:1
extension row (product_attributes) for its behaviour flags and presentation
refinements. The generic core gained only the three entity-agnostic seams
described above (the product registry entry with managedBy, the
{table, column} probe binding, and the apply seam) — zero catalog logic. See
the catalog module page
for the catalog-side view and the migration outcome.
Tenant scope (inherited)
Custom-field values live in host columns, so they inherit the host record's tenant scope for free: values on an org-owned Order / Organization / Customer / QuoteRequest are confined to the same tenant as the host record — the generic layer adds no new scoping path. Definitions belong to the platform (or, if scoped, to an organization) consistently with how the host entity is scoped.
Host-capability flags (extension point)
A field may carry an opaque config object — host-capability flags such as
"filterable" or "search-indexed". The generic core stores but never reads it
for meaning: the host module interprets the flag through its own documented
extension point (e.g. Category filtering picks up a filterable flag; Product
capabilities stay on product_attributes). This keeps catalog-only concerns out
of the generic core — the exact rot this separation exists to prevent, where
product_attributes accreted isVariantAxis / isPromoRule / filterPosition
until it was no longer reusable.
Data retention
Deleting a field definition does not purge stored values: stale values are retained dormant (not surfaced, not read) rather than eagerly deleted, so a mis-deletion is recoverable and host writes never cascade into data loss.
Adding a custom field
Define it from the admin custom-field surface for the target entity type (key + localized label + value type + required + options). It then renders on every record of that type and its value round-trips through the host's create/edit/read paths — no code change.