Press ESC to exit fullscreen
📖 Lesson ⏱️ 90 minutes

Action Types: Writing to the Ontology

Mutate state safely with typed actions, parameters, validations, and side-effects

Why actions exist

The ontology has one rule that earns most of its value: every write goes through an action type. No direct table updates, no ad-hoc SQL, no “fix it in the database” — every state change is a typed, validated, audited operation.

Without that rule, the ontology is just a read model. With it, the ontology becomes the system of record for business state — and every consumer (apps, agents, services) can trust what it reads.

Anatomy of an action type

An action type is a function, typed and named, that:

  1. Takes typed parameters.
  2. Runs validations (preconditions).
  3. Applies one or more state changes to the ontology.
  4. Optionally emits events.
  5. Is recorded in an audit log.

Sketch:

actionType: markShipmentDelivered
displayName: Mark Shipment Delivered
description: >
  Confirms that a shipment has been delivered to its recipient.
  Records the delivery time and signature. Can only be applied
  while the shipment is in_transit or out_for_delivery.

parameters:
  - name: shipmentId
    type: string
    description: The shipment to update
  - name: deliveredAt
    type: timestamp
    description: When the recipient signed for the package
  - name: signature
    type: string
    validation: { minLength: 2 }

validations:
  - check: shipment.exists(shipmentId)
    message: "Shipment not found"
  - check: shipment.status in ['in_transit', 'out_for_delivery']
    message: "Cannot deliver a shipment in status {{ shipment.status }}"
  - check: deliveredAt >= shipment.createdAt
    message: "Delivery cannot precede creation"

effects:
  - update: Shipment[shipmentId]
    set:
      status: "delivered"
      deliveredAt: deliveredAt
      deliverySignature: signature
  - emit: ShipmentDeliveredEvent
    payload:
      shipmentId: shipmentId
      deliveredAt: deliveredAt

Everything that matters about the mutation is in one place: what data it needs, what conditions must hold, what changes.

Parameter design

Parameters are the API contract of your action. Get them right.

Take the object reference, not its fields. shipmentId: string is better than shipmentTrackingNumber: string + region: string — the action resolves the object once, every subsequent reference is unambiguous.

Require the bare minimum. Every optional parameter is a branch in the action logic and a complication for callers. If a default makes sense, document it; if not, require the value.

Name parameters for what they represent. deliveredAt not timestamp. signature not value. The parameter name will be read by every caller — make it self-explanatory.

Group related parameters into structs. If three parameters always travel together (latitude, longitude, accuracyMeters), make them a GeoLocation struct.

Validations — preconditions, explicitly

The validation block answers: what must be true before this action runs?

Three kinds of checks usually appear:

  1. Existence — the referenced object exists and is not soft-deleted.
  2. State — the object is in a state where this action is meaningful.
  3. Domain rules — business invariants that must hold.

Spelling them out in the action type gives you two wins:

  • Failure messages are specific. Instead of “constraint violated,” users see “Cannot deliver a shipment in status cancelled.”
  • The action’s contract is readable. Anyone reading the definition knows when it can fire.

A subtle point: validations should be deterministic from the parameters and the current ontology state, with no side effects. If you find yourself doing API calls inside validation, you have moved past validation into orchestration — split the work.

Effects — the actual mutation

The effects block declares what changes. A small action might have one effect; a bigger one might have many.

Common patterns:

Update properties on a single object:

effects:
  - update: Customer[customerId]
    set: { tier: "platinum" }

Create a new object:

effects:
  - create: Order
    set:
      orderId: newOrderId(...)
      customerId: customerId
      placedAt: now()
      status: "pending"

Create or update a link:

effects:
  - link:
      from: Driver[driverId]
      to:   Vehicle[vehicleId]
      type: driverOperatesVehicle
      properties:
        assignedAt: now()

Emit a domain event:

effects:
  - emit: OrderPlaced
    payload:
      orderId: newOrderId
      customerId: customerId
      total: totalAmount

Multiple effects within one action should run atomically — either all apply, or none do. (The exact transactional guarantees vary by platform; check the docs before relying on them.)

Idempotency

A well-designed action can be called twice with the same parameters and either:

  1. Produce the same result both times (truly idempotent), or
  2. Refuse the second call with a clear “already done” response.

Why this matters: networks fail, retries happen, users double-click. An action that creates a duplicate order on every retry is a bug factory.

Two common patterns:

State-guarded idempotency. The validation block rejects the second call:

validations:
  - check: shipment.status != "delivered"
    message: "Shipment already marked delivered at {{ shipment.deliveredAt }}"

The first call succeeds, the second is a clean no-op (or a clear error, which the UI can present as success).

Idempotency keys. The caller passes a unique key on retry:

parameters:
  - name: idempotencyKey
    type: string

The action stores the key; if the same key shows up again, the action returns the previous result without re-running effects. Useful for create actions where state-guarded idempotency is harder.

Audit logging

Every action invocation should record:

  • Who invoked it (user, service, agent).
  • When it ran.
  • What parameters were passed.
  • Which objects changed.
  • Outcome — success / failure / which validation rejected it.

This is usually free at the platform level — you just need to not bypass it by going around the action layer. Treat the audit log as part of the contract.

A complete worked example

Let’s design assignDriverToShipment end-to-end.

Business rules:

  • A shipment can have at most one assigned driver at a time.
  • A driver can be assigned to multiple shipments concurrently, up to a per-driver capacity.
  • The driver must be currently certified for the vehicle the shipment will use.
  • Assigning re-assigns: if a different driver was previously assigned, they are unassigned with an audit trail.
actionType: assignDriverToShipment
displayName: Assign Driver to Shipment
description: >
  Assigns a certified driver to a shipment. If another driver was
  previously assigned, they are unassigned (with audit trail).

parameters:
  - name: shipmentId
    type: string
  - name: driverId
    type: string

validations:
  - check: shipment.exists(shipmentId)
    message: "Shipment not found"
  - check: driver.exists(driverId)
    message: "Driver not found"
  - check: shipment.status in ['created', 'in_transit', 'out_for_delivery']
    message: "Cannot assign driver to a {{ shipment.status }} shipment"
  - check: driver.activeShipmentCount < driver.capacity
    message: "Driver at capacity ({{ driver.activeShipmentCount }} / {{ driver.capacity }})"
  - check: driver.isCertifiedFor(shipment.vehicleId)
    message: "Driver not certified for vehicle {{ shipment.vehicleId }}"

effects:
  - unlink:
      from: Shipment[shipmentId]
      type: assignedDriver
      whenExists: true
  - link:
      from: Shipment[shipmentId]
      to:   Driver[driverId]
      type: assignedDriver
      properties:
        assignedAt: now()
  - update: Shipment[shipmentId]
    set: { status: if shipment.status == 'created' then 'in_transit' else shipment.status }
  - emit: DriverAssignedToShipment
    payload: { shipmentId, driverId, assignedAt: now() }

A few details worth noticing:

  • The action resolves a referenced driver capacity check through a function (driver.activeShipmentCount) rather than asking the caller for it.
  • The status transition is conditional — created → in_transit, but in_transit → in_transit is a no-op.
  • The unassign-and-reassign is part of one action — the caller does not orchestrate two actions to swap drivers.

Anti-patterns

Anti-pattern 1 — Anemic actions. updateShipmentStatus(shipmentId, newStatus) with no validation. Now every consumer must understand the state machine. Instead: one action per legal transition (markDispatched, markDelivered, markException).

Anti-pattern 2 — Chatty actions. Five separate actions called in sequence by the caller to do what should be one operation. Instead: one action that performs the whole logical step atomically.

Anti-pattern 3 — Reading from outside the validation block. Calling external systems during validation makes validation non-deterministic. Instead: if you need external data, fetch it before invoking the action and pass it as a parameter (or use a separate orchestration layer).

Anti-pattern 4 — Side effects with no events. State changed silently — no audit, no downstream notification. Instead: every state change either produces an emit-event or has a documented reason not to.

Anti-pattern 5 — Actions that escape the type system. Generic executeRawSql(query: string). Never add this. The whole point is that mutations are typed.

Naming actions

  • Verb-first, imperative: markDelivered, assignDriver, closeAccount.
  • Avoid update* — too generic. updateCustomer(...) becomes a catch-all that grows unbounded.
  • Each action expresses one business intent. If you find yourself naming an action updateCustomerAndPlaceOrder, you have two actions.

Key takeaways

  • Actions are the only legitimate way to change ontology state.
  • A good action has typed parameters, explicit validations, atomic effects, and an audit trail.
  • Idempotency is a feature, not an accident — design for it.
  • Prefer many small, intent-specific actions over a few generic ones.

What’s next

Writes are typed. Now we cover reads-with-compute: deriving values, exposing functions over the ontology, and building the typed compute layer.


Mutate with confidence. ✍️