Think with Enlab

Diving deep into the ocean of technology

Stay Connected. No spam!

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 the 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 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.

 

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 with 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 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

To help you properly understand SOLID principles and how to apply them in your projects effectively, we'll explain five principles along with code samples in our own project. The context here is developing a web application for order fulfillment.

This application will include the following features:

  • Shopping and putting items into the basket.
  • Place an order.
  • Export invoice.

First things first, we need to implement the back-end side API using .NET Core.

 

Single responsibility principle

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).

 

Project Structure (Modules)

Project Structure (Modules) in SOLID principles in C#

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

Class structure in SOLID principles

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 can see how service classes are structured.

 

Method structure

Method structure in SOLID principle with C#

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.

 

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)

public class InvoiceService : BaseService, IInvoiceService
{
   //...   

   public async Task Sign(int orderId)
   {
      var order = await _order.GetAsync(orderId);
      var invoiceId = order.InvoiceId.GetValueOrDefault();
      var invoice = await GetInvoiceByType(order, invoiceId);

      switch (order.Type)
      {
          case OrderType.Company:
              //... sign company invoice
              break;
          case OrderType.Personal:
              //... sign personal invoice
              break;
          default:
              throw new ArgumentOutOfRangeException();
      }
   
   //...
}

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

public abstract class InvoiceBase : EntityBase
{
   public Order Order { get; set; }
   public abstract string Sign();

   protected InvoiceBase(Order order)
   {
       Order = order;
   }
}

Then, each type of invoice class will implement its own Sign() method and handle them in its class scope.

 

CompanyInvoice.cs

[Table("CompanyInvoice")]
public class CompanyInvoice : InvoiceBase
{
   public int TaxNumber { get; set; }

   public CompanyInvoice(Order order, int taxNumber) : base(order)
   {
       TaxNumber = taxNumber;
   }

   public override string Sign()
   {
       //... sign company invoice
   }
}

 

PersonalInvoice.cs

[Table("PersonalInvoice")]
public class PersonalInvoice : InvoiceBase
{
   public PersonalInvoice(Order order) : base(order)
   {
   }

   public override string Sign()
   {
       //... sign personal invoice
   }
}

By implementing this way, it works well for “Closed for modification”. As those methods are implemented and managed in their own classes. In higher-level modules, we only need to call the Sign() function without concerning their implementation details.

 

InvoiceService.cs

public class InvoiceService : BaseService, IInvoiceService
{
   //...   

   public async Task Sign(int orderId)
   {
       var order = await _order.GetAsync(orderId);
       var invoiceId = order.InvoiceId.GetValueOrDefault();
       var invoice = await GetInvoiceByType(order, invoiceId);
       invoice?.Sign();
   }

   //...
}

 

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.

 

InvoiceBase.cs

public abstract class InvoiceBase : EntityBase
{
   public Order Order { get; set; }
   public decimal TotalCost { get; set; }
   public abstract void Sign();

   protected InvoiceBase()
   {
   }

   protected InvoiceBase(Order order)
   {
       Order = order;
       TotalCost = order.Total();
   }

   public virtual string Export()
   {
   return $"Exported invoice with {nameof(TotalCost)}: {TotalCost}";
   }
}

But then, they proposed another requirement. Company invoice needs to have their own format. Hence, I overrode the Export() function from the base class and implemented their own one.

You can follow the code examples below:

 

CompanyInvoice.cs

[Table("CompanyInvoice")]
public class CompanyInvoice : InvoiceBase
{
   public int TaxNumber { get; set; }

   public CompanyInvoice(Order order, int taxNumber) : base(order)
   {
       TaxNumber = taxNumber;
   }

   //...

   public override string Export()
   {
       //... exporting
     return $"Exported company invoice with {nameof(TaxNumber)}: {TaxNumber}";
   }
}

In InvoiceService, we implement the Export() method, which will export the details of the invoice based on its type.

 

InvoiceService.cs

public class InvoiceService : BaseService, IInvoiceService
{
   //...   

   public async Task Export(int orderId)
   {
      var order = await _order.GetAsync(orderId);
      var invoiceId = order.InvoiceId.GetValueOrDefault();
      var invoice = await GetInvoiceByType(order, invoiceId);
      return invoice?.Export();
   }
   
   //...
}

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 couldn’t have a tax number. Thus, it would be an inappropriate use case that we should avoid.

 

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 a bank account, and the second is via e-wallet. Here is the wrong way to code.

 

IPayment.cs (Wrong case)

public interface IPayment
{
   public void Pay(int amount);
   public void ScanQRCode();
   // Bank features
   public void AddBankInfo(string cardNumber, string ccv);
   public void PayWithInstallmentLoan();
   // E-wallet features
   public void LinkToBank(string bankAddress);
   public void TopUp(int amount);
}

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 an e-wallet. And vice versa.

Therefore, we hold the common function in the IPayment interface and create specific interfaces, which contain specific functions for each payment method.

 

IPayment.cs

public interface IPayment
{
   public void Pay(int amount);
   public void ScanQRCode();
}

Sub-interfaces for each payment will implement IPayment to inherit common functions that a payment method must have.

 

IBankPayment.cs

public interface IBankPayment: IPayment
{
   public void AddBankInfo(string cardNumber, string ccv);
   public void PayWithInstallmentLoan();
}

 

IEWalletPayment.cs

public interface IEWalletPayment: IPayment
{
   public void LinkToBank(string bankAddress);
   public void TopUp(int amount);
}

After creating the necessary interfaces, we will implement actual classes with functions' details.

 

BankPayment.cs

public class BankPayment: IBankPayment
{
   public void Pay(int amount)
   {
       //... detail
   }

   public void ScanQRCode()
   {
       //... detail
   }

   public void AddBankInfo(string cardNumber, string ccv)
   {
       //... detail
   }

   public void PayWithInstallmentLoan()
   {
       //... detail
   }
}

 

EWalletPayment.cs

public class EWalletPayment: IEWalletPayment
{
   public void Pay(int amount)
   {
        //... detail
   }

   public void ScanQRCode()
   {
        //... detail
   }

   public void LinkToBank(string bankAddress)
   {
        //... detail
   }

   public void TopUp(int amount)
   {
        //... detail
   }
}

 

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 the basket service would implement.

 

IBasketService.cs

public interface IBasketService
{
   public Task Get(int id);
   public Task Create(int userId);
   public Task AddItem(int basketId, int productId);
   public Task RemoveItem(int basketId, int productId);
   public Task MarkAsResolved(int basketId);
}

Then, the BasketService class will inherit the IBasketService interface and implement all functions that have been defined. This ensures details depend on abstraction.

 

BasketService.cs

public class BasketService : BaseService, IBasketService
{
   private readonly IRepository _basketRepo;
   private readonly IRepository _productRepo;

public BasketService(IUnitOfWork unitOfWork, IRepository basketRepo, IRepository productRepo) : base(unitOfWork)
   {
       _basketRepo = basketRepo;
       _productRepo = productRepo;
   }

   public async Task Get(int id)
   {
       //... detail
   }

   public async Task Create(int userId)
   {
       //... detail
   }

   public async Task AddItem(int basketId, int productId)
   {
       //... detail
   }

   public async Task RemoveItem(int basketId, int productId)
   {
       //... detail
   }

   public async Task MarkAsResolved(int basketId)
   {
       //... detail
   }
}

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.

 

BasketController.cs (Wrong case)

[ApiController]
[Route("[controller]")]
public class BasketController : ControllerBase
{
   private readonly ILogger _logger;
   private readonly BasketService _basketService;

   public BasketController(ILogger logger, BasketService basketService)
   {
       _logger = logger;
       _basketService = 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 their interfaces which will be used in other classes of the application, including BasketService.

 

Startup.cs

services
   .AddScoped<IUnitOfWork, UnitOfWork>()
   .AddScoped(typeof(IRepository<>), typeof(Repository<>))
   .AddScoped<IBasketService, BasketService>()
   .AddScoped<IOrderService, OrderService>()
   .AddScoped<IProductService, ProductService>()
   .AddScoped<IInvoiceService, InvoiceService>();

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).

 

BasketController.cs

[ApiController]
[Route("[controller]")]
public class BasketController : ControllerBase
{
   private readonly ILogger _logger;
   private readonly IBasketService _basketService;

   public BasketController(ILogger logger, IBasketService basketService)
   {
       _logger = logger;
       _basketService = basketService;
   }
}

 

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 realized 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 later.

In this article, I explained how to apply SOLID principles to a C# project, from layers and 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 a 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!

 

CTA Enlab Software

 

References:

About the author

Chuong Tran

Hi, I’m a developer from Enlab Software. Currently, I’m working with .NET, Angular, SQL Server, and DevOps (GitLab CI/CD, Docker). I love coding, always focusing on system architecture, algorithms, design patterns to make my products better. I’m happy to share and discuss with you about them.

Up Next

March 21, 2024 by Dat Le
In the dynamic arena of startup development, an innovative trend is reshaping how entrepreneurs bring their...
Unveiling the Web Development Outsourcing Landscape
February 15, 2024 by Dat Le
In the era where digitalization dictates the tempo of business evolution, understanding the intricate fabric of...
Understanding different types of Angular Modules with practical examples
November 25, 2022 by Trong Ngo
In software development, grouping multiple specific functionalities or features into different modules is the key...
How to apply Test Driven Development with practical examples
June 22, 2022 by Tuan Do
Over the last few years, test-driven development (a.k.a. TDD) has grown in popularity. Many programmers...
Roll to Top

Can we send you our next blog posts? Only the best stuffs.

Subscribe