← Open in the full interactive course (progress tracking, search & more)

Five letters, one goal: code that can be changed safely, over and over, for years.

Picture a class called OrderManager. Eighteen months ago it validated an order and saved it to the database. Today it also calculates tax, applies discounts, sends confirmation emails, logs to a file, and — added last sprint — talks to a shipping API. Nobody planned this. Each change was small and reasonable on its own. But now the class is 900 lines long, three different teams touch it for three unrelated reasons, and every deploy carries a faint dread: what did I just break?

This is not a rare, unlucky codebase. It is the default outcome of software that changes over time without deliberate structural discipline. SOLID is a set of five design principles — named with the acronym S-O-L-I-D — that exist specifically to fight this decay. They were assembled by Robert C. Martin from ideas developed across the object-oriented design community (Bertrand Meyer's work on Open/Closed, Barbara Liskov's substitution rule among them), and they've held up for decades because they target the one thing every real system has in common: it will change, and the question is only whether changing it stays safe.

Two of the five — Interface Segregation and Dependency Inversion — already got full, dedicated lessons back in Lessons 079 and 081. This lesson brings all five together for the first time, goes deep on the three that haven't had their own lesson yet — Single Responsibility, Open/Closed, and Liskov Substitution — and, most importantly, shows you why all five are really the same idea wearing five different outfits.

In this lesson, you'll learn what each SOLID principle actually says (precisely, not the watered-down version), see a genuine before/after for S, O, and L with real code, briefly recap I and D in their proper place, and understand why "SOLID" is best understood as a single, unified answer to a single question: how do you build software that stays easy to change?

What Is It?

The Simple Explanation

SOLID is five rules of thumb for shaping classes and their relationships so that adding a feature, fixing a bug, or swapping an implementation doesn't force you to rewrite — or risk breaking — code that already works. Each letter names one rule:

S — Single Responsibility

O — Open/Closed

L — Liskov Substitution

I — Interface Segregation Lesson 079

D — Dependency Inversion Lesson 081

Together

The Technical Definition

Single Responsibility Principle (SRP): a class or module should have only one reason to change. Not "one method," not "does one tiny thing" — one reason, meaning one axis of change, one stakeholder or business concern whose requirements would drive an edit to this code.

Open/Closed Principle (OCP): software entities (classes, modules, functions) should be open for extension but closed for modification. You should be able to add new behavior without editing, recompiling, and re-testing code that already works and is already deployed.

Liskov Substitution Principle (LSP): if S is a subtype of T, objects of type T in a program should be replaceable with objects of type S without altering the correctness of that program. A subtype must honor the behavioral contract of its base type, not merely match its method signatures.

Interface Segregation Principle (ISP) and Dependency Inversion Principle (DIP) already have their own full lessons — Lesson 079 and Lesson 081 — so this lesson won't re-teach them from scratch. Quick recap of each, for completeness:

Why Does It Exist?

The Problem — Software Rots Under Its Own Changes

Every real system evolves. New requirements arrive; old ones change shape; the business finds a new way to make money that nobody designed for. Code that isn't deliberately structured to absorb change accumulates what practitioners call rigidity (one change forces ten more), fragility (a change in one place breaks something unrelated), and immobility (you can't reuse a piece because it's tangled into everything around it). None of that shows up on day one. It shows up eighteen months in, exactly like the OrderManager in the hook.

The Solution — Five Targeted Countermeasures

Each SOLID principle is a countermeasure aimed at one specific failure mode:

Five different symptoms; one underlying disease. Keep that in mind — it's where this lesson ends up.

Big Picture

HOW THE FIVE PRINCIPLES POINT AT ONE GOAL
S — SINGLE RESPONSIBILITY
+
O — OPEN/CLOSED
+
L — LISKOV SUBSTITUTION
+
I — INTERFACE SEGREGATION
+
D — DEPENDENCY INVERSION
RESULT

How It Works — Single Responsibility Principle

The most common misreading of SRP is "a class should do one thing" — taken so literally that people start splitting classes down to a single method out of guilt. That's not the principle. The actual definition is about reasons to change, also called axes of change or stakeholders. A class that calculates payroll, formats a payslip PDF, and emails it has three reasons to change: payroll rules change (finance), the PDF layout changes (design), the email provider changes (infrastructure). Three unrelated teams can each force an edit to the same class — and each one risks breaking the other two concerns by accident.

Before — One Class, Three Reasons to Change

public class PayslipService
{
    public decimal CalculateNetPay(Employee employee)
    {
        // Reason to change #1: finance changes tax bands, deduction rules...
        var gross = employee.BaseSalary + employee.Bonus;
        var tax = gross * 0.22m;
        return gross - tax;
    }

    public string FormatPayslip(Employee employee, decimal netPay)
    {
        // Reason to change #2: design wants a new payslip layout...
        return $"Payslip for {employee.Name}\nNet Pay: {netPay:C}\n---";
    }

    public void EmailPayslip(Employee employee, string payslipText)
    {
        // Reason to change #3: infrastructure switches email providers...
        var smtp = new System.Net.Mail.SmtpClient("smtp.internal.local");
        smtp.Send("payroll@company.com", employee.Email, "Your Payslip", payslipText);
    }
}

Every one of these three concerns can change independently, for unrelated reasons, on unrelated schedules — yet they all live in one class, sharing one set of fields, one set of tests, one blast radius.

After — Split Along Genuine Reasons to Change

public class PayrollCalculator
{
    // Only changes when finance changes pay rules.
    public decimal CalculateNetPay(Employee employee)
    {
        var gross = employee.BaseSalary + employee.Bonus;
        var tax = gross * 0.22m;
        return gross - tax;
    }
}

public class PayslipFormatter
{
    // Only changes when the payslip layout changes.
    public string Format(Employee employee, decimal netPay) =>
        $"Payslip for {employee.Name}\nNet Pay: {netPay:C}\n---";
}

public class PayslipMailer(ISmtpClient smtp)
{
    // Only changes when the email/delivery mechanism changes.
    public void Send(Employee employee, string payslipText) =>
        smtp.Send("payroll@company.com", employee.Email, "Your Payslip", payslipText);
}

// A thin orchestrator wires the pieces together — but owns none of their logic.
public class PayslipWorkflow(PayrollCalculator calculator, PayslipFormatter formatter, PayslipMailer mailer)
{
    public void RunFor(Employee employee)
    {
        var netPay = calculator.CalculateNetPay(employee);
        var text = formatter.Format(employee, netPay);
        mailer.Send(employee, text);
    }
}

Now a change to tax rules touches only PayrollCalculator. A change to layout touches only PayslipFormatter. Nobody testing the tax logic needs to know an SMTP server exists. Each class answers to exactly one stakeholder — that's SRP, correctly applied.

Note the granularity: PayrollCalculator still has several lines and could technically be described as "doing more than one thing" at the statement level. SRP isn't about method count — it's about how many independent business reasons could force this class to change. One class, one axis of change, is the bar. (Keeping the code inside that class itself small and readable is a separate, complementary concern — that's Clean Code, coming up in Lesson 236.)

How It Works — Open/Closed Principle

"Open for extension, closed for modification" sounds paradoxical until you see it in code. It means: when a new case shows up, you should be able to add something — a new class, a new implementation — rather than edit something that already works, is already tested, and is already running in production.

Before — A Switch Statement That Must Grow Forever

public class DiscountCalculator
{
    public decimal Apply(string customerType, decimal total) => customerType switch
    {
        "Regular" => total,
        "Vip" => total * 0.90m,
        "Employee" => total * 0.80m,
        _ => total
    };
}

This works — until the business adds a "Seasonal" discount, or a "FirstTimeBuyer" discount, or a "Loyalty" tier with rules that depend on order history. Every single new discount type means opening this file, editing a method that every existing discount type already relies on, and re-testing all of them to make sure the edit didn't break anything. The blast radius of "add one new discount" is "the entire discount system."

After — Extend by Adding a Class, Not Editing One

public interface IDiscountPolicy
{
    decimal Apply(decimal total);
}

public sealed class RegularCustomerDiscount : IDiscountPolicy
{
    public decimal Apply(decimal total) => total;
}

public sealed class VipDiscount : IDiscountPolicy
{
    public decimal Apply(decimal total) => total * 0.90m;
}

public sealed class EmployeeDiscount : IDiscountPolicy
{
    public decimal Apply(decimal total) => total * 0.80m;
}

// ─── Six months later: a new discount type ───
// No existing file above is touched. This is the ONLY new code.
public sealed class SeasonalDiscount(decimal seasonalRate) : IDiscountPolicy
{
    public decimal Apply(decimal total) => total * (1 - seasonalRate);
}

// ─── Consumer depends only on the abstraction ───
public class CheckoutService(IDiscountPolicy discountPolicy)
{
    public decimal CalculateTotal(decimal subtotal) => discountPolicy.Apply(subtotal);
}

RegularCustomerDiscount, VipDiscount, and EmployeeDiscount are never opened again to add SeasonalDiscount. CheckoutService never changes either — it was written once, against the interface, and stays closed for modification while the system stays open for extension. Whichever policy gets registered in the DI container (Lesson 124) at startup is the one CheckoutService uses; adding a new policy is purely additive.

How It Works — Liskov Substitution Principle

Lesson 074 introduced LSP briefly, in the context of polymorphic collections: any subtype must be usable anywhere the base type is expected, "without the caller needing to know or care which one it actually got." Here's the deeper version, with the example that made this principle famous.

A Genuine LSP Violation — Square : Rectangle

Geometrically, a square is a rectangle — every square satisfies the definition of a rectangle. It seems natural to model it with inheritance:

public class Rectangle
{
    public virtual double Width { get; set; }
    public virtual double Height { get; set; }
    public double Area => Width * Height;
}

public class Square : Rectangle
{
    // To keep a square a square, both sides must move together...
    public override double Width
    {
        get => base.Width;
        set { base.Width = value; base.Height = value; }
    }
    public override double Height
    {
        get => base.Height;
        set { base.Height = value; base.Width = value; }
    }
}

The code compiles. Square genuinely overrides every member Rectangle declares. But watch what happens to a caller written against the base type:

void Resize(Rectangle rectangle)
{
    rectangle.Width = 5;
    rectangle.Height = 10;
    // Anyone reading this expects Area to be 50 for ANY Rectangle.
    Debug.Assert(rectangle.Area == 50);
}

Resize(new Rectangle());  // Area = 50 
Resize(new Square());     // Area = 100  — setting Height silently overwrote Width

Resize never mentions Square. It was written entirely against the Rectangle contract: "width and height are set independently." That is a completely reasonable expectation to build on a class called Rectangle with two independent settable properties — and Square breaks it, silently, without throwing, without a compiler warning. This is exactly what LSP forbids: a subtype whose behavior violates assumptions callers are entitled to make about the base type.

The Fix — Don't Force an Inheritance Relationship the Contract Can't Support

public interface IShape
{
    double Area { get; }
}

public sealed class Rectangle(double width, double height) : IShape
{
    public double Width { get; } = width;
    public double Height { get; } = height;
    public double Area => Width * Height;
}

public sealed class Square(double side) : IShape
{
    public double Side { get; } = side;
    public double Area => Side * Side;
}

Neither class inherits from the other. Both implement IShape, which only promises an Area — a contract both types can honor without lying. There's no inheritance relationship to violate, because there's no shared base contract making a promise ("independently settable width and height") that one of the subtypes can't actually keep. The mathematical "is-a" relationship between squares and rectangles was real; the behavioral substitutability that LSP requires was not — and LSP cares about behavior, not geometry.

Lesson 074 connection: that lesson stated the rule; this is the canonical violation that shows why it matters — a subtype can override every method, compile cleanly, and still be an LSP violation because it breaks a caller's reasonable behavioral assumptions about the base type.

Simple Example — Spotting a Violation Fast

A quick smell test for each of the three principles covered in depth here:

Real-World Example — All Five, Together, in One Payment Flow

A payment-processing feature in a production order system, applying all five principles at once:

// D — depend on abstractions, not concrete gateways (Lesson 081)
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(decimal amount, string customerId);
}

// I — small, focused contract; a reporting screen never needs ChargeAsync (Lesson 079)
public interface IPaymentAuditReader
{
    Task<IReadOnlyList<PaymentRecord>> GetRecentAsync(string customerId);
}

// O — new gateways are added, never by editing this file
public sealed class StripeGateway : IPaymentGateway
{
    public Task<PaymentResult> ChargeAsync(decimal amount, string customerId) => /* Stripe SDK call */ default!;
}

public sealed class PayPalGateway : IPaymentGateway // added later — zero edits above
{
    public Task<PaymentResult> ChargeAsync(decimal amount, string customerId) => /* PayPal SDK call */ default!;
}

// L — any IPaymentGateway must genuinely attempt a charge and return a real result;
// a gateway that just returns success without charging would violate the contract
// callers are entitled to rely on, even though it "compiles."

// S — OrderCheckoutService has exactly one reason to change: checkout orchestration logic.
// It does not calculate tax, does not format receipts, does not manage inventory.
public sealed class OrderCheckoutService(IPaymentGateway gateway, ILogger<OrderCheckoutService> logger)
{
    public async Task<PaymentResult> CheckoutAsync(Order order)
    {
        logger.LogInformation("Charging {CustomerId} for {Amount}", order.CustomerId, order.Total);
        return await gateway.ChargeAsync(order.Total, order.CustomerId);
    }
}

Notice how naturally the five principles reinforce each other here: DIP is what makes OCP possible (you can only add a new gateway without editing consumers because the consumer depends on an abstraction); ISP is what keeps that abstraction from growing bloated as more payment-related features get added; SRP is what keeps OrderCheckoutService from absorbing tax logic or receipt formatting "while we're in here anyway"; and LSP is the silent assumption underneath all of it — that any IPaymentGateway you plug in actually behaves like a payment gateway.

Analogy

A Well-Organized Toolbox

Single Responsibility — each tool has one job. The screwdriver drives screws. It doesn't also try to be a hammer, a level, and a tape measure welded together. When you need a better screwdriver, you replace one tool, not the whole box.

Open/Closed — when a new job shows up, you buy a new tool and add it to the box. You don't melt down the hammer and reforge it every time a new kind of nail appears.

Liskov Substitution — any Phillips-head screwdriver in the "Phillips screwdriver" drawer must actually work like a Phillips screwdriver. If someone slips a flathead into that drawer labeled "Phillips," the next person who reaches in blind gets a nasty surprise mid-job.

Interface Segregation — you don't hand a electrician a single 40-blade multi-tool bristling with woodworking, plumbing, and gardening attachments just so they can use the wire stripper. You hand them a wire stripper.

Dependency Inversion — a good toolbox is built around a standard bit system (a hex socket, say), not one proprietary connector glued to a single brand of drill. The socket is the abstraction; any compliant bit — from any manufacturer — plugs in.

A toolbox built this way lets you add, replace, and combine tools for decades without ever having to reorganize the whole box. That's the entire point of SOLID, translated out of code.

Under the Hood — The Design Reasoning

THE TRADE-OFF SOLID IS ACTUALLY MAKING
1. UP-FRONT COST
2. WHAT YOU'RE BUYING WITH IT
3. WHERE THE TRADE STOPS PAYING OFF

Every SOLID principle spends a little complexity now to buy a lot of safety later. That trade only pays off for code that will actually be touched again — which, for most production systems, is almost all of it.

Common Confusion

1. "SRP means a class should have only one method" — no

SRP counts reasons to change, not method count. A class with fifteen small, related methods that all serve one stakeholder (say, all of them exist purely to answer "is this order valid?") has a single responsibility. A class with two methods serving two unrelated stakeholders does not.

2. "Open/Closed means I can never edit a file again" — no

You will absolutely still edit code — fixing a genuine bug inside VipDiscount is not an OCP violation, because you're correcting existing behavior, not bolting a new, unrelated case onto a growing conditional. OCP is about how you accommodate new variants, not a ban on all future edits everywhere.

3. "LSP is just about method signatures matching" — no

The compiler already enforces signature compatibility; that's not what LSP adds. LSP is about behavioral compatibility — preconditions the subtype can't strengthen, postconditions it can't weaken, invariants of the base type it can't quietly break. Square matched every signature Rectangle declared and still violated LSP.

Common Mistakes

Mistake 1 — Over-applying SRP into an anemic mess

Splitting PayrollCalculator further into GrossPayCalculator, TaxCalculator, DeductionCalculator, and a coordinator to glue them, when finance treats all of that as one indivisible rule set that only ever changes together.

Split along genuine, independent reasons to change — not just because a class has more than one method or more than one field.

Mistake 2 — Applying OCP speculatively, before a second case exists

Building an IDiscountPolicy abstraction, a factory, and a registration system for a discount calculation that has exactly one rule today and no plan for a second one. This is complexity paid for a benefit that may never arrive.

It's often fine to write the simple, direct version first (a plain method, even an if) and introduce the OCP-friendly abstraction once a second, genuinely different variant actually shows up. Premature OCP is speculative generality — a real cost with no guaranteed payoff.

Mistake 3 — An override that throws instead of honoring the contract

public class ReadOnlyReportRepository : IRepository<Report>
{
    public Task DeleteAsync(Guid id) => throw new NotSupportedException(); //  LSP violation
}

Any caller that holds an IRepository<Report> and calls DeleteAsync — which the interface promises it can — gets an exception instead of the behavior the contract advertised.

This is usually a sign the interface itself is too fat (an ISP problem, Lesson 079) — split a narrower IReadOnlyRepository<T> out, so a read-only implementation never has to fake support for a member it can't honor.

When Should I Use It?

Rule of thumb: if you can't yet articulate a second reason this code might change, or a second variant it might need, it's often fine to write the direct version first — and reach for SRP/OCP-style structure the moment a real second reason or variant actually shows up.

Mental Model

S — one class, one reason to change.
O — add new behavior, don't edit old behavior.
L — a subtype must not lie about how it behaves.
I — depend on only what you actually use.
D — depend on the shape of things, not the things themselves.

All five, in one sentence: shape your code so that the next change is small, local, and safe — never large, sprawling, and risky.

Key Takeaway


Check Your Understanding

You've seen all five SOLID principles and gone deep on S, O, and L. Let's check that the distinctions actually stuck.

1. A class named InvoiceService has six small, focused methods: ValidateLineItems, CalculateSubtotal, CalculateTax, ApplyRounding, CalculateTotal, and ToInvoiceSummary. All six exist purely to answer "what is the correct total for this invoice?" Does this class violate SRP?

Show answer

Correct: B

Why B is correct: SRP counts reasons to change, not method count. If every method here would only ever be edited because invoicing/pricing rules changed — one stakeholder, one axis — the class has a single responsibility no matter how many small methods implement it.

Why A is incorrect: Method count is not what SRP measures; a class can have many methods and still be highly cohesive around one responsibility.

Why C is incorrect: There is no such rule anywhere in SRP's definition — it would make SRP indistinguishable from "no class may have more than one method," which is not what Robert C. Martin's principle says.

Why D is incorrect: SRP applies to any class, stateful or stateless — the criterion is reasons to change, unrelated to whether the class holds fields.

Reinforcement: Always ask "how many independent reasons could force an edit here?" — not "how many methods does this have?"

2. A team needs to support a fourth payment method. Under OCP, what should happen?

Show answer

Correct: B

Why B is correct: This is exactly "open for extension, closed for modification" — the new payment method is added as new code, and every existing, tested class is left untouched.

Why A is incorrect: Editing a shared conditional that all existing payment methods flow through means every new payment type risks breaking the other three — the opposite of OCP.

Why C is incorrect: Rewriting a working, tested interface just to fit a new case is a large, risky modification — not the low-risk extension OCP aims for.

Why D is incorrect: A flag that branches internal behavior is the switch-statement problem in disguise — the class is still being edited and re-tested for every new case.

Reinforcement: The OCP test is simple: did adding this feature require opening and editing a file that already worked for other cases?

3. A ReadOnlyList<T> class inherits from a MutableList<T> base class. Every mutating method (Add, Remove, Clear) is overridden to throw InvalidOperationException. What principle does this most directly violate, and why?

Show answer

Correct: B

Why B is correct: This is the textbook shape of an LSP violation — the subtype compiles, matches every signature, but breaks the base type's behavioral contract for any caller that substitutes it in. "Add succeeds" is a reasonable assumption baked into depending on MutableList<T>; throwing instead violates it.

Why A is incorrect: This isn't about how many methods were overridden — it's about whether the overridden behavior honors the base type's contract.

Why C is incorrect: OCP is about whether existing code needed editing to add new behavior; nothing here describes modifying MutableList<T> itself.

Why D is incorrect: DIP is about depending on abstractions vs. concretions; while depending on a concrete base class isn't ideal design, it's not what's being violated by the throwing behavior described here.

Reinforcement: An LSP violation is a behavioral break, not a compile-time one — this is precisely the same shape of problem as Square : Rectangle.

4. Which statement correctly distinguishes Interface Segregation (Lesson 079) from Dependency Inversion (Lesson 081)?

Show answer

Correct: A

Why A is correct: ISP is about the shape of a contract — keep it narrow so consumers aren't forced to depend on members they never call. DIP is about the direction of dependency — high-level policy should depend on an abstraction rather than a concrete, low-level detail. They frequently work together (a small, well-shaped interface is easier to depend on abstractly) but they answer different design questions.

Why B is incorrect: They have genuinely different definitions, targeting different failure modes — fat contracts vs. concrete coupling.

Why C is incorrect: Both principles concern the relationship between classes and interfaces; neither is restricted to only one or the other.

Why D is incorrect: DIP has nothing to do with the sealed keyword, and it isn't a stricter version of ISP — they're independent principles.

Reinforcement: Keep the "I" and "D" straight by their focus: I = shape of the contract, D = direction of the dependency.

5. What is the single underlying goal that all five SOLID principles ultimately share?

Show answer

Correct: C

Why C is correct: SRP, OCP, LSP, ISP, and DIP each target a different specific failure mode, but every one of them is ultimately in service of the same outcome: code that stays safe and cheap to change as requirements evolve, rather than becoming rigid, fragile, and risky to touch.

Why A is incorrect: SOLID often increases the number of classes (more small, focused types) — that is an accepted cost, not the goal.

Why B is incorrect: SOLID is a design-time, maintainability-focused set of principles; runtime performance is not what any of the five are optimizing for, and some (like extra interface indirection) have a negligible runtime cost in exchange for the design benefit.

Why D is incorrect: Not every class needs an interface — SOLID is applied where it earns its cost, not as a blanket mandate.

Reinforcement: Whenever you're unsure whether SOLID applies to a given piece of code, ask the goal-level question directly: will this design make the next change safer and smaller, or not?

You now hold all five SOLID principles as one coherent toolkit — the foundation the rest of this architecture module builds on.


dotnetmadeeasy.com — Learn C# and .NET, the right way.