Skip to main content

SOLID Principles in Modern Software Development

Ryan Dahlberg
Ryan Dahlberg
September 18, 2025 12 min read
Share:
SOLID Principles in Modern Software Development

SOLID Principles in Modern Software Development

Every developer has been there: you open a file to make a “simple change,” and suddenly you’re refactoring half the codebase. Methods are tightly coupled, classes have mysterious dependencies, and that one god object somehow touches everything.

SOLID principles exist to prevent exactly this scenario.

After years of building systems ranging from microservices to AI orchestration platforms like Cortex, I’ve learned that SOLID isn’t just academic theory—it’s the difference between software that evolves gracefully and software that collapses under its own weight.

What is SOLID?

SOLID is an acronym for five object-oriented design principles introduced by Robert C. Martin (Uncle Bob):

  • Single Responsibility Principle
  • Open/Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

These principles guide you toward code that is:

  • Easy to understand
  • Simple to extend
  • Safe to modify
  • Resistant to bugs

Let’s explore each principle with real-world examples.

Single Responsibility Principle (SRP)

A class should have one, and only one, reason to change.

The Problem

class UserService {
  createUser(data: UserData) {
    // Validate user data
    if (!data.email.includes('@')) {
      throw new Error('Invalid email');
    }

    // Save to database
    const user = db.users.insert(data);

    // Send welcome email
    emailService.send({
      to: user.email,
      subject: 'Welcome!',
      body: 'Thanks for signing up!'
    });

    // Log analytics
    analytics.track('user_created', {
      userId: user.id,
      timestamp: Date.now()
    });

    return user;
  }
}

This class has four reasons to change:

  1. Validation rules change
  2. Database schema changes
  3. Email templates change
  4. Analytics requirements change

The Solution

class UserValidator {
  validate(data: UserData): ValidationResult {
    if (!data.email.includes('@')) {
      return { valid: false, error: 'Invalid email' };
    }
    return { valid: true };
  }
}

class UserRepository {
  create(data: UserData): User {
    return db.users.insert(data);
  }
}

class UserNotifier {
  sendWelcomeEmail(user: User): void {
    emailService.send({
      to: user.email,
      subject: 'Welcome!',
      body: 'Thanks for signing up!'
    });
  }
}

class UserAnalytics {
  trackCreation(user: User): void {
    analytics.track('user_created', {
      userId: user.id,
      timestamp: Date.now()
    });
  }
}

class UserService {
  constructor(
    private validator: UserValidator,
    private repository: UserRepository,
    private notifier: UserNotifier,
    private analytics: UserAnalytics
  ) {}

  createUser(data: UserData): User {
    const validation = this.validator.validate(data);
    if (!validation.valid) {
      throw new Error(validation.error);
    }

    const user = this.repository.create(data);
    this.notifier.sendWelcomeEmail(user);
    this.analytics.trackCreation(user);

    return user;
  }
}

Now each class has one reason to change, and responsibilities are clear.

Real-World Impact

In Cortex, we apply SRP rigorously:

  • TaskRouter only routes tasks
  • PromptBuilder only constructs prompts
  • ExecutionEngine only executes workflows
  • MetricsCollector only gathers metrics

This separation makes testing trivial and changes isolated.

Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification.

The Problem

class PaymentProcessor {
  process(payment: Payment) {
    if (payment.method === 'credit_card') {
      // Process credit card
      stripe.charge(payment.amount, payment.card);
    } else if (payment.method === 'paypal') {
      // Process PayPal
      paypal.sendPayment(payment.amount, payment.email);
    } else if (payment.method === 'crypto') {
      // Process crypto
      blockchain.transfer(payment.amount, payment.wallet);
    }
  }
}

Adding a new payment method requires modifying this class. This violates OCP.

The Solution

interface PaymentMethod {
  process(amount: number): Promise<PaymentResult>;
}

class CreditCardPayment implements PaymentMethod {
  constructor(private card: CardDetails) {}

  async process(amount: number): Promise<PaymentResult> {
    return stripe.charge(amount, this.card);
  }
}

class PayPalPayment implements PaymentMethod {
  constructor(private email: string) {}

  async process(amount: number): Promise<PaymentResult> {
    return paypal.sendPayment(amount, this.email);
  }
}

class CryptoPayment implements PaymentMethod {
  constructor(private wallet: string) {}

  async process(amount: number): Promise<PaymentResult> {
    return blockchain.transfer(amount, this.wallet);
  }
}

class PaymentProcessor {
  async process(payment: Payment, method: PaymentMethod): Promise<PaymentResult> {
    return method.process(payment.amount);
  }
}

Now you can add new payment methods without modifying PaymentProcessor. Just create a new class implementing PaymentMethod.

The Power of Extension

class ApplePayPayment implements PaymentMethod {
  constructor(private token: string) {}

  async process(amount: number): Promise<PaymentResult> {
    return applePay.charge(amount, this.token);
  }
}

// Zero changes to PaymentProcessor!

Real-World Application

In Cortex’s task routing system, we use OCP extensively:

interface RoutingStrategy {
  selectAgent(task: Task, agents: Agent[]): Agent;
}

class LoadBalancingStrategy implements RoutingStrategy {
  selectAgent(task: Task, agents: Agent[]): Agent {
    return agents.reduce((min, agent) =>
      agent.activeTaskCount < min.activeTaskCount ? agent : min
    );
  }
}

class SpecialtyRoutingStrategy implements RoutingStrategy {
  selectAgent(task: Task, agents: Agent[]): Agent {
    return agents.find(a => a.specialty === task.category)
      || agents[0];
  }
}

class MLRoutingStrategy implements RoutingStrategy {
  constructor(private model: MLModel) {}

  selectAgent(task: Task, agents: Agent[]): Agent {
    const scores = agents.map(a => this.model.predict(task, a));
    return agents[scores.indexOf(Math.max(...scores))];
  }
}

We can add new routing strategies without touching the router itself.

Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of a subclass without breaking the application.

The Problem

class Bird {
  fly() {
    console.log('Flying!');
  }
}

class Penguin extends Bird {
  fly() {
    throw new Error('Penguins cannot fly!');
  }
}

function makeBirdFly(bird: Bird) {
  bird.fly(); // This crashes if bird is a Penguin!
}

Substituting Penguin for Bird breaks the system. This violates LSP.

The Solution

interface Bird {
  move(): void;
}

class FlyingBird implements Bird {
  move() {
    this.fly();
  }

  private fly() {
    console.log('Flying!');
  }
}

class Penguin implements Bird {
  move() {
    this.swim();
  }

  private swim() {
    console.log('Swimming!');
  }
}

function moveBird(bird: Bird) {
  bird.move(); // Works for all birds!
}

Now all birds can be substituted safely because they share the same behavioral contract.

Real-World Example: Storage Abstraction

interface Storage {
  get(key: string): Promise<string | null>;
  set(key: string, value: string): Promise<void>;
}

class RedisStorage implements Storage {
  async get(key: string): Promise<string | null> {
    return redis.get(key);
  }

  async set(key: string, value: string): Promise<void> {
    await redis.set(key, value);
  }
}

class PostgresStorage implements Storage {
  async get(key: string): Promise<string | null> {
    const result = await db.query('SELECT value FROM kv WHERE key = $1', [key]);
    return result.rows[0]?.value || null;
  }

  async set(key: string, value: string): Promise<void> {
    await db.query(
      'INSERT INTO kv (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2',
      [key, value]
    );
  }
}

class InMemoryStorage implements Storage {
  private store = new Map<string, string>();

  async get(key: string): Promise<string | null> {
    return this.store.get(key) || null;
  }

  async set(key: string, value: string): Promise<void> {
    this.store.set(key, value);
  }
}

You can swap storage implementations freely:

class CacheService {
  constructor(private storage: Storage) {}

  async getValue(key: string): Promise<string | null> {
    return this.storage.get(key);
  }
}

// All work identically from the caller's perspective
const redisCache = new CacheService(new RedisStorage());
const pgCache = new CacheService(new PostgresStorage());
const memCache = new CacheService(new InMemoryStorage());

Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they don’t use.

The Problem

interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
  charge(): void; // Only for robot workers
}

class HumanWorker implements Worker {
  work() { console.log('Working...'); }
  eat() { console.log('Eating...'); }
  sleep() { console.log('Sleeping...'); }
  charge() {
    throw new Error('Humans do not charge!');
  }
}

class RobotWorker implements Worker {
  work() { console.log('Working...'); }
  charge() { console.log('Charging...'); }
  eat() {
    throw new Error('Robots do not eat!');
  }
  sleep() {
    throw new Error('Robots do not sleep!');
  }
}

Both classes are forced to implement methods they don’t need.

The Solution

interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

interface Sleepable {
  sleep(): void;
}

interface Chargeable {
  charge(): void;
}

class HumanWorker implements Workable, Eatable, Sleepable {
  work() { console.log('Working...'); }
  eat() { console.log('Eating...'); }
  sleep() { console.log('Sleeping...'); }
}

class RobotWorker implements Workable, Chargeable {
  work() { console.log('Working...'); }
  charge() { console.log('Charging...'); }
}

Now each class implements only the interfaces it actually uses.

Real-World Application

In building integrations, ISP prevents bloated interfaces:

interface Readable {
  read(id: string): Promise<Data>;
}

interface Writable {
  write(data: Data): Promise<void>;
}

interface Deletable {
  delete(id: string): Promise<void>;
}

// Read-only API
class PublicAPI implements Readable {
  async read(id: string): Promise<Data> {
    return fetch(`/api/public/${id}`);
  }
}

// Full CRUD API
class AdminAPI implements Readable, Writable, Deletable {
  async read(id: string): Promise<Data> {
    return fetch(`/api/admin/${id}`);
  }

  async write(data: Data): Promise<void> {
    await fetch('/api/admin', {
      method: 'POST',
      body: JSON.stringify(data)
    });
  }

  async delete(id: string): Promise<void> {
    await fetch(`/api/admin/${id}`, { method: 'DELETE' });
  }
}

Consumers only depend on what they need:

class ReportGenerator {
  constructor(private dataSource: Readable) {} // Only needs read access

  async generate(id: string): Promise<Report> {
    const data = await this.dataSource.read(id);
    return this.buildReport(data);
  }
}

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions.

The Problem

class MySQLDatabase {
  query(sql: string): any[] {
    // Execute MySQL query
  }
}

class UserService {
  private db = new MySQLDatabase(); // Tight coupling!

  getUser(id: string) {
    return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
  }
}

UserService is tightly coupled to MySQL. Switching databases requires modifying UserService.

The Solution

interface Database {
  query(sql: string): Promise<any[]>;
}

class MySQLDatabase implements Database {
  async query(sql: string): Promise<any[]> {
    // Execute MySQL query
  }
}

class PostgresDatabase implements Database {
  async query(sql: string): Promise<any[]> {
    // Execute Postgres query
  }
}

class UserService {
  constructor(private db: Database) {} // Depends on abstraction

  async getUser(id: string) {
    return this.db.query(`SELECT * FROM users WHERE id = ${id}`);
  }
}

// Dependency injection
const mysqlService = new UserService(new MySQLDatabase());
const pgService = new UserService(new PostgresDatabase());

Now UserService depends on the Database abstraction, not concrete implementations.

Real-World Example: Email Service

interface EmailProvider {
  send(to: string, subject: string, body: string): Promise<void>;
}

class SendGridProvider implements EmailProvider {
  async send(to: string, subject: string, body: string): Promise<void> {
    await sendgrid.send({ to, subject, body });
  }
}

class MailgunProvider implements EmailProvider {
  async send(to: string, subject: string, body: string): Promise<void> {
    await mailgun.messages.send({ to, subject, body });
  }
}

class SMTPProvider implements EmailProvider {
  async send(to: string, subject: string, body: string): Promise<void> {
    await smtp.sendMail({ to, subject, body });
  }
}

class NotificationService {
  constructor(private emailProvider: EmailProvider) {}

  async notifyUser(user: User, message: string): Promise<void> {
    await this.emailProvider.send(
      user.email,
      'Notification',
      message
    );
  }
}

Switch email providers with zero code changes:

// Configuration-based dependency injection
const provider = config.emailProvider === 'sendgrid'
  ? new SendGridProvider()
  : new MailgunProvider();

const notifications = new NotificationService(provider);

SOLID in Practice: Testing Benefits

SOLID principles make testing significantly easier.

Before SOLID

class OrderProcessor {
  processOrder(order: Order) {
    const db = new MySQL();
    const payment = new StripePayment();
    const email = new SendGrid();

    db.save(order);
    payment.charge(order.total);
    email.send(order.customer.email, 'Order confirmed');
  }
}

// How do you test this without hitting real services?

After SOLID

class OrderProcessor {
  constructor(
    private db: Database,
    private payment: PaymentService,
    private email: EmailService
  ) {}

  async processOrder(order: Order) {
    await this.db.save(order);
    await this.payment.charge(order.total);
    await this.email.send(order.customer.email, 'Order confirmed');
  }
}

// Testing is trivial with mocks
describe('OrderProcessor', () => {
  it('should process order successfully', async () => {
    const mockDb = new MockDatabase();
    const mockPayment = new MockPaymentService();
    const mockEmail = new MockEmailService();

    const processor = new OrderProcessor(mockDb, mockPayment, mockEmail);

    await processor.processOrder(testOrder);

    expect(mockDb.saveCalled).toBe(true);
    expect(mockPayment.chargeCalled).toBe(true);
    expect(mockEmail.sendCalled).toBe(true);
  });
});

Common Objections and Rebuttals

”This is overengineering”

When you’re right: Building a prototype or proof-of-concept.

When you’re wrong: Building production software that will evolve over months/years.

SOLID principles pay dividends as codebases grow. The time you “save” by violating them gets paid back with interest during maintenance.

”Too many files and classes”

More files with clear responsibilities beat fewer files with tangled logic.

Compare:

  • 1 file, 500 lines, 10 responsibilities → Nightmare to maintain
  • 10 files, 50 lines each, 1 responsibility → Easy to understand

Modern IDEs make navigating multiple files trivial.

”It’s harder to understand”

Initially, yes. But after a week on the codebase, SOLID code is dramatically easier to understand because:

  • Each class has one clear purpose
  • Dependencies are explicit
  • Changes are isolated

SOLID in Modern Frameworks

Many frameworks enforce SOLID by design:

React (Component Design)

// Single Responsibility: Each component does one thing
function UserAvatar({ user }: Props) {
  return <img src={user.avatar} alt={user.name} />;
}

function UserProfile({ user }: Props) {
  return (
    <div>
      <UserAvatar user={user} />
      <UserInfo user={user} />
      <UserActions user={user} />
    </div>
  );
}

// Dependency Inversion: Accept interfaces, not implementations
interface DataFetcher {
  fetch(url: string): Promise<Data>;
}

function DataComponent({ fetcher }: { fetcher: DataFetcher }) {
  // Component depends on abstraction, not fetch implementation
}

NestJS (Dependency Injection)

// Dependency Inversion built-in
@Injectable()
class UserService {
  constructor(
    private readonly userRepository: UserRepository,
    private readonly emailService: EmailService
  ) {}
}

// Automatically injected with proper implementations

Action Plan: Applying SOLID Today

Week 1: Identify Violations

Review your current codebase:

  1. Find classes with multiple reasons to change (SRP)
  2. Find if/else chains that need modification for new features (OCP)
  3. Find interfaces that force unused methods (ISP)
  4. Find direct dependencies on concrete implementations (DIP)

Week 2: Refactor One Module

Pick one module and apply SOLID:

  • Extract responsibilities into separate classes
  • Define interfaces for key abstractions
  • Use dependency injection

Week 3: Establish Patterns

Create team guidelines:

  • Max class size (e.g., 100-200 lines)
  • Max method size (e.g., 20-30 lines)
  • Interface-first design
  • Constructor-based dependency injection

Ongoing: Code Review Checklist

Ask during every review:

  • Does this class have a single, clear responsibility?
  • Can I add new features without modifying existing code?
  • Are interfaces minimal and focused?
  • Do classes depend on abstractions?

Conclusion: The Long Game

SOLID principles aren’t about writing “perfect” code. They’re about writing code that survives contact with reality.

Reality means:

  • Requirements change
  • Team members change
  • Technologies change
  • Scale changes

SOLID principles create code that adapts to change gracefully rather than crumbling under it.

After years of building systems that lasted and systems that collapsed, I can tell you: the discipline of SOLID design compounds over time.

The 10 extra minutes you spend designing a proper abstraction today saves 10 hours of debugging six months from now.

Further Reading


Part of the Developer Skills series. Building software that lasts.

How do you apply SOLID principles in your projects? What’s been your biggest win from refactoring toward SOLID design? I’d love to hear your experiences.

#Best Practices #SOLID #Software Architecture #Object-Oriented Design #Clean Code