4. Relationships and Projections
A reference field owns the stored value. A relationship owns graph meaning.
abstract final class AreaFields {
static const categoryId = UuidField(
'area_category_id',
title: 'Area Category',
indexed: true,
db: FieldDb(
queryPatterns: [QueryPattern.filter, QueryPattern.aggregate],
),
ui: FieldUI(
widget: UIFieldControl.referencePicker,
filterable: true,
),
reference: UIReferenceHint(targetEntity: 'ops.area_category'),
);
}
abstract final class AreaRels {
static const category = Relationship(
name: 'category',
title: 'Area Category',
targetEntity: 'area_category',
kind: RelationshipKind.belongsTo,
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 kinds
| Kind | Meaning |
|---|---|
belongsTo | This record points to one target. |
hasOne | One target is associated with this record. |
hasMany | Multiple targets point back to this record. |
manyToMany | A set-to-set relationship without extra membership state. |
If a many-to-many link has dates, status, role, scope, provenance, or actions, model the link as an association 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;
- association-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 stable target type such as directory.user. The consuming module must declare dependsOn: ['directory'], and the source module must export the target entity. Module boundaries are enforced, not bypassed with strings.
Checkpoint
Model directory.user ↔ access.role through a rich role_assignment association 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.