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.
| Pattern | Solves | Real-world use |
|---|---|---|
| Strategy | Choosing an algorithm at runtime | Pricing, sorting, validation, routing |
| Factory | Creating objects without naming the class | Plugins, parsers, notification channels |
| Observer | Notifying many objects of a change | Events, UI updates, pub/sub buses |
| Singleton | Sharing exactly one instance app-wide | Config, logging, a cache |
| Dependency Inversion | Decoupling code from its dependencies | Testable 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.
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.
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.
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.
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.
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.
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.
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 doneThe ?.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.
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/elsedoesn'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
nulland each builds one. Don't writeif (_instance == null) _instance = new(...)unguarded — use astatic readonlyfield orLazy<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 isprivate. Go throughAppConfig.Instanceinstead — that's the Singleton working as intended.
📋 Quick Reference
| Pattern | Key code | Tell-tale sign you need it |
|---|---|---|
| Strategy | ctx.SetStrategy(new FastSort()) | A switch over "which algorithm" |
| Factory | Factory.Create("circle") | new of varied types, scattered |
| Observer | subject.Subscribe(observer) | Many objects react to one change |
| Singleton | Config.Instance | Exactly one of something, app-wide |
| Dependency Inversion | Service(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.
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 readonlyfor 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.