In software design over many years, commonly occurring problems were identified. Re-usable solutions to these problems are called Design Patterns. Before diving into design patterns, we need to revisit a few basic principles of software development. Let’s start with SOLID.
SOLID is an acronym for 5 principles.
We will discuss each of the above in detail with practical examples using Typescript. To run these examples using the terminal, simply run the following commands —
tsc --target es5 <filename.ts>
node <filename.js>
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.
Let’s jump directly into a practical example. 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();
This approach works but has a few issues that become pretty substantial when working with larger codebases.
The above code can be made cleaner and simpler by enforcing the Single Responsibility Principle. This can be done in two steps:
cleanupPosts
to a new function since isn’t really a responsibility for fetchPosts
.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));
};
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.
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.
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 the sendEmail
on EmailService
.
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();
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.
Let’s try to fix the above and add the SMS Service without modifying the base 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;
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 the SMSService
class.
There are a number of pros for this approach:
Two key ideas for summarising the Open/Closed principle are as follows:
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.
Imagine you have a class S which has subtypes S1, S2, S3. In object-oriented terms, assume a class Animal which 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 both.
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:
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");
}
}
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.
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 the errors.
When compared to our previous examples we do not have any more if/else conditions on the type of class instance.
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 the different codebases, use of an external library in the codebase etc.
The key focus should be on 2 ideas-
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.
Consider our previous example of PaymentProvider
. This time, imagine that the PaymentProvider
is an interface which 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 the 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;
}
}
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.
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:
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:
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.
Interface Segregation is one of my favourite 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 of composition over inheritance. This might not be well supported by legacy designs but is substantially important for modern software architecture.
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.
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 it’s implementation to input as a string at Line 9.
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 — 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.
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.
The Dependency Inversion principle is probably 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 backwards to generalization, instead of the other way round.
Do checkout Dependency Injection, Adapter Pattern, Service Locator Pattern etc.- these are implementations of the Dependency Inversion Principle itself.
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.