Skip to main content

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 the OpenPreliminaryInquiry edge; one without gets IssueCharges.) A failed .When means 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:

FireResultMeaningRendered as
Moved(State, Data, Events)the transition applied200 + the new status
Blocked(GuardResult[])a guard blocked it (each guard's Rule / Message / Overridable)409, the guard identities in the error body
NotAllowedno such edge from the current state409 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:

  1. EnforcementFire, in the write path.
  2. Read modelAvailableFrom, for the FE.
  3. The browserStateMachineTypeScript folds 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).