Case workflow — the state machine (as built)
How the case workflow is actually implemented. The decision machine note records why we hand-rolled a decider rather than take a library or a saga; this note is the what — the engine, the declared graph, and how a transition flows from HTTP to a persisted status. The authorization axis (who may fire a command) is a separate concern — see Case authorization.
Two pieces: a reusable engine + a declared graph
The engine (MyLegalTeam.Domain/Common/StateMachine) is generic and case-agnostic. The case graph
(MyLegalTeam.Domain/Cases/CaseWorkflow) is data over it — one declaration that is the single source of
truth for enforcement, the available-transitions read model, and the generated TypeScript.
StateMachine<TState, TCommand, TData>
├─ Fire(command, from, data) -> FireResult<TState,TData> (the write: judge + produce effects)
└─ AvailableFrom(from, data) -> IReadOnlyList<AvailableTransition<TState>> (the read model)
The graph, declared
CaseWorkflow.Machine is built with a fluent builder — each edge names its trigger command, an optional
data precondition (.When), an optional legal guard (.Guard), the events it emits, and its
target state:
StateMachineBuilder<CaseStatus, CaseCommand, CaseData>.Create()
.StartWith(new CaseStatus.Intake())
.For(new CaseStatus.Intake())
.On<CaseCommand.OpenPreliminaryInquiry>()
.When(data => data.HasPreliminaryInquiry) // data precondition — is this edge offered at all?
.Guard(Caducidade) // legal guard — is it currently allowed?
.Emit((data, _) => new PreliminaryInquiryOpened(data.CaseId))
.TransitionTo(new CaseStatus.PreliminaryInquiry())
.On<CaseCommand.IssueCharges>()
.When(data => !data.HasPreliminaryInquiry)
.Guard(Caducidade)
.Emit((data, _) => new ChargesIssued(data.CaseId))
.TransitionTo(new CaseStatus.ChargesIssued())
.On<CaseCommand.Suspend>()
.Modify((data, _) => data with { SuspendedFrom = new CaseStatus.Intake() })
.Emit((data, command) => new CaseSuspended(data.CaseId, command.Reason))
.TransitionTo(new CaseStatus.Suspended())
.Build();
Statuses and commands are closed sealed record families (CaseStatus, CaseCommand) — the compiler knows
every state and trigger, so "unknown case" is unrepresentable.
The two axes of a transition
An edge has two independent gates, and the distinction matters:
.When— data precondition. Is this edge part of the graph for this case's data at all? (A case with a preliminary inquiry gets theOpenPreliminaryInquiryedge; one without getsIssueCharges.) A failed.Whenmeans the edge simply isn't offered..Guard— legal guard. The edge exists, but is it currently allowed? A guard names why it blocks and whether an override is permitted. The one guard today is caducidade — 60 days from knowledge of the infraction (art. 329.º CT),Overridable = true.
Guard<CaseData> Caducidade = new(
Rule: "caducidade",
Message: "60 days from knowledge of the infraction have elapsed.",
Overridable: true,
Blocks: data => data.Today > data.KnowledgeDate.AddDays(60));
A third axis — who may fire the command — is deliberately not in the machine (roles never enter
CaseData, so the decider stays pure). It lives in the policy: Case authorization.
Firing: the outcome is a closed result
Fire returns a FireResult — a closed family the caller folds into the Result<T> railway:
FireResult | Meaning | Rendered as |
|---|---|---|
Moved(State, Data, Events) | the transition applied | 200 + the new status |
Blocked(GuardResult[]) | a guard blocked it (each guard's Rule / Message / Overridable) | 409, the guard identities in the error body |
NotAllowed | no such edge from the current state | 409 InvalidTransition |
The Case aggregate owns status: its guarded Advance method calls the machine, applies a Moved
outcome to itself (mutating status + stamping the legal date, no public setter), and raises the emitted
events. The command handler runs that in one transaction; domain-event handlers fire on commit
(side-effects are transactional, not inline). Illegal input is rejected, never silently folded — the
non-negotiable half of a compliance workflow.
The read model: AvailableFrom
The same graph answers "what can this case do now?" without duplicating any rules. AvailableFrom folds the
edges out of the current state into AvailableTransition rows — the command, its derived target status,
whether it's Allowed (data + guards), and the GuardResults blocking it. This is the catamorphism the
frontend's available-transitions screen renders; the authorization layer adds a canFire flag on top (see
the authorization note).
One graph, three consumers — including the browser
Because the graph is a single declaration, nothing re-implements it:
- Enforcement —
Fire, in the write path. - Read model —
AvailableFrom, for the FE. - The browser —
StateMachineTypeScriptfolds the same graph into generated TypeScript, so the client never hand-maintains a parallel copy of the transition rules (the PoC's bypassable client guards were a root cause we designed this out of — see the client-side-integrity PoC note).
Related
- Workflow decision machine — options & discussion — why a hand-rolled decider
- Case authorization — the third axis (who may fire a command)
- Spec:
MyLegalTeam.Domain/Cases/BEHAVIOUR.CaseWorkflow.md,.../Common/StateMachine/BEHAVIOUR.StateMachine.md