Workflow decision machine — options & discussion
Status: RESOLVED (2026-08-18). Decision: plain DDD — a guarded state machine on the Case aggregate (guarded transition methods that mutate status, stamp the legal date, and raise a domain event; no public setter), driven by a transactional command handler per user-action, with side-effects as domain-event handlers on commit (E15) and deadlines as scheduled jobs. It is not an external-settlement saga: the Koltena payments engine's handler-map / witness / inbound-settlement machinery solves async, untrusted, out-of-process provider confirmations — a problem this workflow does not have (transitions are internal, synchronous user actions). We keep the shared house idioms (aggregate, UnitOfWork, domain events,
Result<T>) and drop the saga ceremony. Captured in PRD-004 (Abertura) and the E03 roadmap. The discussion below is retained as the record of how we got here.
"Decision machine" is our neutral umbrella term for the pure function that governs how a case moves between phases — whatever we end up calling it. It sidesteps the state-machine-vs-reducer labelling below; both are framings of the same thing.
The concept (why the naming is fuzzy)
Both a state machine and a reducer are pure (state, input) → … functions; they differ in
intent:
- Reducer / fold —
(state, event) → state. Derives the next state. Cannot reject input; side-effects live elsewhere. (Redux/Elm; event-sourcing'sapply/evolve.) - State machine — explicit states + allowed edges; its job is constraint: illegal transitions are rejected. (UML/Harel; workflows.)
- Decider — the hybrid we keep gravitating to: a pure function that judges legality
and returns effects, e.g.
decide(state, trigger) → Result<(state, effects)>. It's a state machine in intent (it rejects) with a reducer-ish shape (pure, returns effects). If we ever event-source, the second half —evolve(state, event) → state— is the pure reducer that folds committed events back into state.
The one-question test: does illegal input get rejected, or silently folded? A compliance workflow must reject → so the constraint (state-machine) half is non-negotiable; the reducer half only appears if/when we adopt event sourcing.
What our workflow needs (from the PoC autopsy)
- Server-authoritative — not the PoC's bypassable client guards (client-side integrity).
- Transactional side-effects — the transition's effects (stamp legal dates, spawn checklist, open gates, write audit) run in one commit (non-atomic transitions).
- Pure & testable — every legal rule pinned by a cited test (zero test coverage).
- One canonical status vocabulary (status generations).
Options weighed (no winner declared)
| Option | Paradigm fit | Maturity | Dependency | Notes |
|---|---|---|---|---|
Hand-rolled functional decider — pure Decide → Result<(status, Effect[])> in the aggregate | ideal | you own it | none | Small for a fixed graph (~150–250 LOC + tests). Guards = the PoC's canTransition blockers as a railway. |
| Stateless | good, needs discipline | mature (6.2k★, dep-free, .NET 10) | small lib | Mutable idiom; keep all side-effects out of the machine. Can emit effects via an entry/exit collector. Mermaid export = free docs diagram. |
| FunctionalStateMachine | ideal (Fire → (state, commands)) | young (~76★, ~1 maintainer) | MIT lib | Right paradigm off-the-shelf; bus-factor risk at the core of a compliance product. |
| Automatonymous / MassTransit | wrong scope here | discontinued standalone (folded into MassTransit v8) | bus + infra | Event-driven saga engine. Revisit only if the workflow becomes a distributed saga across services. |
The Koltena Result<T> angle
Our house Result<T> (railway monad: Map/Bind/Filter/Tap + LINQ Select/SelectMany
do-notation, sync & async) makes a hand-rolled decider read naturally — the guards become a
short-circuiting pipeline and the output is (status, Effect[]) the command handler runs
transactionally. Watch-outs in that Result<T>: Map/Bind swallow exceptions into
generic MAP_ERROR/BIND_ERROR (prefer explicit domain errors in a legal decider), and
Tap logs to Console.WriteLine (wire real logging). Porting Result<T> into MyLegalTeam
would be a small foundation piece.
Framework context
.NET/ASP.NET ship no native domain state-machine or reducer — by design. The BCL has
compiler-generated state machines (async/yield), orchestration is a separate product
(Durable Task / Dapr Workflow), the old Workflow Foundation faded, and reducers are a
frontend idea (Fluxor). In DDD the idiom is "no library": transitions are aggregate methods
- a pure decision function. So hand-rolling is on-the-grain, not a workaround.
Current leaning (soft, revisit)
A hand-rolled functional decider expressed in the Result<T> railway, effects executed
transactionally by the command handler; a library (Stateless) only if we want an
off-the-shelf graph + Mermaid. Not decided.
Related
- Roadmap theme E03 — Case domain & the decision machine
- Reducer vs state machine · the PoC's FSM