Early on, design patterns felt like twenty-three names memorised into three groups. In daily work, I just wrote what made sense—like a Dictionary<string, Func<IExporter>> —until a reviewer pointed out I had just built a factory without realising it."
The catalogue was never the difficult part. The difficult part is knowing which shelf to reach for, and that is a question about what changes in your system rather than what the pattern is called. So put the catalogue down and walk the line instead.
A car has three stories running through it. How it gets built: which plant supplies which part, what order the stations run in, which components one vehicle is only ever allowed one of. How the parts fit together: the dongle that lets a wireless phone talk to a USB-only head unit, the four safety modules between your foot and the calliper. And what happens once somebody drives it out of the showroom: the fob that wakes four systems at once, the selector that changes what every other control does.
Those are the three families. Creational patterns build the car, structural patterns fit it together, behavioural patterns decide how it behaves on the road. Every pin below is a pattern, coloured by family. Click one.
Nine patterns, one vehicle
Pins 1–3 are about how the car gets built. Pins 4–6 are about how its parts connect. Pins 7–9 are about how it behaves once somebody drives it.
01 — CREATIONALHow the car gets built
Creational patterns all manage the same moment: new. Every new Thing() freezes a decision at that spot — the caller has named a concrete class and is now bound to its constructor, its lifetime, and to its identity — that class and no other. The binding stays invisible until the day you need a second variant. A car has that moment a few thousand times over.
01Factory Method — the seat plant
Nobody on the trim line phones a supplier when a shell needs a seat. The station asks for "a seat for this body," and the plant decides: a cloth bench for the base car, heated leather for the top trim, a fixed-back bucket on the track edition. The line takes the seat and bolts it down.
The base class owns when a seat is made and what happens to it. A subclass decides which seat arrives.
public abstract class TrimLine
{
protected abstract ISeat MakeSeat(); // the factory method
public Cabin Fit() // the part that never changes
{
var seat = MakeSeat();
return new Cabin(seat, Harness.Standard);
}
}
public sealed class BaseTrim : TrimLine
{
protected override ISeat MakeSeat() => new ClothBench(rows: 2);
}
public sealed class TrackTrim : TrimLine
{
protected override ISeat MakeSeat() => new BucketSeat(harnessPoints: 6);
}
You meet this shape constantly in .NET without the ceremony: IHttpClientFactory is one, and so is any DI registration where the caller asks for IPaymentGateway and gets StripeGateway. It earns its place when there is a family of things, more than one variant, and a selection rule the caller has no business knowing.
02Builder — same stations, different vehicle
The line runs the same five stations on every shell: platform, powertrain, body, interior, electronics. The order never changes; what goes into each station does. A 1.2-litre petrol and cloth seats on this shell, a diesel and an empty load bay on the next.
The case for a builder is made against a constructor that has grown too many parameters, half of them booleans. new Vehicle(true, false, 5, null, "steel", true) is a puzzle at every call site, and a seventh option means touching every caller. A builder names each argument and gives you one place — Build() — to validate before an invalid object exists.
var hatchback = new VehicleBuilder()
.Platform("B-segment")
.Powertrain(Engine.Petrol12, Gearbox.Manual6) // each station returns this
.Body(doors: 5, shell: "steel")
.Interior(Trim.Cloth, seats: 5)
.Electronics(Cluster.Base)
.Build(); // throws if the powertrain is missing
If you have written an ASP.NET Core Program.cs you have used one: WebApplication.CreateBuilder(args) holds a half-configured application until Build() turns it into something you can run.
03Singleton — one ECU
A car has exactly one engine control unit deciding fuelling and ignition timing. A second board is a hazard rather than a redundancy, because now two things disagree about how much fuel reaches the injectors.
public sealed class EngineControlUnit
{
private static readonly Lazy<EngineControlUnit> _ecu
= new(() => new EngineControlUnit());
public static EngineControlUnit Instance => _ecu.Value; // thread-safe by construction
private EngineControlUnit() { }
}
The trouble is not the single instance. It is EngineControlUnit.Instance appearing mid-method, because that call is a dependency nobody declared: a test cannot substitute it, and the class signature does not admit it exists. Modern .NET gives the guarantee without the hard wiring.
builder.Services.AddSingleton<IEngineControlUnit, EngineControlUnit>();
// the consumer declares what it needs — and a test can hand it a bench rig
public sealed class FuelTrim(IEngineControlUnit ecu) { }
Same guarantee, visible in the constructor, replaceable in a test. Reach for the static form only when there is no container to reach for.
Creational patterns are about the moment of new — and who is allowed on the floor when it happens.
02 — STRUCTURALHow the parts fit together
Once the shell is on its wheels, the problems change shape. Nothing needs building any more; things need connecting. A wireless phone to a head unit that only understands a cable. Four safety modules between a pedal and a calliper. Six systems onto one button. You have parts that work, and you need the assembled result to present an interface somebody can live with.
04Adapter — the dongle in the USB port
Your phone gave up on cables two upgrades ago: it pairs over Bluetooth and streams CarPlay across its own Wi-Fi. The car has no idea. Its head unit was built in 2019 and speaks one dialect — a phone on the end of a USB cable. You are not replacing the car and you are not replacing the phone. You buy a dongle the size of a matchbox, push it into the USB port, and it pretends to be the cable.
An adapter exists because you own one side of the conversation and not the other. The vendor SDK is not yours to change, nor the partner's SOAP endpoint, nor the internal service three teams depend on. So you write the piece in the middle: wireless on one face, wired on the other, and both ends carry on believing they got what they asked for.
public interface IWiredPhone // all the head unit knows how to talk to
{
Task<PhoneSession> ConnectAsync(UsbPort port);
}
public sealed class WirelessPhone // the phone in your pocket — not yours to change
{
public Task<WifiLink> PairAsync(BluetoothId id) => WifiLink.NegotiateAsync(id);
}
public sealed class WirelessDongle : IWiredPhone
{
private readonly WirelessPhone _phone;
public WirelessDongle(WirelessPhone phone) => _phone = phone;
public async Task<PhoneSession> ConnectAsync(UsbPort port)
{
var link = await _phone.PairAsync(port.LastPairedDevice); // wireless on one side
return PhoneSession.OverUsb(port, link.Stream); // wired on the other
}
}
This is the pattern you will write most often in enterprise work, so be deliberate about where adapters live: at the edge of the solution, in one project, named for what they wrap. The moment an adapter starts making business decisions rather than translating shapes, it has become a service you forgot to name.
05Decorator — the brake stack
A brake is a brake: your foot moves fluid, fluid squeezes a calliper. Then ABS goes in between and starts releasing pressure fifteen times a second. Then EBD rebalances front to rear. Then traction control brakes a wheel you never asked it to. Then emergency braking presses the pedal for you. Every module takes the same command, adds its own behaviour, and passes it inward. The calliper never learns any of them exist.
One rule makes it work: the wrapper implements the same interface as the thing it wraps, and holds a reference to it. That constraint lets you stack modules in any order, at runtime, without a combinatorial explosion — four options would otherwise be sixteen classes.
public interface IBrake { BrakeResult Apply(PedalInput input); }
public abstract class BrakeModule : IBrake
{
protected readonly IBrake Inner;
protected BrakeModule(IBrake inner) => Inner = inner;
public virtual BrakeResult Apply(PedalInput input) => Inner.Apply(input);
}
public sealed class Abs : BrakeModule
{
public Abs(IBrake inner) : base(inner) { }
public override BrakeResult Apply(PedalInput input)
=> input.WheelsLocking ? Inner.Apply(input.Pulsed()) : Inner.Apply(input);
}
IBrake pedal = new Aeb(new TractionControl(new Abs(new Calliper())));
You already use decorators daily in .NET under other names: ASP.NET Core middleware around a request delegate, a DelegatingHandler around a message handler, a Polly retry policy wrapping a timeout. Each adds behaviour on the way in, on the way out, or both, and the innermost call has no idea.
06Facade — the start button
Pressing START is not one action. The car wakes the body bus, checks the immobiliser against the key, primes the fuel pump, cranks the starter, waits for the alternator field, then runs the cluster self-test. Six systems in an order that matters — crank before the immobiliser clears and you get a very expensive click.
public sealed class StartButton
{
// the six subsystems, injected — each still usable on its own
public async Task<StartResult> PressAsync(KeyFob fob)
{
_body.WakeBus();
if (!_immobiliser.Accepts(fob)) return StartResult.Rejected;
await _fuel.PrimeAsync();
_starter.Crank();
await _alternator.WaitForFieldAsync(); // up before the load returns
return _cluster.SelfTest();
}
}
A facade hides nothing and forbids nothing; a technician still drives each module from a diagnostic laptop. It gives the common path a name so the ordering stops being copied into every caller. The signal that you need one is duplication with a sequence in it: the same four calls, same order, in three controllers, and the third is missing a step. That missing step is the bug the facade would have prevented.
Adapter changes the shape. Decorator adds to the behaviour. Facade shortens the sentence. All three leave the original part exactly as it was.
03 — BEHAVIOURALHow it acts once it leaves the gate
The last family is about what happens between objects while the program runs: who tells whom, who decides, and what changes when the situation changes. A car on a transporter has no behaviour. Hand somebody the key and a button twenty metres away needs to reach four systems, and one lever needs to change what every other control means.
07Observer — the key fob
Somebody presses unlock from across a car park. Four things happen without the fob knowing about any of them: the doors unlatch, the mirrors fold out, the puddle lights come on, and the driver's seat slides to a saved position. Add a fifth next model year and the button does not change.
C# has this one in the language, which is why most .NET developers use it for years before learning its name.
public sealed class KeyFob
{
public event EventHandler<UnlockEventArgs>? Unlocked;
public void PressUnlock(DriverProfile profile)
=> Unlocked?.Invoke(this, new UnlockEventArgs(profile, DateTime.UtcNow));
}
fob.Unlocked += (_, e) => _doors.Unlatch();
fob.Unlocked += (_, e) => _mirrors.Unfold();
fob.Unlocked += (_, e) => _lights.Welcome(seconds: 30);
fob.Unlocked += async (_, e) => await _seats.RecallAsync(e.Profile);
Two things will bite you. A subscription that is never removed keeps the subscriber alive as long as the publisher lives — in a long-running service that is a leak you first meet in a production memory graph, so unsubscribe or use IObservable<T> and dispose. And that async handler is fire-and-forget: an exception inside it goes nowhere. Once listeners do real work, move them onto a queue.
08Strategy — the drive modes
The accelerator has one job: ask for torque. What arrives depends on the mode. Eco stretches the first half of the travel and shifts early. Sport sharpens it and holds gears to the limiter. Snow softens the opening third so you do not spin a wheel leaving a driveway. The pedal has no opinion; every map takes the same request.
Strategy is what removes long switch blocks over an enum. The branches become classes, the enum becomes a lookup, and a ninth option no longer means editing a method eight others depend on.
public interface IPedalMap { Torque Translate(double pedalPercent); }
public sealed class Accelerator
{
private IPedalMap _map;
public Accelerator(IPedalMap map) => _map = map;
public void SwitchTo(IPedalMap map) => _map = map; // swap at runtime
public Torque Press(double pedalPercent) => _map.Translate(pedalPercent);
}
// selection lives in one place, not scattered through the control loop
accelerator.SwitchTo(surface.IsSlippery ? _snow
: driver.WantsSport ? _sport
: _eco);
In .NET the practical version is keyed DI: register each strategy against a key and resolve the one you need. The container becomes the lookup table, and every strategy stays testable on its own.
09State — the gear selector
One accelerator, four meanings. Park: the pedal does nothing and the transmission is locked. Reverse: it backs up and the camera comes on. Neutral: the engine revs and the car goes nowhere. Drive: it goes. And the selector refuses to hand you Reverse at fifty kilometres an hour, whatever you do with the lever.
Work the selector. The same action produces different outcomes, and the interlock refuses anything that would cost you a gearbox:
public interface IGear
{
IGear Select(Selector to, Vehicle car);
}
public sealed class Drive : IGear
{
public IGear Select(Selector to, Vehicle car) => to switch
{
Selector.Neutral => new Neutral(),
Selector.Park when !car.IsRolling => new Park(),
Selector.Reverse when !car.IsRolling => new Reverse(),
_ => this // the interlock holds
};
}
Strategy and State share almost the same class diagram, which is why they get confused. The difference is who holds the wheel: a strategy is chosen from outside and stays until someone outside changes it; a state replaces itself from inside as events arrive.
Reach for State when you have an order status, a claim workflow, a device lifecycle — anything where switch (status) has appeared in four methods and each is missing a different case. Once each status is a class, the compiler helps: a new state that forgets an action will not build.
04 — CHOOSINGWhich shelf to reach for
Patterns are names for shapes that solutions converge on, not shapes you start from. Writing the straightforward version first is the right move almost every time. The pressure that turns straightforward code into a pattern arrives later, as a specific complaint. Match the complaint, not the diagram.
| What you catch yourself saying | Shelf |
|---|---|
| "This class names a concrete type it has no business knowing about." | Factory Method |
| "The constructor has eleven parameters and six of them are optional." | Builder |
| "Two of these existing at once would be a bug." | Singleton (via DI) |
| "The interface is wrong and the code behind it is not mine to change." | Adapter |
| "I need to add logging, retries and caching without editing this class." | Decorator |
| "These five calls always happen together, in this order, in four places." | Facade |
| "One event, several unrelated reactions, and the list keeps growing." | Observer |
| "A switch that picks between interchangeable algorithms." | Strategy |
| "A switch on a status field that changes what every other method does." | State |
The rest of the catalogue lives in the same workshop, and most of it maps just as cleanly:
The value of a pattern name is that when you say "make that a decorator" in a review, four people see the same brake pedal with four modules behind it.
You will not need all twenty-three; very little software does. What the catalogue buys you is shared vocabulary and a set of shapes your eye learns to recognise before the code gets bad — the constructor that keeps growing, the switch that has appeared in a fourth method, the class naming a concrete type it should never have heard of. Learn the nine on this line, and the other fourteen are a twenty-minute read on the day you need one.
Discussion