Skip to content
@tde.io/plexis  ·  npm install @tde.io/plexis  ·  npm  ·  GitHub

Domain

This guide walks through the most common domain patterns: defining phases and events, guarding events, running lifecycle hooks, subscribing to changes, and persisting flow state with snapshots. For complete signatures and field tables, see the Definition Functions, Registration Helpers, and Domain Instance reference pages.

A Domain models a durable business flow that persists across time. It has named phases (declared with when), events (on) that move it between phases, lifecycle hooks, and can invoke pipelines on events.

Use defineDomain() to create a domain. The setup function runs synchronously and registers phases using the when() helper.

import { defineDomain, when, on, target, terminal } from '@tde.io/plexis';
type OrderContext = { orderId: string | null };
const order = defineDomain<OrderContext>('order', () => {
when('pending', () => {
on('submit', target('processing'));
});
when('processing', () => {
on('fulfill', target('fulfilled'));
});
when('fulfilled', terminal());
return {
context: { orderId: null },
initial: 'pending',
} as const;
});

The as const annotation on the return value enables TypeScript to narrow the allowed event names in follow() and can().

Register phases with when(id, fn | terminal()). The setup function receives no arguments; it calls scoped helpers to configure the phase:

HelperDescription
on(event, def)Register a named event out of this phase
enter(fn)Runs when the domain enters this phase
exit(fn)Runs when the domain exits this phase

Pass terminal() as the second argument to register a terminal phase with no further behavior.

Register an event on a phase using on(event, target(id)) (simple form) or on(event, () => { ...; return target(id); }) (setup function form) inside a when setup:

when('pending', () => {
// Setup function form — attach guard, action, pipeline
on('submit', () => {
guard((ctx) => ctx.orderId !== null);
action(async (ctx) => ({ submitted: true }));
return target('processing');
});
// Simple form — direct target
on('cancel', target('cancelled'));
});

guard(fn) registers a guard predicate. If it returns false, the event is blocked — no hooks or actions run.

action(fn) registers an event action. It receives the current context and an ActionInput (event name, payload, trace ID) and returns a context patch merged as { ...prev, ...patch }.

pipeline(p) attaches a pipeline to run during the event.

Each of guard, action, and pipeline may be called at most once per on setup. A second call throws DUPLICATE_REGISTRATION.

enter and exit run when the domain enters or leaves a phase. Call them inside the when setup function. Both receive the current context and a PhaseHookInput (event name, payload, trace ID). Both can return context patches.

when('processing', () => {
enter(async (ctx, { event }) => {
console.log(`Entered processing via ${event}`);
return { enteredAt: Date.now() };
});
exit(async (ctx) => {
console.log('Leaving processing');
});
on('fulfill', target('fulfilled'));
});

Execution order on follow(event, payload):

  1. Guard check — blocks if false; no side effects run
  2. exit for the current phase
  3. on action
  4. on pipeline
  5. Phase change to target
  6. enter for the target phase
  7. Phase entry pipeline
  8. History recorded, subscribers notified

Call domain.follow(event, payload?) to trigger an event.

const result = await order.follow('submit');
console.log(result.status); // 'followed'
console.log(result.from); // 'pending'
console.log(result.to); // 'processing'
console.log(result.context); // updated context

See DomainFollowResult for the complete field reference.

Use domain.can(event) to test whether an event can be followed from the current phase without triggering any side effects:

const canSubmit = await order.can('submit');

Subscribe to phase changes:

const unsub = order.subscribe((snapshot) => {
console.log(snapshot.phase, snapshot.context);
});
// Later:
unsub();

Retrieve the event history:

const entries = order.history();
// Each entry: { from, to, event, payload?, context, timestamp, traceId }

snapshot() captures the current phase and context at a point in time. restore() winds the domain back to that captured point — useful for undo/redo, testing, and persisting flow state.

// Persist wizard flow to localStorage so the user can resume later
const snap = order.snapshot();
localStorage.setItem('order-flow', JSON.stringify(snap));
// On page reload — restore exactly where the user left off
const saved = localStorage.getItem('order-flow');
if (saved) order.restore(JSON.parse(saved));

In tests, snapshots let you jump the domain to any starting phase without replaying events:

const processingSnapshot = {
phase: 'processing',
context: { orderId: '123' },
historyLength: 1,
};
order.restore(processingSnapshot);
// Now test events from 'processing' directly
const result = await order.follow('fulfill');

Pass strict: true in the setup return to throw a PlexisError when an unknown event is sent (instead of returning status: 'ignored'):

return { context: {}, initial: 'pending', strict: true } as const;

Using as const on the setup return value lets TypeScript infer the exact set of valid event names:

const order = defineDomain('order', () => {
when('pending', () => { on('submit', target('processing')); });
when('processing', terminal());
return { context: {}, initial: 'pending' } as const;
});
await order.follow('submit'); // ✓ OK
await order.follow('cancel'); // ✗ TypeScript error