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
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/ → External service stubs (agent assignment, notifications)
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 = 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.
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