Course Content
Building Object Types and Links
Hands-on: create Customer, Order, Product object types and wire them with link types
What we are building
We take the Northwind Freight design from the previous lesson and implement it. By the end of this lesson, the ontology will have:
- 5 object types:
Customer,Order,Shipment,Hub,Driver - 6 link types wiring them
- 3 enums and 1 interface
- 5 fixture datasources with sample data
- A query that traverses three links to prove it works
Code samples use TypeScript. Java and Python translations are direct.
Enums first
Enums are referenced from object type properties, so define them up front:
// ontology/enums/region.ts
import { defineEnum } from "ontology-sdk";
export const Region = defineEnum("Region", {
description: "Operational region for accounting and routing.",
values: {
NA: { label: "North America" },
EU: { label: "Europe" },
APAC: { label: "Asia Pacific" },
LATAM: { label: "Latin America" },
},
});// ontology/enums/shipment-status.ts
import { defineEnum } from "ontology-sdk";
export const ShipmentStatus = defineEnum("ShipmentStatus", {
description: "Lifecycle state of a Shipment.",
values: {
created: { label: "Created", terminal: false },
in_transit: { label: "In Transit", terminal: false },
out_for_delivery: { label: "Out for Delivery", terminal: false },
delivered: { label: "Delivered", terminal: true },
exception: { label: "Exception", terminal: true },
cancelled: { label: "Cancelled", terminal: true },
},
});// ontology/enums/order-status.ts
import { defineEnum } from "ontology-sdk";
export const OrderStatus = defineEnum("OrderStatus", {
values: {
pending: {},
confirmed: {},
completed: {},
cancelled: {},
},
});Customer
// ontology/object-types/customer.ts
import { defineObjectType, t } from "ontology-sdk";
import { Region } from "../enums/region";
export const Customer = defineObjectType({
apiName: "customer",
displayName: "Customer",
description:
"A business that has signed a service agreement with Northwind Freight. " +
"Excludes prospects and internal test accounts.",
primaryKey: "customerId",
titleKey: "companyName",
properties: {
customerId: t.string({ pattern: /^cust_[a-z0-9]{8,16}$/ }),
companyName: t.string(),
primaryEmail: t.email(),
region: t.enumRef(Region),
signedAt: t.timestamp(),
headquartersLocation: t.geoPoint({ nullable: true }),
},
source: {
datasource: "customers_v0",
primaryKey: "customer_id",
mapping: {
customerId: "customer_id",
companyName: "company_name",
primaryEmail: "primary_email",
region: (row) => row.region.toUpperCase(),
signedAt: (row) => new Date(row.signed_dt),
headquartersLocation: (row) =>
row.hq_lat && row.hq_lng ? { lat: +row.hq_lat, lng: +row.hq_lng } : null,
},
},
});A few things worth noting:
- Mappings are functions where useful. Uppercasing the region or parsing lat/lng inline keeps the source flexible and the ontology clean.
- Pattern validation is enforced on every ingestion — bad IDs are rejected, not stored.
Hub
// ontology/object-types/hub.ts
import { defineObjectType, t } from "ontology-sdk";
import { Region } from "../enums/region";
import { Locatable } from "../interfaces/locatable";
export const Hub = defineObjectType({
apiName: "hub",
displayName: "Hub",
description: "A physical sorting and dispatch facility.",
primaryKey: "hubId",
titleKey: "name",
implements: [Locatable],
properties: {
hubId: t.string({ pattern: /^hub_[a-z0-9_-]{2,40}$/ }),
name: t.string(),
region: t.enumRef(Region),
location: t.geoPoint(),
timezone: t.string(),
capacityShipments: t.int({ min: 1 }),
},
source: {
datasource: "hubs_v0",
primaryKey: "hub_id",
mapping: {
hubId: "hub_id",
name: "name",
region: (row) => row.region.toUpperCase(),
location: (row) => ({ lat: +row.lat, lng: +row.lng }),
timezone: "tz",
capacityShipments: (row) => parseInt(row.capacity_shipments, 10),
},
},
});Notice implements: [Locatable]. The interface itself:
// ontology/interfaces/locatable.ts
import { defineInterface, t } from "ontology-sdk";
export const Locatable = defineInterface({
apiName: "locatable",
description: "Anything that has a current physical location.",
properties: {
location: t.geoPoint(), // each implementor must expose this property
},
});Hub exposes location — contract satisfied.
Driver
// ontology/object-types/driver.ts
import { defineObjectType, t } from "ontology-sdk";
import { Locatable } from "../interfaces/locatable";
export const Driver = defineObjectType({
apiName: "driver",
displayName: "Driver",
description: "An internal employee certified to operate a vehicle.",
primaryKey: "driverId",
titleKey: "displayName",
implements: [Locatable],
properties: {
driverId: t.string({ pattern: /^drv_[a-z0-9]{6,16}$/ }),
displayName: t.string(),
homeHubId: t.string(),
employeeStatus: t.enum("EmployeeStatus", ["active", "on_leave", "terminated"]),
capacityShipments: t.int({ min: 0, default: 8 }),
location: t.geoPoint({ nullable: true }),
locationUpdatedAt: t.timestamp({ nullable: true }),
},
source: {
datasource: "drivers_v0",
primaryKey: "driver_id",
mapping: {
driverId: "driver_id",
displayName: (row) => `${row.first_name} ${row.last_name}`,
homeHubId: "home_hub_id",
employeeStatus: "employee_status",
capacityShipments: (row) => parseInt(row.capacity ?? "8", 10),
location: () => null, // backfilled from telemetry stream later
locationUpdatedAt: () => null,
},
},
});Driver.location will be null until we wire the telemetry stream. For now, the fixture leaves it nullable.
Order
// ontology/object-types/order.ts
import { defineObjectType, t } from "ontology-sdk";
import { OrderStatus } from "../enums/order-status";
export const Order = defineObjectType({
apiName: "order",
displayName: "Order",
description: "A customer's request for one or more shipments.",
primaryKey: "orderId",
titleKey: "orderId",
properties: {
orderId: t.string({ pattern: /^ord_[a-z0-9]{8,20}$/ }),
customerId: t.string(),
status: t.enumRef(OrderStatus),
placedAt: t.timestamp(),
totalAmount: t.money(),
},
source: {
datasource: "orders_v0",
primaryKey: "order_id",
mapping: {
orderId: "order_id",
customerId: "customer_id",
status: "status",
placedAt: (row) => new Date(row.placed_at),
totalAmount: (row) => ({ amount: +row.total_amount, currency: row.currency }),
},
},
});Shipment
// ontology/object-types/shipment.ts
import { defineObjectType, t } from "ontology-sdk";
import { ShipmentStatus } from "../enums/shipment-status";
import { Locatable } from "../interfaces/locatable";
export const Shipment = defineObjectType({
apiName: "shipment",
displayName: "Shipment",
description: "A package in transit from an origin hub to a destination hub.",
primaryKey: "shipmentId",
titleKey: "shipmentId",
implements: [Locatable],
properties: {
shipmentId: t.string({ pattern: /^shp_\d{4}_[a-z0-9]{8}$/ }),
orderId: t.string(),
status: t.enumRef(ShipmentStatus),
originHubId: t.string(),
destinationHubId: t.string(),
assignedDriverId: t.string({ nullable: true }),
weightKg: t.double({ min: 0, max: 50000 }),
createdAt: t.timestamp(),
deliveredAt: t.timestamp({ nullable: true }),
location: t.geoPoint({ nullable: true }), // satisfies Locatable
},
source: {
datasource: "shipments_v0",
primaryKey: "shipment_id",
mapping: {
shipmentId: "shipment_id",
orderId: "order_id",
status: "status",
originHubId: "origin_hub_id",
destinationHubId: "destination_hub_id",
assignedDriverId: "assigned_driver_id",
weightKg: (row) => parseFloat(row.weight_kg),
createdAt: (row) => new Date(row.created_at),
deliveredAt: (row) => row.delivered_at ? new Date(row.delivered_at) : null,
location: () => null,
},
},
});Link types
Now the verbs. Each link is one file:
// ontology/link-types/customer-placed-order.ts
import { defineLinkType } from "ontology-sdk";
import { Customer } from "../object-types/customer";
import { Order } from "../object-types/order";
export const CustomerPlacedOrder = defineLinkType({
apiName: "customerPlacedOrder",
from: { type: Customer, displayName: "placed orders" },
to: { type: Order, displayName: "placed by" },
cardinality: "oneToMany",
resolve: {
fromProperty: "customerId", // Order.customerId → Customer.customerId
},
});// ontology/link-types/order-produced-shipment.ts
export const OrderProducedShipment = defineLinkType({
apiName: "orderProducedShipment",
from: { type: Order, displayName: "shipments" },
to: { type: Shipment, displayName: "fulfills order" },
cardinality: "oneToMany",
resolve: { fromProperty: "orderId" }, // Shipment.orderId → Order.orderId
});// ontology/link-types/shipment-from-hub.ts
export const ShipmentFromHub = defineLinkType({
apiName: "shipmentFromHub",
from: { type: Shipment, displayName: "originating hub" },
to: { type: Hub, displayName: "outgoing shipments" },
cardinality: "manyToOne",
resolve: { fromProperty: "originHubId" },
});
// shipment-to-hub.ts — same pattern with destinationHubId
// driver-based-at-hub.ts — Driver.homeHubId → Hub.hubId
// shipment-assigned-driver.ts — Shipment.assignedDriverId → Driver.driverIdThe pattern is consistent: declare the two types, pick a cardinality, point at the property that connects them.
Fixture datasources
For the smoke test, create CSV fixtures with a handful of rows each. Excerpts:
# tests/fixtures/hubs.csv
hub_id,name,region,lat,lng,tz,capacity_shipments
hub_ber_main,Berlin Central,EU,52.5200,13.4050,Europe/Berlin,800
hub_par_main,Paris CDG,EU,49.0097,2.5479,Europe/Paris,1200
hub_nyc_main,New York JFK,NA,40.6413,-73.7781,America/New_York,1500# tests/fixtures/customers.csv
customer_id,company_name,primary_email,region,signed_dt,hq_lat,hq_lng
cust_acmelogi01,Acme Logistics,ops@acmelogi.example,EU,2024-03-12T09:00:00Z,52.51,13.40
cust_globextra02,Globex Transport,it@globex.example,NA,2025-01-22T15:30:00Z,40.71,-74.00# tests/fixtures/orders.csv
order_id,customer_id,status,placed_at,total_amount,currency
ord_a1b2c3d4,cust_acmelogi01,completed,2026-04-01T10:00:00Z,4250.00,EUR
ord_e5f6g7h8,cust_acmelogi01,completed,2026-04-15T14:30:00Z,1820.50,EUR
ord_i9j0k1l2,cust_globextra02,pending,2026-05-02T09:45:00Z,9100.00,USD# tests/fixtures/shipments.csv
shipment_id,order_id,status,origin_hub_id,destination_hub_id,assigned_driver_id,weight_kg,created_at,delivered_at
shp_2026_a1b2c3d4,ord_a1b2c3d4,delivered,hub_ber_main,hub_par_main,drv_abc123,1240.5,2026-04-02T08:00:00Z,2026-04-03T17:30:00Z
shp_2026_e5f6g7h8,ord_e5f6g7h8,in_transit,hub_ber_main,hub_par_main,drv_xyz789,820.0,2026-04-16T08:00:00Z,
shp_2026_i9j0k1l2,ord_i9j0k1l2,created,hub_nyc_main,hub_par_main,,3100.0,2026-05-02T15:00:00Z,# tests/fixtures/drivers.csv
driver_id,first_name,last_name,home_hub_id,employee_status,capacity
drv_abc123,Anya,Krause,hub_ber_main,active,10
drv_xyz789,Marco,Bianchi,hub_par_main,active,8Wire the datasource YAMLs
One per fixture; customers.dataset.yaml we already have from the setup lesson. Repeat the pattern for the others. Each looks like:
# datasources/orders.dataset.yaml
apiName: orders_v0
kind: dataset
location: ./tests/fixtures/orders.csv
schema:
- { name: order_id, type: string }
- { name: customer_id, type: string }
- { name: status, type: string }
- { name: placed_at, type: string }
- { name: total_amount, type: string }
- { name: currency, type: string }Validate
pnpm ontology validateExpected: every object type, every link, every datasource passes.
If something fails, the message will tell you which file:
✗ ontology/link-types/order-produced-shipment.ts
resolve.fromProperty references "orderId" on Shipment which is fine,
but ensure the source mapping for Shipment includes order_id → orderId.Fix and re-validate.
Smoke test — three-link traversal
The whole point of the exercise: query across the graph in one expression.
pnpm ontology query "
Customer.byId('cust_acmelogi01')
.placedOrders.where(o => o.status == 'completed')
.placedShipments // through OrderProducedShipment
.map(s => ({ id: s.shipmentId, originHub: s.originatingHub.name }))
"Expected output:
[
{ "id": "shp_2026_a1b2c3d4", "originHub": "Berlin Central" },
{ "id": "shp_2026_e5f6g7h8", "originHub": "Berlin Central" }
]Three link traversals (Customer → Orders → Shipments → Hub), one expression, fully typed. The ontology is alive.
What just happened
You implemented a real, multi-entity, link-rich ontology:
- Five object types with full property typing.
- Six link types making the graph navigable.
- One interface bridging hubs, drivers, and shipments.
- Five fixture datasources, all validated and queryable.
You can hand this to an engineer who has never seen the model and they can answer business questions in code, with type safety, in minutes.
Anti-patterns to avoid as the model grows
Anti-pattern 1 — Inline magic in mappings. A 50-line transformation inside a mapping block is a red flag. Promote it to a named helper in ontology/source-transforms/.
Anti-pattern 2 — Cross-imports between object types. Object types should not import each other (they import enums and interfaces). Link types import both ends.
Anti-pattern 3 — Skipping the link type when a foreign key exists. A customerId property is not a substitute for the CustomerPlacedOrder link type — you want both.
Key takeaways
- Each definition lives in its own file; the structure scales linearly with the size of the model.
- Map source data into ontology types deliberately — never just rename columns.
- Link types make the graph navigable; the property carries the foreign key, the link gives you the relationship.
- A three-link traversal in one expression is the moment the value of the model clicks.
What’s next
We have nouns and verbs. Next: mutations — implementing the action types we designed, plus the functions that compute derived state.
The graph is wired. Now we make it move. 🛠️