All articlesSoftware Design

SOLID Principles: Writing Robust & Maintainable Code (with TypeScript examples)

And why change and speed matter in today's world and competition.

Petar IvanovPetar Ivanov
10 min read
On this page

Intro

Many Software Developers don’t know what SOLID Principles stand for, blindly apply them, or can’t elaborate on them when asked during an interview.

The latter happened to me last month.

I barely remember what SOLID means even though I do write clean and maintainable code.

The theory is not my strongest side.

However, I think it’s good to be aware of the SOLID principles, even in theory. That’s why I decided to write a simple and intuitive article about them.

After reading this article, you’ll learn:

  • the importance of writing robust and maintainable code
  • what are the SOLID Principles
  • what to avoid and prefer while applying the SOLID Principles

Why does writing robust and maintainable code matter?

Change

💡 Writing new code is easy. Maintaining it is an art.

We all wrote some code in the past. After some time, it didn’t suit our needs, so we had to change it.

So, change is inevitable.

There’s only one thing in the universe that is 100% sure → change.

So if we write code, we must write it in a way that can be changed. And better, the code is easy to change. Otherwise, we may end up with a headache.

Speed

Another crucial thing related to change is how fast we can make it.

In today’s world and competition speed matters.

We must be agile which means we do many iterations and incremental improvements in a project.

💡 To go fast, we need to go well!

So learning a bit about SOLID Principles and following them in your next project could help you make changes easily and faster.

What are the SOLID Principles?

The SOLID Principles are software design principles that help us structure and organize our functions, classes, and modules, so they are robust, easy to understand, maintainable, and flexible to change.

SOLID is an acronym for five key design principles:

  • S: Single Responsibility Principle (SRP)
  • O: Open-Closed Principle (OCP)
  • L: Liskov-Substitution Principle (LSP)
  • I: Interface Segregation Principle (ISP)
  • D: Dependency Inversion Principle (DIP)

SOLID Principles were popularized by Robert C. Martin aka Uncle Bob around 2004.

Primary Benefits

In my opinion, following the SOLID Principles can help you write code that is:

  • testable
  • understandable
  • maintainable
  • extensible
  • flexible

This enables things like:

  • adjusting and/or extending an existing functionality without introducing any bugs
  • swapping the inner implementation. For example. switching from one Email Provider to another, like SendGrid to Mailgun.
  • not spending a lot of time to navigate and find what you need throughout the codebase
  • and much more…

S: Single-Responsibility Principle (SRP)

By definition, the principle states:

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

This rule also includes modules and functions.

In other words, a class, function, or module should have a single responsibility.

For example, if there’s a need in our application for logging, caching, or storing then these concerns need to be separated and designed into separate classes each fulfilling its own SRP.

Another way to say it is that each class or function should do one thing and do it well. For example, the UNIX system is built on this principle. In UNIX, there’re multiple commands like grep, ls, and sed which do only one thing, do it well, and could be used with others to compose a bigger application.

Okay, now let’s see another example in TypeScript to better illustrate the principle.

Let’s imagine we have an enterprise application for a company. The company has employees inside different departments like HR and Accounting. For each employee, we must be able to calculate the salary. Since each department and its employees have different salaries, it’s better to split and abstract this logic.

⛔ Avoid

TypeScript
<strong>class Employee</strong> {
  calculateSalary(): number {
    <strong>// code goes here
    // if-else statements to check the employee's department
    // if HR ...
    // if Accounting ...</strong>
  }
}

This violets the SRP because the calculateSalary inside the Employee class is responsible for two things - calculating the salary both for the employees from the HR and Accounting departments.

✅ Prefer

TypeScript
<strong>abstract</strong> class <strong>Employee</strong> {
  <strong>abstract calculateSalary()</strong>: number;
}

<strong>class HR extends Employee</strong> {
  <strong>calculateSalary()</strong>: number {
    <strong>// code goes here (specific for the HR department)</strong>
  }
}

<strong>class Accounting extends Employee</strong> {
  <strong>calculateSalary()</strong>: number {
    <strong>// code goes here (specific for the Accounting department)</strong>
  }
}

This way we’ve separated the responsibility based on the different departments inside the business and company.

Whenever something has to be changed regarding the salary inside the Accounting department, we’ll only update the Accounting class.

Note: It’s up to the team and company how granular they want to be when defining the responsibilities. Some people will argue that if we have more methods inside the class HR we should extract them as well. However, I don’t always think this makes the code better.

O: Open-Closed Principle (OCP)

By definition, the principle states:

“A software artifact should be open for extension but closed for modification”.

Basically, this means that you should strive to write your code in a way that when you add a new functionality, it shouldn’t require changing the existing code.

Note: Bug fixes are allowed to be fixed and therefore modify the existing code if necessary. Otherwise, how we’ll fix them. 😅

And that’s what we aim for in software architecture, isn’t it? 😳 To design the software in a way so that with minimum effort and changes we can go from point A to point B.

Okay, now let’s see an example in TypeScript to better illustrate the principle.

Let’s say the CEO forms a new IT department inside the company.

⛔ Avoid

TypeScript
<strong>class Employee</strong> {
  <strong>calculateSalary()</strong>: number {
    // code goes here
    // if-else statements to check the department
    // if HR ...
    // if Accounting ...
    // <strong>if IT ... </strong><em><strong>(NEW)</strong></em>
  }
}

Here, it violates the OCP because we’re modifying the Employee class. Employee class is not open for extension if new departments are added.

✅ Prefer

TypeScript
<strong>abstract class Employee</strong> {
  <strong>abstract calculateSalary()</strong>: number;
}

<strong>class HR extends Employee</strong> {
  <strong>calculateSalary()</strong>: number {
    <strong>// code goes here (specific for the HR department)</strong>
  }
}

<strong>class Accounting extends Employee</strong> {
  <strong>calculateSalary()</strong>: number {
    <strong>// code goes here (specific for the Accounting department)</strong>
  }
}

<strong>class IT extends Employee</strong> {
  <strong>calculateSalary()</strong>: number {
    <strong>// code goes here (specific for the IT department)</strong>
  }
}

This way we separated the higher-level concept of an Employee from the details - the different employees inside the department.

Higher-level components are protected from changes to lower components and their details.

L: Liskov-Substitution Principle (LSP)

By definition, the principle states:

”Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.”

Well, I don’t get it. It’s so confusing… 🤷‍♂️

Let’s rephrase it to something more intuitive:

“Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.”

So basically, we’re talking about using interfaces and abstract classes.

To better illustrate the concept, let’s see an example.

Following the examples from above with the employees and the departments, let’s say we have to add apaySalariesmethod.

⛔ Avoid

TypeScript
<strong>abstract class Employee</strong> {
  <strong>abstract paySalaries()</strong>: boolean;
}

<strong>class Accounting extends Employee</strong> {
  <strong>paySalaries()</strong>: boolean { ... }
}

<strong>class IT extends Employee</strong> {
  <strong>paySalaries()</strong>: boolean { ... }
}

This violates the LSP because the employee from the IT department cannot pay salaries, but it’s a subclass of Employee which has a paySalaries method.

✅ Prefer

TypeScript
<strong>abstract class Employee</strong> {
  <strong>// ...</strong>
}

<strong>class Accounting extends Employee</strong> {
  <strong>paySalaries()</strong>: boolean;
  // ...
}

<strong>class IT</strong> <strong>extends Employee</strong> {
  // ...
}

Now, Accounting and Employee are separate classes, correctly representing employees thatcanandcannotpay salaries.

I: Interface Segregation Principle (ISP)

By definition, the principle states:

“Prevent classes from relying on things that they don’t need”.

So to accomplish that, we should make sure to split up the unique functionality into interfaces.

Now, let’s see an example in TypeScript to better illustrate the principle.

⛔ Avoid

TypeScript
<strong>interface Worker</strong> {
  work(): void;
  eat(): void;
}

<strong>class HumanWorker implements Worker</strong> {
  <strong>work()</strong> { ... }
  <strong>eat()</strong> { ... }
}

<strong>class RobotWorker implements Worker</strong> {
  <strong>work()</strong> { ... }
  <strong>eat()</strong> { ... } <em><strong>// irrelevant</strong></em>
}

RobotWorker should not implement eat. Robots do not eat. 😮

✅ Prefer

TypeScript
<strong>interface Workable</strong> {
  <strong>work()</strong>: void;
}

<strong>interface Eatable</strong> {
  eat(): void;
}

<strong>class HumanWorker implements Workable, Eatable</strong> {
  <strong>work()</strong> { ... }
  <strong>eat()</strong> { ... }
}

<strong>class RobotWorker implements Workable</strong> {
  <strong>work()</strong> { ... }
}

Now, we have separate interfaces - Workable and Eatable, ensuring that no class implements unnecessary methods.

D: Dependency Inversion Principle (DIP)

By definition, the principle states:

“Abstractions should not depend on details. Detail should depend on abstractions.”

Just to remind you, abstractions mean interface or abstract class whereas a detail means a concrete class.

An example is worth a thousand words. So, let’s see one.

⛔ Avoid

TypeScript
<strong>class LightBulb</strong> {
  <strong>turnOn()</strong> { ... }
  <strong>turnOff()</strong> { ... }
}

<strong>class ElectricPowerSwitch</strong> {
  <strong>bulb: LightBulb;
  isOn: boolean = false;</strong>

  <strong>constructor(bulb: LightBulb)</strong> {
    <strong>this.bulb = bulb;</strong>
  }

  <strong>press()</strong> {
    if (this.<strong>isOn</strong>) {
      this.<strong>bulb</strong>.turnOff();
      isOn = false;
    } else {
      this.<strong>bulb</strong>.turnOn();
      isOn = true;
    }
  }
}

The example violets the DIP principle because ElectricPowerSwitch directly depends on the LightBulb class.

The direct dependency between the ElectricPowerSwitch and LightBulb is problematic because of:

  • lack of flexibility - the direct dependency on LightBulb means that ElectricPowerSwitch can only control instances of LightBulb. If you want to use the switch with a different type of device, like a fan or a heater, you would need to modify the ElectricPowerSwitch class, which violates the OCP
  • tight coupling - the ElectricPowerSwitch is tightly coupled with the LightBulb class. It makes the system less modular and more fragile. Changes in the LightBulb class, such as its behavior, could directly impact the ElectricPowerSwitch class, leading to a higher risk of bugs
  • difficulties in testing - testing the ElectricPowerSwitch class in isolation becomes difficult due to the direct dependency on the LightBulb class

✅ Prefer

TypeScript
<strong>interface SwitchableDevice</strong> {
  <strong>turnOn()</strong> { ... }
  <strong>turnOff()</strong> { ... }
}

<strong>class LightBulb implements SwitchableDevice</strong> {
  <strong>turnOn()</strong> { ... }
  <strong>turnOff()</strong> { ... }
}

<strong>class ElectricPowerSwitch</strong> {
  <strong>device: SwitchableDevice</strong>;

  <strong>constructor(device: SwitchableDevice)</strong> {
    <strong>this.device = device;</strong>
  }

  <strong>press()</strong> {
    if (this.<strong>device</strong>) {
      this.<strong>device</strong>.turnOn();
    } else {
      this.<strong>device</strong>.turnOff();
    }
  }
}

Here, both ElectricPowerSwitch and LightBulb depend on the SwitchableDevice interface, not on each other. This way the ElectricPowerSwitch can work with different devices (concrete classes) as long as they implement the SwitchableDevice interface.

This way we achieve:

Key principle: Loose coupling. 🍝

Note: This principle is a little more tricky to implement and follow in practice. I will write a follow-up article to dig deeper into the Dependency Inversion Principle and share how we can implement it with Dependency Injection Frameworks.

Conclusion

To summarize, SOLID Principles are a great way to write code that is easier and faster to change. However, you shouldn’t be applying the principles blindly since they may harm you more than help you.

The end goal when writing our code is to make it easy to change, understand, maintain, and test. This way our future selves will thank us.

After some time and practice, you won’t see a clear boundary between each other. You’ll be writing robust and maintainable code without so much thinking.

Related articles

Whenever you’re ready, here’s how I can help you:

  1. 1.

    The Conscious React: React architecture, design & clean code — 100+ production tips across 6 chapters, updated for React 19, plus 4 companion repos you can clone and run.

  2. 2.

    The Conscious Node: Node.js architecture, design & clean code — 157 production tips across 10 chapters, from module boundaries to the transactional outbox and zero-downtime deploys.

  3. 3.

    The JavaScript Architect Bundle: Both books + all React companion repos + CLAUDE.md rulesets + both playbooks. The complete path from developer to architect.

  4. 4.

    Free Resources: Architecture playbooks, cheat-sheets, and the JavaScript Architect Roadmap — practical guides for leveling up to senior.

The T-Shaped Dev

Join 30K+ engineers leveling up to architect

One practical tip on JavaScript, React, Node.js, and software architecture every week. No spam, unsubscribe anytime.

Petar Ivanov

Written by

Petar Ivanov

Software engineer, author, and speaker. I help JavaScript developers grow from Mid → Senior → Architect — production-grade React, Node.js, and AI systems.