SOLID Design Principles in TypeScript
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.
Michael Feathers introduced the mnemonic acronym SOLID later, around 2004, 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
A class should be focused on a single purpose and should have one, and only one, reason to change.
SRP 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
Entities should be open for extension, but closed for modification.
Of all the principles of object-oriented design, OCP is one of 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.
As mentioned above, the key to resolving this is 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
Subclasses should be substitutable for their base class.
The LSP 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. Changing the height and width of a square behaves differently from changing the height and width of a rectangle.
It doesn't seem to make sense to differentiate between a square's height and width. A Rectangle
is not an appropriate abstraction for Square
.
We can reach an LSP-compliant solution by removing the parent/child relationship between Rectangle
and Square
and introducing a new Shape
interface to bundle up the shared methods and properties.
This approach promotes composition over inheritance. Eliminating or reducing inheritance within our designs is the most effective way to avoid LSP violations.
interface Shape {
area: number;
}
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
public setWidth(width: number) {
this.width = width;
}
public setHeight(height: number) {
this.height = height;
}
public get area(): number {
return this.width * this.height;
}
}
class Square implements Shape {
constructor(private size: number) {}
public setSize(size: number) {
this.size = size;
}
public get area(): number {
return this.size ** 2;
}
}
Now, clients of Shape
don't have to make incorrect assumptions about the behavior of setter methods. If a client needs to change the property of a shape, it has to work with a concrete reference to the class.
Interface Segregation Principle
Clients should not be forced to depend upon interfaces that they don't use.
In other words, declaring methods on an interface that some clients don't require pollutes the interface and leads to a bloated interface.
ISP concerns interfaces that represent a set of abstracted methods and properties that an implementing class must follow. We define the contract for the behavior and data of an interface but donβt implement it.
Let's see an example of why the Interface Segregation Principle is important.
Here we have a very simple BeverageOrderInterface
for a restaurant to process customer drink orders.
interface BeverageOrderInterface {
orderCoffee(size: string, instructions: string): void;
orderTea(size: string, instructions: string): void;
orderWater(size: string): void;
orderSoda(size: string, flavour: string): void;
}
It seems logical to put all the drink-related methods within the same interface.
But this often leads to ratty design smells like throwing exceptions in classes forced to provide implementations for methods they'll never use.
class HotBeverageOrderService implements BeverageOrderInterface {
orderCoffee(size: string, instructions: string): void {
console.log("Coffee ordered!");
}
orderTea(size: string, instructions: string): void {
console.log("Tea ordered!");
}
orderWater(size: string): void {
throw new Error("Not implemented for hot beverages!"); // β Forced to throw an error here
}
orderSoda(size: string, flavour: string): void {
throw new Error("Not implemented for hot beverages!"); // β Forced to throw an error here
}
}
Another drawback of this design is that if changes to the interface impact the orderWater()
or orderSoda()
APIs, those changes will ripple into this class, making us refactor methods we don't even use. Violating the ISP often triggers violations of other principles like Single Responsibility Principle. π’π©
To summarise, violating ISP causes confusion and added work for developers.
The most direct approach to refactoring this example into an ISP-compliant one is to break the bloated BeverageOrderInterface
into separate, more focused interfaces, also referred to as role interfaces.
interface HotBeverageOrderInterface {
orderCoffee(size: string, instructions: string): void;
orderTea(size: string, instructions: string): void;
}
interface ColdBeverageOrderInterface {
orderWater(size: string): void;
orderSoda(size: string, flavour: string): void;
}
In scenarios where we're dealing with interfaces or abstractions as external dependencies that we can't modify, we can make use of the adapter pattern to adapt BeverageOrderInterface
into one of the target interfaces our clients expect.
With this technique, we abstract away the unnecessary methods we don't need to expose as part of the HotBeverageOrderInterface
.
class HotBeverageOrderServiceAdapter implements HotBeverageOrderInterface {
private readonly adaptee: BeverageOrderInterface;
constructor(adaptee: BeverageOrderInterface) {
this.adaptee = adaptee;
}
orderCoffee(size: string, instructions: string): void {
this.adaptee.orderCoffee(size, instructions);
}
orderTea(size: string, instructions: string): void {
this.adaptee.orderTea(size, instructions);
}
}
Dependency Inversion Principle
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.
DIP helps us reduce or eliminate tight coupling between modules in our code through the use of abstractions.
At its core, the principle advocates for two things.
The first is that essential policies and business logic should not depend on low-level, volatile details such as a database connection or file system.
Second, these lower-level concerns and components should be loosely coupled and reusable through meaningful abstractions.
Inverting Dependencies
Applying DIP begins with introducing an abstraction between our high-level policy and the low-level details required to fulfill the procedure.
Using abstractions removes any concrete dependency on the details and allows for easier reuse and testing of the critical logic in the policy.
The abstraction also allows us to swap out the underlying implementation of low-level details without requiring changes to the high-level policy.
The use of abstractions in this fashion as a type of proxy separating high-level and low-level concerns is the essence of dependency inversion.
Dependency Inversion != Dependency Injection
It's easy to confuse dependency inversion with dependency injection (DI) as they look and sound very similar on the surface.
But these are two separate concepts.
Dependency injection is a technique for supplying dependencies to an object. It's focused on separating the concerns of constructing and using objects in our code. It's not concerned if something is a high-level policy or low-level detail or if an abstraction is required.
Dependency injection is a form of inversion of control (IOC). IOC and DI strategies can help us implement DIP, but these techniques alone do not necessarily mean we're applying DIP.
No IOC/DI framework or tooling can help us determine what is high-level and what is low-level and certainly not identify the proper abstraction to separate the two.
Let's look at an example to understand what we mean by high-level policies and low-level details and the problems we face if we fail to keep them decoupled.
Let's assume the TerminateEmployeeHandler
class is part of some employee domain within a human resources system.
This violates the DIP as we've mixed low-level persistence layer tasks like opening a database connection and fetching and updating data with more important, high-level rules that ensure the employee has the correct status before executing the termination operation.
The domain class has a concrete dependency on the knex SQL library and connection details making it impossible to isolate in a unit test.
class TerminateEmployeeHandler {
private readonly db: Knex;
constructor() {
// β Low-level database infra details embedded in high-level domain logic
this.db = knex({
client: "pg",
connection: {
host: "10.42.103.14",
database: "emp",
user: "root",
password: "l0v2code#!",
},
});
}
async execute(id: number): Promise<Result<string>> {
// π½ Low-level data access code
const employee = await this.db
.select("*")
.from<Employee>("employees")
.where("id", id)
.first();
// πΌ High-level business rule
if (employee?.onMedicalLeave) {
return Result.fail(
`Employee ${id} is on medical leave and cannot be terminated at this time!`
);
}
// πΌ High-level business rule
if (employee?.isRetired) {
return Result.fail(`Employee ${id} is retired and cannot be terminated!`);
}
// π½ Low-level data access code
const count = await this.db("employees").where({ id }).update(employee);
if (count) {
return Result.success(`Employee ${id} terminated successfully!`);
}
return Result.fail(`Employee ${id} could not be terminated!`);
}
}
We can refactor toward a DIP-compliant design by abstracting away the low-level database operations via the EmployeeRepository
interface. The interface is clean and free of any technical details or framework references. π§Ή
interface EmployeeRepository {
get(id: number): Promise<Employee>;
update(employee: Employee): Promise<boolean>;
}
We can then inject the abstraction into our high-level domain code and refactor it to use the interface, thus decoupling it from the low-level database access code and knex
package reference.
class TerminateEmployeeHandler {
private readonly employeeRepository: EmployeeRepository;
constructor(employeeRepository: EmployeeRepository) {
// Database details are hidden behind the repository abstraction β
this.employeeRepository = employeeRepository;
}
async execute(id: number): Promise<Result<string>> {
// No low-level data access, entity is fetched using dependency-free abstraction β
const employee = await this.employeeRepository.get(id);
// πΌ High-level business rule
if (employee?.onMedicalLeave) {
return Result.fail(
`Employee ${id} is on medical leave and cannot be terminated at this time!`
);
}
// πΌ High-level business rule
if (employee?.isRetired) {
return Result.fail(`Employee ${id} is retired and cannot be terminated!`);
}
employee.terminate();
// Entity is updated through abstraction β
this.employeeRepository.update(employee);
return Result.success(`Employee ${id} terminated successfully!`);
}
}
DIP allows us to cleanly decouple and shield important aspects of our code from low-lever, highly volatile details that make maintenance and testing of the important bits harder and more time-consuming.
Infrastructure-related code is a common culprit, so it is important to consider its proximity to high-level code and the risk of disrupting it over the application's life.
Wrapping Up
Although almost ~22 years old, SOLID is as relevant today as ever.
SOLID is largely about eliminating poorly managed dependencies while keeping things cohesive and loosely coupled. Checking these boxes in your design can go a long way in producing quality code that can be changed or extended quickly and safely.
Happy Programming!
Get notified on new posts
Straight from me, no spam, no bullshit. Frequent, helpful, email-only content.
Get notified on new posts
Straight from me, no spam, no bullshit. Frequent, helpful, email-only content.