Course Content
Best Practices and Production Patterns
Patterns that scale: granular object types, idempotent actions, observable functions, ontology hygiene
Why best practices matter here
An ontology touches more of an organization than almost any other software artifact. A bad pattern in a microservice hurts one team; a bad pattern in the ontology multiplies across every consumer.
The patterns below are not theoretical. Each one was learned the painful way at companies running ontologies at scale.
Granularity
Object types — split when divergence is real
Two candidate object types should be separate if they have:
- Different lifecycles (when they appear / disappear).
- Different governance (different roles, different markings).
- Different identities (different ID schemes).
Customer and Prospect, Employee and Contractor, Order and Quote — all look similar enough to merge. They are different in ways that matter. Splitting them costs a little extra modeling; merging them costs the rest of your life debugging.
Actions — one intent per action
Resist the temptation to write updateShipment(...) with twenty optional parameters. Each business intent is its own action:
markShipmentDispatchedmarkLegArrivedmarkShipmentDeliveredmarkShipmentExceptionrerouteShipmentcancelShipment
Each has tight validation, clear permissions, focused tests. A consumer reading the action list immediately understands what is possible.
Functions — small and composable
Functions should do one calculation well. Composition is free; un-composition is not. Build orderTotalUsd, customerLifetimeValue, cohortRetentionRate from the ground up rather than one giant customerAnalytics(customer) blob.
Idempotency by design
The world retries. Networks fail. Users double-click. Webhooks fire twice.
Every action should be idempotent under one of three regimes:
State-guarded. Validations naturally reject the second invocation (
Cannot deliver a shipment with status delivered). Most state-transition actions fit here.Idempotency keys. Creation actions accept an optional key; second call with the same key returns the previous result. Use for
placeOrder,createCustomer.Functional. The action is a pure setter — calling twice with the same parameters produces the same final state. Use for
setCustomerTier(customerId, tier)(the “set” semantics ensure replays are safe).
If an action does not fit any of these, you have a bug factory.
Observability — every action emits events
Every state-changing action should emit a domain event:
emit("ShipmentDelivered", {
shipmentId, deliveredAt, signature,
actor: identity.id,
triggeredBy: parentActionId,
});Why this matters:
- Downstream consumers can react to events without polling.
- Audit logs are richer than just “row changed.”
- Debugging in production becomes “trace the event flow,” not “guess from log timestamps.”
- AI agents can subscribe to events and act on them.
Event names mirror action names with a past-tense business term: placeOrder → OrderPlaced. Keep them stable; consumers depend on them.
Idempotent ingestion
The same discipline applies to ingestion. Re-running a batch load or replaying a stream should not duplicate or corrupt data.
- Use stable primary keys so duplicate rows are upserts, not inserts.
- Mark out-of-order events by event timestamp; ignore events older than current state.
- Reject impossible states at the source — never let bad data trickle into the ontology to be “fixed later.”
Performance patterns
Pre-aggregate where appropriate
Some derived properties are too expensive to compute on every read. Materialize them:
customer.lifetimeValueUsdupdated on everyOrderCompletedevent.hub.shipmentBacklogCountupdated on every shipment status change.
The pattern: a function defines the value; an event-triggered job materializes it. The function remains the source of truth (it can recompute from scratch); the materialized value is a cache.
Bound traversal depth
Queries that walk links should always have a bound:
shipment.assignedDriver // ok: one hop
.homeHub // ok: two hops
.outgoingShipments // here be dragons
.map(s => s.destinationHub.outgoingShipments...) // STOPA query traversing five hops on a fan-out graph can hit billions of objects. Make depth explicit and limit it.
Use object sets, not full materialization
Customer.where(c => c.region == "EU").count() is cheap; Customer.where(c => c.region == "EU").all().length is expensive when the result is huge. The platform can count without materializing; teach your team to use the right method.
Naming hygiene
A name is a contract. Once a consumer uses Customer.signedAt, you cannot rename it without a major-version migration. Spend time on names up front:
- Object types — singular nouns, PascalCase:
Shipment, notShipments. - Properties — camelCase with units in the name:
weightKg,latencyMs,priceUsd. - Booleans —
isActive,hasInsurance, not bareactive. - Timestamps —
<verb>At:createdAt,deliveredAt.Dateis too vague. - IDs —
<object>Idsuffix:customerId,vehicleId. - Actions — imperative verb-first:
markDelivered,assignDriver. - Events — past-tense business term:
OrderPlaced,ShipmentDelivered.
A short style guide checked into the repo and enforced via lint is worth its weight in gold.
The ontology review
Every ontology change should be reviewed like critical infrastructure. Even small changes ripple through dozens of consumers.
Suggested process:
- Author opens a PR. Includes the change, the migration (if any), and updates to docs and tests.
- CI runs validate and tests — must pass before review.
- At least one reviewer from the ontology team. Looks at naming, scoping, security implications.
- At least one reviewer from a major consumer. Catches “this will break our dashboard.”
- Release notes drafted before merge; finalized at release.
For major-version changes, add a public RFC document in docs/rfcs/ and circulate before any code is written.
Documentation that ages well
Three layers of documentation:
- In-schema descriptions. Every object type, property, action, function. This is the primary reference; it travels with the code.
- Conceptual docs. “How does our
Shipmentrelate to ourOrder?” — narrative, with diagrams. In adocs/folder. - Decision records (ADRs). Why did we model X as Y? When you forget, this is what saves you.
Don’t generate docs from code and call it done. Generated reference + hand-written narrative + decision history is the trifecta.
Healthy ontology, unhealthy ontology
A few diagnostics:
Healthy:
- New engineers can read the schema and understand the business.
- Most consumers pin to the same minor version.
- Schema changes ship weekly, broken consumers monthly (or never).
- Most actions have fewer than 50 lines of effect logic.
- The ontology has fewer than ~100 object types.
Unhealthy:
- Every team has “their own” version of the ontology.
- Consumers have stopped updating their pinned version.
- Schema changes are scary; people avoid them.
- A handful of actions have grown 500+ lines and “do everything.”
- The ontology has more than ~300 object types — usually a sign of unsplit duplication, not legitimate complexity.
Specific patterns
The Reference object type
Some object types exist purely as enumerable references — Country, Currency, TimeZone. They are mostly static and read-only.
Make them explicit:
export const Country = defineObjectType({
apiName: "country",
primaryKey: "iso3",
// ... small, static, indexed by ISO code
immutable: true, // no actions, no writes
});Mark as immutable. Consumers know they can cache aggressively and treat as references.
The Snapshot pattern
For “what did this look like at time T?”, do not encode history in the live object. Create a <Entity>Snapshot object type:
export const CustomerSnapshot = defineObjectType({
apiName: "customerSnapshot",
primaryKey: "snapshotId",
properties: {
snapshotId: t.string(),
customerId: t.string(),
asOf: t.timestamp(),
// ... full snapshot of relevant properties
},
});Linked from Customer via Customer → snapshots. Live customer stays clean; history lives where history belongs.
The Outcome envelope
Actions sometimes need to return rich outcomes — created IDs, warnings, advisory information. Use a typed return envelope:
return {
orderId: createdOrderId,
warnings: capacityNearLimit ? ["Driver near capacity"] : [],
};Standardize the shape ({ result, warnings, info }) across actions so consumers can handle them uniformly.
Things to push back on
If you’re the steward of the ontology, you will get requests that feel reasonable and are not:
“Just add this property to Customer for our app.” Maybe. Or maybe it belongs on a
CustomerNotesobject type linked to Customer. Check the rule: is this property always meaningful for every customer?“Can we add an
updateAnythingaction for admins?” No. Bypass actions for specific maintenance needs; never a wildcard.“Our consumer reads directly from the dataset because it’s faster.” That breaks the security model and audit trail. Fix the read path through the ontology, not by going around it.
“We need to ship now; we’ll add tests and validation later.” Later does not come. The validation rule that ships with the action is the validation rule that exists.
Saying no carefully — with a “here’s the right way” alternative — is most of the job.
Key takeaways
- Granularity is the deepest lever: too coarse and the ontology becomes a god object; too granular and it becomes incomprehensible.
- Idempotency is a design property — bake it in, don’t bolt it on.
- Events plus audit make production debuggable.
- Reviews and documentation are not overhead; they are how the ontology stays trustworthy.
What’s next
This is the last conceptual lesson. The final piece of the course is the capstone project: a complete operational ontology you build end-to-end, drawing on everything covered.
Patterns over heroics. 🧘