Skip to main content
Version: Current

WolfDesk Example

A complex ticketing system demonstrating advanced Act patterns: partial states, vertical slices, cross-aggregate reactions, projections, invariants with actor context, and deferred-reaction timers.

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

Source: packages/wolfdesk/src/

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-invariants.ts โ†’ Business rules
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.
})

Invariants with Actor Contextโ€‹

Business rules that check both state and the acting user:

// ticket-invariants.ts
export const mustBeOpen: Invariant<{ status: string }> = {
description: "Ticket must be open",
valid: (state) => state.status === "open",
};

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

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

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();

Custom Error Typesโ€‹

Domain-specific errors for business logic:

export class TicketCannotOpenTwiceError extends Error {
constructor(stream: string) {
super(`Ticket ${stream} is already open`);
}
}

Runningโ€‹

pnpm dev:wolfdesk
pnpm -F wolfdesk test