Press ESC to exit fullscreen
📖 Lesson ⏱️ 75 minutes

Functions on the Ontology

Compute derived values, run logic, and expose typed APIs over your ontology

What a function is in the ontology

A function in the ontology is a typed, named compute over ontology data. Inputs and outputs are declared with ontology types; the body computes a value.

Examples:

  • customerLifetimeValue(customer: Customer): Money
  • estimatedArrivalTime(shipment: Shipment): Timestamp
  • routeDistanceKm(origin: Hub, destination: Hub): Double
  • riskScore(loanApplication: LoanApplication): Double

A function is how the ontology answers “what is the derived state of this entity?” without forcing every consumer to write the same logic.

Function vs. action — the divide

Same vocabulary, different jobs:

FunctionAction
Reads ontologyYesYes
Writes ontologyNoYes
Side effectsNoneRequired (it’s the point)
ReturnsA typed valueAn outcome status + sometimes resulting IDs
CacheableOften, yesNo
IdempotentBy definitionBy design

A function is a pure read. If you find yourself doing writes inside a function, you actually want an action.

A first example

function: customerLifetimeValue
displayName: Customer Lifetime Value
description: Total of all completed order amounts for a customer, in USD.

parameters:
  - name: customer
    type: Customer

returns: Money

body: |
  let orders = customer.placedOrders
                       .filter(o => o.status == "completed")
  let total  = orders.sum(o => o.totalAmount.convertedTo("USD"))
  return total

The exact body language varies — TypeScript, Java, Python, or a platform-specific DSL — but the shape is the same: typed inputs, typed body, typed output.

Pure vs. impure functions

A pure function:

  • Given the same inputs and the same ontology state, returns the same output.
  • Has no side effects (no DB writes, no external calls that change state).
  • Is safely cacheable.

A pure function is the ideal. Every consumer can rely on it, the platform can cache it, and you can test it without standing up half the world.

Impure functions exist too — they call external services, depend on the current wall-clock time, or read data outside the ontology. They are sometimes necessary (fetchExchangeRate(currency), runFraudModel(features)), but flag them explicitly:

function: liveExchangeRate
parameters:
  - name: from
    type: enum<CurrencyCode>
  - name: to
    type: enum<CurrencyCode>
returns: Double
purity: external          # explicitly marks this as making an external call
cacheTtl: 60s             # platform caches for 60 seconds

Marking purity is honest: it tells callers what to expect, and it tells the platform whether it can cache the result.

Functions as derived properties

A common pattern: bind a function to an object type as a derived property.

objectType: Customer
properties:
  - { name: customerId, type: string, primaryKey: true }
  - { name: companyName, type: string }
  - name: lifetimeValueUsd
    type: Money
    derived: true
    function: customerLifetimeValue

Now customer.lifetimeValueUsd is just another property to every consumer. They do not know — and do not need to know — that it is computed at read time.

The platform decides whether to:

  • Compute on read (fresh, possibly slow).
  • Materialize (precompute and store, possibly stale).
  • Cache with TTL (compromise).

Your job is to declare what the property is; the platform’s job is to serve it efficiently.

Composition

Functions compose. Build small ones; combine them into bigger ones.

function: orderTotalUsd
parameters: [{ name: order, type: Order }]
returns: Money
body: order.lines.sum(l => l.quantity * l.unitPrice.convertedTo("USD"))

function: customerLifetimeValue
parameters: [{ name: customer, type: Customer }]
returns: Money
body: customer.placedOrders
              .filter(o => o.status == "completed")
              .sum(o => orderTotalUsd(o))

Notice customerLifetimeValue does not re-implement order totaling — it calls orderTotalUsd. One definition, one place to fix bugs, every consumer benefits.

Functions shine when they traverse the link graph:

function: hubInboundShipmentsToday
parameters: [{ name: hub, type: Hub }]
returns: int
body: hub.incomingShipments
        .filter(s => s.expectedArrival.date == today())
        .count()

function: driverAvailableCapacity
parameters: [{ name: driver, type: Driver }]
returns: int
body: driver.capacity - driver.assignedShipments
                              .filter(s => s.status != "delivered")
                              .count()

Functions like these are read by dashboards, used in action validations (driver.activeShipmentCount < driver.capacity), and consumed by AI agents that need a single typed answer.

ML and external models

A common production use of functions: invoking ML models.

function: shipmentDelayRisk
displayName: Shipment Delay Risk Score
description: Probability (0-1) that this shipment misses its SLA.
parameters: [{ name: shipment, type: Shipment }]
returns: double
purity: external
model:
  name: shipment-delay-v3
  framework: pytorch
features:
  - shipment.weightKg
  - shipment.routeDistanceKm
  - shipment.origin.currentBacklog
  - shipment.destination.weatherScore

This packages the model behind the ontology’s type system. Consumers do not load PyTorch, do not stand up a feature pipeline, and do not need to know how the model works. They just call shipment.delayRisk.

When the model is retrained and redeployed, no consumer code changes.

Performance considerations

A function looks like a free abstraction. It is not — every call costs compute. Design accordingly:

1. Bound the work. A function that traverses 50M linked objects per call will fall over. Add limits or precompute summaries.

2. Cache pure functions. If purity is declared, the platform can cache aggressively. If you mark a function pure when it is not, you will get stale results — be honest.

3. Avoid N+1. A function customer.placedOrders.map(o => orderTotalUsd(o)) evaluated 1000 times is much slower than computing the same in one batched query. Where the platform supports vectorized functions, use them.

4. Watch your fan-out in derived properties. A derived property that is “cheap” for a typical customer but takes seconds for a power user will frustrate the UI. Either:

  • precompute and cache, or
  • return a fast approximation and offer a slower-but-exact version under a different function name.

Function ergonomics

Some patterns that make functions pleasant to live with:

Default arguments. customerLifetimeValue(customer, since = epoch()) — adds flexibility without forcing every caller to provide a date.

Overloads at type level. distanceKm(origin: Hub, destination: Hub) and distanceKm(origin: GeoPoint, destination: GeoPoint) — same name, different inputs.

Return rich types. Don’t squeeze a complex answer into a primitive. routeAnalysis(shipment): RouteAnalysis with a typed struct beats stuffing nine numbers into a comma-separated string.

Document the failure modes. What happens if customer.placedOrders is empty? Probably returns Money(0, "USD"). Say so.

Testing functions

Pure functions are a joy to test:

test("customerLifetimeValue sums completed orders only", () => {
  const customer = mockCustomer({
    orders: [
      { total: 100, status: "completed" },
      { total: 200, status: "pending" },
      { total: 300, status: "completed" },
    ],
  });
  expect(customerLifetimeValue(customer)).toEqual(Money(400, "USD"));
});

Spend the time on unit tests. Functions are read constantly by every downstream system; a bug here ripples everywhere.

Anti-patterns

Anti-pattern 1 — Functions that mutate. If your function calls an action or directly modifies state, it is misclassified. Either split it (function returns the desired change, action applies it) or convert it to an action.

Anti-pattern 2 — Stringly-typed everything. A function returning a string that is “actually JSON, parse it” is a type-system bypass. Return a struct, or define a proper interface.

Anti-pattern 3 — Wall-clock hidden inside pure functions. Calling now() makes a function impure (its output changes minute to minute). Either take a now parameter, or declare the function impure.

Anti-pattern 4 — Functions that should be properties. If the computation is trivial and the inputs are all on the same object, just make it a derived property — don’t make consumers call a function explicitly when they could read customer.tier.

Key takeaways

  • Functions are the compute layer of the ontology — typed, declarative, composable.
  • Prefer purity; mark impurity explicitly when unavoidable.
  • Derived properties are functions bound to an object type — invisible to consumers.
  • Compose small functions into bigger ones, and lean on caching for performance.

What’s next

Functions and links together give us a rich read surface. Next we look at object sets and interfaces — the actual query API consumers use, and the cross-cutting contracts that let multiple object types share behavior.


Compute, but declaratively. 🧮