SOLID has made me the developer that I am today. Deeply understanding it, has vastly improved my code. All thanks to Robert C. MartinOpen in a new tab, who coined the SOLIDOpen in a new tab acronym in the 2000s. I've been applying this concept since 2015. Let me show you how.
#What is SOLID
SOLID is an acronym that represents five concepts that are applied to Software Design:
- Single Responsibility Principle (SRP): A class should have only one reason to change.
- Open/Closed Principle (OCP): Software entities should be open for extension but closed for modification.
- Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types without altering correctness.
- Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions.
These are taken word by word from the original definition by Robert. However, to me, they were too abstract and too hard to grasp in the beginning. So let's try to simplify them.
#Single Responsibility Principle
“”A class should have only one reason to change.
Imagine yourself, a Frontend Architect. Your main task is to turn requirements into working code. Not only code that works today, but that works tomorrow.
Now imagine if you need to deal with marketing.
And customer support.
And handling billing issues .
Yes, perhaps I'm describing a freelancer...
None of these are related to Frontend Architecture, and what ends up happening is that any change in those areas, directly affects Frontend Architecture.
It can distract and even break progress in the task you were tasked to do.
We want to build systems in such a way that one change in certain areas don't affect others.
Code behaves the same way.
class UserService { createUser(name: string, email: string) { // validation if (!email.includes('@')) { throw new Error('Invalid email'); } // save to database console.log('Saving user to DB'); // send email console.log(`Sending welcome email to ${email}`); return { name, email }; } }
This code has 3 different reasons to change:
- Email validation changes
- Database-saving logic changes
- Email logic changes
If you need to describe what your class/function/module/system does by using multiple "ands", they are doing too much!
Let's apply the Single Responsibility principle.
class UserValidator { validateEmail(email: string) { if (!email.includes('@')) { throw new Error('Invalid email'); } } } class UserRepository { save(name: string, email: string) { console.log('Saving user to DB'); return { name, email }; } } class EmailService { sendWelcome(email: string) { console.log(`Sending welcome email to ${email}`); } } class UserService { constructor( private validator: UserValidator, private repository: UserRepository, private emailService: EmailService ) {} createUser(name: string, email: string) { this.validator.validateEmail(email); const user = this.repository.save(name, email); this.emailService.sendWelcome(email); return user; } }
Although it's more code, it's well worth it.
#Open Closed Principle
“”Software entities should be open for extension but closed for modification.
This principle tell us that should be able to add new features without touching existing code.
In an ideal world, if we have a completely new feature that requires that we create completely new code.
If we have a solid architecture, then it would mean that we would only need to add new code, not modify the previous one.
Let's take, for example, the following code:
type PaymentMethod = 'card' | 'cash'; function processPayment(method: PaymentMethod, amount: number) { if (method === 'card') { console.log(`Processing card payment of ${amount}`); } else if (method === 'cash') { console.log(`Processing cash payment of ${amount}`); } }
Every time we need to add a new payment system we need to modify existing code.
However, with a few changes and help from polymorphism (which is a fancy word for treating different things in the same way):
interface PaymentProcessor { process(amount: number): void; } class CardPayment implements PaymentProcessor { process(amount: number) { console.log(`Processing card payment of ${amount}`); } } class CashPayment implements PaymentProcessor { process(amount: number) { console.log(`Processing cash payment of ${amount}`); } } function processPayment(processor: PaymentProcessor, amount: number) { processor.process(amount); }
Now when we add a new payment. It's just a matter of adding new code:
class CryptoPayment implements PaymentProcessor { process(amount: number) { console.log(`Processing crypto payment of ${amount}`); } }
This principle applies to more concepts, like modules, or even complete systems. Or even Frameworks.
In NextJS RoutesOpen in a new tab, when we add a new route in the application, it follows this principle, since it loads the folder automagically and creates a new route for it. You don't need to register the route somewhere, you just add it.


#Liskov Substitution
“”Subtypes must be substitutable for their base types without altering correctness.
This principle is encountered less often and it refers to supertypes and subtypes, which has to do with inheritance.
When we have a class where we've defined a base method, and in a subclass we override it, the program should work correctly even if we swap them.
class Bird { fly() { console.log('Flying'); } } class Penguin extends Bird { fly() { throw new Error('Penguins cannot fly'); } } function makeBirdFly(bird: Bird) { bird.fly(); } makeBirdFly(new Penguin()); // Breaks expectation
Correct usage:
interface Bird {} interface FlyingBird extends Bird { fly(): void; } class Sparrow implements FlyingBird { fly() { console.log('Flying'); } } class Penguin implements Bird { swim() { console.log('Swimming'); } } function makeBirdFly(bird: FlyingBird) { bird.fly(); } makeBirdFly(new Sparrow());
This principle also covers subtler cases.
If a subtype throws an error for the inherited method it would also break it. For instance, if we do something like this:
class Bird { fly() { console.log('Flying'); } } class Penguin extends Bird { fly() { throw new Error('Cannot fly'); } } function makeBirdFly(bird: Bird) { bird.fly(); } makeBirdFly(new Penguin()); // 💥 runtime error
Or even if we change the return type it could be considered that we are not following this principle:
class Bird { speak(): string { return 'chirp'; } } class Penguin extends Bird { speak(): any { return { sound: 'honk' }; // ❌ different type } } function makeBirdSpeak(bird: Bird) { const sound = bird.speak(); console.log(sound.toUpperCase()); // expects string } makeBirdSpeak(new Penguin()); // 💥 runtime error
But why should we care about this?
Because it protects you from surprises when swapping implementations. When we are coding, we should follow the Law of Least AstonishmentOpen in a new tab. We developers tend not to like surprises.
#Interface Segregation Principle
“”Clients should not be forced to depend on interfaces they do not use.
To me, this principle feels redundant with the Single Responsibility principle. Regardless, let's see it in detail.
An interface that could be useful to create in your projects to abstract the way we connect with repositories is the following:
export interface CrudRepository<Entity extends { id: Id }, CreateEntity> { findAll(): Promise<Entity[]>; findById(id: Id): Promise<Entity | null>; create(data: CreateEntity): Promise<void>; update(entity: Entity): Promise<void>; delete(id: Id): Promise<void>; }
This way we could always interact with data (albeit it being an API, database, or any other type) through the same methods.
Forget about forgetting if your creation method should be called create or save!
However, we might promptly encounter a problem.
Let's say we find a repository that has all the CRUD operations except delete. Ugh. Now what?
Should we implement an empty method?
Throw an exception?
There seems like there's no elegant solution!
Well, the problem is the root. We added too many methods to the interface. Let's break it apart.
interface FindableAll<Entity> { findAll(): Promise<Entity[]>; } interface FindableById<Entity extends { id: Id }> { findById(id: Id): Promise<Entity | null>; } interface Creatable<CreateEntity> { create(data: CreateEntity): Promise<void>; } interface Updatable<Entity extends { id: Id }> { update(entity: Entity): Promise<void>; } interface Deletable { delete(id: Id): Promise<void>; }
Now, we can still have the CrudRepository, but we can build it differently.
export interface CrudRepository<Entity extends { id: Id }, CreateEntity> extends FindableAll<Entity>, FindableById<Entity>, Creatable<CreateEntity>, Updatable<Entity>, Deletable {}
Not only does it's more semantic, but now, when we have a repository that doesn't need all methods, well, we just don't include those!
export interface UserRepository extends FindableAll<User>, FindableById<User>, Creatable<CreateUser> {}
#Dependency Inversion Principle
“”High-level modules should not depend on low-level modules. Both should depend on abstractions.
The dependency inversion principle, apart from making systems more flexible, is single-handedly the principle that makes testing the easiest.
In any given codebase, testing really is quite easy, the problem is that sometimes our code is so rigid that it makes testing incredibly difficult.
class DieselEngine { getPower(): number { return Math.random() * 100; } } class Car { private engine = new DieselEngine(); getSpeed(): number { return this.engine.getPower() * 2; } }
How would you test the previous code?
The SUTOpen in a new tab (Subject Under Test) is that we need to assert that this.engine.getPower() has been called and the result is multiplied by 2.
We even have a Math.random()! The problem with this code is that the dependencies are fixed; we directly use them, making them exceedingly difficult to swap.
If we follow this type of coding, when we make tests, we are going to need to mock imports. That's as fragile as it gets with tests.
No, there's a better approach.
class DieselEngine { getPower(): number { return Math.random() * 100; } } class Car { constructor(private dieselEngine: DieselEngine) {} getSpeed(): number { return this.dieselEngine.getPower() * 2; } }
Now, if we want to test the Car, we just give it a FakeEngine!
class FakeEngine implements Engine { getPower(): number { return 50; // deterministic } } const car = new Car(new FakeEngine()); console.log(car.getSpeed()); // 100 ✅ always the same
What about Math.random()? That's also another dependency! We can also abstract it away.
interface RandomGenerator { next(): number; } class DefaultRandom implements RandomGenerator { next(): number { return Math.random(); } } class DieselEngine { constructor(private random: RandomGenerator) {} getPower(): number { return this.random.next() * 100; } } class Car { constructor(private engine: { getPower(): number }) {} getSpeed(): number { return this.engine.getPower() * 2; } }
This principle applies to functions as well:
type RandomFn = () => number; function calculateDiscount(price: number, random: RandomFn): number { return price * random(); }
I prefer classes, that way I can say that I have more class.
No, really, it's just because they allow me to group different functions together, be able to construct them, reusing the constructor parameters and provide public/private visibility.
I can say that objectively, for large codebases, classes provide more value. That doesn't mean I don't have here and there some utility functions, I still do, but most of my code is classes.
The great thing about principles is that they apply to multiple programming languages, paradigms and can be applied with AI.
#Conclusion
As you've seen, the principles, in essence, are quite easy to go over, but challenging to internalize.
- Single Responsibility: Keep things focused so changes stay isolated.
- Open/Closed: Add new behavior without breaking existing code.
- Liskov Substitution: Make implementations interchangeable without surprises.
- Interface Segregation: Only depend on what you actually use.
- Dependency Inversion: Rely on abstractions to keep code flexible and testable.
As with most things in life, all is about striking balance, making our code pleasing to work with now and in the future.
Now it's time to vote on the next issue of the newsletter!
P.D: I recently gave a talk at Codemotion Madrid on how I've built this newsletter. Although the recording is not available yet, you can check the slides hereOpen in a new tab. I created a Gmail clone. Pretty cool.