Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

README.md

State — Behavioral Design Pattern

What is it?

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.


When to use it

  • An object's behavior depends heavily on its current state and must change at runtime.
  • You have switch or if/else blocks 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.


Go's approach vs. OOP languages

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

Structure of this implementation

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.

Concrete states

State Permitted transitions Blocked transitions
OpenState AssignIN_PROGRESS, CloseCLOSED Escalate, Resolve, Reopen
InProgress Escalate (stays IN_PROGRESS), ResolveRESOLVED Assign, Reopen, Close
Resolved ReopenRE_OPENED, CloseCLOSED Assign, Escalate, Resolve
ReopenedState AssignIN_PROGRESS Escalate, Reopen, Resolve, Close
CloseState — (terminal state) All operations

State transition diagram

                    ┌─────────┐
              ┌────►│  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.


Thread safety

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.


Testing approach

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.


Key interview talking points

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.