Good Design & SOLID
Chapter 1 gave you the mechanics of objects — inheritance, polymorphism, composition. This chapter is about judgement: what makes one design better than another. You'll learn the two goals every design serves (reuse and extensibility), three pre-SOLID design principles, the five SOLID principles — with the deepest treatment reserved for Liskov Substitution — and finally what a design pattern actually is, which sets up chapters 3–5.
What does "good design" even mean?
Good design is design that stays cheap to change. That sounds underwhelming until you realise that almost everything that goes wrong in software projects — blown deadlines, fragile releases, "we can't touch that module" folklore — is the cost of change growing out of control. The lecture slides boil good design down to two measurable features: code reuse and extensibility.
Code reuse — why copy-paste is debt
Cost and time are the two most valuable metrics in software development. Less development time means entering the market before competitors; lower cost means more budget left for everything else. Reusing code you already have is the most direct way to save both — and design patterns exist largely to make components flexible enough to be reusable.
The enemy of reuse is copy-paste. Copying a block of code feels free — it takes three seconds. But you haven't reused the code, you've cloned it, and every clone is a debt you now owe:
- Bugs multiply. Fix a bug in one copy and it silently survives in the other four. Nobody remembers where the copies are.
- Changes multiply. A new requirement ("tax rules changed") must be applied to every clone, consistently, or the system disagrees with itself.
- Clones drift. Each copy gets edited slightly differently over time, so eventually you can't even tell whether the differences are intentional.
"Debt" is the right word: like financial debt, duplicated code charges interest — every future change costs more than it should, forever, until you pay the principal back by consolidating the copies into one reusable unit. Most of this chapter is about how to build that reusable unit so it doesn't collapse the first time a requirement changes.
Extensibility — change is the only constant
Requirements will change. Your design's job is to absorb that change without breaking. Why is change guaranteed?
- We understand a problem better once we start solving it. The design you'd produce after six months of building is always better than the one you produced on day one — so day-one designs get revised.
- The world around the software changes. New payment providers appear, tax law changes, the business pivots, an OS update breaks an assumption. None of this is your code's fault; it must cope anyway.
- Success creates change. If the product works, users ask for more. The reward for shipping version 1 is the change requests for version 2.
Seasoned developers therefore design for change: they ask "when this requirement moves, how many files do I have to edit, and how scared will I be while editing them?" Good design practice — especially reusing design patterns that others have battle-tested on similar problems — reduces the time and cost of maintaining software.
A building with plumbing embedded in solid concrete versus one with service ducts. Both deliver water on day one. But when a pipe needs replacing, the first building requires a jackhammer and the second requires a screwdriver. "Extensible design" is the service duct: slightly more effort up front, dramatically cheaper change forever after.
Two words appear constantly from here on. Coupling = how much one class knows about / depends on another (we want it low: changes shouldn't ripple). Cohesion = how strongly the contents of one class belong together (we want it high: one class, one job). Every principle below is, at heart, a recipe for lowering coupling or raising cohesion.
Principle 1 — Encapsulate what varies
Identify the aspects of your application that vary and separate them from what stays the same. The goal is to minimise the effect caused by changes: isolate the volatile parts of the program into independent modules, so that when they change (and they will), the rest of the code doesn't even notice.
A ship is divided into watertight compartments by bulkheads. If one compartment floods, the damage stops at the bulkhead — the ship stays afloat. Encapsulating what varies builds bulkheads into your code: a change floods one module and stops there.
The slides show this at two levels: method level and class level. Let's walk both, using the pizza-chain scenario from your tutorial (Tutorial 17): a global pizza company where every country has a different tax rule and a different cooking requirement.
Encapsulate at method level
Here's the naive version. Watch what happens inside GetOrderTotal:
public class PizzaOrder
{
public decimal GetOrderTotal(decimal subtotal, string country)
{
decimal total = subtotal;
// Tax logic — THIS is the part that keeps changing
if (country == "US")
total += subtotal * 0.10m; // US sales tax
else if (country == "UK")
total += subtotal * 0.20m; // UK VAT
// ...next month: Italy, Japan, Australia...
return total;
}
}
GetOrderTotal — a method whose real job (totalling an order) never changed at all.public class PizzaOrder
{
public decimal GetOrderTotal(decimal subtotal, string country)
{
// The STABLE part: totalling never changes
return subtotal + subtotal * GetTaxRate(country);
}
// The VOLATILE part now lives behind one named seam
private decimal GetTaxRate(string country) => country switch
{
"US" => 0.10m,
"UK" => 0.20m,
_ => 0m
};
}
GetTaxRate. The totalling logic is
protected behind a bulkhead. Small move, real benefit.Encapsulate at class level
Method extraction works until the varying part grows. Real tax calculation needs country + state +
province + product category + tax-exemption rules… Soon GetTaxRate and its helper fields dominate the
class, and PizzaOrder has quietly become a tax engine that also happens to total pizzas. Time to
promote the variation to its own class:
public class TaxCalculator
{
public decimal GetTaxRate(string country, string state, string product)
{
// All the messy, ever-changing tax law lives HERE and only here
if (country == "US") return GetUsTax(state);
if (country == "UK") return 0.20m;
return 0m;
}
private decimal GetUsTax(string state) => state == "CA" ? 0.0725m : 0.06m;
}
public class PizzaOrder
{
private readonly TaxCalculator _tax = new(); // delegate the volatile job
public decimal GetOrderTotal(decimal subtotal, string country, string state)
=> subtotal + subtotal * _tax.GetTaxRate(country, state, "food");
}
The pattern in the pattern: once the variation lives in its own class, the next natural step is to
define an interface for it (ITaxStrategy) and let different implementations be swapped in —
UsTax, UkTax, EuTax. At that point you have independently invented the
Strategy pattern. The tutorial's full solution does exactly this,
with a TaxStrategy and a CookingStrategy (wood-fire vs electric oven) plugged into a
PizzaOrder that never changes again — even when a DiscountStrategy is added later.
When reviewing code, ask: 1) Does a class contain a long if/else or switch
on a "type", "mode" or "country"? 2) Am I creating subclasses for every combination of features?
3) To add a new variation, must I open and modify an existing class? 4) Does my main class
know low-level details (tax maths, oven temperatures) that aren't its job? 5) Can I swap a behaviour at
runtime without constructing a new object? A "yes" to 1–4 or a "no" to 5 means a variation is begging to be encapsulated.
Principle 2 — Program to an interface, not an implementation
Depend on abstractions, not on concrete classes. Before unpacking that, one important clarification:
In this principle, an interface is any contract: a description of what operations
an object offers, separate from how they're implemented. In C# that contract is usually expressed with the
interface keyword, sometimes with an abstract class (when implementations share state or
default logic — see abstraction). Python expresses it with ABCs, Go with
implicit interfaces. The principle is about the idea of a contract, not one language feature: "talk to the job
description, not to a specific employee."
The slides illustrate the idea with a cat: a cat that can eat any food is more flexible than a cat that can only eat sausage. Concretely:
public class Cat
{
// This cat can ONLY eat sausage. New food = edit Cat.
public void Eat(Sausage food)
{
Console.WriteLine($"Eating {food.Name}: +{food.Nutrition} energy");
}
}
Sausage specifically. Introducing
Fish means modifying Cat — the class that had nothing to do with the change.public interface IFood
{
string Name { get; }
int Nutrition { get; }
}
public class Sausage : IFood { /* ... */ }
public class Fish : IFood { /* ... */ }
public class Cat
{
// This cat eats anything that fulfils the IFood contract
public void Eat(IFood food)
{
Console.WriteLine($"Eating {food.Name}: +{food.Nutrition} energy");
}
}
Cat never changes. The connection between the classes
is now through a thin, stable contract instead of a fat, volatile implementation.The slides give a four-step recipe for setting up this kind of flexible collaboration between two classes:
- Determine what exactly one object needs from the other — which methods does it actually call?
- Describe those methods in a new interface (or abstract class).
- Make the dependency class implement that interface.
- Make the dependent class use the interface rather than the concrete class.
After making this change you probably won't feel any immediate benefit — the code got slightly more complicated (an extra type, an extra level of indirection). That's expected. The principle pays off at extension points: places where you can feel that new variations will appear, or where other people will want to extend your code. Don't interface everything; interface the seams that will move.
The worked example: a software company
The slides develop this principle into a three-stage refactor that's worth following closely, because its final
stage is your first real design pattern. Scenario: a Company that creates software using different kinds
of employees.
The company knows every concrete employee class personally.
public class Designer
{
public void DesignArchitecture() => Console.WriteLine("Designing architecture...");
}
public class Programmer
{
public void WriteCode() => Console.WriteLine("Writing code...");
}
public class Tester
{
public void TestSoftware() => Console.WriteLine("Testing software...");
}
public class Company
{
public void CreateSoftware()
{
// Coupled to THREE concrete classes AND their individual method names
var d = new Designer(); d.DesignArchitecture();
var p = new Programmer(); p.WriteCode();
var t = new Tester(); t.TestSoftware();
}
}
Every new employee type means editing Company: a new field, a new call, a new method name to learn.
The company class is tightly coupled to concrete classes of employees.
Generalise the work-related methods into one interface, then use polymorphism.
public interface IEmployee
{
void DoWork();
}
public class Designer : IEmployee
{
public void DoWork() => Console.WriteLine("Designing architecture...");
}
public class Programmer : IEmployee
{
public void DoWork() => Console.WriteLine("Writing code...");
}
public class Tester : IEmployee
{
public void DoWork() => Console.WriteLine("Testing software...");
}
public class Company
{
public void CreateSoftware()
{
var employees = new List<IEmployee>
{
new Designer(), new Programmer(), new Tester() // still coupled here!
};
foreach (var e in employees)
e.DoWork(); // polymorphism does the routing
}
}
Better — the work loop is now generic. But Company still news up concrete
classes, so it remains coupled to them. If we introduce new types of companies that work with other types of
employees (a game studio needs artists, an outsourcing firm needs only programmers and testers), we'd have to
override most of Company instead of reusing it.
Declare the employee-creating method abstract; each concrete company decides which
employees it needs.
public abstract class Company
{
// The stable, reusable business process — written ONCE
public void CreateSoftware()
{
foreach (var e in GetEmployees())
e.DoWork();
}
// The varying part: WHICH employees? Subclasses decide.
protected abstract IEnumerable<IEmployee> GetEmployees();
}
public class GameDevCompany : Company
{
protected override IEnumerable<IEmployee> GetEmployees()
=> new IEmployee[] { new Designer(), new Programmer(), new Tester() };
}
public class OutsourcingCompany : Company
{
protected override IEnumerable<IEmployee> GetEmployees()
=> new IEmployee[] { new Programmer(), new Tester() };
}
The base Company is now independent of all concrete employee classes. You can
extend it with new company types and new employee types, reusing the shared process, without touching the code
that existing clients already depend on.
Deferring "which object to create" to subclasses via an abstract creation method is the Factory Method pattern — chapter 3 covers it fully. Notice how naturally it emerged: we didn't "apply a pattern", we followed a principle until the pattern appeared.
Programming to an interface is also what makes code testable. Your tutorial's camera app takes any
ICamera — so a test can inject a MockCamera whose Capture() just records that it
was called, no hardware needed. The same trick works for payment gateways, databases, clocks and email servers.
This is everyday practice in .NET: nearly every service in ASP.NET Core (e.g. ILogger<T>,
HttpClient via IHttpClientFactory) is consumed through an interface for exactly this reason.
Principle 3 — Favour composition over inheritance
Inheritance is the most obvious way to reuse code — and the most expensive one to walk back. When two classes share code, the reflex is: make a common base class and move the shared code up. Easy. Unfortunately, inheritance comes with caveats that often become apparent only after your program has tons of classes and changing anything is hard. The slides list five:
| Problem with inheritance | What it means in practice |
|---|---|
| A subclass can't reduce the superclass's interface | You must implement all abstract members of the parent even if you'll never use them — hello, penguins forced to implement Fly(). |
| Overrides must stay behaviour-compatible | When you override a method, the new behaviour must fit what callers of the base expect. (This is the Liskov Substitution Principle — a whole section below.) |
| Inheritance breaks the superclass's encapsulation | Internal details of the parent (protected fields, call order between methods) become visible to — and relied upon by — subclasses. |
| Tight coupling to the superclass | Any change in a superclass may break subclasses. This is the "fragile base class" problem: the base can't evolve safely because unknown children depend on its internals. |
| Parallel hierarchies / class explosion | Inheritance works in one dimension. With two or more independent dimensions of variation you need a class for every combination — the hierarchy bloats to a ridiculous size. |
The combinatorial explosion, drawn
The slides' example: a catalogue app for a vehicle manufacturer. The company makes cars and trucks (cargo type); each can be electric or gas (engine); each has manual controls or autopilot (navigation). Model each combination as a subclass and count what happens:
Worse than the count: the same engine code is duplicated in ElectricCarAutopilot and
ElectricTruckManual — inheritance promised reuse and delivered copy-paste with extra steps.
The fix: delegate behaviours to component objects
Composition flips "is-a" into "has-a". Instead of a transport being an electric-autopilot-car, a transport has an engine and has a driver, and delegates behaviour to them (see delegation, chapter 1). Each dimension of variation becomes a small family of components:
// Any combination, assembled on demand — no subclass zoo:
var evTruck = new Transport(new ElectricEngine(), new Autopilot());
evTruck.Deliver("solar panels");
public interface IEngine { void Move(); }
public interface IDriver { void Navigate(); }
public class CombustionEngine : IEngine
{
public void Move() => Console.WriteLine("Burning gas, moving...");
}
public class ElectricEngine : IEngine
{
public void Move() => Console.WriteLine("Silently gliding on battery...");
}
public class HumanDriver : IDriver
{
public void Navigate() => Console.WriteLine("Human picks the route.");
}
public class Autopilot : IDriver
{
public void Navigate() => Console.WriteLine("AI computes optimal route.");
}
public class Transport
{
private IEngine _engine;
private IDriver _driver;
public Transport(IEngine engine, IDriver driver)
=> (_engine, _driver) = (engine, driver);
// Bonus: behaviours can be REPLACED AT RUNTIME —
// impossible with inheritance, where the class is fixed at construction.
public void SwapEngine(IEngine newEngine) => _engine = newEngine;
public void Deliver(string cargo)
{
_driver.Navigate();
_engine.Move();
Console.WriteLine($"Delivering {cargo}.");
}
}
Extracting each behaviour family behind an interface and plugging implementations into a host object is the Strategy pattern (chapter 5). The slides make the same reveal. Two principles in, and two of the most-used GoF patterns have already fallen out naturally.
Same disease, business flavour: the payment class explosion
Your tutorial (Tutorial 14) shows the identical problem in a payment system — worth seeing because it looks "enterprisey" rather than academic:
public abstract class Payment { /* ... */ }
public class CreditCardEmailPayment : Payment { /* ... */ }
public class CreditCardSmsPayment : Payment { /* ... */ }
public class PayPalEmailPayment : Payment { /* ... */ }
public class PayPalSmsPayment : Payment { /* ... */ }
// Business: "add Crypto payments and Slack notifications"
// → SIX new classes to cover the new combinations. And the
// email-sending code is already duplicated in two classes.
// "Add Crypto + Slack" = TWO small classes. Zero edits elsewhere.
var svc = new PaymentService(new PayPal(), new SmsNotifier());
svc.ProcessTransaction(250m);
public interface IPaymentMethod { string Authorize(decimal amount); }
public interface INotifier { void Send(string message); }
public class CreditCard : IPaymentMethod
{
public string Authorize(decimal amount) => $"Charged ${amount} to credit card.";
}
public class PayPal : IPaymentMethod
{
public string Authorize(decimal amount) => $"Charged ${amount} to PayPal.";
}
public class EmailNotifier : INotifier
{
public void Send(string message) => Console.WriteLine($"EMAIL: {message}");
}
public class SmsNotifier : INotifier
{
public void Send(string message) => Console.WriteLine($"SMS: {message}");
}
public class PaymentService
{
private readonly IPaymentMethod _method; // has-a
private readonly INotifier _notifier; // has-a
public PaymentService(IPaymentMethod method, INotifier notifier)
=> (_method, _notifier) = (method, notifier);
public void ProcessTransaction(decimal amount)
=> _notifier.Send(_method.Authorize(amount)); // delegation
}
CryptoPayment must
implement Authorize or it won't compile — C# enforces at compile time what Python's ABCs enforce
at instantiation time.Inheritance is still the right tool when there is a genuine, stable is-a relationship with shared state and behaviour, and the base class is designed for extension. The principle says: when you reach for inheritance purely to reuse code or to model combinations of features, reach for composition instead. Rule of thumb: inherit to be something, compose to use something.
SOLID — five principles, one goal
SOLID is a mnemonic for five design principles intended to make software designs more understandable, flexible and maintainable. They were popularised by Robert C. Martin; the L honours Barbara Liskov. You've already built the intuition for all five — the three principles above are their foundation.
Straight from the slides: using these principles mindlessly can cause more harm than good. The cost of applying them everywhere is a design more complicated than it needs to be — five interfaces wrapping a program that writes "Hello, world". Strive for them, but be pragmatic: apply a principle where you can articulate the change it protects you from. If you can't name the change, you're adding ceremony, not design.
S — Single Responsibility Principle
A class should have just one reason to change. Make every class responsible for a single part of the functionality, and make that responsibility entirely encapsulated by (hidden inside) the class. The main goal is reducing complexity — and containing change.
SRP is measured in reasons to change, not lines or methods. A class with twenty methods that all serve "render an invoice as HTML" has one responsibility. A class with three methods — calculate, print, save — has three, because three different stakeholders (accountants, designers, DBAs) can each force it to change. Ask: who can ask for a change to this class? Each distinct answer is a responsibility.
The slides' example: an Employee class that manages employee data and prints a timesheet
report. Two reasons to change: (1) the core job — managing employee data — evolves; (2) the timesheet report format
changes over time. Solution: move report printing into its own class. Your tutorial's bookstore invoice (Tutorial 19)
is the same disease with three symptoms — data + math, formatting, and persistence in one "God class":
public class Invoice
{
public string BookTitle { get; }
public int Quantity { get; }
public decimal Price { get; }
public Invoice(string title, int qty, decimal price)
=> (BookTitle, Quantity, Price) = (title, qty, price);
// Responsibility 1: business calculation
public decimal GetTotal() => Price * Quantity;
// Responsibility 2: presentation
public void PrintInvoice()
=> Console.WriteLine($"Invoice: {BookTitle} x {Quantity} = ${GetTotal()}");
// Responsibility 3: persistence
public void SaveToFile(string path)
=> File.WriteAllText(path, $"{BookTitle}, total {GetTotal()}");
}
Invoice. Move storage to the cloud? Edit
Invoice. Reuse the invoice data on a website without dragging in file I/O? You can't.// Reason to change: billing rules
public class Invoice
{
public string BookTitle { get; }
public int Quantity { get; }
public decimal Price { get; }
public Invoice(string title, int qty, decimal price)
=> (BookTitle, Quantity, Price) = (title, qty, price);
public decimal GetTotal() => Price * Quantity;
}
// Reason to change: how invoices are displayed
public class InvoicePrinter
{
public void PrintToConsole(Invoice inv)
{
Console.WriteLine("--- INVOICE ---");
Console.WriteLine($"Item: {inv.BookTitle}");
Console.WriteLine($"Total: ${inv.GetTotal()}");
}
}
// Reason to change: where/how invoices are stored
public class InvoicePersistence
{
public void SaveToFile(Invoice inv, string path)
=> File.WriteAllText(path, $"{inv.BookTitle}, total {inv.GetTotal()}");
}
InvoicePersistence.
The other two classes don't even need re-testing.ASP.NET Core splits "handle the request" (Controller), "log what happened"
(ILogger), "serialise the response" (JsonSerializer) and "store the data"
(your repository/EF Core DbContext) into separate types — each swappable and testable alone.
A controller that formats its own JSON and opens its own SQL connections is the textbook SRP violation.
O — Open/Closed Principle
Classes should be open for extension but closed for modification. The main idea: when you implement a new feature, don't break — or even touch — existing code. A class is open if you can extend it: subclass it, override behaviour, or plug new components into it. A class is closed (think "complete", like a shipped API) if it's ready to be used by others: its interface is stable. A class can be both at the same time — that's the goal.
If a class is already developed, tested, reviewed and shipped inside an app or framework, editing its code is risky: every line you change can break every client that depends on it, and you must re-verify all of them. Extension adds new code alongside old code — the old code's guarantees survive untouched. OCP converts "risky edits to a load-bearing wall" into "hang a new picture on it".
Straight from the slides: if you know there's a bug in a class, just go and fix it — don't create a subclass to patch around it. A child class shouldn't be responsible for the parent's issues. OCP is about new features, not about freezing broken code.
For the exam:
OCP means you add features by extension, never by modifying shipped code, so existing behaviour is safe.
In practice: OCP shrinks the blast radius and the re-testing burden of a change — it
doesn't make regressions impossible or eliminate the need for regression/integration testing. Untouched files
can still misbehave if they share mutable state with the new extension, if the new extension is itself buggy in
a way that corrupts shared data, or if the abstraction being extended was wrong in the first place. "Nobody
edited Order.cs" is strong evidence nothing there regressed — it isn't a proof that nothing
regressed anywhere.
The slides' scenario: an e-commerce Order class that calculates shipping cost with all shipping
methods hard-coded inside. Adding a new shipping method means changing Order — and risking every
order-related feature in the app:
public class Order
{
public List<LineItem> Items { get; } = new();
public string ShippingMethod { get; set; } = "ground";
public decimal GetTotalWeight() => Items.Sum(i => i.Weight);
public decimal GetShippingCost() => ShippingMethod switch
{
// Every new carrier = reopen this tested, shipped class
"ground" => Math.Max(5m, GetTotalWeight() * 0.5m),
"air" => Math.Max(10m, GetTotalWeight() * 1.5m),
_ => throw new ArgumentException("Unknown method")
};
}
GetShippingCost, re-test every existing carrier, and pray you didn't fat-finger
the air-freight rate while you were in there.public interface IShipping
{
string Name { get; }
decimal GetCost(Order order);
}
public class GroundShipping : IShipping
{
public string Name => "Ground";
public decimal GetCost(Order o) => Math.Max(5m, o.GetTotalWeight() * 0.5m);
}
public class AirShipping : IShipping
{
public string Name => "Air";
public decimal GetCost(Order o) => Math.Max(10m, o.GetTotalWeight() * 1.5m);
}
// NEW FEATURE — a new file. No existing file was opened.
public class DroneShipping : IShipping
{
public string Name => "Drone";
public decimal GetCost(Order o) => 15m + o.GetTotalWeight() * 2m;
}
public class Order
{
public List<LineItem> Items { get; } = new();
public IShipping Shipping { get; } // plug-in point — required, not an afterthought
public Order(IShipping shipping) => Shipping = shipping;
public decimal GetTotalWeight() => Items.Sum(i => i.Weight);
public decimal GetShippingCost() => Shipping.GetCost(this);
}
Order is closed (its code never changes again for this axis) yet
open (any future carrier plugs in). Note Shipping is { get; }, not
{ get; set; } — a shipping method is required to construct an Order at all, so
there's no window where Shipping is null and GetShippingCost() can throw a
NullReferenceException. The UI selects a carrier and passes it into the constructor of
whichever class the user chose.Order holds an open diamond (aggregation) to the IShipping contract; concrete carriers implement it with dashed arrows.Order.GetShippingCost() method — and this simulation (realistically!) sneaks a regression
into the Air rate while you're in there. Ship an Air parcel after adding Drone and compare. In the
Strategy version the new carrier is a new class: existing carriers' files are never opened,
so that particular kind of regression can't happen — OCP shrinks the blast radius of the change, it doesn't
guarantee the feature is bug-free. Switching modes resets the scenario.Plugin architectures are OCP at scale: VS Code and Photoshop are "closed" apps you extend with new
packages. In .NET specifically: ASP.NET Core middleware (add a pipeline step without touching the
pipeline), JsonConverters plugged into JsonSerializerOptions, LINQ extension methods
extending IEnumerable<T> without modifying it.
L — Liskov Substitution Principle
When extending a class, you should be able to pass objects of the subclass anywhere the parent class is expected — without breaking the client code. Named for Barbara Liskov, who formalised it in 1987. In plain terms: the subclass must remain behaviour-compatible with the superclass. When you override a method, extend the base behaviour; don't replace it with something that surprises callers.
The compiler happily accepts any subclass — Penguin : Bird compiles beautifully. LSP is about the
promises the base class makes that the type system can't see: "Withdraw reduces the balance",
"Fly doesn't throw", "changing Width leaves Height alone". Client code is written against those promises.
A subclass that breaks one turns every polymorphic call site into a landmine — code that worked for years
explodes the day someone adds a new subclass, in a file nobody changed.
A substitute teacher. The class expects "a teacher": someone who takes attendance, teaches the lesson, sets homework. A substitute who teaches a different subject in a different style is fine — that's overriding. A substitute who locks the door and refuses to teach ("this class doesn't support lessons") breaks the school, because everything downstream — timetables, parents, exams — assumed the teacher contract.
The one-sentence test
Whenever you create a subclass, ask:
"If I swap every instance of the parent class in the entire program for this subclass, will the program still work perfectly — without changing a single other line?" Yes → LSP holds. No → you have an "is-a" relationship in English, but not in software design.
The motivating crash: BankAccount vs FixedDepositAccount
From your tutorial (Tutorial 22): a bank has accounts with a Withdraw method. Then the bank
introduces fixed deposits — accounts that don't allow withdrawal until maturity. The "obvious"
model:
public class BankAccount
{
public decimal Balance { get; protected set; }
public BankAccount(decimal opening) => Balance = opening;
public virtual void Withdraw(decimal amount)
{
Balance -= amount;
Console.WriteLine($"Withdrew ${amount}. New balance: ${Balance}");
}
}
public class SavingsAccount : BankAccount
{
public SavingsAccount(decimal opening) : base(opening) { }
}
public class FixedDepositAccount : BankAccount
{
public FixedDepositAccount(decimal opening) : base(opening) { }
public override void Withdraw(decimal amount)
// VIOLATION: the subclass "disables" an inherited operation
=> throw new InvalidOperationException(
"Withdrawals are NOT allowed from fixed deposits!");
}
Now somebody — possibly in a different team, months later — writes perfectly reasonable client code against the base class:
public static void ProcessEndOfMonthFees(List<BankAccount> accounts)
{
foreach (var acc in accounts)
acc.Withdraw(100m); // 💥 crashes the batch job the day
// a FixedDepositAccount enters the list
}
Nothing in ProcessEndOfMonthFees is wrong. Nothing in it ever changed. It still dies in production.
That's the signature of an LSP violation: the crime and the crash are in different files.
The 7-point substitution checklist
The slides give a concrete checklist for "behaviour-compatible". A subclass method, compared with the base method it overrides, must satisfy all seven. The memory hook for the first two: inputs may loosen, outputs may tighten — never the reverse.
1. Parameter types: match or be more abstract (contravariance)
If the base method accepts a Cat, an override that effectively accepts any
Animal is safe — it can handle everything clients already send, plus more. An override that only
accepts BengalCat is dangerous — clients sending ordinary cats now fail.
class Trainer
{
// Base promise: "give me any Cat"
public virtual void Feed(Cat cat) => cat.Eat();
}
class PickyTrainer : Trainer
{
// C# forces the override signature to match — but you can still
// violate the RULE at runtime by rejecting inputs the base accepted:
public override void Feed(Cat cat)
{
if (cat is not BengalCat) // ❌ demands MORE
throw new ArgumentException("Bengals only!");
cat.Eat();
}
}
Contravariant = may vary in the opposite direction of the inheritance arrow (parameters may get more general in a subclass). Covariant = may vary in the same direction (return types may get more specific). C# checks override signatures for you, so in C# this rule usually shows up as the runtime type-check shown above — legal to the compiler, still an LSP violation.
2. Return type: match or be a subtype (covariance)
Requirements for return types are the inverse of parameter types. The client says
Cat c = shelter.BuyCat(); — anything that is a cat keeps that line working:
class CatShelter
{
public virtual Cat BuyCat() => new Cat();
}
class BengalShelter : CatShelter
{
// ✅ Legal since C# 9: covariant return. A BengalCat IS a Cat,
// so every existing caller still gets what it was promised.
public override BengalCat BuyCat() => new BengalCat();
}
// ❌ Returning something MORE GENERAL (e.g. plain object / an Animal
// that may not be a Cat) would break callers expecting Cat members.
3. No new exception types
Exceptions thrown by the override should match, or be subtypes of, the ones the base method already throws —
because try/catch blocks in client code target the base method's documented exceptions. A brand-new
exception type slips past every existing catch and crashes the application:
class BankAccount
{
// Documented contract: throws InsufficientFundsException only
public virtual void Withdraw(decimal amount)
{
if (amount > Balance) throw new InsufficientFundsException();
Balance -= amount;
}
}
class FixedDepositAccount : BankAccount
{
public override void Withdraw(decimal amount)
=> throw new DepositLockedException(); // ❌ new type — client's
// catch (InsufficientFundsException)
// never sees it coming
}
Java has checked exceptions, so the compiler polices this rule (its Exception hierarchy defines what
overrides may throw). C# has no checked exceptions — the rule is entirely your responsibility. Document throwable
exceptions with ///<exception> comments and honour them in overrides.
4. Don't strengthen pre-conditions
A pre-condition is what must be true before a method is called (jargon: the method's demands
on its caller). The base method sets the bar; a subclass may lower it, never raise it. Slide example: the base accepts
any int; the override throws on negatives. Client code that happily passed negative numbers for years
now breaks — but only when it happens to receive the subclass:
class SavingsAccount : BankAccount
{
// Base precondition: 0 < amount ≤ Balance
public override void Withdraw(decimal amount) { /* same as base */ }
}
class PremiumAccount : BankAccount
{
public override void Withdraw(decimal amount)
{
if (amount < 100m) // ❌ STRONGER demand:
throw new ArgumentException( // base allowed $20,
"Minimum withdrawal is $100."); // this rejects it
Balance -= amount;
}
}
5. Don't weaken post-conditions
A post-condition is what the method guarantees is true after it returns (its promises). A subclass may promise more, never less. Slide example: a base method that works with a database always closes its connections before returning. A subclass keeps connections open "for reuse". The client, trusting the base promise, terminates right after the call — and litters the system with ghost connections:
class ReportJob
{
public virtual void Run()
{
var conn = OpenConnection();
try { /* ... write report ... */ }
finally { conn.Close(); } // promise: nothing left open
}
}
class PooledReportJob : ReportJob
{
public override void Run()
{
var conn = OpenConnection();
/* ... write report ... */
// ❌ keeps conn open for "efficiency" — a WEAKER guarantee.
// Clients that exit immediately afterwards leak connections.
}
}
6. Preserve the superclass's invariants
An invariant is a condition that must hold for the object's entire lifetime — often unwritten: "a cat has four legs", "Balance never goes negative", "changing a rectangle's width doesn't touch its height". Invariants are the sneakiest rule because many live only in the original author's head. The safest override adds behaviour without touching existing state. The classic violation is Rectangle/Square — expanded in the deep-dive below.
7. Don't change state the base class wouldn't allow
Closely related to invariants: a subclass shouldn't mutate the superclass's private/internal state in ways the base's own methods never would. Some languages let you do it anyway — reflection in C#/Java, or Python and JavaScript where "private" is a naming convention with no enforcement. The fact that you can reach the field doesn't make the resulting object substitutable:
class SavingsAccount
{
protected decimal _balance; // base's own methods
public decimal Balance => _balance; // NEVER let it go below 0
public virtual void Withdraw(decimal a)
{
if (a > _balance) throw new InsufficientFundsException();
_balance -= a;
}
}
class OverdraftAccount : SavingsAccount
{
public override void Withdraw(decimal a)
=> _balance -= a; // ❌ can drive _balance negative — a state
// no base-class method could ever produce.
// Every client assuming Balance ≥ 0 breaks.
}
| Aspect | Parent class | LSP-compliant subclass |
|---|---|---|
| Input (arguments) | Accepts X | Accepts X (or more) |
| Output (return) | Returns Y | Returns Y (or more specific) |
| Exceptions | Throws ErrorA | Throws ErrorA, a subtype of it, or nothing new |
| Pre-conditions | Demands P | Demands P (or less) |
| Post-conditions | Guarantees Q | Guarantees Q (or more) |
| State / invariants | Maintains property Z | Maintains property Z |
Two fast smell tests (from your tutorial)
NotSupportedException/NotImplementedException? Then the subclass can't honour the
contract, and the method shouldn't be in that contract at all. Penguin.Fly() throwing is the
canonical example. Fix: move the ability to a narrower type (IFlyingBird) — which is also the
Interface Segregation Principle knocking.
if (doc is ReadOnlyDocument) ... else doc.Save()? Type checks are the client
defending itself against non-substitutable subclasses. If you must ask what something really is before trusting
it, polymorphism has already failed — and every new subclass will force another edit to that client
(an OCP violation stacked on top of the LSP one).
Worked example A — the read-only document (slides, checklist rule 7)
The slides close the checklist with this scenario: a document hierarchy where ReadOnlyDocument
subtracts an ability instead of adding one.
public class Document
{
public string Data { get; set; } = "";
public virtual void Open() => Console.WriteLine("Opening...");
public virtual void Save() => Console.WriteLine("Saving...");
}
public class ReadOnlyDocument : Document
{
public override void Save()
=> throw new NotSupportedException("Can't save a read-only file.");
}
public class Project
{
public List<Document> Documents { get; } = new();
public void SaveAll()
{
foreach (var doc in Documents)
if (doc is not ReadOnlyDocument) // client forced to type-check!
doc.Save();
}
}
SaveAll must change again.// The BASE class promises only what EVERY document can do
public class Document
{
public string Data { get; protected set; } = "";
public void Open() => Console.WriteLine("Opening...");
}
// The subclass EXTENDS behaviour by adding the save ability
public class WritableDocument : Document
{
public void Save() => Console.WriteLine("Saving...");
}
public class Project
{
public List<Document> AllDocs { get; } = new();
public List<WritableDocument> WritableDocs { get; } = new();
public void SaveAll()
{
foreach (var doc in WritableDocs) // no type checks —
doc.Save(); // everything here CAN save
}
}
Worked example B — fixing the bank hierarchy
Apply the same move to the fixed-deposit crash from earlier. FixedDepositAccount can't honour the
Withdraw contract, so it must not inherit it. Split the hierarchy by capability:
public abstract class Account
{
public decimal Balance { get; protected set; }
protected Account(decimal opening) => Balance = opening;
public void Deposit(decimal amount) => Balance += amount;
}
public abstract class WithdrawableAccount : Account
{
protected WithdrawableAccount(decimal opening) : base(opening) { }
public abstract void Withdraw(decimal amount);
}
public class SavingsAccount : WithdrawableAccount
{
public SavingsAccount(decimal opening) : base(opening) { }
public override void Withdraw(decimal amount)
{
if (amount > Balance) throw new InsufficientFundsException();
Balance -= amount;
}
}
public class FixedDepositAccount : Account // note: NOT withdrawable
{
public FixedDepositAccount(decimal opening) : base(opening) { }
public void AddInterest() => Balance *= 1.05m;
}
// The batch job now DECLARES what it needs — the compiler enforces LSP:
public static void ProcessEndOfMonthFees(List<WithdrawableAccount> accounts)
{
foreach (var acc in accounts)
acc.Withdraw(100m); // cannot crash: fixed deposits can't
} // even enter this list
The crash didn't just get handled — it became unwritable. Passing a fixed deposit to the batch
job is now a compile-time error instead of a 2 a.m. production incident. (Here an abstract class is the
right abstraction rather than an interface, because accounts share state — Balance — and shared logic
like Deposit.)
Deep dive: the Rectangle/Square problem (invariants, rule 6)
Mathematically a square is a rectangle, so this looks obviously right:
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
public class Square : Rectangle
{
public override int Width
{
get => base.Width;
set { base.Width = value; base.Height = value; } // keep it square
}
public override int Height
{
get => base.Height;
set { base.Height = value; base.Width = value; }
}
}
Each class is internally consistent. The break is in client code that relies on the rectangle's unwritten invariant — setting Width does not change Height:
static void StretchToTwenty(Rectangle r)
{
r.Width = 4;
r.Height = 5;
// Every Rectangle ever: area is 20.
Console.WriteLine(r.Area); // Rectangle → 20 Square → 25 💥
}
Pass a Square: setting Height = 5 silently rewrote Width to 5, so the area is 25.
No exception, no compiler warning — just wrong numbers flowing downstream, which is worse than a crash.
The lesson: "is-a" must be judged against the base class's behavioural contract (including its
invariants), not against English or geometry. Squares-as-rectangles only works for immutable shapes (no
setters, so the invariant can't be violated) — otherwise favour composition, or keep the types separate.
A batch job written against BankAccount runs
acc.Withdraw(50) on a $200 account. Choose how a new subclass behaves, run the client, and watch
which checklist rule breaks:
System.IO.Stream is a giant base class whose subclasses can't all do everything —
NetworkStream.Seek() throws NotSupportedException. Sounds familiar? .NET mitigates the LSP
problem with capability flags (CanSeek, CanWrite) that clients must consult first.
It works, but note the cost: every caller carries if (s.CanSeek) checks — exactly the type-check smell.
A collection example done better: read-only collections implement IReadOnlyList<T>, a
separate, narrower interface, instead of throwing from Add… although Array implementing
IList<T> and throwing on Add shows the old sin too. Real frameworks make trade-offs;
now you can name the trade-off.
I — Interface Segregation Principle
Clients shouldn't be forced to depend on methods they don't use. Make interfaces narrow enough that implementing classes never have to write "dummy" methods for behaviour they don't have. There's no need to cram unrelated methods into one interface — break it into several refined ones. A class can implement as many interfaces as it likes (unlike class inheritance, which in C# allows only one base class), so small interfaces cost nothing to recombine.
A restaurant hands you one menu per course, not a 400-page binder that also includes the supplier contracts, the staff rota and the fire-safety manual. You depend on exactly the pages you need. A "fat interface" is the binder: every reader is coupled to every page, and reprinting any page means redistributing the binder to everyone.
Your tutorial's scenario (Tutorial 23): office equipment. High-end machines print, scan and fax, so the first design starts with one "multi-function device" interface. Then a humble home printer shows up:
public interface IMultiFunctionDevice
{
void PrintDocument(string doc);
void ScanDocument(string doc);
void FaxDocument(string doc);
}
public class OfficeSuperPrinter : IMultiFunctionDevice
{
public void PrintDocument(string doc) => Console.WriteLine("Printing...");
public void ScanDocument(string doc) => Console.WriteLine("Scanning...");
public void FaxDocument(string doc) => Console.WriteLine("Faxing...");
}
public class SimpleHomePrinter : IMultiFunctionDevice
{
public void PrintDocument(string doc) => Console.WriteLine("Printing at home...");
// Forced to "implement" hardware it doesn't have:
public void ScanDocument(string doc)
=> throw new NotSupportedException("No scanner installed.");
public void FaxDocument(string doc)
=> throw new NotSupportedException("It's 2026. No fax.");
}
public interface IPrinter { void PrintDocument(string doc); }
public interface IScanner { void ScanDocument(string doc); }
public interface IFax { void FaxDocument(string doc); }
public class SimpleHomePrinter : IPrinter
{
public void PrintDocument(string doc) => Console.WriteLine("Printing at home...");
}
public class Photocopier : IPrinter, IScanner
{
public void PrintDocument(string doc) => Console.WriteLine("Copy-printing...");
public void ScanDocument(string doc) => Console.WriteLine("Copy-scanning...");
}
public class OfficeSuperPrinter : IPrinter, IScanner, IFax
{
public void PrintDocument(string doc) => Console.WriteLine("Printing...");
public void ScanDocument(string doc) => Console.WriteLine("Scanning...");
public void FaxDocument(string doc) => Console.WriteLine("Faxing...");
}
// The CLIENT declares exactly what it needs — nothing more:
public static void ArchiveContract(IScanner scanner)
=> scanner.ScanDocument("Contract.pdf");
// ArchiveContract(new SimpleHomePrinter()); ← compile error.
// The impossible operation became impossible to request.
IMultiFunctionDevice forcing all three methods on everyone.All three fight "too much in one place", from different angles. SRP is about a class
having one reason to change. ISP is about an interface serving one kind of client.
LSP is what breaks when you ignore ISP: fat interfaces force partial implementers to throw
NotSupportedException, which is the LSP no-op/exception violation. Segregate the interface and the LSP
problem evaporates — remember the WithdrawableAccount fix, which was exactly this move.
The collection interfaces are a masterclass: IEnumerable<T> (just iterate) ⊂
IReadOnlyCollection<T> (+ count) ⊂ IReadOnlyList<T> (+ index), with
ICollection<T>/IList<T> adding mutation separately. A method that only loops
should ask for IEnumerable<T> — then every collection, array, or LINQ query can be passed
in. Likewise IDisposable holds exactly one method. Your tutorial's TravelAlert capstone applies the
same idea: read-receipt tracking lives in a separate ITrackable so SmsService isn't forced
to fake it.
D — Dependency Inversion Principle
The final piece of the puzzle. Two statements, straight from the slides:
- High-level modules shouldn't depend on low-level modules. Both should depend on abstractions.
- Abstractions shouldn't depend on details. Details should depend on abstractions.
Jargon first. Low-level classes implement basic operations: talking to a disk, transferring data over a network, connecting to a database. High-level classes contain the business logic that directs low-level classes to do something — the code that embodies what your application is actually for. An abstraction here means an interface or abstract class describing an operation in business terms ("get me a temperature", "notify the customer") rather than implementation terms ("run this SQL").
The natural flow of development: you build low-level pieces first (you can't test business logic without a database, right?), then write business logic on top, calling the low-level classes directly. This is especially common when prototyping a new system, when you're not even sure what's possible at the higher level yet. Result: your most valuable code — the business rules — depends on your most replaceable code. Swap MySQL for Postgres, or email for SMS, and the business logic has to change. DIP inverts the arrow: the high level defines an interface expressing what it needs, and the low-level classes adapt to it. The details now depend on the policy, not the other way round.
Your tutorial's weather reporter (Tutorial 25) shows both directions of the arrow:
public class LocalDatabase
{
public double GetTemperature() => 22.0; // low-level detail
}
public class EmailService
{
public void Send(string msg) => Console.WriteLine($"EMAIL: {msg}");
}
public class WeatherReporter
{
// Hard-coded dependencies, created INSIDE the class
private readonly LocalDatabase _source = new();
private readonly EmailService _notifier = new();
public void Report()
{
double t = _source.GetTemperature();
_notifier.Send($"Current temperature is {t}°C");
}
}
WeatherReporter — the high-level class pays for
every low-level change.// Wiring — the ONLY place that knows concrete types:
var reporter = new WeatherReporter(new CloudWeatherApi(), new SmsService());
reporter.Report();
// Abstractions — defined in terms of what the HIGH level needs
public interface IWeatherSource { double GetTemperature(); }
public interface INotifier { void Send(string message); }
// Details now depend on (implement) the abstractions
public class LocalDatabase : IWeatherSource
{
public double GetTemperature() => 22.0;
}
public class CloudWeatherApi : IWeatherSource
{
public double GetTemperature() => 28.0; // satellite feed
}
public class EmailService : INotifier
{
public void Send(string msg) => Console.WriteLine($"EMAIL: {msg}");
}
public class SmsService : INotifier
{
public void Send(string msg) => Console.WriteLine($"SMS: {msg}");
}
public class WeatherReporter
{
private readonly IWeatherSource _source;
private readonly INotifier _notifier;
// DEPENDENCY INJECTION: the tools are handed in from outside
public WeatherReporter(IWeatherSource source, INotifier notifier)
=> (_source, _notifier) = (source, notifier);
public void Report()
=> _notifier.Send($"Current temperature is {_source.GetTemperature()}°C");
}
FakeSource returning −50° without touching any real service.A subtle but crucial point from the tutorial: in DIP, the high-level module defines the interface
("here's what I need: something switchable / a temperature source"), and low-level modules adapt to it. Think of a
USB port: the computer (high level) defines the socket; every gadget (low level) shapes itself to fit. That's what
distinguishes DIP from plain "program to an interface" — it also dictates the interface's owner and direction.
The smart-home tutorial does this literally: Switch needs on/off, so it owns
ISwitchable, and LightBulb and Fan conform — the same switch then toggles anything.
Three related terms, often mashed together. DIP (Dependency Inversion Principle) is the
design rule: depend on abstractions. DI (Dependency Injection) is a technique for
obeying it: hand dependencies in through the constructor instead of new-ing them inside.
IoC container (Inversion of Control container) is a tool that automates DI: in ASP.NET Core
you write builder.Services.AddScoped<IWeatherSource, CloudWeatherApi>() once, and the framework
constructs and injects everything. You can satisfy DIP with plain constructors and no container at all —
the principle is about the direction of dependencies, not the machinery.
Everywhere. ASP.NET Core is built around constructor injection and IServiceCollection;
ILogger<T>, IConfiguration, IHttpClientFactory are abstractions your
high-level code depends on while the framework wires the details. Entity Framework's DbContext can be
swapped for an in-memory provider in tests for the same reason. The "Hollywood Principle" — don't call us,
we'll call you — describes the resulting flow: your class doesn't fetch its tools; the tools find it.
All five, one story
Run the TravelAlert capstone from your tutorial through the whole alphabet: the AlertCoordinator only
coordinates (S); adding SlackService touches no existing file (O); every
IMessageService honours Send so the coordinator's loop never breaks (L);
read-receipt tracking sits in a separate ITrackable so SMS isn't forced to fake it (I);
and the coordinator sees only IDataSource/IMessageService/ILogger
(D). Five principles, but really one instinct applied five ways: protect what's stable from
what changes, through small, honest contracts.
From principles to patterns
You've now watched the same thing happen twice: following "encapsulate what varies" produced Strategy, and following "program to an interface" produced Factory Method. That's not a coincidence — it's the whole idea. A design pattern is a general, reusable solution to a commonly occurring problem in software design.
Three clarifications the slides insist on, because each kills a common misconception:
- A pattern is not a finished design you can transform directly into code. It's a description or
template for how to solve a problem, usable in many different situations. There is no
Strategy.csto download; there's a shape you re-create in your own domain. - Patterns deal with problems at the level of software design — how classes and objects relate. Not every software pattern is a design pattern (there are also architectural patterns, chapter 8, and idioms at the code level, chapter 7).
- Algorithms are not design patterns. Quicksort solves a computational problem (given input, produce output efficiently). Patterns solve design problems (how do I add carriers without editing Order?). An algorithm is a recipe; a pattern is a floor plan.
The design challenge patterns answer
Recall the tension from the top of this page. A design should be specific to the problem at hand, yet general enough to address future problems and requirements — and you want to avoid redesign, or at least minimise it. How do expert designers manage that balancing act? The slides' answer is disarmingly honest: they reuse solutions that have worked for them in the past. When they find a good solution, they use it again and again. Patterns are simply that expert experience, written down, named, and organised — so you don't need ten years of scar tissue to design like someone who has it.
The four essential elements of a pattern
Every properly documented pattern (in the Gang of Four catalogue and in chapters 3–5 of this site) has four parts:
Two axes of classification
By purpose — what kind of design problem the pattern attacks. This gives the three families that structure chapters 3–5:
| Purpose | Deals with… | Patterns (→ chapter) |
|---|---|---|
| Creational | Object creation mechanisms — making systems independent of how their objects are created, composed, represented. | Chapter 3: Factory Method, Abstract Factory, Builder, Prototype, Singleton |
| Structural | The composition of classes and objects into larger structures. | Chapter 4: Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy |
| Behavioural | How classes and objects interact and distribute responsibility. | Chapter 5: Chain of Responsibility, Command, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor, Interpreter |
By scope — the axis everyone skips, so let's not. Scope specifies whether the pattern applies primarily to classes or to objects:
Factory Method Adapter (class form) Template Method Interpreter
SwapEngine() on the composed Transport? That's the object-scope superpower.
Almost everything else — most patterns are object-scoped, for exactly the composition-over-inheritance reasons this chapter taught.
Almost all patterns use inheritance somewhere (usually to define interfaces/abstract classes), so the "class pattern" label is reserved for the few whose core mechanism is a class relationship. Crossing the two axes, the slides note how each purpose splits by scope:
| Class scope (inheritance, compile-time) | Object scope (composition, run-time) | |
|---|---|---|
| Creational | Defer part of object creation to subclasses (Factory Method — your Stage-3 Company). |
Defer creation to another object (Abstract Factory, Builder, Prototype, Singleton). |
| Structural | Use inheritance to compose classes (class Adapter, via multiple interface inheritance). | Describe ways to assemble objects (Decorator wrapping a stream, Composite trees…). |
| Behavioural | Use inheritance to describe algorithms and control flow (Template Method, Interpreter). | Describe how a group of objects cooperates to do what no single object can (Observer, Mediator, Strategy…). |
Patterns relate to each other
The GoF book ends with a famous "pattern relationships" map, and you'll feel it constantly in chapters 3–5: patterns chain (a principle's fix becomes a pattern's starting point — encapsulate-what-varies grows into Strategy), collaborate (a Composite is often built by a Builder, traversed by an Iterator, operated on by a Visitor), and rhyme (Strategy, State and Command have near-identical UML but totally different intents — which is why chapter 12 exists to compare the confusable ones side by side). Learn each pattern's intent, not its class diagram; diagrams repeat, intents don't. Chapter 6 then shows many patterns cooperating inside one document-editor case study.
Next: Chapter 3 — Creational patterns, starting with Factory Method (which you've already half-learned today). Then Chapter 4 — Structural and Chapter 5 — Behavioural (say hello to Strategy again), Chapter 6 to see them fused, and Chapter 12 when they start blurring together. Keep this page's principles in your pocket throughout: every pattern you're about to meet is just one of these principles, crystallised against a specific recurring problem.
End-of-chapter quiz
Eight scenarios. For each, identify the principle (or concept) at play — the skill the exam, and real code reviews, actually test.
Solid on all of it? (Pun fully intended.) Then you're ready for the catalogue: Chapter 3 — Creational Patterns. If any principle still feels wobbly, the fastest fix is to revisit its interactive demo above and predict each log line before clicking.