4. Relationships and Projections
A reference field owns the stored value. A relationship owns graph meaning.
An area connects to its category; a projection selects the details a screen needs.
abstract final class AreaFields {
static const categoryId = ReferenceIdField(
'area_category_id',
title: 'Area Category',
indexed: true,
db: FieldDb(
queryPatterns: [QueryPattern.filter, QueryPattern.aggregate],
),
ui: FieldUI(
widget: UIFieldControl.referencePicker,
filterable: true,
),
reference: UIReferenceHint(
target: EntityRef(module: ModuleRef('ops'), name: 'area_category'),
),
);
}
abstract final class AreaRels {
static const category = Relationship(
name: 'category',
title: 'Area Category',
target: EntityRef(module: ModuleRef('ops'), name: 'area_category'),
cardinality: RelationshipCardinality.hasOne,
kind: RelationshipKind.classifiedBy,
field: AreaFields.categoryId,
required: false,
cascade: CascadeRule.restrict,
embed: EmbedHint.lazy,
);
}Declare both on the owning facet:
Facet(
name: 'hierarchy',
fields: const [AreaFields.categoryId],
relationships: const [AreaRels.category],
)Relationship cardinality
| Cardinality | Meaning |
|---|---|
hasOne | This record points to one target. The declaring side holds the foreign key. |
hasMany | Multiple targets point back to this record. Usually derived as the reverse of hasOne. |
manyToMany | A set-to-set relationship without extra membership state. |
Relationship kind
| Kind | Meaning |
|---|---|
maps | This mapping record ties that end in. |
locatedIn | This occupies that place. |
classifiedBy | This instance is classified by that type. |
partOf | This is a part of that. |
participatesIn | This takes part in that event. |
appliesTo | This quality or measurement applies to that bearer. |
specifies | This spec or plan specifies those events. |
about | This information is about that thing. |
realizes | This event carries out that function or spec. |
If a many-to-many link has dates, status, role, scope, provenance, or actions, model the link as a mapping entity. A role assignment is data, not an anonymous join table.
Physical and logical edges
RelationshipStorage.foreignKey lowers to a Postgres foreign key. RelationshipStorage.logical participates in the graph without claiming a column or constraint. Use logical edges for:
- remote or externally owned records;
- polymorphic targets;
- mapping-derived inverses;
- cross-module links whose storage is owned elsewhere.
Polymorphic logical relationships may share an ID field and select targets through a discriminator field/value.
Cascade and embed are separate
CascadeRule answers what happens when the target disappears. EmbedHint answers how a read projection should load the target.
Never choose cascade behavior to obtain a UI result.
Flatten related values with RelatedField
RelatedField<String>(
'area_category_name',
via: AreaRels.category,
source: AreaCategoryFields.name,
ui: const FieldUI(
filterable: true,
sortable: true,
),
)The DB/API projection can expose a category label without persisting a duplicate column on area. Related fields may compose through direct neighbors, but the chain must terminate in a stored field and must remain acyclic.
UI rendering follows semantics
- Editor: reference picker, not UUID text input.
- Summary: linked entity label, not raw ID.
- Table: projected label when declared.
- Relationship tab: collections for one-to-many or many-to-many edges.
- Navigation: internal right arrow; reserve the up-right arrow for external links.
The UI does not infer a relationship from a field ending in _id. Declare the reference and the relationship explicitly.
Cross-module relationships
Use a structured target identity such as:
const directory = ModuleRef('directory');
const user = EntityRef(module: directory, name: 'user');The consuming module declares dependencies: [directory], and Directory exports EntityExport(entity: user). Wire strings such as directory.user are derived at protocol/storage boundaries. Module boundaries are enforced, not bypassed with authored dotted strings.
Model occurrences instead of shortcut genealogy edges
When a relationship itself represents an auditable business occurrence, make that occurrence an entity. A material transformation can use ordinary mapping entities for typed input, output, and resource participation, including quantities, equipment, actors, revision pins, actions, and evidence.
Blueprint does not define a special direct lot-to-lot edge or a second transaction engine. The occurrence remains the auditable fact.
Checkpoint
Model directory.user ↔ access.role through a rich role_assignment mapping entity. Include:
- user and role references;
- tenant/site scope;
- effective-from/effective-until;
- assignment status;
- the actor who assigned it.
Then continue to Actions, rules, and lifecycles.