ssm

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 15, 2026 License: MIT Imports: 2 Imported by: 0

README

ssm - Simple State Machine for Go

A minimal, generic state machine library for Go. Zero dependencies. Two type-safe modes: simple transitions and event-driven.

SSM is a decision engine, not an execution engine. It validates whether a transition is legal and reports what would happen. It does not mutate your entities or execute side effects — that's the caller's responsibility.

go get github.com/karolusz/ssm

Simple Mode

When the caller knows the target state directly.

type OrderStatus string

const (
    Created   OrderStatus = "created"
    Paid      OrderStatus = "paid"
    Shipped   OrderStatus = "shipped"
    Delivered OrderStatus = "delivered"
    Cancelled OrderStatus = "cancelled"
)

type Order struct {
    ID     string
    Status OrderStatus
}

sm, err := ssm.New(
    func(o Order) OrderStatus { return o.Status },
    []ssm.Transition[Order, OrderStatus]{
        {From: Created, To: Paid},
        {From: Created, To: Cancelled},
        {From: Paid, To: Shipped},
        {From: Paid, To: Cancelled},
        {From: Shipped, To: Delivered},
    },
)

order := Order{ID: "123", Status: Created}

err := sm.Can(order, Paid)       // nil — transition allowed
err := sm.Can(order, Delivered)  // error — no transition from created to delivered

// The caller mutates and persists
order.Status = Paid
repo.Save(order)

Event Mode

When transitions are triggered by named events. The SM resolves the target state.

type Event string

const (
    Submit  Event = "submit"
    Approve Event = "approve"
    Reject  Event = "reject"
)

sm, err := ssm.NewWithEvents(
    func(p Payment) Status { return p.Status },
    []ssm.EventTransition[Payment, Event, Status]{
        {On: Submit,  From: Pending, To: Pending},   // self-transition
        {On: Approve, From: Pending, To: Approved},
        {On: Reject,  From: Pending, To: Rejected},
    },
)

result, err := sm.Can(payment, Approve)
// result.From = Pending, result.To = Approved, result.Event = Approve

// Self-transition detection
result, err := sm.Can(payment, Submit)
result.Reentry() // true

// What events are available?
sm.AvailableEvents(Pending) // [submit, approve, reject]

Guards

Guards are functions that conditionally allow or block a transition.

maxRetries := func(p Payment) error {
    if p.RetryCount >= 3 {
        return fmt.Errorf("max retries exceeded")
    }
    return nil
}

ssm.Transition[Payment, Status]{
    From: Retry, To: Retry, Guard: maxRetries,
}

Compose guards with combinators:

ssm.All(guard1, guard2)  // all must pass
ssm.Any(guard1, guard2)  // at least one must pass
ssm.Not(guard1)          // negate

Error Handling

All transition failures wrap ErrTransitionDenied for generic checks:

if errors.Is(err, ssm.ErrTransitionDenied) {
    // any transition failure
}

Use errors.As to extract details:

// Simple mode
var notFound ssm.ErrTransitionNotFound[OrderStatus]
if errors.As(err, &notFound) {
    fmt.Printf("no transition from %v to %v", notFound.From, notFound.To)
}

// Event mode
var notPermitted ssm.ErrEventNotPermitted[Event, Status]
if errors.As(err, &notPermitted) {
    fmt.Printf("event %v not permitted in state %v", notPermitted.Event, notPermitted.State)
}

Design Choices

  • Pure validation — the SM checks if a transition is legal and returns. It does not mutate entity state, execute side effects, or manage persistence. This keeps it composable with any execution pattern (transactions, event sourcing, CQRS).
  • Immutable after construction — both machine types are safe for concurrent use without locking.
  • Struct literal configuration — no builders, no method chaining. Transitions are plain Go structs: explicit, composable, easy to generate programmatically.
  • Zero dependencies — stdlib only, including tests.

License

MIT

Documentation

Overview

Package ssm implements a Simple State Machine

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrTransitionDenied = errors.New("transition denied")

ErrTransitionDenied is a sentinel wrapping all transition failures. Use errors.Is(err, ErrTransitionDenied) for generic error handling.

Functions

This section is empty.

Types

type ErrEventNotPermitted

type ErrEventNotPermitted[Event, State any] struct {
	Event Event
	State State
}

ErrEventNotPermitted indicates the event cannot be handled in the current state.

func (ErrEventNotPermitted[Event, State]) Error

func (e ErrEventNotPermitted[Event, State]) Error() string

type ErrTransitionExists

type ErrTransitionExists[State any] struct {
	From State
	To   State
}

ErrTransitionExists indicates a duplicate transition was registered.

func (ErrTransitionExists[State]) Error

func (e ErrTransitionExists[State]) Error() string

type ErrTransitionNotFound

type ErrTransitionNotFound[State any] struct {
	From State
	To   State
}

ErrTransitionNotFound indicates no transition exists between the given states.

func (ErrTransitionNotFound[State]) Error

func (e ErrTransitionNotFound[State]) Error() string

type EventResult

type EventResult[Event, State comparable] struct {
	From  State
	To    State
	Event Event
}

EventResult describes the resolved transition for an event.

func (EventResult[Event, State]) Reentry

func (r EventResult[Event, State]) Reentry() bool

Reentry returns true when the transition is a self-transition (From == To).

type EventStateMachine

type EventStateMachine[Entity any, Event, State comparable] struct {
	// contains filtered or unexported fields
}

EventStateMachine is an event-driven state machine. Immutable after construction, safe for concurrent use.

func NewWithEvents

func NewWithEvents[Entity any, Event, State comparable](
	getState func(e Entity) State,
	transitions []EventTransition[Entity, Event, State],
) (*EventStateMachine[Entity, Event, State], error)

NewWithEvents constructs an event-driven state machine.

Example
package main

import (
	"fmt"

	"github.com/karolusz/ssm"
)

type PaymentEvent string

const (
	EventSubmit  PaymentEvent = "submit"
	EventApprove PaymentEvent = "approve"
	EventReject  PaymentEvent = "reject"
)

type PaymentStatus string

const (
	PaymentPending  PaymentStatus = "pending"
	PaymentApproved PaymentStatus = "approved"
	PaymentRejected PaymentStatus = "rejected"
)

type Payment struct {
	Status PaymentStatus
}

func main() {
	sm, err := ssm.NewWithEvents(
		func(p Payment) PaymentStatus { return p.Status },
		[]ssm.EventTransition[Payment, PaymentEvent, PaymentStatus]{
			{On: EventSubmit, From: PaymentPending, To: PaymentPending},
			{On: EventApprove, From: PaymentPending, To: PaymentApproved},
			{On: EventReject, From: PaymentPending, To: PaymentRejected},
		},
	)
	if err != nil {
		panic(err)
	}

	payment := Payment{Status: PaymentPending}

	// Fire an event — the SM resolves the target state
	result, err := sm.Can(payment, EventApprove)
	fmt.Printf("approve: %s -> %s (err: %v)\n", result.From, result.To, err)

	// Self-transition detection
	result, err = sm.Can(payment, EventSubmit)
	fmt.Printf("submit: reentry=%v (err: %v)\n", result.Reentry(), err)

	// Event not permitted in current state
	approved := Payment{Status: PaymentApproved}
	_, err = sm.Can(approved, EventApprove)
	fmt.Println("approve when approved:", err)

}
Output:
approve: pending -> approved (err: <nil>)
submit: reentry=true (err: <nil>)
approve when approved: transition denied: event approve not permitted in state approved

func (*EventStateMachine[Entity, Event, State]) AvailableEvents

func (s *EventStateMachine[Entity, Event, State]) AvailableEvents(from State) []Event

AvailableEvents returns the events that can be handled from the given state.

func (*EventStateMachine[Entity, Event, State]) Can

func (s *EventStateMachine[Entity, Event, State]) Can(
	e Entity,
	ev Event,
) (EventResult[Event, State], error)

Can checks if the event can be handled for the entity's current state. Returns an EventResult describing the resolved transition, or an error.

type EventTransition

type EventTransition[Entity any, Event, State comparable] struct {
	On    Event
	From  State
	To    State
	Guard Guard[Entity]
}

EventTransition represents a legal state transition triggered by an event.

type Guard

type Guard[Entity any] func(e Entity) error

Guard is a function that determines whether a transition is allowed to occur.

func All

func All[Entity any](guards ...Guard[Entity]) Guard[Entity]

All requires every guard to pass for the combined guard to pass.

Example
package main

import (
	"fmt"

	"github.com/karolusz/ssm"
)

type OrderStatus string

const (
	OrderCreated OrderStatus = "created"

	OrderCancelled OrderStatus = "cancelled"
)

type Order struct {
	ID     string
	Status OrderStatus
}

func main() {
	minAmount := func(o Order) error {
		if o.ID == "" {
			return fmt.Errorf("order ID is required")
		}
		return nil
	}
	isNotCancelled := func(o Order) error {
		if o.Status == OrderCancelled {
			return fmt.Errorf("order is cancelled")
		}
		return nil
	}

	combined := ssm.All(minAmount, isNotCancelled)

	err := combined(Order{ID: "123", Status: OrderCreated})
	fmt.Println("valid order:", err)

	err = combined(Order{ID: "", Status: OrderCreated})
	fmt.Println("missing ID:", err)

}
Output:
valid order: <nil>
missing ID: order ID is required

func Any

func Any[Entity any](guards ...Guard[Entity]) Guard[Entity]

Any requires at least one guard to pass for the combined guard to pass.

func Not

func Not[Entity any](guard Guard[Entity]) Guard[Entity]

Not negates the result of the provided guard. A nil guard (no restriction) negated means always restricted.

type StateMachine

type StateMachine[Entity any, State comparable] struct {
	// contains filtered or unexported fields
}

StateMachine is a simple state machine. Immutable after construction, safe for concurrent use.

func New

func New[Entity any, State comparable](
	getState func(e Entity) State,
	transitions []Transition[Entity, State],
) (*StateMachine[Entity, State], error)

New constructs a simple state machine.

Example
package main

import (
	"fmt"

	"github.com/karolusz/ssm"
)

type OrderStatus string

const (
	OrderCreated   OrderStatus = "created"
	OrderPaid      OrderStatus = "paid"
	OrderShipped   OrderStatus = "shipped"
	OrderDelivered OrderStatus = "delivered"
	OrderCancelled OrderStatus = "cancelled"
)

type Order struct {
	ID     string
	Status OrderStatus
}

func main() {
	sm, err := ssm.New(
		func(o Order) OrderStatus { return o.Status },
		[]ssm.Transition[Order, OrderStatus]{
			{From: OrderCreated, To: OrderPaid},
			{From: OrderCreated, To: OrderCancelled},
			{From: OrderPaid, To: OrderShipped},
			{From: OrderPaid, To: OrderCancelled},
			{From: OrderShipped, To: OrderDelivered},
		},
	)
	if err != nil {
		panic(err)
	}

	order := Order{ID: "123", Status: OrderCreated}

	// Check if a transition is allowed
	err = sm.Can(order, OrderPaid)
	fmt.Println("created -> paid:", err)

	// Check an illegal transition
	err = sm.Can(order, OrderDelivered)
	fmt.Println("created -> delivered:", err)

}
Output:
created -> paid: <nil>
created -> delivered: transition denied: transition not found: created -> delivered

func (*StateMachine[Entity, State]) Can

func (s *StateMachine[Entity, State]) Can(
	e Entity,
	to State,
) error

Can checks if a transition is possible. Returns an error if the transition is not possible.

func (*StateMachine[Entity, State]) Transitions

func (s *StateMachine[Entity, State]) Transitions(from State) []State

Transitions returns all states reachable from the given state.

type Transition

type Transition[Entity any, State comparable] struct {
	From  State
	To    State
	Guard Guard[Entity]
}

Transition represents a legal state transition.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL