In this article, we are going to discuss Clean Architecture in .NET.
So let’s get going.
VIDEO: Clean Architecture with MediatR and CQRS in .NET.
What is Clean Architecture?
Clean Architecture is an architecture pattern aimed at building applications that we can maintain, scale, and test easily.
It achieves this by separating the application into different layers that have distinct responsibilities:
- Domain Layer – The domain layer represents the application’s core business rules and entities. This is the innermost layer and should not have any external dependencies.Â
- Application Layer – The application layer sits just outside the domain layer and acts as an intermediary between the domain layer and other layers. In other words, it contains the use cases of the application and we expose the core business rules of the domain layer through the application layer. This layer depends just on the domain layer.
- Infrastructure Layer – We implement all the external services like databases, file storage, emails, etc. in the infrastructure layer. It contains the implementations of the interfaces defined in the domain layer.
- Presentation Layer – The presentation layer handles the user interactions and fetches data to the user interface.
A fundamental principle of Clean Architecture is that the dependencies should point from the concrete outer layers to the abstract inner layers.
This makes it possible to change the specific implementations in the future without affecting other areas of the application. Additionally, the Clean Architecture follows a well-structured approach to organizing the code which helps in maintainability and testability.Â
Differences Between the Clean Architecture and Onion Architecture
Both the Clean Architecture and the Onion Architecture pattern follow the same principles and aim to achieve similar goals such as separation of concerns, loose coupling of components, etc. Many developers consider these as different interpretations of the same fundamental concepts due to their similar goals and principles. However, they do have some slight differences.
Clean Architecture separates different layers based on the level of abstraction, with dependencies always pointing inwards from concrete outer layers to abstract inner layers. It is significant for large projects that require their components to be decoupled and need to support independent development and deployment cycles.
On the other hand, Onion Architecture is more focused on the central layer known as the Core, using interfaces and dependency inversion to decouple the application layers and enable a higher degree of testability. It is focused on domain models and business rules, with all additional concentric layers pointing towards the core. It is particularly beneficial for projects that need to adhere to a domain-driven design approach and is suited for small to medium-sized projects.
In this way, while they share similar foundational concepts, the choice between Clean Architecture and Onion Architecture can depend on the specific needs and scale of the project.
How to Implement Clean Architecture in .NET?
Now let’s see how to implement an ASP.NET Core Web API project using the Clean Architecture design pattern. We’ll take a look at the different layers and what code needs to go in each layer.
Let’s start by creating a new solution for the application:
First, we create a solution called ToDoApp with three folders – Core, Infrastructure, and Presentation. After that, we add the following projects to it:
ToDoApp.Domain
– This is a class library project to represent the Domain layer of the application.ToDoApp.Application
– This is a class library project as well and represents the Application layer of the application. This project will refer only to theToDoApp.Domain
project.ToDoApp.Infrastructure
– This is another class library project for representing the Infrastructure layer of the application.ToDoApp.Persistence
– This is a class library project as well and part of the Infrastructure layer. This is specifically for dealing with the data persistence of the application.ToDoApp.API
– This is an ASP.NET Web API project that represents the Presentation layer of the application. Ideally, this should depend on just theToDoApp.Application
project. However, for dependency injection, we will need to add a reference toToDoApp.Infrastructure
andToDoApp.Persistence
. But remember that those should be just run time dependencies and we should not create any compile time dependencies with those layers.Â
Here notice that we organize the Domain and Application projects into the Core folder. Similarly, the Infrastructure and Persistence projects go into the Infrastructure folder. However, the API project goes into the Presentation folder. By doing so, we will have a solution structure that follows the Clean Architecture principles.
The Domain Layer
Now let’s see what code needs to go in the Domain Layer. The Domain layer is primarily for keeping the entities and interfaces of the application.
Keeping this in mind, first, let’s add the ToDoItem
entity class:
public class ToDoItem { public int Id { get; set; } public required string Description { get; set; } public bool IsDone { get; set; } }
In the class, we define the Id,
Description
, and IsDone
properties.
After that, let’s add the IToDoRepository
interface:
public interface IToDoRepository { Task<List<ToDoItem>> GetAllAsync(); Task<int> CreateAsync(ToDoItem item); }
In the repository interface, let’s just add two methods – GetAllAsync()
and CreateAsync()
. This is for implementing the repository pattern. The repository pattern introduces an abstraction layer between the data access layer and the business logic layer of the application. This improves code maintainability, readability, and testability.Â
The Application Layer
While implementing Clean Architecture in .NET, CQRS with MediatRÂ is a popular choice.
CQRS (Command Query Responsibility Segregation) design pattern separates the components responsible for reading and writing data.
Similarly, MediatR helps in implementing the mediator pattern. By using a mediator pattern, we can manage communications between different parts of the application that are loosely coupled.
In this example, we are going to use CQRS and Mediator patterns for implementing the application layer of our ToDoApp. Keeping this in mind, we are going to create a command for creating a ToDoItem
and a query for listing all the ToDoItem
instances.
First, let’s add the MediatR
NuGet package:
dotnet add package MediatR
Then, let’s create a command:
public class CreateToDoItemCommand : IRequest<int> { public required string Description { get; set; } }
The CreateToDoItemCommand
class implements the IRequest
interface defined in the MediatR
library and provides the response type as the parameter. We add the Description
property to the class.
After that, let’s create a handler for our CreateToDoItemCommand
class:
public class CreateToDoItemCommandHandler(IToDoRepository toDoRepository) : IRequestHandler<CreateToDoItemCommand, int> { public Task<int> Handle( CreateToDoItemCommand request, CancellationToken cancellationToken) { var item = new ToDoItem { Description = request.Description }; return toDoRepository.CreateAsync(item); } }
The CreateToDoItemCommandHandler
class implements the IRequestHandler
interface from the MediatR
library and provides both the request and response types as parameters. In the Handle()
method, we call the CreateAsync()
method of the repository and pass the ToDoItem
into it.
With that, the command is ready.Â
Now, let’s create a simple query:
public class ToDoItemQuery : IRequest<List<Domain.Entities.ToDoItem>> { }
Here the ToDoItemQuery
class body is empty as there are no parameters for this operation and the response type is a list of ToDoItem
.
After that, let’s add a handler for our query class:
public class ToDoItemQueryHandler(IToDoRepository toDoRepository) : IRequestHandler<ToDoItemQuery, List<Domain.Entities.ToDoItem>> { public Task<List<Domain.Entities.ToDoItem>> Handle( ToDoItemQuery request, CancellationToken cancellationToken) { return toDoRepository.GetAllAsync(); } }
In the ToDoItemQueryHandler
class we implement the Handle()
method, internally calling the GetAllAsync()
method of the repository.
Finally, let’s add the dependency injection for MediatR
as well:
public static IServiceCollection AddApplicationDependencies(this IServiceCollection services) { services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(AppDomain.CurrentDomain.GetAssemblies())); return services; }
With that, the Application layer is ready.
The Infrastructure Layer
In the Infrastructure layer, let’s add a concrete service with data storage logic. To do that, we are going to add an in-memory data storage service to the Persistence project as this is related to data persistence.
Let’s create an InMemoryToDoRepository
service that implements the IToDoRepository
interface:
public class InMemoryToDoRepository : IToDoRepository { private static readonly List<ToDoItem> _items = []; public Task<int> CreateAsync(ToDoItem item) { _items.Add(item); return Task.FromResult(item.Id); } public Task<List<ToDoItem>> GetAllAsync() { return Task.FromResult(_items); } }
Here first we define a list to hold the ToDoItem
. Then, in the CreateAsync()
method, we add the ToDoItem
to an in-memory list. Similarly, in the GetAllAsync()
method, we get all items from the list.
That’s all for the Infrastructure layer for now. If we have integrations with other external components such as email services, file storage services, etc., we can add separate sections or projects for each of those.
The Presentation Layer
In the Presentation layer, we have an ASP.NET Core Web API project.
So let’s go ahead and add a controller:
[Route("api/[controller]")] [ApiController] public class ToDoItemController(IMediator mediator) : ControllerBase { [HttpGet] public async Task<IActionResult> Get() { return Ok(await mediator.Send(new ToDoItemQuery())); } [HttpPost] public async Task<IActionResult> Post([FromBody] CreateToDoItemCommand command) { await mediator.Send(command); return Created(); } }
In the ToDoItemController
class, we inject the Mediator and define two action methods- Get()
and Post()
. The Get()
method sends a request to the ToDoItemQueryHandler
 class using MediatR
. Similarly, the Post()
method sends a request to the CreateToDoItemCommandHandler
class.
Here, for the sake of simplicity, we are using the command object directly in the API Controller. However, in real-world projects, it is always better to create separate Data Transfer Objects (DTOs) for each layer and map them to the respective types before sending them into the next layer.
Finally, let’s add the dependencies into the DI container:
builder.Services.AddApplicationDependencies(); builder.Services.AddSingleton<IToDoRepository, InMemoryToDoRepository>();
First, we call the AddApplicationDependencies()
method in the Application project. After that, we add the InMemoryToDoRepository
type to the DI container.
With that, we have created an ASP.NET Core Web API project that follows the Clean Architecture design pattern. We should be able to create new ToDoItem
instances and list all the items by running the app.
Advantages of Clean Architecture
As the above example shows, Clean Architecture has several advantages. Let’s discuss those in detail.
Clear Separation of Concerns
The Clean Architecture separates our application into clear and distinct layers such as Domain, Application, Infrastructure, Presentation, etc.
Each layer is independent and has a distinct responsibility. This makes the code more organized and maintainable. Additionally, it makes our code easier to understand. With Clean Architecture, we will have a clear picture of where certain behaviors and logic should live in our application.
Loose Coupling Between Components
In Clean Architecture, the UI and infrastructure components depend only on the application’s core.
This means we can change those components without affecting other parts of the application:
For example, if we decide to change the in-memory repository with an SQL database using EF Core, we just need to change the concrete implementation of the repository in the Infrastructure layer:
public class SqlToDoRepository : IToDoRepository { private readonly ToDoDbContext _context; public SqlToDoRepository(ToDoDbContext context) { _context = context; } public Task<int> CreateAsync(ToDoItem item) { _context.ToDoItems.Add(item); return _context.SaveChangesAsync(); } public Task<List<ToDoItem>> GetAllAsync() { return _context.ToDoItems.ToListAsync(); } }
We do that by replacing the InMemoryToDoRepository
class with a new class SqlToDoRepository
that implements the IToDoRepository
interface. In the new repository class, we inject the EF Core DbContext
and change the methods to save and retrieve from the SQL database using EF Core methods.
After that, we update the DI container to use this new repository. Other parts of the application such as the Core and Presentation need not be aware of this change. Â
Testability
Another advantage of Clean Architecture is the ability to test each layer independently. The business logic resides at the core of the application and can be tested in isolation of the Infrastructure and Presentation layer.Â
For instance, we can write unit tests for the application layer commands and handlers independently.
First, let’s write a unit test for CreateToDoItemCommand
:
[Fact] public void GivenCreateToDoItemCommandHandler_WhenHandleCalled_ThenCreateNewToDoItem() { // Arrange var toDoRepositoryMock = new Mock<IToDoRepository>(); toDoRepositoryMock.Setup(x => x.CreateAsync(It.IsAny<ToDoItem>())) .ReturnsAsync(1); var createToDoItemCommandHandler = new CreateToDoItemCommandHandler(toDoRepositoryMock.Object); var createToDoItemCommand = new CreateToDoItemCommand { Description = "Test Description" }; // Act var result = createToDoItemCommandHandler.Handle(createToDoItemCommand, CancellationToken.None).Result; // Assert Assert.Equal(1, result); }
After that, let’s write a unit test for ToDoItemQuery
:
[Fact] public async Task GivenToDoItemQueryHandler_WhenHandleCalled_ThenReturnToDoItems() { // Arrange var mockRepository = new Mock<IToDoRepository>(); mockRepository.Setup(x => x.GetAllAsync()) .ReturnsAsync( [ new() { Description = "Item 1" }, new() { Description = "Item 2" } ]); var handler = new ToDoItemQueryHandler(mockRepository.Object); var query = new ToDoItemQuery(); // Act var result = await handler.Handle(query, CancellationToken.None); // Assert Assert.Equal(2, result.Count); Assert.Equal("Item 1", result[0].Description); Assert.Equal("Item 2", result[^1].Description); }
Likewise, if we had any entity-specific behaviors or validations in the Domain layer, we could write independent tests for those. Not only that, we can write independent tests for UI or Infrastructure components as well.Â
Scalability
With Clean Architecture, it is possible to scale each layer of the application independently. This gives us the flexibility to adapt to the changing performance requirements of the application. For example, the Core business logic layer can be scaled separately from the Infrastructure or the Presentation layers. We can either scale horizontally by adding more instances of services or vertically by increasing computing power, memory, etc. for specific services.
Team collaboration
With Clean Architecture, it is possible to have different teams working independently on different application layers. This gives us the flexibility of different teams with different skill sets working on different layers in parallel without much dependency on other teams. Of course, this results in faster development as the time lost by waiting on dependencies is minimal.
Disadvantages of Clean Architecture
While Clean Architecture offers a plethora of advantages, it is not without some potential disadvantages and challenges that can occur in certain scenarios such as:
Increased Complexity & Abstraction – The structured and layered approach of Clean Architecture can potentially lead to increased complexity. For smaller projects, creating these layers and abstractions can be an overhead and there could be a steep learning curve as well.
Dependency Injection Overhead – Setting up dependency injection for all the layers and components can be cumbersome as the project grows.Â
Performance Issues – In some cases, strict separation of concerns might introduce performance issues. For instance, we might need to map data between different layers and make multiple calls within an operation which may be performance-intensive.Â
Keeping this in mind, we should carefully evaluate the benefits of Clean Architecture against potential drawbacks before adopting this pattern for our project.
When to Use Clean Architecture?
Even though Clean Architecture is a software design pattern with many benefits, it’s not a one-size-fits-all solution for all projects. So we should carefully evaluate the requirements of our application to determine if Clean Architecture is a good option for it. Here are some scenarios where we can consider using Clean Architecture:
- Large, complex, and evolving projects – Separation of concerns helps manage large and complex projects by making sure the core domain logic will be independent of both the UI layer and infrastructure layer and remains unchanged.Â
- Projects with large testability requirements – The layered structure and loosely coupled components make it easier to write unit tests and integration tests in different layers. This is specifically helpful for long-term projects that have very high test coverage requirements.
- Projects where multiple development teams work together – The clear separation of concerns makes it easier for multiple teams to work on different layers without much dependency on other teams.
- Projects that need to support infrastructure changes in the future with minimal impact – Since infrastructure doesn’t have a direct coupling with core business logic, we can change the infrastructure components in the future with minimal impacts to other areas.
Conclusion
In this article, we explored Clean Architecture with ASP.NET Core Web API development. We looked at its principles, different layers, and implementation. Apart from that, we discussed some advantages and potential disadvantages that can occur while using Clean Architecture. Going beyond this, we looked at scenarios where Clean Architecture is a good choice for our projects.