0% read
    ← All stories
    Object-Oriented Design · Factory Floor

    Design Patterns
    on the Assembly Linenine patterns, one production line

    Twenty-three names in a catalogue is a memory problem. One car you can walk around is something else. Nine patterns, six interactive diagrams, and C# you can read in a sitting.

    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.

    Figure 01 — the build sheetclick any pin · drag to pan
    ENGINE BAY CABIN LOAD BAY PLATFORM KEY FOB 1 2 3 4 5 6 7 8 9
    CREATIONAL STRUCTURAL BEHAVIOURAL
    SELECT A PIN

    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.

    TrimLine.cs — the plant decides
    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.

    Figure 02 — one line, three vehiclespick a build
    The five stations and their order belong to the builder. The values at each station belong to the caller.

    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.

    VehicleBuilder.cs — named stations, one validation point
    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.

    EngineControlUnit.cs — one board, created once
    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.

    Program.cs — one instance, still injectable
    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.

    WirelessDongle.cs — wired on one side, wireless on the other
    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.

    Figure 03 — the brake stacktoggle modules
    Every module implements IBrake and holds an IBrake. The calliper at the centre is unchanged in all sixteen combinations.

    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.

    BrakeModule.cs — wrap, delegate, add
    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.

    StartButton.cs — six systems, one press
    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.

    Figure 04 — one press, many listenersclick a card to unsubscribe
    The publisher holds a list, not a set of instructions. KeyFob.PressUnlock() is the same two lines whether one system is listening or five.

    C# has this one in the language, which is why most .NET developers use it for years before learning its name.

    KeyFob.cs — the publisher stays ignorant
    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.

    Accelerator.cs — one request, several pedal maps
    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:

    Figure 05 — the selector as a state machinetry any action
    PARK
    Each gear answers the same set of actions differently — and some answers depend on whether the car is still rolling.
    Gear.cs — the state returns its successor
    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.

    Figure 06 — the complaint indexfilter by family
    What you catch yourself sayingShelf
    "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
    Nine shelves cover the complaints that come up weekly. The other fourteen come up when they come up.

    The rest of the catalogue lives in the same workshop, and most of it maps just as cleanly:

    PrototypeCloning an approved build sheet for the next unit instead of speccing it again.
    CompositeA subassembly holds parts, a part holds fasteners, and "torque check" means the same at every level.
    ProxyThe immobiliser: the same conversation with the engine, held earlier and cheaper.
    CommandA service work order the technician can queue, hand over, or reverse tomorrow.
    Template MethodPre-delivery inspection: fixed steps, blanks each model fills differently.
    MediatorThe CAN bus, so forty control units stop wiring to each other directly.

    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.

    — — —
    ← Back to all stories

    Discussion