Skip to content
Bitloops - Give your AI agents high-signal context in milliseconds.
HomeAbout us
DocsBlog
ResourcesSoftware DesignSOLID Principles: Five Rules for Better Code

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.

9 min readUpdated March 4, 2026Software Design

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!
javascript

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);
javascript

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 ReportGenerator
javascript

The 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());
javascript

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 error
javascript

Penguin 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"
javascript

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 20
javascript

Square 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();
}
javascript

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!
javascript

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 needs
javascript

D: 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 OrderService
javascript

High-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 testing
javascript

Now 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 testing
javascript

FAQ

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

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