Beginner-Friendly Guide

OOP & SOLID for Dummies

Plain-English explanations, real-world analogies, and copy-paste C# code. By the end you'll understand why these ideas exist — not just what they are.

No prior OOP needed C# examples Bad vs. Good code
01

What is OOP?

Imagine your kitchen as a single giant function called MakeDinner(). It chops, fries, boils, plates, and cleans — all in one 800-line method. Want to change the sauce? Edit the giant function. Want to add a starter? Edit it again. Eventually nobody can find anything and changing one ingredient breaks dessert.

Now imagine the same kitchen rebuilt as objects: a Knife that knows how to chop, a Pan that knows how to fry, a Plate that knows how to hold food. Each tool owns its data and knows its own job. MakeDinner() just orchestrates them.

That's Object-Oriented Programming. It's not magic — it's organisation. Real-world things become objects; their behaviour becomes methods; their state becomes fields. The reward: code you can change without breaking three other things.

Brain Power

You're modelling a banking app. You see two design choices for a "withdraw money" operation:

(a) BankingHelper.Withdraw(account, amount) — a static helper function.

(b) account.Withdraw(amount) — a method on the account itself.

One of these is OOP. Which? And what's the real-world consequence of picking the other one?

(b) is OOP. The account owns the balance — it should also own the rules for changing it. With (a), the balance is exposed and anyone can mutate it, so the "minimum balance" rule has to be repeated everywhere. With (b), the rule lives in one method, in one class. Behaviour belongs with the data it operates on. That single sentence is what separates OOP from "a bunch of functions".
Big Idea

Think in "things", not "steps"

Imagine a coffee shop. You don't think "step 1: heat water, step 2: grind beans…". You think in things — a Barista, a CoffeeMachine, an Order. Each thing has data (state) and actions (behaviour). That's OOP.

// A "thing" in code is called a CLASS.
// An actual instance of that thing is called an OBJECT.

public class CoffeeMachine
{
    // ── STATE (data the object owns) ─────────────
    public int  WaterLevel  { get; private set; } = 100;
    public bool IsOn        { get; private set; }

    // ── BEHAVIOUR (what the object can do) ───────
    public void TurnOn()  => IsOn = true;
    public void TurnOff() => IsOn = false;

    public void Brew()
    {
        if (!IsOn)            throw new InvalidOperationException("Turn me on first!");
        if (WaterLevel < 10)  throw new InvalidOperationException("Refill me!");
        WaterLevel -= 10;
        Console.WriteLine("☕ Brewing...");
    }
}

// Using it:
var machine = new CoffeeMachine();   // ← that's an OBJECT
machine.TurnOn();
machine.Brew();
Console.WriteLine(machine.WaterLevel);   // 90
Vocabulary

Class vs Object — the cookie analogy

A class is the cookie cutter. An object is a cookie made with it. You write the class once; you can create as many objects from it as you want, each with its own data.

// The cookie cutter (class — written once)
public class Dog
{
    public string Name  { get; set; } = "";
    public int    Age   { get; set; }

    public void Bark() => Console.WriteLine($"{Name}: Woof!");
}

// The cookies (objects — made many times)
var rex   = new Dog { Name = "Rex",   Age = 4 };
var luna  = new Dog { Name = "Luna",  Age = 2 };
var buddy = new Dog { Name = "Buddy", Age = 7 };

rex.Bark();    // Rex: Woof!
luna.Bark();   // Luna: Woof!

// Each object has its OWN data — changing rex.Age
// doesn't touch luna or buddy.
rex.Age = 5;
Console.WriteLine(luna.Age);   // still 2
Overview

The 4 pillars (you'll meet each one next)

OOP rests on four ideas. Don't worry about memorising them yet — each pillar gets its own card. Here's the one-line version of each:

// 1️⃣  ENCAPSULATION
//    "Hide the messy details, expose a simple button."
//    → A microwave: you press Start. You don't wire the magnetron.

// 2️⃣  INHERITANCE
//    "A Dog IS-A Animal. Reuse what Animal already knows."
//    → A SportsCar IS-A Car. It already has wheels & an engine.

// 3️⃣  POLYMORPHISM
//    "Same call, different behaviour depending on the object."
//    → animal.MakeSound() barks for a Dog, meows for a Cat.

// 4️⃣  ABSTRACTION
//    "Talk to a contract, not a specific implementation."
//    → You use ILogger; you don't care if it's a file or console.

// SOLID is FIVE rules that help you USE these pillars well.
// We'll get there in Section 5.
Section 1 wrap — what you now know
  • OOP = code organised as "things" (objects) that own their data and know their own job.
  • Each object bundles state (fields) and behaviour (methods) into one place.
  • Behaviour belongs with the data it operates on. account.Withdraw(amount) beats Helper.Withdraw(account, amount).
  • The four pillars: Encapsulation (hide internals), Inheritance (reuse), Polymorphism (same call, different behaviour), Abstraction (depend on contracts, not implementations).
  • OOP doesn't make code faster to write — it makes it safer to change. The benefit shows up six months in.
02

Encapsulation

A vending machine has buttons on the front and complicated stuff inside. You insert a coin, press B4, a snack comes out. You never reach into the back to drop coins on the counter and grab a snack directly. The machine controls what you can do.

That's encapsulation: keep the internal data private, and only let the outside world change it through methods you control. The class becomes a vending machine — public buttons, hidden internals.

Why bother? Because the moment you make a field public, every other piece of code can touch it. Add a new rule ("balance must never go negative") and you have to add a check in every single place that touches the balance. Make it private with a controlled Withdraw() method, and the rule lives in one place forever.

Brain Power

You have public int Age; on a Person class. Three years pass and you discover you need to validate that age is non-negative and below 150. What's the painful refactor — and how does encapsulation prevent it from being painful?

With a public field, every person.Age = 200 in the codebase becomes a potential bug — you have to hunt them all down and add validation everywhere. With a property public int Age { get; private set; } and a SetAge(int v) method, you add the validation once and every caller is automatically correct. Encapsulation isn't about secrecy — it's about being able to change your mind.
❌ Bad

Exposed fields = chaos

If anyone can poke your internal data directly, anyone can put your object into an impossible state. Here's the trap:

// ❌ BAD — public fields, no rules
public class BankAccount
{
    public decimal Balance;   // anyone can write whatever they want!
}

var acc = new BankAccount();
acc.Balance = -9999999m;   // 🤦  We just gave the user a million in debt
                           // because nothing stopped them.
✅ Good

Private data + public methods = safety

Lock the data behind private. Expose only the operations that make sense, and check the rules inside the object — not in every caller.

public class BankAccount
{
    // ── Private state — outsiders can't touch it ─
    private decimal _balance;

    // ── Read-only "window" into the state ────────
    public decimal Balance => _balance;

    public void Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Deposit must be positive.");
        _balance += amount;
    }

    public void Withdraw(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("Withdrawal must be positive.");
        if (amount > _balance)
            throw new InvalidOperationException("Not enough funds.");
        _balance -= amount;
    }
}

var acc = new BankAccount();
acc.Deposit(100);
acc.Withdraw(30);
// acc._balance = -1;   // ❌ compile error — _balance is private
Console.WriteLine(acc.Balance);   // 70
Shortcut

Properties — C#'s built-in encapsulation

A C# property is a field with manners. You get a clean obj.Name syntax but the compiler still calls a hidden getter/setter — so you can add rules later without breaking callers.

public class Person
{
    // Read-write, no validation (auto-property)
    public string Name { get; set; } = "";

    // Read-only from outside, settable from inside the class
    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;

    // Set ONCE at construction, never again
    public Guid Id { get; init; } = Guid.NewGuid();

    // Custom setter with validation
    private int _age;
    public int Age
    {
        get => _age;
        set
        {
            if (value < 0 || value > 150)
                throw new ArgumentOutOfRangeException(nameof(value));
            _age = value;
        }
    }
}

var p = new Person { Name = "Anna", Age = 30 };
// p.Age = -5;   // throws — encapsulation enforced
// p.Id  = ...;  // compile error — init-only
Section 2 wrap — what you now know
  • Encapsulation = keep data private; expose controlled methods.
  • Use private by default. Make things public only when callers genuinely need them.
  • Properties (public int Age { get; private set; }) give you a clean API today and a place to add validation tomorrow without breaking callers.
  • If you find yourself adding the same check in many places, the data isn't encapsulated. Move the check inside the class.
  • The real win isn't "secrecy" — it's the ability to change your mind without hunting through the whole codebase.
03

Inheritance & Polymorphism

You've built an Animal class with Name, Age, Eat(), Sleep(). Now you need a Dog. A Dog is also an Animal — same name, age, eat, sleep — plus it can Bark(). Do you copy-paste all of Animal into Dog?

Of course not. You say "Dog inherits from Animal". One line of C# (class Dog : Animal) and Dog gets everything Animal already had. You only write the new bits.

And then there's the bigger trick — polymorphism. You have a list of Animals. Some are Dogs, some are Cats, some are Cows. You call animal.Speak() on each. Magic: the Dog barks, the Cat meows, the Cow moos. Same method call, different behaviours, decided at runtime by which object you actually have. That's the OOP superpower.

Brain Power

You're tempted to make a Square class inherit from Rectangle, because a square is a rectangle, right? It even shares Width and Height.

Why is this one of the most famous OOP traps in history? (Hint: try setting Width = 5 on a Square. What should Height do? What does the Rectangle base class expect?)

This is the classic Liskov Substitution violation. In a Rectangle, setting Width doesn't change Height. In a Square, it must. So Square breaks the contract Rectangle promised — and any code expecting a Rectangle blows up when handed a Square. "Is-a" in English is not "is-a" in OOP. Use inheritance when the child can be substituted for the parent everywhere without surprises. Otherwise prefer composition (more on this in the SOLID section).
Inheritance

A Dog IS-A Animal

You write the common stuff once in a base class, then create child classes for the specifics. The child automatically gets everything the parent had.

// Base class — shared bits live here
public class Animal
{
    public string Name { get; set; } = "";

    public void Eat()  => Console.WriteLine($"{Name} is eating.");
    public void Sleep() => Console.WriteLine($"{Name} is sleeping.");
}

// Child class — reuses Eat & Sleep "for free", adds its own
public class Dog : Animal           // ← " : Animal " means "is an Animal"
{
    public void Bark() => Console.WriteLine($"{Name}: Woof!");
}

public class Cat : Animal
{
    public void Purr() => Console.WriteLine($"{Name}: Purr...");
}

var rex = new Dog { Name = "Rex" };
rex.Eat();    // inherited from Animal — Rex is eating.
rex.Bark();   // Dog-specific          — Rex: Woof!

// Rule of thumb: only use inheritance when "child IS-A parent"
// is genuinely true. A Car is NOT an Engine — it HAS-AN engine.
Polymorphism

Same call, different behaviour

Mark a method virtual in the parent and override it in each child. Now you can call animal.MakeSound() without caring whether animal is actually a Dog or a Cat — each one knows what to do.

public class Animal
{
    public string Name { get; set; } = "";

    // "virtual" = children are ALLOWED to replace this
    public virtual void MakeSound() =>
        Console.WriteLine($"{Name} makes a sound.");
}

public class Dog : Animal
{
    public override void MakeSound() =>
        Console.WriteLine($"{Name}: Woof!");
}

public class Cat : Animal
{
    public override void MakeSound() =>
        Console.WriteLine($"{Name}: Meow!");
}

public class Cow : Animal
{
    public override void MakeSound() =>
        Console.WriteLine($"{Name}: Moo!");
}

// ── The magic moment ─────────────────────────────
Animal[] zoo =
{
    new Dog { Name = "Rex"  },
    new Cat { Name = "Luna" },
    new Cow { Name = "Bess" }
};

foreach (var a in zoo)
    a.MakeSound();   // Rex: Woof!  /  Luna: Meow!  /  Bess: Moo!

// One loop, three different behaviours. THAT is polymorphism.
Pro tip

Composition > deep inheritance trees

Inheritance is tempting but easy to overuse. If you find yourself writing Manager : Employee : Person : LivingThing, stop. Prefer composition: an object has other objects that do the work.

// ❌ "is-a" abused — a Car is NOT an Engine
// public class Car : Engine { ... }   // wrong relationship

// ✅ "has-a" — a Car HAS an Engine, HAS Wheels, HAS a Radio
public class Engine { public void Start() => Console.WriteLine("Vroom"); }
public class Wheels { public void Roll()  => Console.WriteLine("Rolling"); }
public class Radio  { public void Play()  => Console.WriteLine("♪♪♪"); }

public class Car
{
    private readonly Engine _engine = new();
    private readonly Wheels _wheels = new();
    private readonly Radio  _radio  = new();

    public void Drive()
    {
        _engine.Start();
        _wheels.Roll();
        _radio.Play();
    }
}

// Why this is better:
//  - Swap any piece (e.g. ElectricEngine) without rewriting Car.
//  - Each piece is testable on its own.
//  - No "fragile base class" problem when Engine changes.
Section 3 wrap — what you now know
  • Inheritance (class Dog : Animal) lets a child class reuse everything the parent already has.
  • Use virtual in the parent + override in the child to specialise behaviour. Use base.Method() to call the parent's version.
  • Polymorphism = same method call, different runtime behaviour. animal.Speak() works whether animal holds a Dog, Cat, or Cow.
  • Inheritance is powerful but easy to abuse. Prefer composition ("a Car HAS-A Engine") over deep inheritance trees.
  • The Square-Rectangle problem is the canonical warning sign — "is-a" in English isn't always "is-a" in OOP.
  • If a subclass can't honour everything the parent promised, you don't have inheritance — you have a bug waiting to happen.
04

Abstraction & Interfaces

You press the accelerator pedal. The car speeds up. Do you know whether you're driving petrol, diesel, electric, or hybrid? You don't have to. The pedal is an abstraction — a clean interface that says "go faster" without forcing you to understand the engine underneath.

Same idea in C#. You write code that says "I need something that can SaveOrder()". You don't care if it saves to SQL Server, MongoDB, a flat file, or a REST API in the cloud. You depend on the interface, not the implementation.

Two tools do this: interfaces (pure contracts — "you must have these methods") and abstract classes (contracts plus some shared code). Both let you swap implementations without changing the code that uses them. This is the foundation of testable, swappable, future-proof software.

Brain Power

Interface or abstract class? You need to model "Vehicle" — a thing that has wheels, an engine, can Start() and Stop(), and a WheelCount property. Some vehicles share a starting sequence; some are radically different (electric vs petrol).

Which tool fits, and why? (The decision shapes how much code gets shared and how much gets duplicated.)

Rule of thumb: interface when you want pure contract — "anyone who claims to be a Vehicle must have these methods." abstract class when there's shared code you want to put in one place — "all Vehicles start the same way, but each subclass does Stop() differently." In practice: lead with interfaces. Reach for abstract classes when you have real duplication to extract.
Interfaces

An interface is a contract

An interface says "anything that implements me must have these methods". It contains no code, only signatures. Then anyone who needs that capability can depend on the interface — not on a specific class.

// The contract — names of methods, no bodies
public interface ILogger
{
    void Info(string message);
    void Error(string message);
}

// Implementation #1 — writes to the console
public class ConsoleLogger : ILogger
{
    public void Info(string m)  => Console.WriteLine($"INFO:  {m}");
    public void Error(string m) => Console.WriteLine($"ERROR: {m}");
}

// Implementation #2 — writes to a file
public class FileLogger : ILogger
{
    private readonly string _path;
    public FileLogger(string path) => _path = path;

    public void Info(string m)  => File.AppendAllText(_path, $"INFO:  {m}\n");
    public void Error(string m) => File.AppendAllText(_path, $"ERROR: {m}\n");
}

// A class that uses ANY logger — it doesn't care which one
public class OrderService
{
    private readonly ILogger _log;
    public OrderService(ILogger log) => _log = log;

    public void Place(int orderId)
    {
        _log.Info($"Placing order {orderId}");
        // ... do work ...
    }
}

// Swap implementations without changing OrderService at all
var svc1 = new OrderService(new ConsoleLogger());
var svc2 = new OrderService(new FileLogger("log.txt"));
Abstract

Abstract class — half-built recipe

An abstract class is like an interface with some code already filled in. You can't instantiate it directly — only its concrete children. Use it when subclasses share state and behaviour, not just a contract.

// Abstract = "you can't 'new' me, you must subclass me"
public abstract class Shape
{
    public string Color { get; set; } = "black";

    // Concrete method — every shape gets this for free
    public void Describe() =>
        Console.WriteLine($"A {Color} shape with area {Area()}.");

    // Abstract method — every child MUST implement this
    public abstract double Area();
}

public class Circle : Shape
{
    public double Radius { get; set; }
    public override double Area() => Math.PI * Radius * Radius;
}

public class Rectangle : Shape
{
    public double Width  { get; set; }
    public double Height { get; set; }
    public override double Area() => Width * Height;
}

// var s = new Shape();           // ❌ compile error — abstract
var c = new Circle    { Radius = 5,         Color = "red"  };
var r = new Rectangle { Width  = 3, Height = 4, Color = "blue" };

c.Describe();   // A red shape with area 78.539...
r.Describe();   // A blue shape with area 12.

// Interface  → "what you can do"        (no state, multiple inheritance)
// Abstract   → "what you can do AND share" (state + base behaviour)
Section 4 wrap — what you now know
  • Abstraction = describe what, hide how. Depend on contracts, not concrete classes.
  • Interface = pure contract. "Anyone who claims to be this must have these methods." No shared code.
  • Abstract class = contract plus shared implementation. Use when there's real duplication to extract.
  • Default: lead with interfaces. Reach for abstract classes only when shared code earns its keep.
  • Programming "against an interface" is what makes code testable (swap real services for fakes) and swappable (replace SQL with NoSQL without touching business logic).
  • If your class has the name of a concrete technology in it (SqlUserRepository), the interface (IUserRepository) is probably the one your other code should depend on.
05

SOLID Principles

You now have the four OOP pillars: encapsulation, inheritance, polymorphism, abstraction. With great power comes the ability to make a real mess. SOLID is five rules that keep you from doing that.

Coined by Robert C. Martin ("Uncle Bob"), SOLID is what separates the codebase you can change in 5 minutes from the one where every change breaks three other things. Senior engineers don't memorise SOLID — they internalise it, then use it as a tool to argue with their younger selves.

You'll see all five letters: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. Don't try to memorise them by name. Internalise the pain each one prevents, and you'll spot violations everywhere.

Brain Power

You inherit a class called UserManager with 47 methods: SaveUser, SendWelcomeEmail, ExportToCSV, ValidatePassword, GenerateReport, LogActivity, SendSlackNotification… you get the idea.

Which SOLID letter is being violated, and what concretely would you do about it?

Single Responsibility (the S). One class, one reason to change. This UserManager will change when the email template changes, when the CSV format changes, when the password policy changes, when Slack moves to Discord, and on and on. Each responsibility belongs in its own class: UserRepository, EmailService, CsvExporter, PasswordValidator. Now each class has exactly one reason to change — and you can swap any of them without touching the others.
S — SRP

Single Responsibility — one job per class

A class should have one reason to change. If your Invoice class calculates totals, writes to the database, and sends emails, three different teams will fight over it. Split it.

// ❌ BAD — three jobs in one class
public class Invoice
{
    public List<decimal> Lines { get; } = [];

    public decimal CalcTotal() => Lines.Sum();

    public void SaveToDb()  { /* SQL... */ }   // 🚩 reason to change #2
    public void EmailPdf()  { /* SMTP... */ }  // 🚩 reason to change #3
}

// ✅ GOOD — one job each
public class Invoice
{
    public List<decimal> Lines { get; } = [];
    public decimal CalcTotal() => Lines.Sum();
}

public class InvoiceRepository
{
    public void Save(Invoice inv) { /* SQL... */ }
}

public class InvoiceEmailer
{
    public void Send(Invoice inv) { /* SMTP... */ }
}

// Why bother?
//  - The DB schema changes → only InvoiceRepository changes.
//  - The email provider changes → only InvoiceEmailer changes.
//  - You can test CalcTotal() without touching DB or SMTP.
O — OCP

Open/Closed — extend, don't edit

Classes should be open for extension, closed for modification. Adding a new feature should mean adding a new class — not editing existing, already-tested ones.

// ❌ BAD — every new shape forces you to edit the if/else
public class AreaCalculator
{
    public double Area(object shape)
    {
        if (shape is Circle c)        return Math.PI * c.R * c.R;
        if (shape is Rectangle r)     return r.W * r.H;
        if (shape is Triangle t)      return 0.5 * t.B * t.H;
        // tomorrow: add Hexagon → edit this method AGAIN
        throw new NotSupportedException();
    }
}

// ✅ GOOD — each shape knows its own area; calculator never changes
public interface IShape { double Area(); }

public class Circle    : IShape { public double R; public double Area() => Math.PI * R * R; }
public class Rectangle : IShape { public double W, H; public double Area() => W * H; }
public class Triangle  : IShape { public double B, H; public double Area() => 0.5 * B * H; }

public class AreaCalculator
{
    public double Total(IEnumerable<IShape> shapes) =>
        shapes.Sum(s => s.Area());
}

// Adding Hexagon tomorrow? Just write:
//    public class Hexagon : IShape { public double Area() => ... }
// AreaCalculator stays UNTOUCHED.
L — LSP

Liskov — a child must keep its parent's promises

Anywhere you can use the parent class, you should be able to swap in a child class without breaking anything. If the child surprises the caller, you've broken Liskov.

// ❌ BAD — the classic "Square IS-A Rectangle" trap
public class Rectangle
{
    public virtual int Width  { get; set; }
    public virtual int Height { get; set; }
}

public class Square : Rectangle
{
    // A square forces width == height — breaks Rectangle's contract
    public override int Width  { set { base.Width = base.Height = value; } }
    public override int Height { set { base.Width = base.Height = value; } }
}

void Resize(Rectangle r) { r.Width = 5; r.Height = 4; /* expect area 20 */ }

Resize(new Rectangle());  // area = 20 ✅
Resize(new Square());     // area = 16 ❌  — Square broke the promise!

// ✅ GOOD — they're two different things; don't fake the IS-A
public interface IShape { int Area(); }
public class Rectangle : IShape { public int W, H; public int Area() => W * H; }
public class Square    : IShape { public int Side; public int Area() => Side * Side; }

// Lesson: "looks like" ≠ "behaves like". If subclassing forces you
// to break an expected behaviour, it's NOT an IS-A relationship.
I — ISP

Interface Segregation — small, focused contracts

Don't force a class to implement methods it doesn't need. Prefer many small interfaces over one giant one — callers should depend only on the methods they actually use.

// ❌ BAD — a "fat" interface that forces everyone to implement everything
public interface IMultiFunctionDevice
{
    void Print(string doc);
    void Scan(string doc);
    void Fax(string doc);
}

// A cheap printer that can't fax... must still implement Fax 🤦
public class CheapPrinter : IMultiFunctionDevice
{
    public void Print(string d) => Console.WriteLine($"Printing {d}");
    public void Scan(string d)  => throw new NotSupportedException();   // 💥
    public void Fax(string d)   => throw new NotSupportedException();   // 💥
}

// ✅ GOOD — split the contract by capability
public interface IPrinter { void Print(string doc); }
public interface IScanner { void Scan(string doc); }
public interface IFax     { void Fax(string doc); }

public class CheapPrinter : IPrinter
{
    public void Print(string d) => Console.WriteLine($"Printing {d}");
}

public class OfficeMachine : IPrinter, IScanner, IFax
{
    public void Print(string d) => Console.WriteLine($"Printing {d}");
    public void Scan(string d)  => Console.WriteLine($"Scanning {d}");
    public void Fax(string d)   => Console.WriteLine($"Faxing {d}");
}

// Callers depend only on what they need:
void Backup(IScanner s, string doc) => s.Scan(doc);
D — DIP

Dependency Inversion — depend on contracts, not concretes

High-level code (business rules) should not depend on low-level code (file I/O, HTTP, DB). Both should depend on abstractions. In practice: take dependencies as interface parameters instead of new-ing them inside.

// ❌ BAD — OrderService is glued to SmtpEmailSender forever
public class SmtpEmailSender
{
    public void Send(string to, string body) { /* SMTP... */ }
}

public class OrderService
{
    private readonly SmtpEmailSender _email = new();   // 🚩 hard dependency

    public void Place(Order o)
    {
        // ... save order ...
        _email.Send(o.CustomerEmail, "Thanks!");
    }
}

// Problems:
//  - Can't unit-test without actually sending mail.
//  - Switching to SendGrid means editing OrderService.

// ✅ GOOD — depend on an INTERFACE, inject the concrete from outside
public interface IEmailSender
{
    void Send(string to, string body);
}

public class SmtpEmailSender    : IEmailSender { public void Send(string t, string b) { /* SMTP */ } }
public class SendGridSender     : IEmailSender { public void Send(string t, string b) { /* HTTP */ } }
public class FakeEmailForTests  : IEmailSender { public List<string> Sent = []; public void Send(string t, string b) => Sent.Add(t); }

public class OrderService
{
    private readonly IEmailSender _email;

    // The dependency is "injected" via the constructor (DI)
    public OrderService(IEmailSender email) => _email = email;

    public void Place(Order o)
    {
        // ... save order ...
        _email.Send(o.CustomerEmail, "Thanks!");
    }
}

// In production:
var svc  = new OrderService(new SmtpEmailSender());
// In tests:
var fake = new FakeEmailForTests();
var test = new OrderService(fake);
// Now you can assert on `fake.Sent` without any real email being sent.
Section 5 wrap — SOLID in 5 sentences
  • Single Responsibility — One class, one reason to change. If you can't describe it in one sentence, split it.
  • Open/Closed — Open to extension (new variants), closed to modification (don't touch what already works). Achieved with abstractions.
  • Liskov Substitution — A child class must work everywhere the parent works, without surprises. "Square is-a Rectangle" fails this.
  • Interface Segregation — Many small interfaces beat one huge one. A class shouldn't have to implement methods it doesn't need.
  • Dependency Inversion — Depend on interfaces, not concrete classes. The high-level code shouldn't know which database it's using.
  • SOLID isn't a checklist — it's a vocabulary for spotting bad design in code reviews. Once you've felt the pain each rule prevents, you'll never forget it.
06

Putting It All Together

You've met every pillar and every principle. Time to see them all working together in a single, realistic example.

This card walks through a small payment-processing module that uses every idea from this tutorial — encapsulation (the order owns its total), polymorphism (different payment methods), interfaces (swappable payment gateways), and every letter of SOLID. Read it slowly. Try to spot each pillar/principle as it goes by.

That conscious-effort step — "ah, that's the Open/Closed Principle in action" — is what turns SOLID from a checklist you forget into a tool you actually reach for in real work.

Brain Power — final exam

Before reading the code: how would you add a brand-new payment method (e.g. Apple Pay) to a well-designed payment system? Should you have to edit any existing class? Should you have to edit the orchestrating service?

(The answers are: NO, NO, and NO. Adding Apple Pay should be a single new class implementing the existing interface, with zero changes to anything that already works. That's Open/Closed in one sentence.)

If you can pass this test — "add a new variant without modifying anything existing" — your design is genuinely good. If you can't, refactor until you can. That's the entire point of OOP and SOLID: code you can add to safely, not just code that compiles.
Mini App

A payment processor — OOP + SOLID in 30 lines

A checkout that supports Card, PayPal, and Crypto payments. Adding a new method tomorrow means writing one new class — nothing else changes.

// ── Abstraction (DIP + OCP + ISP) ────────────
public interface IPaymentMethod
{
    bool Pay(decimal amount);
}

// ── Each concrete payment is a separate class (SRP) ──
public class CardPayment : IPaymentMethod
{
    public bool Pay(decimal amount)
    {
        Console.WriteLine($"💳 Charged €{amount} to card.");
        return true;
    }
}

public class PayPalPayment : IPaymentMethod
{
    public bool Pay(decimal amount)
    {
        Console.WriteLine($"🅿️ Sent €{amount} via PayPal.");
        return true;
    }
}

public class CryptoPayment : IPaymentMethod
{
    public bool Pay(decimal amount)
    {
        Console.WriteLine($"₿ Transferred €{amount} in crypto.");
        return true;
    }
}

// ── Checkout depends on the contract, NOT on a specific method (DIP) ──
public class Checkout
{
    private readonly IPaymentMethod _payment;
    public Checkout(IPaymentMethod payment) => _payment = payment;

    public void Complete(decimal amount)
    {
        if (amount <= 0) throw new ArgumentException("Amount must be positive."); // encapsulated rule
        if (_payment.Pay(amount))                                                   // polymorphism!
            Console.WriteLine("✅ Order complete.");
    }
}

// ── Usage ────────────────────────────────────
new Checkout(new CardPayment()).Complete(49.90m);
new Checkout(new PayPalPayment()).Complete(120m);
new Checkout(new CryptoPayment()).Complete(0.0021m);

// Tomorrow: ApplePay? Write one new class. Checkout stays untouched.
// → S: each class has one job.
// → O: extend by adding, not editing.
// → L: any IPaymentMethod can stand in for another.
// → I: IPaymentMethod is small & focused.
// → D: Checkout depends on the abstraction.
Cheatsheet

One-line reminders

Keep this open in a tab. When in doubt, ask: "which letter am I violating right now?" — that question alone catches most design mistakes.

// ── OOP pillars ──────────────────────────────
//  Encapsulation  → Hide data, expose safe operations.
//  Inheritance    → Reuse via "IS-A"; prefer composition when in doubt.
//  Polymorphism   → Same call, behaviour decided by the object.
//  Abstraction    → Talk to a contract, not an implementation.

// ── SOLID ────────────────────────────────────
//  S — Single Responsibility    "One reason to change."
//  O — Open / Closed            "Extend by adding, not editing."
//  L — Liskov Substitution      "Children must keep parent's promises."
//  I — Interface Segregation    "Many small contracts > one fat one."
//  D — Dependency Inversion     "Depend on abstractions, inject concretes."

// ── Quick smell-test ─────────────────────────
//  → "I'm afraid to touch this class."          → probably SRP / OCP
//  → "This subclass throws NotImplemented."     → ISP / LSP
//  → "I can't unit-test this without a DB."     → DIP
//  → "Adding a feature means editing 5 files."  → OCP
The whole tutorial in one breath
  • Encapsulation — hide internals, expose controlled methods. So you can change your mind safely.
  • Inheritance — reuse what a parent class already knows. But not too deep — composition usually beats it.
  • Polymorphism — same call, different runtime behaviour. The OOP superpower.
  • Abstraction — depend on contracts, not implementations. Testable. Swappable.
  • SOLID — the five rules that keep the four pillars from turning into spaghetti.
  • OOP done right doesn't make today's code faster — it makes next year's code safer to change. That's the whole pitch.
Mastery Move — where to go next

1. Design Patterns — the Gang of Four book (Strategy, Observer, Decorator, Factory, etc.) is the next layer. Patterns are OOP + SOLID in named, reusable shapes.

2. C# Deep-Dive Tutorial — modern generics, async, advanced LINQ, all of which lean heavily on the OOP foundations you just learned.

3. Minimal API for Dummies — apply OOP/SOLID to a real REST API. Dependency injection is just the Dependency Inversion Principle wearing a framework costume.

4. Algorithms & Data Structures — for the algorithmic side of being a senior C# engineer.

One more thing: internalising SOLID takes time. Re-read this page in three months after you've shipped something real. The principles you skim today will feel obvious then.