The Attribute Engine
This is the single most important subsystem to understand before touching
Products, Batches, Arrivals, Issuing, Orders, or Reports. It's how HLMIS
stays generic: instead of a named column per deployment-specific field
(VVMStage, funder, hmisCode, …), an admin defines what an entity
tracks, and values are stored in a JSON bag.
Why it exists
VLMIS hardcoded Rwanda's vaccine supply chain into its schema — every
Product, Batch, and StockMovement carried VVM stage, funder, partner
name, HMIS code whether a deployment needed them or not. The attribute
engine replaces that: Rwanda's fields become the first seeded example of
a generic mechanism, not the reason the mechanism doesn't exist for anyone
else.
The guiding rule:
ADAPTER_MODE may decide which attribute
definitions are seeded and active — it must never decide what columns
physically exist. A Batch table has the same real columns for Rwanda and
for a laptop distributor; only the attribute-definition rows differ.
The two halves
AttributeDefinition — the schema
One row per configurable field.
src/core/attribute-definitions/entities/attribute-definition.entity.ts:
| Field | Meaning |
|---|---|
entityType | one of 10: Product, Batch, StockMovement, IssuedProducts, OrderDetails, Inventory, Issuing, Order, Consumer, Stock |
action | null = applies to every action on that entity type; a value (e.g. "Wastage") narrows it to one action — see below |
key | the property name inside attributeValues (e.g. wastage_reason) |
label | display name; the frontend renders this, not key |
dataType | text | number | boolean | date | select |
options | string[] for select, else null |
required / displayOrder / isActive | self-explanatory; isActive: false is the soft-delete |
isSystem | set only by seed migrations — protects definitions that backend logic reads by exact key |
unique | enforce one value per entity type — checked by ProductsImportService only, not the single-record API paths |
A DB-level unique index on (entityType, key) — not just frontend slug
validation.
attributeValues: jsonb — the values
A nullable jsonb column (default {}) on each participating entity,
added by its own migration (AddAttributeValuesTo<Entity>). Shape is
{ [key]: value }.
Action scoping
Some entities do several different things (an arrival vs. an issue vs. a
wastage entry are all StockMovements). A definition with action: null
applies to all of them; action: "Wastage" applies only to that one.
Endpoints declare which (entityType, action) pairs they perform with the
@AttributeAction(...) decorator
(src/core/attribute-definitions/attribute-action.decorator.ts). A
boot-time registry collects these, and GET /api/attribute-definitions/for-endpoint / the actions endpoints let the
frontend ask "what fields apply here?". assertValidAction() rejects a
definition whose action isn't a registered one.
Validation on write
validateAttributeValues()
(src/core/attribute-definitions/attribute-values.validator.ts) checks
submitted values against the currently active definitions for that
entity/action: required keys present, select values within options,
correct primitive per dataType. It throws BadRequestException with a
list of errors. Services call it before persisting; a global
AttributeActionInterceptor is the no-op-unless-tagged plumbing that
resolves the same metadata per request.
Seeding and ADAPTER_MODE
Vaccine/DHIS2 fields are seeded as isSystem: true definitions by
migrations (SeedProductLegacyFieldAttributes,
SeedArrivalAttributeDefinitions, SeedIssuedProductsVvmStageAttribute,
…). 1789600000001-SetAttributeActivationByAdapterMode then sets each
one's isActive from ADAPTER_MODE at migration time — so a generic
deployment boots with VVM/HMIS/funder fields inactive (present as rows,
never rendered), and a DHIS2 deployment boots with them active. Newer seed
migrations check ADAPTER_MODE before inserting instead — that's the
preferred pattern going forward.
Known asymmetry: deactivate() refuses for isSystem definitions,
reactivate() has no matching guard — a one-way door, tracked in the
backend's genericity-gaps doc.
Legacy column sync
Some named columns were backfilled into attributeValues and then
dropped (see Migrations & Schema).
Product's legacy columns are the exception — still read directly in a few
places, so syncLegacyColumns()
(src/core/products/legacy-attribute-column-sync.ts) mirrors
attributeValues back into them on write via LEGACY_ATTRIBUTE_COLUMN_MAP.
There's no such shim for StockMovement / Batch / OrderDetails — their
old columns are simply gone.
Adding a new attribute-driven field
You usually don't write code — an admin creates the definition through
POST /api/attribute-definitions and it's live. You only touch the backend
when:
- a new entity type needs
attributeValues→ add the column via migration, add it toATTRIBUTE_ENTITY_TYPES - an endpoint needs to accept/validate values for an action it doesn't yet
declare → add
@AttributeAction(...), callvalidateAttributeValues() - backend logic must read a specific key → seed it as
isSystemso it can't be renamed out from under the code