ADR-0014: Generic State Machine in Shared Kernel
Status
AcceptedTags
fieldforce, state-machine, shared-kernel, ddd, reuseDecision
The genericStateMachine[TState, TEvent comparable] type lives at internal/shared/domain/statemachine/, not inside the fieldforce module. It uses a pure-function style: Trigger(ctx, currentState, event, transitionContext) → (newState, error). The fieldforce task machine registers its 9 transitions using this shared type at internal/modules/fieldforce/domain/statemachine/task_machine.go.
Why
Fieldforce was the first module to need a state machine. Placing it inside the fieldforce module has zero cost today, but the moment a second module (leave approval, order lifecycle) needs the same primitive, every import path referencing the fieldforce-local type must change. In a monorepo with a single Go module, placing it in the shared kernel costs the same now and avoids migration later. Rejected alternatives:- Fieldforce-local state machine: Zero cost upfront. Migration to shared requires touching every module that adopted the fieldforce-local path. Rejected.
- Interface-only shared kernel, implementations per module: The value is the
Triggerimplementation (guard execution, error handling, transition lookup), not just the interface. Duplicating it per module defeats the purpose. Rejected.
How it works
TransitionContext is map[string]any carrying role, review_note, user IDs. The machine never persists anything — it is a pure computation.
Rules for agents
- Any module that models lifecycle state transitions MUST use
internal/shared/domain/statemachine/StateMachine— do not create module-local machine types Triggeris a pure function — never callrepo.Updateor any I/O inside aGuardorAction; return the new state and let the service layer persistErrInvalidTransitionis the sentinel for all guard failures — callers type-assert to extractFromandEventfor error messages- Do not add role checks in the HTTP handler that duplicate guard logic in the task machine’s transitions