Mert Özen Further With Every Line
Backend Object-Oriented Programming in C# — #15

A Few Design Patterns in C#: Factory, Strategy and Observer

Mert Özen Nov 7, 2026 9 min 4 views
A Few Design Patterns in C#: Factory, Strategy and Observer

What design patterns are for, static factory methods and a switch-based factory, strategy with Func and interfaces, observer with events, isolating a broken subscriber, and subscription leaks.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

Two parties want to be told when stock changes: one sends an email, the other updates the search index. The mail server is down at that moment and the first subscriber throws an exception. Does the search index still get updated? Was the stock saved as 0?

var inventory = new Inventory();
inventory.StockChanged += (_, e) => throw new InvalidOperationException("Mail server is down.");
inventory.StockChanged += (_, e) => Console.WriteLine($"Search index updated: {e.Isbn}");

try
{
    inventory.SetStock("978-0441013593", 0);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}

Console.WriteLine(inventory.GetStock("978-0441013593"));

public sealed class StockChangedEventArgs : EventArgs
{
    public StockChangedEventArgs(string isbn, int newStock)
    {
        Isbn = isbn;
        NewStock = newStock;
    }

    public string Isbn { get; }
    public int NewStock { get; }
}

public sealed class Inventory
{
    private readonly Dictionary<string, int> _stock = new();

    public event EventHandler<StockChangedEventArgs>? StockChanged;

    public int GetStock(string isbn) => _stock.TryGetValue(isbn, out int count) ? count : 0;

    public void SetStock(string isbn, int count)
    {
        _stock[isbn] = count;
        StockChanged?.Invoke(this, new StockChangedEventArgs(isbn, count));
    }
}

What Patterns Are For

Output:

Mail server is down.
0

The stock was saved, but the search index was never updated. The subscribers of an event are called in order from a single delegate list; if one throws, the list stops there and the exception bubbles up to the code that called SetStock. A problem in the mail server also broke the unrelated search feature. We will fix this in the observer section of this post.

This example is actually a design pattern: observer. Design patterns are named solutions to design problems that come up again and again. The "Design Patterns" book, published in 1994 and whose authors are known as the "Gang of Four", catalogs 23 of them. The biggest benefit of patterns is a shared vocabulary: when someone on the team says "let's put a strategy here", everyone pictures the same structure.

The book belongs to the C++ and Smalltalk era; some patterns made up for features those languages lacked. In C#, delegates, lambdas, events and interfaces are first-class citizens, so many patterns shrink to a few lines. In this post we look at the three you will use most, with modern C#.

Factory: Gathering Creation in One Place

The essence of the factory pattern is to gather the knowledge of how an object is created in one place. It has two common forms: static factory methods on the type itself, and a factory that decides which concrete type to produce:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var dune = Book.CreatePrinted("978-0441013593", "Dune", 12.99m, weightKg: 0.45m);
var emma = Book.CreateDigital("978-0141439518", "Emma", 3.99m);

foreach (var country in new[] { "TR", "DE", "US" })
{
    try
    {
        IShippingCalculator shipping = ShippingCalculatorFactory.For(country);
        Console.WriteLine($"{country}: {dune.Title} {shipping.Cost(dune):0.00}, {emma.Title} {shipping.Cost(emma):0.00}");
    }
    catch (NotSupportedException ex)
    {
        Console.WriteLine(ex.Message);
    }
}

try
{
    Book.CreatePrinted("978-0547928227", "The Hobbit", -1m, weightKg: 0.3m);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine(ex.Message);
}

public sealed class Book
{
    private Book(string isbn, string title, decimal price, decimal weightKg, bool isDigital)
    {
        Isbn = isbn;
        Title = title;
        Price = price;
        WeightKg = weightKg;
        IsDigital = isDigital;
    }

    public string Isbn { get; }
    public string Title { get; }
    public decimal Price { get; }
    public decimal WeightKg { get; }
    public bool IsDigital { get; }

    public static Book CreatePrinted(string isbn, string title, decimal price, decimal weightKg)
    {
        if (price < 0)
            throw new ArgumentOutOfRangeException(nameof(price), "Price cannot be negative.");

        return new Book(isbn, title, price, weightKg, isDigital: false);
    }

    public static Book CreateDigital(string isbn, string title, decimal price) =>
        new Book(isbn, title, price, weightKg: 0m, isDigital: true);
}

public interface IShippingCalculator
{
    decimal Cost(Book book);
}

public sealed class DomesticShipping : IShippingCalculator
{
    public decimal Cost(Book book) => book.IsDigital ? 0m : 2.50m;
}

public sealed class EuShipping : IShippingCalculator
{
    public decimal Cost(Book book) => book.IsDigital ? 0m : 5.00m + book.WeightKg * 4m;
}

public static class ShippingCalculatorFactory
{
    public static IShippingCalculator For(string countryCode) => countryCode switch
    {
        "TR" => new DomesticShipping(),
        "DE" or "FR" or "NL" => new EuShipping(),
        _ => throw new NotSupportedException($"No shipping to {countryCode}.")
    };
}
TR: Dune 2.50, Emma 0.00
DE: Dune 6.80, Emma 0.00
No shipping to US.
Price cannot be negative. (Parameter 'price')

The constructor of Book is private and takes five parameters, one of which is a bool. In a call like new Book(..., true), nobody reading it can tell what true means. CreatePrinted and CreateDigital, on the other hand, state the intent with their names, and each applies its own rule: a printed book cannot be created with a negative price, and a digital book always weighs zero. .NET itself likes this pattern: TimeSpan.FromMinutes, Task.FromResult, Guid.NewGuid.

ShippingCalculatorFactory answers the question "which type?". The switch expression and the or pattern we saw in the fundamentals series turn that decision into a readable table. The calling code only knows IShippingCalculator; when a new country is added, the factory is the only place that changes. In large applications this job is usually taken over by a dependency injection container; a container is a big factory that knows which class to create for which interface.

Strategy: Making Behavior Replaceable

Strategy separates an algorithm from the code that uses it and makes it replaceable at runtime. The IDiscountPolicy in the previous post was already a strategy. If the behavior is a single operation, you do not even need an interface; a Func is enough:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var campaigns = new Dictionary<string, Func<decimal, decimal>>(StringComparer.OrdinalIgnoreCase)
{
    ["none"] = total => total,
    ["student"] = total => total * 0.85m,
    ["over50"] = total => total >= 50m ? total - 10m : total
};

decimal[] prices = { 12.99m, 7.50m, 34.90m };

foreach (var code in new[] { "none", "STUDENT", "over50" })
{
    var checkout = new Checkout(campaigns[code]);
    Console.WriteLine($"{code}: {checkout.Total(prices):0.00}");
}

public sealed class Checkout
{
    private readonly Func<decimal, decimal> _discount;

    public Checkout(Func<decimal, decimal> discount) => _discount = discount;

    public decimal Total(IEnumerable<decimal> prices) =>
        Math.Round(_discount(prices.Sum()), 2, MidpointRounding.AwayFromZero);
}
none: 55.39
STUDENT: 47.08
over50: 45.39

Checkout does not know which campaign is applied; it calls the function it was given. The campaigns sit in a dictionary and are picked by the code the user enters; thanks to StringComparer.OrdinalIgnoreCase, "STUDENT" was found too. A new campaign is one more line in the dictionary.

Interface or Func? If the strategy is a single method and holds no state, a Func does the same job with less ceremony. If the strategy has a name, several methods or dependencies of its own, or will be registered with a dependency injection container, an interface is better: replacing IDiscountPolicy with a fake in a test is easier than guessing what a lambda does. We will go deep into delegates and Func in the first post of the Advanced series.

Observer: Announcing Changes

In observer, one object (the publisher) announces a change in its state to the other objects listening to it (the subscribers). The publisher does not know who the subscribers are; it just says "something changed". In C#, the built-in form of this pattern is the event. Let's solve the problem from the opening by calling each subscriber separately:

var inventory = new Inventory();
var mailer = new RestockMailer();

inventory.StockChanged += mailer.OnStockChanged;
inventory.StockChanged += (_, e) => throw new InvalidOperationException("Search index is down.");
inventory.StockChanged += (_, e) => Console.WriteLine($"Audit: {e.Isbn} -> {e.NewStock}");

inventory.SetStock("978-0441013593", 0);

inventory.StockChanged -= mailer.OnStockChanged;
inventory.SetStock("978-0141439518", 0);

public sealed class RestockMailer
{
    public void OnStockChanged(object? sender, StockChangedEventArgs e)
    {
        if (e.NewStock == 0)
            Console.WriteLine($"Mail: {e.Isbn} is out of stock");
    }
}

public sealed class StockChangedEventArgs : EventArgs
{
    public StockChangedEventArgs(string isbn, int newStock)
    {
        Isbn = isbn;
        NewStock = newStock;
    }

    public string Isbn { get; }
    public int NewStock { get; }
}

public sealed class Inventory
{
    private readonly Dictionary<string, int> _stock = new();

    public event EventHandler<StockChangedEventArgs>? StockChanged;

    public int GetStock(string isbn) => _stock.TryGetValue(isbn, out int count) ? count : 0;

    public void SetStock(string isbn, int count)
    {
        _stock[isbn] = count;
        OnStockChanged(new StockChangedEventArgs(isbn, count));
    }

    private void OnStockChanged(StockChangedEventArgs e)
    {
        if (StockChanged is null)
            return;

        foreach (EventHandler<StockChangedEventArgs> handler in StockChanged.GetInvocationList())
        {
            try
            {
                handler(this, e);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Subscriber failed: {ex.Message}");
            }
        }
    }
}
Mail: 978-0441013593 is out of stock
Subscriber failed: Search index is down.
Audit: 978-0441013593 -> 0
Subscriber failed: Search index is down.
Audit: 978-0141439518 -> 0

GetInvocationList() gives you the list of subscribers behind the event; calling each one inside its own try/catch means a broken subscriber can no longer block the others. Before the second SetStock we removed the email subscriber with -=, and the email line did not show up in the second round. In a real application you would log the error instead of using Console.WriteLine; do not forget the difference between swallowing and logging.

The parts of this shape come from .NET's event convention: the EventHandler<T> delegate (sender and arguments), an argument class deriving from EventArgs, and a method named OnXxx that raises the event. The event keyword guarantees that outsiders can only do += and -= and cannot raise the event themselves. The BCL also has the IObservable<T> and IObserver<T> interfaces; they were designed especially for listening to a stream of values and become powerful together with the System.Reactive (Rx) library. We will see the details of events in the Advanced series.

Unsubscribing and Leaks

-= only works when you pass the same delegate. Subscribing with a lambda and trying to unsubscribe with a lambda is a common mistake:

var inventory = new Inventory();

inventory.StockChanged += (_, e) => Console.WriteLine($"Log: {e}");
inventory.StockChanged -= (_, e) => Console.WriteLine($"Log: {e}");

inventory.SetStock("978-0441013593", 4);

public sealed class Inventory
{
    public event EventHandler<string>? StockChanged;

    public void SetStock(string isbn, int count) => StockChanged?.Invoke(this, $"{isbn}={count}");
}
Log: 978-0441013593=4

Even though the two lambdas look the same, they are two separate delegates; -= found no match in the list and silently did nothing. If you will need to unsubscribe, keep the subscriber in a variable or as a method (like mailer.OnStockChanged).

Not unsubscribing has another cost. The publisher holds references to its subscribers through delegates. As long as a long-lived publisher (an Inventory that lives for the whole application) keeps a short-lived subscriber (a screen, a per-request object) in its list, that subscriber is reachable for the garbage collector and is never collected. In the post on object lifetime we said "if memory keeps growing, the cause is usually objects that are still reachable"; forgotten event subscriptions are the classic example. If a subscriber lives shorter than its publisher, it needs to unsubscribe with -= when it is done; IDisposable is a good place for that.

Common Pitfalls

Using a pattern for the pattern's sake. An IBookFactory that returns a single concrete type, a strategy setup in a system with one campaign, an event nobody listens to: all of them tell the reader "there is flexibility here", but that flexibility is never used. Bring a pattern in when the problem it answers actually shows up.

Quietly falling back to a default in a factory. Writing _ => new DomesticShipping() calculates a domestic shipping price for "US" too, and nobody notices. Making the unknown case visible with an exception is better than issuing a wrong invoice.

Doing long work in an event handler. Events run synchronously by default; SetStock does not return until all subscribers are done. A slow email send slows down the stock update as well. We will see how to hand long work off to a queue with Channels in the Asynchronous Programming series.

Assuming subscribers are called in a particular order. Today they run in the order they were added, but one relying on another's result is a fragile design. If order matters, it is not an event but a set of explicitly ordered steps.

A Small Experiment

★☆☆ Add an InternationalShipping for "US" to ShippingCalculatorFactory: 15 plus 8 per kilogram for printed books. See the output change without touching the calling code.

★★☆ What happens when a code that is not in the campaigns dictionary is entered? Using TryGetValue, write a selection method that falls back to the "none" campaign for unknown codes and prints that it did. Why is this different from the "quiet default" in the factory pitfall?

★★★ Make RestockMailer IDisposable: it should take the Inventory in its constructor and subscribe, and unsubscribe in Dispose. Prove that the email line does not show up for a SetStock made after the using block ends.

Before we finish the series, it is time to put all the pieces together. In the next and final post we build a small but complete OOP Bookstore with records, equality, operators, composition, IDisposable and these three patterns.