Skip to main content

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's apply/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)

Options weighed (no winner declared)

OptionParadigm fitMaturityDependencyNotes
Hand-rolled functional decider — pure Decide → Result<(status, Effect[])> in the aggregateidealyou own itnoneSmall for a fixed graph (~150–250 LOC + tests). Guards = the PoC's canTransition blockers as a railway.
Statelessgood, needs disciplinemature (6.2k★, dep-free, .NET 10)small libMutable idiom; keep all side-effects out of the machine. Can emit effects via an entry/exit collector. Mermaid export = free docs diagram.
FunctionalStateMachineideal (Fire → (state, commands))young (~76★, ~1 maintainer)MIT libRight paradigm off-the-shelf; bus-factor risk at the core of a compliance product.
Automatonymous / MassTransitwrong scope herediscontinued standalone (folded into MassTransit v8)bus + infraEvent-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.