Skip to main content
Version: Current

WolfDesk Example

A complex ticketing system demonstrating advanced Act patterns: partial states, vertical slices, cross-aggregate reactions, projections, business rules as named preconditions, and deferred-reaction timers.

Inspired by the ticketing system from "Learning Domain-Driven Design" by Vlad Khononov.

Source: packages/wolfdesk/src/

See it animated: WolfDesk ticket flows plays every action, state check, event, reaction and timer in five scenarios: open to resolved, the escalation chain, timers only, retries and blocking, and refused actions. How these are made.

Architecture​

bootstrap.ts → act().withSlice(Creation).withSlice(Messaging).withSlice(Ops).withSlice(Timers).withProjection(Projection).build()
ticket-creation.ts → TicketCreation state + TicketCreationSlice
ticket-messaging.ts → TicketMessaging state + TicketMessagingSlice
ticket-operations.ts → TicketOperations state + TicketOpsSlice
agent.ts → Agent state (the desk, open tickets per agent), its slice, and the slices that load it
ticket-timers.ts → TicketTimersSlice (deferred escalate / reassign / close timers)
ticket-projections.ts → TicketProjection (read model)
ticket-preconditions.ts → Business rules (preconditions)
schemas/ → Zod schemas for actions, events, state
services/ → Picking an agent from the agents table, notification stub

Patterns Demonstrated​

Partial States​

Three separate state definitions share the name "Ticket" and merge automatically:

// ticket-creation.ts
const TicketCreation = state({ Ticket: TicketCreationState })
.emits({ TicketOpened, TicketClosed, TicketResolved })
// ...

// ticket-messaging.ts
const TicketMessaging = state({ Ticket: TicketMessagingState })
.emits({ MessageAdded, AttachmentAdded })
// ...

// ticket-operations.ts
const TicketOperations = state({ Ticket: TicketOperationsState })
.emits({ TicketAssigned, TicketEscalated })
// ...

When composed via act().withSlice(...), these merge into a single "Ticket" state with all actions, events, and patches combined.

Vertical Slices​

Each feature is a self-contained slice with its state and reactions:

export const TicketCreationSlice = slice()
.withState(TicketCreation)
.withState(TicketOperations) // included so the reaction can dispatch AssignTicket
.on("TicketOpened")
.do(async function assign(event, _stream, app) {
const agent = await assignAgent(
event.stream,
event.data.supportCategoryId,
event.data.priority,
);
await app.do(
"AssignTicket",
{
stream: event.stream,
actor: { id: randomUUID(), name: "assign reaction" },
},
agent,
);
})
.build();

The slice declares TicketOperations via .withState() because its reaction dispatches AssignTicket — an action that lives on that partial state. Without that registration, the slice's app.do("AssignTicket", ...) call wouldn't typecheck.

Cross-Aggregate Reactions​

When a ticket is opened, the creation slice automatically assigns an agent by dispatching an action on the operations state. Notice the dispatch needs an explicit synthetic actor — reactions are system-driven, not user-driven, so the example mints { id: randomUUID(), name: "assign reaction" } for traceability:

.on("TicketOpened").do(async function assign(event, _stream, app) {
await app.do(
"AssignTicket",
{ stream: event.stream, actor: { id: randomUUID(), name: "assign reaction" } },
agent,
);
// reactingTo is auto-injected — the framework threads `event` into the new
// commit's correlation/causation chain. Pass an explicit 4th argument only
// when you want to override that default.
})

Preconditions on the state and the actor​

Rules that check the current state and the acting user, declared with .requires(...). Each is false at some point in a ticket's normal life (a closed ticket, another user), so each is a precondition of the actions that list it. They ignore the payload, typed over unknown, so every ticket action can use them:

// ticket-preconditions.ts
export const mustBeOpen: Precondition<{ status: string }, unknown> = {
description: "Ticket must be open",
holds: (_, state) => state.status === "open",
};

export const mustBeUserOrAgent: Precondition<
{ userId: string; agentId?: string },
unknown
> = {
description: "Must be ticket owner or assigned agent",
holds: (_, state, actor) =>
state.userId === actor?.id || state.agentId === actor?.id,
};

// Used in state builder
.on({ MarkTicketResolved })
.requires([mustBeOpen, mustBeUserOrAgent])
.emit((_, __, { actor }) => ["TicketResolved", { resolvedById: actor.id }])

Preconditions on the payload​

Every business rule in wolfdesk is declared, none is thrown from a handler, so the act CLI, the diagram and the flow animation list them all. A rule that reads the action's payload is typed over the part it reads, and sits in the same .requires(...) list:

export const messageExists: Precondition<
{ messages: Record<string, unknown> },
{ messageId: string }
> = {
description: "Message must exist",
holds: ({ messageId }, state) => !!state.messages[messageId],
};

export const mustBeReceiver: Precondition<
{ messages: Record<string, { to?: string }> },
{ messageId: string }
> = {
description: "Must be receiver to ack",
holds: ({ messageId }, state, actor) =>
state.messages[messageId]?.to === actor?.id,
};

.on({ AcknowledgeMessage })
.requires([mustBeOpen, messageExists, mustBeReceiver])
.emit("MessageRead")

A refused action throws PreconditionError, with the message "AcknowledgeMessage failed precondition: Must be receiver to ack"; its description names the rule.

Projections (Read Models)​

A standalone projection maintains a read model across all ticket events:

export const TicketProjection = projection("tickets")
.on({ TicketOpened })
.do(async function opened({ stream, data }) {
await db.insert(tickets).values({ id: stream, ...data });
})
.on({ TicketAssigned })
.do(async function assigned({ stream, data }) {
await db.update(tickets).set(data).where(eq(tickets.id, stream));
})
.on({ MessageAdded })
.do(async function messageAdded({ stream }) {
await db.update(tickets)
.set({ messages: sql`${tickets.messages} + 1` })
.where(eq(tickets.id, stream));
})
.build();

Deferred-Reaction Timers​

The ticket lifecycle needs a few things to happen on a clock: escalate an unanswered ticket after its SLA window, reassign an escalated ticket the user still hasn't answered, and close a ticket that's gone quiet. Wolfdesk drives all three from ticket-timers.ts as deferred reactions rather than a background polling loop. Each deadline already rides on the ticket's events, so TicketTimersSlice .defers to that instant, sleeps, and re-checks live state when it wakes. That wake-time check is the same load-and-guard a polling job would run against the read model, without the periodic scan.

Each automation leases its own per-ticket target (escalate:<id>, reassign:<id>, close:<id>), so a pending wait never blocks the ticket's hot-path reactions like assignment, messaging, and webhooks. The handler reads the source ticket from event.stream, not the synthetic target it holds.

Escalation is a one-shot that reacts to TicketAssigned and defers to the event's escalateAfter:

.on("TicketAssigned")
.defer((event) => ({ at: event.data.escalateAfter }))
.do(autoEscalate)
.to((event) => ({ target: `escalate:${event.stream}`, source: event.stream }))

Reassignment is a recurring chain. Both TicketEscalated and TicketReassigned land on the reassign:<id> target; because the escalation event carries no deadline, the handler reads reassignAfter from live state and re-arms imperatively with throw new DeferSignal({ at: state.reassignAfter }). Each ReassignTicket moves the deadline forward, so the resulting TicketReassigned schedules the next wait, and the loop stops once the user is answered or the ticket closes. Close-on-inactivity reacts to TicketOpened and defers to the open event's optional closeAfter, closing the ticket on waking if it's still open.

A Limit Across Streams​

An agent holds at most 5 open tickets. The count is a state of its own, Agent, one stream per agent, whose schema holds the limit. Assigning, reassigning, resolving and closing a ticket load it, and the event lands in the same commit as the ticket's own:

.on({ ReassignTicket }, { maxRetries: 4 })
.loads(Agent, (_, state) => state.agentId && agentStream(state.agentId), ["TicketDropped"], "current")
.loads(Agent, (data) => agentStream(data.agentId), ["TicketTaken"], "next")
.requires([mustBeOpen, mustBeEscalated, mustBeDueForReassignment, mustNotBeAcknowledgedByAgent])
.emit((data) => [
["TicketReassigned", data],
["TicketDropped", {}],
["TicketTaken", {}],
])

A ticket with no agent has no slot to free, so its current load returns undefined: nothing is loaded, and TicketDropped is dropped from the commit. The ticket's own version guards that fact, so if someone assigns it first, this action's retry sees the agent and loads them. When a pick finds a full agent, the assign reaction fails, and its retry (10 seconds later, by default) asks assignAgent for another agent. If every attempt finds a full agent, the ticket's reaction stream blocks after 4 tries, and app.unblock resumes it. A ticket holds its slot until it's resolved or closed, whichever comes first, and frees it once, so a resolved ticket retired by .autocloses doesn't keep one. A resolved ticket can't be assigned or reassigned. Rules across streams has the pattern on its own.

Picking an Agent​

AddAgent puts an agent on the desk, in one support category, and AgentProjection keeps an agents table with each agent's open tickets. The assign and reassign reactions ask that table for the agent with the fewest open tickets in the ticket's category.

The table only suggests. It can lag an agent's stream, and two tickets can pick the same last slot, so the commit decides: the loser's load sees the real count, Agent's schema refuses it, and the reaction's retry picks again from a table that has caught up.

When nobody is free, nothing has failed, so the reaction throws a DeferSignal and looks again in a minute. The ticket waits, unassigned, without blocking anything, because the reaction runs on its own target. It stops looking if the ticket is closed, resolved or assigned by hand meanwhile.

Composition​

Everything is wired together in bootstrap.ts:

export const app = act()
.withSlice(TicketCreationSlice)
.withSlice(TicketMessagingSlice)
.withSlice(TicketOpsSlice)
.withSlice(TicketTimersSlice)
.withProjection(TicketProjection)
.build();

Running​

pnpm dev:wolfdesk
pnpm -F wolfdesk test