← All weekly notes

7 min read

A role-aware state machine in Go

How I represented vehicle states, transition rules and role permissions in a small concurrency-safe Go package.

Go · State machines · Concurrency · Authorization

I started AVSM in June 2019 as an abstract vehicle state machine. The goal was to model a vehicle lifecycle while also deciding which user roles were allowed to perform each transition.

The package had a few useful constraints: invalid transitions should return descriptive errors, the transition check should be fast enough for a soft real-time API and the library should not depend on an external state store. The caller creates a Vehicle, supplies its lifecycle state and asks the package to validate each transition.

The domain

The model has nine vehicle states:

  • Ready: available to a rider.
  • Riding: currently used by an end user.
  • BatteryLow: operational but no longer available to riders.
  • Bounty: available for a hunter to collect and charge.
  • Collected: picked up by a hunter.
  • Dropped: returned after charging.
  • ServiceMode: removed from normal operation.
  • Terminated: permanently retired.
  • Unknown: state cannot be established.

It also has four roles:

  • EndUser: a rider using the normal customer flow.
  • Hunter: a person collecting and charging vehicles.
  • Automatic: a system-triggered transition such as a battery event.
  • Admin: a privileged operator allowed to move to any registered state.

The main lifecycle can be pictured as two connected paths:

Ready ── EndUser or Hunter ──→ Riding ── EndUser or Hunter ──→ Ready                                   │                              Automatic                                   ↓                              BatteryLow                                   │                              Automatic                                   ↓                                Bounty                                   │                                Hunter                                   ↓                              Collected                                   │                                Hunter                                   ↓                               Dropped ── Hunter ──→ Ready

Admin is treated as an override and can move the vehicle to any state that the machine knows about.

Representing states and roles

The package gives states and roles their own Go types. This makes function signatures clearer than passing arbitrary strings everywhere:

type State string const (	Ready       = "Ready"	Riding      = "Riding"	BatteryLow  = "BatteryLow"	Bounty      = "Bounty"	Collected   = "Collected"	Dropped     = "Dropped"	ServiceMode = "ServiceMode"	Terminated  = "Terminated"	Unknown     = "Unknown") type Role string const (	Automatic = "Automatic"	Admin     = "Admin"	EndUser   = "EndUser"	Hunter    = "Hunter")

The public transition method then communicates the domain directly:

func (v *Vehicle) StateTransition(toState State, role Role) error

One improvement would be to declare each constant with its explicit type, for example Ready State = "Ready". That prevents an untyped constant from being accepted in places where the domain type should be enforced.

A transition table with role permissions

The main design choice is a nested map:

type Vehicle struct {	state       State	mu          sync.RWMutex	transitions map[State]map[State][]Role}

The first key is the current state. The second key is the destination. The value is the list of roles permitted to use that edge.

transitions := map[State]map[State][]Role{	Ready: {		Bounty: {Automatic},		Riding: {Admin, EndUser, Hunter},		Unknown: {Automatic},	},	BatteryLow: {		Bounty: {Automatic},	},	Bounty: {		Collected: {Hunter},	},	Riding: {		Ready:      {Admin, EndUser, Hunter},		BatteryLow: {Automatic},	},	Collected: {		Dropped: {Hunter},	},	Dropped: {		Ready: {Hunter},	},}

This representation makes a transition lookup direct:

rules[current state][requested state] → permitted roles

For a small role list, scanning the slice is inexpensive and easy to read. If the number of roles grew significantly, the value could become a set or a bit mask for constant-time membership checks.

Validating one transition

StateTransition first validates the machine and destination state. It then handles the admin override before checking the normal edge permissions.

func (v *Vehicle) StateTransition(toState State, role Role) error {	v.mu.Lock()	defer v.mu.Unlock() 	if v.transitions == nil {		return newErrorStruct(			"no states added to the Vehicle",			ErrorVehicleNotInitialized,		)	} 	if _, ok := v.transitions[toState]; !ok {		return newErrorStruct(			fmt.Sprintf("state %s has not been registered", toState),			ErrorStateUndefined,		)	} 	if role == Admin {		v.state = toState		return nil	} 	roles, ok := v.transitions[v.state][toState]	if !ok {		return newErrorStruct(			fmt.Sprintf("transition from state %s to %s is not permitted", v.state, toState),			ErrorTransitionNotPermitted,		)	} 	for _, permittedRole := range roles {		if role == permittedRole {			v.state = toState			return nil		}	} 	return newErrorStruct(		fmt.Sprintf("role %s cannot move %s to %s", role, v.state, toState),		ErrorRolePermissionDenied,	)}

The write lock makes validation and mutation one operation. Without it, two callers could both validate against the same old state and then overwrite each other's result.

The distinction between an invalid edge and an invalid role is useful. Ready → Collected is not part of the lifecycle at all, while BatteryLow → Bounty is a valid edge that only Automatic may perform.

Structured errors

The package does not rely only on error text. It gives every failure a stable code:

type ErrorCode uint const (	ErrorUnknown ErrorCode = iota	ErrorVehicleNotInitialized	ErrorTransitionNotPermitted	ErrorRolePermissionDenied	ErrorStateUndefined) type Error struct {	message string	code    ErrorCode} func (e *Error) Error() string {	return fmt.Sprintf("%s (%d): %s", e.code, e.code, e.message)} func (e *Error) Code() ErrorCode { return e.code }

This lets an API map domain failures to stable responses without parsing English messages. A caller can distinguish an unregistered state from a permission failure and decide whether to return a validation error, authorization error or internal configuration error.

Today I would expose the fields through methods as this package does, but I would also support errors.Is or errors.As so callers can use Go's standard error inspection patterns.

Reading state safely

CurrentState uses a read lock:

func (v *Vehicle) CurrentState() State {	v.mu.RLock()	defer v.mu.RUnlock() 	return v.state}

Several readers can inspect the state concurrently, while a transition takes the exclusive write lock.

There is one locking problem in the original setup method. SetStateTransitionRules takes RLock but writes both state and transitions. A read lock does not protect writes and should be replaced with Lock:

func (v *Vehicle) SetStateTransitionRules() {	v.mu.Lock()	defer v.mu.Unlock() 	v.state = Ready	v.transitions = defaultTransitions()}

An even cleaner design is to initialize the object completely before returning it, so callers cannot forget the setup step:

func NewVehicle(initial State) (*Vehicle, error) {	rules := defaultTransitions()	if _, ok := rules[initial]; !ok {		return nil, ErrStateUndefined	} 	return &Vehicle{		state:       initial,		transitions: rules,	}, nil}

Testing paths and rejected transitions

The tests cover a complete successful journey:

v := Vehicle{}v.SetStateTransitionRules() _ = v.StateTransition(Riding, EndUser)_ = v.StateTransition(BatteryLow, Automatic)_ = v.StateTransition(Bounty, Automatic)_ = v.StateTransition(Collected, Hunter)_ = v.StateTransition(Dropped, Hunter)_ = v.StateTransition(Ready, Hunter)

They also verify the two important rejection cases:

err := v.StateTransition(Collected, Hunter)// Ready → Collected is not a registered edge. _ = v.StateTransition(Riding, EndUser)err = v.StateTransition(BatteryLow, Hunter)// Riding → BatteryLow exists, but Hunter lacks permission.

For a production package, I would make the tests table-driven and check the returned error after every transition. I would also run go test -race, test concurrent readers and writers and assert that a failed transition never changes the current state.

A stateless alternative

The package does not use global state or external persistence, but each Vehicle still contains its current state. If the surrounding service already owns persistence, the state-machine logic can be made completely pure:

func NextState(from, to State, role Role) (State, error) {	if err := validateTransition(from, to, role); err != nil {		return from, err	}	return to, nil}

The API loads a vehicle record, calls NextState and saves the returned state with optimistic concurrency control. This avoids keeping duplicate state in a long-lived Go object and works naturally when requests are handled by several service instances.

The transition table can remain immutable and shared by all calls. Authorization remains part of the domain rule rather than being scattered across HTTP handlers.

What I want to remember

A state machine becomes much easier to reason about when transitions are data. The nested map made valid edges and their permitted roles visible in one place, while structured errors explained why a request was rejected.

The important boundary is the atomic check-and-change operation. Whether the state lives inside a locked object or in a database record, the transition must be validated against the same version of the state that is updated.

For a reusable library, I would now prefer an immutable transition table, a constructor that cannot return an uninitialized machine and a pure NextState function when persistence belongs to the caller.

Source