SOLID Principles: Object-Oriented Design Guide
Every codebase eventually reaches a breaking point. Adding features becomes a game of Jenga—touch one class and three others collapse. Tests break for unrelated changes. New developers spend weeks...
Key Insights
- SOLID principles aren’t academic exercises—they’re battle-tested patterns that make code easier to test, extend, and maintain without rewriting existing functionality.
- Each principle addresses a specific type of coupling that causes pain during maintenance: SRP handles responsibility coupling, OCP handles modification coupling, LSP handles inheritance coupling, ISP handles interface coupling, and DIP handles implementation coupling.
- Apply SOLID incrementally and pragmatically; over-engineering a simple CRUD app with excessive abstractions creates more problems than it solves.
Introduction: Why SOLID Matters
Every codebase eventually reaches a breaking point. Adding features becomes a game of Jenga—touch one class and three others collapse. Tests break for unrelated changes. New developers spend weeks understanding tangled dependencies.
SOLID principles, introduced by Robert C. Martin, directly address these pain points. They’re not theoretical ideals but practical guidelines extracted from decades of software maintenance nightmares. Each principle targets a specific type of coupling that makes code rigid, fragile, and difficult to test.
Throughout this guide, I’ll show before-and-after code examples demonstrating how applying these principles transforms problematic code into maintainable systems. The examples use TypeScript, but the concepts apply to any object-oriented language.
Single Responsibility Principle (SRP)
A class should have only one reason to change. This doesn’t mean one method—it means one axis of change, one stakeholder whose requirements drive modifications.
Here’s a common violation:
class UserService {
async createUser(email: string, password: string): Promise<User> {
// Validation logic
if (!email.includes('@')) {
throw new Error('Invalid email');
}
// Password hashing
const hashedPassword = await bcrypt.hash(password, 10);
// Database operation
const user = await this.db.query(
'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
[email, hashedPassword]
);
// Email sending
await this.sendWelcomeEmail(email);
// Audit logging
await this.logAuditEvent('USER_CREATED', user.id);
return user;
}
private async sendWelcomeEmail(email: string): Promise<void> {
const template = this.loadTemplate('welcome');
await this.smtpClient.send({ to: email, body: template });
}
private async logAuditEvent(event: string, userId: string): Promise<void> {
await this.db.query('INSERT INTO audit_log...');
}
}
This class changes when email templates change, when the database schema changes, when audit requirements change, and when validation rules change. Four reasons to change means four sources of bugs.
Refactored version:
class UserRepository {
async create(email: string, hashedPassword: string): Promise<User> {
return this.db.query(
'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
[email, hashedPassword]
);
}
}
class PasswordHasher {
async hash(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
}
class WelcomeEmailSender {
async send(email: string): Promise<void> {
const template = this.templateLoader.load('welcome');
await this.mailer.send({ to: email, body: template });
}
}
class AuditLogger {
async log(event: string, entityId: string): Promise<void> {
await this.repository.create({ event, entityId, timestamp: new Date() });
}
}
class UserRegistrationService {
constructor(
private userRepo: UserRepository,
private passwordHasher: PasswordHasher,
private welcomeEmail: WelcomeEmailSender,
private auditLogger: AuditLogger
) {}
async register(email: string, password: string): Promise<User> {
const hashedPassword = await this.passwordHasher.hash(password);
const user = await this.userRepo.create(email, hashedPassword);
await this.welcomeEmail.send(email);
await this.auditLogger.log('USER_CREATED', user.id);
return user;
}
}
Each class now has one reason to change. The UserRegistrationService orchestrates the workflow but delegates each responsibility to focused collaborators.
Open/Closed Principle (OCP)
Software entities should be open for extension but closed for modification. When requirements change, you should add new code rather than changing existing, tested code.
The strategy pattern is the classic OCP implementation:
// Violation: Adding a new payment method requires modifying this class
class PaymentProcessor {
process(payment: Payment): void {
if (payment.type === 'credit_card') {
// Credit card logic
} else if (payment.type === 'paypal') {
// PayPal logic
} else if (payment.type === 'crypto') {
// Crypto logic - had to modify existing class!
}
}
}
Every new payment method forces changes to tested code. Here’s the OCP-compliant version:
interface PaymentStrategy {
supports(type: string): boolean;
process(payment: Payment): Promise<PaymentResult>;
}
class CreditCardPayment implements PaymentStrategy {
supports(type: string): boolean {
return type === 'credit_card';
}
async process(payment: Payment): Promise<PaymentResult> {
// Credit card specific logic
return this.stripeClient.charge(payment.amount, payment.cardToken);
}
}
class PayPalPayment implements PaymentStrategy {
supports(type: string): boolean {
return type === 'paypal';
}
async process(payment: Payment): Promise<PaymentResult> {
return this.paypalClient.executePayment(payment.paypalOrderId);
}
}
class PaymentProcessor {
constructor(private strategies: PaymentStrategy[]) {}
async process(payment: Payment): Promise<PaymentResult> {
const strategy = this.strategies.find(s => s.supports(payment.type));
if (!strategy) {
throw new Error(`Unsupported payment type: ${payment.type}`);
}
return strategy.process(payment);
}
}
Adding cryptocurrency payments now means creating a new CryptoPayment class and registering it—zero modifications to existing code.
Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering program correctness. This isn’t just about method signatures matching—it’s about behavioral contracts.
The classic violation:
class Bird {
fly(): void {
console.log('Flying through the air');
}
}
class Penguin extends Bird {
fly(): void {
throw new Error('Penguins cannot fly!'); // LSP violation
}
}
function makeBirdFly(bird: Bird): void {
bird.fly(); // Crashes if passed a Penguin
}
Code expecting a Bird breaks when given a Penguin. The fix involves rethinking the hierarchy:
interface Bird {
move(): void;
eat(): void;
}
interface FlyingBird extends Bird {
fly(): void;
}
class Sparrow implements FlyingBird {
move(): void {
this.fly();
}
fly(): void {
console.log('Flying through the air');
}
eat(): void {
console.log('Eating seeds');
}
}
class Penguin implements Bird {
move(): void {
this.swim();
}
swim(): void {
console.log('Swimming through water');
}
eat(): void {
console.log('Eating fish');
}
}
function makeBirdMove(bird: Bird): void {
bird.move(); // Works for all birds
}
Now Penguin doesn’t promise capabilities it can’t deliver. Functions working with Bird use only guaranteed behaviors.
Interface Segregation Principle (ISP)
Clients shouldn’t be forced to depend on interfaces they don’t use. Fat interfaces create unnecessary coupling.
// Violation: Robots don't eat or sleep
interface IWorker {
work(): void;
eat(): void;
sleep(): void;
}
class HumanWorker implements IWorker {
work(): void { /* ... */ }
eat(): void { /* ... */ }
sleep(): void { /* ... */ }
}
class RobotWorker implements IWorker {
work(): void { /* ... */ }
eat(): void { throw new Error('Robots do not eat'); }
sleep(): void { throw new Error('Robots do not sleep'); }
}
Split into role-based interfaces:
interface Workable {
work(): void;
}
interface Feedable {
eat(): void;
}
interface Restable {
sleep(): void;
}
class HumanWorker implements Workable, Feedable, Restable {
work(): void { console.log('Working on tasks'); }
eat(): void { console.log('Taking lunch break'); }
sleep(): void { console.log('Resting at home'); }
}
class RobotWorker implements Workable {
work(): void { console.log('Processing tasks 24/7'); }
}
// Functions depend only on what they need
function assignTask(worker: Workable): void {
worker.work();
}
function scheduleLunch(worker: Feedable): void {
worker.eat();
}
Now RobotWorker implements only relevant interfaces. Functions declare exactly what they need.
Dependency Inversion Principle (DIP)
High-level modules shouldn’t depend on low-level modules. Both should depend on abstractions.
// Violation: NotificationService is tightly coupled to EmailSender
class EmailSender {
send(to: string, message: string): void {
// SMTP implementation
}
}
class NotificationService {
private emailSender = new EmailSender(); // Direct dependency
notify(userId: string, message: string): void {
const user = this.getUser(userId);
this.emailSender.send(user.email, message);
}
}
Adding SMS notifications requires modifying NotificationService. Testing requires a real email server.
interface MessageSender {
send(recipient: string, message: string): Promise<void>;
}
class EmailSender implements MessageSender {
async send(recipient: string, message: string): Promise<void> {
await this.smtpClient.send({ to: recipient, body: message });
}
}
class SMSSender implements MessageSender {
async send(recipient: string, message: string): Promise<void> {
await this.twilioClient.sendSMS(recipient, message);
}
}
class NotificationService {
constructor(private sender: MessageSender) {} // Depends on abstraction
async notify(userId: string, message: string): Promise<void> {
const user = await this.getUser(userId);
await this.sender.send(user.contactInfo, message);
}
}
// Easy to test with mock
class MockSender implements MessageSender {
public sentMessages: Array<{recipient: string; message: string}> = [];
async send(recipient: string, message: string): Promise<void> {
this.sentMessages.push({ recipient, message });
}
}
The high-level NotificationService now depends on the MessageSender abstraction. Implementations are injected, making the system flexible and testable.
Applying SOLID in Practice
SOLID principles aren’t all-or-nothing rules. Apply them when the cost of rigidity exceeds the cost of abstraction.
When to apply aggressively:
- Code that changes frequently
- Code with multiple consumers
- Code that needs testing in isolation
- Domain logic that shouldn’t know about infrastructure
When to be pragmatic:
- Simple CRUD operations
- Scripts and one-off tools
- Prototypes and MVPs
- Code with a single, stable use case
Here’s a mini case study combining multiple principles:
// Before: Violates SRP, OCP, DIP
class OrderProcessor {
process(order: Order): void {
// Calculate total (business logic)
let total = 0;
for (const item of order.items) {
total += item.price * item.quantity;
if (item.quantity > 10) total *= 0.9; // Discount
}
// Save to database (infrastructure)
db.query('INSERT INTO orders...', [order]);
// Send confirmation (infrastructure)
smtp.send(order.customerEmail, 'Order confirmed');
}
}
// After: SOLID-compliant
interface PricingStrategy {
calculate(items: OrderItem[]): number;
}
interface OrderRepository {
save(order: Order): Promise<void>;
}
interface OrderNotifier {
sendConfirmation(order: Order): Promise<void>;
}
class BulkDiscountPricing implements PricingStrategy {
calculate(items: OrderItem[]): number {
return items.reduce((total, item) => {
const subtotal = item.price * item.quantity;
return total + (item.quantity > 10 ? subtotal * 0.9 : subtotal);
}, 0);
}
}
class OrderProcessor {
constructor(
private pricing: PricingStrategy,
private repository: OrderRepository,
private notifier: OrderNotifier
) {}
async process(order: Order): Promise<void> {
order.total = this.pricing.calculate(order.items);
await this.repository.save(order);
await this.notifier.sendConfirmation(order);
}
}
The refactored version separates concerns (SRP), allows new pricing strategies without modification (OCP), and depends on abstractions (DIP). Each component is testable in isolation.
Start with the simplest design that works. When you feel pain—difficult tests, cascading changes, copy-paste code—reach for SOLID. The principles are tools for managing complexity, not goals in themselves.