Press ESC to exit fullscreen
📖 Lesson ⏱️ 75 minutes

Link Types and Relationships

Model the verbs between objects: one-to-many, many-to-many, intersection links, and cardinality

If object types are the nouns of your ontology, link types are the verbs that connect them.

A Customer does not exist in isolation. They place orders. They signed contracts. They belong to a region. Each of those verbs is a link type.

A link type definition includes:

  • The two object types it connects.
  • The direction(s) the link is navigable.
  • The cardinality on each end.
  • A display name for each direction (placed orders / placed by).
  • Optional link properties — facts about the relationship itself, not the entities.

A first example

linkType: customerPlacedOrder
fromObjectType: Customer
toObjectType:   Order
fromDisplayName: placed orders
toDisplayName:   placed by
cardinality:    oneToMany    # one Customer → many Orders, each Order → one Customer

Now customer.placedOrders gives you all the Orders, and order.placedBy gives you the Customer.

Cardinality

Cardinality describes how many objects can be linked on each side. The standard cases:

One-to-one (1:1)

A Customer has exactly one CustomerAccount, and an Account belongs to exactly one Customer.

cardinality: oneToOne

Rare in practice. Often a sign the two should be a single object type. Use it deliberately — common legitimate use: separating sensitive data behind tighter security.

One-to-many (1:N)

The most common case. Customer → Orders, Order → OrderLines, Hub → Shipments.

cardinality: oneToMany

The “many” side stores the foreign key in its backing datasource (Order.customerId).

Many-to-many (N:M)

A Driver can operate many Vehicles, and a Vehicle can be operated by many Drivers.

linkType: driverOperatesVehicle
fromObjectType: Driver
toObjectType:   Vehicle
cardinality:    manyToMany

Many-to-many is where models start to wobble. The big question: does the relationship itself have properties?

  • “Alice drives the truck” → no extra facts. A pure many-to-many link is fine.
  • “Alice has been certified to drive this truck since Jan 2024 and her certification expires in Dec 2026” → those facts belong on the relationship.

When the relationship carries facts, promote it to an intersection object type:

objectType: DriverCertification
properties:
  - { name: driverCertificationId, type: string, primaryKey: true }
  - { name: certifiedAt, type: timestamp }
  - { name: expiresAt,   type: timestamp }
  - { name: status, type: enum<CertificationStatus> }

# Now two one-to-many links:
linkType: driverCertificationOfDriver
fromObjectType: Driver
toObjectType:   DriverCertification
cardinality:    oneToMany

linkType: driverCertificationForVehicle
fromObjectType: Vehicle
toObjectType:   DriverCertification
cardinality:    oneToMany

The DriverCertification is the intersection — it is a real entity in your business, not a hidden join table.

Direction and navigability

Some platforms support directional vs bidirectional links. A link type definition typically specifies both display names:

  • Customer → placed orders → Order (forward)
  • Order → placed by → Customer (reverse)

Both directions should be navigable in code:

const orders = await customer.placedOrders.load();
const buyer  = await order.placedBy.load();

If a direction is meaningless or never used, you can omit a display name and skip the reverse navigation — but be cautious. Today’s “never used” is tomorrow’s required query.

Sometimes a relationship can be modeled either as a link type or as a foreign-key property on the object. When to choose which:

Use a link type whenUse a property when
You want graph traversal in queriesThe target is referenced but not navigated
The target is a first-class entity in the ontologyThe target is a value, not an entity (currency code)
You will need to traverse links repeatedly (customer → orders → lines)The reference is for join tracking only
Either side may need link properties laterThe relationship is intrinsic to the value

Reasonable defaults:

  • Order.customerId: stringalso register a link type Order → placedBy → Customer. Both are useful.
  • Address.country: enum<CountryCode> — just a property, not a link to Country (countries aren’t a first-class entity in your domain).
  • Shipment.currentDriverId: string — definitely a link too.

The link type does not replace the property; it makes the relationship navigable at the ontology level.

When the link itself has facts, but you do not want a full intersection object type, some platforms support link properties on the link directly:

linkType: driverOperatesVehicle
fromObjectType: Driver
toObjectType:   Vehicle
cardinality:    manyToMany
linkProperties:
  - { name: assignedAt,  type: timestamp }
  - { name: assignedBy,  type: string }     # user who made the assignment

This is a compromise — lighter than an intersection object type, but the link properties cannot themselves be linked to other objects. Use it for short-lived metadata; promote to an intersection object type when the relationship grows in importance.

Cardinality at scale — watch the fan-out

A Customer linked to 10 orders is normal. A Customer linked to 10 million orders (long-tail B2C, or a “system” pseudo-customer) is a problem:

  • Loading all linked orders becomes infeasible.
  • Indexing slows down.
  • UIs that “show all linked objects” hang.

Mitigations:

  1. Default to paginated traversal. Never assume all links fit in memory.
  2. Detect “hub” objects — a small number of object instances with massive fan-out — and treat them carefully.
  3. Consider summary properties. Store customer.totalOrderCount and customer.lastOrderId as derived properties instead of always loading all orders.

A worked example — the logistics graph

Let’s draw out the link structure of our running example:

Customer ─placedOrder→ Order

                          └─containsLine→ OrderLine

                                              └─producedShipment→ Shipment

                                            ┌─originatesFrom→ Hub  ←──┘
                                            │  destination →  Hub  ←──┘
                                            ├─assignedTo →   Driver
                                            └─carriedBy →    Vehicle

Driver ─operates← (DriverAssignment) →operates→ Vehicle

                          (with: assignedAt, assignedBy, status)

Notice:

  • Cardinalities are made explicit: Customer 1:N Order, Order 1:N OrderLine, Driver N:M Vehicle (through DriverAssignment).
  • Hubs appear on both ends of Shipment — origin and destination are two different link types pointing to the same object type.
  • DriverAssignment is an intersection object type with its own properties — because the certification, assignment history, and current status matter on their own.

Common pitfalls

Pitfall 1 — Conflating identity and links. Don’t have Customer.companyName: string and Order.customerCompanyName: string. Pick one source of truth (Customer.companyName), navigate via the link type when you need it on Order.

Pitfall 2 — Modeling enums as links. A property Customer.tier: enum<CustomerTier> does not need to be a link to a CustomerTier object type unless the tiers themselves have rich behavior (rules, benefits, expirations). Most enums stay enums.

Pitfall 3 — Forgetting reverse navigability. You define Order → placedBy → Customer but forget to label the reverse Customer → placedOrders. Now customer.placedOrders is awkward to express. Define both display names up front.

Pitfall 4 — Many-to-many without intersection objects when there are facts. Eventually someone asks “when was this driver assigned to this vehicle?” If you stored a pure many-to-many link, you have no answer. Use an intersection object type from the start when there is even a hint of relationship facts.

Pitfall 5 — Circular link soup. If you can navigate A → B → C → A → B → ... and it makes sense in your data, you have a cycle. Cycles are fine in the model, but queries that walk them without termination conditions will run forever. Always bound traversal depth.

Modeling cheat sheet

Q: Are both ends real entities in our business?
   → No  → property (foreign-key reference)
   → Yes → continue

Q: Will we navigate this relationship?
   → No  → property is enough
   → Yes → link type

Q: Does the relationship itself carry facts?
   → No  → simple link type
   → Yes, lightweight → link with link properties
   → Yes, rich  → intersection object type + two link types

Q: What is the cardinality?
   → Pick one: 1:1, 1:N, N:M
   → Sanity-check: any side ever going to fan out into millions?
       → Yes → plan for pagination + summary properties

Key takeaways

  • Link types model the verbs between object types — typed, directional, with explicit cardinality.
  • One-to-many is the bread-and-butter case; many-to-many usually becomes an intersection object type.
  • Link types and properties can coexist — keep the foreign-key property for raw joins, add the link type for ontology-level traversal.
  • Watch for fan-out: a small number of objects with millions of links is a special case to plan for, not an edge case to discover in production.

What’s next

Reads are now expressive — you can traverse the graph. Next we cover writes: the typed, validated, auditable way to mutate the ontology — action types.


The graph is wired. Now we make it move. 🔗