SOLID Principles: Write Maintainable Software

Software Engineering By Tryz
SOLIDOOPClean CodeArchitectureTypeScript

Table of Contents

Introduction

As a project grows, code that once felt simple can become harder to maintain. A small change starts affecting many files. Tests become difficult to write. Classes know too much about each other. Adding a feature feels risky.

SOLID is a set of design principles that helps object-oriented code stay easier to change. It does not automatically make code good, but it gives developers a vocabulary for spotting design problems.

To keep the examples concrete, this article uses TypeScript. Its class, interface, and type system make responsibility boundaries, object contracts, and dependency injection easier to see.

What Is SOLID?

SOLID stands for:

  1. Single Responsibility Principle
  2. Open/Closed Principle
  3. Liskov Substitution Principle
  4. Interface Segregation Principle
  5. Dependency Inversion Principle

These principles are especially useful when building services, domain logic, modules, and systems that will evolve over time.

S: Single Responsibility Principle

The Single Responsibility Principle says a class should have one reason to change.

For example, this class has too many responsibilities:

type InvoiceItem = {
  price: number;
  quantity: number;
};

type Invoice = {
  id: string;
  items: InvoiceItem[];
};

class InvoiceService {
  calculateTotal(items: InvoiceItem[]) {
    return items.reduce((total, item) => total + item.price * item.quantity, 0);
  }

  saveToDatabase(invoice: Invoice) {
    // Save invoice
  }

  sendEmail(invoice: Invoice) {
    // Send invoice email
  }

  generatePdf(invoice: Invoice) {
    // Generate invoice PDF
  }
}

It calculates, persists, emails, and generates files. A better design separates responsibilities:

class InvoiceCalculator {
  calculateTotal(items: InvoiceItem[]) {
    return items.reduce((total, item) => total + item.price * item.quantity, 0);
  }
}

class InvoiceRepository {
  save(invoice: Invoice) {
    // Save invoice
  }
}

class InvoiceMailer {
  send(invoice: Invoice) {
    // Send invoice email
  }
}

class InvoicePdfGenerator {
  generate(invoice: Invoice) {
    // Generate invoice PDF
  }
}

Now each class changes for a clearer reason.

O: Open/Closed Principle

The Open/Closed Principle says code should be open for extension but closed for modification.

Imagine a payment service with many if statements:

function pay(method: string, amount: number) {
  if (method === "credit_card") {
    // Pay by card
  } else if (method === "paypal") {
    // Pay by PayPal
  }
}

Every new payment method requires editing the same method. A more extensible design uses polymorphism:

interface PaymentGateway {
  pay(amount: number): void;
}

class CreditCardGateway implements PaymentGateway {
  pay(amount: number) {
    console.log(`Paying ${amount} by credit card`);
  }
}

class PaypalGateway implements PaymentGateway {
  pay(amount: number) {
    console.log(`Paying ${amount} by PayPal`);
  }
}

class PaymentService {
  constructor(private readonly gateway: PaymentGateway) {}

  checkout(amount: number) {
    this.gateway.pay(amount);
  }
}

Adding a new gateway can be done by adding a new class instead of editing core payment logic.

L: Liskov Substitution Principle

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

If a class extends another class but breaks expected behavior, the design becomes dangerous.

For example, if ReadOnlyFile extends File but throws an error whenever write() is called, code that expects a normal File may break unexpectedly.

The lesson: inheritance should preserve behavior. If a child class cannot safely behave like the parent, composition or a different abstraction may be better.

I: Interface Segregation Principle

The Interface Segregation Principle says clients should not be forced to depend on methods they do not use.

Avoid large interfaces like:

interface Worker {
  code(): void;
  design(): void;
  test(): void;
  deploy(): void;
}

Not every worker does all of those things. Smaller interfaces are easier to implement:

interface CanCode {
  code(): void;
}

interface CanDesign {
  design(): void;
}

interface CanTest {
  test(): void;
}

interface CanDeploy {
  deploy(): void;
}

Focused interfaces reduce fake methods, empty implementations, and unnecessary coupling.

D: Dependency Inversion Principle

The Dependency Inversion Principle says high-level modules should depend on abstractions, not concrete details.

Instead of a service depending directly on a specific email provider:

class MailgunClient {
  sendMessage(to: string, subject: string, body: string) {
    // Send email through Mailgun
  }
}

class UserService {
  constructor(private readonly mailer: MailgunClient) {}

  welcomeUser(email: string) {
    this.mailer.sendMessage(email, "Welcome", "Thanks for joining us.");
  }
}

depend on an interface:

interface Mailer {
  send(to: string, subject: string, body: string): void;
}

class MailgunMailer implements Mailer {
  send(to: string, subject: string, body: string) {
    // Send email through Mailgun
  }
}

class UserService {
  constructor(private readonly mailer: Mailer) {}

  welcomeUser(email: string) {
    this.mailer.send(email, "Welcome", "Thanks for joining us.");
  }
}

Now the service is easier to test and the email provider can be changed without rewriting business logic.

When SOLID Helps

SOLID is most useful when:

  • a codebase has many business rules
  • classes are reused in multiple places
  • tests are difficult because dependencies are hard-coded
  • features keep changing
  • several developers work on the same system
  • the project will be maintained for a long time

For tiny scripts, applying every principle heavily can be overkill. Use SOLID to reduce real complexity, not to add ceremony.

Conclusion

SOLID helps developers design code that is easier to extend, test, and maintain.

The practical goal is simple: when requirements change, the code should not collapse under its own structure. Start with responsibility boundaries, reduce direct dependencies, and introduce abstractions only when they make the system easier to work with.

In the examples above, TypeScript helps make the core idea visible: define clear contracts, keep implementations focused, and avoid letting high-level code depend too tightly on low-level details.

Keep reading within the same topic.

Don't Miss Out

Get the latest tech articles, tips, and insights delivered to your inbox.