OOP · Designing with Objects
SOLID Principles
Five questions to ask before the design starts to hurt.
Sign in to track your score
What you'll learn
- Spot a class that has picked up too many unrelated jobs.
- Add a feature without reopening code that already works.
- Tell when a child class quietly breaks its parent's promises.
- Replace a hard-wired provider with a contract — and know when not to.
How to use this module. Wherever you see THINK, PREDICT or SPOT THE PROBLEM, stop and answer before reading on. The answer always follows immediately — cover it with your hand if you need to. Every code sample here compiles and was run to confirm its output.
Here is a class from a food delivery app. It works. It shipped. Customers are using it right now.
class FoodOrderSystem {
public:
double calculateTotal();
void applyDiscount();
void saveToDatabase();
void processStripePayment();
void sendConfirmationEmail();
void printReceipt();
};
Six months later, four things happen.
The team moves from MySQL to PostgreSQL. This class changes.
Marketing invents a festive discount. This class changes.
The email provider raises its prices, so they switch. This class changes.
Finance wants a new receipt layout. This class changes.
Think — Four completely unrelated events. One file edited every time.
Nothing here is broken — the code still works. So what exactly is the problem?
The problem is that this design makes every future change expensive and risky. Touch the file to fix the receipt layout, and you are one typo away from breaking payments.
Working code is the first goal. Code that stays easy to change is the second, and it is the one that decides whether a project is still pleasant to work on in two years.
SOLID is five ideas that help you notice, early, when a design is drifting toward the class above. Each one is really a question you ask about your own code.

Watch out — These are guidelines for thinking, not laws. A 60-line program does not need five interfaces. Applied blindly, SOLID makes simple code worse, and this module will show you where that line is.
7.1 Single Responsibility
Take a smaller version of that class.
class RestaurantOrder {
public:
double calculateTotal();
void saveToDatabase();
void sendConfirmationEmail();
void printReceipt();
};
Ask what happens when the database team switches to PostgreSQL.
saveToDatabase() has to change. Fine. But does calculateTotal() need to change? Of course not — menu prices have nothing to do with which database you use. Yet it sits in the file you are now editing, and it will be recompiled, re-reviewed and re-tested along with your change.
Now the receipt gets a new layout. printReceipt() changes. Does the price calculation care? No. Same file again.
Say it plainly: this one class has four unrelated jobs, and each job brings its own stream of future changes.
That observation is what the Single Responsibility Principle is about.
Key idea — Try to give a class one clear job — so it has one clear reason to change.
The phrase "reason to change" is the useful part. Instead of arguing about whether two functions are "related", ask who would ask for them to change. A database engineer, a designer, an accountant and a marketer are four different people with four different reasons. That is four responsibilities.

Split along those lines:
class Order {
public:
void addItem(std::string n, double p) { names.push_back(n); prices.push_back(p); }
double calculateTotal() const {
double t = 0;
for (double p : prices) t += p;
return t;
}
private:
std::vector<std::string> names;
std::vector<double> prices;
};
class OrderRepository {
public:
void save(const Order& o) { std::cout << "Saved order worth " << o.calculateTotal() << "\n"; }
};
class EmailService {
public:
void send(std::string to) { std::cout << "Email sent to " << to << "\n"; }
};
class ReceiptPrinter {
public:
void print(const Order& o) { std::cout << "Receipt: " << o.calculateTotal() << "\n"; }
};
Saved order worth 410
Email sent to ana@site.com
Receipt: 410
Why it is better: the database change now touches one small file that contains nothing else. The price logic is not in the blast radius.
WATCH OUT One responsibility does not mean one method. Order could grow addItem, removeItem, applyCoupon and itemCount and still have exactly one job: knowing what is in this order and what it costs.
Four classes was the right answer here. Forty would not be.
Spot the problem — Three of these belong together. Which one does not, and how can you tell?
> class Playlist {
> public:
> void addSong(Song s);
> void removeSong(int index);
> int songCount() const;
> void uploadToCloud();
> };
>
Answer — **** uploadToCloud() The first three are all about what is in this playlist. They change when playlist rules change.
uploadToCloud() changes when the cloud provider changes its API — a completely different trigger, owned by completely different people. Move it to something like PlaylistUploader and Playlist stops caring about the network entirely.
7.2 Open/Closed
A shipping calculator starts out simple and honest.
class ShippingCalculator {
public:
double calculate(std::string type, double weight) {
if (type == "standard") return weight * 5;
else if (type == "express") return weight * 12;
else if (type == "same_day") return weight * 20;
return 0;
}
};
Then the company launches drone delivery. You open this file and add a branch. Then locker pickup arrives. Another branch. Then international express, with its own customs rule. Another branch, and this one needs an extra parameter.
Predict — Before drone delivery was added, this function was correct and well tested.
After the fifth edit, what is the chance that standard shipping still works exactly as it did?
Answer — lower every time Nothing about standard shipping changed, yet its code has been reopened, recompiled and re-shipped five times. Each edit is a fresh chance to break something that was already finished.
There is a quieter problem too. Run the original with an unknown type:
c.calculate("drone", 2); // returns 0
It does not complain. It silently charges nothing.
This is what the Open/Closed Principle addresses. The two halves of the name:
- Open for extension — you can add new behaviour.
- Closed for modification — without editing the code that already works.
The usual way to get both is the polymorphism from Module 5: one contract, many small classes.
class ShippingMethod {
public:
virtual double cost(double weight) const = 0;
virtual ~ShippingMethod() = default;
};
class StandardShipping : public ShippingMethod {
public:
double cost(double w) const override { return w * 5; }
};
class ExpressShipping : public ShippingMethod {
public:
double cost(double w) const override { return w * 12; }
};
BEGINNER NOTE The = 0 on cost() is not assigning zero to anything. It is the syntax that marks a pure virtual function — the base class saying "this operation must exist, and every child has to supply it." A class with one cannot be created on its own.
The = default on the line below it looks similar and means something unrelated: it asks the compiler for its standard version of that destructor.
Drone delivery arrives as a new file:
class DroneShipping : public ShippingMethod {
public:
double cost(double w) const override { return w * 30 + 15; }
};
And any code that quotes a price never changes at all:
void quote(const ShippingMethod& m, double weight) {
std::cout << m.cost(weight) << "\n";
}

Why it is better: StandardShipping was written once and has not been touched since. It cannot be broken by a change to drones, because nobody opens it.
WATCH OUT This is not a rule against if. Conditionals are fine, and a branch on three fixed cases that will never grow is perfectly good code.
The warning sign is a conditional you keep reopening for the same kind of reason. If every new feature means another else if in the same function, that is the signal.
Quick check — A pricing function has a branch for weekday and weekend rates. It has not changed in two years.
Should you refactor it into two classes?
Answer — no Nothing is straining. Two stable cases that nobody reopens cost you nothing. Turning them into an interface and two classes adds files, indirection and reading effort in exchange for flexibility you have no evidence you need.
Refactor when the pain is real, not when a principle says you might feel pain one day.
7.3 Liskov Substitution
This one takes the longest to click, so start with something that works.
A backup routine accepts any storage and saves a file to it:
void backup(FileStorage& store) {
store.save("invoices.csv");
}
Hand it a LocalDiskStorage and the file lands on disk. Hand it a CloudStorage and the file goes to a bucket. Both work, and backup() never had to know which it got. That is the whole point of a base class.
Now someone adds a third type.
class ReadOnlyArchive : public FileStorage {
public:
void save(std::string) override {
// throw reports an error because this object cannot do
// the operation its parent promised.
throw std::runtime_error("this archive is read-only");
}
};
BEGINNER NOTE throw stops the function immediately and reports an error upward to whoever called it. The caller can catch it and react, or the program stops.
You do not need exception handling for this module. Read it simply as: this call fails instead of doing what it promised.
It compiles. It is a perfectly legal child class. And it detonates:
Wrote invoices.csv to disk
Backup failed: this archive is read-only
Think —
backup()asked for aFileStorage. It got one.So who is at fault here — the backup function, or the archive?
Answer — the archive FileStorage made a promise: call save() and your file is stored. LocalDiskStorage keeps it. ReadOnlyArchive inherits the promise and then refuses to honour it.
The damage is not just the crash. Every caller holding a FileStorage& must now wonder "but which one is this really?" before calling save() — and that question is exactly what the base class existed to eliminate.
This is the Liskov Substitution Principle, and one sentence carries it:
Key idea — A child should keep the promises its parent made. If code works with the parent, it should keep working when handed the child.

Fixing it
The archive is not a bad class. It is in the wrong place in the hierarchy. It can genuinely read; it simply cannot write. So split the promise:
class ReadableStorage {
public:
virtual std::string read(std::string name) const = 0;
virtual ~ReadableStorage() = default;
};
class WritableStorage : public ReadableStorage {
public:
virtual void save(std::string name) = 0;
};
class LocalDiskStorage : public WritableStorage {
public:
std::string read(std::string n) const override { return "contents of " + n; }
void save(std::string n) override { std::cout << "Wrote " << n << " to disk\n"; }
};
class ReadOnlyArchive : public ReadableStorage { // no save() to break
public:
std::string read(std::string n) const override { return "archived " + n; }
};
Now backup() asks for a WritableStorage&, and the archive cannot be passed to it. The mistake became a compile error instead of a runtime crash.
Wrote invoices.csv to disk
contents of invoices.csv
archived invoices.csv
REMEMBER C++ will let you inherit from anything. It checks that the function signatures match — it cannot check that the behaviour matches.
Module 6 asked whether the is-a sentence was true. This principle asks a sharper follow-up: is it still true once you read what the child actually does?
Quick check — A Notification base class has send(). A DraftNotification overrides it to do nothing at all, silently.
Does that break substitution?
Answer — yes Doing nothing quietly is worse than throwing. Code that calls send() and moves on believes the message went out. Nothing crashes, nothing logs, and the bug surfaces days later as "the customer never got the email."
A draft is not a kind of notification you can send. It belongs somewhere else in the design.
7.4 Interface Segregation
An office has printers, scanners, fax machines and big all-in-one units. Someone writes one contract to cover them all.
class OfficeMachine {
public:
virtual void print() = 0;
virtual void scan() = 0;
virtual void fax() = 0;
virtual void staple() = 0;
virtual ~OfficeMachine() = default;
};
Reasonable enough, until the cheapest device in the building has to implement it.
class SimplePrinter : public OfficeMachine {
public:
void print() override { std::cout << "Printing\n"; }
void scan() override { } // it has no scanner
void fax() override { } // it has no phone line
void staple() override { } // it has no stapler
};
Three functions exist for one reason: the contract demanded them. They do nothing.
Notice how bad the consequences are. p.scan() compiles, runs, returns normally, and produces no scan. A caller has no way to tell success from silence.
That pushes people toward the patch that makes it worse — a bool canScan() that every caller must remember to check first.
This is what the Interface Segregation Principle is about.
Key idea — Do not force a class to implement functions it has no use for. Prefer several small contracts over one large one.
class Printer {
public:
virtual void print() = 0;
virtual ~Printer() = default;
};
class Scanner {
public:
virtual void scan() = 0;
virtual ~Scanner() = default;
};
Each device then claims only what it can actually do:
class SimplePrinter : public Printer {
public:
void print() override { std::cout << "Printing\n"; }
};
class AllInOne : public Printer, public Scanner {
public:
void print() override { std::cout << "Printing\n"; }
void scan() override { std::cout << "Scanning\n"; }
};

Why it is better: a print queue asks for a Printer& and every object it receives can genuinely print. SimplePrinter has no empty methods, and there is nothing to check before calling.
Watch out — Small does not mean one method each.
Printercould reasonably holdprint(),cancelJob()andpaperLevel()— they all belong to printing.The test is not the count. It is whether some class implementing this contract would be left writing empty functions.
Notice this connects straight back to the last section. An empty scan() is also a quiet Liskov violation: OfficeMachine promised scanning, and SimplePrinter pretended. Oversized interfaces are one of the most common ways children end up unable to keep their parent's promises.
7.5 Dependency Inversion
An order service takes payments.
class FoodOrderService {
public:
void placeOrder(double amount) {
std::cout << "Order placed\n";
payment.charge(amount);
}
private:
StripePayment payment; // hard-wired
};
First, the plain-English version of the word everyone uses here. When one class creates and uses another specific class by name, we say it depends on it. FoodOrderService depends on StripePayment.
Now the company expands and wants UPI payments in India.
FoodOrderService has to change. Read that again — the class that knows about carts, totals and order states has to be edited because of a payment provider. The business logic and one vendor's library are welded together.
Think — Should the code that places orders care which company moves the money?
Almost certainly not. It needs one thing to happen — the amount gets paid — and it has no stake in who does it.
That is the Dependency Inversion Principle.
Key idea — Important logic should depend on a general contract, not on one specific implementation.
Write the contract, and let providers implement it:
class Payment {
public:
virtual void pay(double amount) = 0;
virtual ~Payment() = default;
};
class StripePayment : public Payment {
public:
void pay(double amount) override { std::cout << "Paid " << amount << " via Stripe\n"; }
};
class UpiPayment : public Payment {
public:
void pay(double amount) override { std::cout << "Paid " << amount << " via UPI\n"; }
};
Then the service holds the contract instead of a provider:
class FoodOrderService {
public:
FoodOrderService(Payment& p) : payment(p) {}
void placeOrder(double amount) {
std::cout << "Order placed\n";
payment.pay(amount);
}
private:
Payment& payment;
};
BEGINNER NOTE The : payment(p) before the { } is a constructor initializer list. It runs before the constructor's body.
It is required here because payment is a reference. A reference must be attached to an object the moment it is created — you cannot leave it empty and assign it later inside the braces.

The provider is chosen outside and handed in:
UpiPayment upi;
StripePayment stripe;
FoodOrderService a(upi); a.placeOrder(450);
FoodOrderService b(stripe); b.placeOrder(450);
Order placed
Paid 450 via UPI
Order placed
Paid 450 via Stripe
Handing an object its collaborators from outside, instead of letting it build its own, is called dependency injection. That is the whole idea — a name for the constructor parameter above, not a framework you need to install.

Why it is better: the same FoodOrderService, unmodified, now works with either provider. Adding PayPal means writing one new class and changing one line in main. And testing became possible — a fake Payment that just records the amount lets you test order logic without touching a real payment network.
The word "inversion" refers to which way the arrow points. Before, the important class pointed down at a vendor. Now both the service and the vendor point at the contract in the middle, and neither points at the other.
Watch out — This is worth doing where the thing behind the contract might genuinely change, or needs faking in tests — payment providers, storage, notifications, clocks.
Putting an interface in front of something that will never have a second implementation adds a layer for nothing. A class that uses
std::stringdoes not need aStringLikeabstraction.
How the five fit together

They overlap far more than the five separate letters suggest. The shipping calculator fix used polymorphism to satisfy Open/Closed, which is the same mechanism Dependency Inversion relies on. The oversized office-machine interface caused a Liskov problem as a side effect.
That is normal. These are five angles on one question: when this code has to change, how much of it do I have to touch?
More classes is not the goal
The most common way beginners misapply SOLID is to hear "split responsibilities" and produce this:
Order
OrderCalculator
OrderCalculatorHelper
OrderCalculationManager
OrderCalculationService
OrderCalculationFactory

Six files to add up some numbers. Every one of them is a file to open, a name to remember, and a hop to follow when you are trying to find where the total is actually computed. That design is harder to change than the one it replaced, which means it failed at the only thing it was trying to achieve.
Remember — Good design is not about having more classes. It is about making responsibilities and dependencies clear.
If a small program is already easy to read and easy to change, it is already good. Leave it alone.
Common Misconceptions
❌ SRP means a class should have only one method. Why it sounds right: "single" sounds like a count, and every example splits a big class into small ones. What is true: it means one reason to change. Order can have a dozen methods as long as they all serve one job. A class with one method that talks to the database, formats a receipt and sends an email still breaks SRP.
❌ OCP means you can never modify existing code. Why it sounds right: "closed for modification" sounds absolute. What is true: you fix bugs, you refactor, you delete dead code. The principle is about not having to reopen working logic every time you add a feature of the same kind. A stable if with two fixed cases is not a violation.
❌ LSP means a child must behave exactly like its parent. Why it sounds right: "substitution" suggests the two are interchangeable in every way. What is true: children are supposed to behave differently — that is what overriding is for. What they must not do is break the parent's promises. send() may send by SMS instead of email; it may not silently do nothing.
❌ LSP is just about matching function signatures. Why it sounds right: the compiler checks signatures, so it feels like the compiler is checking substitution. What is true: signatures are the easy half and the compiler handles them. The promises are about behaviour — what the function guarantees when it returns — and no compiler checks those. ReadOnlyArchive had a perfect signature.
❌ ISP means every interface should have exactly one method. Why it sounds right: the fix for a fat interface is always shown as splitting it up. What is true: the test is whether an implementer would be forced to write empty functions. Several related methods in one contract are fine. Splitting until every interface has one method produces its own kind of mess.
❌ DIP means use dependency injection everywhere. Why it sounds right: injection is how DIP is usually demonstrated, so the two get treated as one thing. What is true: injection is a technique; the principle is about not welding important logic to one specific provider. It earns its place where the implementation might change or needs faking in tests — not in front of every type you use.
❌ SOLID means writing more classes. Why it sounds right: most before-and-after examples end with more files than they started with. What is true: sometimes the count goes up, but that is a side effect and not the goal. A design with more classes and murkier responsibilities is worse than the one it replaced.
❌ Every program should apply all five principles. Why it sounds right: they are presented as a set, so they feel like a checklist. What is true: they are tools for managing change. A script you will run twice and delete needs none of them. Apply the one that answers a problem you actually have.
Interview Corner
Q1. Why does this class break SRP?
class TicketBooking {
public:
double calculatePrice();
void saveToDatabase();
void sendSmsToUser();
void generateQrCode();
};
Four unrelated jobs, each with its own trigger for change: pricing rules, the database, the SMS provider, the QR library. Any one of those forces an edit to a file containing the other three. Splitting them means a change to the SMS provider touches only the class that sends SMS.
Q2. How would you add a new shipping method without editing the calculator every time? Define a ShippingMethod contract with a cost() function and give each method its own small class. A new type arrives as a new class; the code that quotes prices takes a ShippingMethod& and never changes. That is Open/Closed, and polymorphism is the mechanism.
Q3. Why does this child break LSP?
class FileStorage {
public:
virtual void save(std::string name) = 0;
};
class ReadOnlyArchive : public FileStorage {
public:
void save(std::string) override {
throw std::runtime_error("read-only");
}
};
FileStorage promises that calling save() stores the file. ReadOnlyArchive inherits that promise and breaks it, so any function taking a FileStorage& can now fail depending on what it was handed. The fix is structural: separate readable from writable storage so the archive never claims a promise it cannot keep.
Q4. A child class overrides a method to do nothing instead of throwing. Is that safer? No — it is usually worse. An exception is loud and immediate. Doing nothing silently means the caller believes the work happened, and the failure surfaces much later and much further from its cause.
Q5. Why is this interface too large, and what would you do?
class OfficeMachine {
virtual void print() = 0;
virtual void scan() = 0;
virtual void fax() = 0;
virtual void staple() = 0;
};
A basic printer has to implement all four, so three become empty stubs that quietly do nothing. Split it into Printer, Scanner and FaxMachine; a simple printer implements one, an all-in-one implements several. No empty functions, and every object handed to a print queue can really print.
Q6. How would you remove this class's direct dependency on Stripe?
class FoodOrderService {
private:
StripePayment payment;
};
Introduce a Payment contract with pay(), have StripePayment implement it, and give FoodOrderService a Payment& through its constructor. The service then works with any provider without being edited, and a fake Payment makes the order logic testable on its own.
Q7. What is actually being "inverted" in Dependency Inversion? The direction of the dependency arrow. Normally important code points down at the specific tools it uses. After inverting, the important code and the tool both point at a contract in the middle, so neither depends on the other. The vendor's class now has to fit your interface rather than your logic fitting the vendor.
Q8. A junior teammate refactors a 50-line script into eleven classes and calls it SOLID. What do you say? That SOLID is about making change cheap, and eleven classes for 50 lines makes it more expensive — more files to open, more indirection to follow. I would ask which change they are expecting, and keep only the separations that make that change easier. If the script was already easy to read and modify, the honest answer is that it needed nothing.
- 1.

Look at diagram A. Which principle is it warning you about, and why?
- 2.

Look at diagram B. What design problem does the arrow show?
- 3.

Look at diagram C. Which principle suggests splitting
Machine, and what is the giveaway? - 4.
The scenario
You inherit this class from a food delivery app. It works, and every team that touches the product ends up editing it.
class FoodOrderSystem {
public:
double calculateTotal();
void applyDiscount();
void saveToDatabase();
void processStripePayment();
void sendConfirmationEmail();
void printReceipt();
};

Redesign it. Around fifteen minutes, and the work is deciding what separates from what — not writing a large application.
Task 1. Which responsibilities should become their own classes, and what is each one's single reason to change?
Task 2. How would you add a UPI payment option without editing the order logic?
Task 3. Marketing wants a new discount type every festival. Which principle does that pressure, and what is your answer to it?
Task 4. Name one abstraction that genuinely earns its place here, and one you would refuse to add.
Hint — For Task 1, do not ask "are these related?" Ask "who would file the ticket that forces this to change?" Different people means different classes.
For Tasks 2 and 3, notice that both questions have the same shape: something that will keep arriving in new varieties. That shape almost always wants a contract plus small implementations.
A possible solution
Three contracts, for the three things that will keep changing:
class Payment {
public:
virtual void pay(double amount) = 0;
virtual ~Payment() = default;
};
class Notifier {
public:
virtual void notify(std::string message) = 0;
virtual ~Notifier() = default;
};
class Discount {
public:
virtual double apply(double total) const = 0;
virtual ~Discount() = default;
};
Providers implement them, one small class each:
class UpiPayment : public Payment {
public:
void pay(double amount) override { std::cout << "Paid " << amount << " via UPI\n"; }
};
class SmsNotifier : public Notifier {
public:
void notify(std::string m) override { std::cout << "SMS: " << m << "\n"; }
};
class FestiveDiscount : public Discount {
public:
double apply(double total) const override { return total * 0.9; }
};
The order knows only what is in it, and storage is its own job:
class Order {
public:
void addItem(double price) { prices.push_back(price); }
double total() const {
double t = 0;
for (double p : prices) t += p;
return t;
}
private:
std::vector<double> prices;
};
class OrderRepository {
public:
void save(const Order& o) { std::cout << "Stored order of " << o.total() << "\n"; }
};