A pull request adds IShippingStrategy, two classes that implement it and a third that chooses between them. One reviewer approves it because it follows the pattern. A second asks why it is not a Func<decimal, decimal> and two methods. Each of them is right about something.

The problems the 23 patterns name are still there. For a number of them the language or the platform now carries the solution, so the class structure of 1994 is often no longer what you write, and what you still need is the name, the patterns that are structure, and the judgement to use none.

This part takes from Part 7 the boundaries of Stage 10, which the host composes in a container, and from Part 4 the catalogue itself: what each pattern is for is described there and not repeated here. It is the last of eight parts, and it ends by answering the question the series began with.

The question was first asked of Lisp

The question in the title is thirty years old. On 5 May 1996, at Object World, Peter Norvig presented slides on what became of the patterns in two other languages; his page for them gives the date. The finding has a precise scope, stated on one slide: "16 of 23 patterns have qualitatively simpler implementation in Lisp or Dylan than in C++ for at least some uses of each pattern". He attributes it to first-class types, first-class functions, macros and other features of those languages.

Three limits are in that sentence. The comparison is with C++. The claim is simpler, not unnecessary. And it holds for some uses of each pattern, not all. The slides return here because C# has since acquired first-class functions, and the same three limits apply to it.

C# changed one release at a time. Microsoft's history of the language lists iterators among the features of C# 2.0, released in November 2005, and dates lambda expressions to C# 3.0, released in November 2007. The answer to the title therefore depends on the version.

The example targets .NET 10. Microsoft's support policy lists it as a long-term support release of 11 November 2025, and .NET 11 as a standard-term support release of 10 November 2026. The language versioning page gives C# 14 as the default for .NET 10 and C# 15 as the default for .NET 11. Everything below was compiled as C# 14.

The 23 patterns against C# 14 and .NET 10

The table follows the book's order, as the authors' own slides give it for the 1994 catalogue. The third column says whether src/Patterns holds a second region with the modern form. The fourth is a verdict, and it is our judgement after reading each file, not a measurement.

Four verdicts are used. Absorbed by the language means a keyword or a built-in type does the pattern's work and the classes are no longer written. Provided by the platform means a library that ships with .NET does it. Still written by hand means nothing shorter exists that is honest. Rarely needed is about the problem, not the code.

PatternWhat C# or .NET provides todayModern form in the exampleVerdict
Abstract FactoryNothing that removes the factory interfaceNoStill written by hand
BuilderObject initialisers with required and init members, which check that a part is presentpattern-builder-modernShortened only: a rule across parts still needs the builder
Factory MethodA Func<T> passed as an argumentpattern-factory-method-modernAbsorbed by the language, outside a framework's base class
PrototypeA record copied with a with expression, a shallow copypattern-prototype-modernAbsorbed by the language, for immutable members only
SingletonLazy<T> for the classic form; the container's singleton lifetime for the single instanceNo -modern region; host-container-stage10Provided by the platform, without the global access
AdapterNothing shorterNoStill written by hand
BridgeNothing shorterNoStill written by hand, and rarely needed
CompositeNothing shorter; a record keeps the leaf to one lineNoStill written by hand
DecoratorDelegates composed into a pipeline; ASP.NET Core middleware is that pipeline for requestspattern-decorator-modernProvided by the platform for requests, by hand elsewhere
FacadeNothing shorterNoStill written by hand
FlyweightString interning, for stringspattern-flyweight-modernProvided by the platform for strings, rarely needed otherwise
ProxyNothing in the exampleNoStill written by hand
Chain of ResponsibilityA middleware pipeline, in which a component may answer or pass the request onNoProvided by the platform for requests, by hand elsewhere
CommandAction delegates and closurespattern-command-modernShortened only: absorbed when a command does nothing but execute
InterpreterNothing shorterNoRarely needed, and written by hand when it is
IteratorIEnumerable<T>, yield return and foreachpattern-iterator-modernAbsorbed by the language
MediatorNothing shorterNoStill written by hand
MementoNothing shorterNoStill written by hand
ObserverThe event keyword; IObservable<T> and IObserver<T> in the base librarypattern-observer-modernAbsorbed by the language, and provided by the platform for streams
StateNothing shorter in the exampleNoStill written by hand
StrategyFunc delegates and lambda expressionspattern-strategy-modernAbsorbed by the language, for a strategy of one method
Template MethodA delegate handed to one sealed classpattern-template-method-modernAbsorbed by the language, outside a framework's base class
VisitorPattern matching in a switch expression over recordspattern-visitor-modernShortened only: the compile-time check is lost in C# 14

Eleven rows have a modern region and twelve do not. Of the eleven, two are absorbed without a condition: Iterator and Observer. Five more are absorbed or provided under a condition the verdict states, and it is the condition that a review should ask about: one method, immutable members, no base class imposed by a framework, strings. Three are shortened and not replaced. Decorator sits between: its shape is the platform's for one kind of work, the handling of requests.

One feature does most of it. Five of the eleven modern regions pass a delegate where the classic form has an interface or a subclass, and a sixth, the event, is built on one. Microsoft's guide defines a delegate as a type that represents references to methods with a particular parameter list and return type, and its reference on lambda expressions opens: "Use a lambda expression to create an anonymous function." That is Norvig's first-class function, arrived in C#.

Two rows mention a pipeline the example does not contain: src/Patterns has no web application in it. Microsoft's page on middleware describes components that each choose whether to pass a request on and can work before and after the next one, which covers a decorator and a chain of responsibility at once. The verdict for those two rows rests on that page, not on a region.

Six pairs, read side by side

Part 4 quotes Strategy, Decorator, Visitor, Composite and Singleton in full. The six below are the other pairs that show the most.

Iterator: absorbed whole

The classic form, in src/Patterns/Behavioral/Iterator.cs:

C#
public interface IIterator
{
    bool MoveNext();
    Line Current { get; }
}

public IIterator LinesOver(decimal amount) => new OverIterator(lines, amount);

// The iterator keeps the position, so the basket can be walked twice at once.
private sealed class OverIterator(Line[] lines, decimal amount) : IIterator
{
    private int _index = -1;

    public Line Current => lines[_index];

    public bool MoveNext()
    {
        do
        {
            _index++;
        }
        while (_index < lines.Length && lines[_index].Amount <= amount);

        return _index < lines.Length;
    }
}

And the form the language supplies:

C#
// The language has had this pattern built in since C# 2. `yield return` makes the
// compiler write the iterator class, position included, and foreach consumes it.
public IEnumerable<Line> EnumerateOver(decimal amount)
{
    foreach (Line line in lines)
    {
        if (line.Amount > amount) yield return line;
    }
}

What changed: the interface, the nested class and the _index field are gone, because the compiler writes them. Microsoft's reference for the statement says: "Use the yield statement in an iterator to provide the next value or signal the end of an iteration." What did not change: the caller still cannot see the array, and the lines come out in the same order. Iterator_and_yield_return_visit_the_same_lines, in BehaviouralPatternTests.cs, compares the two.

There is a second gain that the line count hides. IIterator is this basket's own type, so the caller in the test drives it with a while loop, and nothing else in the platform knows how to consume it. The modern method returns IEnumerable<Line>, the interface that foreach and the library's methods over sequences already accept. The language did not only shorten the pattern. It standardised it. We would not write the classic form today for any reason we can name.

Observer: absorbed, with the lifetime problem intact

The classic form, in src/Patterns/Behavioral/Observer.cs:

C#
public interface IPriceObserver
{
    void PriceChanged(string sku, decimal newPrice);
}

public sealed class Product(string sku, decimal price)
{
    private readonly List<IPriceObserver> _observers = [];

    public decimal Price => price;

    public void Attach(IPriceObserver observer) => _observers.Add(observer);
    public void Detach(IPriceObserver observer) => _observers.Remove(observer);

    public void SetPrice(decimal newPrice)
    {
        price = newPrice;
        foreach (IPriceObserver observer in _observers.ToArray())
        {
            observer.PriceChanged(sku, newPrice);
        }
    }
}

The modern form:

C#
// An event is the observer pattern as a language feature: += attaches, -= detaches and
// the delegate keeps the list. For a stream of values over time, with completion and
// errors, the base library offers IObservable<T> and IObserver<T> with the same shape.
public sealed record PriceChange(string Sku, decimal NewPrice);

public sealed class ObservableProduct(string sku, decimal price)
{
    public event EventHandler<PriceChange>? PriceChanged;

    public decimal Price => price;

    public void SetPrice(decimal newPrice)
    {
        price = newPrice;
        PriceChanged?.Invoke(this, new PriceChange(sku, newPrice));
    }
}

What changed: IPriceObserver, the list, Attach and Detach are gone. The event keyword declares the subscription point, += and -= replace the two methods, and a subscriber is any method with the right signature. Microsoft's events guide names the two roles publisher and subscriber. What did not change: Observer_and_a_language_event_deliver_the_same_notifications records the same two price changes from both.

What also did not change is who holds whom. The classic product keeps its observers in a list. The modern product keeps them in the event's delegate. In both, a subscriber that is never removed stays reachable for as long as the product lives. The test shows how easily that happens: it subscribes with a lambda and keeps no reference to it, so it has nothing to pass to -=. In a long-lived publisher it is a leak, and the shorter syntax makes it easier to write.

The region's comment points to the other platform form. IObservable<T> is documented in one line: "Defines a provider for push-based notification." The example does not implement it.

Command: shortened, and one responsibility moves to the caller

The classic form, in src/Patterns/Behavioral/Command.cs:

C#
public interface ICommand
{
    void Execute();
    void Undo();
}

public sealed class AddLine(Cart cart, string sku) : ICommand
{
    public void Execute() => cart.Skus.Add(sku);
    public void Undo() => cart.Skus.RemoveAt(cart.Skus.LastIndexOf(sku));
}

public sealed class History
{
    private readonly Stack<ICommand> _done = new();

    public void Run(ICommand command)
    {
        command.Execute();
        _done.Push(command);
    }

    public void UndoLast()
    {
        if (_done.TryPop(out ICommand? command)) command.Undo();
    }
}

The modern form:

C#
// A command with nothing but Execute is an Action, and a closure carries its arguments.
// Undo is a second Action. The class returns when a command needs a name, has to be
// serialised, or is inspected before it runs.
public sealed class DelegateHistory
{
    private readonly Stack<Action> _undo = new();

    public void Run(Action execute, Action undo)
    {
        execute();
        _undo.Push(undo);
    }

    public void UndoLast()
    {
        if (_undo.TryPop(out Action? undo)) undo();
    }
}

What changed: ICommand and AddLine are gone, and Run takes two Action values. What did not: Command_and_a_pair_of_delegates_leave_the_cart_in_the_same_state adds two lines to each cart, undoes one, and finds the carts equal.

In the classic form Execute and Undo are written once, side by side, in AddLine, and Undo removes the last occurrence of the SKU. In the delegate form the caller writes both lambdas at every call, and the test's undo lambda calls Remove, which takes out the first occurrence. The two agree because each SKU is added once. Add the same SKU twice with another between them, undo once, and the two carts hold the same lines in a different order.

That is why the verdict is shortened only. A delegate is one method. A command with an undo is two methods that must match, and an interface is the construct that keeps two methods together. The region's own comment lists the other reasons the class returns: a command that needs a name, has to be serialised, or is inspected before it runs.

Prototype: absorbed, for a different kind of object

The classic form, in src/Patterns/Creational/Prototype.cs:

C#
public sealed class OrderTemplate(string tier, List<string> skus)
{
    public string Tier { get; set; } = tier;
    public List<string> Skus { get; } = skus;

    // The copy owns its own list, so changing the copy cannot change the prototype.
    public OrderTemplate Clone() => new(Tier, [.. Skus]);
}

The modern form:

C#
// A record copies itself with `with`. The copy is shallow, which is safe here only
// because every member is immutable: the two orders may share the array, and neither
// can change it.
public sealed record OrderSnapshot(string Tier, ImmutableArray<string> Skus)
{
    public OrderSnapshot ForTier(string tier) => this with { Tier = tier };

    public OrderSnapshot Adding(string sku) => this with { Skus = Skus.Add(sku) };
}

What changed is more than the syntax. OrderTemplate is mutable: the test clones it, then sets Tier and adds to Skus on the copy. OrderSnapshot cannot be edited at all, and each change returns a new value. Microsoft's record reference counts nondestructive mutation among the behaviour a record has built in. What did not change: Prototype_and_a_record_copied_with_with_give_the_same_copy ends with the same tier and the same SKUs in both copies, and the originals untouched.

The condition in the verdict is in the region's comment. The copy with makes is shallow. Give OrderSnapshot a List<string> in place of the ImmutableArray<string> and the copy and the original share one list, which is the defect the classic Clone exists to prevent. The record removes the method, not the decision about how deep a copy goes.

Template Method: the subclass becomes an argument

The classic form, in src/Patterns/Behavioral/TemplateMethod.cs:

C#
public abstract class PriceSummary
{
    // The template method: the steps and their order are fixed here.
    public string Render(decimal net)
    {
        decimal tax = Tax(net);
        return string.Create(CultureInfo.InvariantCulture,
            $"{Title}: net {net:0.00}, tax {tax:0.00}, total {net + tax:0.00}");
    }

    protected abstract string Title { get; }
    protected abstract decimal Tax(decimal net);
}

public sealed class DomesticSummary : PriceSummary
{
    protected override string Title => "Domestic";
    protected override decimal Tax(decimal net) => Math.Round(net * 0.23m, 2, MidpointRounding.AwayFromZero);
}

public sealed class ExportSummary : PriceSummary
{
    protected override string Title => "Export";
    protected override decimal Tax(decimal net) => 0m;
}

The modern form:

C#
// Composition in place of inheritance: the varying step is handed in as a delegate.
// One sealed class, no hierarchy, and the step can be chosen while the program runs.
public sealed class ComposedSummary(string title, Func<decimal, decimal> tax)
{
    public string Render(decimal net)
    {
        decimal taxAmount = tax(net);
        return string.Create(CultureInfo.InvariantCulture,
            $"{title}: net {net:0.00}, tax {taxAmount:0.00}, total {net + taxAmount:0.00}");
    }
}

What changed: three classes became one, and the two abstract members became two constructor arguments. What did not: TemplateMethod_and_composition_with_a_delegate_render_the_same_text renders both markets at 101.50 and compares the strings.

Factory Method changes in the same way and for the same reason. In pattern-factory-method an abstract Checkout asks a subclass which discount to create. In pattern-factory-method-modern a sealed FuncCheckout takes a Func<IDiscount>, in six lines, and FactoryMethod_and_a_func_factory_give_the_same_totals proves the totals equal.

The authors' classification gives class scope, which we read as the inheritance kind, to four entries only: Factory Method, Adapter in its class form, Interpreter and Template Method. The book's introduction also advises, in the words of an interview with Erich Gamma, to "favor object composition over class inheritance". A delegate makes that advice cost one parameter. In our reading that is why two of the four class-scope patterns are the ones a delegate replaces most cleanly.

The classic form keeps one place: a framework that owns the base class and calls your override. The abstract test class in test-stage08-characterisation, which Part 6 quotes, is that case inside this series.

Builder: the compiler checks presence, the builder checks a rule

The classic form, in src/Patterns/Creational/Builder.cs:

C#
public sealed class OrderBuilder
{
    private readonly List<Line> _lines = [];
    private string _tier = "Standard";
    private string? _coupon;

    public OrderBuilder AddLine(string sku, decimal unitPrice, int quantity = 1)
    {
        _lines.Add(new Line(sku, unitPrice, quantity));
        return this;
    }

    public OrderBuilder ForTier(string tier)
    {
        _tier = tier;
        return this;
    }

    public OrderBuilder WithCoupon(string coupon)
    {
        _coupon = coupon;
        return this;
    }

    public Order Build() =>
        _lines.Count == 0
            ? throw new InvalidOperationException("An order needs at least one line.")
            : new Order([.. _lines], _tier, _coupon);
}

The modern form:

C#
// Object initialisers with required members: the compiler refuses an incomplete object,
// and optional parts have defaults. Keep the builder when construction really is
// stepwise, or when a rule spans several parts and must be checked at the end.
public sealed class OrderDraft
{
    public required IReadOnlyList<Line> Lines { get; init; }
    public string Tier { get; init; } = "Standard";
    public string? Coupon { get; init; }
}

What changed: the chain of calls became an object initialiser, and a missing Lines is refused by the compiler and no longer by an exception. What did not: Builder_and_required_members_describe_the_same_order finds the same lines, tier and coupon in both.

The same test contains the limit. Its last assertion constructs new OrderDraft { Lines = [] }, a draft with no lines at all, and reads its default tier. That object compiles and exists. The other builder test, Builder_assembles_an_order_step_by_step_and_refuses_an_empty_one, shows Build throwing for the same content. A required member says a value must be given. It says nothing about which values are acceptable.

So the modern form replaces the builder as a way to name optional parts, not as the place where a rule is checked. That check must live somewhere: in a builder, or in a constructor or factory of the type itself, which is where Stage 04 and Stage 09 put it.

Singleton and the container

The classic Singleton does two things: it allows one instance, and it gives every caller one well-known way to reach it. Part 4 quotes the class, PriceList, where Lazy<T> creates the instance on first use and a static Instance property is the way in. The platform now offers the first of the two without the second.

Microsoft documents dependency injection as part of .NET, and the container's service lifetimes as three. A transient service is created each time it is requested. A scoped service is created once for each scope: "For web applications, a scoped lifetime indicates that services are created once per client request (connection)." A singleton is created the first time it is requested, or supplied as an instance by the developer, and reused for every later request.

The host registers the services of Stage 10 with all three, in src/Pricing.Host/ContainerRoot.cs:

C#
public static ServiceProvider Build(decimal taxRate = SampleOrders.TaxRate)
{
    ServiceCollection services = new();

    // Singleton: one instance for the life of the container. Right for the in-memory
    // repository, which IS the stored data, and for objects with no state at all.
    services.AddSingleton<IOrderRepository, InMemoryOrderRepository>();
    services.AddSingleton<ITaxRateProvider>(new FixedTaxRateProvider(taxRate));
    services.AddSingleton<DiscountPolicy>();

    // Scoped: one instance per scope, and a scope is one unit of work (one request).
    // The journal collects what that unit of work did and is discarded with it.
    services.AddScoped<IQuoteJournal, InMemoryQuoteJournal>();

    // Transient: a new instance every time one is asked for. Right for a use case,
    // which is cheap, holds no state and must not outlive the scoped journal it uses.
    services.AddTransient<PriceOrder>();

    AddStage07(services);

    // Both checks are off unless asked for. They turn a wrong lifetime (a singleton
    // that captures a scoped service) into a failure at start-up.
    return services.BuildServiceProvider(
        new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true });
}

Both ways of making a singleton are here: the container creates the repository on first request, and the code supplies the tax-rate provider as an instance. The tests in the test-lifetimes region of CompositionTests.cs observe each lifetime as instances: A_singleton_is_one_instance_for_the_whole_container, A_scoped_service_is_one_instance_per_scope and A_transient_service_is_a_new_instance_every_time. The host prints the same three observations, and Part 5 shows those lines.

A lifetime can also be wrong. The last lines of the region switch on the container's two checks, and one test shows a check at work:

C#
[Fact]
public void A_scoped_service_cannot_be_resolved_outside_a_scope()
{
    using ServiceProvider provider = ContainerRoot.Build();

    // Scope validation is switched on in the container root. The use case is
    // transient but needs the scoped journal, so the root provider refuses both.
    Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IQuoteJournal>());
    Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<PriceOrder>());
}

The journal is scoped, and the use case is transient but needs the journal, so the root provider refuses both.

Now compare the registered repository with PriceList. InMemoryOrderRepository has an ordinary public constructor, and nothing in the class says there is one of it. The single instance is a property of one line of the composition root, and a test that wants another builds another container, as every test in CompositionTests.cs does. The PriceOrder use case receives an IOrderRepository in its constructor and cannot tell how many exist.

That is why a container-managed single instance is not the Singleton pattern. The pattern's defining half is the global access point, and the comment under PriceList names its cost: no signature shows who depends on it, and a test cannot replace it. The container keeps the lifetime and drops the access point. The interview of 2009 in which Gamma argued for dropping Singleton also lists Dependency Injection among the candidates the authors considered adding.

In our judgement the difference survives only while the container stays in the composition root. A class that takes the service provider as a dependency and asks it for what it needs has a global access point again, with a longer name. The registered form also has a cost the static one lacks: a missing registration is an exception at run time, not a compiler error.

Visitor and the hierarchy the compiler cannot close

Part 4 quotes both forms of Visitor, so only the consequence belongs here. The modern form is a switch expression over a record hierarchy whose base has a private constructor. Microsoft documents the feature under pattern matching. In pattern-visitor-modern the last arm of the switch is a discard that throws UnreachableException.

That arm is the price. A person reading the file can see that LineItem has exactly two cases. The C# 14 compiler does not conclude it, so the switch needs an arm for everything else. Add a third record and the code still compiles. The new case reaches the discard and throws when it runs, which means a test finds it, if a test exists for it. In the classic form a third item type adds a method to IItemVisitor<T>, and every visitor stops compiling until it answers.

So in C# 14 the modern form shortens Visitor and gives up the property that justified its ceremony.

Microsoft's history page lists union types and closed hierarchies among the features of C# 15, released in November 2026. The example targets .NET 10, compiles as C# 14 and uses neither. We have not rebuilt the region with them, and we make no claim here about their syntax or about what they do to the discard arm.

What does not go away

The vocabulary

FuncCheckout takes a Func<decimal, decimal>. Nothing in that type says shipping, and nothing says strategy. A reviewer who calls the parameter a strategy tells a colleague, in one word, that the rule is passed in, that it can be replaced, and that the checkout must not branch on which rule it holds. The class structure went away. The word is doing more work than before, because the code no longer carries it. Both reviewers of the opening were right: the problem is a strategy, and C# 14 does not need three classes to solve it.

The patterns that are structure

Twelve rows of the table have no modern region, and for six of them the reason is the same. Composite, Adapter, Facade, State, Mediator and Proxy are not mechanisms that a language feature could supply. Each is a decision about which object knows what. A bundle that is priced like a product, a foreign tax table kept behind the pricing code's own interface, three subsystems behind one call, an order whose permitted operations depend on where it is, a screen that owns how its parts affect each other, a cache with the interface of the thing it caches: no keyword makes those decisions.

Proxy needs a qualification. The example shows one kind, a caching stand-in, written by hand. We make no claim here about other kinds.

Stage 10 is built from one of these. Its adapters implement ports that the application owns, which is Part 7's subject. In our reading, the patterns that survive unchanged are the ones architecture is made of.

What the modern forms cost

Each short form has a failure of its own. What follows is our judgement, drawn from the regions above.

  • A delegate has no name and no room for a second method. Func<decimal, decimal> is the type of the shipping rule in FuncCheckout and of the tax step in ComposedSummary, and the compiler accepts one for the other. Stage 06 needs a small record, NamedRule, to give each delegate the name the interface form carried as a property.
  • Events leak subscriptions. The publisher keeps every subscriber alive, and a lambda cannot be detached unless someone kept it.
  • A container hides the composition. The hand-written root of Stage 07 is ten lines you can read from top to bottom. In the container the same graph is a set of registrations, the order of the three discount policies is the order of three lines, and a mistake is found when the program runs.
  • Pattern matching spreads a decision. One switch over the item types is clearer than a visitor. Five of them in five files are five places to change when a type is added, and in C# 14 the discard arm in each will hide the omission.

None of this argues for the classic forms. It is what to check when you choose the short one.

The judgement to use none

The pricing example has eleven stages and, as Part 4 counts, one pattern used in earnest. Stage 03 has none and suits its problem. A pattern applied where nothing varies is still an indirection with nothing behind it, and a shorter form does not correct that. It makes it cheaper to write. A Func parameter that only ever receives one function is the same unused indirection as an interface with one implementation.

So the answer to the title has three parts. The problems: yes, all of them, because they come from the work and not from the language. The class structures: for eleven of the 23, often not, under the conditions in the table. The names: more than before.

Why we design software the way we do

The series asked one question and followed one pricing routine through eleven designs to answer it. Every one of the 13 catalogue entries computes the same totals for the same orders, to the cent. The designs differ in one thing only: what a change costs, and how you find out that it was wrong.

Part 1 showed that each early advance removed a difficulty of the tools and left the difficulty of the problem. Part 2 showed where the first boundaries came from: a module hides a decision that is likely to change. Part 3 showed objects as several mechanisms, each worth its cost where it protects an invariant or lets a decision vary. Part 4 showed patterns as names for recurring problems. Part 5 gave the criteria that judge a design, coupling and cohesion. Part 6 showed how to move running code towards a better design without changing what it does. Part 7 took the same ideas to the boundaries of a system: a domain model, with ports and adapters around it.

We design software the way we do because the rules of a business change, and because people have to read the code before they can change it. Each technique in this series is a way of deciding, in advance, which change will be cheap, and of paying for that with an indirection, a name or a boundary. None of them replaced the one before: the structured routine of Stage 01 is inside every later stage, and the modules of Stage 03 are still the right design for a small stable problem.

The tools will keep absorbing the mechanisms, as C# absorbed the iterator. They cannot absorb the decision about what will change. That decision is the design, and it is still yours to make.

What to take from this

  • When a design has a single method that varies, pass a delegate and say the pattern's name in the parameter name or the review. Move to an interface when a second member, a name or state appears.
  • When two operations must match, such as execute and undo, keep them in one type. Do not let callers write the pair.
  • Before copying a record with with, check that every member is immutable. If one is not, the copy shares it.
  • Treat required as a check that a value is present. Put rules about the value in a constructor, a factory or a builder.
  • Register single instances in the composition root and inject them. Do not pass the service provider into a class.
  • For each switch over a type hierarchy in C# 14, keep a test that fails when a case is added, because the compiler will not.

Run the code

The example is one download, software-foundations-example.zip: unpack it and run the commands in its foundations folder. It was built and its tests were run with the .NET 10 SDK on 13 November 2026, and it needs that SDK. .NET 10 is a long-term support release; .NET 11 was released on 10 November 2026 and the example does not need it.

  • dotnet test runs the tests.
  • dotnet run --project src/Pricing.Host prints the output, which ends with the three lifetime lines.

The tests that cover what this article showed:

  • BehaviouralPatternTests.cs: Iterator_and_yield_return_visit_the_same_lines, Observer_and_a_language_event_deliver_the_same_notifications, Command_and_a_pair_of_delegates_leave_the_cart_in_the_same_state, TemplateMethod_and_composition_with_a_delegate_render_the_same_text and Visitor_and_a_switch_expression_over_records_give_the_same_cost.
  • CreationalPatternTests.cs: Prototype_and_a_record_copied_with_with_give_the_same_copy, Builder_and_required_members_describe_the_same_order, Builder_assembles_an_order_step_by_step_and_refuses_an_empty_one, FactoryMethod_and_a_func_factory_give_the_same_totals and Singleton_every_caller_reaches_the_same_instance.
  • CompositionTests.cs: the three lifetime tests named above and A_scoped_service_cannot_be_resolved_outside_a_scope.

Those tests prove agreement for the inputs they use and no further: the Command pair agrees because no SKU repeats. ./counterproof.sh changes pricing rules in the stages and does not touch src/Patterns, so this article does not rely on it.

References

Tags#software-foundations#software-design#engineering#dotnet#development#havecode

The Foundations of Software Engineering

From the origins of programming to modern architecture. 8 parts, read in order.

  1. 01
    Before Design Patterns: How Software Engineering Learned to Manage Complexity

    From Lovelace's Notes of 1843 to Brooks's essay of 1986, each advance in programming removed a difficulty of the tools and left the difficulty of the problem. One pricing routine, written three ways with identical output, shows what structure and names buy and what they do not.

  2. 02
    Structured Programming, Modularity and Information Hiding

    Structured control flow, procedures, modules, information hiding, coupling, cohesion and separation of concerns are separate ideas from separate authors. The same pricing rules, cut as functions and then as modules, show what Parnas's criterion localises and what it does not.

  3. 03
    Object-Oriented Programming: Origins, Promises and Trade-offs

    Object orientation is several mechanisms with separate origins: Simula's subclasses, abstract data types, Smalltalk's messaging. One pricing example shows what encapsulation and an interface buy, what they cost, and when functions over plain data stay the better design.

  4. 04
    The Gang of Four: 23 Design Patterns and the Problems They Solve

    The 23 patterns of the 1994 book, each as a problem, a shape, a cost and its modern C# form, in one pricing example. Most are a single move, an interface placed where something varies, and the example needed only one of them.

  5. 05
    SOLID, Coupling, Cohesion and Dependency Inversion

    Coupling and cohesion are the criteria; the five SOLID principles are later heuristics with separate origins in Meyer, Liskov and Martin. One pricing calculator shows what each buys and costs, and why dependency inversion, dependency injection and a container are three different things.

  6. 06
    Refactoring: Improving Design Without Changing Behaviour

    Refactoring changes structure against a recorded definition of behaviour, in steps small enough to check. One legacy pricing method is pinned by characterisation tests, reshaped in seven steps, and the tests themselves are proved able to fail.

  7. 07
    From Design Patterns to Domain-Driven Design and Modern Architecture

    Domain-driven design, ports and adapters, the onion and the clean architecture share one rule: the business rules refer to nothing else. The pricing example gains a domain model and a tested boundary, and needs 22 types where one class did.

  8. 08
    Do We Still Need Gang of Four Patterns in Modern C# and .NET?

    You are here.

Support independent engineering knowledge

Knowledge, Analysis, the Field Guide and the tools are free to read and use, and they stay that way. If this article saved you time, you can support the research and writing behind it. Support is voluntary and unlocks nothing.

Buy me a coffee (opens in a new tab)João Lívio's page on buymeacoffee.com

Comments