SOLID Principles using Typescript
Typescript is like the object-oriented version of Javascript. This article discusses the best practices of object-oriented software design using Typescript.
Over the years, we’ve observed the same set of problems occur over and over again in software design. Solutions have emerged too — reusable solutions known as Design Patterns. But before we get into that, we ought to revisit a few basic principles of software development. Let’s start with SOLID.
SOLID is an acronym for 5 principles.
- Single Responsibility Principle
- Open/Closed Principle
- The Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
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>
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
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:
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 of posts.
- It is difficult to re-use — 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 it isn’t really a responsibility forfetchPosts
.
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.
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 the 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 the sendEmail
on EmailService
.
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.
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 base NotificationService
class.
In the above solution, rather than modifying the NotificationService
class, we create a separate OrderNotificationService
class. This extends the generic NotificationService
and instantiates the SMSService
class.
There are a number of pros for this approach:
1. Previous code remains untouched
2. No breaking test cases
3. 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 modules modification might have unforeseen consequences in various submodule implementations.
Liskov Substitution Principle
Description
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 Liskov 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.
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
Now, the ConnectionError
class implements the CustomError
class using a constructor and two abstract methods — createErrorMessage
and logError
.
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.
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:
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 “has/a” class type instead of “is/a” class type. Let’s take a look at an example to understand this better:
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.
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 the 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.
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 which is implemented by CreditCardPaymentProvider
and WalletPaymentProvider
.
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.
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:
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.
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:
1. PaymentProvider
, and
2. PaymentValidator
Similarly, the WalletPaymentProvider
is also fixed with the class now implementing:
1. PaymentProvider
interface, and
2. PaymentVerifier
interface
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 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.
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
function which takes in an error as a parameter. The developer is using Graylog for saving logs, and so we instantiate the Graylog class, saving logs using its saveLog
method.
Where this Fails
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.
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.
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:
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.
Instead of changing multiple implementation details, we have our separate LoggerServices which can be injected into the errorDecorator
function.
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 most critical of all the SOLID principles. This is because it’s not an obvious choice at first to abstract out the Service layers which 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 check out 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 deal with various examples faced in real software design instead of just using Animal
or Rectangle
classes. Upcoming parts will deal with more advanced design patterns such as creational and structural patterns.
Update:
The second article in this series of Blog Posts for understanding Design Patterns using Typescript is live. It is Creational Design Patterns using Typescript.