Skip to main content
    Courses/C#/Advanced OOP Patterns

    Advanced Track

    Advanced OOP Patterns

    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.

    What You'll Learn

    • Use the Strategy pattern to swap algorithms at runtime through an interface
    • Build a Factory method that hides object creation behind one entry point
    • Wire up the Observer pattern so many subscribers react to one event
    • Create a thread-safe Singleton — and know when not to use one
    • Apply Dependency Inversion: program to interfaces, not concrete classes
    • Recognise over-engineering and pick a pattern only when it earns its keep

    💡 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.

    Worked example: a discount Strategy

    Read every comment, run it, and watch the price change when the strategy is swapped.

    Try it Yourself »
    C#
    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 : IDiscountStrateg
    ...

    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.

    🎯 Your turn: implement a Strategy

    Fill in the ___ blanks so the half-price strategy is selected, then check the output.

    Try it Yourself »
    C#
    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(IDisco
    ...

    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.

    Worked example: a shape Factory

    One method decides which IShape to build; callers never use new.

    Try it Yourself »
    C#
    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
    {
        p
    ...

    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:

    🎯 Your turn: a Factory method

    Return the right IShape from the factory and request one in Main, then check the area.

    Try it Yourself »
    C#
    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 Ar
    ...

    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.

    Worked example: a stock-price Observer

    One PriceChanged call notifies every subscriber; each reacts differently.

    Try it Yourself »
    C#
    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) =
    ...

    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.

    Worked example: a thread-safe Singleton

    A private constructor plus a static readonly field gives exactly one shared instance.

    Try it Yourself »
    C#
    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 => _i
    ...

    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.

    Worked example: depend on an interface

    OrderService asks for an IMessageSender — swap email for SMS with no edits.

    Try it Yourself »
    C#
    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($"📱 {mes
    ...

    🔎 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.

    Worked example: Factory builds a Strategy

    Add a carrier by editing one line; the rest of the program is untouched.

    Try it Yourself »
    C#
    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
    ...

    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

    • 💡 Reach for a pattern when the pain is real: a single if/else doesn't need Strategy. Add the pattern the second or third time the same change hurts.
    • 💡 Prefer the framework's version: C# events are Observer; the DI container is Factory + Singleton. Don't hand-roll what the platform gives you.
    • 💡 Avoid the Singleton when you can: a single shared instance is global state in disguise and makes testing hard. Register a single instance in a DI container instead.
    • 💡 Always unsubscribe observers (-= on events) when an object is done, or the subject keeps it alive and you leak memory.
    • 💡 Name strategies after what they do (PercentOff, ExpressShipping), not how (Strategy1). The pattern should disappear into readable names.

    Common Errors (and the fix)

    • Non-thread-safe lazy Singleton: two threads both see the instance as null and each builds one. Don't write if (_instance == null) _instance = new(...) unguarded — use a static readonly field or Lazy<T>, which the runtime initialises exactly once.
    • Leaking observers: you subscribe with += but never -=. The subject holds a reference to your object forever, so the garbage collector can't free it. Always unsubscribe when the observer's life ends.
    • Over-engineering: wrapping a one-line calculation in a Factory, a Strategy, and three interfaces. If there's only ever one algorithm, you've added indirection with no payoff — delete the pattern.
    • "CS0144: Cannot create an instance of the abstract type or interface 'IShape'": you tried new IShape(). You can only instantiate a concrete class (new Circle(2)); the interface is the contract, not the object.
    • "CS0122: 'AppConfig.AppConfig()' is inaccessible due to its protection level": you wrote new AppConfig() on a Singleton whose constructor is private. Go through AppConfig.Instance instead — that's the Singleton working as intended.

    📋 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.

    🎯 Mini-Challenge: a Strategy-based calculator

    Define IOperation, two strategies, and a Calculator that swaps them; expect 10 then 24.

    Try it Yourself »
    C#
    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 ou
    ...

    🎉 Lesson Complete

    • Strategy swaps interchangeable algorithms behind one interface at runtime
    • Factory centralises object creation so callers depend on interfaces, not classes
    • Observer lets a subject notify many subscribers — and you must unsubscribe to avoid leaks
    • Singleton shares one instance; use static readonly for thread safety, and prefer DI when you can
    • Dependency Inversion — program to interfaces, not concrete types — underpins them all
    • ✅ Patterns are tools, not trophies: reach for one only when complexity earns it
    • Next lesson: Dependency Injection — letting a container supply your dependencies for you

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service