Why links instead of cross-module foreign keys
The rule is short:Intra-domain foreign keys are fine. Cross-domain references must go through a link table.Inside one module you use ordinary Drizzle
.references() calls that create real Postgres foreign-key constraints. Across a module boundary you do not, for one concrete reason: a cross-package .references() forces schema co-installation. The consumer who installs one vertical but not the module that owns the referenced table cannot create that constraint, so the reference breaks the module-as-a-package boundary. A standalone vertical should not create a hard FK into another module’s canonical tables.
Links solve this by keeping the wiring explicit at the deployment layer rather than baked into either module’s schema:
- The source module exports a
LinkableDefinition(for examplepersonLinkable,productLinkable). It commits to being linkable, not to any particular partner. - The deployment declares the actual association with
defineLink(...)in itssrc/links/directory. - A generated pivot table materializes the association. Its lifecycle is owned by the deployment, not by either feature module.
Declaring a link
You declare a link by pairing the two linkable definitions in the deployment’ssrc/links/.
defineLink is a typed contract from @voyant-travel/core. The current link model is pair-first: a materialized link row carries the left-side id, the right-side id, and row identity plus lifecycle timestamps. It supports one-to-one, one-to-many, many-to-one, many-to-many, and read-only externally-owned traversal, which covers the current baseline.
You can scaffold the defineLink snippet with the CLI rather than writing it by hand. Each argument is a <module>.<entity> reference.
--right-list flag (and related list flags) shapes the cardinality and the convenience list accessors generated for the link.
The relationship policy: links stay pair-first
A link row is a pair-first association record, not a generic business object with arbitrary edge payload. This is a deliberate constraint, and respecting it keeps the link runtime from drifting into an ad-hoc graph-metadata system. When a relationship genuinely needs business fields of its own, the answer is not to enrich the link. It is to give the relationship an owner.| The relationship is… | Use |
|---|---|
| Only “A is linked to B”, mutable, no fields of its own | The pair-first link model (defineLink) |
| Carrying real product state (a role or label, a status, commercial config, lifecycle that affects behavior, or fields you must filter on) | An owned relationship record: a module-owned table with explicit columns, API shape, validation, and indexes |
Materializing link tables
A declared link is just a contract until you generate its table.voyant db sync-links materializes the link tables, and the --emit-drizzle form folds them into the migration history so Drizzle owns their diff and snapshot.
Emit Drizzle definitions
Run
voyant db sync-links --emit-drizzle. It writes drizzle.links.generated.ts, which the manifest’s schemas references.Generate the migration
Run
voyant db generate so Drizzle diffs the link tables into the regular migration history, rather than applying them out-of-band as raw DDL.voyant db doctor --fail-on-drift) checks that every declared link table is present in the latest snapshot, so a link you declared but never materialized fails CI.
A custom module’s schema should follow the same rule. When a deployment-local table in
src/modules/<name>/schema.ts needs to reference a framework entity, use a plain text("person_id") column plus a defineLink in src/links, not a hard cross-module FK. The deployment migration source is applied after the framework bundle, so a hard FK would technically work, but the link keeps the module relocatable and matches house style.Reading across modules: traversal and projections
Declaring a link tells the framework two entities can be related. Reading the combined graph at runtime is the query side. The default cross-module read model is application-layer traversal through the query graph (@voyant-travel/core/query and the link runtime), not precomputed tables:
- base records come from each module’s own fetchers,
- associations resolve through the shared link service,
- linked records hydrate through registered target fetchers,
- the final read shape is stitched in runtime code.
Next steps
Data models
Intra-module relations, money, indexes, and constraints.
Modules
Why modules stay isolated and how they compose.
Configuration
How declared links feed the manifest and migration bundle.
Command reference
voyant generate link and voyant db sync-links.