Home

/

The Production-Ready Playbook

/

State

State

Chapter 3
Part II
11
min read

State

The problem. An object behaves differently depending on which mode it is in, and the mode logic has spread into flag checks scattered across every method. An order moves through a known lifecycle, placed, confirmed, preparing, ready, picked up, delivered, with cancellation legal only at certain points, and a pile of boolean flags cannot enforce that.

public interface IOrderState
{
    IOrderState Confirm();
    IOrderState Cancel();
}

public sealed class Placed : IOrderState
{
    public IOrderState Confirm() => new Confirmed();
    public IOrderState Cancel()  => new Cancelled();
}

public sealed class Confirmed : IOrderState
{
    public IOrderState Confirm() => throw new InvalidOperationException("already confirmed");
    public IOrderState Cancel()  => new Cancelled();   // refund path
}

Each state owns its own legal transitions. An illegal move, like cancelling an order the courier has already collected, fails loudly in one obvious place instead of slipping through a missed flag.

What it buys you in production. The legal transitions live in the type system, so the illegal ones are hard to commit by accident. An order cannot jump from Placed straight to Delivered, because no method offers that move. This matters most for anything with a lifecycle, and the order is the spine of the whole app: a wrong transition is a customer charged for food that never came, or a refund on a meal already eaten.

The Order state machine

Skip-if. Two states and one transition. A boolean is fine and a state machine is theatre. State earns its place when transitions are constrained, several, and worth enforcing, which the order lifecycle plainly is.

State also ties forward. If you record each transition rather than just the current mode, the list of transitions is an event log, and you are one step from the Event Sourcing story in the persistence chapter, where OrderPlaced, OrderConfirmed, and the rest become the write model. State changes are events you have not written down yet.

A lifecycle modelled as states is a bug class deleted. The illegal transition stops compiling.

In the front-end. The customer's order-tracking screen is the same machine on the client. A small state hook drives the "preparing → on its way → arriving" UI, and modelling it as explicit states stops the screen from showing "courier 2 minutes away" for an order that was cancelled.

Proxy

The problem. You want to control access to an object: defer its creation, check a permission, throttle the call, or stand in for something that lives across a network, without the caller knowing any of that is happening. A restaurant's menu carries a photo for every item, and loading all of them up front is wasted bandwidth for images the customer may never scroll to.

public sealed class LazyMenuImage(string blobKey, IBlobStore store) : IMenuImage
{
    private byte[]? _bytes;

    public async Task<byte[]> Load(CancellationToken ct) =>
        _bytes ??= await store.Fetch(blobKey, ct);   // fetched once, on first use
}

The proxy presents the same IMenuImage as the real thing and defers the fetch until someone asks. The caller sees an image and nothing else.

What it buys you in production. Proxy puts a policy (lazy loading, auth, rate limiting) in front of an object without polluting that object. The same shape stands in for a remote restaurant service: a local proxy holds the address, opens the connection on first call, and the order code never knows whether the menu came from memory or a network hop. The structure looks like Decorator, and the code often does too; the intent is the difference. Decorator adds behaviour, Proxy controls access.

Skip-if. No access concern to enforce and no remote call to stand in for. A proxy that only forwards is dead weight. Reach for it when there is a real gate: a permission, a quota, a deferral, a network hop.

This is the same idea you will meet at the hosting altitude as the Ambassador and Sidecar. A proxy controls access to an object in-process; an ambassador controls access to a remote service out-of-process. One pattern, two altitudes.

In the front-end. Menu images lazy-load the same way: an <img loading="lazy"> or an intersection-observer wrapper fetches the photo only as it scrolls into view. Optimistic UI is a proxy too, standing in with a predicted result while the real call is in flight.

Composite

The problem. You have a tree of objects where a single thing and a group of things should be treated the same way, and you do not want every caller writing "if it is a leaf do this, if it is a branch recurse." A menu is exactly this tree: sections hold items, items hold modifier-groups (size, extras), modifier-groups hold modifiers, and you want one question, "what does this cost and is it available?", answered uniformly whether you ask a single modifier or a whole section.

public interface IMenuComponent
{
    Money Price();
    bool  Available();
}

public sealed class Modifier(Money price, bool inStock) : IMenuComponent
{
    public Money Price()    => price;            // leaf
    public bool  Available() => inStock;
}

public sealed class MenuNode(IReadOnlyList<IMenuComponent> children) : IMenuComponent
{
    public Money Price()    => children.Aggregate(Money.Zero, (sum, c) => sum + c.Price());
    public bool  Available() => children.All(c => c.Available());   // branch rolls up
}

A leaf (Modifier) and a branch (MenuNode) share IMenuComponent, so price sums and availability rolls up the tree with no special-casing. A section is unavailable when any required child is out of stock, and its price is just the sum below it, computed by the same two methods at every level.

The menu as a Composite

What it buys you in production. One interface for the whole menu means the pricing engine, the renderer, and the availability check each walk the tree without knowing its depth. Add a new node type, a "combo" that bundles items, and it slots in as another IMenuComponent with no edits to the walkers. The recursion lives in the structure, not smeared across every caller (GoF).

Skip-if. The structure is flat, or only ever one level deep. A list of items with no nesting does not need Composite; a foreach reads better than a tree. It earns its keep when the hierarchy is genuinely recursive and you want leaf and branch handled alike, which a real menu with nested modifiers is.

In the front-end. This is the component tree itself. Rendering the nested menu is a recursive component that draws a section or a single item with the same code, and the React reconciler is a Composite walking your elements. The pattern you reach for to render the menu is the pattern the framework already runs underneath it.

Money

The problem. Every price in this chapter has a currency and a rounding rule, and the moment you store it as a bare decimal you have scattered both across the codebase. One method rounds half-up, another truncates, a third adds a euro subtotal to a dollar delivery fee and nobody notices until a customer who orders in two markets sees a total that does not add up. Money is not a number. It is an amount and a currency travelling together, and when you split them you get the bugs that surface only in production, in another timezone, in a currency your tests never touch.

public readonly record struct Money(long Cents, string Currency)
{
    public static readonly Money Zero = new(0, "");   // additive identity; adopts the other currency

    public static Money operator +(Money a, Money b)
    {
        if (a == Zero) return b;
        if (b == Zero) return a;
        if (a.Currency != b.Currency)
            throw new InvalidOperationException($"cannot add {a.Currency} to {b.Currency}");
        return a with { Cents = a.Cents + b.Cents };
    }

    public static Money operator *(Money m, decimal factor) =>
        m with { Cents = (long)Math.Round(m.Cents * factor, MidpointRounding.ToEven) };
}

This is the Money you have already been reading: the amount.Cents the payment adapter handed to Stripe, the * clock.MultiplierAt(...) in the surge decorator, the Money.Zero seed the menu Composite summed into. One immutable value type owns the cents, the currency, and the single rounding convention the whole system agrees on.

What it buys you in production. A currency mismatch stops being a silently wrong total three calls later and becomes a loud exception on the line that caused it. Rounding lives in one place, so the surge multiplier and the promo discount cannot disagree about a half-cent. The type also makes a whole bug class structurally hard: you cannot hand a raw 1995 to something expecting Money, so you can never lose track of whether that meant dollars, cents, or pounds. Value objects (Fowler, Patterns of Enterprise Application Architecture, 2002, the Money pattern) pay off anywhere a primitive hides rules, and money hides the most expensive ones in the system.

Skip-if. A genuinely single-currency product that rounds in one obvious place can live with a decimal and a documented convention. But the type costs a dozen lines and the bugs it prevents reach an accountant, so the bar to skip it sits higher than it first looks. The day a second currency arrives, the decimal version is a refactor across every price you have; the Money version is a new string.

A bare decimal for money is a currency bug waiting for its second market. A type that carries its own currency never has that bug.

Null Object

The problem. A collaborator is optional, so half your methods guard it before they use it. The customer who turned off push notifications, the order with no promo, the tenant with no custom branding: each leaves a reference that might be set or might be null, and every call site pays the tax of checking first. Scatter enough if (notifier is not null) through the order flow and the real work disappears behind the defending.

public interface ICustomerNotifier
{
    Task Notify(OrderId order, string message, CancellationToken ct);
}

public sealed class PushNotifier(IPushService push) : ICustomerNotifier
{
    public Task Notify(OrderId order, string message, CancellationToken ct) =>
        push.Send(order, message, ct);
}

public sealed class NullNotifier : ICustomerNotifier      // does nothing, on purpose
{
    public static readonly NullNotifier Instance = new();
    public Task Notify(OrderId order, string message, CancellationToken ct) => Task.CompletedTask;
}

A customer who opted out is handed a NullNotifier; everyone else gets the real one. The status-update code calls notifier.Notify(...) and never asks which it is holding.

What it buys you in production. The null check is gone, because there is no null. The do-nothing path becomes one named, tested class instead of a branch copied to every call site, and a reader sees intent: this customer is deliberately not notified, not a missing dependency someone forgot to wire. The Null Object (Woolf, Pattern Languages of Program Design 3, 1998) is really a Strategy whose algorithm is "do nothing," which is why it drops in exactly where a Strategy does: register it like any other implementation and the caller stays oblivious.

Skip-if. Absence that means something is not a job for a Null Object. When no courier is free, the matcher returning null is a real condition the caller has to handle, hold the order, try again, tell the customer, and a do-nothing stand-in would bury a decision you need to make out loud. Reach for it when the neutral behaviour genuinely is "nothing happens." Keep the null when "nothing here" is news.

A Null Object turns "might be there" into "always there, sometimes silent." Use it for neutral behaviour, never to hide a real nothing.

The Singleton skip: global state wearing a pattern's name

Singleton is in the GoF catalogue. It is also the clearest over-engineering example at this altitude, and it is worth walking the trap because so many codebases still fall into it.

The classic shape is a class with a private constructor and a static Instance property, guaranteeing exactly one instance for the lifetime of the process. The intent sounds reasonable: some things should exist once. The cost is that you have created global mutable state and hidden a dependency. Every class that calls Logger.Instance now depends on Logger without saying so in its constructor, which means you cannot substitute it in a test, you cannot tell from the signature what a method touches, and two tests that share the instance can poison each other. Misko Hevery's line, that singletons are pathological liars, is about exactly this: the dependency is real but invisible.

The fix is not a cleverer Singleton. It is the dependency container you will meet in the next chapter, and the principle behind it is the Dependency Inversion Principle, the "D" in Robert C. Martin's SOLID (Clean Architecture, 2017): depend on an abstraction, hand it in, do not reach out to a global. You want one instance, so you register the type with a singleton lifetime and inject it:

services.AddSingleton<IClock, SystemClock>();
// Consumers ask for IClock in their constructor.
// One instance, an honest dependency, a trivial test double.

Same guarantee (one instance), opposite properties. The dependency is declared in the constructor, the lifetime is the container's job rather than the class's, and the test swaps in a fake clock without touching global state. The pattern you wanted was never "one instance." It was "one instance, injected." The container gives you that and the GoF Singleton does not.

"One of these" is a lifetime, not a pattern. Let the container own it.

Honorable mentions

Four more GoF patterns sit just outside this set, useful enough to name and not quite frequent enough to teach in full.

Factory Method centralises which concrete type to create from runtime input, the switch that picks the right courier-matching strategy or order-state handler, kept in one place. It used to earn a top slot, but in a DI-driven codebase the container absorbs most creation: you register the types and resolve by key, or inject a Func<MatchingMode, ICourierMatchingStrategy>, and the hand-written factory mostly disappears. Reach for the explicit version only when the choice is too involved for a registration.

Facade puts a simple interface over a messy subsystem. You will reach for it the day a third-party library or a tangle of internal services needs a single, sane entry point, and the component-level Anti-Corruption Layer in the next chapter is Facade grown up with a domain motive.

Template Method defines the skeleton of an algorithm in a base class and lets subclasses fill in the steps. It is quietly everywhere in framework code (think a base controller or a base handler with hooks), but in application code the same job is often cleaner as a Strategy passed in, because composition outlasts inheritance.

Chain of Responsibility passes a request along a line of handlers until one of them takes it. You rarely write it by hand at the object level, because its real home is one altitude up as the request pipeline, which is exactly the Middleware pattern in the next chapter.

These patterns organise code inside a single boundary. Next we climb a rung and organise the service around them.

the-pareto-stack-cloud-design-patterns-for-small-teams
the-ladder-of-altitudes
how-to-read-this
object-level-the-patterns-that-earn-their-keep
decorator
state
component-level-structuring-one-service
ports-and-adapters-hexagonal
mediator-the-commandquery-split
data-persistence
optimistic-concurrency
messaging-scale
outbox
resilience-staying-up-when-dependencies-dont
rate-limiting-throttling
timeout-fallback
the-composed-pipeline
observability-diagnostics-seeing-inside-production
metrics-the-four-golden-signals
externalised-configuration
hosting-cloud-agnostic-by-default
sidecar-ambassador
orchestrator-agnostic-deploy
a-reference-service
the-relay-outbox-to-queue
the-payment-saga-charge-pay-out-compensate
the-over-engineering-tax
conclusion-production-ready-deliberately
the-pattern-quick-reference-card
altitude-3-data-persistence
altitude-5-resilience
the-skip-list
full-event-sourcing-for-crud
robert-c-martin-uncle-bob-the-house-authority-for-structure
altitude-2-component
altitude-4-messaging-scale
altitude-6-observability-diagnostics

Download the full PDF for free?

Free download — no account required

Get the PDF
Get the PDF
Related Chapters
Free Download
Get the full PDF
All pages, including all code examples, diagrams, and the appendix reference card.
No spam. Unsubscribe at any time.
Your email won't be shared.
Oops! There's a problem with your request. We're working on fixing it. Please try again later.