SOLID Principles: Five Rules for Better Code
SOLID principles prevent code from turning into an untouchable mess. They're not dogma—they're guidelines that solve real problems. Follow them where they matter, ignore them where they don't.
Why SOLID Matters
SOLID principles are not rules. They're guidelines that help you avoid common anti-patterns. Follow them, and your code gets more flexible, testable, and maintainable. Violate them, and you end up with tightly coupled messes.
The principles work together. They're not independent—they reinforce each other.
S: Single Responsibility Principle
Definition: A class should have one reason to change. One job. One responsibility.
If you can describe what a class does in one sentence without using "and," you're on the right track.
Violation: Multiple Reasons to Change
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
// Responsibility 1: User data management
updateEmail(newEmail) {
this.email = newEmail;
}
// Responsibility 2: Email validation
validateEmail(email) {
return email.includes('@') && email.includes('.');
}
// Responsibility 3: Password hashing
hashPassword(password) {
return crypto.hash(password);
}
// Responsibility 4: Database persistence
save() {
database.insert('users', { name: this.name, email: this.email });
}
// Responsibility 5: Email sending
sendWelcomeEmail() {
emailService.send(this.email, 'Welcome');
}
// Responsibility 6: Logging
logLogin() {
logger.info(`User ${this.name} logged in`);
}
}
// This class changes when:
// - User data structure changes
// - Email validation rules change
// - Password hashing algorithm changes
// - Database schema changes
// - Email templates change
// - Logging requirements change
// Six reasons to change one class!Each change could break something else. The class is hard to test (needs database, email service, logger).
Fix: Separate Concerns
// Single Responsibility: User data only
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
updateEmail(newEmail) {
if (!EmailValidator.isValid(newEmail)) {
throw new Error('Invalid email');
}
this.email = newEmail;
}
}
// Single Responsibility: Email validation only
class EmailValidator {
static isValid(email) {
return email.includes('@') && email.includes('.');
}
}
// Single Responsibility: Password security only
class PasswordHasher {
static hash(password) {
return crypto.hash(password);
}
}
// Single Responsibility: Persistence only
class UserRepository {
save(user) {
database.insert('users', { name: user.name, email: user.email });
}
}
// Single Responsibility: Notifications only
class WelcomeEmailSender {
send(user) {
emailService.send(user.email, 'Welcome ' + user.name);
}
}
// Single Responsibility: Audit logging only
class LoginLogger {
logLogin(user) {
logger.info(`User ${user.name} logged in`);
}
}
// Usage: Composed, testable
const user = new User('Alice', 'alice@example.com');
userRepository.save(user);
welcomeSender.send(user);
loginLogger.logLogin(user);Now each class has one reason to change. Easier to test. Easier to modify.
O: Open-Closed Principle
Definition: Open for extension, closed for modification. You should be able to add new features without changing existing code.
Violation: Changing Existing Code
class ReportGenerator {
generate(format) {
if (format === 'pdf') {
return this.generatePDF();
} else if (format === 'excel') {
return this.generateExcel();
} else if (format === 'csv') {
return this.generateCSV();
}
// Every new format requires changing this class
}
generatePDF() { /* ... */ }
generateExcel() { /* ... */ }
generateCSV() { /* ... */ }
}
// Add a new format (HTML)?
// You must modify ReportGeneratorThe class isn't closed for modification. Every new format requires editing it.
Fix: Use Strategies
interface ReportFormatter {
format(data): string;
}
class PDFFormatter implements ReportFormatter {
format(data) {
// PDF logic
}
}
class ExcelFormatter implements ReportFormatter {
format(data) {
// Excel logic
}
class CSVFormatter implements ReportFormatter {
format(data) {
// CSV logic
}
}
class HTMLFormatter implements ReportFormatter {
format(data) {
// HTML logic
}
}
class ReportGenerator {
generate(data, formatter) {
// No if statements, no conditions
// Just use the formatter
return formatter.format(data);
}
}
// Usage: Add new format without changing ReportGenerator
const pdfReport = generator.generate(data, new PDFFormatter());
const htmlReport = generator.generate(data, new HTMLFormatter());ReportGenerator is closed for modification. New formats are added by creating new formatters (extension).
L: Liskov Substitution Principle
Definition: If S is a subtype of T, objects of type S should be substitutable for objects of type T without breaking the program.
In plain language: A subclass must be usable anywhere its parent class is used.
Violation: Broken Contract
class Bird {
fly() {
return "flying";
}
}
class Penguin extends Bird {
fly() {
throw new Error("Penguins can't fly");
}
}
function makeBirdFly(bird) {
return bird.fly(); // Assumes Bird can fly
}
// This breaks!
const penguin = new Penguin();
makeBirdFly(penguin); // Throws errorPenguin violates the Bird contract. It says it can fly but can't.
Fix: Correct the Hierarchy
class Bird {
move() {
return "moving";
}
}
class FlyingBird extends Bird {
fly() {
return "flying";
}
move() {
return this.fly();
}
}
class SwimmingBird extends Bird {
swim() {
return "swimming";
}
move() {
return this.swim();
}
}
class Penguin extends SwimmingBird {
swim() {
return "swimming gracefully";
}
}
// Now it works
function makeBirdMove(bird) {
return bird.move(); // All birds can move
}
const penguin = new Penguin();
makeBirdMove(penguin); // Works: "swimming gracefully"
const eagle = new FlyingBird();
makeBirdMove(eagle); // Works: "flying"Now the hierarchy is correct. Penguins are swimming birds, not flying birds.
Real-World Example: Rectangle vs. Square
// Bad: Square is a Rectangle, but...
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
setWidth(w) { this.width = w; }
setHeight(h) { this.height = h; }
getArea() {
return this.width * this.height;
}
}
class Square extends Rectangle {
setWidth(w) {
this.width = w;
this.height = w; // Square must keep width == height
}
setHeight(h) {
this.width = h;
this.height = h;
}
}
function testRectangle(rect) {
rect.setWidth(5);
rect.setHeight(4);
return rect.getArea() === 20; // Expects 20
}
const square = new Square(5, 5);
testRectangle(square); // Returns false! Area is 16, not 20Square violates the Rectangle contract. Setters have different semantics.
Fix: Don't force the hierarchy.
class Shape {
getArea() {
// Abstract
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
class Square extends Shape {
constructor(side) {
super();
this.side = side;
}
getArea() {
return this.side * this.side;
}
}
// Both are Shapes, but have their own contracts
function getShapeArea(shape) {
return shape.getArea();
}I: Interface Segregation Principle
Definition: Don't force clients to depend on interfaces they don't use. Create many specific interfaces instead of one large interface.
Violation: Fat Interface
interface Worker {
work(): void;
eat(): void;
sleep(): void;
manage(): void;
}
class Manager implements Worker {
work() { /* managing */ }
eat() { /* eating */ }
sleep() { /* sleeping */ }
manage() { /* managing others */ }
}
class Robot implements Worker {
work() { /* working */ }
eat() {
throw new Error("Robots don't eat");
}
sleep() {
throw new Error("Robots don't sleep");
}
manage() {
throw new Error("Robots can't manage");
}
}
// Robot must implement methods it doesn't use!Robot is forced to implement methods it doesn't care about.
Fix: Segregate Interfaces
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
interface Sleepable {
sleep(): void;
}
interface Manageable {
manage(): void;
}
class Manager implements Workable, Eatable, Sleepable, Manageable {
work() { /* ... */ }
eat() { /* ... */ }
sleep() { /* ... */ }
manage() { /* ... */ }
}
class Robot implements Workable {
work() { /* ... */ }
// No unnecessary methods!
}
// Each class only implements what it needsD: Dependency Inversion Principle
Definition: High-level modules shouldn't depend on low-level modules. Both should depend on abstractions.
Violation: Concrete Dependency
class OrderService {
constructor() {
// High-level module depends on low-level concrete class
this.database = new MySQLDatabase();
this.emailService = new GmailEmailService();
}
placeOrder(order) {
this.database.insert('orders', order);
this.emailService.send(order.customer.email, 'Order placed');
}
}
// Can't test without real database and email service
// Can't switch to PostgreSQL without changing OrderService
// Can't use a different email provider without changing OrderServiceHigh-level logic (OrderService) depends on low-level details (MySQL, Gmail).
Fix: Depend on Abstractions
interface Database {
insert(table, data): void;
}
interface EmailService {
send(to, message): void;
}
class OrderService {
constructor(database, emailService) {
// Depend on abstractions, not concretions
this.database = database;
this.emailService = emailService;
}
placeOrder(order) {
this.database.insert('orders', order);
this.emailService.send(order.customer.email, 'Order placed');
}
}
// Low-level implementations
class MySQLDatabase implements Database {
insert(table, data) { /* MySQL logic */ }
}
class PostgreSQLDatabase implements Database {
insert(table, data) { /* PostgreSQL logic */ }
}
class GmailService implements EmailService {
send(to, message) { /* Gmail logic */ }
}
class SendGridService implements EmailService {
send(to, message) { /* SendGrid logic */ }
}
// Usage: OrderService doesn't care which implementation is used
const service1 = new OrderService(new MySQLDatabase(), new GmailService());
const service2 = new OrderService(new PostgreSQLDatabase(), new SendGridService());
const service3 = new OrderService(new MockDatabase(), new MockEmailService()); // For testingNow high-level logic is decoupled from low-level details. Easy to test, easy to change databases or email providers. This is the same principle that drives Hexagonal Architecture — ports and adapters keep your domain logic framework-independent.
How SOLID Principles Work Together
// Example: Building a payment processing system with SOLID
// D: Inversion of Dependency
// High-level depends on abstraction, not concrete payment gateways
interface PaymentGateway {
process(amount): Promise<PaymentResult>;
}
// O: Open-Closed
// Can add new payment gateways without changing processor
class StripeGateway implements PaymentGateway {
process(amount) { /* Stripe logic */ }
}
class PayPalGateway implements PaymentGateway {
process(amount) { /* PayPal logic */ }
}
// S: Single Responsibility
// Each class has one job
class PaymentProcessor {
constructor(gateway) {
this.gateway = gateway;
}
process(amount) {
return this.gateway.process(amount);
}
}
class PaymentLogger {
log(transaction) {
// Logging only
}
}
class PaymentNotifier {
notify(transaction) {
// Notification only
}
}
// I: Interface Segregation
// Each class depends only on interfaces it uses
interface PaymentProcessor {
process(amount): Promise<PaymentResult>;
}
interface PaymentLogger {
log(transaction): void;
}
// L: Liskov Substitution
// Any PaymentGateway can be used in place of another
function processOrderPayment(order, gateway) {
return gateway.process(order.total);
}
processOrderPayment(order, new StripeGateway()); // Works
processOrderPayment(order, new PayPalGateway()); // Works
processOrderPayment(order, new MockGateway()); // Works for testingFAQ
Do I need to follow all SOLID principles?
They're guidelines, not laws. The more you follow them, the better your code. Start with SRP and DIP—those give the most benefit.
Can SOLID principles conflict?
Rarely. They support each other. If you feel conflict, you're probably applying one wrong.
Is SOLID only for object-oriented code?
The principles apply broadly. Functional programming has equivalent concepts. The idea of separation of concerns is universal.
When should I start applying SOLID?
Start early. It's easier to write code right than to refactor code later. But don't over-engineer at the start.
How much abstraction is too much?
If you can't easily trace through the code mentally, you have too much. SOLID isn't about maximum abstraction—it's about right-sized abstraction.
Primary Sources
- Robert Martin's handbook on writing clean, maintainable code and SOLID principles. Clean Code
- Robert Martin's guide to organizing architecture layers and dependency rules. Clean Architecture
- Foundational domain-driven design text on modeling complex business domains effectively. Domain-Driven Design
- Practical implementation strategies for domain-driven design in enterprise systems. Implementing DDD
- Patterns for organizing application layers and managing architectural complexity. EAA Patterns
- Martin Fowler's techniques for refactoring code to improve design and maintainability. Refactoring
- Kent Beck's foundational approach to test-driven development and design improvement. TDD by Example
More in this hub
SOLID Principles: Five Rules for Better Code
8 / 10Previous
Article 7
Behavior-Driven Development: Bridging the Communication Gap
Next
Article 9
Anti-Patterns in Software Design: What NOT to Do
Also in this hub
Get Started with Bitloops.
Apply what you learn in these hubs to real AI-assisted delivery workflows with shared context, traceable reasoning, and architecture-aware engineering practices.
curl -sSL https://bitloops.com/install.sh | bash