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.