Low Level DesignDesign patterns
State pattern
The State design pattern through three real examples, from a publishing workflow to an order lifecycle to order events racing across servers, with diagrams, live demos and code in five languages.
- Read first
- Strategy
The State pattern gives an object one small object per situation it can be in, and hands every action to the one that is current. An article in review can be approved but not edited. A shipped order can be delivered but not cancelled. Instead of every method asking “which status am I in?”, each status is a class that knows what it allows and which status comes next.
It fits anything with a lifecycle: documents, orders, payments, tickets, network connections. TCP’s connection states are the textbook example. And every order system has one, whether or not anyone drew it. This page builds in three steps. First, a three-state workflow. Then an order with side effects and guards. Then state that lives in a database, and changes under events that race.
The shape
Section titled “The shape”Every State pattern has three parts:
- The context is the object with a lifecycle, for example
DocumentorOrder. It holds the data and a reference to its current state, and forwards each action to that state. - The state interface has one method per action (or one method that takes the event). Its default answer is “not in this state”.
- Concrete states each handle the actions that are legal in them, and move the context to the next state.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
If this looks like Strategy, it is the same shape. The difference is the dashed arrows between the states. A strategy is chosen from outside, and stays put. A state knows its successors, and replaces itself as things happen.
Worked examples
Section titled “Worked examples”A publishing workflow
- A class per state
- Illegal actions have no code path
- States choose the next state
A company’s help center publishes articles. A writer drafts an article and submits it for review. An editor approves it, which publishes it, or rejects it with a note, which sends it back to the writer as a draft. Only drafts can be edited, nothing goes live without review, and published articles are read-only.
Without the pattern
Section titled “Without the pattern”The first version keeps a status enum and checks it in every method:
// Before: the status is an enum, and every method asks "which status am I in?"enum Status { DRAFT, IN_REVIEW, PUBLISHED }
class Document { Status status = Status.DRAFT; private String text;
Document(String text) { this.text = text; }
// flaw 1: four methods times three statuses. The rules for one status (say, IN_REVIEW) // are spread across all four methods, so nobody can read them in one place. void edit(String newText) { switch (status) { case DRAFT -> text = newText; case IN_REVIEW -> throw new IllegalStateException("locked for review"); case PUBLISHED -> throw new IllegalStateException("published documents are read-only"); } }
void submit() { if (status != Status.DRAFT) throw new IllegalStateException("only a draft can be submitted"); status = Status.IN_REVIEW; }
void approve() { // flaw 2: written when there were only DRAFT and PUBLISHED. IN_REVIEW was added // later, nothing forced anyone to revisit this check, and a draft now skips review. if (status == Status.PUBLISHED) throw new IllegalStateException("already published"); status = Status.PUBLISHED; }
void reject(String note) { if (status != Status.IN_REVIEW) throw new IllegalStateException("nothing to reject"); status = Status.DRAFT; }}
class Main { public static void main(String[] args) { Document doc = new Document("Q3 pricing update"); doc.edit("Q3 pricing update, v2"); System.out.println("after edit: " + doc.status); // after edit: DRAFT // Should be refused: nobody has reviewed it. Nothing stops it. doc.approve(); System.out.println("after approve: " + doc.status); // after approve: PUBLISHED }}# Before: the status is an enum, and every method asks "which status am I in?"from enum import Enum
class Status(Enum): DRAFT = 1 IN_REVIEW = 2 PUBLISHED = 3
class Document: def __init__(self, text: str): self.status = Status.DRAFT self._text = text
# flaw 1: four methods times three statuses. The rules for one status (say, IN_REVIEW) # are spread across all four methods, so nobody can read them in one place. def edit(self, new_text: str) -> None: if self.status is Status.DRAFT: self._text = new_text elif self.status is Status.IN_REVIEW: raise RuntimeError("locked for review") else: raise RuntimeError("published documents are read-only")
def submit(self) -> None: if self.status is not Status.DRAFT: raise RuntimeError("only a draft can be submitted") self.status = Status.IN_REVIEW
def approve(self) -> None: # flaw 2: written when there were only DRAFT and PUBLISHED. IN_REVIEW was added # later, nothing forced anyone to revisit this check, and a draft now skips review. if self.status is Status.PUBLISHED: raise RuntimeError("already published") self.status = Status.PUBLISHED
def reject(self, note: str) -> None: if self.status is not Status.IN_REVIEW: raise RuntimeError("nothing to reject") self.status = Status.DRAFT
if __name__ == "__main__": doc = Document("Q3 pricing update") doc.edit("Q3 pricing update, v2") print("after edit:", doc.status.name) # after edit: DRAFT # Should be refused: nobody has reviewed it. Nothing stops it. doc.approve() print("after approve:", doc.status.name) # after approve: PUBLISHEDusing System;
// Before: the status is an enum, and every method asks "which status am I in?"public enum Status { DRAFT, IN_REVIEW, PUBLISHED }
public sealed class Document(string text){ public Status Status { get; private set; } = Status.DRAFT; private string _text = text;
// flaw 1: four methods times three statuses. The rules for one status (say, IN_REVIEW) // are spread across all four methods, so nobody can read them in one place. public void Edit(string newText) { switch (Status) { case Status.DRAFT: _text = newText; break; case Status.IN_REVIEW: throw new InvalidOperationException("locked for review"); case Status.PUBLISHED: throw new InvalidOperationException("published documents are read-only"); } }
public void Submit() { if (Status != Status.DRAFT) throw new InvalidOperationException("only a draft can be submitted"); Status = Status.IN_REVIEW; }
public void Approve() { // flaw 2: written when there were only DRAFT and PUBLISHED. IN_REVIEW was added // later, nothing forced anyone to revisit this check, and a draft now skips review. if (Status == Status.PUBLISHED) throw new InvalidOperationException("already published"); Status = Status.PUBLISHED; }
public void Reject(string note) { if (Status != Status.IN_REVIEW) throw new InvalidOperationException("nothing to reject"); Status = Status.DRAFT; }}
public static class Program{ public static void Main() { var doc = new Document("Q3 pricing update"); doc.Edit("Q3 pricing update, v2"); Console.WriteLine($"after edit: {doc.Status}"); // after edit: DRAFT // Should be refused: nobody has reviewed it. Nothing stops it. doc.Approve(); Console.WriteLine($"after approve: {doc.Status}"); // after approve: PUBLISHED }}package main
import ( "errors" "fmt")
// Before: the status is a string constant, and every method asks "which status am I in?"type Status string
const ( Draft Status = "DRAFT" InReview Status = "IN_REVIEW" Published Status = "PUBLISHED")
type Document struct { Status Status text string}
// flaw 1: four methods times three statuses. The rules for one status (say, InReview)// are spread across all four methods, so nobody can read them in one place.func (d *Document) Edit(text string) error { switch d.Status { case Draft: d.text = text return nil case InReview: return errors.New("locked for review") default: return errors.New("published documents are read-only") }}
func (d *Document) Submit() error { if d.Status != Draft { return errors.New("only a draft can be submitted") } d.Status = InReview return nil}
func (d *Document) Approve() error { // flaw 2: written when there were only Draft and Published. InReview was added // later, nothing forced anyone to revisit this check, and a draft now skips review. if d.Status == Published { return errors.New("already published") } d.Status = Published return nil}
func (d *Document) Reject(note string) error { if d.Status != InReview { return errors.New("nothing to reject") } d.Status = Draft return nil}
func main() { doc := &Document{Status: Draft, text: "Q3 pricing update"} doc.Edit("Q3 pricing update, v2") fmt.Println("after edit:", doc.Status) // after edit: DRAFT // Should be refused: nobody has reviewed it. Nothing stops it. doc.Approve() fmt.Println("after approve:", doc.Status) // after approve: PUBLISHED}// Before: the status is a string union, and every method asks "which status am I in?"type Status = 'DRAFT' | 'IN_REVIEW' | 'PUBLISHED';
class Document { status: Status = 'DRAFT'; constructor(private text: string) {}
// flaw 1: four methods times three statuses. The rules for one status (say, IN_REVIEW) // are spread across all four methods, so nobody can read them in one place. edit(newText: string): void { switch (this.status) { case 'DRAFT': this.text = newText; return; case 'IN_REVIEW': throw new Error('locked for review'); case 'PUBLISHED': throw new Error('published documents are read-only'); } }
submit(): void { if (this.status !== 'DRAFT') throw new Error('only a draft can be submitted'); this.status = 'IN_REVIEW'; }
approve(): void { // flaw 2: written when there were only DRAFT and PUBLISHED. IN_REVIEW was added // later, nothing forced anyone to revisit this check, and a draft now skips review. if (this.status === 'PUBLISHED') throw new Error('already published'); this.status = 'PUBLISHED'; }
reject(note: string): void { if (this.status !== 'IN_REVIEW') throw new Error('nothing to reject'); this.status = 'DRAFT'; }}
const doc = new Document('Q3 pricing update');doc.edit('Q3 pricing update, v2');console.log('after edit:', doc.status); // after edit: DRAFT// Should be refused: nobody has reviewed it. Nothing stops it.doc.approve();console.log('after approve:', doc.status); // after approve: PUBLISHEDIt started with two statuses, and approve() was written then: “refuse if already published”. When
review was added, submit() and reject() were written for it. But nothing pointed anyone back at
approve(). So a draft can still be approved straight to the public site. The rules for “in review”
are spread across four methods, and every new status means reading all of them again. A switch
expression over the enum would catch a missing case, but only in methods that use one. The if
checks here are invisible to the compiler.
With State
Section titled “With State”Make each status a class. The document forwards every action to its current state. A state implements only what is legal in it, and the interface’s defaults refuse everything else:
- Color key
- State
- Ends well
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Click through the workflow. With State classes, the current state’s class says up front what it handles, so illegal actions are disabled with the reason. Switch to the enum version and approve a draft:
Draftedit(), submit()InReviewapprove(), reject()Publishednothing: all refused
Document Q3 pricing update, v1
is in Draft
// One method per action. The defaults refuse, so each state overrides only the actions// that are legal in it. An action a state doesn't mention has no code path to run.interface DocState { String name(); default void edit(Document d, String text) { throw refuse("edit"); } default void submit(Document d) { throw refuse("submit"); } default void approve(Document d) { throw refuse("approve"); } default void reject(Document d, String note) { throw refuse("reject"); }
private IllegalStateException refuse(String action) { return new IllegalStateException(name() + " cannot " + action); }}
// Every rule about drafts is in this class, and nowhere else.class Draft implements DocState { public String name() { return "Draft"; } public void edit(Document d, String text) { d.setText(text); } // The state picks the next state. The document never decides where to go. public void submit(Document d) { d.transitionTo(new InReview()); }}
class InReview implements DocState { public String name() { return "InReview"; } // A state can refuse with a better reason than the default. public void edit(Document d, String text) { throw new IllegalStateException("InReview cannot edit: locked for review, reject it to make changes"); } public void approve(Document d) { d.transitionTo(new Published()); } public void reject(Document d, String note) { d.setReviewNote(note); d.transitionTo(new Draft()); }}
// Published handles nothing, so every action is refused by the defaults.class Published implements DocState { public String name() { return "Published"; }}
// The context. It holds the data and the current state, and forwards every action.// There is no switch on the state anywhere in this class.class Document { // States hold no data, so a new one is cheap. You could also share one of each. private DocState state = new Draft(); private String text; private String reviewNote = "";
Document(String text) { this.text = text; }
void edit(String text) { state.edit(this, text); } void submit() { state.submit(this); } void approve() { state.approve(this); } void reject(String note) { state.reject(this, note); }
// Package-private: only states move the document. One method for every transition // is also the one place to log, audit or notify (the Hard level builds on this). void transitionTo(DocState next) { System.out.println(state.name() + " -> " + next.name()); state = next; }
void setText(String text) { this.text = text; } void setReviewNote(String note) { this.reviewNote = note; } String reviewNote() { return reviewNote; }}
class Main { static void attempt(Runnable action) { try { action.run(); } catch (IllegalStateException e) { System.out.println("refused: " + e.getMessage()); } }
public static void main(String[] args) { Document doc = new Document("Q3 pricing update"); doc.edit("Q3 pricing update, v2"); attempt(doc::approve); // refused: Draft cannot approve doc.submit(); // Draft -> InReview attempt(() -> doc.edit("sneaky change")); // refused: InReview cannot edit: locked for review, reject it to make changes doc.reject("cite the source for the new prices"); // InReview -> Draft System.out.println("note: " + doc.reviewNote()); // note: cite the source for the new prices doc.edit("Q3 pricing update, v3"); doc.submit(); // Draft -> InReview doc.approve(); // InReview -> Published attempt(() -> doc.reject("too late")); // refused: Published cannot reject }}from __future__ import annotations
class IllegalAction(Exception): pass
# One method per action. The defaults refuse, so each state overrides only the# actions that are legal in it. An action a state doesn't mention has no code path.class DocState: name = "DocState"
def edit(self, d: Document, text: str) -> None: raise self._refuse("edit")
def submit(self, d: Document) -> None: raise self._refuse("submit")
def approve(self, d: Document) -> None: raise self._refuse("approve")
def reject(self, d: Document, note: str) -> None: raise self._refuse("reject")
def _refuse(self, action: str) -> IllegalAction: return IllegalAction(f"{self.name} cannot {action}")
# Every rule about drafts is in this class, and nowhere else.class Draft(DocState): name = "Draft"
def edit(self, d: Document, text: str) -> None: d.text = text
# The state picks the next state. The document never decides where to go. def submit(self, d: Document) -> None: d.transition_to(InReview())
class InReview(DocState): name = "InReview"
# A state can refuse with a better reason than the default. def edit(self, d: Document, text: str) -> None: raise IllegalAction("InReview cannot edit: locked for review, reject it to make changes")
def approve(self, d: Document) -> None: d.transition_to(Published())
def reject(self, d: Document, note: str) -> None: d.review_note = note d.transition_to(Draft())
# Published handles nothing, so every action is refused by the defaults.class Published(DocState): name = "Published"
# The context. It holds the data and the current state, and forwards every action.# There is no if on the state anywhere in this class.class Document: def __init__(self, text: str): # States hold no data, so a new one is cheap. You could also share one of each. self._state: DocState = Draft() self.text = text self.review_note = ""
def edit(self, text: str) -> None: self._state.edit(self, text)
def submit(self) -> None: self._state.submit(self)
def approve(self) -> None: self._state.approve(self)
def reject(self, note: str) -> None: self._state.reject(self, note)
# Only states call this. One method for every transition is also the one place to # log, audit or notify (the Hard level builds on this). def transition_to(self, nxt: DocState) -> None: print(f"{self._state.name} -> {nxt.name}") self._state = nxt
def attempt(action) -> None: try: action() except IllegalAction as e: print(f"refused: {e}")
if __name__ == "__main__": doc = Document("Q3 pricing update") doc.edit("Q3 pricing update, v2") attempt(doc.approve) # refused: Draft cannot approve doc.submit() # Draft -> InReview attempt(lambda: doc.edit("sneaky change")) # refused: InReview cannot edit: locked for review, reject it to make changes doc.reject("cite the source for the new prices") # InReview -> Draft print(f"note: {doc.review_note}") # note: cite the source for the new prices doc.edit("Q3 pricing update, v3") doc.submit() # Draft -> InReview doc.approve() # InReview -> Published attempt(lambda: doc.reject("too late")) # refused: Published cannot rejectusing System;
// One method per action. The defaults refuse, so each state overrides only the actions// that are legal in it. An action a state doesn't mention has no code path to run.public abstract class DocState{ public abstract string Name { get; } public virtual void Edit(Document d, string text) => throw Refuse("edit"); public virtual void Submit(Document d) => throw Refuse("submit"); public virtual void Approve(Document d) => throw Refuse("approve"); public virtual void Reject(Document d, string note) => throw Refuse("reject");
private InvalidOperationException Refuse(string action) => new($"{Name} cannot {action}");}
// Every rule about drafts is in this class, and nowhere else.public sealed class Draft : DocState{ public override string Name => "Draft"; public override void Edit(Document d, string text) => d.Text = text; // The state picks the next state. The document never decides where to go. public override void Submit(Document d) => d.TransitionTo(new InReview());}
public sealed class InReview : DocState{ public override string Name => "InReview"; // A state can refuse with a better reason than the default. public override void Edit(Document d, string text) => throw new InvalidOperationException("InReview cannot edit: locked for review, reject it to make changes"); public override void Approve(Document d) => d.TransitionTo(new Published()); public override void Reject(Document d, string note) { d.ReviewNote = note; d.TransitionTo(new Draft()); }}
// Published handles nothing, so every action is refused by the defaults.public sealed class Published : DocState{ public override string Name => "Published";}
// The context. It holds the data and the current state, and forwards every action.// There is no switch on the state anywhere in this class.public sealed class Document(string text){ // States hold no data, so a new one is cheap. You could also share one of each. private DocState _state = new Draft(); public string Text { get; internal set; } = text; public string ReviewNote { get; internal set; } = "";
public void Edit(string text) => _state.Edit(this, text); public void Submit() => _state.Submit(this); public void Approve() => _state.Approve(this); public void Reject(string note) => _state.Reject(this, note);
// Internal: only states move the document. One method for every transition is also // the one place to log, audit or notify (the Hard level builds on this). internal void TransitionTo(DocState next) { Console.WriteLine($"{_state.Name} -> {next.Name}"); _state = next; }}
public static class Program{ static void Attempt(Action action) { try { action(); } catch (InvalidOperationException e) { Console.WriteLine($"refused: {e.Message}"); } }
public static void Main() { var doc = new Document("Q3 pricing update"); doc.Edit("Q3 pricing update, v2"); Attempt(doc.Approve); // refused: Draft cannot approve doc.Submit(); // Draft -> InReview Attempt(() => doc.Edit("sneaky change")); // refused: InReview cannot edit: locked for review, reject it to make changes doc.Reject("cite the source for the new prices"); // InReview -> Draft Console.WriteLine($"note: {doc.ReviewNote}"); // note: cite the source for the new prices doc.Edit("Q3 pricing update, v3"); doc.Submit(); // Draft -> InReview doc.Approve(); // InReview -> Published Attempt(() => doc.Reject("too late")); // refused: Published cannot reject }}package main
import ( "errors" "fmt")
// DocState has one method per action. Each state embeds refuseAll, whose methods refuse,// and overrides only the actions that are legal in it. An action a state doesn't mention// has no code path to run.type DocState interface { Name() string Edit(d *Document, text string) error Submit(d *Document) error Approve(d *Document) error Reject(d *Document, note string) error}
type refuseAll struct{ name string }
func (r refuseAll) Name() string { return r.name }func (r refuseAll) refuse(action string) error { return fmt.Errorf("%s cannot %s", r.name, action) }func (r refuseAll) Edit(*Document, string) error { return r.refuse("edit") }func (r refuseAll) Submit(*Document) error { return r.refuse("submit") }func (r refuseAll) Approve(*Document) error { return r.refuse("approve") }func (r refuseAll) Reject(*Document, string) error { return r.refuse("reject") }
// Draft: every rule about drafts is here, and nowhere else.type Draft struct{ refuseAll }
func NewDraft() Draft { return Draft{refuseAll{"Draft"}} }
func (Draft) Edit(d *Document, text string) error { d.text = text; return nil }
// Submit: the state picks the next state. The document never decides where to go.func (Draft) Submit(d *Document) error { d.transitionTo(NewInReview()); return nil }
type InReview struct{ refuseAll }
func NewInReview() InReview { return InReview{refuseAll{"InReview"}} }
// Edit: a state can refuse with a better reason than the default.func (InReview) Edit(*Document, string) error { return errors.New("InReview cannot edit: locked for review, reject it to make changes")}func (InReview) Approve(d *Document) error { d.transitionTo(NewPublished()); return nil }func (InReview) Reject(d *Document, note string) error { d.reviewNote = note d.transitionTo(NewDraft()) return nil}
// Published handles nothing, so every action is refused by refuseAll.type Published struct{ refuseAll }
func NewPublished() Published { return Published{refuseAll{"Published"}} }
// Document is the context. It holds the data and the current state, and forwards every// action. There is no switch on the state anywhere in this type.type Document struct { // States hold no data, so a new one is cheap. You could also share one of each. state DocState text string reviewNote string}
func NewDocument(text string) *Document { return &Document{state: NewDraft(), text: text} }
func (d *Document) Edit(text string) error { return d.state.Edit(d, text) }func (d *Document) Submit() error { return d.state.Submit(d) }func (d *Document) Approve() error { return d.state.Approve(d) }func (d *Document) Reject(note string) error { return d.state.Reject(d, note) }
// transitionTo is unexported: only code in this package (the states) moves the document.// One function for every transition is also the one place to log, audit or notify.func (d *Document) transitionTo(next DocState) { fmt.Printf("%s -> %s\n", d.state.Name(), next.Name()) d.state = next}
func attempt(err error) { if err != nil { fmt.Println("refused:", err) }}
func main() { doc := NewDocument("Q3 pricing update") doc.Edit("Q3 pricing update, v2") attempt(doc.Approve()) // refused: Draft cannot approve doc.Submit() // Draft -> InReview attempt(doc.Edit("sneaky change")) // refused: InReview cannot edit: locked for review, reject it to make changes doc.Reject("cite the source for the new prices") // InReview -> Draft fmt.Println("note:", doc.reviewNote) // note: cite the source for the new prices doc.Edit("Q3 pricing update, v3") doc.Submit() // Draft -> InReview doc.Approve() // InReview -> Published attempt(doc.Reject("too late")) // refused: Published cannot reject}// One method per action. The defaults refuse, so each state overrides only the actions// that are legal in it. An action a state doesn't mention has no code path to run.abstract class DocState { abstract readonly name: string; edit(d: Document, text: string): void { throw this.refuse('edit'); } submit(d: Document): void { throw this.refuse('submit'); } approve(d: Document): void { throw this.refuse('approve'); } reject(d: Document, note: string): void { throw this.refuse('reject'); } private refuse(action: string): Error { return new Error(`${this.name} cannot ${action}`); }}
// Every rule about drafts is in this class, and nowhere else.class Draft extends DocState { readonly name = 'Draft'; override edit(d: Document, text: string): void { d.text = text; } // The state picks the next state. The document never decides where to go. override submit(d: Document): void { d.transitionTo(new InReview()); }}
class InReview extends DocState { readonly name = 'InReview'; // A state can refuse with a better reason than the default. override edit(): void { throw new Error('InReview cannot edit: locked for review, reject it to make changes'); } override approve(d: Document): void { d.transitionTo(new Published()); } override reject(d: Document, note: string): void { d.reviewNote = note; d.transitionTo(new Draft()); }}
// Published handles nothing, so every action is refused by the defaults.class Published extends DocState { readonly name = 'Published';}
// The context. It holds the data and the current state, and forwards every action.// There is no switch on the state anywhere in this class.class Document { // States hold no data, so a new one is cheap. You could also share one of each. private state: DocState = new Draft(); reviewNote = ''; constructor(public text: string) {}
edit(text: string): void { this.state.edit(this, text); } submit(): void { this.state.submit(this); } approve(): void { this.state.approve(this); } reject(note: string): void { this.state.reject(this, note); }
// Only states call this. One method for every transition is also the one place to // log, audit or notify (the Hard level builds on this). transitionTo(next: DocState): void { console.log(`${this.state.name} -> ${next.name}`); this.state = next; }}
function attempt(action: () => void): void { try { action(); } catch (e) { console.log(`refused: ${(e as Error).message}`); }}
const doc = new Document('Q3 pricing update');doc.edit('Q3 pricing update, v2');attempt(() => doc.approve()); // refused: Draft cannot approvedoc.submit(); // Draft -> InReviewattempt(() => doc.edit('sneaky change')); // refused: InReview cannot edit: locked for review, reject it to make changesdoc.reject('cite the source for the new prices'); // InReview -> Draftconsole.log(`note: ${doc.reviewNote}`); // note: cite the source for the new pricesdoc.edit('Q3 pricing update, v3');doc.submit(); // Draft -> InReviewdoc.approve(); // InReview -> Publishedattempt(() => doc.reject('too late')); // refused: Published cannot rejectWhat to notice
Section titled “What to notice”- Each state’s rules are in one place. Everything about review is in
InReview. It can be approved or rejected, and editing is refused with a reason. Reading one class tells you everything that can happen to an article in review. - Illegal actions have no code path.
Draftdoesn’t implementapprove, so the default refuses it withDraft cannot approve. The bug in the enum version can’t be written here: approving a draft would mean adding anapprovemethod toDrafton purpose. - States choose the next state.
InReview.approvedecides that approval leads toPublished. The document doesn’t know the graph. It only knows its current state. - One method changes state. Every transition goes through
transitionTo. That’s where logging, auditing and notifications go, once, and the Hard level builds on it. - States hold no data. The text and the review note live in
Document. A state is pure behavior, so one instance of each could be shared by every document, and switching state is assigning a reference. The vending machine makes the same choice for the same reason. - New states aren’t free. A “Scheduled” state is a new class, plus an edit to whichever state leads into it. That’s still far less than reopening every method of the enum version.
Editors want to schedule articles: approve now, go live Monday at 9. What changes?
Whether the answer is a new Scheduled state (edit refused, publishNow and unschedule
allowed). Plus a change to InReview.approve, to go there when a date is set, with Document
untouched. The red flag is adding if (status == SCHEDULED) to each method. So is forgetting to
decide what edit means while scheduled.
Every action means something different, depending on the article’s status. So I’ll make each status a class behind a DocState interface. The document forwards actions to its current state. The state does what’s legal, and picks the next state. Anything a state doesn’t handle is refused by default.
An order lifecycle
- Entry and exit actions
- Guards
- Classes vs. a transition table
An online store’s orders start in PendingPayment, with a 30-minute timer that cancels them if the customer never pays. A paid order ships once the fraud check has cleared. The customer can cancel until the parcel ships, and a paid order that is cancelled must be refunded. After delivery, a return refunds the payment. Each step has side effects: start and stop the payment timer, email the tracking link, release reserved stock, refund, restock.
- Color key
- State
- Ends well
The side effects come in three kinds, and putting each in the right place is most of this level:
- Entry and exit actions belong to a state. Entering
PendingPaymentstarts the timer. Leaving it stops the timer, whichever way the order leaves. - Transition actions belong to one move. Paid to Cancelled refunds the payment. PendingPayment to Cancelled must not, even though both end in the same state.
- Guards are conditions on data that a move needs.
shipfromPaidexists, but only once the fraud check has passed.cancelfromShippeddoesn’t exist at all.
There are two common ways to write this, and both are in production everywhere. The demo runs the same order through both at once:
Class per state
class PendingPayment
Transition table
| from | event | guard / action | to |
|---|---|---|---|
| PendingPayment | pay | Paid | |
| PendingPayment | cancel | Cancelled | |
| Paid | ship | [fraud check passed] | Shipped |
| Paid | cancel | / refund payment | Cancelled |
| Shipped | deliver | Delivered | |
| Delivered | return | / refund payment | Refunded |
Design 1: a class per state
Section titled “Design 1: a class per state”- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
// Entry and exit actions default to nothing; events default to refusing.// A state overrides only what it does.interface OrderState { String name(); default void onEnter(Order o) {} default void onExit(Order o) {} default void pay(Order o) { throw refuse("pay"); } default void ship(Order o) { throw refuse("ship"); } default void deliver(Order o) { throw refuse("deliver"); } default void cancel(Order o) { throw refuse("cancel"); } default void requestReturn(Order o) { throw refuse("return"); }
private IllegalStateException refuse(String event) { return new IllegalStateException(name() + " cannot " + event); }}
class PendingPayment implements OrderState { public String name() { return "PendingPayment"; } // Started on entry, stopped on exit. The timer stops whichever way the order leaves // (paid or cancelled), and no transition has to remember to do it. public void onEnter(Order o) { o.log("enter PendingPayment: start payment timer"); } public void onExit(Order o) { o.log("exit PendingPayment: stop payment timer"); } public void pay(Order o) { o.transitionTo(new Paid(), null); } public void cancel(Order o) { o.transitionTo(new Cancelled(), null); }}
class Paid implements OrderState { public String name() { return "Paid"; } public void ship(Order o) { // A guard: the move exists from this state, but only while a condition on the data // holds. Different from Shipped.cancel, which doesn't exist at all. if (!o.fraudCheckPassed()) throw new IllegalStateException("fraud check not passed yet"); o.transitionTo(new Shipped(), null); } // A transition action: it belongs to this move (Paid -> Cancelled), not to either state. // Cancelling an unpaid order goes to the same state and must not refund. public void cancel(Order o) { o.transitionTo(new Cancelled(), "refund payment"); }}
class Shipped implements OrderState { public String name() { return "Shipped"; } public void onEnter(Order o) { o.log("enter Shipped: email tracking link"); } public void deliver(Order o) { o.transitionTo(new Delivered(), null); } // No cancel(): once the parcel has left, the default refuses it.}
class Delivered implements OrderState { public String name() { return "Delivered"; } public void requestReturn(Order o) { o.transitionTo(new Refunded(), "refund payment"); }}
class Cancelled implements OrderState { public String name() { return "Cancelled"; } public void onEnter(Order o) { o.log("enter Cancelled: release reserved stock"); }}
class Refunded implements OrderState { public String name() { return "Refunded"; } public void onEnter(Order o) { o.log("enter Refunded: restock returned item"); }}
class Order { final String id; private OrderState state; private boolean fraudCheckPassed;
Order(String id) { this.id = id; System.out.println(id + " created"); state = new PendingPayment(); state.onEnter(this); // the first state's entry action runs too }
void pay() { state.pay(this); } void ship() { state.ship(this); } void deliver() { state.deliver(this); } void cancel() { state.cancel(this); } void requestReturn() { state.requestReturn(this); }
// Data, not an event: it changes no state by itself. Guards read it. void passFraudCheck() { fraudCheckPassed = true; } boolean fraudCheckPassed() { return fraudCheckPassed; }
// Exit, then the transition's action, then entry: the order UML state machines use. // In real code the action and the entry and exit actions call services; here they print. void transitionTo(OrderState next, String action) { state.onExit(this); log(state.name() + " -> " + next.name() + (action == null ? "" : ": " + action)); state = next; next.onEnter(this); }
void log(String line) { System.out.println(" " + line); }}
class Main { static void step(Order o, String event, Runnable r) { System.out.println(o.id + " " + event); try { r.run(); } catch (IllegalStateException e) { o.log("refused: " + e.getMessage()); } }
public static void main(String[] args) { Order a = new Order("A1"); // A1 created // enter PendingPayment: start payment timer step(a, "pay", a::pay); // A1 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid step(a, "ship", a::ship); // A1 ship // refused: fraud check not passed yet a.passFraudCheck(); step(a, "ship", a::ship); // A1 ship // Paid -> Shipped // enter Shipped: email tracking link step(a, "cancel", a::cancel); // A1 cancel // refused: Shipped cannot cancel step(a, "deliver", a::deliver); // A1 deliver // Shipped -> Delivered step(a, "return", a::requestReturn); // A1 return // Delivered -> Refunded: refund payment // enter Refunded: restock returned item
Order b = new Order("B2"); // B2 created // enter PendingPayment: start payment timer step(b, "pay", b::pay); // B2 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid step(b, "cancel", b::cancel); // B2 cancel // Paid -> Cancelled: refund payment // enter Cancelled: release reserved stock }}from __future__ import annotationsfrom typing import Callable
class IllegalAction(Exception): pass
# Entry and exit actions default to nothing; events default to refusing.# A state overrides only what it does.class OrderState: name = "OrderState"
def on_enter(self, o: Order) -> None: pass
def on_exit(self, o: Order) -> None: pass
def pay(self, o: Order) -> None: raise self._refuse("pay")
def ship(self, o: Order) -> None: raise self._refuse("ship")
def deliver(self, o: Order) -> None: raise self._refuse("deliver")
def cancel(self, o: Order) -> None: raise self._refuse("cancel")
def request_return(self, o: Order) -> None: raise self._refuse("return")
def _refuse(self, event: str) -> IllegalAction: return IllegalAction(f"{self.name} cannot {event}")
class PendingPayment(OrderState): name = "PendingPayment"
# Started on entry, stopped on exit. The timer stops whichever way the order leaves # (paid or cancelled), and no transition has to remember to do it. def on_enter(self, o: Order) -> None: o.log("enter PendingPayment: start payment timer")
def on_exit(self, o: Order) -> None: o.log("exit PendingPayment: stop payment timer")
def pay(self, o: Order) -> None: o.transition_to(Paid(), None)
def cancel(self, o: Order) -> None: o.transition_to(Cancelled(), None)
class Paid(OrderState): name = "Paid"
def ship(self, o: Order) -> None: # A guard: the move exists from this state, but only while a condition on the data # holds. Different from Shipped.cancel, which doesn't exist at all. if not o.fraud_check_passed: raise IllegalAction("fraud check not passed yet") o.transition_to(Shipped(), None)
# A transition action: it belongs to this move (Paid -> Cancelled), not to either state. # Cancelling an unpaid order goes to the same state and must not refund. def cancel(self, o: Order) -> None: o.transition_to(Cancelled(), "refund payment")
class Shipped(OrderState): name = "Shipped"
def on_enter(self, o: Order) -> None: o.log("enter Shipped: email tracking link")
def deliver(self, o: Order) -> None: o.transition_to(Delivered(), None)
# No cancel(): once the parcel has left, the default refuses it.
class Delivered(OrderState): name = "Delivered"
def request_return(self, o: Order) -> None: o.transition_to(Refunded(), "refund payment")
class Cancelled(OrderState): name = "Cancelled"
def on_enter(self, o: Order) -> None: o.log("enter Cancelled: release reserved stock")
class Refunded(OrderState): name = "Refunded"
def on_enter(self, o: Order) -> None: o.log("enter Refunded: restock returned item")
class Order: def __init__(self, order_id: str): self.id = order_id # Data, not an event: it changes no state by itself. Guards read it. self.fraud_check_passed = False print(f"{order_id} created") self._state: OrderState = PendingPayment() self._state.on_enter(self) # the first state's entry action runs too
def pay(self) -> None: self._state.pay(self)
def ship(self) -> None: self._state.ship(self)
def deliver(self) -> None: self._state.deliver(self)
def cancel(self) -> None: self._state.cancel(self)
def request_return(self) -> None: self._state.request_return(self)
# Exit, then the transition's action, then entry: the order UML state machines use. # In real code the action and the entry and exit actions call services; here they print. def transition_to(self, nxt: OrderState, action: str | None) -> None: self._state.on_exit(self) self.log(f"{self._state.name} -> {nxt.name}" + (f": {action}" if action else "")) self._state = nxt nxt.on_enter(self)
def log(self, line: str) -> None: print(f" {line}")
def step(o: Order, event: str, action: Callable[[], None]) -> None: print(f"{o.id} {event}") try: action() except IllegalAction as e: o.log(f"refused: {e}")
if __name__ == "__main__": a = Order("A1") # A1 created # enter PendingPayment: start payment timer step(a, "pay", a.pay) # A1 pay # exit PendingPayment: stop payment timer # PendingPayment -> Paid step(a, "ship", a.ship) # A1 ship # refused: fraud check not passed yet a.fraud_check_passed = True step(a, "ship", a.ship) # A1 ship # Paid -> Shipped # enter Shipped: email tracking link step(a, "cancel", a.cancel) # A1 cancel # refused: Shipped cannot cancel step(a, "deliver", a.deliver) # A1 deliver # Shipped -> Delivered step(a, "return", a.request_return) # A1 return # Delivered -> Refunded: refund payment # enter Refunded: restock returned item
b = Order("B2") # B2 created # enter PendingPayment: start payment timer step(b, "pay", b.pay) # B2 pay # exit PendingPayment: stop payment timer # PendingPayment -> Paid step(b, "cancel", b.cancel) # B2 cancel # Paid -> Cancelled: refund payment # enter Cancelled: release reserved stockusing System;
// Entry and exit actions default to nothing; events default to refusing.// A state overrides only what it does.public abstract class OrderState{ public abstract string Name { get; } public virtual void OnEnter(Order o) { } public virtual void OnExit(Order o) { } public virtual void Pay(Order o) => throw Refuse("pay"); public virtual void Ship(Order o) => throw Refuse("ship"); public virtual void Deliver(Order o) => throw Refuse("deliver"); public virtual void Cancel(Order o) => throw Refuse("cancel"); public virtual void RequestReturn(Order o) => throw Refuse("return");
private InvalidOperationException Refuse(string evt) => new($"{Name} cannot {evt}");}
public sealed class PendingPayment : OrderState{ public override string Name => "PendingPayment"; // Started on entry, stopped on exit. The timer stops whichever way the order leaves // (paid or cancelled), and no transition has to remember to do it. public override void OnEnter(Order o) => o.Log("enter PendingPayment: start payment timer"); public override void OnExit(Order o) => o.Log("exit PendingPayment: stop payment timer"); public override void Pay(Order o) => o.TransitionTo(new Paid(), null); public override void Cancel(Order o) => o.TransitionTo(new Cancelled(), null);}
public sealed class Paid : OrderState{ public override string Name => "Paid"; public override void Ship(Order o) { // A guard: the move exists from this state, but only while a condition on the data // holds. Different from Shipped.Cancel, which doesn't exist at all. if (!o.FraudCheckPassed) throw new InvalidOperationException("fraud check not passed yet"); o.TransitionTo(new Shipped(), null); } // A transition action: it belongs to this move (Paid -> Cancelled), not to either state. // Cancelling an unpaid order goes to the same state and must not refund. public override void Cancel(Order o) => o.TransitionTo(new Cancelled(), "refund payment");}
public sealed class Shipped : OrderState{ public override string Name => "Shipped"; public override void OnEnter(Order o) => o.Log("enter Shipped: email tracking link"); public override void Deliver(Order o) => o.TransitionTo(new Delivered(), null); // No Cancel(): once the parcel has left, the default refuses it.}
public sealed class Delivered : OrderState{ public override string Name => "Delivered"; public override void RequestReturn(Order o) => o.TransitionTo(new Refunded(), "refund payment");}
public sealed class Cancelled : OrderState{ public override string Name => "Cancelled"; public override void OnEnter(Order o) => o.Log("enter Cancelled: release reserved stock");}
public sealed class Refunded : OrderState{ public override string Name => "Refunded"; public override void OnEnter(Order o) => o.Log("enter Refunded: restock returned item");}
public sealed class Order{ public string Id { get; } private OrderState _state; // Data, not an event: it changes no state by itself. Guards read it. public bool FraudCheckPassed { get; set; }
public Order(string id) { Id = id; Console.WriteLine($"{id} created"); _state = new PendingPayment(); _state.OnEnter(this); // the first state's entry action runs too }
public void Pay() => _state.Pay(this); public void Ship() => _state.Ship(this); public void Deliver() => _state.Deliver(this); public void Cancel() => _state.Cancel(this); public void RequestReturn() => _state.RequestReturn(this);
// Exit, then the transition's action, then entry: the order UML state machines use. // In real code the action and the entry and exit actions call services; here they print. internal void TransitionTo(OrderState next, string? action) { _state.OnExit(this); Log($"{_state.Name} -> {next.Name}" + (action is null ? "" : $": {action}")); _state = next; next.OnEnter(this); }
internal void Log(string line) => Console.WriteLine($" {line}");}
public static class Program{ static void Step(Order o, string evt, Action action) { Console.WriteLine($"{o.Id} {evt}"); try { action(); } catch (InvalidOperationException e) { o.Log($"refused: {e.Message}"); } }
public static void Main() { var a = new Order("A1"); // A1 created // enter PendingPayment: start payment timer Step(a, "pay", a.Pay); // A1 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid Step(a, "ship", a.Ship); // A1 ship // refused: fraud check not passed yet a.FraudCheckPassed = true; Step(a, "ship", a.Ship); // A1 ship // Paid -> Shipped // enter Shipped: email tracking link Step(a, "cancel", a.Cancel); // A1 cancel // refused: Shipped cannot cancel Step(a, "deliver", a.Deliver); // A1 deliver // Shipped -> Delivered Step(a, "return", a.RequestReturn); // A1 return // Delivered -> Refunded: refund payment // enter Refunded: restock returned item
var b = new Order("B2"); // B2 created // enter PendingPayment: start payment timer Step(b, "pay", b.Pay); // B2 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid Step(b, "cancel", b.Cancel); // B2 cancel // Paid -> Cancelled: refund payment // enter Cancelled: release reserved stock }}package main
import ( "errors" "fmt")
// OrderState: entry and exit actions default to nothing; events default to refusing.// Each state embeds base and overrides only what it does.type OrderState interface { Name() string OnEnter(o *Order) OnExit(o *Order) Pay(o *Order) error Ship(o *Order) error Deliver(o *Order) error Cancel(o *Order) error RequestReturn(o *Order) error}
type base struct{ name string }
func (b base) Name() string { return b.name }func (b base) refuse(event string) error { return fmt.Errorf("%s cannot %s", b.name, event) }func (base) OnEnter(*Order) {}func (base) OnExit(*Order) {}func (b base) Pay(*Order) error { return b.refuse("pay") }func (b base) Ship(*Order) error { return b.refuse("ship") }func (b base) Deliver(*Order) error { return b.refuse("deliver") }func (b base) Cancel(*Order) error { return b.refuse("cancel") }func (b base) RequestReturn(*Order) error { return b.refuse("return") }
type PendingPayment struct{ base }
// OnEnter and OnExit: started on entry, stopped on exit. The timer stops whichever way the// order leaves (paid or cancelled), and no transition has to remember to do it.func (PendingPayment) OnEnter(o *Order) { o.log("enter PendingPayment: start payment timer") }func (PendingPayment) OnExit(o *Order) { o.log("exit PendingPayment: stop payment timer") }func (PendingPayment) Pay(o *Order) error { o.transitionTo(Paid{base{"Paid"}}, "") return nil}func (PendingPayment) Cancel(o *Order) error { o.transitionTo(Cancelled{base{"Cancelled"}}, "") return nil}
type Paid struct{ base }
func (Paid) Ship(o *Order) error { // A guard: the move exists from this state, but only while a condition on the data // holds. Different from Shipped.Cancel, which doesn't exist at all. if !o.FraudCheckPassed { return errors.New("fraud check not passed yet") } o.transitionTo(Shipped{base{"Shipped"}}, "") return nil}
// Cancel has a transition action: it belongs to this move (Paid -> Cancelled), not to// either state. Cancelling an unpaid order goes to the same state and must not refund.func (Paid) Cancel(o *Order) error { o.transitionTo(Cancelled{base{"Cancelled"}}, "refund payment") return nil}
// Shipped has no Cancel: once the parcel has left, base refuses it.type Shipped struct{ base }
func (Shipped) OnEnter(o *Order) { o.log("enter Shipped: email tracking link") }func (Shipped) Deliver(o *Order) error { o.transitionTo(Delivered{base{"Delivered"}}, "") return nil}
type Delivered struct{ base }
func (Delivered) RequestReturn(o *Order) error { o.transitionTo(Refunded{base{"Refunded"}}, "refund payment") return nil}
type Cancelled struct{ base }
func (Cancelled) OnEnter(o *Order) { o.log("enter Cancelled: release reserved stock") }
type Refunded struct{ base }
func (Refunded) OnEnter(o *Order) { o.log("enter Refunded: restock returned item") }
type Order struct { ID string state OrderState // Data, not an event: it changes no state by itself. Guards read it. FraudCheckPassed bool}
func NewOrder(id string) *Order { fmt.Println(id, "created") o := &Order{ID: id, state: PendingPayment{base{"PendingPayment"}}} o.state.OnEnter(o) // the first state's entry action runs too return o}
func (o *Order) Pay() error { return o.state.Pay(o) }func (o *Order) Ship() error { return o.state.Ship(o) }func (o *Order) Deliver() error { return o.state.Deliver(o) }func (o *Order) Cancel() error { return o.state.Cancel(o) }func (o *Order) RequestReturn() error { return o.state.RequestReturn(o) }
// transitionTo runs exit, then the transition's action, then entry: the order UML state// machines use. In real code these call services; here they print.func (o *Order) transitionTo(next OrderState, action string) { o.state.OnExit(o) line := o.state.Name() + " -> " + next.Name() if action != "" { line += ": " + action } o.log(line) o.state = next next.OnEnter(o)}
func (o *Order) log(line string) { fmt.Println(" " + line) }
func step(o *Order, event string, fire func() error) { fmt.Println(o.ID, event) if err := fire(); err != nil { o.log("refused: " + err.Error()) }}
func main() { a := NewOrder("A1") // A1 created // enter PendingPayment: start payment timer step(a, "pay", a.Pay) // A1 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid step(a, "ship", a.Ship) // A1 ship // refused: fraud check not passed yet a.FraudCheckPassed = true step(a, "ship", a.Ship) // A1 ship // Paid -> Shipped // enter Shipped: email tracking link step(a, "cancel", a.Cancel) // A1 cancel // refused: Shipped cannot cancel step(a, "deliver", a.Deliver) // A1 deliver // Shipped -> Delivered step(a, "return", a.RequestReturn) // A1 return // Delivered -> Refunded: refund payment // enter Refunded: restock returned item
b := NewOrder("B2") // B2 created // enter PendingPayment: start payment timer step(b, "pay", b.Pay) // B2 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid step(b, "cancel", b.Cancel) // B2 cancel // Paid -> Cancelled: refund payment // enter Cancelled: release reserved stock}// Entry and exit actions default to nothing; events default to refusing.// A state overrides only what it does.abstract class OrderState { abstract readonly name: string; onEnter(o: Order): void {} onExit(o: Order): void {} pay(o: Order): void { throw this.refuse('pay'); } ship(o: Order): void { throw this.refuse('ship'); } deliver(o: Order): void { throw this.refuse('deliver'); } cancel(o: Order): void { throw this.refuse('cancel'); } requestReturn(o: Order): void { throw this.refuse('return'); } private refuse(event: string): Error { return new Error(`${this.name} cannot ${event}`); }}
class PendingPayment extends OrderState { readonly name = 'PendingPayment'; // Started on entry, stopped on exit. The timer stops whichever way the order leaves // (paid or cancelled), and no transition has to remember to do it. override onEnter(o: Order): void { o.log('enter PendingPayment: start payment timer'); } override onExit(o: Order): void { o.log('exit PendingPayment: stop payment timer'); } override pay(o: Order): void { o.transitionTo(new Paid()); } override cancel(o: Order): void { o.transitionTo(new Cancelled()); }}
class Paid extends OrderState { readonly name = 'Paid'; override ship(o: Order): void { // A guard: the move exists from this state, but only while a condition on the data // holds. Different from Shipped.cancel, which doesn't exist at all. if (!o.fraudCheckPassed) throw new Error('fraud check not passed yet'); o.transitionTo(new Shipped()); } // A transition action: it belongs to this move (Paid -> Cancelled), not to either state. // Cancelling an unpaid order goes to the same state and must not refund. override cancel(o: Order): void { o.transitionTo(new Cancelled(), 'refund payment'); }}
class Shipped extends OrderState { readonly name = 'Shipped'; override onEnter(o: Order): void { o.log('enter Shipped: email tracking link'); } override deliver(o: Order): void { o.transitionTo(new Delivered()); } // No cancel(): once the parcel has left, the default refuses it.}
class Delivered extends OrderState { readonly name = 'Delivered'; override requestReturn(o: Order): void { o.transitionTo(new Refunded(), 'refund payment'); }}
class Cancelled extends OrderState { readonly name = 'Cancelled'; override onEnter(o: Order): void { o.log('enter Cancelled: release reserved stock'); }}
class Refunded extends OrderState { readonly name = 'Refunded'; override onEnter(o: Order): void { o.log('enter Refunded: restock returned item'); }}
class Order { private state: OrderState; // Data, not an event: it changes no state by itself. Guards read it. fraudCheckPassed = false;
constructor(readonly id: string) { console.log(`${id} created`); this.state = new PendingPayment(); this.state.onEnter(this); // the first state's entry action runs too }
pay(): void { this.state.pay(this); } ship(): void { this.state.ship(this); } deliver(): void { this.state.deliver(this); } cancel(): void { this.state.cancel(this); } requestReturn(): void { this.state.requestReturn(this); }
// Exit, then the transition's action, then entry: the order UML state machines use. // In real code the action and the entry and exit actions call services; here they print. transitionTo(next: OrderState, action?: string): void { this.state.onExit(this); this.log(`${this.state.name} -> ${next.name}` + (action ? `: ${action}` : '')); this.state = next; next.onEnter(this); }
log(line: string): void { console.log(` ${line}`); }}
function step(o: Order, event: string, fire: () => void): void { console.log(`${o.id} ${event}`); try { fire(); } catch (e) { o.log(`refused: ${(e as Error).message}`); }}
const a = new Order('A1');// A1 created// enter PendingPayment: start payment timerstep(a, 'pay', () => a.pay());// A1 pay// exit PendingPayment: stop payment timer// PendingPayment -> Paidstep(a, 'ship', () => a.ship());// A1 ship// refused: fraud check not passed yeta.fraudCheckPassed = true;step(a, 'ship', () => a.ship());// A1 ship// Paid -> Shipped// enter Shipped: email tracking linkstep(a, 'cancel', () => a.cancel());// A1 cancel// refused: Shipped cannot cancelstep(a, 'deliver', () => a.deliver());// A1 deliver// Shipped -> Deliveredstep(a, 'return', () => a.requestReturn());// A1 return// Delivered -> Refunded: refund payment// enter Refunded: restock returned item
const b = new Order('B2');// B2 created// enter PendingPayment: start payment timerstep(b, 'pay', () => b.pay());// B2 pay// exit PendingPayment: stop payment timer// PendingPayment -> Paidstep(b, 'cancel', () => b.cancel());// B2 cancel// Paid -> Cancelled: refund payment// enter Cancelled: release reserved stockDesign 2: a transition table
Section titled “Design 2: a transition table”The same machine as data: one row per legal move, with its guard and action, and a map of
entry and exit actions per state. The Order has one generic fire(event) that looks up the
row.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.HashSet;import java.util.List;import java.util.Map;import java.util.Set;import java.util.function.Predicate;
// One row per legal move. Anything not in the table is refused, so the table is the// whole state machine: you can print it, draw it, test it, or load it from config.record Transition(String from, String event, String to, Predicate<Order> guard, String guardFails, String action) { static Transition of(String from, String event, String to) { return new Transition(from, event, to, o -> true, null, null); } Transition when(Predicate<Order> g, String whyNot) { return new Transition(from, event, to, g, whyNot, action); } Transition doing(String a) { return new Transition(from, event, to, guard, guardFails, a); }}
final class OrderRules { static final List<Transition> TABLE = List.of( Transition.of("PendingPayment", "pay", "Paid"), Transition.of("PendingPayment", "cancel", "Cancelled"), Transition.of("Paid", "ship", "Shipped") .when(Order::fraudCheckPassed, "fraud check not passed yet"), Transition.of("Paid", "cancel", "Cancelled").doing("refund payment"), Transition.of("Shipped", "deliver", "Delivered"), Transition.of("Delivered", "return", "Refunded").doing("refund payment"));
// Entry and exit actions are per state, so they live beside the table, not in a row. static final Map<String, String> ON_ENTER = Map.of( "PendingPayment", "start payment timer", "Shipped", "email tracking link", "Cancelled", "release reserved stock", "Refunded", "restock returned item"); static final Map<String, String> ON_EXIT = Map.of("PendingPayment", "stop payment timer");
// Data can be checked when it loads. Two rows for the same (state, event) would make // the machine depend on row order, so refuse to start. static { Set<String> seen = new HashSet<>(); for (Transition t : TABLE) if (!seen.add(t.from() + "/" + t.event())) throw new IllegalStateException("duplicate row: " + t.from() + " on " + t.event()); }}
class Order { final String id; private String state = "PendingPayment"; private boolean fraudCheckPassed;
Order(String id) { this.id = id; System.out.println(id + " created"); enter(state); // the first state's entry action runs too }
// One generic method replaces pay(), ship(), cancel()... The cost: a typo in an event // name is a run-time refusal, not a compile error. void fire(String event) { Transition t = OrderRules.TABLE.stream() .filter(r -> r.from().equals(state) && r.event().equals(event)) .findFirst() .orElseThrow(() -> new IllegalStateException(state + " cannot " + event)); if (!t.guard().test(this)) throw new IllegalStateException(t.guardFails()); // Same order as the class version: exit, the row's action, entry. String exit = OrderRules.ON_EXIT.get(state); if (exit != null) log("exit " + state + ": " + exit); log(state + " -> " + t.to() + (t.action() == null ? "" : ": " + t.action())); state = t.to(); enter(state); }
private void enter(String s) { String entry = OrderRules.ON_ENTER.get(s); if (entry != null) log("enter " + s + ": " + entry); }
void passFraudCheck() { fraudCheckPassed = true; } boolean fraudCheckPassed() { return fraudCheckPassed; } void log(String line) { System.out.println(" " + line); }}
class Main { static void step(Order o, String event) { System.out.println(o.id + " " + event); try { o.fire(event); } catch (IllegalStateException e) { o.log("refused: " + e.getMessage()); } }
public static void main(String[] args) { // The same script as the class version, and the same output, line for line. Order a = new Order("A1"); // A1 created // enter PendingPayment: start payment timer step(a, "pay"); // A1 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid step(a, "ship"); // A1 ship // refused: fraud check not passed yet a.passFraudCheck(); step(a, "ship"); // A1 ship // Paid -> Shipped // enter Shipped: email tracking link step(a, "cancel"); // A1 cancel // refused: Shipped cannot cancel step(a, "deliver"); // A1 deliver // Shipped -> Delivered step(a, "return"); // A1 return // Delivered -> Refunded: refund payment // enter Refunded: restock returned item
Order b = new Order("B2"); // B2 created // enter PendingPayment: start payment timer step(b, "pay"); // B2 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid step(b, "cancel"); // B2 cancel // Paid -> Cancelled: refund payment // enter Cancelled: release reserved stock }}from __future__ import annotationsfrom dataclasses import dataclassfrom typing import Callable, Optional
class IllegalAction(Exception): pass
# One row per legal move. Anything not in the table is refused, so the table is the# whole state machine: you can print it, draw it, test it, or load it from config.@dataclass(frozen=True)class Transition: src: str event: str to: str guard: Optional[Callable[[Order], bool]] = None guard_fails: str = "" action: Optional[str] = None
TABLE = [ Transition("PendingPayment", "pay", "Paid"), Transition("PendingPayment", "cancel", "Cancelled"), Transition("Paid", "ship", "Shipped", guard=lambda o: o.fraud_check_passed, guard_fails="fraud check not passed yet"), Transition("Paid", "cancel", "Cancelled", action="refund payment"), Transition("Shipped", "deliver", "Delivered"), Transition("Delivered", "return", "Refunded", action="refund payment"),]
# Entry and exit actions are per state, so they live beside the table, not in a row.ON_ENTER = { "PendingPayment": "start payment timer", "Shipped": "email tracking link", "Cancelled": "release reserved stock", "Refunded": "restock returned item",}ON_EXIT = {"PendingPayment": "stop payment timer"}
# Data can be checked when it loads. Two rows for the same (state, event) would make the# machine depend on row order, so refuse to start.def _validate(table: list[Transition]) -> None: seen: set[tuple[str, str]] = set() for t in table: if (t.src, t.event) in seen: raise ValueError(f"duplicate row: {t.src} on {t.event}") seen.add((t.src, t.event))
_validate(TABLE)
class Order: def __init__(self, order_id: str): self.id = order_id self.state = "PendingPayment" self.fraud_check_passed = False print(f"{order_id} created") self._enter(self.state) # the first state's entry action runs too
# One generic method replaces pay(), ship(), cancel()... The cost: a typo in an event # name is a run-time refusal, not an error your editor shows you. def fire(self, event: str) -> None: t = next((r for r in TABLE if r.src == self.state and r.event == event), None) if t is None: raise IllegalAction(f"{self.state} cannot {event}") if t.guard is not None and not t.guard(self): raise IllegalAction(t.guard_fails) # Same order as the class version: exit, the row's action, entry. if self.state in ON_EXIT: self.log(f"exit {self.state}: {ON_EXIT[self.state]}") self.log(f"{self.state} -> {t.to}" + (f": {t.action}" if t.action else "")) self.state = t.to self._enter(self.state)
def _enter(self, s: str) -> None: if s in ON_ENTER: self.log(f"enter {s}: {ON_ENTER[s]}")
def log(self, line: str) -> None: print(f" {line}")
def step(o: Order, event: str) -> None: print(f"{o.id} {event}") try: o.fire(event) except IllegalAction as e: o.log(f"refused: {e}")
if __name__ == "__main__": # The same script as the class version, and the same output, line for line. a = Order("A1") # A1 created # enter PendingPayment: start payment timer step(a, "pay") # A1 pay # exit PendingPayment: stop payment timer # PendingPayment -> Paid step(a, "ship") # A1 ship # refused: fraud check not passed yet a.fraud_check_passed = True step(a, "ship") # A1 ship # Paid -> Shipped # enter Shipped: email tracking link step(a, "cancel") # A1 cancel # refused: Shipped cannot cancel step(a, "deliver") # A1 deliver # Shipped -> Delivered step(a, "return") # A1 return # Delivered -> Refunded: refund payment # enter Refunded: restock returned item
b = Order("B2") # B2 created # enter PendingPayment: start payment timer step(b, "pay") # B2 pay # exit PendingPayment: stop payment timer # PendingPayment -> Paid step(b, "cancel") # B2 cancel # Paid -> Cancelled: refund payment # enter Cancelled: release reserved stockusing System;using System.Collections.Generic;using System.Linq;
// One row per legal move. Anything not in the table is refused, so the table is the// whole state machine: you can print it, draw it, test it, or load it from config.public sealed record Transition( string From, string Event, string To, Func<Order, bool>? Guard = null, string GuardFails = "", string? Action = null);
public static class OrderRules{ public static readonly IReadOnlyList<Transition> Table = Validate(new List<Transition> { new("PendingPayment", "pay", "Paid"), new("PendingPayment", "cancel", "Cancelled"), new("Paid", "ship", "Shipped", Guard: o => o.FraudCheckPassed, GuardFails: "fraud check not passed yet"), new("Paid", "cancel", "Cancelled", Action: "refund payment"), new("Shipped", "deliver", "Delivered"), new("Delivered", "return", "Refunded", Action: "refund payment"), });
// Entry and exit actions are per state, so they live beside the table, not in a row. public static readonly IReadOnlyDictionary<string, string> OnEnter = new Dictionary<string, string> { ["PendingPayment"] = "start payment timer", ["Shipped"] = "email tracking link", ["Cancelled"] = "release reserved stock", ["Refunded"] = "restock returned item", }; public static readonly IReadOnlyDictionary<string, string> OnExit = new Dictionary<string, string> { ["PendingPayment"] = "stop payment timer", };
// Data can be checked when it loads. Two rows for the same (state, event) would make // the machine depend on row order, so refuse to start. private static List<Transition> Validate(List<Transition> table) { var seen = new HashSet<(string, string)>(); foreach (var t in table) if (!seen.Add((t.From, t.Event))) throw new InvalidOperationException($"duplicate row: {t.From} on {t.Event}"); return table; }}
public sealed class Order{ public string Id { get; } public string State { get; private set; } = "PendingPayment"; public bool FraudCheckPassed { get; set; }
public Order(string id) { Id = id; Console.WriteLine($"{id} created"); Enter(State); // the first state's entry action runs too }
// One generic method replaces Pay(), Ship(), Cancel()... The cost: a typo in an event // name is a run-time refusal, not a compile error. public void Fire(string evt) { var t = OrderRules.Table.FirstOrDefault(r => r.From == State && r.Event == evt) ?? throw new InvalidOperationException($"{State} cannot {evt}"); if (t.Guard is not null && !t.Guard(this)) throw new InvalidOperationException(t.GuardFails); // Same order as the class version: exit, the row's action, entry. if (OrderRules.OnExit.TryGetValue(State, out var exit)) Log($"exit {State}: {exit}"); Log($"{State} -> {t.To}" + (t.Action is null ? "" : $": {t.Action}")); State = t.To; Enter(State); }
private void Enter(string s) { if (OrderRules.OnEnter.TryGetValue(s, out var entry)) Log($"enter {s}: {entry}"); }
public void Log(string line) => Console.WriteLine($" {line}");}
public static class Program{ static void Step(Order o, string evt) { Console.WriteLine($"{o.Id} {evt}"); try { o.Fire(evt); } catch (InvalidOperationException e) { o.Log($"refused: {e.Message}"); } }
public static void Main() { // The same script as the class version, and the same output, line for line. var a = new Order("A1"); // A1 created // enter PendingPayment: start payment timer Step(a, "pay"); // A1 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid Step(a, "ship"); // A1 ship // refused: fraud check not passed yet a.FraudCheckPassed = true; Step(a, "ship"); // A1 ship // Paid -> Shipped // enter Shipped: email tracking link Step(a, "cancel"); // A1 cancel // refused: Shipped cannot cancel Step(a, "deliver"); // A1 deliver // Shipped -> Delivered Step(a, "return"); // A1 return // Delivered -> Refunded: refund payment // enter Refunded: restock returned item
var b = new Order("B2"); // B2 created // enter PendingPayment: start payment timer Step(b, "pay"); // B2 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid Step(b, "cancel"); // B2 cancel // Paid -> Cancelled: refund payment // enter Cancelled: release reserved stock }}package main
import ( "errors" "fmt")
// Transition is one row per legal move. Anything not in the table is refused, so the// table is the whole state machine: you can print it, draw it, test it, or load it from config.type Transition struct { From, Event, To string Guard func(*Order) bool // nil means always allowed GuardFails string Action string}
var table = mustValidate([]Transition{ {From: "PendingPayment", Event: "pay", To: "Paid"}, {From: "PendingPayment", Event: "cancel", To: "Cancelled"}, {From: "Paid", Event: "ship", To: "Shipped", Guard: func(o *Order) bool { return o.FraudCheckPassed }, GuardFails: "fraud check not passed yet"}, {From: "Paid", Event: "cancel", To: "Cancelled", Action: "refund payment"}, {From: "Shipped", Event: "deliver", To: "Delivered"}, {From: "Delivered", Event: "return", To: "Refunded", Action: "refund payment"},})
// Entry and exit actions are per state, so they live beside the table, not in a row.var onEnter = map[string]string{ "PendingPayment": "start payment timer", "Shipped": "email tracking link", "Cancelled": "release reserved stock", "Refunded": "restock returned item",}var onExit = map[string]string{"PendingPayment": "stop payment timer"}
// mustValidate checks the data when it loads. Two rows for the same (state, event) would// make the machine depend on row order, so refuse to start.func mustValidate(rows []Transition) []Transition { seen := map[[2]string]bool{} for _, t := range rows { key := [2]string{t.From, t.Event} if seen[key] { panic(fmt.Sprintf("duplicate row: %s on %s", t.From, t.Event)) } seen[key] = true } return rows}
type Order struct { ID string State string FraudCheckPassed bool}
func NewOrder(id string) *Order { fmt.Println(id, "created") o := &Order{ID: id, State: "PendingPayment"} o.enter(o.State) // the first state's entry action runs too return o}
// Fire is one generic method in place of Pay, Ship, Cancel... The cost: a typo in an// event name is a run-time refusal, not a compile error.func (o *Order) Fire(event string) error { var t *Transition for i := range table { if table[i].From == o.State && table[i].Event == event { t = &table[i] break } } if t == nil { return fmt.Errorf("%s cannot %s", o.State, event) } if t.Guard != nil && !t.Guard(o) { return errors.New(t.GuardFails) } // Same order as the class version: exit, the row's action, entry. if exit, ok := onExit[o.State]; ok { o.log("exit " + o.State + ": " + exit) } line := o.State + " -> " + t.To if t.Action != "" { line += ": " + t.Action } o.log(line) o.State = t.To o.enter(o.State) return nil}
func (o *Order) enter(s string) { if entry, ok := onEnter[s]; ok { o.log("enter " + s + ": " + entry) }}
func (o *Order) log(line string) { fmt.Println(" " + line) }
func step(o *Order, event string) { fmt.Println(o.ID, event) if err := o.Fire(event); err != nil { o.log("refused: " + err.Error()) }}
func main() { // The same script as the class version, and the same output, line for line. a := NewOrder("A1") // A1 created // enter PendingPayment: start payment timer step(a, "pay") // A1 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid step(a, "ship") // A1 ship // refused: fraud check not passed yet a.FraudCheckPassed = true step(a, "ship") // A1 ship // Paid -> Shipped // enter Shipped: email tracking link step(a, "cancel") // A1 cancel // refused: Shipped cannot cancel step(a, "deliver") // A1 deliver // Shipped -> Delivered step(a, "return") // A1 return // Delivered -> Refunded: refund payment // enter Refunded: restock returned item
b := NewOrder("B2") // B2 created // enter PendingPayment: start payment timer step(b, "pay") // B2 pay // exit PendingPayment: stop payment timer // PendingPayment -> Paid step(b, "cancel") // B2 cancel // Paid -> Cancelled: refund payment // enter Cancelled: release reserved stock}type OrderStateName = 'PendingPayment' | 'Paid' | 'Shipped' | 'Delivered' | 'Cancelled' | 'Refunded';type OrderEvent = 'pay' | 'ship' | 'deliver' | 'cancel' | 'return';
// One row per legal move. Anything not in the table is refused, so the table is the// whole state machine: you can print it, draw it, test it, or load it from config.interface Transition { from: OrderStateName; event: OrderEvent; to: OrderStateName; guard?: (o: Order) => boolean; guardFails?: string; action?: string;}
// String unions let the compiler check names in the table, which a plain string table// can't. A table loaded from config would need the same check at load time.const TABLE: readonly Transition[] = validate([ { from: 'PendingPayment', event: 'pay', to: 'Paid' }, { from: 'PendingPayment', event: 'cancel', to: 'Cancelled' }, { from: 'Paid', event: 'ship', to: 'Shipped', guard: (o) => o.fraudCheckPassed, guardFails: 'fraud check not passed yet' }, { from: 'Paid', event: 'cancel', to: 'Cancelled', action: 'refund payment' }, { from: 'Shipped', event: 'deliver', to: 'Delivered' }, { from: 'Delivered', event: 'return', to: 'Refunded', action: 'refund payment' },]);
// Entry and exit actions are per state, so they live beside the table, not in a row.const ON_ENTER: Partial<Record<OrderStateName, string>> = { PendingPayment: 'start payment timer', Shipped: 'email tracking link', Cancelled: 'release reserved stock', Refunded: 'restock returned item',};const ON_EXIT: Partial<Record<OrderStateName, string>> = { PendingPayment: 'stop payment timer' };
// Data can be checked when it loads. Two rows for the same (state, event) would make the// machine depend on row order, so refuse to start.function validate(rows: Transition[]): Transition[] { const seen = new Set<string>(); for (const t of rows) { const key = `${t.from}/${t.event}`; if (seen.has(key)) throw new Error(`duplicate row: ${t.from} on ${t.event}`); seen.add(key); } return rows;}
class Order { state: OrderStateName = 'PendingPayment'; fraudCheckPassed = false;
constructor(readonly id: string) { console.log(`${id} created`); this.enter(this.state); // the first state's entry action runs too }
// One generic method replaces pay(), ship(), cancel()... Without the union types, a // typo in an event name would be a run-time refusal, not a compile error. fire(event: OrderEvent): void { const t = TABLE.find((r) => r.from === this.state && r.event === event); if (!t) throw new Error(`${this.state} cannot ${event}`); if (t.guard && !t.guard(this)) throw new Error(t.guardFails); // Same order as the class version: exit, the row's action, entry. const exit = ON_EXIT[this.state]; if (exit) this.log(`exit ${this.state}: ${exit}`); this.log(`${this.state} -> ${t.to}` + (t.action ? `: ${t.action}` : '')); this.state = t.to; this.enter(this.state); }
private enter(s: OrderStateName): void { const entry = ON_ENTER[s]; if (entry) this.log(`enter ${s}: ${entry}`); }
log(line: string): void { console.log(` ${line}`); }}
function step(o: Order, event: OrderEvent): void { console.log(`${o.id} ${event}`); try { o.fire(event); } catch (e) { o.log(`refused: ${(e as Error).message}`); }}
// The same script as the class version, and the same output, line for line.const a = new Order('A1');// A1 created// enter PendingPayment: start payment timerstep(a, 'pay');// A1 pay// exit PendingPayment: stop payment timer// PendingPayment -> Paidstep(a, 'ship');// A1 ship// refused: fraud check not passed yeta.fraudCheckPassed = true;step(a, 'ship');// A1 ship// Paid -> Shipped// enter Shipped: email tracking linkstep(a, 'cancel');// A1 cancel// refused: Shipped cannot cancelstep(a, 'deliver');// A1 deliver// Shipped -> Deliveredstep(a, 'return');// A1 return// Delivered -> Refunded: refund payment// enter Refunded: restock returned item
const b = new Order('B2');// B2 created// enter PendingPayment: start payment timerstep(b, 'pay');// B2 pay// exit PendingPayment: stop payment timer// PendingPayment -> Paidstep(b, 'cancel');// B2 cancel// Paid -> Cancelled: refund payment// enter Cancelled: release reserved stockBoth programs run the same script and print the same output, line for line.
What to notice
Section titled “What to notice”- Exit actions catch every way out. The timer is stopped by
PendingPayment’s exit action. So neitherpaynorcancelhas to remember it. And a third way out, added next year, can’t forget it either. - The order of effects is fixed. Exit actions run first, then the transition’s action, then entry actions. That’s the order UML state machines use, and it means a state’s exit always sees the order as it was.
- A guard is not a missing transition.
Shipped cannot cancelmeans “never from here”.fraud check not passed yetmeans “not yet”. The caller handles them differently. The first is a final answer for the customer. The second is worth retrying. - The fraud check is data, not an event. Clearing it changes no state by itself. The guard reads
it when
shiparrives. If clearing it should ship the order automatically, make it an event instead. - The table can be checked as data. It is validated when it loads: no two rows for the same state and event. It can be printed, drawn or diffed in review. The classes can’t be inspected that way without running them.
- Names in the table are strings. A typo in an event name is a refusal at run time. The TypeScript version uses string unions, so the compiler checks the table. In the others, the load-time check is the safety net.
Classes or a table?
Section titled “Classes or a table?”| Class per state | Transition table | |
|---|---|---|
| Where a state’s behavior lives | In its class, next to its transitions | Spread across rows and the entry and exit maps |
| Seeing the whole machine | Read every class, or keep a diagram up to date | Read one list; draw it from the data |
| States that do real work per event | Natural: each method is ordinary code | Awkward: actions become names mapped to code |
| Changing the rules without a release | No | Possible: rows can come from config |
| Compiler help | Methods per event are checked | Only if names are typed (enums, unions) |
For this order, states differ mostly in which moves are legal, and the table is shorter. The parking lot makes that call for its ticket. The vending machine goes the other way. Every coin, button and sensor report means something different in every state. So a class per state keeps each answer in one place. Many real systems mix them. A table says which moves are legal, and per-state code says what each state does.
Customers can cancel until the parcel ships. Where does that rule live, and how does the app know whether to show the cancel button?
Whether the rule lives once, in the state machine (no cancel in Shipped, or no row for it).
And whether the app asks the machine which events are allowed now, from the current state’s
methods or the table’s rows. The red flag is copying “status is Paid or PendingPayment” into the
frontend. The two copies drift, and the button shows for orders that will refuse.
Order events from many servers
- State stored as a name and a version
- Compare-and-set on every transition
- Idempotent event handling
- Audit log and outbox
The order service now runs on several servers. Each order is a row in the database, with its state’s name. Events reach it from everywhere. The customer clicks. The payment provider sends webhooks. And the warehouse asks “may I ship A1?” right before handing the parcel to the carrier. Webhooks are delivered at least once. So after a timeout, the same event arrives again.
The state classes still decide what is legal. What’s new is that two servers can load the same order, each decide on what they read, and each write back.
The race
Section titled “The race”The order is Paid. The warehouse asks to ship it at the same second the customer cancels. Both handlers read the row before either writes:
A1state Paidversion 2Warehouse evt-2 ship
Customer evt-3 cancel
Effects sent
Audit log
With last write wins, both decisions were legal on what each handler read, so both apply. The parcel goes to the carrier and the refund goes to the customer, and the row only remembers whichever write landed last:
import java.util.ArrayList;import java.util.HashMap;import java.util.List;import java.util.Map;
record Event(String id, String type) {}
// What a state decides for one event: move to `next` and send `effect`, or refuse.record Decision(String next, String effect, String refusal) { static Decision go(String next, String effect) { return new Decision(next, effect, null); } static Decision refuse(String state, String event) { return new Decision(null, null, "cannot " + event + ", order is " + state); }}
// Events now arrive as data from a queue, so a state takes the event's name.interface OrderState { String name(); Decision on(String event);}
class PendingPayment implements OrderState { public String name() { return "PendingPayment"; } public Decision on(String event) { return switch (event) { case "pay" -> Decision.go("Paid", "email receipt"); case "cancel" -> Decision.go("Cancelled", "release stock"); default -> Decision.refuse(name(), event); }; }}
class Paid implements OrderState { public String name() { return "Paid"; } public Decision on(String event) { return switch (event) { case "ship" -> Decision.go("Shipped", "email tracking link"); case "cancel" -> Decision.go("Cancelled", "refund payment"); default -> Decision.refuse(name(), event); }; }}
class Shipped implements OrderState { public String name() { return "Shipped"; } public Decision on(String event) { return event.equals("deliver") ? Decision.go("Delivered", null) : Decision.refuse(name(), event); }}
class Delivered implements OrderState { public String name() { return "Delivered"; } public Decision on(String event) { return Decision.refuse(name(), event); }}
class Cancelled implements OrderState { public String name() { return "Cancelled"; } public Decision on(String event) { return Decision.refuse(name(), event); }}
final class States { private static final Map<String, OrderState> BY_NAME = new HashMap<>(); static { for (OrderState s : List.of(new PendingPayment(), new Paid(), new Shipped(), new Delivered(), new Cancelled())) BY_NAME.put(s.name(), s); } static OrderState fromName(String name) { return BY_NAME.get(name); }}
// Stands in for the database: one row per order, holding only the state's name.class OrderStore { private final Map<String, String> rows = new HashMap<>(); final List<String> sent = new ArrayList<>();
synchronized void insert(String id, String state) { rows.put(id, state); } synchronized String load(String id) { return rows.get(id); } // flaw 1: last write wins. Nothing checks that the row still holds what the handler read. synchronized void save(String id, String state) { rows.put(id, state); }}
class OrderEvents { private final OrderStore store; OrderEvents(OrderStore store) { this.store = store; }
void handle(String orderId, Event e) { apply(orderId, e, store.load(orderId)); }
void apply(String orderId, Event e, String state) { Decision d = States.fromName(state).on(e.type()); if (d.refusal() != null) { report(e, "refused: " + d.refusal()); return; } store.save(orderId, d.next()); // flaw 2: the effect goes out at once, even if another handler's write lands on top. if (d.effect() != null) store.sent.add(d.effect()); report(e, state + " -> " + d.next()); }
private static void report(Event e, String what) { System.out.println(e.id() + " " + e.type() + ": " + what); }}
class Main { public static void main(String[] args) { OrderStore store = new OrderStore(); OrderEvents events = new OrderEvents(store); store.insert("A1", "PendingPayment"); events.handle("A1", new Event("evt-1", "pay")); // evt-1 pay: PendingPayment -> Paid
// The warehouse asks to ship while the customer cancels. Both handlers read the // row before either writes, as two servers would. String warehouseRead = store.load("A1"); String customerRead = store.load("A1"); events.apply("A1", new Event("evt-2", "ship"), warehouseRead); // evt-2 ship: Paid -> Shipped events.apply("A1", new Event("evt-3", "cancel"), customerRead); // evt-3 cancel: Paid -> Cancelled
// flaw 3: the warehouse's webhook timed out and is sent again. Nothing remembers // evt-2, so the warehouse gets an error for a parcel it has already handed over. events.handle("A1", new Event("evt-2", "ship")); // evt-2 ship: refused: cannot ship, order is Cancelled
System.out.println("state: " + store.load("A1")); // state: Cancelled System.out.println("sent: " + String.join(", ", store.sent)); // sent: email receipt, email tracking link, refund payment // The parcel is with the carrier and the money is back with the customer. }}from __future__ import annotationsimport threadingfrom dataclasses import dataclassfrom typing import Optional
@dataclass(frozen=True)class Event: id: str type: str
# What a state decides for one event: move to `next` and send `effect`, or refuse.@dataclass(frozen=True)class Decision: next: Optional[str] = None effect: Optional[str] = None refusal: Optional[str] = None
def go(nxt: str, effect: Optional[str]) -> Decision: return Decision(next=nxt, effect=effect)
def refuse(state: str, event: str) -> Decision: return Decision(refusal=f"cannot {event}, order is {state}")
# Events now arrive as data from a queue, so a state takes the event's name.class OrderState: name = "OrderState"
def on(self, event: str) -> Decision: return refuse(self.name, event)
class PendingPayment(OrderState): name = "PendingPayment"
def on(self, event: str) -> Decision: if event == "pay": return go("Paid", "email receipt") if event == "cancel": return go("Cancelled", "release stock") return refuse(self.name, event)
class Paid(OrderState): name = "Paid"
def on(self, event: str) -> Decision: if event == "ship": return go("Shipped", "email tracking link") if event == "cancel": return go("Cancelled", "refund payment") return refuse(self.name, event)
class Shipped(OrderState): name = "Shipped"
def on(self, event: str) -> Decision: return go("Delivered", None) if event == "deliver" else refuse(self.name, event)
class Delivered(OrderState): name = "Delivered"
class Cancelled(OrderState): name = "Cancelled"
BY_NAME = {s.name: s for s in (PendingPayment(), Paid(), Shipped(), Delivered(), Cancelled())}
# Stands in for the database: one row per order, holding only the state's name.class OrderStore: def __init__(self): self._lock = threading.Lock() self._rows: dict[str, str] = {} self.sent: list[str] = []
def insert(self, order_id: str, state: str) -> None: with self._lock: self._rows[order_id] = state
def load(self, order_id: str) -> str: with self._lock: return self._rows[order_id]
# flaw 1: last write wins. Nothing checks that the row still holds what the handler read. def save(self, order_id: str, state: str) -> None: with self._lock: self._rows[order_id] = state
class OrderEvents: def __init__(self, store: OrderStore): self._store = store
def handle(self, order_id: str, e: Event) -> None: self.apply(order_id, e, self._store.load(order_id))
def apply(self, order_id: str, e: Event, state: str) -> None: d = BY_NAME[state].on(e.type) if d.refusal is not None: report(e, f"refused: {d.refusal}") return self._store.save(order_id, d.next) # flaw 2: the effect goes out at once, even if another handler's write lands on top. if d.effect is not None: self._store.sent.append(d.effect) report(e, f"{state} -> {d.next}")
def report(e: Event, what: str) -> None: print(f"{e.id} {e.type}: {what}")
if __name__ == "__main__": store = OrderStore() events = OrderEvents(store) store.insert("A1", "PendingPayment") events.handle("A1", Event("evt-1", "pay")) # evt-1 pay: PendingPayment -> Paid
# The warehouse asks to ship while the customer cancels. Both handlers read the # row before either writes, as two servers would. warehouse_read = store.load("A1") customer_read = store.load("A1") events.apply("A1", Event("evt-2", "ship"), warehouse_read) # evt-2 ship: Paid -> Shipped events.apply("A1", Event("evt-3", "cancel"), customer_read) # evt-3 cancel: Paid -> Cancelled
# flaw 3: the warehouse's webhook timed out and is sent again. Nothing remembers # evt-2, so the warehouse gets an error for a parcel it has already handed over. events.handle("A1", Event("evt-2", "ship")) # evt-2 ship: refused: cannot ship, order is Cancelled
print(f"state: {store.load('A1')}") # state: Cancelled print(f"sent: {', '.join(store.sent)}") # sent: email receipt, email tracking link, refund payment # The parcel is with the carrier and the money is back with the customer.using System;using System.Collections.Generic;using System.Linq;
public sealed record Event(string Id, string Type);
// What a state decides for one event: move to Next and send Effect, or refuse.public sealed record Decision(string? Next, string? Effect, string? Refusal){ public static Decision Go(string next, string? effect) => new(next, effect, null); public static Decision Refuse(string state, string evt) => new(null, null, $"cannot {evt}, order is {state}");}
// Events now arrive as data from a queue, so a state takes the event's name.public interface IOrderState{ string Name { get; } Decision On(string evt);}
public sealed class PendingPayment : IOrderState{ public string Name => "PendingPayment"; public Decision On(string evt) => evt switch { "pay" => Decision.Go("Paid", "email receipt"), "cancel" => Decision.Go("Cancelled", "release stock"), _ => Decision.Refuse(Name, evt), };}
public sealed class Paid : IOrderState{ public string Name => "Paid"; public Decision On(string evt) => evt switch { "ship" => Decision.Go("Shipped", "email tracking link"), "cancel" => Decision.Go("Cancelled", "refund payment"), _ => Decision.Refuse(Name, evt), };}
public sealed class Shipped : IOrderState{ public string Name => "Shipped"; public Decision On(string evt) => evt == "deliver" ? Decision.Go("Delivered", null) : Decision.Refuse(Name, evt);}
public sealed class Delivered : IOrderState{ public string Name => "Delivered"; public Decision On(string evt) => Decision.Refuse(Name, evt);}
public sealed class Cancelled : IOrderState{ public string Name => "Cancelled"; public Decision On(string evt) => Decision.Refuse(Name, evt);}
public static class States{ private static readonly Dictionary<string, IOrderState> ByName = new IOrderState[] { new PendingPayment(), new Paid(), new Shipped(), new Delivered(), new Cancelled() } .ToDictionary(s => s.Name);
public static IOrderState FromName(string name) => ByName[name];}
// Stands in for the database: one row per order, holding only the state's name.public sealed class OrderStore{ private readonly object _lock = new(); private readonly Dictionary<string, string> _rows = new(); public List<string> Sent { get; } = new();
public void Insert(string id, string state) { lock (_lock) _rows[id] = state; } public string Load(string id) { lock (_lock) return _rows[id]; } // flaw 1: last write wins. Nothing checks that the row still holds what the handler read. public void Save(string id, string state) { lock (_lock) _rows[id] = state; }}
public sealed class OrderEvents(OrderStore store){ public void Handle(string orderId, Event e) => Apply(orderId, e, store.Load(orderId));
public void Apply(string orderId, Event e, string state) { var d = States.FromName(state).On(e.Type); if (d.Refusal is not null) { Report(e, $"refused: {d.Refusal}"); return; } store.Save(orderId, d.Next!); // flaw 2: the effect goes out at once, even if another handler's write lands on top. if (d.Effect is not null) store.Sent.Add(d.Effect); Report(e, $"{state} -> {d.Next}"); }
private static void Report(Event e, string what) => Console.WriteLine($"{e.Id} {e.Type}: {what}");}
public static class Program{ public static void Main() { var store = new OrderStore(); var events = new OrderEvents(store); store.Insert("A1", "PendingPayment"); events.Handle("A1", new Event("evt-1", "pay")); // evt-1 pay: PendingPayment -> Paid
// The warehouse asks to ship while the customer cancels. Both handlers read the // row before either writes, as two servers would. var warehouseRead = store.Load("A1"); var customerRead = store.Load("A1"); events.Apply("A1", new Event("evt-2", "ship"), warehouseRead); // evt-2 ship: Paid -> Shipped events.Apply("A1", new Event("evt-3", "cancel"), customerRead); // evt-3 cancel: Paid -> Cancelled
// flaw 3: the warehouse's webhook timed out and is sent again. Nothing remembers // evt-2, so the warehouse gets an error for a parcel it has already handed over. events.Handle("A1", new Event("evt-2", "ship")); // evt-2 ship: refused: cannot ship, order is Cancelled
Console.WriteLine($"state: {store.Load("A1")}"); // state: Cancelled Console.WriteLine($"sent: {string.Join(", ", store.Sent)}"); // sent: email receipt, email tracking link, refund payment // The parcel is with the carrier and the money is back with the customer. }}package main
import ( "fmt" "strings" "sync")
type Event struct{ ID, Type string }
// Decision is what a state decides for one event: move to Next and send Effect, or refuse.type Decision struct{ Next, Effect, Refusal string }
func goTo(next, effect string) Decision { return Decision{Next: next, Effect: effect} }func refuse(state, event string) Decision { return Decision{Refusal: fmt.Sprintf("cannot %s, order is %s", event, state)}}
// OrderState: events now arrive as data from a queue, so a state takes the event's name.type OrderState interface { Name() string On(event string) Decision}
type PendingPayment struct{}
func (PendingPayment) Name() string { return "PendingPayment" }func (s PendingPayment) On(event string) Decision { switch event { case "pay": return goTo("Paid", "email receipt") case "cancel": return goTo("Cancelled", "release stock") } return refuse(s.Name(), event)}
type Paid struct{}
func (Paid) Name() string { return "Paid" }func (s Paid) On(event string) Decision { switch event { case "ship": return goTo("Shipped", "email tracking link") case "cancel": return goTo("Cancelled", "refund payment") } return refuse(s.Name(), event)}
type Shipped struct{}
func (Shipped) Name() string { return "Shipped" }func (s Shipped) On(event string) Decision { if event == "deliver" { return goTo("Delivered", "") } return refuse(s.Name(), event)}
type Delivered struct{}
func (Delivered) Name() string { return "Delivered" }func (s Delivered) On(event string) Decision { return refuse(s.Name(), event) }
type Cancelled struct{}
func (Cancelled) Name() string { return "Cancelled" }func (s Cancelled) On(event string) Decision { return refuse(s.Name(), event) }
var byName = map[string]OrderState{}
func init() { for _, s := range []OrderState{PendingPayment{}, Paid{}, Shipped{}, Delivered{}, Cancelled{}} { byName[s.Name()] = s }}
// OrderStore stands in for the database: one row per order, holding only the state's name.type OrderStore struct { mu sync.Mutex rows map[string]string Sent []string}
func (st *OrderStore) Insert(id, state string) { st.mu.Lock() defer st.mu.Unlock() st.rows[id] = state}func (st *OrderStore) Load(id string) string { st.mu.Lock(); defer st.mu.Unlock(); return st.rows[id] }
// Save has flaw 1: last write wins. Nothing checks that the row still holds what the// handler read.func (st *OrderStore) Save(id, state string) { st.mu.Lock(); defer st.mu.Unlock(); st.rows[id] = state }
type OrderEvents struct{ store *OrderStore }
func (h OrderEvents) Handle(orderID string, e Event) { h.Apply(orderID, e, h.store.Load(orderID)) }
func (h OrderEvents) Apply(orderID string, e Event, state string) { d := byName[state].On(e.Type) if d.Refusal != "" { report(e, "refused: "+d.Refusal) return } h.store.Save(orderID, d.Next) // flaw 2: the effect goes out at once, even if another handler's write lands on top. if d.Effect != "" { h.store.Sent = append(h.store.Sent, d.Effect) } report(e, state+" -> "+d.Next)}
func report(e Event, what string) { fmt.Printf("%s %s: %s\n", e.ID, e.Type, what) }
func main() { store := &OrderStore{rows: map[string]string{}} events := OrderEvents{store} store.Insert("A1", "PendingPayment") events.Handle("A1", Event{"evt-1", "pay"}) // evt-1 pay: PendingPayment -> Paid
// The warehouse asks to ship while the customer cancels. Both handlers read the // row before either writes, as two servers would. warehouseRead := store.Load("A1") customerRead := store.Load("A1") events.Apply("A1", Event{"evt-2", "ship"}, warehouseRead) // evt-2 ship: Paid -> Shipped events.Apply("A1", Event{"evt-3", "cancel"}, customerRead) // evt-3 cancel: Paid -> Cancelled
// flaw 3: the warehouse's webhook timed out and is sent again. Nothing remembers // evt-2, so the warehouse gets an error for a parcel it has already handed over. events.Handle("A1", Event{"evt-2", "ship"}) // evt-2 ship: refused: cannot ship, order is Cancelled
fmt.Println("state:", store.Load("A1")) // state: Cancelled fmt.Println("sent:", strings.Join(store.Sent, ", ")) // sent: email receipt, email tracking link, refund payment // The parcel is with the carrier and the money is back with the customer.}interface OrderEvent { id: string; type: string;}
// What a state decides for one event: move to `next` and send `effect`, or refuse.type Decision = { next: string; effect?: string } | { refusal: string };
const go = (next: string, effect?: string): Decision => ({ next, effect });const refuse = (state: string, event: string): Decision => ({ refusal: `cannot ${event}, order is ${state}` });
// Events now arrive as data from a queue, so a state takes the event's name.interface OrderState { readonly name: string; on(event: string): Decision;}
const PendingPayment: OrderState = { name: 'PendingPayment', on(event) { if (event === 'pay') return go('Paid', 'email receipt'); if (event === 'cancel') return go('Cancelled', 'release stock'); return refuse(this.name, event); },};
const Paid: OrderState = { name: 'Paid', on(event) { if (event === 'ship') return go('Shipped', 'email tracking link'); if (event === 'cancel') return go('Cancelled', 'refund payment'); return refuse(this.name, event); },};
const Shipped: OrderState = { name: 'Shipped', on(event) { return event === 'deliver' ? go('Delivered') : refuse(this.name, event); },};
const Delivered: OrderState = { name: 'Delivered', on: (event) => refuse('Delivered', event) };const Cancelled: OrderState = { name: 'Cancelled', on: (event) => refuse('Cancelled', event) };
const BY_NAME = new Map([PendingPayment, Paid, Shipped, Delivered, Cancelled].map((s) => [s.name, s]));
// Stands in for the database: one row per order, holding only the state's name. In Node// the race is just as real: each handler awaits the database between its read and write.class OrderStore { private readonly rows = new Map<string, string>(); readonly sent: string[] = [];
insert(id: string, state: string): void { this.rows.set(id, state); } load(id: string): string { return this.rows.get(id)!; } // flaw 1: last write wins. Nothing checks that the row still holds what the handler read. save(id: string, state: string): void { this.rows.set(id, state); }}
class OrderEvents { constructor(private readonly store: OrderStore) {}
handle(orderId: string, e: OrderEvent): void { this.apply(orderId, e, this.store.load(orderId)); }
apply(orderId: string, e: OrderEvent, state: string): void { const d = BY_NAME.get(state)!.on(e.type); if ('refusal' in d) { report(e, `refused: ${d.refusal}`); return; } this.store.save(orderId, d.next); // flaw 2: the effect goes out at once, even if another handler's write lands on top. if (d.effect) this.store.sent.push(d.effect); report(e, `${state} -> ${d.next}`); }}
function report(e: OrderEvent, what: string): void { console.log(`${e.id} ${e.type}: ${what}`);}
const store = new OrderStore();const events = new OrderEvents(store);store.insert('A1', 'PendingPayment');events.handle('A1', { id: 'evt-1', type: 'pay' }); // evt-1 pay: PendingPayment -> Paid
// The warehouse asks to ship while the customer cancels. Both handlers read the// row before either writes, as two servers would.const warehouseRead = store.load('A1');const customerRead = store.load('A1');events.apply('A1', { id: 'evt-2', type: 'ship' }, warehouseRead); // evt-2 ship: Paid -> Shippedevents.apply('A1', { id: 'evt-3', type: 'cancel' }, customerRead); // evt-3 cancel: Paid -> Cancelled
// flaw 3: the warehouse's webhook timed out and is sent again. Nothing remembers// evt-2, so the warehouse gets an error for a parcel it has already handed over.events.handle('A1', { id: 'evt-2', type: 'ship' }); // evt-2 ship: refused: cannot ship, order is Cancelled
console.log(`state: ${store.load('A1')}`); // state: Cancelledconsole.log(`sent: ${store.sent.join(', ')}`); // sent: email receipt, email tracking link, refund payment// The parcel is with the carrier and the money is back with the customer.The state classes did nothing wrong. Each decision was correct for the state it was given. But the state was stale by the time it was saved. The fix is in how a decision becomes a change.
With a version check
Section titled “With a version check”- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- Color key
- Database
import java.util.ArrayList;import java.util.HashMap;import java.util.HashSet;import java.util.List;import java.util.Map;import java.util.Set;
record Event(String id, String type) {}
// What the database holds: the state's name and a version that goes up on every change.record Row(String state, int version) {}
// What a state decides for one event: move to `next` and queue `effect`, or refuse.record Decision(String next, String effect, String refusal) { static Decision go(String next, String effect) { return new Decision(next, effect, null); } static Decision refuse(String state, String event) { return new Decision(null, null, "cannot " + event + ", order is " + state); }}
// Pure: a state looks only at the event. No database, no clock, no network, so deciding// again on a retry is harmless. Effects come back as data and go out after the commit.interface OrderState { String name(); Decision on(String event);}
class PendingPayment implements OrderState { public String name() { return "PendingPayment"; } public Decision on(String event) { return switch (event) { case "pay" -> Decision.go("Paid", "email receipt"); case "cancel" -> Decision.go("Cancelled", "release stock"); default -> Decision.refuse(name(), event); }; }}
class Paid implements OrderState { public String name() { return "Paid"; } public Decision on(String event) { return switch (event) { case "ship" -> Decision.go("Shipped", "email tracking link"); case "cancel" -> Decision.go("Cancelled", "refund payment"); default -> Decision.refuse(name(), event); }; }}
class Shipped implements OrderState { public String name() { return "Shipped"; } public Decision on(String event) { return event.equals("deliver") ? Decision.go("Delivered", null) : Decision.refuse(name(), event); }}
class Delivered implements OrderState { public String name() { return "Delivered"; } public Decision on(String event) { return Decision.refuse(name(), event); }}
class Cancelled implements OrderState { public String name() { return "Cancelled"; } public Decision on(String event) { return Decision.refuse(name(), event); }}
// Rebuilds the state object from the name stored in the row. States hold no data, so one// shared instance of each is enough, and the name is all that needs storing.final class States { private static final Map<String, OrderState> BY_NAME = new HashMap<>(); static { for (OrderState s : List.of(new PendingPayment(), new Paid(), new Shipped(), new Delivered(), new Cancelled())) BY_NAME.put(s.name(), s); } static OrderState fromName(String name) { OrderState s = BY_NAME.get(name); // Fail loudly. A name this code doesn't know usually means a newer release wrote it. if (s == null) throw new IllegalStateException("unknown state '" + name + "': written by a newer release?"); return s; }}
// Stands in for the database. Each synchronized method is one transaction.class OrderStore { private final Map<String, Row> rows = new HashMap<>(); private final Set<String> processed = new HashSet<>(); final List<String> audit = new ArrayList<>(); final List<String> outbox = new ArrayList<>();
synchronized void insert(String id, String state) { rows.put(id, new Row(state, 1)); } synchronized Row load(String id) { return rows.get(id); } synchronized boolean seen(String eventId) { return processed.contains(eventId); }
// Compare-and-set, plus everything that must happen with it, in one transaction. In SQL: // UPDATE orders SET state = :next, version = version + 1 // WHERE id = :id AND version = :read -- 0 rows changed: someone moved first // INSERT INTO processed_events (event_id) ... -- unique key: a duplicate fails too // INSERT INTO order_audit ...; INSERT INTO outbox ... synchronized boolean commit(String id, Row read, String next, Event e, String effect) { if (rows.get(id).version() != read.version() || processed.contains(e.id())) return false; rows.put(id, new Row(next, read.version() + 1)); processed.add(e.id()); audit.add("v" + (read.version() + 1) + " " + read.state() + " -> " + next + " by " + e.id()); // The effect is written, not sent. A relay sends outbox rows after the commit, so a // refund can't go out for a transition that lost the race. if (effect != null) outbox.add(effect); return true; }}
class OrderEvents { private static final int MAX_ATTEMPTS = 3; private final OrderStore store; OrderEvents(OrderStore store) { this.store = store; }
void handle(String orderId, Event e) { apply(orderId, e, store.load(orderId)); }
// Starts from a row that may already be stale, as it is when two servers read at once. void apply(String orderId, Event e, Row row) { for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { // Idempotency: an event already applied is reported as done, not as an error, // so the sender stops retrying. if (store.seen(e.id())) { report(e, "duplicate, already applied"); return; } Decision d = States.fromName(row.state()).on(e.type()); // A refusal is not recorded: nothing changed, and the same event may be legal later. if (d.refusal() != null) { report(e, "refused: " + d.refusal()); return; } if (store.commit(orderId, row, d.next(), e, d.effect())) { report(e, row.state() + " -> " + d.next() + " (v" + (row.version() + 1) + ")"); return; } // Lost the race. Reload and decide again: the new state may refuse this event. Row fresh = store.load(orderId); report(e, "conflict: read v" + row.version() + ", row is now v" + fresh.version() + "; retrying"); row = fresh; } report(e, "gave up after " + MAX_ATTEMPTS + " conflicts"); }
private static void report(Event e, String what) { System.out.println(e.id() + " " + e.type() + ": " + what); }}
class Main { public static void main(String[] args) { OrderStore store = new OrderStore(); OrderEvents events = new OrderEvents(store); store.insert("A1", "PendingPayment"); events.handle("A1", new Event("evt-1", "pay")); // evt-1 pay: PendingPayment -> Paid (v2)
// The warehouse asks to ship while the customer cancels. Both handlers read the // row before either writes, as two servers would. Row warehouseRead = store.load("A1"); Row customerRead = store.load("A1"); events.apply("A1", new Event("evt-2", "ship"), warehouseRead); // evt-2 ship: Paid -> Shipped (v3) events.apply("A1", new Event("evt-3", "cancel"), customerRead); // evt-3 cancel: conflict: read v2, row is now v3; retrying // evt-3 cancel: refused: cannot cancel, order is Shipped
// The warehouse's webhook timed out, so it sends evt-2 again. events.handle("A1", new Event("evt-2", "ship")); // evt-2 ship: duplicate, already applied events.handle("A1", new Event("evt-4", "deliver")); // evt-4 deliver: Shipped -> Delivered (v4)
for (String line : store.audit) System.out.println("audit " + line); // audit v2 PendingPayment -> Paid by evt-1 // audit v3 Paid -> Shipped by evt-2 // audit v4 Shipped -> Delivered by evt-4 System.out.println("outbox: " + String.join(", ", store.outbox)); // outbox: email receipt, email tracking link
// A row written by a newer release, with a state this one has never heard of. store.insert("B7", "OnHold"); try { events.handle("B7", new Event("evt-5", "cancel")); } catch (IllegalStateException ex) { System.out.println("error: " + ex.getMessage()); } // error: unknown state 'OnHold': written by a newer release? }}from __future__ import annotationsimport threadingfrom dataclasses import dataclassfrom typing import Optional
@dataclass(frozen=True)class Event: id: str type: str
# What the database holds: the state's name and a version that goes up on every change.@dataclass(frozen=True)class Row: state: str version: int
# What a state decides for one event: move to `next` and queue `effect`, or refuse.@dataclass(frozen=True)class Decision: next: Optional[str] = None effect: Optional[str] = None refusal: Optional[str] = None
def go(nxt: str, effect: Optional[str]) -> Decision: return Decision(next=nxt, effect=effect)
def refuse(state: str, event: str) -> Decision: return Decision(refusal=f"cannot {event}, order is {state}")
# Pure: a state looks only at the event. No database, no clock, no network, so deciding# again on a retry is harmless. Effects come back as data and go out after the commit.class OrderState: name = "OrderState"
def on(self, event: str) -> Decision: return refuse(self.name, event)
class PendingPayment(OrderState): name = "PendingPayment"
def on(self, event: str) -> Decision: if event == "pay": return go("Paid", "email receipt") if event == "cancel": return go("Cancelled", "release stock") return refuse(self.name, event)
class Paid(OrderState): name = "Paid"
def on(self, event: str) -> Decision: if event == "ship": return go("Shipped", "email tracking link") if event == "cancel": return go("Cancelled", "refund payment") return refuse(self.name, event)
class Shipped(OrderState): name = "Shipped"
def on(self, event: str) -> Decision: return go("Delivered", None) if event == "deliver" else refuse(self.name, event)
class Delivered(OrderState): name = "Delivered"
class Cancelled(OrderState): name = "Cancelled"
# Rebuilds the state object from the name stored in the row. States hold no data, so one# shared instance of each is enough, and the name is all that needs storing._BY_NAME = {s.name: s for s in (PendingPayment(), Paid(), Shipped(), Delivered(), Cancelled())}
def state_from_name(name: str) -> OrderState: s = _BY_NAME.get(name) # Fail loudly. A name this code doesn't know usually means a newer release wrote it. if s is None: raise LookupError(f"unknown state '{name}': written by a newer release?") return s
# Stands in for the database. Each method that takes the lock is one transaction.class OrderStore: def __init__(self): self._lock = threading.Lock() self._rows: dict[str, Row] = {} self._processed: set[str] = set() self.audit: list[str] = [] self.outbox: list[str] = []
def insert(self, order_id: str, state: str) -> None: with self._lock: self._rows[order_id] = Row(state, 1)
def load(self, order_id: str) -> Row: with self._lock: return self._rows[order_id]
def seen(self, event_id: str) -> bool: with self._lock: return event_id in self._processed
# Compare-and-set, plus everything that must happen with it, in one transaction. In SQL: # UPDATE orders SET state = :next, version = version + 1 # WHERE id = :id AND version = :read -- 0 rows changed: someone moved first # INSERT INTO processed_events (event_id) ... -- unique key: a duplicate fails too # INSERT INTO order_audit ...; INSERT INTO outbox ... def commit(self, order_id: str, read: Row, nxt: str, e: Event, effect: Optional[str]) -> bool: with self._lock: if self._rows[order_id].version != read.version or e.id in self._processed: return False self._rows[order_id] = Row(nxt, read.version + 1) self._processed.add(e.id) self.audit.append(f"v{read.version + 1} {read.state} -> {nxt} by {e.id}") # The effect is written, not sent. A relay sends outbox rows after the commit, # so a refund can't go out for a transition that lost the race. if effect is not None: self.outbox.append(effect) return True
class OrderEvents: MAX_ATTEMPTS = 3
def __init__(self, store: OrderStore): self._store = store
def handle(self, order_id: str, e: Event) -> None: self.apply(order_id, e, self._store.load(order_id))
# Starts from a row that may already be stale, as it is when two servers read at once. def apply(self, order_id: str, e: Event, row: Row) -> None: for _ in range(self.MAX_ATTEMPTS): # Idempotency: an event already applied is reported as done, not as an error, # so the sender stops retrying. if self._store.seen(e.id): report(e, "duplicate, already applied") return d = state_from_name(row.state).on(e.type) # A refusal is not recorded: nothing changed, and the same event may be legal later. if d.refusal is not None: report(e, f"refused: {d.refusal}") return if self._store.commit(order_id, row, d.next, e, d.effect): report(e, f"{row.state} -> {d.next} (v{row.version + 1})") return # Lost the race. Reload and decide again: the new state may refuse this event. fresh = self._store.load(order_id) report(e, f"conflict: read v{row.version}, row is now v{fresh.version}; retrying") row = fresh report(e, f"gave up after {self.MAX_ATTEMPTS} conflicts")
def report(e: Event, what: str) -> None: print(f"{e.id} {e.type}: {what}")
if __name__ == "__main__": store = OrderStore() events = OrderEvents(store) store.insert("A1", "PendingPayment") events.handle("A1", Event("evt-1", "pay")) # evt-1 pay: PendingPayment -> Paid (v2)
# The warehouse asks to ship while the customer cancels. Both handlers read the # row before either writes, as two servers would. warehouse_read = store.load("A1") customer_read = store.load("A1") events.apply("A1", Event("evt-2", "ship"), warehouse_read) # evt-2 ship: Paid -> Shipped (v3) events.apply("A1", Event("evt-3", "cancel"), customer_read) # evt-3 cancel: conflict: read v2, row is now v3; retrying # evt-3 cancel: refused: cannot cancel, order is Shipped
# The warehouse's webhook timed out, so it sends evt-2 again. events.handle("A1", Event("evt-2", "ship")) # evt-2 ship: duplicate, already applied events.handle("A1", Event("evt-4", "deliver")) # evt-4 deliver: Shipped -> Delivered (v4)
for line in store.audit: print(f"audit {line}") # audit v2 PendingPayment -> Paid by evt-1 # audit v3 Paid -> Shipped by evt-2 # audit v4 Shipped -> Delivered by evt-4 print(f"outbox: {', '.join(store.outbox)}") # outbox: email receipt, email tracking link
# A row written by a newer release, with a state this one has never heard of. store.insert("B7", "OnHold") try: events.handle("B7", Event("evt-5", "cancel")) except LookupError as ex: print(f"error: {ex.args[0]}") # error: unknown state 'OnHold': written by a newer release?using System;using System.Collections.Generic;using System.Linq;
public sealed record Event(string Id, string Type);
// What the database holds: the state's name and a version that goes up on every change.public sealed record Row(string State, int Version);
// What a state decides for one event: move to Next and queue Effect, or refuse.public sealed record Decision(string? Next, string? Effect, string? Refusal){ public static Decision Go(string next, string? effect) => new(next, effect, null); public static Decision Refuse(string state, string evt) => new(null, null, $"cannot {evt}, order is {state}");}
// Pure: a state looks only at the event. No database, no clock, no network, so deciding// again on a retry is harmless. Effects come back as data and go out after the commit.public interface IOrderState{ string Name { get; } Decision On(string evt);}
public sealed class PendingPayment : IOrderState{ public string Name => "PendingPayment"; public Decision On(string evt) => evt switch { "pay" => Decision.Go("Paid", "email receipt"), "cancel" => Decision.Go("Cancelled", "release stock"), _ => Decision.Refuse(Name, evt), };}
public sealed class Paid : IOrderState{ public string Name => "Paid"; public Decision On(string evt) => evt switch { "ship" => Decision.Go("Shipped", "email tracking link"), "cancel" => Decision.Go("Cancelled", "refund payment"), _ => Decision.Refuse(Name, evt), };}
public sealed class Shipped : IOrderState{ public string Name => "Shipped"; public Decision On(string evt) => evt == "deliver" ? Decision.Go("Delivered", null) : Decision.Refuse(Name, evt);}
public sealed class Delivered : IOrderState{ public string Name => "Delivered"; public Decision On(string evt) => Decision.Refuse(Name, evt);}
public sealed class Cancelled : IOrderState{ public string Name => "Cancelled"; public Decision On(string evt) => Decision.Refuse(Name, evt);}
// Rebuilds the state object from the name stored in the row. States hold no data, so one// shared instance of each is enough, and the name is all that needs storing.public static class States{ private static readonly Dictionary<string, IOrderState> ByName = new IOrderState[] { new PendingPayment(), new Paid(), new Shipped(), new Delivered(), new Cancelled() } .ToDictionary(s => s.Name);
public static IOrderState FromName(string name) => // Fail loudly. A name this code doesn't know usually means a newer release wrote it. ByName.TryGetValue(name, out var s) ? s : throw new InvalidOperationException($"unknown state '{name}': written by a newer release?");}
// Stands in for the database. Each method that takes the lock is one transaction.public sealed class OrderStore{ private readonly object _lock = new(); private readonly Dictionary<string, Row> _rows = new(); private readonly HashSet<string> _processed = new(); public List<string> Audit { get; } = new(); public List<string> Outbox { get; } = new();
public void Insert(string id, string state) { lock (_lock) _rows[id] = new Row(state, 1); } public Row Load(string id) { lock (_lock) return _rows[id]; } public bool Seen(string eventId) { lock (_lock) return _processed.Contains(eventId); }
// Compare-and-set, plus everything that must happen with it, in one transaction. In SQL: // UPDATE orders SET state = @next, version = version + 1 // WHERE id = @id AND version = @read -- 0 rows changed: someone moved first // INSERT INTO processed_events (event_id) ... -- unique key: a duplicate fails too // INSERT INTO order_audit ...; INSERT INTO outbox ... public bool Commit(string id, Row read, string next, Event e, string? effect) { lock (_lock) { if (_rows[id].Version != read.Version || _processed.Contains(e.Id)) return false; _rows[id] = new Row(next, read.Version + 1); _processed.Add(e.Id); Audit.Add($"v{read.Version + 1} {read.State} -> {next} by {e.Id}"); // The effect is written, not sent. A relay sends outbox rows after the commit, // so a refund can't go out for a transition that lost the race. if (effect is not null) Outbox.Add(effect); return true; } }}
public sealed class OrderEvents(OrderStore store){ private const int MaxAttempts = 3;
public void Handle(string orderId, Event e) => Apply(orderId, e, store.Load(orderId));
// Starts from a row that may already be stale, as it is when two servers read at once. public void Apply(string orderId, Event e, Row row) { for (var attempt = 1; attempt <= MaxAttempts; attempt++) { // Idempotency: an event already applied is reported as done, not as an error, // so the sender stops retrying. if (store.Seen(e.Id)) { Report(e, "duplicate, already applied"); return; } var d = States.FromName(row.State).On(e.Type); // A refusal is not recorded: nothing changed, and the same event may be legal later. if (d.Refusal is not null) { Report(e, $"refused: {d.Refusal}"); return; } if (store.Commit(orderId, row, d.Next!, e, d.Effect)) { Report(e, $"{row.State} -> {d.Next} (v{row.Version + 1})"); return; } // Lost the race. Reload and decide again: the new state may refuse this event. var fresh = store.Load(orderId); Report(e, $"conflict: read v{row.Version}, row is now v{fresh.Version}; retrying"); row = fresh; } Report(e, $"gave up after {MaxAttempts} conflicts"); }
private static void Report(Event e, string what) => Console.WriteLine($"{e.Id} {e.Type}: {what}");}
public static class Program{ public static void Main() { var store = new OrderStore(); var events = new OrderEvents(store); store.Insert("A1", "PendingPayment"); events.Handle("A1", new Event("evt-1", "pay")); // evt-1 pay: PendingPayment -> Paid (v2)
// The warehouse asks to ship while the customer cancels. Both handlers read the // row before either writes, as two servers would. var warehouseRead = store.Load("A1"); var customerRead = store.Load("A1"); events.Apply("A1", new Event("evt-2", "ship"), warehouseRead); // evt-2 ship: Paid -> Shipped (v3) events.Apply("A1", new Event("evt-3", "cancel"), customerRead); // evt-3 cancel: conflict: read v2, row is now v3; retrying // evt-3 cancel: refused: cannot cancel, order is Shipped
// The warehouse's webhook timed out, so it sends evt-2 again. events.Handle("A1", new Event("evt-2", "ship")); // evt-2 ship: duplicate, already applied events.Handle("A1", new Event("evt-4", "deliver")); // evt-4 deliver: Shipped -> Delivered (v4)
foreach (var line in store.Audit) Console.WriteLine($"audit {line}"); // audit v2 PendingPayment -> Paid by evt-1 // audit v3 Paid -> Shipped by evt-2 // audit v4 Shipped -> Delivered by evt-4 Console.WriteLine($"outbox: {string.Join(", ", store.Outbox)}"); // outbox: email receipt, email tracking link
// A row written by a newer release, with a state this one has never heard of. store.Insert("B7", "OnHold"); try { events.Handle("B7", new Event("evt-5", "cancel")); } catch (InvalidOperationException ex) { Console.WriteLine($"error: {ex.Message}"); } // error: unknown state 'OnHold': written by a newer release? }}package main
import ( "fmt" "strings" "sync")
type Event struct{ ID, Type string }
// Row is what the database holds: the state's name and a version that goes up on every change.type Row struct { State string Version int}
// Decision is what a state decides for one event: move to Next and queue Effect, or refuse.type Decision struct{ Next, Effect, Refusal string }
func goTo(next, effect string) Decision { return Decision{Next: next, Effect: effect} }func refuse(state, event string) Decision { return Decision{Refusal: fmt.Sprintf("cannot %s, order is %s", event, state)}}
// OrderState is pure: a state looks only at the event. No database, no clock, no network,// so deciding again on a retry is harmless. Effects come back as data and go out after// the commit.type OrderState interface { Name() string On(event string) Decision}
type PendingPayment struct{}
func (PendingPayment) Name() string { return "PendingPayment" }func (s PendingPayment) On(event string) Decision { switch event { case "pay": return goTo("Paid", "email receipt") case "cancel": return goTo("Cancelled", "release stock") } return refuse(s.Name(), event)}
type Paid struct{}
func (Paid) Name() string { return "Paid" }func (s Paid) On(event string) Decision { switch event { case "ship": return goTo("Shipped", "email tracking link") case "cancel": return goTo("Cancelled", "refund payment") } return refuse(s.Name(), event)}
type Shipped struct{}
func (Shipped) Name() string { return "Shipped" }func (s Shipped) On(event string) Decision { if event == "deliver" { return goTo("Delivered", "") } return refuse(s.Name(), event)}
type Delivered struct{}
func (Delivered) Name() string { return "Delivered" }func (s Delivered) On(event string) Decision { return refuse(s.Name(), event) }
type Cancelled struct{}
func (Cancelled) Name() string { return "Cancelled" }func (s Cancelled) On(event string) Decision { return refuse(s.Name(), event) }
// byName rebuilds the state object from the name stored in the row. States hold no data,// so one shared value of each is enough, and the name is all that needs storing.var byName = map[string]OrderState{}
func init() { for _, s := range []OrderState{PendingPayment{}, Paid{}, Shipped{}, Delivered{}, Cancelled{}} { byName[s.Name()] = s }}
func stateFromName(name string) (OrderState, error) { s, ok := byName[name] // Fail loudly. A name this code doesn't know usually means a newer release wrote it. if !ok { return nil, fmt.Errorf("unknown state '%s': written by a newer release?", name) } return s, nil}
// OrderStore stands in for the database. Each method that takes the mutex is one transaction.type OrderStore struct { mu sync.Mutex rows map[string]Row processed map[string]bool Audit []string Outbox []string}
func NewOrderStore() *OrderStore { return &OrderStore{rows: map[string]Row{}, processed: map[string]bool{}}}
func (st *OrderStore) Insert(id, state string) { st.mu.Lock() defer st.mu.Unlock() st.rows[id] = Row{state, 1}}
func (st *OrderStore) Load(id string) Row { st.mu.Lock() defer st.mu.Unlock() return st.rows[id]}
func (st *OrderStore) Seen(eventID string) bool { st.mu.Lock() defer st.mu.Unlock() return st.processed[eventID]}
// Commit is compare-and-set, plus everything that must happen with it, in one// transaction. In SQL://// UPDATE orders SET state = :next, version = version + 1// WHERE id = :id AND version = :read -- 0 rows changed: someone moved first// INSERT INTO processed_events (event_id) ... -- unique key: a duplicate fails too// INSERT INTO order_audit ...; INSERT INTO outbox ...func (st *OrderStore) Commit(id string, read Row, next string, e Event, effect string) bool { st.mu.Lock() defer st.mu.Unlock() if st.rows[id].Version != read.Version || st.processed[e.ID] { return false } st.rows[id] = Row{next, read.Version + 1} st.processed[e.ID] = true st.Audit = append(st.Audit, fmt.Sprintf("v%d %s -> %s by %s", read.Version+1, read.State, next, e.ID)) // The effect is written, not sent. A relay sends outbox rows after the commit, so a // refund can't go out for a transition that lost the race. if effect != "" { st.Outbox = append(st.Outbox, effect) } return true}
const maxAttempts = 3
type OrderEvents struct{ store *OrderStore }
func (h OrderEvents) Handle(orderID string, e Event) error { return h.Apply(orderID, e, h.store.Load(orderID))}
// Apply starts from a row that may already be stale, as it is when two servers read at once.func (h OrderEvents) Apply(orderID string, e Event, row Row) error { for attempt := 1; attempt <= maxAttempts; attempt++ { // Idempotency: an event already applied is reported as done, not as an error, // so the sender stops retrying. if h.store.Seen(e.ID) { report(e, "duplicate, already applied") return nil } state, err := stateFromName(row.State) if err != nil { return err } d := state.On(e.Type) // A refusal is not recorded: nothing changed, and the same event may be legal later. if d.Refusal != "" { report(e, "refused: "+d.Refusal) return nil } if h.store.Commit(orderID, row, d.Next, e, d.Effect) { report(e, fmt.Sprintf("%s -> %s (v%d)", row.State, d.Next, row.Version+1)) return nil } // Lost the race. Reload and decide again: the new state may refuse this event. fresh := h.store.Load(orderID) report(e, fmt.Sprintf("conflict: read v%d, row is now v%d; retrying", row.Version, fresh.Version)) row = fresh } report(e, fmt.Sprintf("gave up after %d conflicts", maxAttempts)) return nil}
func report(e Event, what string) { fmt.Printf("%s %s: %s\n", e.ID, e.Type, what) }
func main() { store := NewOrderStore() events := OrderEvents{store} store.Insert("A1", "PendingPayment") events.Handle("A1", Event{"evt-1", "pay"}) // evt-1 pay: PendingPayment -> Paid (v2)
// The warehouse asks to ship while the customer cancels. Both handlers read the // row before either writes, as two servers would. warehouseRead := store.Load("A1") customerRead := store.Load("A1") events.Apply("A1", Event{"evt-2", "ship"}, warehouseRead) // evt-2 ship: Paid -> Shipped (v3) events.Apply("A1", Event{"evt-3", "cancel"}, customerRead) // evt-3 cancel: conflict: read v2, row is now v3; retrying // evt-3 cancel: refused: cannot cancel, order is Shipped
// The warehouse's webhook timed out, so it sends evt-2 again. events.Handle("A1", Event{"evt-2", "ship"}) // evt-2 ship: duplicate, already applied events.Handle("A1", Event{"evt-4", "deliver"}) // evt-4 deliver: Shipped -> Delivered (v4)
for _, line := range store.Audit { fmt.Println("audit " + line) } // audit v2 PendingPayment -> Paid by evt-1 // audit v3 Paid -> Shipped by evt-2 // audit v4 Shipped -> Delivered by evt-4 fmt.Println("outbox: " + strings.Join(store.Outbox, ", ")) // outbox: email receipt, email tracking link
// A row written by a newer release, with a state this one has never heard of. store.Insert("B7", "OnHold") if err := events.Handle("B7", Event{"evt-5", "cancel"}); err != nil { fmt.Println("error:", err) } // error: unknown state 'OnHold': written by a newer release?}interface OrderEvent { id: string; type: string;}
// What the database holds: the state's name and a version that goes up on every change.interface Row { readonly state: string; readonly version: number;}
// What a state decides for one event: move to `next` and queue `effect`, or refuse.type Decision = { next: string; effect?: string } | { refusal: string };
const go = (next: string, effect?: string): Decision => ({ next, effect });const refuse = (state: string, event: string): Decision => ({ refusal: `cannot ${event}, order is ${state}` });
// Pure: a state looks only at the event. No database, no clock, no network, so deciding// again on a retry is harmless. Effects come back as data and go out after the commit.interface OrderState { readonly name: string; on(event: string): Decision;}
const PendingPayment: OrderState = { name: 'PendingPayment', on(event) { if (event === 'pay') return go('Paid', 'email receipt'); if (event === 'cancel') return go('Cancelled', 'release stock'); return refuse(this.name, event); },};
const Paid: OrderState = { name: 'Paid', on(event) { if (event === 'ship') return go('Shipped', 'email tracking link'); if (event === 'cancel') return go('Cancelled', 'refund payment'); return refuse(this.name, event); },};
const Shipped: OrderState = { name: 'Shipped', on(event) { return event === 'deliver' ? go('Delivered') : refuse(this.name, event); },};
const Delivered: OrderState = { name: 'Delivered', on: (event) => refuse('Delivered', event) };const Cancelled: OrderState = { name: 'Cancelled', on: (event) => refuse('Cancelled', event) };
// Rebuilds the state object from the name stored in the row. States hold no data, so one// shared object of each is enough, and the name is all that needs storing.const BY_NAME = new Map([PendingPayment, Paid, Shipped, Delivered, Cancelled].map((s) => [s.name, s]));
function stateFromName(name: string): OrderState { const s = BY_NAME.get(name); // Fail loudly. A name this code doesn't know usually means a newer release wrote it. if (!s) throw new Error(`unknown state '${name}': written by a newer release?`); return s;}
// Stands in for the database. In Node the race is just as real as with threads: each// handler awaits the database between its read and its write, and others run meanwhile.class OrderStore { private readonly rows = new Map<string, Row>(); private readonly processed = new Set<string>(); readonly audit: string[] = []; readonly outbox: string[] = [];
insert(id: string, state: string): void { this.rows.set(id, { state, version: 1 }); } load(id: string): Row { return this.rows.get(id)!; } seen(eventId: string): boolean { return this.processed.has(eventId); }
// Compare-and-set, plus everything that must happen with it, in one transaction. In SQL: // UPDATE orders SET state = :next, version = version + 1 // WHERE id = :id AND version = :read -- 0 rows changed: someone moved first // INSERT INTO processed_events (event_id) ... -- unique key: a duplicate fails too // INSERT INTO order_audit ...; INSERT INTO outbox ... commit(id: string, read: Row, next: string, e: OrderEvent, effect?: string): boolean { if (this.rows.get(id)!.version !== read.version || this.processed.has(e.id)) return false; this.rows.set(id, { state: next, version: read.version + 1 }); this.processed.add(e.id); this.audit.push(`v${read.version + 1} ${read.state} -> ${next} by ${e.id}`); // The effect is written, not sent. A relay sends outbox rows after the commit, so a // refund can't go out for a transition that lost the race. if (effect) this.outbox.push(effect); return true; }}
const MAX_ATTEMPTS = 3;
class OrderEvents { constructor(private readonly store: OrderStore) {}
handle(orderId: string, e: OrderEvent): void { this.apply(orderId, e, this.store.load(orderId)); }
// Starts from a row that may already be stale, as it is when two servers read at once. apply(orderId: string, e: OrderEvent, row: Row): void { for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { // Idempotency: an event already applied is reported as done, not as an error, // so the sender stops retrying. if (this.store.seen(e.id)) return report(e, 'duplicate, already applied'); const d = stateFromName(row.state).on(e.type); // A refusal is not recorded: nothing changed, and the same event may be legal later. if ('refusal' in d) return report(e, `refused: ${d.refusal}`); if (this.store.commit(orderId, row, d.next, e, d.effect)) { return report(e, `${row.state} -> ${d.next} (v${row.version + 1})`); } // Lost the race. Reload and decide again: the new state may refuse this event. const fresh = this.store.load(orderId); report(e, `conflict: read v${row.version}, row is now v${fresh.version}; retrying`); row = fresh; } report(e, `gave up after ${MAX_ATTEMPTS} conflicts`); }}
function report(e: OrderEvent, what: string): void { console.log(`${e.id} ${e.type}: ${what}`);}
const store = new OrderStore();const events = new OrderEvents(store);store.insert('A1', 'PendingPayment');events.handle('A1', { id: 'evt-1', type: 'pay' }); // evt-1 pay: PendingPayment -> Paid (v2)
// The warehouse asks to ship while the customer cancels. Both handlers read the// row before either writes, as two servers would.const warehouseRead = store.load('A1');const customerRead = store.load('A1');events.apply('A1', { id: 'evt-2', type: 'ship' }, warehouseRead);// evt-2 ship: Paid -> Shipped (v3)events.apply('A1', { id: 'evt-3', type: 'cancel' }, customerRead);// evt-3 cancel: conflict: read v2, row is now v3; retrying// evt-3 cancel: refused: cannot cancel, order is Shipped
// The warehouse's webhook timed out, so it sends evt-2 again.events.handle('A1', { id: 'evt-2', type: 'ship' }); // evt-2 ship: duplicate, already appliedevents.handle('A1', { id: 'evt-4', type: 'deliver' }); // evt-4 deliver: Shipped -> Delivered (v4)
for (const line of store.audit) console.log(`audit ${line}`);// audit v2 PendingPayment -> Paid by evt-1// audit v3 Paid -> Shipped by evt-2// audit v4 Shipped -> Delivered by evt-4console.log(`outbox: ${store.outbox.join(', ')}`);// outbox: email receipt, email tracking link
// A row written by a newer release, with a state this one has never heard of.store.insert('B7', 'OnHold');try { events.handle('B7', { id: 'evt-5', type: 'cancel' });} catch (ex) { console.log(`error: ${(ex as Error).message}`);}// error: unknown state 'OnHold': written by a newer release?What to notice
Section titled “What to notice”- States became pure deciders. In the Easy level a state called
transitionToitself. Here it can’t, because a change isn’t real until the commit succeeds.on(event)returns aDecision(next state and effect, or a refusal) and touches nothing, so deciding again on a retry is harmless. - The version makes the write conditional.
UPDATE orders SET state = ?, version = version + 1 WHERE id = ? AND version = ?changes one row or none. None means someone moved first. Reload, and decide again. The retry doesn’t replay the cancel blindly. It asks the new state, andShippedrefuses it. - One transaction holds everything that must agree. The new state, the processed event id, the audit row and the outbox row commit together, or not at all. A refund is only written to the outbox. A relay sends it after the commit. So a transition that lost the race never sends one. That’s the outbox pattern, covered with sagas in distributed transactions.
- Duplicates are successes. The event id is stored with the transition, under a unique
key. When the warehouse sends
evt-2again, the answer is “already applied”, which stops its retries. Answering with an error (as the before version does) makes it retry forever or page someone. Two copies racing each other are caught by the same version check. - Refusals aren’t recorded. A refused event changed nothing, and it may be legal later. Webhooks
arrive out of order. A
deliverthat beats itsshipshould fail now, and succeed when it’s delivered again. - The state is stored as a name.
States.fromNameturns the row’s name back into the shared state object. States hold no data, so the name is all there is to store. An unknown name fails loudly. In a rolling deploy, ship the code that can read a new state before any code that writes it. Otherwise old servers will meetOnHoldand stop. - The audit log is the history. Every transition, its version and the event that caused it. “Why was this order refunded?” is answered by a query, not by guessing from logs.
Every few days an order ends up both refunded and shipped. Orders are processed on six servers. Find the bug and fix it.
Whether you spot read, decide, write without a condition. Whether you fix it with a version column and a compare-and-set, in the same transaction as the event id, the audit row and the outbox row. And whether you retry by deciding again against the new state. The red flag is a lock inside one service, which doesn’t cover six servers. So is sending the refund before the commit.
The state classes decide. The database makes it real. A handler loads the state name and version, rebuilds the state, and asks it for a decision. It commits with a compare-and-set on the version, together with the event id, the audit row and the outbox. If the version moved, it reloads and asks the new state. Duplicates return “already applied”.
Tradeoffs
Section titled “Tradeoffs”Use State in three cases. The same action means different things in different situations. There
are more than a few states or actions. Or illegal transitions are expensive (money, stock, published
content). Keep the enum when there are two or three states, and the actions barely differ. Then a
switch the compiler checks for completeness says it all.
In each language
Section titled “In each language”| Language | Usual form | Worth knowing |
|---|---|---|
| Java | An interface with default methods that refuse, one class per state | An enum whose constants override methods is a compact State when states hold no data |
| Python | A base class whose methods raise, one subclass per state | A dict of (state, event) rows is the table form; the transitions library builds machines from them |
| C# | An abstract class with virtual methods that throw | Switch expressions on (state, event) tuples express a table well; the Stateless library is a common choice |
| Go | An interface and a struct per state, with defaults from an embedded struct | State functions (a function that returns the next function) are idiomatic; the lexer in text/template uses them |
| TypeScript | Classes, or plain objects that satisfy an interface | A discriminated union of states with a switch gets exhaustiveness checks; XState implements statecharts |
Commonly confused with
Section titled “Commonly confused with”| Pattern | Looks like State because | The difference |
|---|---|---|
| Strategy | A context delegates to one of several objects behind an interface | The client picks a strategy and it stays put. States replace themselves as things happen, and they know their successors. |
An enum with a switch |
It models the same states | The enum is the data and the behavior lives in every method. With few states and actions, and an exhaustive switch, it’s the better choice. |
State machine libraries (XState, Spring Statemachine, Stateless, transitions) |
They are state machines | They implement the table form plus nested and parallel states, history and diagrams. Reach for one when the machine is large or nested; for a dozen states the pattern is enough. |
| Workflow or saga (distributed transactions) | Orders move through steps | A saga coordinates steps across services and compensates when a later step fails. Its own progress is a state machine, but State governs one object’s legal moves. |
Where it shows up
Section titled “Where it shows up”On this site:
- Vending machine: a class per state, because every coin, button and sensor event means something different in each.
- Parking lot: the ticket and the spot use an enum with a transition table, because their states differ only in which moves are legal.
- Strategy: the same shape, chosen from outside instead of changing itself.
In real systems, you’ll find state machines in:
- TCP connection states (LISTEN, SYN-SENT, ESTABLISHED and the rest).
- Payment objects, such as Stripe’s PaymentIntent statuses.
- Kubernetes pod phases.
- Pull requests (open, merged, closed), and CI jobs.
- The order, ticket and document workflows in nearly every business application.
Interview questions
Section titled “Interview questions”Interviewer asksWhat's the difference between State and Strategy?
The class diagram is the same. With Strategy, the client chooses an algorithm, and it stays until someone swaps it. Strategies don’t know each other. With State, the current state handles an action, and then picks the next state itself. So the object’s behavior changes as its situation does. Who switches, and why, is the difference.
Interviewer asksWho should change the state: the context or the states?
Usually the states: the state that handles an action knows where it leads, so the transition sits next to the behavior. A central transition table in the context shows the whole graph in one place but splits each decision from its effect. Either way, all changes go through one method, so logging and auditing happen once.
Interviewer asksShould state objects hold data?
Usually not. Data lives in the context, and states are behavior only. Then one instance of each
can be shared, switching is assigning a reference, and a stored state is just its name. A state
with its own data (attempts left in Retrying) works, but it can’t be shared and its data has to
be stored with it.
Interviewer asksHow do you persist an object that uses the State pattern?
Store the state’s name and a version, not the object. On load, map the name back to the state object with a registry. Fail loudly on a name you don’t know. On every transition, write with a compare-and-set on the version. Do it in the same transaction as the audit row, the processed event id and any outbox messages.
Interviewer asksWhen is an enum and a switch better than State?
When there are a handful of states and actions, the behavior per state is small, and the language
checks that a switch covers every case. A switch is easier to read top to bottom than five
classes. Move to State when the switches multiply across methods, or when a missed check would let
an illegal move through.
| Decision | We chose | We gave up |
|---|---|---|
| Where status rules live | One class per state behind one interface | Seeing one action’s behavior in one place |
| Illegal actions | Refused by default; a state implements only what’s legal | Nothing; it’s the point |
| Who picks the next state | The state that handled the action | A graph you can read in one file, unless you keep a table |
| Side effects | Entry and exit actions per state, actions per transition | A single place to read every effect |
| Classes or a table | Classes when states do real work, a table when they differ in legality | The other one’s strengths |
| Stored state | A name and a version, rebuilt through a registry | Storing data inside state objects |
| Concurrent events | Compare-and-set per transition, retry by deciding again | Simple unconditional writes |
| Duplicates and effects | Event ids, audit and outbox in the same transaction | A bit more schema and a relay to run |
Further reading
Section titled “Further reading”- Strategy, the same shape with a different job
- Vending machine and Parking lot, one class-per-state design and one table design
- Distributed transactions and sagas, for the outbox and for workflows that span services
- Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), the State chapter, whose example is a TCP connection
- David Harel, “Statecharts: A visual formalism for complex systems” (1987), the origin of the nested and parallel states that statechart libraries implement
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.