In this article

What is SOLID?

SOLID is an acronym used to describe a set of design principles that relate to object-oriented software design:

  • Single Responsibility Principle
  • Open/Closed Principle
  • Liskov Substitution Principle
  • Interface Segregation Principle
  • Dependency Inversion Principle

These five principles guide us toward effective application architecture that enables future feature development and maintenance. Let's look at some code examples that illustrate how applying the principles leads to more effective software architecture.

Single Responsibility Principle

The Single Responsibility Principle (SRP) states that every module within an application must have responsibility for a single feature of that application. The principle is often stated as having one and only one "reason to change." To express this another way: there should be only one consumer of the functionality of a module that would need to specify changes to the functionality.

Consider the following example of a class defining an online shopping experience:

export default class Shopper {
    email: string;
    firstName: string;
    lastName: string;
    billingInfo: Address;
    shippingInfo: Address;
    cartItems: Array<CartItem> = new Array<CartItem>();

    constructor(email: string, firstName: string, lastName: string) {
        this.email = email;
        this.firstName = firstName;
        this.lastName = lastName;
    }

    updateShippingInfo(shippingInfo: Address) {
        this.shippingInfo = shippingInfo;
    }

    updateBillingInfo(billingInfo: Address) {
        this.billingInfo = billingInfo;
    }

    addItem(item: CartItem) {
        this.cartItems.push(item);
    }

    removeItem(item: CartItem) {
        const itemIndex = this.cartItems.findIndex((testItem) => testItem.id === item.id);
        if(itemIndex > -1) {
            this.cartItems.splice(itemIndex, 1);
        }        
    }

    calculateBillingTotal() {
        return this.cartItems.reduce((totalPrice, item) => {
            return totalPrice + item.price;
        }, 0.0)
    }
}

This class is responsible for multiple things: holding information about the shopper, keeping track of the items within the shopper's cart and providing a total billing amount based on the cart items.

If a change needed to be made to the shopper's information it would have to be made in this class. If a change to the billing model needed to be made (perhaps calculating sales tax for the items in the cart), this class would have to change.

Applying the Single-Responsibility Principle

This module can be broken into three different parts that each govern one aspect of the online shopping experience: the Shopper keeps track of the user's personal information.

export default class Shopper {
    email: string;
    firstName: string;
    lastName: string;
    billingInfo: Address;
    shippingInfo: Address;

    constructor(email: string, firstName: string, lastName: string) {
        this.email = email;
        this.firstName = firstName;
        this.lastName = lastName;
    }

    updateShippingInfo(shippingInfo: Address) {
        this.shippingInfo = shippingInfo;
    }

    updateBillingInfo(billingInfo: Address) {
        this.billingInfo = billingInfo;
    }
}

The Cart keeps track of the user's cart items.

export default class Cart {
    cartItems: Array<CartItem> = new Array<CartItem>();

    constructor() {}

    addItem(item: CartItem) {
        this.cartItems.push(item);
    }

    removeItem(item: CartItem) {
        const itemIndex = this.cartItems.findIndex((testItem) => testItem.id === item.id);
        if(itemIndex > -1) {
            this.cartItems.splice(itemIndex, 1);
        }
    }
}

And the BillingCalculator is responsible for calculating a total price based off of items that are passed to it.

export default class BillingCalculator {
    constructor() {}

    calculateBillingTotal(items: Array<CartItem>) {
        return items.reduce((totalPrice, item) => {
            return totalPrice + item.price;
        }, 0.0)
    }
}

Each component has a single-responsibility and a single reason to change if a change is needed.

Open/Closed Principle

The Open/Closed Principle (OCP) states that a module should allow consumers to extend the capabilities of the module, but not change the fundamental behavior of the module (thus affecting other consumers). Adhering to the Open/Closed Principle usually involves inheriting from an abstract base class that applies to a more generic implementation.

In the following example, we have a need to describe two different modes of transport, a Bike and a Car.

export default class Bike {
    person: any;
    bell: any;
    
    constructor(person: any, bell: any) {
        this.person = person;
        this.bell = bell;
    }

    ride() {
        this.person.pedal();
    }

    ringBell() {
        this.bell.ring();
    }
}
export default class Car {
    engine: any;
    horn: any;
    gear: string = 'Park';

    constructor(engine: any, horn: any) {
        this.engine = engine;
        this.horn = horn;
    }

    drive() {
        this.engine.start();
        this.gear = 'Drive';
    }

    soundHorn() {
        this.horn.honk();
    }
}

In this case, both modes of transport have similar features but due to the specificity of the objects, they require their own individual implementations.

Applying the Open/Closed Principle

In the following example, Bike and Car are implemented to conform to an abstract Vehicle interface. This allows Vehicle to be extended rather than modified in order to describe any number of modes of transport.

export default interface Vehicle {
    powerSource: any;
    alertSource: any;
    
    drive(): void

    alert(): void
}
export default class Bike implements Vehicle {
    powerSource: any;
    alertSource: any;

    constructor(person: any, bell: any) {
        this.powerSource = person;
        this.alertSource = bell;
    }
    
    drive() {
        this.powerSource.pedal();
    }

    alert() {
        this.alertSource.ring();
    }
}
export default class Car implements Vehicle {
    powerSource: any;
    alertSource: any;
    gear: string = 'Park';

    constructor(engine: any, horn: any) {
        this.powerSource = engine;
        this.alertSource = horn;
    }
    
    drive() {
        this.powerSource.start();
        this.gear = 'Drive';
    }

    alert() {
        this.alertSource.honk();
    }
}

Liskov Substitution Principle

The Liskov Substitution Principle (LSP) states that if an object is a subtype of another object, it should be able to function as its supertype. A popular illustration of the LSP is the comparison of squares and rectangles. While it may make sense to make a square a subtype of a rectangle conceptually, from an object inheritance perspective it does not make sense. Consider the following definition for a Rectangle.

export default class Rectangle {
    width: number;
    height: number;
    constructor(width: number, height: number) {
        this.width = width;
        this.height = height;
    }

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

    setHeight(height: number) {
        this.height = height;
    }

    getArea() {
        return this.width * this.height;
    }

    getPerimeter() {
        return 2 * (this.width + this.height);
    }
}

In order for a Square class to inherit from Rectangle it needs to override both the setWidth and setHeight methods.

export default class Square extends Rectangle {
    constructor(sideLength) {
        super(sideLength, sideLength);
    }

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

    setHeight(height: number) {
        this.height = height;
        this.width = height;
    }
}

Now there is no way for the Square class to substitute as a Rectangle, which violates the LSP.

Applying the Liskov Substitution Principle

Rectangle and parallelogram examples

Consider the following examples of a Rectangle and a Parallelogram. Here the Rectangle establishes all of the base class behavior for a 4-sided shape where the sides are parallel to each other.

export default class Rectangle {
    base: number;
    height: number;
    constructor(base: number, height: number) {
        this.base = base;
        this.height = height;
    }

    setBase(base: number) {
        this.base = base;
    }

    setHeight(height: number) {
        this.height = height;
    }

    getArea() {
        return this.base * this.height;
    }

    getPerimeter() {
        return 2 * (this.base + this.height);
    }
}

In order to implement a Parallelogram shape (namely a 4-sided shape where the sides are parallel to each other but the internal angle varies from 90 degrees) only a single method needs to be added.

export default class Parallelogram extends Rectangle {
    interiorAngle: number;

    constructor(base: number, height: number, interiorAngle = 90.0) {
        super(base, height);
        this.setInteriorAngle(interiorAngle);
    }

    setInteriorAngle(angle: number) {
        this.interiorAngle = angle;
    }
}

The Parallelogram shape can substitute for the Rectangle class merely by setting the internal angle to 90 degrees. This satisfies the LSP.

Interface Segregation Principle

The Interface Segregation Principle (ISP) states that no object should inherit an interface that it does not implement. Generally, this means that interfaces should be specific rather than broad.

For example, consider a Person interface that defines some behaviors that you would expect for a person to implement.

export default interface Person {
    legs: Array<any>
    hands: Array<any>
    mouth: any

    eat(food: string): void

    walk(): void

    speak(words: string): void
}

Now we can use this interface to create an Adult class.

export default class Adult implements Person {
    legs: Array<any>;
    hands: Array<any>;
    mouth: any;

    constructor(legs: Array<any>, hands: Array<any>, mouth: any) {
        this.legs = legs;
        this.hands = hands;
        this.mouth = mouth;
    }
    
    eat(food: string) {
        this.mouth.open();
        this.hands[0].move(food, this.mouth);
        this.mouth.receive(food);
        this.mouth.chew();
        this.mouth.swallow();
    }

    walk() {
        this.legs[0].move();
        this.legs[1].move();
    }

    speak(words: string) {
        this.mouth.open();
        this.mouth.send(words);
    }
}

The Adult implements all of the methods specified in the interface. However, what if we want to implement a Baby class? Clearly a Baby does not walk yet, nor do they speak. With the current interface, we would have to throw exceptions for those two methods to make sure they are not used.

export default class Baby implements Person {
    legs: Array<any>;
    hands: Array<any>;
    mouth: any;
    constructor(legs: Array<any>, hands: Array<any>, mouth: any) {
        this.legs = legs;
        this.hands = hands;
        this.mouth = mouth;
    }

    eat(food: any) {
        this.mouth.open();
        this.mouth.receive(food);
        this.mouth.swallow();
    }

    walk() {
        throw new Error("Not Implemented");
    }

    speak(words: string) {
        throw new Error("Not Implemented");
    }
}

This example illustrates the violation of the ISP. Baby does not implement all of the features that the interface specifies.

Applying the Interface Segregation Principle

The ISP guides us to design several simpler interfaces that cover the functionality required.

export default interface CanEat {
    mouth: any
    eat(food:any): void
}
export default interface CanSpeak {
    mouth: any;
    speak(words: string): void
}
export default interface CanWalk { 
    legs: Array<any>   
    walk(): void
}

This allows Adult to implement all three of these interfaces.

export default class Adult implements CanEat, CanSpeak, CanWalk {
    legs: Array<any>;
    mouth: any;
    hands: Array<any>;

    constructor(legs: Array<any>, hands: Array<any>, mouth: any) {
        this.legs = legs;
        this.hands = hands;
        this.mouth = mouth;
    }
    
    eat(food: any) {
        this.mouth.open();
        this.hands[0].move(food, this.mouth);
        this.mouth.receive(food);
        this.mouth.chew();
        this.mouth.swallow();
    }

    walk() {
        this.legs[0].move();
        this.legs[1].move();
    }

    speak(words: string) {
        this.mouth.open();
        this.mouth.send(words);
    }

}

However, for the Baby class we only need one.

export default class Baby implements CanEat {
    mouth: any;

    constructor(mouth: any) {
        this.mouth = mouth;
    }

    eat(food: any) {
        this.mouth.open();
        this.mouth.receive(food);
        this.mouth.swallow();
    }
}

Keeping interfaces small allows these classes to be built up incrementally and more easily extended in the future.

Dependency Inversion Principle

The Dependency Inversion Principle (DIP) states that high-level and low-level modules must not depend directly on each other, but both must depend on an abstraction. This allows the coupling between the modules to be eliminated.

In the following example, the class to fetch and display articles is highly coupled to the fetch dependency.

export default class ArticleList {
    articles: Array<Article> = new Array<Article>();

    constructor() {
        this.fetchArticles();
    }

    fetchArticles() {
        fetch("http://articles.online/articles")
        .then((response) => response.json())
        .then((json) => {
            this.articles = json.articles;
        });
    }

    searchArticles(searchTerm) {
        fetch(`http://articles.online/articles?${searchTerm}`)
        .then((response) => response.json())
        .then((json) => {
            this.articles = json.articles;
        });
    }
}

This violates the DIP, making the module very difficult to test, and difficult to modify.

Applying the Dependency Inversion Principle

In the following example we first define an interface that we expect our network dependency to satisfy.

export default interface ArticleService {
    baseUrl: string;
    search(query: string): Promise<Array<Article>>
}

The ArticleList class then calls for this service interface rather than a specific implementation.

export default class ArticleList {
    articleService: ArticleService;
    articles: Array<Article> = new Array<Article>();

    constructor(service: ArticleService) {
        this.articleService = service;
        this.fetchArticles();
    }

    fetchArticles() {
        this.articleService.search('')
            .then((articles) => {
                this.articles = articles;
            });
    }

    searchArticles(searchTerm: string) {
        this.articleService.search(searchTerm)
            .then((articles) => {
                this.articles = articles;
            });
    }
}

This removes the coupling between the high-level module and the low-level network module. This allows us to easily satisfy the requirement of the interface with any network framework we wish to use. For example fetch can be implemented as follows:

export default class FetchService implements ArticleService {
    baseUrl: string;
    
    constructor(baseUrl: string) {
        this.baseUrl = baseUrl;
    }

    search(query: string) {
        return fetch(`${this.baseUrl}?${query}`)
            .then(response => response.json());
    }
}

Switching to the Axios framework requires only this implementation:

export default class FetchService implements ArticleService {
    baseUrl: string;

    constructor(baseUrl) {
        this.baseUrl = baseUrl;
    }

    search(query: string) {
        return axios.get(`${this.baseUrl}?${query}`)
            .then(response => response.data);
    }
}

In this example we use constructor injection (the dependency is provided through the constructor) to satisfy the service requirement, however, there are numerous ways to provide and mock dependencies that satisfy the DIP.

Conclusion

These five principles make up one of the building blocks of effective object-oriented design. Applying these principles leads to efficient and clear code that is easy to understand and test. The result is software that is less expensive to maintain and is easily extensible when further feature development is required.

Learn more about our development expertise.
Explore