SOLID Principles: Explained on Practical Examples
Whether you're an experienced developer of just a junior, you might have heard the term SOLID more often than not. This acronym represents five design principles heavily used in OOP landscape, and their aim is to make the code more maintainable.
To achieve the desired maintainability depends on how effectively you or your team are able to implement these principles in your codebase. Beware that many teams or even experienced devs may often use and mention the word SOLID but the reality of their code is miles away from being actually SOLID.
This article then serves as a practical explanation of these principles and perhaps a reference for you. Each letter in the acronym stands for one principle, so lets go through these one by one.
S: Single Responsibility Principle (SRP)
Perhaps the most known and ever mentioned principle from SOLID is the Single Responsibility Principle (SRP). As Robert C. Martin and Micah Martin simply put in their book, this principle states that:
"A class should have only one reason to change."[1]
To achieve that, class should have just one responsibility, hence the name of this principle.
SRP was first described by Tom DeMarco (in 1979) and Meilir Page-Jones (in 1988) by a single word - cohesion. In a broader definition, cohesion describes responsibilities of a module, whereas the SRP as defined by SOLID principles is concerned about the responsibility of a class.
Common confusion between cohesion and SRP
I've seen developers mixing these definitions and often contemplating about responsibilities on a domain/package level while using the term SRP. While valid concerns, it's good to differentiate what level of responsibilities are you referring to, and perhaps prefer the term cohesion when talking about higher level responsibilities.
Higher you go in terms of responsibility levels of either specific domain or parts of your app, more blurry the line will be.
The SRP however talks about class
, and it's responsibility, so I would recommend focusing on that bit. It's much much easier to get this right compared to responsibilities of domain/package or other parts of your app. Essentially each level up adds a lot more blurriness and at some point there is no right or wrong answer but different point of views.
What problems does the SRP solve?
If done right, implementation of the single responsibility principle makes it easier to adjust your code without affecting places that have nothing to do with your change. For instance, let's consider this class:
class Content {
private readonly paragraphs: string[]
constructor(paragraphs: string[]) {
this.paragraphs = paragraphs
}
asHtml() {
const html = this.paragraphs.map(para => `<p>${para}</p>`).join('<br/>')
return html.replace(/{{BLOG_LINK}}/, '<a href="https://martinmilo.com">https://martinmilo.com</a>')
}
}
On the first glance, you might not see the problem. It's just a simple class with one method, so how could it possibly violate the SRP?
Well, there are two problems, one with the implementation of the method and the other with the intent.
asHtml() {
const html = this.paragraphs.map(para => `<p>${para}</p>`).join('<br/>')
return html.replace(/{{BLOG_LINK}}/, '<a href="https://martinmilo.com">https://martinmilo.com</a>')
}
The method first creates an HTML
string with specific tags and then replaces all occurrences of {{BLOG_LINK}}
within the text with the actual link wrapped in a
tag.
Problem is that the method knows about both variable {{BLOG_LINK}}
and the value used for replacement. Okay, but isn't this exactly the responsibility of a Content
class?
Sure it might but then it already knows about how to format an HTML output, leading to more than just one responsibility.
Should the class
Content
be responsible for formatting the outputor should it rather be responsible for replacing content variables within paragraphs?
Now lets consider we're okay with keeping this class responsible for two small things, because we just want to ship features. There's a requirement to add a plain text
output, so that we can use it as a field in API that the mobile devices interact with. Easy-peasy, we just add another method:
asText() {
return this.paragraphs.join('\n').replace(/{{BLOG_LINK}}/, 'https://martinmilo.com')
}
Notice that we can't re-use variable replacement we already implemented in previous method since this one only inserts plain value without HTML
tag. All good, right? Yes, unless we won't ever touch this code again. But why?
The class now knows how to provide HTML and plain text outputs but also how to replace content variables. You would have to always come back and change the code whenever someone adds or changes a content variable.
Consider that we'd have to add either new format or introduce more content variables. What if we have to validate
HTML output to be sure that our additional variable replacements didn't break it? Would we add private method(s) specific to HTML format?
Practical application of SRP to split the responsibilities of a class
It's easy to keep thinking that there's no need to split responsibilities from a seemingly small class and delaying the refactor. However this might quickly backfire if you're in a team since the other devs might already use (or better word abuse) the class for their own needs.
Back to our example, lets consider the need of adding bunch of other content variables, perhaps new output format, validation of the HTML. Combine this with usage in variety of unrelated places and you might already start to see how quickly the seemingly neat class grows into messy monstrosity. Let's fix this.
First lets create a separate class that would have a single purpose - replacing our predefined variables within a string.
class VariableReplacer {
static replace(text: string): string {
// Add more replacements here…
// Ideally the regex pattern and replace value should be registered outside of this class
return text.replace(/{{BLOG_LINK}}/, 'https://martinmilo.com')
}
}
As stated in the comments, the definition of the variable (regex pattern) and it's value should ideally be somewhere else. But even if we omit that change, we have already centralized the logic of variable replacement into a single class. Anytime someone adds or changes new variable, it is clear which place needs to be affected.
Now lets implement another class that deals with HTML
content.
class HTMLContentFormatter {
private html: string
constructor(paragraphs: string[]) {
this.html = paragraphs
.map((para) => `<p>${para}</p>`)
.join('<br/>');
}
format() {
// Naive implementation of wrapping links in anchor tags…
this.html = this.html.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1">$1</a>')
// Validate the HTML before returning it…
return this.html
}
}
As you might see, the class provides the format()
method and does HTML-specific formatting and validation. Notice that the method inserts a
tags by simply replacing the plain links instead of knowing anything about content variables.
Finally, this is our original class. Notice the change in name as well:
class ContentFormatter {
private readonly paragraphs: string[]
constructor(paragraphs: string[]) {
this.paragraphs = paragraphs.map(VariableReplacer.replace)
}
asHtml() {
return new HTMLContent().format(this.paragraphs)
}
asText() {
return this.paragraphs.join('\n')
}
}
The class became only responsible for formatting the content. Sure, it depends on other classes but there's a big difference now:
You want to add new output format? Add new method to the
ContentFormatter
class and that's itYou want to add or change content variable? Adjust the code in
VariableReplacer
class and done, no need to think of any other placeYou want to insert/adjust some tags or further validate HTML in a specific way? All you have to do is to touch
HTMLContent
class
Adding granular classes might seem like an overkill at first but sooner you split the responsibilities of a class, less headaches you'll get along the way.
"The SRP is one of the simplest of the principle, and one of the hardest to get right. Con-joining responsibilities is something that we do naturally."[1]
Whenever you create a new class, think of it's purpose. If you use very generic name, you're essentially saying that the specific purpose/responsibility is not defined. Other developers that work on the project might then add more methods and expand responsibilities of such class, whether it's intentional or not.
In the end, this is exactly how the famous legacy code is born. You start with a small class that has seemingly small responsibilities, which you don't see as a problem.
Give it some time while other devs makes some changes on it and you find a monstrosity that does many things. You're now afraid to even touch it. A one liner change might become an afternoon full of pain because what seemed to be an easy change resulted in bugs in an unrelated part of your codebase due to high coupling.
SRP tries to prevent high coupling and makes the code arguably more understandable and re-usable.
O: Open-Closed principle (OCP)
The second principle from SOLID is called Open-closed principle, or OCP in short and was introduced by Meyer, Bertrand. It's original definition is following:
"Software entities (classes, functions, etc.) should be open for extension, but closed for modification."[2]
Simply put, you should aim for extending your classes rather than changing existing methods. That way you greatly reduce the risk of breaking existing functionality or potentially introducing bugs.
What problems does the OCP solve?
Sticking to the open-closed principle enables you to simply add new functionality without touching the existing code. The question is, how to actually do that? Lets have a look at the following code:
class FileUpload {
private readonly s3 = new S3(config)
private readonly disk = new Disk(config)
async uploadImage(path: string, file: File) {
const image = ImageService.compress(file)
await this.s3.put(`images/${path}`, image)
}
async uploadDocument(path: string, file: File) {
await this.disk.put(`documents/${path}`, file)
}
}
// We export the instance of a class (singleton)
export default new FileUpload()
Long story short, we have a FileUpload
class with two methods to upload different file types. The reason why we split it is simple. Images need to be compressed and put in S3 bucket, while documents are simply put in some local or external folder for whatever reason.
When we only expect images, we can just call uploadImage
but lets consider a feature where user can upload and preview from the allowed extensions. This is how we handle it at the moment:
class FilePreviewService {
async createFile(file: File) {
const id = uuid()
const path = `${id}/${file.name}`
if (isImage(file)) {
await fileUpload.uploadImage(path, file)
} else if (isDocument(file)) {
await fileUpload.uploadDocument(path, file)
} else {
throw new Error('Unknown file type!')
}
// Save the information in our DB
await fileRepository.create({ id, path })
}
}
The code seems pretty much okay unless you need to handle a new file type upload. Lets implement HTML file upload.
How would we implement new HTML file upload?
Add new method in
FileUpload
to put the HTML file in a diskValidate the HTML syntax and strip specific tags (script, etc.)
Change the
FilePreviewService
(and all other classes which allow new file type) to handle the case when file hashtml
extension
The problem is that the current implementation of FileUpload
does not make the code extendable. It might not seems so - in the end we're just adding new if statement, right? Well, with more complex scenario there might be more logic that might be affected.
Any time you change existing code, you risk forgetting something and introducing bugs. Let's make the code open for extension and prevent modification of existing functionality.
Practical application of OCP to make code open for extension
We will first introduce an interface called IFileUpload
. This interface would define file extensions and a single method called upload.
interface IFileUpload {
fileExtensions: string[];
upload(path: string, file: File);
}
Second step is to break individual methods of our FileUpload
class into separate classes. Each class needs to implement the interface we've just introduced.
class ImageFileUpload implements IFileUpload {
private readonly s3 = new S3(config);
public fileExtensions = ['jpg', 'png'];
async upload(path: string, file: File) {
const image = ImageService.compress(file);
await this.s3.put(`images/${path}`, image);
}
}
export default new ImageFileUpload();
class DocumentFileUpload implements IFileUpload {
private readonly disk = new Disk(config);
public fileExtensions = ['pdf'];
async upload(path: string, file: File) {
await this.disk.put(`documents/${path}`, file);
}
}
export default new DocumentFileUpload();
class HtmlFileUpload implements IFileUpload {
private readonly disk = new Disk(config);
public fileExtensions = ['html'];
async upload(path: string, file: File) {
// Strip specific tags…
// Validate HTML syntax…
await this.disk.put(`html/${path}`, file);
}
}
export default new HtmlFileUpload();
Notice the following:
Each class instantiates the storage it needs - if you'd have just one storage for all, you could as well inject the instance in the constructor
Each class implements a
IFileUpload
interface and thus has to define allowed file extensions and upload methodEach class knows how to deal with specific file types - for instance, image upload might further differentiate between
png
andjpg
images and perform different optimization strategies, and html upload knows how to strip some tags and validate HTML syntax
Okay, that's nice but so far we've just introduced bunch of classes. Do we just introduce another else if
statement to the FilePreviewService
? While you might, there's a better way.
class FileUpload {
constructor(private readonly uploadServices: IFileUpload[]) {}
async upload(path: string, file: File) {
const service = this.uploadServices.find((service) => {
return service.fileExtensions.includes(file.extension);
});
if (!service) {
throw new Error('File type not allowed or unknown!');
}
await service.upload(path, file);
}
}
Our original FileUpload
class went from implementing methods for different file groups to just orchestrating which one to choose from a file extension. Lets see how to tie this all together:
class FilePreviewService {
async createFile(file: File) {
const id = uuid();
const path = `${id}/${file.name}`;
await fileUpload.upload(path, file);
await fileRepository.create({ id, path });
}
}
As you can see, the FilePreviewService
just calls the upload method of an instance of a FileUpload
class which got registered with desired file upload services that deal with specific file types.
// Need a file upload for all types?
const fileUpload = new FileUpload([
imageFileUpload,
documentFileUpload,
htmlFileUpload
]);
// Need a file upload only for images
const fileUpload = new FileUpload([
imageFileUpload
]);
// Need a file upload only for documents and html?
const fileUpload = new FileUpload([
documentFileUpload,
htmlFileUpload
]);
With this approach we've made the code extremely modular and satisfied the open-closed principle.
Code is open for an extension - anytime we need to add new type, we create new class and pass its instance in a
FileUpload
wherever neededCode is closed for modification - we don't need to touch the file upload logic for existing classes when adding new file upload
It takes time to wrap your head around the OCP, and it is sometimes hard to foresee its need but it extremely useful when you find yourself introducing combination of method or class calls. It is also perfectly fine keeping the class together unless there's a practical need.
L: Liskov Substitution Principle (LSP)
The third principle from SOLID is called Liskov substitution principle, or LSP in short and was introduced by Barbara Liskov.
The very first definition talks about pointers and thus might be a little confusing for developers working mostly with higher level OOP languages, hence this quote:
"If S is a declared subtype of T, objects of type S should behave as objects of type T are expected to behave, if they are treated as objects of type T."[3]
Simply put, if I extend a class, I may implement more methods or even override original methods but if I override some methods, I should keep the same return types, and at least same input type.
I may further extend input types or increase number of parameters. Details of these restrictions are resembling the methodology called design by contract.
What problems does the LSP solve?
First of all, Liskov substitution principle helps you prevent unexpected behavior by wrongly extending your classes. One of the most criticized feature of OOP is inheritance. It's relative simplicity without constraints might be one of the reasons why the term "inheritance hell" exists.
LSP basically tells you something along these lines:
Just because you have some class which already implements most of what you need doesn't mean that you should extend it. Especially if your new class would basically override bunch of methods and adjust their behavior for itself. Ever heard about composition?
You can, of course, still go forward with inheritance but only if it makes sense in a specific scenario.
Furthermore, the LSP and OCP share same aim with extending code rather than touching existing functionality. If you realize that by extending the class you'd have to rework existing parent class, maybe you're doing something wrong or the original class was too broad. Or maybe the composition is better in this particular scenario.
Suppose we have TeamRepository
with a method to retrieve individual team by the user ID. The method is super straightforward - it accept an ID as a string and returns Team
object.
class TeamRepository {
async getForUser(userId: string): Promise<Team> {
// Execute the ORM method or SQL statement…
return db.execute('SELECT FROM…', { userId });
}
}
Now there's a feature requirement. Some customers require that the user might belong to multiple teams. Your team decides to create another repository called ExtTeamRepository
which extends the original one.
Point is that you can reuse most of the original class methods since all write operations are done individually by team ID, thus the only thing that needs to be adjusted is read part.
class ExtTeamRepository extends TeamRepository {
async getForUser(userId: string): Promise<Team[]> {
// Execute the ORM method or SQL statement…
// Return array of teams now…
}
}
Looks good, doesn't it? Well, no. LSP is violated not because the ExtTeamRepository
simply overrides the method of the original class but because it returns different type.
class UserTeamsController {
constructor(
private readonly teamRepository: TeamRepository,
private readonly extTeamRepository: ExtTeamRepository,
) {}
async getTeams(userId: string) {
// Should return single Team…
await this.teamRepository.getForUser(userId);
// …and it does ✅
// Should also return only single Team…
// …because it is a subtype of the TeamRepository
await this.extTeamRepository.getForUser(userId);
// …but returns array of Team, thus violates LSP ❌
}
}
Practical application of LSP to prevent unexpected behavior
Seeing the examples above, perhaps your first thought is that we should just implement different method in ExtTeamRepository
, something like this:
class ExtTeamRepository extends TeamRepository {
async getManyForUser(userId: string): Promise<Team[]> {
// Return array of teams…
}
// No need to override original method getForUser
}
However this still violates LSP. Which team does the original method getForUser
return when your user has three teams? Here we come with another attempt to fix this with the following code:
class ExtTeamRepository extends TeamRepository {
async getManyForUser(userId: string): Promise<Team[]> {
// Return array of teams…
}
async getForUser(userId: string): Promise<Team> {
throw Error('Not allowed method')
}
}
Unfortunately, this won't do either. The code might look okay and work but the LSP is still violated because you're introducing unexpected behavior. Anytime you add new method in the original class you might need to override the method in each subclass.
On the other hand, the caller might see that an instance of a ExtTeamRepository
is a subtype of the TeamRepository
but can't rely on the API or behavior provided by the original class. What's worse, the caller is still able to call these methods since they are already implemented.
So how do we satisfy the LSP with the code we have?
class TeamRepository {
async getOne(teamId: string): Promise<Team> {}
async update(teamId: string): Promise<Team> {}
async delete(teamId: string): Promise<void> {}
}
We first refactor the TeamRepository
to only keep the methods which require the individual team ID. Notice that we also have a getOne
method, since splitting repository simply by read and write operations would leave this one out, which we do not want.
Now we have multiple options. We can inject the TeamRepository
in the constructor of the new class and only expose the methods we want. This way we prevent overriding original class methods.
// Example with dependency injection
class UserTeamsRepository {
constructor(private readonly teamRepository: TeamRepository) {}
// Only expose methods we want from TeamRepository
async getOneForUser(userId: string): Promise<Team> {}
async getManyForUser(userId: string): Promise<Team[]> {}
}
Another possibility is to extend the original TeamRepository
and only implement new methods. This way we still satisfy LSP by keeping the behavior of subclass same as the original class.
// Example with inheritance
class UserTeamsRepository extends TeamRepository {
// This way all methods of TeamRepository would be exposed
async getOneForUser(userId: string): Promise<Team> {}
async getManyForUser(userId: string): Promise<Team[]> {}
}
For instance, the delete method would behave same whether it's called by an instance of TeamRepository
or UserTeamsRepository
.
const teamRepository = new TeamRepository()
const userTeamRepository = new UserTeamRepository()
// These two calls would behave exactly same
// Thus satisfying the LSP
teamRepository.delete()
userTeamRepository.delet()
We can even go further with a UserTeamsRepository
and introduce a single method, however that is outside of an LSP scope. Let me know if you'd like to see how to improve this further and I'll dive into this in another article.
To sum up our code changes in order to satisfy Liskov substitution principle, we've achieved the following:
Not abused OOP inheritance - we haven't extended an existing class for a sake of partially re-using funcionality only to override remaining functionality unfit for our needs
Kept the behavior of subclasses same and thus less error prone and easily testable - this is not just a buzzword, you can swap the parent class with a subclass and all the original tests should still work the same
Essentially satisfied the OCP as well by making the code open for extension and closed for modification - if we now need
CompanyTeamRepository
, we can just easily extend the team repository without touching existing code
Out of the five SOLID principles, the LSP is in my humble opinion pretty much underrated principle since most of the focus is on SRP.
Modeling classes in OOP is difficult and it's only a matter of time when you hit the inheritance problems in one way or another.
That does not mean that the inheritance itself is bad, it's just easily abused, and because of that, it's no wonder that OOP critique is mostly focused on the inheritance.
If you stick to the LSP, you'd have a good chance to avoid inheritance hell altogether since your subclasses wouldn't simply change behavior of the parent class. Furthermore, whenever introducing functionality, you'd very early see whether you can even extend parent class, prefer composition or refactor it to make it more modular.
I: Interface Segregation Principle (ISP)
The fourth principle from SOLID is called Interface segregation principle, or ISP in short and was formulated by Robert C. Martin. It is defined as following:
"Clients should not be forced to depend upon interfaces that they don’t use."[4]
Robert C. Martin in his paper about ISP also states that:
"Many client-specific interfaces are better than one general-purpose interface."[1]
In other words, classes or objects shouldn't depend on methods that they do not use. This may often occur when your class depends on interface but only uses subset of methods provided by given interface. In such case, you should break the interface into smaller bits.
What problems does the ISP solve?
If you've read the ins and outs of the previous principles, you may already noticed that the aim of all could be summed up as keeping your code lean by employing certain methodology. Same can be said about ISP but its focus is on interfaces.
Interface segregation principle focuses on keeping the interfaces lean and thus decoupling the system. Leaner interfaces mean that the class doesn't have to depend on or implement methods it does not need.
The code is easier to change, the naming of the interfaces is arguably easier since the interface itself focuses on specific action or set of actions rather than providing a generic signature for the whole class/object.
Consider the following interface:
interface ITeam {
private readonly name: string
rename(name: string): void {}
assign(userId: string): void {}
}
The interface is implemented by the Team
class, which is fair and fine. Now lets look at the User class which depends on the interface.
class User {
private readonly id: string
constructor(private readonly team: ITeam) {}
assignTeam() {
this.team.assign(this.id)
}
}
While the example above works fine, it violates the ISP. This is because the User
class only calls subset of the interface methods.
Different example in which the ISP is violated by interface implementation is following:
interface IFileUpload {
compress(file: File): void {}
upload(file: File): void {}
}
class ImageFileUpload implements IFileUpload {
// Fine ✅
}
class VideoFileUpload implements IFileUpload {
// Fine ✅
}
class DocumentFileUpload implements IFileUpload {
// Wrong ❌
// We only want to implement upload() method
}
Practical application of ISP to keep interfaces lean
In order to prevent violation of ISP and make code less coupled in general, it's good to separate interfaces by the functionality, i.e. what they do.
Sure, you may still create interfaces for data objects, models, etc. by what fields they define rather than their methods (thus functionality) but be very cautious whenever you want to add new method to your interface.
Lets split the interface from our first example.
interface ITeam {
private readonly name: string
rename(name: string): void {}
}
interface IAssignTeam {
assign(memberId: string): void {}
}
// And the class implements both interfaces
class Team implements ITeam, IAssignTeam {}
Now our User
class does not depend on the ITeam
interface which primarily defines the model but on the new interface which only exposes one method.
class User {
private readonly id: string
constructor(private readonly team: IAssignTeam) {}
assignTeam() {
this.team.assign(this.id)
}
}
As you can see, the class now doesn't know anything about the fields of the team implementation, nor it's internal functionalities. After all, it only needs the assign
method.
If you ever wrote some tests, you might already see the other benefit. Mocking the dependency becomes much easier and you can even easily swap the mocks depending on the test case.
Another example how to satisfy the ISP with class implementing interfaces.
interface IUploadFile {
upload(file: File): void {}
}
interface ICompressFile {
compress(file: File): void {}
}
// Now the classes only implement interfaces they need ✅
class ImageFileUpload implements IUploadFile, ICompressFile {}
class VideoFileUpload implements IUploadFile, ICompressFile {}
// Notice that below class doesn't need to compress file
class DocumentFileUpload implements IUploadFile {}
Interface segregation principle is quite simple to understand and relatively easy to implement. Granted, the implementation difficulty depends on the specific code-base but if you're cautious enough, it would help you avoid few headaches down the road.
D: Dependency Inversion Principle (DIP)
The fifth and final principle from SOLID is called Dependency inversion principle, or DIP in short, although usually referred to just as DI - Dependency inversion. Formulated by Robert C. Martin in one of his books, it is defined as:
"Depend upon abstractions, not concretions."[1]
As stated, your classes or functions should depend on abstractions, i.e. interfaces as much as possible. Instead of depending on another class, which is a concrete implementation, you swap it with an interface.
What problems does the DIP solve?
Dependency inversion principle enables you to significantly decouple the code. It improves testability and modularity, and also makes it easier to refactor the code.
Suppose we have a EmailService
class that provides an implementation of few methods which interact with underlying email provider.
class EmailService {
async send(email: string, message: string): Promise<void> {
// Very naive implementation of the email repository
// Send email with given message…
}
}
Now in our UserService
service, we inject the EmailService
and call it's methods wherever needed.
class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly emailService: EmailService,
) {}
async register(data: any): Promise<User> {
const user = this.userRepository.create(data)
await this.emailService.send(user.email)
return user
}
}
So far so good, the code works well. What's the problem then? Well, our UserService
directly depends on the implementation of EmailService
, or in other words depends on a concrete thing instead of an abstraction.
Depending directly on the implementation results in tight coupling and introduces following problems:
Worse modularity - code is less modular because I can't swap the implementation of the
EmailService
with a different one (i.e. if the service implements 3rd party provider A, I can't easily swap this with 3rd party provider B)Worse testability - code is harder to test because you either have to mock the service or retrieve it and inject it in the constructor of class that depends on it
Practical application of DIP to prevent tight couping
To satisfy dependency inversion principle and achieve loosely coupled modules, we basically have to introduce an abstraction layer with interfaces. These should not depend on any concrete details.
interface IEmailService {
async send(email: string, message: string): Promise<void> {}
}
class EmailService implements IEmailService {
async send(email: string, message: string): Promise<void> {
// Send email with given message…
}
}
We've created an interface which the EmailService
implements. Generally speaking, aim for creating abstractions/interfaces before you start implementing the class.
class UserService {
// Notice that we now depend on interfaces instead of classes
constructor(
private readonly userRepository: IUserRepository,
private readonly emailService: IEmailService,
) {}
async register(data: any): Promise<User> {
// …
}
}
We've now successfully applied the DIP and inverted the dependencies.
const userRepository = new UserRepository()
const emailService = new EmailService()
// This part hasn't changed, we still inject dependencies same way
new UserService(userRepository, emailService)
// But since we now depend on the interfaces
// we can now swap the implementation easily
const otherEmailService = new OtherEmailService()
new UserService(userRepository, otherEmailService)
// Of course OtherEmailService needs to implement IEmailService
You may need some time to wrap your head around the Dependency inversion principle. It is extremely easy to apply but you may start seeing the benefits only after some time, perhaps sooner when you write lots of tests.
SOLID Summary
SOLID principles are often referred to as a foundation of modern OOP software architecture. These principles can arguably help you make the code more modular, extensible and testable.
Perhaps the most important benefit that SOLID principles provide (given they are implemented correctly) is to keep your code lean and thus easier to extend without directly changing it.
After all, good code is a one that doesn't have to change.
To achieve that, each SOLID principle focuses on different strategy, some of which you might need now and some of which you may need later as your project grows.
As with any software design principle, be pragmatic about it and do not apply any principle just for sake of applying it unless you're convinced that it would be a net benefit.
Do you think SRP is still the most important one of SOLID set of principles? And does your team only boasts about SOLID or actually implements these principles in real?