Skip to content

4. Relationships and Projections

A reference field owns the stored value. A relationship owns graph meaning.

dart
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:

dart
Facet(
  name: 'hierarchy',
  fields: const [AreaFields.categoryId],
  relationships: const [AreaRels.category],
)

Relationship kinds

KindMeaning
belongsToThis record points to one target.
hasOneOne target is associated with this record.
hasManyMultiple targets point back to this record.
manyToManyA 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.

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

Blue is the Vyuh Blueprint documentation surface.