SOLID Principles for Solid Code
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
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.