Home / Articles / Understanding SOLID Principles Through Practical Code Examples

This article is published in English.

Understanding SOLID Principles Through Practical Code Examples

This guide breaks down all five SOLID principles with concrete code examples, showing how they apply in real projects and React applications.

1903 words

When you're building software, getting the code to run correctly is only half the job.

Once an application starts to grow, its codebase often becomes harder to read, harder to change, and harder to keep working correctly. A tiny tweak in one part of the system can quietly break something unrelated.

This is exactly the kind of problem the SOLID principles were designed to address.

SOLID is a group of five design principles from object-oriented programming that push you toward code that is:

  • Simpler to maintain
  • Simpler to test
  • Simpler to extend
  • More loosely coupled
  • Easier for a team to reason about

The acronym breaks down as:

S — Single Responsibility Principle

O — Open/Closed Principle

L — Liskov Substitution Principle

I — Interface Segregation Principle

D — Dependency Inversion Principle

Let's walk through each one using straightforward examples.

1. S — Single Responsibility Principle

“A class should have only one reason to change.”

Put simply, every class or module should be built around a single job.

Consider a User class that's responsible for:

  • Holding user data
  • Talking to the database
  • Sending emails
  • Building reports

That's far too much packed into a single class.

class User {
  createUser() {
    // create user
  }
saveToDatabase() {
    // save user
  }
  sendEmail() {
    // send email
  }
  generateReport() {
    // generate report
  }
}

If the email logic needs to change, you're editing the User class.

If the database handling changes, you're back in that same class again.

A cleaner approach splits these jobs apart.

class User {
  createUser() {
    // create user
  }
}
class UserRepository {
  saveToDatabase() {
    // database logic
  }
}
class EmailService {
  sendEmail() {
    // email logic
  }
}
class ReportService {
  generateReport() {
    // report logic
  }
}

With this split, each class now owns exactly one concern.

Why is this useful?

Whenever a requirement shifts, you immediately know which piece of code to touch.

One job per class means one reason for that class to ever change.

2. O — Open/Closed Principle

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

The wording sounds abstract, but the concept behind it is not.

The goal is to introduce new functionality without repeatedly rewriting code that already works.

Take a payment-processing example:

function processPayment(type, amount) {
  if (type === "card") {
    // card payment
  } else if (type === "upi") {
    // UPI payment
  } else if (type === "paypal") {
    // PayPal payment
  }
}

Now suppose you need to support:

  • Stripe
  • Razorpay
  • Apple Pay
  • Google Pay

Every new option makes that function larger and messier.

A better strategy is to give each payment method its own class.

class CardPayment {
  pay(amount) {
    console.log(`Card payment: ${amount}`);
  }
}
class UpiPayment {
  pay(amount) {
    console.log(`UPI payment: ${amount}`);
  }
}
class PaypalPayment {
  pay(amount) {
    console.log(`PayPal payment: ${amount}`);
  }
}

With that structure in place, adding another payment option doesn't require touching the classes you've already written.

class StripePayment {
  pay(amount) {
    console.log(`Stripe payment: ${amount}`);
  }
}

The original implementations stay exactly as they were.

The idea

Grow the system by adding new code, not by repeatedly editing code that's already stable.

3. L — Liskov Substitution Principle

“Subtypes should be replaceable with their base types.”

At its core, this principle states:

If B is a subtype of A, it should be possible to swap B in wherever A is used, and the application should keep working correctly.

A classic illustration involves birds.

Say you define:

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

Then you extend it:

class Sparrow extends Bird {
  fly() {
    console.log("Sparrow is flying");
  }
}

So far, so good.

But what happens with a penguin?

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

This reveals a flaw in the design.

If other parts of the code assume every Bird can fly, handing it a Penguin instance will break that expectation.

A more sensible design pulls the flying behavior out into its own piece.

class Bird {
  eat() {
    console.log("Eating");
  }
}
class FlyingBird extends Bird {
  fly() {
    console.log("Flying");
  }
}
class Sparrow extends FlyingBird {}
class Penguin extends Bird {}

This way, penguins are no longer forced to support behavior that doesn't apply to them.

The lesson

Avoid building inheritance hierarchies that don't hold up logically.

A subclass needs to work correctly anywhere its parent class is expected to work.

4. I — Interface Segregation Principle

“Clients should not be forced to depend on methods they don't use.”

Picture an interface built like this:

print()
scan()
fax()
copy()

Now picture a basic printer that can only, well, print.

Why should that printer be required to implement scan(), fax(), and copy() as well?

There's no good reason for that.

The better approach is to split the interface according to what each capability actually does.

For instance:

class Printer {
  print() {
    console.log("Printing...");
  }
}
class Scanner {
  scan() {
    console.log("Scanning...");
  }
}
class FaxMachine {
  fax() {
    console.log("Faxing...");
  }
}

A simple printer only has to implement the behavior it genuinely supports.

In modern JavaScript

JavaScript doesn't have formal interfaces the way Java or C# do, but the underlying idea still holds.

You can put it into practice through:

  • Small modules
  • Small APIs
  • Composition
  • Separate services
  • Focused React components

Rather than bundling everything into one giant service:

userService.getUser();
userService.createUser();
userService.deleteUser();
userService.sendEmail();
userService.generateReport();

split the responsibilities apart:

userService.getUser();
userService.createUser();
emailService.sendEmail();
reportService.generateReport();

The lesson

Never force a component, class, or module to rely on functionality it has no use for.

5. D — Dependency Inversion Principle

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

This principle exists to cut down on tight coupling.

Take this example:

class MongoDB {
  save(data) {
    console.log("Saving to MongoDB");
  }
}
class UserService {
  constructor() {
    this.database = new MongoDB();
  }
  saveUser(user) {
    this.database.save(user);
  }
}

The issue here is that UserService is wired directly to MongoDB.

Switching to PostgreSQL later would mean going back and changing UserService itself.

A better approach is to inject the dependency instead.

class UserService {
  constructor(database) {
    this.database = database;
  }
saveUser(user) {
    this.database.save(user);
  }
}

Now different database implementations can be passed in freely.

const mongoDB = new MongoDB();
const userService = new UserService(mongoDB);

And later, swapping it out is trivial:

const postgresDB = new PostgreSQL();
const userService = new UserService(postgresDB);

UserService never needs to know which database sits behind it.

Why is this useful?

It leaves you with code that is:

  • Easier to test
  • Easier to swap out
  • Less tightly coupled
  • Easier to maintain over time

SOLID in a Real Project

Following SOLID doesn't mean spinning up a dedicated class for every single thing.

That distinction matters a lot.

SOLID is about making sound design choices, not piling up abstractions for their own sake.

In a React application, for example, these ideas naturally show up when you separate:

Components
    ↓
Hooks
    ↓
Services
    ↓
API Layer
    ↓
Database

A component's job is mostly to handle UI.

A custom hook can own reusable state logic.

An API service can take care of HTTP communication.

The backend handles business logic.

The database layer handles persistence.

Splitting things this way keeps the application manageable as it grows.

SOLID and React

Even though SOLID grew out of object-oriented design, several of its ideas translate well into React work.

Single Responsibility

Rather than building one massive component:

Dashboard.jsx

that tries to do everything, break it apart into:

Dashboard
UserProfile
Statistics
RecentOrders
Notifications

Each piece then has a much clearer job.

Open/Closed

Design reusable components that gain new behavior through props instead of having their internals rewritten every time a new variation is needed.

<Button variant="primary">
  Save
</Button>
<Button variant="danger">
  Delete
</Button>

Dependency Inversion

Rather than binding a component directly to one specific way of fetching data, keep the API logic inside a service or hook.

const users = await userService.getUsers();

The component itself doesn't need to know how that request is carried out under the hood.

Why SOLID Matters

The actual payoff from SOLID has little to do with making code look elegant.

It's about making future changes less painful.

Picture a project involving:

10 developers → 100 features → thousands of files → constant changes

Without deliberate design, one small requirement can trigger a cascade of unrelated breakage.

With clear separation and loose coupling, changes stay far more predictable instead.

SOLID can help you:

  • Cut down on code duplication
  • Reduce tight coupling
  • Improve testability
  • Make features easier to extend
  • Simplify debugging
  • Improve collaboration across a team
  • Keep large applications maintainable

SOLID Doesn't Mean Overengineering

This might be the single most important takeaway.

Don't apply SOLID as a rigid checklist.

Take a simple function like:

function add(a, b) {
  return a + b;
}

it doesn't need five classes, three interfaces, and a dependency injection container to go with it.

The point was never to make code more elaborate.

The point is to make genuinely complex code easier to work with.

Reach for SOLID once the complexity of the system actually justifies the extra structure.

Quick Summary

S — Single Responsibility: a class or module should carry one main responsibility.

O — Open/Closed: extend behavior instead of repeatedly editing code that already works.

L — Liskov Substitution: subclasses should work correctly anywhere the parent class is expected.

I — Interface Segregation: don't make clients depend on functionality they don't need.

D — Dependency Inversion: depend on abstractions rather than hard-wiring concrete implementations.

Final Thoughts

SOLID was never meant to be five definitions memorized for an interview.

It's a way of reasoning about software design.

While writing code, it helps to pause and ask:

Is this module trying to do too many things at once?

Will adding a new feature force a rewrite of existing code?

Are unnecessary dependencies being introduced here?

Can this code be tested without a struggle?

Is something being forced to support behavior it doesn't actually need?

Sitting with these questions tends to matter more than being able to recite what each SOLID letter stands for.

Good software isn't just software that works right now.

Good software is software that keeps changing gracefully instead of turning into a nightmare.