ADR-0014: Generic State Machine in Shared Kernel

Status

Accepted

Tags

fieldforce, state-machine, shared-kernel, ddd, reuse

Decision

The generic StateMachine[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 Trigger implementation (guard execution, error handling, transition lookup), not just the interface. Duplicating it per module defeats the purpose. Rejected.

How it works

internal/shared/domain/statemachine/
  machine.go      → StateMachine[TState, TEvent comparable]
  transition.go   → Transition[TState, TEvent]{ From, Event, To, Guard, Action }
  errors.go       → ErrInvalidTransition{ From, Event string }

internal/modules/fieldforce/domain/statemachine/
  task_machine.go → NewTaskStateMachine() — registers all 9 transitions
Trigger flow:
Trigger(ctx, currentState, event, tctx)
  1. Find Transition where From == currentState AND Event == event
  2. Run Guard(tctx) — returns ErrInvalidTransition if role or state invalid
  3. Run Action(tctx) — optional side-effect (e.g. append ReviewNote to tctx)
  4. Return (To, nil) — caller is responsible for persisting the new state
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
  • Trigger is a pure function — never call repo.Update or any I/O inside a Guard or Action; return the new state and let the service layer persist
  • ErrInvalidTransition is the sentinel for all guard failures — callers type-assert to extract From and Event for error messages
  • Do not add role checks in the HTTP handler that duplicate guard logic in the task machine’s transitions

Bad pattern (do not generate)

// Module-local state machine — requires migration when next module needs it
package fieldforce

type TaskStateMachine struct{} // wrong — put in internal/shared/domain/statemachine

// Persisting inside an Action — breaks pure-function contract
statemachine.Transition[TaskStatus, TaskEvent]{
    Action: func(tctx statemachine.TransitionContext) error {
        repo.Update(ctx, task) // wrong — Action must not do I/O
        return nil
    },
}

Good pattern

// Shared kernel type imported by fieldforce
import "github.com/waynecheah/go-core/internal/shared/domain/statemachine"

func NewTaskStateMachine() *statemachine.StateMachine[TaskStatus, TaskEvent] {
    return statemachine.New([]statemachine.Transition[TaskStatus, TaskEvent]{
        {
            From:  TaskStatusPending,
            Event: EventStart,
            To:    TaskStatusInProgress,
            Guard: requireRole("field_team", "supervisor", "manager"),
        },
        // ... 8 more transitions
    })
}

// Service calls Trigger, then persists the result
newStatus, err := taskMachine.Trigger(ctx, task.Status, event, tctx)
if err != nil { return nil, err }
task.Status = newStatus
return repo.Update(ctx, task) // persistence is the caller's responsibility