Introduction
Clean Architecture is a popular software design pattern that enables you to write maintainable, scalable, and testable applications. Introduced by Robert C. Martin (Uncle Bob), this architecture pattern separates your application into layers, making it easier to adapt to changing requirements and technologies. In this article, we'll explore how to implement Clean Architecture in .NET 8.
Why Clean Architecture?
In large applications, code tends to become tightly coupled, making it hard to maintain. Clean Architecture helps by promoting:
- Separation of Concerns: Each layer has its own responsibility, reducing coupling.
- Testability: Core business logic can be tested independently of external systems (e.g., UI, databases).
- Maintainability: Changes in one part of the system (e.g., UI or database) don’t affect other parts.
- Flexibility: You can swap external dependencies like databases without affecting business logic.
Core Principles of Clean Architecture
Clean Architecture divides an application into layers with the following key rule: Source code dependencies should point inward, meaning the core business logic should not depend on anything external like a database or web framework.
The main layers are:
- Entities (Domain Layer): Core business logic and rules.
- Use Cases (Application Layer): Application-specific business rules.
- Interface Adapters (Adapters Layer): Adapts external input/output for the application.
- Frameworks & Drivers (Infrastructure Layer): External systems like databases, APIs, or UI frameworks.
Let’s walk through each layer with .NET 8 examples.
Layers in Clean Architecture
1. Entities (Domain Layer)
The Entities layer contains the core business logic and models. These are simple objects representing your problem domain. Entities are independent of any external technology or framework, making them reusable across different layers.
// Domain Layer
public class Order
{
public int Id { get; set; }
public string CustomerName { get; set; }
public List<OrderItem> Items { get; set; }
public decimal GetTotal() => Items.Sum(i => i.Price * i.Quantity);
}
In this example, Order
represents a business entity. It contains logic related to the business, like calculating the total price. This logic is independent of external systems like databases or UI frameworks.
2. Use Cases (Application Layer)
The Use Cases layer defines how your application behaves in specific scenarios. Use cases orchestrate interactions between entities but are independent of any external concerns like databases or APIs.
// Application Layer (Use Case)
public class CreateOrderUseCase
{
private readonly IOrderRepository _orderRepository;
public CreateOrderUseCase(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task ExecuteAsync(Order order)
{
// Business rules validation
if (order.Items.Count == 0)
throw new InvalidOperationException("Order must have at least one item.");
// Save order
await _orderRepository.SaveAsync(order);
}
}
The CreateOrderUseCase
coordinates the process of creating an order. It interacts with the domain model (Order
) and communicates with an external dependency (IOrderRepository
). However, it does not know the details of how or where the order is stored.
3. Interface Adapters (Adapters Layer)
The Interface Adapters layer translates data between the core logic and external systems like user interfaces or APIs. In .NET 8, this layer typically includes things like ASP.NET controllers or presenters.
// Interface Adapters Layer
[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
private readonly CreateOrderUseCase _createOrderUseCase;
public OrderController(CreateOrderUseCase createOrderUseCase)
{
_createOrderUseCase = createOrderUseCase;
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] OrderDto orderDto)
{
var order = new Order
{
CustomerName = orderDto.CustomerName,
Items = orderDto.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity,
Price = i.Price
}).ToList()
};
await _createOrderUseCase.ExecuteAsync(order);
return Ok();
}
}
The OrderController
adapts the incoming API request into a form that the core application logic (use case) can work with. It handles HTTP requests, processes input data (DTO), and forwards it to the use case. This layer is also responsible for sending responses back to the client.
4. Infrastructure (Frameworks & Drivers Layer)
The Infrastructure layer contains the actual implementation of external dependencies like databases, file systems, and external APIs. In .NET 8, we often use Entity Framework Core to interact with databases.
// Infrastructure Layer (Repository Implementation)
public class SqlOrderRepository : IOrderRepository
{
private readonly AppDbContext _dbContext;
public SqlOrderRepository(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task SaveAsync(Order order)
{
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();
}
}
Here, SqlOrderRepository
is responsible for persisting Order
entities to a SQL database using Entity Framework Core. It implements the IOrderRepository
interface, which is defined in the core layers, ensuring that the core application logic remains decoupled from the actual database technology.
Dependency Inversion in Clean Architecture
A key principle of Clean Architecture is the Dependency Inversion Principle (DIP). This principle ensures that higher-level modules (like the Application Layer) do not depend on lower-level modules (like database or API implementations). Instead, both depend on abstractions.
In our example, the CreateOrderUseCase
depends on the IOrderRepository
interface, not on any concrete implementation. This allows us to swap the database implementation (e.g., SQL, NoSQL) without affecting the core application logic.
Setting Up Dependency Injection in .NET 8
In .NET 8, dependency injection (DI) is used to inject dependencies into controllers, use cases, and repositories. Here's how you can register your dependencies in Program.cs
:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Register use cases
builder.Services.AddScoped<CreateOrderUseCase>();
// Register repositories
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
// Add other services
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Build app
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
This code registers the CreateOrderUseCase
and the SqlOrderRepository
as services in the DI container. It also configures the Entity Framework Core DbContext and sets up the HTTP request pipeline.
Putting It All Together
In a Clean Architecture system, each layer has a distinct responsibility, and communication happens only through well-defined interfaces. Let’s see how this all works together:
- The user sends an HTTP POST request to create an order.
- The controller receives the request and converts the input (DTO) into domain objects.
- The controller calls the use case, passing the domain objects.
- The use case performs the business logic and interacts with the repository interface.
- The repository interface is implemented by the infrastructure layer, where the order is saved to the database.
- The result is returned back to the controller, and then to the user.
Each layer remains decoupled from the other layers, allowing the system to be more maintainable and testable.
Testing the Use Cases
One of the major benefits of Clean Architecture is the ease of testing the core application logic without worrying about external dependencies. Here’s an example of how you can unit test the CreateOrderUseCase
:
// Unit Test Example
public class CreateOrderUseCaseTests
{
[Fact]
public async Task Should_ThrowException_When_OrderHasNoItems()
{
// Arrange
var orderRepository = new Mock<IOrderRepository>();
var useCase = new CreateOrderUseCase(orderRepository.Object);
var order = new Order { CustomerName = "John Doe", Items = new List<OrderItem>() };
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => useCase.ExecuteAsync(order));
}
[Fact]
public async Task Should_SaveOrder_When_OrderIsValid()
{
// Arrange
var orderRepository = new Mock<IOrderRepository>();
var useCase = new CreateOrderUseCase(orderRepository.Object);
var order = new Order
{
CustomerName = "John Doe",
Items = new List<OrderItem>
{
new OrderItem { ProductId = 1, Quantity = 2, Price = 10 }
}
};
// Act
await useCase.ExecuteAsync(order);
// Assert
orderRepository.Verify(repo => repo.SaveAsync(order), Times.Once);
}
}
Here, we mock the IOrderRepository
interface, allowing us to test the use case in isolation without interacting with a real database.
Summary
Clean Architecture in .NET 8 offers a robust structure that helps to build applications that are scalable
Read more here Clean Architecture
Top comments (0)