SOLID Principles Through Real Code, Not Definitions
SOLID gets taught as five definitions to memorize. That’s not how principles are useful. Principles are useful when you can recognize a violation in a codebase, name what’s wrong, and know what direction to move in.
Here’s each principle through the lens of what violation looks like, what the fix looks like, and what you actually gain.
S - Single Responsibility Principle
A class should have one reason to change.
The definition sounds clean. In practice, it means: how many different kinds of problems could cause you to edit this class?
// Violation: this class has three reasons to change
class User {
constructor(public name: string, public email: string) {}
save() {
// database logic
db.query(`INSERT INTO users (name, email) VALUES (?, ?)`, [this.name, this.email]);
}
sendWelcomeEmail() {
// email logic
mailer.send({ to: this.email, subject: 'Welcome!' });
}
toJSON() {
return { name: this.name, email: this.email };
}
}
This class changes if the database schema changes, if the email provider changes, or if the API response format changes. Three unrelated concerns in one class.
// Each class has one reason to change
class User {
constructor(public name: string, public email: string) {}
}
class UserRepository {
save(user: User) {
db.query(`INSERT INTO users (name, email) VALUES (?, ?)`, [user.name, user.email]);
}
}
class UserMailer {
sendWelcome(user: User) {
mailer.send({ to: user.email, subject: 'Welcome!' });
}
}
The clearer signal: if you’re writing a class and you find yourself reaching for unrelated imports - a database client, an email library, and a serializer - something is absorbing too much.
O - Open/Closed Principle
Open for extension, closed for modification.
You should be able to add new behavior without editing existing code. The way violations feel: every time a new case is added, you open the same file.
// Violation: adding a new payment method means editing this function
function processPayment(order: Order, method: string) {
if (method === 'card') {
stripeClient.charge(order.total, order.cardToken);
} else if (method === 'paypal') {
paypalClient.charge(order.total, order.paypalEmail);
}
// every new method is another else-if
}
Every new payment method is a modification to this function - and to its tests, and to whoever is reviewing the diff. The function is not closed to modification.
interface PaymentProcessor {
charge(order: Order): void;
}
class StripeProcessor implements PaymentProcessor {
charge(order: Order) {
stripeClient.charge(order.total, order.cardToken);
}
}
class PayPalProcessor implements PaymentProcessor {
charge(order: Order) {
paypalClient.charge(order.total, order.paypalEmail);
}
}
function processPayment(order: Order, processor: PaymentProcessor) {
processor.charge(order);
}
Now adding a new payment method means adding a new class. The processPayment function doesn’t change. Existing tests don’t change. The blast radius of a new feature is contained.
L - Liskov Substitution Principle
Subtypes must be substitutable for their base types.
This one is the easiest to violate and the hardest to spot. If you have a class that inherits from another, the subclass must behave consistently with the base class contract. Code using the base class shouldn’t need to know which subclass it’s dealing with.
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(w: number) { this.width = w; }
setHeight(h: number) { this.height = h; }
area() { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(w: number) {
this.width = w;
this.height = w; // squares must stay square
}
setHeight(h: number) {
this.width = h;
this.height = h;
}
}
This looks reasonable. But consider code that uses Rectangle:
function stretchWidth(r: Rectangle) {
r.setWidth(r.area() / r.setHeight.length); // any logic that treats width and height as independent
}
When a Square is passed in, the behavior breaks - setting width also sets height. The Square cannot substitute for Rectangle without violating the caller’s expectations.
The fix here isn’t to patch the Square. It’s to question whether Square should extend Rectangle at all - they share geometric properties but not a behavioral contract. Separate types, or a common interface that doesn’t expose independent mutation of width and height, is the correct model.
The LSP violation smell: checking instanceof in code that’s supposed to work on a base type. If you’re writing if (shape instanceof Square), the substitution is broken.
I - Interface Segregation Principle
No code should depend on methods it doesn’t use.
Fat interfaces force implementors to provide methods that are irrelevant to them, and force callers to acknowledge methods they don’t need.
// Violation: every device must implement every method
interface Printer {
print(doc: Document): void;
scan(doc: Document): void;
fax(doc: Document): void;
staple(doc: Document): void;
}
class BasicPrinter implements Printer {
print(doc: Document) { /* real impl */ }
scan(doc: Document) { throw new Error('Not supported'); }
fax(doc: Document) { throw new Error('Not supported'); }
staple(doc: Document) { throw new Error('Not supported'); }
}
BasicPrinter implements an interface it can’t fulfill. Any caller that uses a Printer now can’t trust that calling scan will work.
interface Printable { print(doc: Document): void; }
interface Scannable { scan(doc: Document): void; }
interface Faxable { fax(doc: Document): void; }
class BasicPrinter implements Printable {
print(doc: Document) { /* real impl */ }
}
class OfficePrinter implements Printable, Scannable, Faxable {
print(doc: Document) { /* ... */ }
scan(doc: Document) { /* ... */ }
fax(doc: Document) { /* ... */ }
}
Each consumer takes only what it needs. BasicPrinter implements only what it can. No throwing NotImplemented errors that masquerade as legitimate behavior.
D - Dependency Inversion Principle
Depend on abstractions, not concretions.
High-level modules shouldn’t import low-level modules directly. Both should depend on interfaces.
// Violation: business logic is coupled to a specific database
class OrderService {
private db = new PostgresDatabase(); // hard dependency
getOrder(id: string) {
return this.db.query(`SELECT * FROM orders WHERE id = ?`, [id]);
}
}
OrderService is now inseparable from PostgreSQL. You can’t test it without a real database. You can’t swap the database. You can’t mock it.
interface OrderRepository {
findById(id: string): Promise<Order>;
}
class PostgresOrderRepository implements OrderRepository {
async findById(id: string) {
return db.query(`SELECT * FROM orders WHERE id = ?`, [id]);
}
}
class OrderService {
constructor(private repo: OrderRepository) {}
getOrder(id: string) {
return this.repo.findById(id);
}
}
// In tests:
const mockRepo: OrderRepository = { findById: async () => mockOrder };
const service = new OrderService(mockRepo);
OrderService now depends on an interface, not a database driver. The database can change. The test can inject a mock. The business logic remains isolated.
The five principles work together, not in isolation. A class that violates SRP often ends up violating OCP too - because it’s absorbing too much, changing it means touching business logic, persistence, and presentation at once. A class that violates DIP is hard to test, which means violations of everything else tend to go undetected.
You don’t need to apply all five to every class you write. Most well-written code satisfies them naturally. What the principles give you is a vocabulary for the specific way a design is going wrong, and a direction to move when it is.