Course Content
Implementing Actions and Functions
Hands-on: ship typed actions that mutate state and functions that compute derived metrics
What we are building
Picking up from the previous lesson — the Northwind ontology has objects and links. Now we add:
- 3 action types covering the most important state transitions:
placeOrdermarkShipmentDeliveredassignDriverToShipment
- 4 functions powering derived data:
orderTotalUsdcustomerLifetimeValuedriverActiveShipmentCountshipmentInTransitDurationHours
- A test suite for all of the above.
Function 1 — orderTotalUsd
The simplest function: return the order total, converted to USD. We will assume a static exchange table for now (later we will federate from a live source).
// ontology/functions/order-total-usd.ts
import { defineFunction, t } from "ontology-sdk";
import { Order } from "../object-types/order";
const USD_PER = {
USD: 1.00,
EUR: 1.08,
GBP: 1.27,
} as const;
export const orderTotalUsd = defineFunction({
apiName: "orderTotalUsd",
description: "Total of the order in USD using the static reference table.",
purity: "pure",
parameters: { order: Order },
returns: t.money({ currency: "USD" }),
body: ({ order }) => {
const rate = USD_PER[order.totalAmount.currency as keyof typeof USD_PER];
if (!rate) throw new Error(`Unsupported currency: ${order.totalAmount.currency}`);
return { amount: order.totalAmount.amount * rate, currency: "USD" };
},
});A few details:
purity: "pure"— given the same order, returns the same value. The platform can cache.- Typed parameters and return — callers know the shape statically.
- Throws on unknown currency rather than silently returning zero. Loud failure beats silent corruption.
Function 2 — customerLifetimeValue
Compose orderTotalUsd across all completed orders of a customer.
// ontology/functions/customer-lifetime-value.ts
import { defineFunction, t } from "ontology-sdk";
import { Customer } from "../object-types/customer";
import { orderTotalUsd } from "./order-total-usd";
export const customerLifetimeValue = defineFunction({
apiName: "customerLifetimeValue",
description: "Sum of completed order totals in USD for a customer.",
purity: "pure",
parameters: { customer: Customer },
returns: t.money({ currency: "USD" }),
body: async ({ customer }) => {
const completed = await customer.placedOrders
.where(o => o.status === "completed")
.all();
const total = completed.reduce(
(acc, o) => acc + orderTotalUsd({ order: o }).amount,
0,
);
return { amount: total, currency: "USD" };
},
});Compose, don’t reimplement. orderTotalUsd is the single place currency conversion lives.
Function 3 — driverActiveShipmentCount
Useful in action validation later — how many non-delivered shipments is a driver currently assigned to?
// ontology/functions/driver-active-shipment-count.ts
import { defineFunction, t } from "ontology-sdk";
import { Driver } from "../object-types/driver";
export const driverActiveShipmentCount = defineFunction({
apiName: "driverActiveShipmentCount",
description:
"Number of non-terminal shipments currently assigned to a driver.",
purity: "pure",
parameters: { driver: Driver },
returns: t.int(),
body: async ({ driver }) =>
driver.assignedShipments
.where(s => !["delivered", "cancelled", "exception"].includes(s.status))
.count(),
});Note: this relies on a link Driver → assignedShipments → Shipment, which is the reverse direction of shipment-assigned-driver.ts from the previous lesson.
Function 4 — shipmentInTransitDurationHours
A derived property candidate.
// ontology/functions/shipment-in-transit-duration-hours.ts
import { defineFunction, t } from "ontology-sdk";
import { Shipment } from "../object-types/shipment";
export const shipmentInTransitDurationHours = defineFunction({
apiName: "shipmentInTransitDurationHours",
description:
"Hours between createdAt and either deliveredAt (if delivered) or now.",
purity: "external", // depends on wall clock
parameters: { shipment: Shipment, now: t.timestamp({ optional: true }) },
returns: t.double(),
body: ({ shipment, now }) => {
const end = shipment.deliveredAt ?? now ?? new Date();
return (end.getTime() - shipment.createdAt.getTime()) / (1000 * 60 * 60);
},
});now is taken as an optional parameter so tests can pass a fixed clock. Real callers can leave it out and get wall-clock behavior. The function is marked external because its result depends on the clock when now is omitted.
Expose derived properties
Bind functions to object types so consumers see them as ordinary properties:
// ontology/object-types/customer.ts (additions)
properties: {
// ... existing properties
lifetimeValueUsd: t.derived(customerLifetimeValue),
},// ontology/object-types/driver.ts (additions)
properties: {
// ... existing properties
activeShipmentCount: t.derived(driverActiveShipmentCount),
},Now customer.lifetimeValueUsd and driver.activeShipmentCount are first-class — every consumer sees them, the platform decides how to materialize.
Action 1 — placeOrder
A creation action with multiple effects.
// ontology/action-types/place-order.ts
import { defineActionType, t } from "ontology-sdk";
import { Customer } from "../object-types/customer";
import { Order } from "../object-types/order";
export const placeOrder = defineActionType({
apiName: "placeOrder",
displayName: "Place Order",
description:
"Creates a new pending order for a customer. Caller supplies the line " +
"totals; the action assigns the orderId and timestamps.",
parameters: {
customerId: t.string(),
lineTotalAmount: t.money(),
idempotencyKey: t.string({ optional: true }),
},
validations: ({ params, ontology }) => [
{
name: "customer-exists",
check: () => ontology.exists(Customer, params.customerId),
message: () => `Customer ${params.customerId} not found`,
},
{
name: "amount-positive",
check: () => params.lineTotalAmount.amount > 0,
message: () => "Order total must be positive",
},
],
effects: async ({ params, mutate, mintId, now }) => {
const orderId = mintId("ord");
const order = await mutate.create(Order, {
orderId,
customerId: params.customerId,
status: "pending",
placedAt: now(),
totalAmount: params.lineTotalAmount,
});
return { orderId: order.orderId };
},
});Notes:
- Validations are functions returning a structured result; failure messages are computed from the parameters.
- Effects receive helpers (
mutate,mintId,now) — all the platform plumbing, typed and injectable for tests. - Returning the created
orderIdis what the caller usually wants — let them chain.
Action 2 — markShipmentDelivered
A state-transition action with idempotency.
// ontology/action-types/mark-shipment-delivered.ts
import { defineActionType, t } from "ontology-sdk";
import { Shipment } from "../object-types/shipment";
export const markShipmentDelivered = defineActionType({
apiName: "markShipmentDelivered",
displayName: "Mark Shipment Delivered",
parameters: {
shipmentId: t.string(),
deliveredAt: t.timestamp(),
signature: t.string({ minLength: 2 }),
},
validations: ({ params, ontology }) => [
{
name: "shipment-exists",
check: () => ontology.exists(Shipment, params.shipmentId),
message: () => `Shipment ${params.shipmentId} not found`,
},
{
name: "state-machine",
check: async () => {
const s = await ontology.byId(Shipment, params.shipmentId);
return ["in_transit", "out_for_delivery"].includes(s.status);
},
message: async () => {
const s = await ontology.byId(Shipment, params.shipmentId);
return `Cannot deliver a shipment with status ${s.status}`;
},
},
{
name: "delivery-not-before-creation",
check: async () => {
const s = await ontology.byId(Shipment, params.shipmentId);
return params.deliveredAt >= s.createdAt;
},
message: () => "Delivery time cannot precede creation",
},
],
effects: async ({ params, mutate, emit }) => {
await mutate.update(Shipment, params.shipmentId, {
status: "delivered",
deliveredAt: params.deliveredAt,
});
emit("ShipmentDelivered", {
shipmentId: params.shipmentId,
deliveredAt: params.deliveredAt,
signature: params.signature,
});
},
});Idempotency is state-guarded here: calling the action on an already-delivered shipment fails the state-machine validation with a clear message. The UI presents that as “already delivered” rather than a generic error.
Action 3 — assignDriverToShipment
A multi-link action with capacity and certification checks.
// ontology/action-types/assign-driver-to-shipment.ts
import { defineActionType, t } from "ontology-sdk";
import { Shipment } from "../object-types/shipment";
import { Driver } from "../object-types/driver";
import { driverActiveShipmentCount } from "../functions/driver-active-shipment-count";
export const assignDriverToShipment = defineActionType({
apiName: "assignDriverToShipment",
displayName: "Assign Driver to Shipment",
parameters: {
shipmentId: t.string(),
driverId: t.string(),
},
validations: ({ params, ontology }) => [
{
name: "shipment-exists",
check: () => ontology.exists(Shipment, params.shipmentId),
message: () => `Shipment ${params.shipmentId} not found`,
},
{
name: "driver-exists",
check: () => ontology.exists(Driver, params.driverId),
message: () => `Driver ${params.driverId} not found`,
},
{
name: "shipment-assignable",
check: async () => {
const s = await ontology.byId(Shipment, params.shipmentId);
return !["delivered", "cancelled", "exception"].includes(s.status);
},
message: () => "Shipment is in a terminal state",
},
{
name: "driver-active",
check: async () => {
const d = await ontology.byId(Driver, params.driverId);
return d.employeeStatus === "active";
},
message: () => "Driver is not currently active",
},
{
name: "driver-capacity",
check: async () => {
const d = await ontology.byId(Driver, params.driverId);
const active = await driverActiveShipmentCount({ driver: d });
return active < d.capacityShipments;
},
message: async () => {
const d = await ontology.byId(Driver, params.driverId);
return `Driver at capacity (${d.capacityShipments})`;
},
},
],
effects: async ({ params, mutate, emit }) => {
await mutate.update(Shipment, params.shipmentId, {
assignedDriverId: params.driverId,
status: "in_transit", // promote from "created" if applicable
});
emit("DriverAssignedToShipment", {
shipmentId: params.shipmentId,
driverId: params.driverId,
});
},
});Notice the action calls a function (driverActiveShipmentCount) during validation. The action is the right abstraction for mutation; the function is the right abstraction for computing the current count. They compose.
Testing
Set up tests/actions.test.ts with the platform’s test harness. The pattern:
import { describe, it, expect, beforeEach } from "vitest";
import { TestHarness, loadFixtures } from "ontology-sdk/test";
import { markShipmentDelivered } from "../ontology/action-types/mark-shipment-delivered";
describe("markShipmentDelivered", () => {
let h: TestHarness;
beforeEach(async () => {
h = await loadFixtures("./tests/fixtures");
});
it("transitions an in_transit shipment to delivered", async () => {
const result = await h.run(markShipmentDelivered, {
shipmentId: "shp_2026_e5f6g7h8",
deliveredAt: new Date("2026-04-17T18:00:00Z"),
signature: "M.B.",
});
expect(result.ok).toBe(true);
const s = await h.byId("Shipment", "shp_2026_e5f6g7h8");
expect(s.status).toBe("delivered");
expect(s.deliveredAt).toEqual(new Date("2026-04-17T18:00:00Z"));
});
it("rejects delivery for an already-delivered shipment", async () => {
const result = await h.run(markShipmentDelivered, {
shipmentId: "shp_2026_a1b2c3d4", // already delivered in fixture
deliveredAt: new Date(),
signature: "X.X.",
});
expect(result.ok).toBe(false);
expect(result.failedValidation).toBe("state-machine");
});
it("rejects delivery before creation time", async () => {
const result = await h.run(markShipmentDelivered, {
shipmentId: "shp_2026_e5f6g7h8",
deliveredAt: new Date("2020-01-01T00:00:00Z"),
signature: "M.B.",
});
expect(result.ok).toBe(false);
expect(result.failedValidation).toBe("delivery-not-before-creation");
});
});Three tests for one action: golden path + each failure case. Do this for every action you ship. Validations untested are validations broken.
Testing functions
Functions are even easier:
import { describe, it, expect } from "vitest";
import { orderTotalUsd } from "../ontology/functions/order-total-usd";
describe("orderTotalUsd", () => {
it("returns EUR-to-USD conversion", () => {
const order = { totalAmount: { amount: 100, currency: "EUR" } } as any;
const result = orderTotalUsd({ order });
expect(result).toEqual({ amount: 108, currency: "USD" });
});
it("returns the value as-is when already USD", () => {
const order = { totalAmount: { amount: 100, currency: "USD" } } as any;
expect(orderTotalUsd({ order })).toEqual({ amount: 100, currency: "USD" });
});
it("throws on unknown currency", () => {
const order = { totalAmount: { amount: 100, currency: "XBT" } } as any;
expect(() => orderTotalUsd({ order })).toThrow(/Unsupported currency/);
});
});Pure functions test instantly. Take advantage.
Run it
pnpm ontology validate # schema check
pnpm test # unit tests
pnpm ontology query "
Driver.byId('drv_abc123').activeShipmentCount
"Expected:
0(Anya’s only fixture shipment is already delivered.)
pnpm ontology query "
Customer.byId('cust_acmelogi01').lifetimeValueUsd
"Expected: { amount: 6556.14, currency: 'USD' } (4250 + 1820.50 EUR × 1.08).
Both derived properties resolve through the function layer, transparently to the caller.
What you have now
- A working ontology with mutation: actions are the only way state changes.
- Functions powering derived data, composable and testable.
- A test suite covering golden paths and validation failures.
This is a small but complete ontology — readable, writable, queryable, derivable. The remaining lessons cover production-grade concerns: security, versioning, best practices.
Anti-patterns to avoid
Anti-pattern 1 — Direct datasource writes. If anyone on the team writes to the underlying CSV or database to “fix” state, the ontology and the source drift. There is one write path: actions.
Anti-pattern 2 — Validation logic in consumers. A frontend re-implementing the “can this be delivered?” check is duplication that will rot. Let the action be the source of truth; the UI just disables the button based on the same state.
Anti-pattern 3 — One mega-action. updateShipmentEverything(shipmentId, ...thirtyFields) defeats the point. Each business intent is its own action.
Key takeaways
- Action types are how state changes — typed parameters, validations, effects.
- Functions compute derived state — pure when possible, marked impure when not.
- Derived properties bind functions to object types so consumers do not need to know they are computed.
- Tests for every action validation cost nothing to write and save real outages.
What’s next
The ontology works. But it is wide open — every reader can see every property, every writer can invoke every action. Next: security, permissions, and markings.
The model mutates. The contract holds. ⚙️