DEV Community

Germán
Germán

Posted on • Originally published at Medium

SOLID Principles Made Simple in TypeScript

When your code and class contracts both play nice with SOLID — everyone wins :)

You’ve probably heard about the SOLID principles — five core ideas that help you write cleaner, more maintainable code. But while the theory is everywhere, applying these principles in real projects — or explaining them to a teammate — can be a bit trickier.

This article isn’t about complex real-world cases. The goal is to show simple, focused examples that clearly illustrate when a principle is being followed and when it’s not. Use it as a reference for yourself or share it with your team if you want everyone to speak the same language when it comes to writing solid code.

What are the SOLID Principles?

SOLID is an acronym that brings together five design principles for object-oriented programming. They’re not specific to TypeScript, but they apply well to it — and to many modern programming languages.

S — Single Responsibility Principle (SRP)
A class should have one, and only one, reason to change.

O — Open/Closed Principle (OCP)
Software should be open for extension, but closed for modification.

L — Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types.

I — Interface Segregation Principle (ISP)
Clients shouldn’t be forced to depend on methods they don’t use.

D — Dependency Inversion Principle (DIP)
High-level modules shouldn’t depend on low-level modules, but on abstractions.

1. Single Responsibility Principle (SRP)

A class should have only one reason to change.

❌ Incorrect: This class has more than one responsibility

class UserController {
    constructor(
        private userRepository: UserRepository,
        private emailService: EmailService
    ) {}

    public createUser(request: Request) {
        const user = User.create(request.name, request.email, request.password);
        this.userRepository.save(user);

        this.emailService.sendEmail(user.email, 'Welcome to our app');

        return {
            message: 'User created successfully',
            user: user
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

This code breaks the Single Responsibility Principle because the UserController is trying to do too much at once. It’s not just handling the request — it’s also creating the user, saving it to the database, sending a welcome email, and formatting the response. Each of those tasks could (and should) be handled by separate parts of the system.

✅ Correct: This class has only one responsibility

class UserService {
    constructor(private userRepository: UserRepository) {}

    public createUser(request: Request): User {
        const user = User.create(request.name, request.email, request.password);
        this.userRepository.save(user);

        return user;
    }
}

class UserNotificationService {
  constructor(private emailService: EmailService) {}

  public sendWelcomeEmail(user: User): void {
    this.emailService.sendEmail(user.email, 'Welcome to our app');
  }
}

class UserController {
  constructor(
    private userService: UserService,
    private userNotificationService: UserNotificationService
  ) {}

  public createUser(request: Request) {
    const user = this.userService.createUser(request);
    this.userNotificationService.sendWelcomeEmail(user);

    return {
      message: 'User created successfully',
      user: user
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

In this improved version, the code is split into three clear parts, each with its own responsibility. The UserService is in charge of everything related to users: it handles their creation and manages how they're saved. The UserNotificationService focuses solely on notifications, specifically sending welcome emails. Finally, the UserController acts as the bridge between the services, orchestrating the process and formatting the response sent back to the client.

This structure is much cleaner and more maintainable. Since each class has a single, well-defined purpose, it’s easier to understand, test, and modify the code. If you ever need to change how emails are sent, you only touch the UserNotificationService. If the logic for creating users evolves, you update the UserService. And if the API response needs tweaking, you adjust the UserController. Each class changes for just one reason, which is exactly what the Single Responsibility Principle is about.

By keeping responsibilities separate and dependencies well-defined, the code becomes more robust and flexible over time.

2. Open/Closed Principle (OCP)

A class should be open for extension but closed for modification.

❌ Incorrect: This class is not open for extension

class PaymentService {
  public process(paymentMethod: string) {
    if (paymentMethod === 'credit-card') {
      // Process credit card payment
    } else if (paymentMethod === 'crypto') {
      // Process crypto payment
    } else {
      throw new Error('Invalid payment method');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This implementation breaks the Open/Closed Principle because every time a new payment method is added, the PaymentService class needs to be modified. You have to add another if condition and implement the logic directly inside the class. This approach makes the code harder to maintain over time and more prone to errors, since each change requires altering existing, working code. Instead of being closed for modification and open for extension, the class keeps evolving internally with each new payment type, which is exactly what OCP tries to prevent.

✅ Correct: I can extend the PaymentService class to add new payment methods without modifying the existing code

interface PaymentMethod {
  process(): void;
}

class CreditCardPayment implements PaymentMethod {
  process(): void {
    // Process credit card payment
  }
}

class CryptoPayment implements PaymentMethod {
  process(): void {
    // Process crypto payment
  }
}

class PaymentService {
  public process(paymentMethod: PaymentMethod) {
    paymentMethod.process();
  }
}
Enter fullscreen mode Exit fullscreen mode

This implementation respects the Open/Closed Principle because the PaymentService class remains closed for modification—its core logic doesn’t change when new payment methods are introduced. Instead, new methods can be added by simply creating new classes that implement the PaymentMethod interface. Each payment method is encapsulated in its own class, which also aligns with the Single Responsibility Principle. As a result, the code is cleaner, easier to maintain, and more extensible. You can add new functionality without risking existing behavior. This design also improves testability, since each payment method can be tested on its own, and brings flexibility, allowing different methods to be used or replaced independently.

3. Liskov Substitution Principle (LSP)

A class should be able to be replaced by its subclass without affecting the correctness of the program.

❌ Incorrect: This class violates LSP because the subclass cannot replace the parent class

class Property {
  constructor(public address: string, public area: number) {}

  public valuate(): number {
    return AVM.estimate(this.address, this.area);
  }
}

class SoldProperty extends Property {
  public valuate(): number {
    throw new Error('Sold properties cannot be valuated');
  }
}
Enter fullscreen mode Exit fullscreen mode

This implementation breaks the Liskov Substitution Principle because the SoldProperty subclass can’t be used as a drop-in replacement for its parent Property class. The valuate() method in SoldProperty throws an error, which breaks the expected behavior. So, any code that expects a Property object would fail if it received a SoldProperty instead. In other words, SoldProperty isn’t a proper subtype since it doesn’t fully honor the contract of its parent class.

✅ Correct: Using interfaces to define clear contracts and proper type hierarchies

interface Property {
  address: string;
  getStatus(): string;
}

interface ValuableProperty extends Property {
  valuate(): number;
}

class AvailableProperty implements ValuableProperty {
  constructor(public address: string, public area: number) {}

  public getStatus(): string {
    return 'Available';
  }

  public valuate(): number {
    return AVM.estimate(this.address, this.area);
  }
}

class SoldProperty implements Property {
  constructor(public address: string, public soldPrice: number) {}

  public getStatus(): string {
    return 'Sold';
  }
}
Enter fullscreen mode Exit fullscreen mode

This implementation follows the Liskov Substitution Principle (LSP) because it defines a clear and logical type hierarchy using interfaces. The base Property interface captures the common behavior of all properties, while ValuableProperty extends it to represent those that can be valued. AvailableProperty implements ValuableProperty since it's meant to be valuated, whereas SoldProperty only implements Property, as valuation is no longer relevant. This structure ensures that any code expecting a Property can safely work with both available and sold properties without breaking functionality, preserving type safety and respecting the distinct responsibilities of each class.

4. Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces that they do not use.

❌ Incorrect: This class violates ISP because the client is forced to depend on an interface that it does not use

interface Property {
  getAddress(): string;
  getArea(): number;
  markAsReformed(): void;
}

class CatalogProperty implements Property {
  getAddress(): string {
    return 'Carrer dels Tallers, 11';
  }

  getArea(): number {
    return 100;
  }

  markAsReformed(): void {
    throw new Error('Not implemented');
  }
}
Enter fullscreen mode Exit fullscreen mode

This implementation breaks the Interface Segregation Principle because the CatalogProperty class has to implement methods it doesn’t actually use. For example, the markAsReformed() method doesn’t make sense for catalog properties. Basically, it forces the class to depend on functionality it doesn’t need, which goes against the idea that clients should only rely on the methods relevant to them.

✅ Correct: Segregating interfaces to avoid unnecessary dependencies

interface BasicProperty {
  getAddress(): string;
  getArea(): number;
}

interface Reformable {
  markAsReformed(): void;
}

class CatalogProperty implements BasicProperty {
  getAddress(): string {
    return 'Carrer dels Tallers, 11';
  }

  getArea(): number {
    return 100;
  }
}

class InvestmentProperty implements BasicProperty, Reformable {
  getAddress(): string {
    return 'Carrer dels Tallers, 11';
  }

  getArea(): number {
    return 100;
  }

  markAsReformed(): void {
    // Reform logic
  }
}
Enter fullscreen mode Exit fullscreen mode

This implementation respects the Interface Segregation Principle by splitting responsibilities into different interfaces. We have BasicProperty for common property features, and Reformable for properties that can be reformed. This way, each class only implements what it really needs, avoiding unnecessary methods. The result is cleaner, more modular code that’s easier to maintain.

5. Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

❌ Incorrect: This class violates DIP because the high-level module depends on the low-level module

class InvestorNotifier {
  constructor(private emailClient: SendGridClient) {}

  public notify(investor: Investor, message: string) {
    this.emailClient.sendEmail(investor.email, message);
  }
}
Enter fullscreen mode Exit fullscreen mode

This implementation breaks the Dependency Inversion Principle because the InvestorNotifier class is directly tied to the specific SendGridClient implementation. This tight coupling makes it hard to switch to another email service, like Mailgun, without changing the notifier itself. Ideally, the high-level module (InvestorNotifier) shouldn’t depend on low-level details like a particular email client.

✅ Correct: Using abstractions to decouple high-level and low-level modules

interface EmailService {
  send(to: string, message: string): void;
}

class SendGridEmailService implements EmailService {
  send(to: string, message: string): void {
    // Send email using SendGrid
  }
}

class InvestorNotifier {
  constructor(private emailService: EmailService) {}

  public notify(investor: Investor, message: string) {
    this.emailService.send(investor.email, message);
  }
}
Enter fullscreen mode Exit fullscreen mode

This implementation respects the Dependency Inversion Principle because the high-level module (InvestorNotifier) depends on an abstraction (EmailService), not on a concrete implementation. The low-level module (SendGridEmailService) simply implements this abstraction. This setup makes it easy to switch email providers, like moving from SendGrid to Mailgun, without having to change the InvestorNotifier class. As a result, the code becomes more flexible and easier to maintain.

Conclusion

At first, following SOLID principles might feel like extra work. But once you get used to them, you’ll quickly see the benefits: testing gets easier, updates become simpler, and your code turns cleaner — the kind of code that everyone on your team can understand and build upon.

If this article helped you get a better grasp of SOLID, please consider ⭐️ the GitHub repo, leave a comment, or share it with your team. Every little bit helps spread better coding practices!

Check out all the examples here:
https://github.com/gertoska/simple-solid-in-typescript

Top comments (0)