How to apply SOLID principles with practical examples in C#
In Object-Oriented Programming (OOP), SOLID is one of the most popular sets of design principles that help developers write readable, reusable, maintainable, and scalable code. It's a mnemonic acronym for five design principles listed below:
1. Single Responsibility Principle (SRS)
2. Open/Closed Principle (OCP)
3. Liskov Substitution Principle (LSP)
4. Interface Segregation Principle (ISP)
5. Dependency Inversion Principle (DIP)
This article will get into the concept of each principle in Object-Oriented Design and why you should use them in software design. We also explore how to apply these principles effectively in your application with practical examples using C#. Even if you aren't a C# developer, some OOP experience can also help you produce a robust and maintainable software.
Why you should use SOLID design principles
As software developers, the natural tendency is to start developing applications based on your own hands-on experience and knowledge right away. However, over time issues in the application arise, adaptations to changes, and new features happen. Since then, you gradually realize that you have put too much effort into one thing: modifying the application. Even when implementing a simple task, it also requires understanding the whole system. You can’t blame them for changes or new features since they are inevitable parts of software development. So, what is the main problem here?
The obvious answer could be derived from the application's design. Keeping the system design as clean and scalable as possible is one of the critical things that any professional developer should dedicate their time to. And that’s where SOLID design principles come into play. It helps developers eliminate design smells and build the best designs for a set of features.
Although the SOLID design principles were first introduced by the famous Computer Scientist Robert C. Martin (a.k.a. Uncle Bob) in his paper in 2000, its acronym was introduced later by Michael Feathers. Uncle Bob is also the author of best-selling books Clean Code, Clean Architecture, Agile Software Development: Principles, Patterns, and Practices, and Designing Object-Oriented C++ Applications Using The Booch Method.
Why you should use SOLID design principles
As software developers, the natural tendency is to start developing applications based on your own hands-on experience and knowledge right away. However, over time issues in the application arise, adaptations to changes, and new features happen. Since then, you gradually realize that you have put too much effort into one thing: modifying the application. Even when implementing a simple task, it also requires understanding the whole system. You can’t blame them for changes or new features since they are inevitable parts of software development. So, what is the main problem here?
The obvious answer could be derived from the application's design. Keeping the system design as clean and scalable as possible is one of the critical things that any professional developer should dedicate their time to. And that’s where SOLID design principles come into play. It helps developers eliminate design smells and build the best designs for a set of features.
Although the SOLID design principles were first introduced by the famous Computer Scientist Robert C. Martin (a.k.a. Uncle Bob) in his paper in 2000, its acronym was introduced later by Michael Feathers. Uncle Bob is also the author of best-selling books Clean Code, Clean Architecture, Agile Software Development: Principles, Patterns, and Practices, and Designing Object-Oriented C++ Applications Using The Booch Method.
Advantages of SOLID design principles in C#
The SOLID are long-standing principles used to manage most of the software design problems you encounter in your daily programming process.
Whether you are designing or developing the application, you can leverage the following advantages of SOLID principles to write code in the right way.
Maintainability:
So far, maintenance is vital for any organization to keep high quality as a standard in developing software. As the business has been growing and the market requires more changes, the software design should be adapted to future modifications at ease.
Testability:
When you design and develop a large-scale application, it's essential to build one that facilitates testing each functionality early and easily.
Flexibility and scalability:
Flexibility and scalability are the foremost crucial parts of enterprise applications. As a result, the system design should be adaptable to any later update and extensible for adding new features smoothly and efficiently.
Parallel development:
Parallel development is one of the most key factors for saving time and costs in software development. It could be challenging for all team members to work on the same module at the same time. That’s why the software needs to be broken down into various modules which allow different teams to work independently and simultaneously.
SOLID principles in a .NET Core application
Robert Martin states that “Every software module should have only one reason to change.”
It's tempting to pack a class with a lot of functionality, like when you can only take one suitcase on your flight. However, the issue is that your class won't be conceptually cohesive, giving it several reasons to change. Reducing the number of times to modify a class is essential. If this job's specifications change, you only need to modify that specific class. This change is less likely to break the whole application since other classes continue to function as before. As a result, classes have become smaller, cleaner, and thus easier to maintain.
To simplify the coding process, we choose 3-layer architecture to implement this project. Our application includes three layers: API, Application, Entity, and Infrastructure (a.k.a. Data Access Layer).
‧
As you can see in the above screenshot, each layer is designed for one responsibility:
‧API: Handle requests from clients.:
‧Application: Process business logic.
Data Access Layer: :
Entities: POCO classes, construction, and model validation.
Infrastructure: Communicate with the database (Persistent Layer).
Class Structure
‧
In the Application Layer, each service class is designed to handle only one feature. If there are multiple classes in a layer, they should relate to one responsibility. In the example, you could see how service classes are structured.:
Method Structure
‧
Each method, in this case, in the Basket class is also responsible for one functionality. This approach enables you to structure your code clearer and ease the refactoring process in the future.
Single responsibility principle
Open/Closed Principle:
“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”
Modules that follow the Open/Closed Principle include two key attributes.
‧ "Open for extension."
It means that the module’s behavior can be extended. When the requirements of the application change, you can extend the module with new behaviors that adapt to those changes.
‧ "Closed for modification."
The source code of such a module is inviolate when you extend the module's behavior. You shouldn't refactor and modify the existing code for the module until bugs are detected. The reason is the source code has already passed the unit testing, so changing it can affect other existing functionalities.
In our application, there are two types of invoices. One for the company, and another for the individual. And each of them will acquire different methods of signing.
Let's look at the code. The following example shows that signing implementation details of two invoice types are placed in InvoiceService. They work totally fine, but outside their core class (CompanyInvoice & PersonalInvoice). As a result, they are not closed for modification.
InvoiceService.cs (Wrong case)
‧
To fix them, we will create the InvoiceBase abstract class, which defines common methods required in invoice features. We also place the abstract Sign() method in the base class.
InvoiceBase.cs
‧
Then, each type of invoice class will implement its own Sign() method and handle them in its class scope.
To fix them, we will create the InvoiceBase abstract class, which defines common methods required in invoice features. We also place the abstract Sign() method in the base class.
CompanyInvoiceBase.cs
‧
PersonalInvoice.cs
‧
PersonalInvoice.cs
‧
Liskov Substitution Principle:
“You should be able to use any derived class instead of a parent class and have it behave in the same manner without modification.”
‧ This principle clearly states that when you have a parent class and a child class in your project, the child class can be a substitution of the parent class without changing the correctness of the application.
In this application, besides the signing method, business analysts required a default exporting form for the invoice. Therefore, we declared the Export() method in the InvoiceBase class to generate the default form of the export feature.
Now let's see the code example.
‧
But then, they proposed another requirement. Company invoice needs to have its own format. Hence, I overrode the Export() function from the base class and implemented their own one.
You can follow the code examples as below:
‧
In InvoiceService, we implement the Export() method, which will export the details of the invoice based on its type.
‧
The personal invoice doesn’t override the default Export() method in InvoiceBase class, and given that, it will return the default export form. However, the company invoice does, it will return the export form which was overridden in the company invoice.
The Export() method in the company invoice is called a good substitution for the Export() method in the base class (InvoiceBase) without affecting the correctness of our application.
On the other hand, if we inherited PersonalInvoice from CompanyInvoice, they would affect the correctness of the application. At that time, the personal invoice can’t have a tax number. Thus, it would be an inappropriate use case that we should avoid.
Interface Segregation Principle
Interface Segregation Principle
“Clients should not be forced to implement interfaces they don't use. Instead of one fat interface, many small interfaces are preferred based on groups of functions, each one serving one submodule.”
In the requirement, our order fulfillment application allows 2 payment methods. The first is paying via bank account, and the second is via e-wallet. Here is the wrong way to code.
‧
In the first stage, we created an IPayment interface for both of them. But then, we realized that some functions are designed for specific payment methods. For instance, adding bank information and paying with installment loans are features that are only allowed on a bank account, not spent for e-wallet. And vice versa.
Therefore, we hold the common function in the IPayment interface and create specific interfaces, which contains specific functions for each payment method.
‧
Sub-interfaces for each payment will implement IPayment to inherit common functions that a payment method must have.
‧
After creating the necessary interfaces, we will implement actual classes with functions' details.
‧
‧
Dependency Inversion Principle
It’s the last principle in SOLID and states two critical parts as follows:
High-level modules should not depend on low-level modules. Both should depend on abstractions (interface).
Abstractions should not depend upon details. Details should depend on abstractions.
The high-level modules include the important policy decisions and business models of an application. If these modules depend on the lower-level details, changes to the lower-level modules can directly impact the higher-level ones, forcing them to change in turn. When building a real-time application, the critical point is always to keep the high-level module and low-level module loosely coupled as much as possible. It helps to protect the other class while you are making changes to any specific class. All in all, instead of a high-level module depending on a low-level module, both should depend on an abstraction.
Let me give you an example that helps you understand this principle clearly.
First, I defined an abstract interface IBasketService along with functions that basket service would implement.
‧
Then, the BasketService class will inherit the IBasketService interface and implement all functions that have been defined. This ensures details depend on abstraction.
‧
Next, we need to use BasketService in BasketController. Given that, we pass the service to the constructor of BasketController. Once again, it works fine but violates the Dependency Inversion Principle. This means that the BasketController class depends on low-level modules, specifically the BasketService.
‧
Instead, BasketController should depend on abstraction, and in this case, it is IBasketService. We will implement them along with Dependency Injection.
In programming, Dependency Injection (DI) is a powerful technique in which an object receives other objects that it depends on, called dependencies. In the code below, I applied DI to register all classes along with and their interfaces which will be used in other classes of the application, including BasketService.
‧
In high-level modules, we could use classes from low-level modules by injecting their interface via DI. The DI would create instances and provide them to the current class. This keeps BasketController and other similar classes from depending on low-level modules but just only relying on abstractions (interface).
‧
Final thoughts,
So far, the above example helps you distinguish SOLID principles in the right and wrong way. Throughout the years of programming, I used to refactor many projects, and what I realize is that it’s really tricky to change the code structure if you don’t apply the SOLID principles and other standards like DRY. Clear, understandable, loosely coupled, and scalable are all important attributes we should focus on. Without them, the codebase will become a nightmare for other developers to maintain and update them later.
In this article, I explained how to apply SOLID principles into a C# project, from layers, classes to methods. Though, I can’t cover all specific cases in your real project, hopefully this article can inspire you to apply these principles in the C# code structure and benefit from their traits. Let's make the most of every chance you have to practice SOLID as much as possible. It will deepen your experiences in creating the most beautiful codebase.
For better understanding, you can find the source code on GitHub here.
Thank you for spending your time reading the whole article.
Happy coding and have a nice day!
References:
Robert C. Martin, Agile Software Development, Principles, Patterns, and Practices, 2002.
Yiğit Kemal Erinç, The SOLID Principles of Object-Oriented Programming, www.freecodecamp.org, 2020.
Dot Net Tutorials, SOLID Design Principles in C#, www.dotnettutorials.net.
Abstractions should not depend upon details. Details should depend on abstractions.