SOLID Principles Explained with Practical JavaScript Examples

Updated

The SOLID principles describe five ways to manage responsibility, extension, substitution, interfaces and dependency direction in software. This guide uses JavaScript examples to explain each principle, diagnose change-related problems and decide when an abstraction earns its cost.

The goal is not to memorize definitions. The goal is to recognize when code is becoming hard to change and use better boundaries before the system becomes painful.

What SOLID Stands For

SOLID is an acronym:

  1. S: Single Responsibility Principle
  2. O: Open/Closed Principle
  3. L: Liskov Substitution Principle
  4. I: Interface Segregation Principle
  5. D: Dependency Inversion Principle

Each principle helps reduce a different kind of maintenance problem.

1. Single Responsibility Principle

The Single Responsibility Principle says that a module, class or function should have one clear reason to change.

This does not mean every function must be tiny. It means responsibilities should not be mixed in ways that make future changes risky.

Poor Example

function registerUser(user) {
  validateUser(user);
  saveUserToDatabase(user);
  sendWelcomeEmail(user);
  writeAuditLog(user);
}

This function validates, persists, emails and logs. If email behavior changes, the registration workflow must be touched. If audit logging changes, the same function changes again.

Cleaner Example

function registerUser(user) {
  validateUser(user);
  const savedUser = userRepository.save(user);
  welcomeEmailService.send(savedUser);
  auditLogger.recordUserRegistration(savedUser);
}

The workflow is still visible, but the details live behind clearer responsibilities. Coordination itself can be one responsibility; calling several operations is not automatically an SRP violation. The key is whether unrelated policies force the same module to change. The original SRP explanation relates responsibility to the actors whose requirements drive a change.

2. Open/Closed Principle

The Open/Closed Principle says software should be open for extension but closed for modification. In practical terms, adding a new behavior should not require editing a fragile chain of existing conditions.

Poor Example

function calculateShipping(order, method) {
  if (method === "standard") return order.weight * 5;
  if (method === "express") return order.weight * 10;
  if (method === "overnight") return order.weight * 20;
  throw new Error("Unknown shipping method");
}

Every new shipping method requires changing the same function.

Cleaner Example

const standardShipping = (order) => order.weight * 5;
const expressShipping = (order) => order.weight * 10;

function calculateShipping(order, strategy) {
  return strategy(order);
}

calculateShipping({ weight: 2 }, expressShipping);

Now a caller can supply a new strategy without editing the calculation flow. This boundary is worthwhile when shipping methods genuinely vary; a single stable rule does not need a strategy abstraction.

3. Liskov Substitution Principle

The Liskov Substitution Principle says that a subtype should be usable anywhere its parent type is expected without breaking behavior.

In simpler words: if code expects a certain kind of object, replacing it with a related object should not create surprising behavior.

Problem Example

class Bird {
  fly() {
    return "Flying";
  }
}

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

If other code expects every Bird to fly, Penguin breaks that expectation.

Cleaner Design

class Bird {}

class FlyingBird extends Bird {
  fly() {
    return "Flying";
  }
}

class Penguin extends Bird {
  swim() {
    return "Swimming";
  }
}

The design now models behavior more honestly. Not every bird flies, so flying should not be forced into the base type.

4. Interface Segregation Principle

The Interface Segregation Principle says code should not be forced to depend on methods it does not use.

In JavaScript, this often appears as large objects or services passed into functions even though the function only needs one small capability.

Poor Example

function sendInvoiceEmail(userService, invoice) {
  const user = userService.getUser(invoice.userId);
  userService.sendEmail(user.email, "Your invoice is ready");
}

The function depends on a full userService even though it only needs user lookup and email sending.

Cleaner Example

function sendInvoiceEmail({ userReader, emailSender }, invoice) {
  const user = userReader.getUser(invoice.userId);
  emailSender.send(user.email, "Your invoice is ready");
}

The dependencies are narrower and easier to replace in tests.

5. Dependency Inversion Principle

The Dependency Inversion Principle says high-level business logic should not depend directly on low-level implementation details. Both should depend on clear abstractions.

This principle is useful for testing and for changing infrastructure later.

Poor Example

function notifyCustomer(order) {
  const emailClient = new EmailClient(process.env.EMAIL_API_KEY);
  emailClient.send(order.customerEmail, "Your order has shipped");
}

The business logic creates a specific email client directly. Testing this function now requires dealing with email infrastructure.

Cleaner Example

function notifyCustomer(order, notificationService) {
  notificationService.send(order.customerEmail, "Your order has shipped");
}

The function depends on a capability, not a concrete email provider. In production, the service can send real email. In tests, it can be a simple fake.

Dependency injection is the mechanism of passing that service. Dependency inversion is the design decision that the business policy owns or depends on an abstraction instead of a concrete infrastructure detail. Passing a concrete database client everywhere is injection, but does not necessarily establish a useful domain boundary. These short examples assume synchronous adapters unless a function is marked async; real asynchronous callers must await effects and define failure behavior.

SOLID Without Over-Engineering

SOLID principles are helpful, but they can be misused. Beginners sometimes create too many interfaces, classes, factories and layers before the code needs them.

Use SOLID when it solves a real problem:

  • A function has multiple reasons to change.
  • A new feature requires editing many unrelated files.
  • Tests are difficult because dependencies are hard-coded.
  • Similar objects cannot be substituted safely.
  • A module depends on a large service but only uses a tiny part.

Do not add architecture only to make code look advanced.

A Practical SOLID Review Checklist

Ask these questions during code review:

  • Does this function or class have one clear responsibility?
  • Can a new behavior be added without editing fragile existing logic?
  • Do related types behave consistently?
  • Are dependencies broader than necessary?
  • Can business logic be tested without real databases, APIs or email services?
  • Does the design make the next likely change easier?

If the answer is no, consider a small refactor.

Common Mistakes with SOLID

Treating SOLID as Rules Instead of Principles

SOLID is guidance, not a law. Context matters. A small script does not need the same structure as a long-lived production system.

Creating Too Many Abstractions

An abstraction should clarify a real boundary. If it only hides simple code behind more files, it may make the system harder to understand.

Ignoring Tests

SOLID and testing work together. Better boundaries make testing easier and tests give confidence when improving boundaries.

Applying Object-Oriented Patterns Everywhere

The ideas behind SOLID can help in functional and modular code too. Focus on responsibility, dependency direction and replaceable behavior rather than forcing every solution into a class hierarchy.

Diagnose Before Applying a Principle

Start with the kind of change that hurts:

  • If unrelated policies repeatedly alter the same module, examine single responsibility.
  • If each new variant edits a fragile dispatcher, examine open/closed.
  • If a subtype needs exceptions, no-op methods or surprise errors, examine Liskov substitution.
  • If consumers receive a large dependency but use one capability, examine interface segregation.
  • If business tests require live infrastructure, examine dependency inversion.

One symptom can involve several principles, but applying all five at once usually produces more layers than insight.

Worked Design Exercise: Exporting Reports

Assume exportReport(report, format) contains a growing if chain for CSV and JSON. The product roadmap contains a confirmed PDF export next month.

  1. Observed pressure: formats vary, while report selection and authorization remain stable.
  2. Small boundary: pass a formatter with a serialize(report) capability.
  3. Substitution contract: every formatter must return a byte sequence or reject with a documented formatting error; none may silently save files.
  4. Narrow dependency: the export workflow needs serialize, not an entire document service.
  5. Test: run the same contract cases—empty report, Unicode text and invalid fields—against every formatter.
async function exportReport(report, formatter, fileStore) {
  const contents = await formatter.serialize(report);
  return fileStore.save(`${report.id}.${formatter.extension}`, contents);
}

This design uses open/closed, interface segregation and dependency inversion because a real second axis of change exists. If CSV is the only expected format, serializeCsv(report) is clearer and cheaper.

Here serialize may return the agreed byte sequence directly or a promise for it. Validate formatters against that contract, including rejection behavior. A successful call should not silently write files; persistence belongs to fileStore.

A Proportionate SOLID Standard

Count outcomes, not interfaces: how many existing branches must a confirmed variant change, how many infrastructure services a business-rule test starts and how many methods a consumer is forced to know. Compare those signals after the smallest design change. Do not chase zero coupling—software components must collaborate.

For module-level trade-offs beyond SOLID, continue with maintainable software design principles. When applying a principle to existing behavior, use the safe refactoring workflow.

Frequently Asked Questions

What are the five SOLID principles?

They are single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion. Each addresses a different pressure on responsibility, extension or collaboration between components.

Are SOLID principles only useful in class-based code?

They originated in object-oriented design, but responsibility, narrow capabilities and dependency direction also help modular JavaScript. Use the idea that fits the problem rather than introducing a class hierarchy solely to match an example.

Does SRP mean a function can call only one other function?

No. A use-case coordinator can call several capabilities while retaining one reason to change. Keep the independent rules behind those capabilities cohesive and understandable.

How is dependency inversion different from dependency injection?

Inversion concerns what the policy depends on; injection concerns how an implementation is supplied. Injection supports inversion when the supplied implementation satisfies a deliberately chosen abstraction.

How do I verify Liskov substitution in practice?

Run shared contract tests against each implementation. Check accepted inputs, returned data, errors and observable effects, including whether a subtype unexpectedly requires stronger preconditions or drops a promised behavior. The testable-code guide shows how to make those boundaries observable.

Related Posts

How to Start Coding: A Beginner's Guide to Learning Programming

Learning how to start coding begins with one language, a working first program and regular practice. This beginner's guide helps you choose Python or JavaScript, set up your tools, understand the cor

Read More

Clean Code Best Practices for Readable, Maintainable Software

Clean code best practices make a program's intent, rules and side effects easier to understand. This guide uses JavaScript examples and a short code audit to help you improve naming, function boundar

Read More

REST API Design: Build Predictable RESTful APIs with Express

REST API design gives clients predictable resource URLs, HTTP methods and error responses. This guide explains the core conventions, correct retry semantics and a runnable Express example so you can

Read More