The State pattern lets an object alter its behavior when its internal state changes, making it appear as though the object changed its class. Instead of a growing chain of if/else or switch blocks that check a status field, each state is its own type that knows which transitions are legal and what to do when they are not.
It sits in the Behavioral family because it governs how an object responds to the same method call differently depending on which state it currently holds.
- An object's behavior depends heavily on its current state and must change at runtime.
- You have
switchorif/elseblocks spread across many methods that all branch on the same status field — each branch is a candidate state type. - Illegal transitions need to be enforced cleanly, without scattering guard conditions throughout the codebase.
Avoid it when the object only has two or three states and the transitions are trivial — a plain status field with a small switch is simpler and easier to follow.
Classical OOP uses an abstract State base class. Go uses an interface — no inheritance, no base class boilerplate:
| Mechanism | Purpose |
|---|---|
TicketState interface |
Declares every possible operation (Assign, Resolve, Close, Reopen, Escalate) |
| Concrete state structs | Each struct implements the full interface; permitted transitions update t.state; illegal ones return ErrOperationNotPermitted |
Ticket (context) |
Holds the current TicketState and exposes the same operations, delegating every call into the current state |
setState |
Unexported — only concrete state structs can drive transitions, keeping control flow inside the state machine |
sync.RWMutex |
Makes state reads and transitions safe for concurrent callers |
state/
├── state/
│ ├── ticket.go # Context: Ticket struct, public operations, mutex
│ ├── ticket_status.go # TicketState interface + all concrete state structs
│ └── ticket_status_test.go # Per-state unit tests and full lifecycle flows
└── main.go # Usage demo
The interface — state/ticket_status.go
type TicketState interface {
Name() string
Assign(t *Ticket) error
Esclate(t *Ticket) error
Resolve(t *Ticket) error
Reopen(t *Ticket) error
Close(t *Ticket) error
}Every state implements every operation. Illegal transitions call operationNotPermitted, which logs and returns ErrOperationNotPermitted. This means the context never needs to check what state it is in — it always delegates blindly.
The context — state/ticket.go
Ticket holds a single state TicketState field (unexported) and a sync.RWMutex. Public methods (Assign, Resolve, Close, Reopen, Escalate) take the write lock, delegate to the current state, and return. GetState uses a read lock and returns state.Name().
The private setState(state TicketState) is the only way to change the current state — and only concrete state structs call it, so transitions are always driven by the state machine itself, never by external callers.
| State | Permitted transitions | Blocked transitions |
|---|---|---|
OpenState |
Assign → IN_PROGRESS, Close → CLOSED |
Escalate, Resolve, Reopen |
InProgress |
Escalate (stays IN_PROGRESS), Resolve → RESOLVED |
Assign, Reopen, Close |
Resolved |
Reopen → RE_OPENED, Close → CLOSED |
Assign, Escalate, Resolve |
ReopenedState |
Assign → IN_PROGRESS |
Escalate, Reopen, Resolve, Close |
CloseState |
— (terminal state) | All operations |
┌─────────┐
┌────►│ OPEN │
│ └────┬────┘
│ │ Assign
│ ▼
│ ┌─────────────┐
│ │ IN_PROGRESS │◄──────────────┐
│ └──────┬──────┘ │
│ │ Resolve │ Assign
│ ▼ │
│ ┌──────────┐ ┌───────────────┐
│ │ RESOLVED │──Reopen─►│ RE_OPENED │
│ └────┬─────┘ └───────────────┘
│ │ Close
│ ▼
│ ┌────────┐
└──────│ CLOSED │ (terminal — no transitions out)
Close └────────┘
Escalation is an in-place action on IN_PROGRESS — it does not change the state, it signals urgency while the ticket stays with the assignee.
Every public method on Ticket takes mu.Lock() before delegating to the state. GetState uses mu.RLock(). Multiple goroutines can safely call operations on the same ticket concurrently — a read will never race with a transition.
state/ticket_status_test.go covers three layers:
| Layer | What it verifies |
|---|---|
| Per-state permitted transitions | Each allowed operation moves the ticket to the expected next state |
| Per-state blocked transitions | Each illegal operation returns exactly ErrOperationNotPermitted |
| Full lifecycle flows | Multi-step sequences (OPEN → IN_PROGRESS → RESOLVED, RESOLVED → RE_OPENED → IN_PROGRESS, OPEN → CLOSED) produce the correct final state |
The newTicketInState helper lets each test start a ticket in an arbitrary state without going through a full lifecycle, keeping test setup minimal.
Q: Why not just use a status enum and a switch statement? With a switch, every method that changes behavior per-state needs its own switch. Five operations across five states means up to 25 branches, scattered across the file. The State pattern centralizes each state's rules into one struct — adding a new state means adding one new type, touching nothing else.
Q: How does State differ from Strategy? Strategy swaps an algorithm from the outside — the caller chooses which strategy to inject. State transitions happen from the inside — the current state drives what comes next, and the context never inspects its own state. The context in Strategy is passive; in State it is an active participant.
Q: Why is setState unexported?
It enforces that only the state machine drives transitions. If external callers could call setState directly, they could bypass the guard logic in each state struct and put the ticket into an inconsistent state.
Q: How would you persist state across restarts?
Store Name() in the database. On load, map the string back to the correct concrete struct via a factory function and inject it into the ticket. The state machine resumes from wherever it left off.
Q: What does Escalate do in IN_PROGRESS?
It is an in-place action — the ticket stays IN_PROGRESS but escalation side effects (notifications, priority bumps) can be triggered. The state does not change because escalation does not hand the ticket to a new owner; it flags urgency within the current assignment.