SOLID Principles using Typescript
If you are a software engineer, probably you already listened about ‘SOLID’ and its principles, “SOLID” are one of the most important principles when you are programming on an object-oriented project.
In this article, I’ll explain you SOLID OCP principle easily.
Let’s look at each principle one by one. Following the SOLID acronym, they are:
- The Single Responsibility Principle
- The Open-Closed Principle
- The Liskov Substitution Principle
- The Interface Segregation Principle
- The Dependency Inversion Principle
S: Single-responsibility principle
Description
This principle states that every method/class should handle a single responsibility. This is important because it results in better readability of code and separation of concerns.
The Challenge
Suppose in a particular API, we wish to fetch posts, clean up some data, and then send back a response. Here’s some fairly easy-to-use code that should serve our purposes:
import fetch from "node-fetch";
const getPosts = async (userId: number) => {
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}/posts`
);
const posts = await response.json();
// Do some cleanup; remove UserID from post since it's not really needed
const cleanedPosts = posts.map((post) => {
delete post["userId"];
return post;
});
return cleanedPosts;
} catch (e) {
// Log error in some kind of Error Logging Service, here we just do console log
console.log(e);
// Send a meaningful but non-technical error message back to the end-user
throw Error("Error while fetching Posts!");
}
};
const main = async () => {
const result = await getPosts(1);
console.log(result);
};
main();
Where this fails
This approach works but has a few issues that become pretty substantial when working with larger codebases.
- The function handles too many things — fetching data, error handling, and even cleaning up posts.
- It is difficult to re-use — again the tight coupling is an issue.
Solution
The above code can be made cleaner and simpler by enforcing the Single Responsibility Principle. This can be done in two steps:
- Taking the error handling code out of the main function — the error handling part can be generic and common to every other function.
- Extracting the
cleanupPosts
to a new function since isn’t really a responsibility forfetchPosts
.
import fetch from "node-fetch";
const fetchPosts = async (userId: number) => {
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}/posts`
);
return await response.json();
} catch (e) {
handleError(e, "Error while fetching Posts!");
}
};
const handleError = (e, message) => {
// Log error in some kind of Error Logging Service, here we just do console log
console.log(e);
// Send a generic Error message back to the user
throw Error(message);
};
const cleanupPosts = (posts) => {
// Do some cleanup; remove UserID from post since it's not really needed
return posts.map((post) => {
delete post["userId"];
return post;
});
};
const main = async () => {
const posts = await fetchPosts(1);
console.log(cleanupPosts(posts));
};
Summary
The Single Responsibility Principle is the easiest to understand, digest and follow of all the SOLID principles. In case you need a trigger to keep up with it, just keep in mind that a class/module should have only 1 reason to change.
O: Open-closed principle
Description
The core meaning of the Open/Closed principle is made clear by the statement: *open to extension, closed for modification. *The idea is that a class, once implemented, should be closed for any further modification. If any more functionality is needed, it can be added later using extension features such as inheritance. This is primarily done so as to not break existing code as well as unit tests. It also results in a modular code.
The Challenge
Suppose there is a NotificationService
that helps us send out an email to the end user. The gist is self-explanatory. There are 2 classes — EmailService
and NotificationService
.NotificationService
calls thesendEmail
onEmailService
.
class EmailService {
public sendEmail(email: string, message: string): void {
console.log(`Email Sent: ${message} to ${email}`);
}
}
class NotificationService {
private _emailService: EmailService;
constructor() {
this._emailService = new EmailService();
}
public sendNotification(email: string, message: string) {
this._emailService.sendEmail(email, message);
}
}
const main = () => {
const notificationService = new NotificationService();
notificationService.sendNotification(
"hello@example.com",
"Generic Notification"
);
};
main();
Now, extending this example — let’s add a requirement to create a notification when an order is completed, sending both an Email and an SMS to the end user. One way to solve this would be to create a new SMSService
which is also initialized in the NotificationService
class.
class EmailService {
public sendEmail(email: string, message: string): void {
console.log(`Email Sent: ${message}`);
}
}
class SMSService {
public sendSms(phone: number, message: string): void {
console.log(`Message ${message} sent to ${phone}`);
}
}
class NotificationService {
private _emailService: EmailService;
private _smsService: SMSService;
constructor() {
this._emailService = new EmailService();
this._smsService = new SMSService();
}
public sendNotification(
email: string,
message: string,
phone: number,
smsMessage: string
) {
this._emailService.sendEmail(email, message);
if (phone && smsMessage) {
this._smsService.sendSms(phone, smsMessage);
}
}
}
const main = () => {
const orderNotificationService = new NotificationService();
orderNotificationService.sendNotification(
"hello@example.com",
"Generic Notification",
9876543210,
"SMS Notification"
);
};
main();
Where this Fails
The above solution works well, looks clean, and produces the desired functional outcome. But the tests fail, and all instances of these services will need to be modified in the code. Additionally, what if the code is closed to modification already — for instance, what if the base classes are part of a library? This is where sticking to the Open/Closed principle aids us.
Solution
Let’s try to fix the above and add the SMS Service without modifying the baseNotificationService
class.
class EmailService {
public sendEmail(email: string, message: string): void {
console.log(`Email Sent: ${message}`);
}
}
class SMSService {
public sendSms(phone: number, message: string): void {
console.log(`Message ${message} sent to ${phone}`);
}
}
class NotificationService {
private _emailService: EmailService;
constructor() {
this._emailService = new EmailService();
}
public sendNotification(email: string, message: string) {
this._emailService.sendEmail(email, message);
}
}
class OrderNotificationService extends NotificationService {
private _smsService: SMSService;
constructor() {
super();
this._smsService = new SMSService();
}
public sendOrderNotification(
email: string,
emailMessage: string,
phone?: number,
smsMessage?: string
) {
if (email && emailMessage) {
this.sendNotification(email, emailMessage);
}
if (phone && smsMessage) {
this._smsService.sendSms(phone, smsMessage);
}
}
}
const main = () => {
const orderNotificationService = new OrderNotificationService();
orderNotificationService.sendOrderNotification(
"hello@example.com",
"Order accepted",
9876543210,
"Order Accepted"
);
};
main();
In the above solution, rather than modifying the NotificationService
class, we instead create a separate OrderNotificationService
class. This extends the generic NotificationService
and instantiates theSMSService
class. There are a number of pros to this approach:
- The previous code remains untouched
- No breaking test cases
- No referential changes to other parts of the code
Summary
Two key ideas for summarising the Open/Closed principle are as follows:
- A module will be considered open if it’s available for extension.
- A module will be considered closed if it’s available for use by other submodules.
This principle is most crucial for enterprise/large codebases. The impact is large because modifying a module might have unforeseen consequences in various submodule implementations.
L: Liskov Substitution Principle
Description
Imagine you have a class S which has subtypes S1, S2, and S3. In object-oriented terms, assume a class Animal that is extended by subclasses like Dog, Cat, etc. The Liksov Substitution Principle states that any object of type S (Animal in our case) can be substituted with any of its subclasses (S1, S2, S3). Since this type of substitution was first introduced by Barbara Liskov, it’s known as the Liskov Substitution Principle.
Now if our Animal class has a walk method, it should work fine on instances of Dog and Cat.
The Challenge
Suppose we’re building an Error handler for a particular web application and the requirements are to perform different types of actions based on the type of error. In this scenario, let’s just take 2 types of errors:
- Database Error
- Connection Error
Both of the above error classes extend an abstract class called CustomError
abstract class CustomError {
error: Error;
errorMessage: string;
constructor(error: Error) {
this.error = error;
}
abstract createErrorMessage(): void;
abstract logError(): void;
}
Now, the ConnectionError
class implements the CustomError
class using a constructor and two abstract methods —createErrorMessage
and logError
.
class ConnectionError extends CustomError {
constructor(error: Error) {
super(error);
}
createErrorMessage(): void {
this.errorMessage = `Connection error: ${this.error.message}`;
}
logError(): void {
console.log(this.errorMessage);
}
}
But the DatabaseError class is also implemented similarly, except for one requirement change wherein the database error being critical in nature also needs a createAlert method.
class DBError extends CustomError {
constructor(error: Error) {
super(error);
}
createErrorMessage(): void {
this.errorMessage = `DB error: ${this.error.message}`;
}
logError(): void {
console.log(this.errorMessage);
}
createAlert(): void {
console.log("Alert Sent");
}
}
Where This Fails
The above example clearly violates the Liskov Substitution principle. Using a subclass of DBError can be an issue when you try to use it in a common error handler function:
abstract class CustomError {
error: Error;
errorMessage: string;
constructor(error: Error) {
this.error = error;
}
abstract createErrorMessage(): void;
abstract logError(): void;
}
class ConnectionError extends CustomError {
constructor(error: Error) {
super(error);
}
createErrorMessage(): void {
this.errorMessage = `Connection error: ${this.error.message}`;
}
logError(): void {
console.log(this.errorMessage);
}
}
class DBError extends CustomError {
constructor(error: Error) {
super(error);
}
createErrorMessage(): void {
this.errorMessage = `DB error: ${this.error.message}`;
}
logError(): void {
console.log(this.errorMessage);
}
createAlert(): void {
console.log("Alert Sent");
}
}
const errorDecorator = (customError: CustomError) => {
customError.createErrorMessage();
customError.logError();
if (customError instanceof DBError) {
customError.createAlert();
}
};
const main = () => {
const dbError = new DBError(new Error("DB err1"));
errorDecorator(dbError);
};
main();
In the above example, line 41 is a **code-smell — **because it requires knowing the instance type beforehand. Extend this case to future errors of APIError
, GraphError
and so on, and it results in a series of never-ending if/else cases. The problem arises because of the overgeneralization of use cases.
Solution
Predicting the future of these types of classes is where the problem exists. It is better to be defensive in such assumptions and go for a “has/a” **class type instead of an “is/a” **class type. Let’s take a look at an example to understand this better:
abstract class CustomError {
error: Error;
errorMessage: string;
constructor(error: Error) {
this.error = error;
}
abstract createErrorMessage(): void;
abstract logError(): void;
}
class ConnectionError extends CustomError {
constructor(error: Error) {
super(error);
}
createErrorMessage(): void {
this.errorMessage = `Connection error: ${this.error.message}`;
}
logError(): void {
console.log(this.errorMessage);
}
}
class AlertSystem {
public sendAlert(message: string) {
console.log("Alert sent");
}
}
class DBError extends CustomError {
constructor(error: Error) {
super(error);
}
createErrorMessage(): void {
this.errorMessage = `DB error: ${this.error.message}`;
}
logError(): void {
console.log(this.errorMessage);
const alert = new AlertSystem();
alert.sendAlert(this.errorMessage);
}
}
const errorDecorator = (customError: CustomError) => {
customError.createErrorMessage();
customError.logError();
};
const main = () => {
const dbError = new DBError(new Error("DB err1"));
errorDecorator(dbError);
};
main();
Considering our example of error handlers again: One approach can be to compose our logging method with an alerting mechanism. The AlertSystem
is now used in composition and added to DBError’s logError
instead. Another viable approach would have been to completely decouple the AlertSystem
from both errors. When compared to our previous examples we do not have any more if/else conditions on the type of class instance.
Summary
In my opinion, the Liskov Substitution principle should be treated as a guideline and not as a strict rule because, in practice, this principle is the hardest to keep an eye on during development. This could be for a number of reasons- implementations might be in different codebases, use of an external library in the codebase, etc.
The key focus should be on 2 ideas-
- Do not work on generalizations prematurely for any domain.
- Try to maintain the superclass contract in the subclass.
I: Interface segregation principle
Description
The Interface Segregation Principle — or ISP for short — states that instead of a generalized interface for a class, it is better to use separate segregated interfaces with smaller functionalities. This is similar to ideas we’ve discussed so far around maintaining loose coupling, but for interfaces.
### The Challenge
Consider our previous example of PaymentProvider
. This time, imagine that the PaymentProvider
is an interface that is implemented by CreditCardPaymentProvider
and WalletPaymentProvider
.
interface PaymentProvider {
validate: () => boolean;
getPaymentCommission: () => number;
processPayment: () => string;
verifyPayment: () => boolean;
}
Let’s implement the interface PaymentProvider
for our CreditCartPaymentProvider
class. The credit card provider does not provide an API to verify payment individually, but since we’re implementing PaymentProvider
, we are required to implement the verifyPayment
method, otherwise, the class implementation will throw an error.
class CreditCardPaymentProvider implements PaymentProvider {
validate() {
// Payment is validated
console.log("Payment Card Validated");
return true;
}
getPaymentCommission() {
// Commission is returned
return 10;
}
processPayment() {
// Payment processed
console.log("Payment Processed");
return "Payment Fingerprint";
}
verifyPayment() {
// No verify Payment API exist
// Return false to just implement the Payment Provider
return false;
}
}
Now suppose the wallet providers do not have a validate
API, to implement PaymentProvider
for WalletPaymentProvider
. In this case, we must create a validate method — which does nothing as can be seen below:
class WalletPaymentProvider implements PaymentProvider {
validate() {
// No validate method exists
// Just for sake of implementation return false
return false;
}
getPaymentCommission() {
// Commission is returned
return 5;
}
processPayment() {
// Payment processed
console.log("Payment Processed");
return "Payment Fingerprint";
}
verifyPayment() {
// Payment verification does exist on Wallet Payment Provider
console.log("Payment Verified");
return false;
}
}
Where This Fails
The above implementation works fine but seeing the fake implementations, we know this is a **code smell **that would quickly become an issue with a number of such fake implementations popping up throughout the code.
Solution
The above scenario can be fixed using the interface segregation principle. Firstly, we need to take a look at our interface rather than its implementation and see if it can be refactored to decouple various constituents of the PaymentProvider
interface.
interface PaymentProvider {
getPaymentCommission: () => number;
processPayment: () => string;
}
interface PaymentValidator {
validate: () => boolean;
}
interface PaymentVerifier {
verifyPayment: () => boolean;
}
We now have three interfaces instead of one and each implementation can be decoupled further. Since the CreditCardPaymentProvider
does not have any verifyPayment
method, we can simply implement:
- PaymentProvider, and
- PaymentValidator
class CreditCardPaymentProvider implements PaymentProvider, PaymentValidator {
validate() {
// Payment is validated
console.log("Payment Card Validated");
return true;
}
getPaymentCommission() {
// Commission is returned
return 10;
}
processPayment() {
// Payment processed
console.log("Payment Processed");
return "Payment Fingerprint";
}
}
Similarly, the WalletPaymentProvider
is also fixed with the class now implementing:
- PaymentProvider interface, and
- PaymentValidator interface
class WalletPaymentProvider implements PaymentProvider, PaymentVerifier {
getPaymentCommission() {
// Commission is returned
return 5;
}
processPayment() {
// Payment processed
console.log("Payment Processed");
return "Payment Fingerprint";
}
verifyPayment() {
// Payment verification
console.log("Payment Verified");
return false;
}
}
Finally, the cohesion issues and fake implementations are gone, and we’ve achieved the desired result using interface segregation.
Summary
Interface Segregation is one of my favorite design principles. In simple words, it proposes to split large interfaces into smaller ones with a specific purpose. This provides loose coupling, better management of code, and easier usability of code.
A key idea to grasp is composition over inheritance. This might not be well supported by legacy designs but is substantially important for modern software architecture.
D: Dependency inversion principle
Description
The Dependency Inversion Principle states that high-level modules should not depend on low-level modules, but rather on abstractions. Secondly, abstraction should not depend on details. When you think about it, this sounds like common sense. Practically, though, we might miss these details when we work on our software architecture.
The Challenge
We will again take into consideration our Logger example for this scenario. The Dependency Inversion Principle isn’t as obvious during implementation as the other principles.
In this example, consider an errorDecorator
The above scenario works fine as long as you don't need to switch to a different logger in the near future. But let's say you do — for better compatibility, pricing, etc. The immediate solution then would be to simply use a RedisLog
class instead of GrayLog
. But the RedisLog
implementation is probably different from that of GrayLog
- perhaps it uses the sendLog
function instead of saveLog
and accepts a string parameter instead of an object param.
Then we change its implementation to input as a string.
class RedisLog {
sendLog(logMessage: string) {
console.log(`Log Sent to Redis for logMessage`);
}
}
const errorDecorator = (error: Error) => {
const log = new RedisLog();
log.sendLog(JSON.stringify(error));
};
const main = () => {
errorDecorator(new Error("Error Message"));
};
main();
Now, the above case is a simple one with 2 minor changes — the method name and its parameters. But practically, there might be a number of changes with functions added/removed and parameters modified. This isn’t an ideal approach, since this would affect a number of code changes at the implementation level.
Solution
Going a little deeper, we see that the issue arises because our errorDecorator
function (which can be a class too) depends on the low-level implementation details of Loggers
available. We now know that the Dependency Inversion principle recommends relying on high-level abstractions instead of low-level implementation details.
So, let’s create an abstract module instead which should be the dependency of our errorDecorator
function:
abstract class LoggerService {
createLog: (logObject: object) => void;
}
That’s it — the LoggerService
takes a log object in its createLog function, and this can be implemented by any external logger API. For GrayLog
we can use GrayLoggerService
, for RedisLog
create a RedisLoggerService
implementation and so on.
class GrayLoggerService implements LoggerService {
createLog(logObject: object) {
const grayLog = new GrayLog();
grayLog.saveLog(logObject);
}
}
class RedisLoggerService implements LoggerService {
createLog(logObject: object) {
const logMessage = JSON.stringify(logObject);
const redisLog = new RedisLog();
redisLog.sendLog(logMessage);
}
}
Instead of changing multiple implementation details, we have our separate LoggerServices
which can be injected into the errorDecorator function.
const errorDecorator = (error: Error, loggerService: LoggerService) => {
loggerService.createLog(error);
};
const main = () => {
errorDecorator(new Error("Error Message"), new RedisLoggerService());
};
main();
In the above solution, you can see that the errorDecorator is not dependent on any low-level implementation modules such as GrayLog
or RedisLog
but is completely decoupled from the implementation. Additionally, by adhering to this we implicitly follow the Open/Closed principle since it is open to extension and closed to modification.
Summary
The Dependency Inversion principle is probably the most critical of all the SOLID principles. This is because it’s not an obvious choice at first to abstract out the Service layers that are needed for low-level implementations. The idea, usually, is to look at low-level implementations first and then work backward to generalization, instead of the other way around.
Do check Dependency Injection, Adapter Pattern, Service Locator Pattern, etc.- these are implementations of the Dependency Inversion Principle itself.
Conclusion
In this part, we went through practical scenarios of using all the design principles of SOLID using typescript language. The examples were simplified for learning purposes but dealt with various challenges faced in real software design. The upcoming parts will deal with more advanced design patterns such as creational and structural patterns.