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

Guards vs Forks

Guards and forks both involve a boolean condition, but they live at different layers and have different effects. A guard stops a domain event; a fork routes a pipeline to a branch. Choosing the right one is mostly about which layer owns the decision.

A guard lives on an event (OnDef.guard) and is evaluated before any side effects run. If it returns false, the event is blocked: no exit, no action, no pipeline, no phase change, no history entry. The domain stays exactly where it was.

import { defineDomain, when, on, terminal } from '@tde.io/plexis';
type Ctx = { balance: number; amount: number };
const account = defineDomain<Ctx>('account', () => {
when('open', () => {
on('withdraw', () => {
// Guard: block the event entirely if there are insufficient funds
guard(async (ctx) => ctx.balance >= ctx.amount);
action(async (ctx) => ({ balance: ctx.balance - ctx.amount }));
return target('open');
});
});
return { context: { balance: 100, amount: 0 }, initial: 'open' };
});
const result = await account.follow('withdraw');
// result.status === 'blocked' — guard returned false, nothing ran

The DomainFollowResult.status will be 'blocked' when a guard rejects the event.

A fork lives on a pipeline node’s forks array. Unlike a guard, forks do not block execution — they route it. The pipeline always continues; the fork decides which direction it takes.

import { definePipeline, node, action, fork, target, terminal } from '@tde.io/plexis';
type Ctx = { amount: number; fundsSufficient: boolean };
const process = definePipeline<Ctx>('process', () => {
node('check-funds', () => {
action(async (ctx) => ({ fundsSufficient: ctx.amount <= 100 }));
fork('charge', target('charge'), (ctx) => ctx.fundsSufficient);
fork('decline', target('decline'), (ctx) => !ctx.fundsSufficient);
});
node('charge', terminal());
node('decline', terminal());
return { initial: 'check-funds' };
});
const result = await process.run({ amount: 150, fundsSufficient: false });
// Pipeline always runs to a terminal — it routes to 'decline', not blocked
// result.finalNode === 'decline'
// result.status === 'completed'
SituationUse
Block a domain event before any side effects runGuard
Route a pipeline to a success or failure branchFork
The decision is about “can this event happen at all?”Guard
The decision is about “what should happen next in this workflow?”Fork
The entity stays in the same phase when deniedGuard
The workflow always ends at some terminalFork

Guards protect domain integrity. They are the right place for invariants that must hold before a phase change is allowed — balance checks, role checks, status pre-conditions.

Forks encode workflow logic. They are the right place for conditional routing that should be captured in the audit trail, traced, and part of the pipeline’s declared graph.

Yes. A domain event can have a guard and a pipeline. If the guard passes, the pipeline runs as part of the event. Inside that pipeline, forks route the work.

import { defineDomain, definePipeline, when, on, target, guard, pipeline, enter, exit, node, action, fork, terminal } from '@tde.io/plexis';
type Ctx = { balance: number; amount: number; charged: boolean };
const charge = definePipeline<Ctx>('charge', () => {
node('attempt', () => {
action(async (ctx) => ({ charged: true }));
fork('next', target('done'));
});
node('done', terminal());
return { initial: 'attempt' };
});
const account = defineDomain<Ctx>('account', () => {
when('open', () => {
on('withdraw', () => {
guard(async (ctx) => ctx.balance >= ctx.amount); // blocks if insufficient
pipeline(charge); // runs if guard passes
action(async (ctx) => ({ balance: ctx.balance - ctx.amount }));
return target('open');
});
});
return {
context: { balance: 100, amount: 50, charged: false },
initial: 'open',
};
});

For full type signatures, see OnDef in the Registration Helpers reference and PipelineForkDef in the Registration Helpers reference.