Advanced OOP Patterns

Reviewed & published by Brayan K

By the end of this lesson you'll be able to apply the everyday design patterns — Factory, Strategy, Observer, and Singleton — and the Dependency Inversion principle that ties them together, so your C# code stays flexible, testable, and easy to extend.

Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

Design patterns are like the standard joints a carpenter knows — a dovetail, a mortise and tenon, a dowel. Nobody invents a new joint for every shelf; they reach for the one that fits the job. A power drill is a great analogy for the patterns here: the drill body (your code) doesn't care which bit is fitted — it just spins whatever clicks into the chuck. A Strategy is swapping the bit (the algorithm) without changing the drill. A Factory is the bit holder that hands you the right one by name. The chuck itself — the standard socket every bit must fit — is an interface, and "always fit to the chuck, never weld a bit on" is Dependency Inversion. Learn the joints once and you stop reinventing them.

The Patterns in This Lesson

A design pattern is a named, reusable solution to a problem that keeps coming up — not a library you install, but a shape your classes take. The four below (from the classic "Gang of Four" catalogue) are the ones you'll actually reach for week to week, and they all rest on one idea: program to an interface, not a concrete class.

PatternSolvesReal-world use
StrategyChoosing an algorithm at runtimePricing, sorting, validation, routing
FactoryCreating objects without naming the classPlugins, parsers, notification channels
ObserverNotifying many objects of a changeEvents, UI updates, pub/sub buses
SingletonSharing exactly one instance app-wideConfig, logging, a cache
Dependency InversionDecoupling code from its dependenciesTestable services, swappable backends

1. Strategy — Swap Algorithms at Runtime

The Strategy pattern turns a sprawling if/switch over "which way of doing this" into a set of small classes that all implement one interface. A context class holds a reference to the interface and delegates the work, so you can change the behaviour by swapping in a different strategy object — even while the program runs — without editing the context at all. Read this worked example, run it, then you'll wire one up yourself.

using System.Globalization;
using System;

// STRATEGY pattern = a family of interchangeable algorithms behind ONE interface.
// The caller picks which algorithm to use at runtime — no if/else ladders.
interface IDiscountStrategy
{
    decimal Apply(decimal price);   // each strategy returns the discounted price
}

// Each concrete strategy is one self-contained algorithm.
class NoDiscount : IDiscountStrategy
{
    public decimal Apply(decimal price) => price;            // full price
}

class PercentOff : IDiscountStrategy
{
    private readonly decimal _percent;
    public PercentOff(decimal percent) => _percent = percent;
    public decimal Apply(decimal price) => price * (1 - _percent / 100);
}

// The CONTEXT holds a strategy and delegates the work to it.
// It has NO idea which concrete algorithm it is using — that's the point.
class Checkout
{
    private IDiscountStrategy _discount;
    public Checkout(IDiscountStrategy discount) => _discount = discount;

    // Swap the algorithm at runtime without touching this class.
    public void SetDiscount(IDiscountStrategy discount) => _discount = discount;

    public void Ring(decimal price) =>
        Console.WriteLine($"Pay: {_discount.Apply(price):C}");
}

class Program
{
    static void Main()
    {
        // Currency formatting follows the thread's culture: {value:C} prints
        // £ in London, $ in Boston and € in Paris. Pin it when the output has
        // to be the same everywhere — as it does on a page that shows you the
        // result.
        CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-GB");

        var checkout = new Checkout(new NoDiscount());
        checkout.Ring(100m);                       // Pay: £100.00

        checkout.SetDiscount(new PercentOff(20));  // swap strategy at runtime
        checkout.Ring(100m);                       // Pay: £80.00
    }
}

// ✅ Expected output:
//    Pay: £100.00
//    Pay: £80.00

Your turn. The program below has a HalfPrice strategy and a Checkout context — they just need two blanks filled. Implement the discount and pick the strategy at runtime, then run it.

using System;

// 🎯 YOUR TURN — wire up a Strategy and pick one at runtime.

interface IDiscountStrategy
{
    decimal Apply(decimal price);
}

class NoDiscount : IDiscountStrategy
{
    public decimal Apply(decimal price) => price;          // done for you
}

class HalfPrice : IDiscountStrategy
{
    // 1) Return HALF of the price.
    public decimal Apply(decimal price) => ___;           // 👉 price * 0.5m
}

class Checkout
{
    private IDiscountStrategy _discount;
    public Checkout(IDiscountStrategy discount) => _discount = discount;
    public void Ring(decimal price) =>
        Console.WriteLine($"Pay: {_discount.Apply(price):C}");
}

class Program
{
    static void Main()
    {
        // 2) Create a Checkout that uses the HalfPrice strategy.
        var checkout = new Checkout(new ___());           // 👉 HalfPrice

        checkout.Ring(50m);

        // ✅ Expected output:
        //    Pay: £25.00
    }
}

2. Factory — Hide Object Creation

A Factory gathers all the new SomeClass(...) calls into one place so the rest of your code asks for an object by name or type and gets back an interface. This matters because scattering new Circle() everywhere couples every caller to a concrete class — change the class and you hunt down every site. With a factory, callers depend only on the IShape interface, and adding a new shape means editing one method. Read the worked example first, then finish your own.

using System;

// FACTORY METHOD = one place that decides which concrete object to build.
// Callers ask for "a shape" by name and get back the right IShape — they
// never write 'new Circle()' or 'new Square()' themselves.
interface IShape
{
    double Area();
    string Name { get; }
}

class Circle : IShape
{
    private readonly double _r;
    public Circle(double r) => _r = r;
    public string Name => "Circle";
    public double Area() => Math.PI * _r * _r;
}

class Square : IShape
{
    private readonly double _side;
    public Square(double side) => _side = side;
    public string Name => "Square";
    public double Area() => _side * _side;
}

// The FACTORY: creation logic lives here and nowhere else.
static class ShapeFactory
{
    public static IShape Create(string type, double size) => type.ToLower() switch
    {
        "circle" => new Circle(size),
        "square" => new Square(size),
        _ => throw new ArgumentException($"Unknown shape: {type}")
    };
}

class Program
{
    static void Main()
    {
        // The caller only knows interfaces and names — not concrete classes.
        IShape a = ShapeFactory.Create("circle", 2);
        IShape b = ShapeFactory.Create("square", 3);

        Console.WriteLine($"{a.Name} area: {a.Area():F2}");   // Circle area: 12.57
        Console.WriteLine($"{b.Name} area: {b.Area():F2}");   // Square area: 9.00
    }
}

// ✅ Expected output:
//    Circle area: 12.57
//    Square area: 9.00

Now you try. Finish the factory so it returns a Square for "square" and a Circle otherwise, then ask the factory (not new) for a square. Fill in the two blanks:

using System;

// 🎯 YOUR TURN — finish a factory that returns different IShape objects.

interface IShape
{
    double Area();
    string Name { get; }
}

class Circle : IShape
{
    private readonly double _r;
    public Circle(double r) => _r = r;
    public string Name => "Circle";
    public double Area() => Math.PI * _r * _r;
}

class Square : IShape
{
    private readonly double _side;
    public Square(double side) => _side = side;
    public string Name => "Square";
    public double Area() => _side * _side;
}

static class ShapeFactory
{
    public static IShape Create(string type, double size)
    {
        // 1) Return a Square when type is "square".
        if (type == "square") return new ___(size);   // 👉 Square

        // 2) Otherwise return a Circle.
        return new Circle(size);
    }
}

class Program
{
    static void Main()
    {
        // 3) Ask the factory for a "square" of size 4 (don't use 'new' here).
        IShape shape = ShapeFactory.___("square", 4);  // 👉 Create

        Console.WriteLine($"{shape.Name} area: {shape.Area():F2}");

        // ✅ Expected output:
        //    Square area: 16.00
    }
}

3. Observer — One Change, Many Reactions

The Observer pattern sets up a one-to-many link: a subject keeps a list of observers and, when its state changes, calls each one. The subject doesn't know or care what the observers do — it just notifies them — so you can add an email alert, an analytics tracker, or a logger without ever touching the subject. C# bakes this in through event and delegates, but building it by hand makes the moving parts obvious.

using System;
using System.Collections.Generic;

// OBSERVER = a subject notifies a list of observers when something happens.
// The subject doesn't know what each observer DOES — only how to call it.
interface IObserver
{
    void Notify(string eventName);
}

// The SUBJECT holds subscribers and broadcasts to all of them.
class Stock
{
    private readonly List<IObserver> _observers = new();

    public void Subscribe(IObserver o) => _observers.Add(o);
    public void Unsubscribe(IObserver o) => _observers.Remove(o);   // avoids leaks

    public void PriceChanged(string symbol)
    {
        foreach (var o in _observers) o.Notify(symbol);   // tell everyone
    }
}

// Two independent observers — each reacts in its own way.
class TraderApp : IObserver
{
    public void Notify(string e) => Console.WriteLine($"  📈 Trader sees {e} moved");
}

class AuditLog : IObserver
{
    public void Notify(string e) => Console.WriteLine($"  📝 Logged change for {e}");
}

class Program
{
    static void Main()
    {
        var stock = new Stock();
        stock.Subscribe(new TraderApp());
        stock.Subscribe(new AuditLog());

        Console.WriteLine("Price update:");
        stock.PriceChanged("ACME");
        //   📈 Trader sees ACME moved
        //   📝 Logged change for ACME
    }
}

4. Singleton — Exactly One Instance

A Singleton guarantees a class has just one instance and gives the whole app a single way to reach it — handy for shared configuration or a logger. You make the constructor private so no one else can call new, and expose the one instance through a static property. The trap is thread safety: if two threads race to build it lazily you can get two instances. The simplest correct version uses a static readonly field, which the C# runtime initialises exactly once for you.

using System;

// SINGLETON = a class with exactly ONE shared instance for the whole app.
// Used for things there should only ever be one of: config, a logger, a cache.
sealed class AppConfig
{
    // 'static readonly' is initialised once, lazily, and is THREAD-SAFE in C#
    // because the runtime guarantees a type's static fields run only once.
    private static readonly AppConfig _instance = new AppConfig();

    // The single, global access point.
    public static AppConfig Instance => _instance;

    public string Environment { get; set; } = "Production";

    // A PRIVATE constructor stops anyone writing 'new AppConfig()' elsewhere.
    private AppConfig() { }
}

class Program
{
    static void Main()
    {
        // Everyone shares the SAME object, so a change is seen everywhere.
        AppConfig.Instance.Environment = "Staging";

        Console.WriteLine(AppConfig.Instance.Environment);  // Staging

        // Two reads return the exact same instance, not a copy.
        bool same = ReferenceEquals(AppConfig.Instance, AppConfig.Instance);
        Console.WriteLine($"Same instance? {same}");        // Same instance? True
    }
}

// ✅ Expected output:
//    Staging
//    Same instance? True

5. Dependency Inversion — Program to Interfaces

Notice what every pattern above shares: classes talk to each other through interfaces, never concrete types. That's the Dependency Inversion principle — the "D" in SOLID. High-level code (a service) shouldn't be welded to low-level details (a specific email library); both should depend on an abstraction. In practice this means asking for an interface in your constructor and letting the caller supply the concrete object. It's what makes code testable (pass a fake), swappable (change the backend), and is the foundation of the Dependency Injection you'll meet next.

using System;

// DEPENDENCY INVERSION: depend on an INTERFACE, not a concrete class.
// High-level code (OrderService) shouldn't be glued to one low-level detail.
interface IMessageSender
{
    void Send(string message);
}

// Two concrete details — both honour the same contract.
class EmailSender : IMessageSender
{
    public void Send(string message) => Console.WriteLine($"📧 {message}");
}

class SmsSender : IMessageSender
{
    public void Send(string message) => Console.WriteLine($"📱 {message}");
}

// OrderService asks for an IMessageSender in its constructor (injection).
// It never says 'new EmailSender()' — so you can swap the detail freely.
class OrderService
{
    private readonly IMessageSender _sender;
    public OrderService(IMessageSender sender) => _sender = sender;

    public void Confirm(string id) => _sender.Send($"Order {id} confirmed");
}

class Program
{
    static void Main()
    {
        // Choose the implementation at the edge of the program.
        var byEmail = new OrderService(new EmailSender());
        byEmail.Confirm("A1");                 // 📧 Order A1 confirmed

        var bySms = new OrderService(new SmsSender());
        bySms.Confirm("B2");                   // 📱 Order B2 confirmed
        // Same OrderService code, different behaviour — no edits required.
    }
}

// ✅ Expected output:
//    📧 Order A1 confirmed
//    📱 Order B2 confirmed

🔎 Deep Dive: events are the built-in Observer

You rarely need to hand-roll the Observer list in real C# — the language gives you event and delegates that do the bookkeeping for you. The hand-written version is worth understanding, but reach for events in production.

class Stock
{
    public event Action<string> PriceChanged;        // the subject

    public void Update(string symbol) =>
        PriceChanged?.Invoke(symbol);                // notify all subscribers
}

// Subscribe with += , and ALWAYS unsubscribe with -= to avoid leaks:
var stock = new Stock();
Action<string> handler = s => Console.WriteLine($"Moved: {s}");
stock.PriceChanged += handler;     // subscribe
stock.PriceChanged -= handler;     // unsubscribe when done

The ?.Invoke is null-safe: if nobody has subscribed, PriceChanged is null and a plain PriceChanged(symbol) would throw.

Putting It Together: a Shipping Quote

Here's a small but real program that combines this lesson's ideas: a Factory picks a Strategy by name, and the calling code only ever sees the IShippingStrategy interface (Dependency Inversion). To add a new carrier you edit one dictionary entry — nothing else changes.

using System.Globalization;
using System;
using System.Collections.Generic;

// === A shipping quote app — Factory builds a Strategy, chosen at runtime ===

// STRATEGY: each carrier prices a parcel differently.
interface IShippingStrategy
{
    decimal Cost(double weightKg);
}

class StandardShipping : IShippingStrategy
{
    public decimal Cost(double weightKg) => 3.00m + (decimal)weightKg * 1.50m;
}

class ExpressShipping : IShippingStrategy
{
    public decimal Cost(double weightKg) => 6.00m + (decimal)weightKg * 2.50m;
}

// FACTORY: maps a name to the right strategy — easy to extend, hard to break.
static class ShippingFactory
{
    private static readonly Dictionary<string, Func<IShippingStrategy>> _map = new()
    {
        ["standard"] = () => new StandardShipping(),
        ["express"]  = () => new ExpressShipping()
    };

    public static IShippingStrategy Create(string type) =>
        _map.TryGetValue(type, out var make)
            ? make()
            : throw new ArgumentException($"No carrier: {type}");
}

class Program
{
    static void Main()
    {
        // Currency formatting follows the thread's culture: {value:C} prints
        // £ in London, $ in Boston and € in Paris. Pin it when the output has
        // to be the same everywhere — as it does on a page that shows you the
        // result.
        CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-GB");

        double weight = 2.0;
        foreach (var type in new[] { "standard", "express" })
        {
            // Factory hands back a strategy; we program to the interface only.
            IShippingStrategy carrier = ShippingFactory.Create(type);
            Console.WriteLine($"{type,-9}: {carrier.Cost(weight):C}");
        }
        // standard : £6.00
        // express  : £11.00
    }
}

// ✅ Expected output:
//    standard : £6.00
//    express  : £11.00

Notice the factory stores Func<IShippingStrategy> creators in a dictionary. That's the "open for extension, closed for modification" idea — you register new behaviour without rewriting the lookup.

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

PatternKey codeTell-tale sign you need it
Strategyctx.SetStrategy(new FastSort())A switch over "which algorithm"
FactoryFactory.Create("circle")new of varied types, scattered
Observersubject.Subscribe(observer)Many objects react to one change
SingletonConfig.InstanceExactly one of something, app-wide
Dependency InversionService(IRepo repo)A class hard-codes a dependency

Frequently Asked Questions

Q: How is the Factory pattern different from just calling a constructor?

A constructor builds one specific class; you have to name it. A factory chooses which class to build based on input and hands back an interface, so callers stay decoupled from concrete types and you can add new ones in one place.

Q: Strategy and Dependency Inversion look almost identical — what's the difference?

They share the mechanism (depend on an interface), but the intent differs. Strategy is about swapping interchangeable algorithms, often at runtime. Dependency Inversion is the broader principle that any dependency should be an abstraction. Strategy is one way of applying it.

Q: Is the Singleton pattern an anti-pattern?

It has a bad reputation because it's global state that hides dependencies and complicates testing. It's not forbidden, but in modern C# you usually get the same "one shared instance" by registering the type as a singleton in a Dependency Injection container instead.

Q: How do I know when I'm over-engineering with patterns?

If a pattern adds interfaces and classes but the behaviour never actually varies, it's over-engineering. Start with the simplest code that works; introduce a pattern only when a real, repeated change becomes painful without it.

Q: Why does removing observers matter so much?

An observer that subscribes but never unsubscribes is kept alive by the subject's reference, so it can never be garbage-collected — a classic memory leak. Pair every += / Subscribe with a matching -= / Unsubscribe.

Mini-Challenge: a Strategy Calculator

No blanks this time — just a brief and an outline. Build an IOperation interface with Add and Multiply strategies, a Calculator that delegates to whichever operation it holds, then swap the strategy at runtime. Run it and check your output against the expected lines in the comments.

using System;

// 🎯 MINI-CHALLENGE: a Strategy-based calculator
// 1. Define an interface IOperation with one method:  double Run(double a, double b);
// 2. Make two strategies: Add (returns a + b) and Multiply (returns a * b).
// 3. Make a Calculator class that holds an IOperation and has a
//    Calculate(double a, double b) method that delegates to it.
// 4. In Main: build a Calculator with Add, print Calculate(6, 4);
//    then swap to Multiply and print Calculate(6, 4).
//
// ✅ Expected output:
//    10
//    24

interface IOperation
{
    // your method here
}

class Program
{
    static void Main()
    {
        // your code here
    }
}

🎉 Lesson Complete

Practice quiz

What problem does the Strategy pattern solve?

  • Creating objects without naming the class
  • Sharing one instance app-wide
  • Swapping interchangeable algorithms behind one interface, even at runtime
  • Notifying many subscribers

Answer: Swapping interchangeable algorithms behind one interface, even at runtime. Strategy puts a family of interchangeable algorithms behind one interface so the caller can swap them at runtime.

In Strategy, what does the context class do?

  • Holds a reference to the interface and delegates the work to it
  • Implements every algorithm itself
  • Creates new objects
  • Stores global state

Answer: Holds a reference to the interface and delegates the work to it. The context holds a strategy reference and delegates to it, without knowing which concrete algorithm it uses.

What does a Factory method give back to callers?

  • A concrete class they must name
  • A static field
  • Nothing
  • An interface, while hiding which concrete class was created

Answer: An interface, while hiding which concrete class was created. A factory centralises creation and returns an interface, so callers stay decoupled from concrete types.

What guarantees a Singleton has exactly one instance accessible app-wide?

  • A public constructor
  • A private constructor plus a static access point
  • An interface
  • An event

Answer: A private constructor plus a static access point. Making the constructor private stops outside 'new' calls, and a static property exposes the single instance.

Why is a 'static readonly' field a thread-safe way to build a Singleton?

  • The runtime guarantees a type's static fields are initialised exactly once
  • It uses a lock on every read
  • It is never null
  • It runs on every thread separately

Answer: The runtime guarantees a type's static fields are initialised exactly once. The C# runtime initialises a type's static fields exactly once, so static readonly avoids the lazy-init race.

In the Observer pattern, what does the subject do when its state changes?

  • Nothing
  • Deletes its observers
  • Calls each subscribed observer to notify them
  • Creates new observers

Answer: Calls each subscribed observer to notify them. The subject keeps a list of observers and notifies each one on a change, without knowing what they do.

Why must you unsubscribe observers (e.g. -= on events)?

  • For speed
  • Otherwise the subject's reference keeps them alive — a memory leak
  • It's required by the compiler
  • To change their behaviour

Answer: Otherwise the subject's reference keeps them alive — a memory leak. An observer that subscribes but never unsubscribes is kept alive by the subject's reference and can never be garbage-collected.

What is the Dependency Inversion principle?

  • Depend on concrete classes for speed
  • Invert all loops
  • Avoid interfaces
  • Depend on an interface (abstraction), not a concrete class

Answer: Depend on an interface (abstraction), not a concrete class. Dependency Inversion (the D in SOLID) says high-level code should depend on abstractions, not concrete details.

How does a class typically receive its dependency under Dependency Inversion?

  • By calling 'new' internally
  • By asking for an interface in its constructor (injection)
  • Via a global static
  • It hard-codes it

Answer: By asking for an interface in its constructor (injection). The class asks for an interface in its constructor and the caller supplies the concrete object, keeping it swappable and testable.

What is C#'s built-in equivalent of the Observer pattern?

  • record
  • lock
  • event and delegates
  • interface

Answer: event and delegates. C# events and delegates do the Observer bookkeeping for you; prefer them over hand-rolling the observer list in production.

Continue this course