SOLID is a set of five object-oriented design principles that help keep code easy to change: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. Each principle targets a specific kind of pain, such as a class that breaks whenever an unrelated feature changes or a test that needs a real database. This article gives you the SOLID principles explained with small TypeScript examples, the smell each one fixes, and honest guidance on when not to apply them.

What are the SOLID principles?

Letter Principle One-line meaning Smell it fixes
S Single Responsibility A module should have one reason to change God classes, merge conflicts in one file
O Open/Closed Extend behavior without editing stable code Growing switch statements on type
L Liskov Substitution Subtypes must honor the base type's contract Subclasses that throw or special-case
I Interface Segregation Clients should not depend on methods they do not use Fat interfaces, stub methods
D Dependency Inversion Depend on abstractions, not concrete details Hard-coded infrastructure, untestable code

These are heuristics, not laws. They are most valuable at boundaries that change often or that you need to test in isolation.

Single Responsibility Principle (SRP)

The Single Responsibility Principle says a class or module should have one reason to change. "Reason" means a stakeholder or concern, not a single method. A class that calculates invoice totals, formats them as PDF, and emails them changes whenever finance, design, or the email provider changes.

The fix is to split along those axes: an InvoiceCalculator, an InvoiceRenderer, and an InvoiceMailer, coordinated by a small service. Each piece is simpler to test and can evolve independently. The single responsibility principle guide walks through more examples.

A useful check: describe the class in one sentence without using "and". If you cannot, it probably has more than one responsibility.

Open/Closed Principle (OCP)

The Open/Closed Principle says software should be open for extension but closed for modification. When you add a new variant, you should be able to add new code rather than edit tested code.

The classic smell is a switch on a type field that appears in several places. Every new type means finding and editing each switch. Replacing the switch with polymorphism, usually an interface with one implementation per variant, lets you add a variant by adding a class. This is closely related to the Strategy pattern.

Do not apply OCP speculatively. If there is only one variant and no evidence of more coming, a plain function is fine. Introduce the abstraction when the second or third variant actually arrives.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle says that code written against a base type must keep working when given any subtype. A subtype may do more, but it must not demand more from callers or promise less.

The textbook example is Square extends Rectangle. A rectangle lets you set width and height independently. A square cannot, so setting the width must also change the height, which breaks any caller that assumed the two are independent. Mathematically a square is a rectangle; behaviorally it is not a substitutable one.

Warning signs of an LSP violation:

  • A subclass method throws "not supported".
  • Callers check instanceof before calling a method.
  • A subclass tightens input requirements or weakens output guarantees.

The fix is usually a different hierarchy or composition instead of inheritance. See the Liskov substitution principle guide for a deeper treatment.

Interface Segregation Principle (ISP)

The Interface Segregation Principle says clients should not be forced to depend on methods they do not use. A large Machine interface with print, scan, and fax forces a basic printer to implement scan and fax as empty stubs or exceptions, which also tends to violate LSP.

Split the interface into focused roles such as Printer, Scanner, and FaxMachine. A multifunction device implements all three; a basic printer implements one. Consumers depend only on the role they need, which makes mocks smaller and changes safer.

In TypeScript this is cheap because interfaces are structural. A function can accept Pick<Service, "read"> or a tiny purpose-built interface without the implementation needing to declare anything.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle says high-level policy should not depend on low-level details; both should depend on abstractions. In practice, the business logic defines the interface it needs, and infrastructure implements it.

This is what makes code testable. If an OrderService constructs its own database client, every test needs a database. If it receives an OrderRepository interface, tests can pass an in-memory fake. Passing dependencies in is called dependency injection, and it is the most common way to apply DIP.

SOLID refactor in TypeScript

The example below applies SRP, OCP, and DIP to a small checkout flow. Discounts are pluggable, persistence and notifications are abstractions, and the service coordinates without knowing any concrete details.

interface DiscountRule {
  apply(subtotal: number): number;
}

class NoDiscount implements DiscountRule {
  apply(subtotal: number): number {
    return subtotal;
  }
}

class PercentOff implements DiscountRule {
  constructor(private readonly percent: number) {}
  apply(subtotal: number): number {
    return subtotal * (1 - this.percent / 100);
  }
}

interface OrderRepository {
  save(order: { id: string; total: number }): Promise<void>;
}

interface Notifier {
  send(message: string): Promise<void>;
}

class CheckoutService {
  constructor(
    private readonly repo: OrderRepository,
    private readonly notifier: Notifier,
  ) {}

  async checkout(id: string, prices: number[], discount: DiscountRule): Promise<number> {
    const subtotal = prices.reduce((sum, p) => sum + p, 0);
    const total = Math.max(0, discount.apply(subtotal));
    await this.repo.save({ id, total });
    await this.notifier.send(`Order ${id} confirmed: ${total.toFixed(2)}`);
    return total;
  }
}

// In tests, plain objects satisfy the interfaces.
const saved: { id: string; total: number }[] = [];
const service = new CheckoutService(
  { save: async (o) => { saved.push(o); } },
  { send: async () => {} },
);

Adding a new discount type means writing a new DiscountRule class; CheckoutService does not change. Swapping email for SMS means a new Notifier. Testing needs no database or network.

When SOLID principles go too far

SOLID can be overapplied. Common symptoms:

  1. Interfaces with exactly one implementation and no test double, created "just in case".
  2. Dozens of tiny classes that make a simple flow hard to follow.
  3. Factories for objects that are only constructed in one place.
  4. Abstractions named after patterns rather than domain concepts.

Balance SOLID with DRY, KISS, and YAGNI. A good rule is to introduce an abstraction when you feel real pain: a second implementation, a test that is hard to write, or a file that keeps causing merge conflicts. Principles serve the code, not the other way around.

If you want to see how these principles show up inside reusable designs, read design patterns every developer should know.

Key takeaways

  • SRP: split code by reason to change, not by number of methods.
  • OCP: replace repeated type switches with polymorphism once real variants exist.
  • LSP: a subtype must work anywhere its base type is expected, with no surprises.
  • ISP: prefer small role interfaces over large ones with stub methods.
  • DIP: let business logic own the interfaces and inject infrastructure, which makes testing easy.
  • Apply SOLID in response to real change and testing pain, not speculatively.

Frequently asked questions

What is the most important SOLID principle?

Many engineers find Single Responsibility and Dependency Inversion deliver the most day-to-day value. SRP keeps classes focused and easier to reason about, while DIP makes code testable and decoupled from infrastructure. The others become important as a codebase grows more variants and implementations.

Do SOLID principles apply to functional programming?

Yes, in adapted form. Small single-purpose functions reflect SRP, higher-order functions provide extension without modification, and passing functions as parameters is a form of dependency inversion. The vocabulary is object-oriented, but the goals of isolation and replaceability are universal.

What is the difference between dependency inversion and dependency injection?

Dependency inversion is the design principle: high-level code should depend on abstractions rather than concrete implementations. Dependency injection is a technique for achieving it, where dependencies are passed in through constructors or parameters instead of being created internally. You can inject dependencies without inverting them, but inversion is what gives injection its value.

Are SOLID principles asked in interviews?

Yes, especially in low-level design and object-oriented design rounds. Interviewers usually care less about reciting definitions and more about whether your class design shows clear responsibilities, extensibility, and testability. Naming the principle behind a design choice is a good way to show intent.