Bootstrap

SOLID Design Principles in TypeScript

SOLID Design Principles in TypeScript
Photo by Sharon McCutcheon

Software, by definition, is intended to be re-shaped as needed throughout its lifetime - it's in the name.

In other words, creating clean and maintainable designs that are easy to change in response to new requirements is vital for any successful product.

At the outset of most new projects, there is often a level of optimism and excitement within the team at the prospect of creating an elegant and innovative software design to support the application's critical early features.

The team rides their optimism and drive to make something outstanding, along with a bit of blood, sweat, and tears to deliver a first release of the product - life is good.

As time passes, new requirements emerge, and bugs appear as the design is bent and pulled in different directions to meet the complex and ever-changing demands of the real world.

Poorly designed software will buckle and show cracks quickly under this pressure. A minor tweak in one region of the codebase unexpectedly breaks something in a completely unrelated part. The most straightforward changes require a disproportionate amount of time and testing.

Get notified on new posts

Straight from me, no spam, no bullshit. Frequent, helpful, email-only content.

In extreme cases, the project may grind to a halt as the team's velocity becomes increasingly limited by the growing pile of technical debt within the project.

Sound familiar? It's not a new story. Way back in his 2000 paper "Design Principles and Design Patterns", Robert C. Martin describes this very predicament. He warns us that without good design, our software will rot over time in the face of changing requirements the original design did not anticipate.

Rotting designs typically exhibit four main symptoms in their rigidity, fragility, immobility and viscosity.

These deficiencies emerge for a variety of different reasons; however, a few common ones include:

  • Cramming more logic and data into existing classes unrelated to the class's original responsibility.
  • Creating concrete dependencies between classes - tight coupling makes it hard to change one class without affecting or breaking the other.
  • A buildup of duplicated code occurs when the risk and effort required to make code reusable is too high, so it gets rewritten with slight variations instead of reused.

In his paper, Uncle Bob presents five object-oriented principles to help us avoid these pitfalls by creating more resilient designs that offer greater flexibility and are easier to understand and change.

The mnemonic acronym SOLID was introduced later, around 2004, by Michael Feathers to describe this set of valuable principles that software professionals across the globe have embraced.

This post will examine each SOLID principle using examples in TypeScript. TypeScript's versatility and common object-oriented patterns allow us to write more SOLID code in the browser, backend, or anywhere the transpiled JavaScript can run 🀘.

Single Responsibility Principle

The SRP reminds us that a class should be focused on a single purpose and should have one, and only one, reason to change.

Single responsibility is a fundamental principle experienced developers apply naturally across the spectrum from classes to microservices to ensure they focus on one and only one thing.

In the context of object-oriented programming, classes are a common area where SRP is easy to violate when we add responsibilities to them that have no business being there. We don’t want to create objects that do too much and have unrelated behavior.

Let's look at a simple example.

class Invoice {
  id!: number;
  customerId!: number;
  items!: LineItem[];

  /* Get the invoice total */
  get total() {
    return this.items.reduce((n, { price }) => n + price, 0);
  }

  /* Adds an item to the invoice */
  addItem(item: LineItem): void {
    this.items.push(item);
  }

  /* Removes the specified item from the invoice */
  removeItem(id: string): void {
    this.items = this.items.filter((i) => i.id != id);
  }

  /* Soft deletes the invoice in the database
   * ❌ Violates SRP
   */
  protected async delete(): Promise<QueryResult> {
    return SqlConnection.update("invoices", "set active=0 where id=?", [
      this.id,
    ]);
  }
}

The above example violates SRP. Why? The primary responsibility of the Invoice class should be encapsulating the state and behavior of an invoice and not fetching or updating data in the database.

Changes to how the system stores invoices in the database could ripple into this class, causing it to break in unpredictable ways and forcing changes that have nothing to do with its original purpose, potentially causing even more headaches.

A simple solution is to move the delete() method into another class focused on database operations.

/*  Manages invoices in the persistence layer */
class InvoiceRepository {
  /* Soft deletes the invoice in the database */
  protected async delete(id: number): Promise<QueryResult> {
    return SqlConnection.update("invoices", "set active=0 where id=?", [id]);
  }
}

The Invoice class is now free from database-related responsibilities and complies with SRP.

/* SRP-Compliant βœ… */
class Invoice {
  id!: number;
  customerId!: number;
  items!: LineItem[];

  /* Get the invoice total */
  get total() {
    return this.items.reduce((n, { price }) => n + price, 0);
  }

   /* Adds an item to the invoice */
  addItem(item: LineItem): void {
    this.items.push(item);
  }

   /* Removes the specified item from the invoice */
  removeItem(id: string): void {
    this.items = this.items.filter((i) => i.id != id);
  }
}

SRP helps us write more cohesive code that is better aligned and focused on a clear purpose.

Open-closed Principle

The OCP states that entities should be open for extension, but closed for modification.

Of all the principles of object-oriented design, this is the most important.

This may sound a little contradictory; how can we close something for modification?

The subtle trick here is that we want to be able to extend or change what entities can do without tinkering with their internals through the use of abstractions.

Consider the following GreetingService class as an example.

class GreetingService {
  language: string;

  constructor(language: string) {
    this.language = language;
  }

  /* Returns a greeting for the configured language */
  execute(): string {
    // ❌ Violates OCP
    switch (this.language) {
      case "en": {
        return "Hello";
      }

      case "es": {
        return "Hola";
      }

      case "fr": {
        return "Bonjour";
      }

      default:
        return "";
    }
  }
}

This simple class expects a language argument in its constructor, which the execute() method uses to return the associated greeting.

The flaw with this code is that every time we wish to add or remove a language from the list, we must modify the switch statement accordingly. That might seem harmless enough for such a simple example; however, a similar misstep in larger, more complex applications can be painful as the code is continuously changed to keep up with ever-changing requirements.

The constant modification adds extra effort and risk when it negatively affects dependent code, requiring more time and effort to debug and patch errors.

The key to resolving this, as mentioned above, is by using abstractions. πŸ”‘

Adding an abstraction over the language via the LanguageProvider interface makes it trivial to extend the GreetingService with new languages.

interface LanguageProvider {
  greet(): string;
}

First, we can create individual classes for each language by implementing the LanguageProvider interface.

class EnLanguageProvider implements LanguageProvider {
  /* Returns a greeting in english */
  greet(): string {
    return "Hello";
  }
}

class FrLanguageProvider implements LanguageProvider {
  /* Returns a greeting in french */
  greet(): string {
    return "Bonjour";
  }
}

Next, we can refactor the GreetingService to require a LanguageProvider instance and replace the problematic switch statement with a call to the instance's greet() method.

/* OCP-Compliant βœ… */
class GreetingService {
  languageProvider: LanguageProvider;

  constructor(languageProvider: LanguageProvider) {
    this.languageProvider = languageProvider;
  }

  /* Returns a greeting for the configured language provider */
  execute(): string {
    return this.languageProvider.greet();
  }
}

The class is now OCP compliant as new languages can be added or removed without touching its internals. πŸ…

Liskov Substitution Principle

The LSP states that subclasses should be substitutable for their base class.

The Liskov Substitution Principle was introduced by Barbara Liskov in her conference keynote "Data abstraction" in 1987.

The conditions for substitutability are complex, and it's worth noting the rules on pre-and postconditions are identical to those introduced by Bertrand Meyer in his 1988 book "Object-Oriented Software Construction".

Robert Martin summarized this as follows:

Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it.

In other words, client code that uses an abstract type shouldn't have to modify its behavior depending on the derived type it receives.

Let's look at a classic example of this smell using geometry.

We've implemented the following Rectangle class in our application.

class Rectangle {
  constructor(private width: number, private length: number) {}

  public setWidth(width: number) {
    this.width = width;
  }

  public setLength(length: number) {
    this.length = length;
  }

  public get area(): number {
    return this.width * this.length;
  }
}

One day we discovered the need to extend the application by adding a Square class.

A square is considered a rectangle, so we implement a new Square as a subclass of Rectangle.

We override the setWidth() and setHeight() methods and can reuse the implementation of the area property in the base class.

class Square extends Rectangle {
  constructor(side: number) {
    super(side, side);
  }

  /* Override setWidth to ensure all sides are the same */
  public setWidth(width: number) {
    super.setWidth(width);
    super.setHeight(width);
  }

  /* Override setHeight to ensure all sides are the same */
  public setHeight(height: number) {
    super.setWidth(height);
    super.setHeight(height);
  }
}

By overriding setWidth() and setHeight() to make both dimensions equal, we ensure instances of Square are mathematically valid squares.

And TypeScript allows us to pass a Square where it expects Rectangle.

const rect: Rectangle = new Square(10); // Can be either a Rectangle or a Square

But, the tricky part is that clients might make incorrect assumptions about Rectangle behavior resulting in unexpected results.

Here's a client that functions appropriately with Rectangle but breaks if it receives a Square.

clientMethod(rect: Rectangle) {
    rect.setWidth(5);
    rect.setHeight(10);
    assert(rect.area == 50); // ❌ 'Assertion failed' for Square (area==100)
}

Square behaves differently than Rectangle and cannot be substituted for it.

Get notified on new posts

Straight from me, no spam, no bullshit. Frequent, helpful, email-only content.

Get notified on new posts
X

Straight from me, no spam, no bullshit. Frequent, helpful, email-only content.