You are viewing a preview of this lesson. Sign in to start learning
Back to ASP.NET with .NET 10

Clean Architecture & Patterns

Structure applications using clean architecture principles

Clean Architecture & Patterns in ASP.NET

Master Clean Architecture and design patterns in ASP.NET with free flashcards and spaced repetition practice. This lesson covers separation of concerns, dependency injection, repository patterns, and CQRSβ€”essential concepts for building maintainable, testable, and scalable applications in .NET 10.

πŸ’» Welcome to Clean Architecture

Clean Architecture isn't just another buzzwordβ€”it's a philosophy for organizing code that has transformed how modern applications are built. When Uncle Bob Martin introduced these principles, he gave developers a blueprint for creating systems that survive the test of time, technology shifts, and team changes.

In ASP.NET with .NET 10, Clean Architecture becomes especially powerful because the framework provides excellent tooling for dependency injection, minimal APIs, and modular design. Whether you're building microservices, web APIs, or full-stack applications, these patterns will help you write code that's easier to test, modify, and understand.

πŸ’‘ Did you know? The "Clean" in Clean Architecture refers to the clarity of dependencies flowing in one directionβ€”always inward toward business logic, never outward toward implementation details.

πŸ—οΈ Core Concepts

The Dependency Rule

The fundamental principle of Clean Architecture is the Dependency Rule: source code dependencies must point only inward, toward higher-level policies. Nothing in an inner circle can know anything about an outer circle.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    🌐 Presentation Layer               β”‚
β”‚  (Controllers, Views, APIs)            β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚   β”‚  πŸ’Ό Application Layer           β”‚  β”‚
β”‚   β”‚ (Use Cases, DTOs, Interfaces)   β”‚  β”‚
β”‚   β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚  β”‚
β”‚   β”‚  β”‚  🎯 Domain Layer        β”‚   β”‚  β”‚
β”‚   β”‚  β”‚ (Entities, Value        β”‚   β”‚  β”‚
β”‚   β”‚  β”‚  Objects, Rules)        β”‚   β”‚  β”‚
β”‚   β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚  β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚    ↑                                   β”‚
β”‚    β”‚ Dependencies point INWARD         β”‚
β””β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
     β”‚
  πŸ“¦ Infrastructure Layer
  (Database, External APIs, File System)

Layer Responsibilities

Layer Responsibility Examples
Domain Business logic and rules Order, Customer, ValidationRules
Application Use cases and orchestration CreateOrderUseCase, OrderDto
Infrastructure Technical implementation SqlOrderRepository, EmailService
Presentation User interface and API OrdersController, Minimal API endpoints

Dependency Injection in .NET 10

Dependency Injection (DI) is the mechanism that makes Clean Architecture practical. Instead of classes creating their own dependencies, they receive them through constructors or properties.

// ❌ BAD: Tight coupling
public class OrderService
{
    private readonly SqlOrderRepository _repository = new();
}

// βœ… GOOD: Dependency injection
public class OrderService
{
    private readonly IOrderRepository _repository;
    
    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }
}

.NET 10 provides built-in DI through the IServiceCollection interface:

var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();

πŸ’‘ Service Lifetimes:

  • Transient: Created each time they're requested
  • Scoped: Created once per HTTP request
  • Singleton: Created once for application lifetime

The Repository Pattern

The Repository Pattern abstracts data access logic, providing a collection-like interface for accessing domain entities.

// Domain layer - interface only
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id);
    Task<IEnumerable<Order>> GetAllAsync();
    Task AddAsync(Order order);
    Task UpdateAsync(Order order);
    Task DeleteAsync(Guid id);
}

// Infrastructure layer - implementation
public class SqlOrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;
    
    public SqlOrderRepository(ApplicationDbContext context)
    {
        _context = context;
    }
    
    public async Task<Order?> GetByIdAsync(Guid id)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == id);
    }
    
    // Other methods...
}

Benefits:

  • βœ… Testability: Mock repositories in unit tests
  • βœ… Flexibility: Swap implementations (SQL β†’ MongoDB)
  • βœ… Separation: Domain doesn't know about EF Core

Unit of Work Pattern

The Unit of Work Pattern maintains a list of objects affected by a business transaction and coordinates writing changes.

public interface IUnitOfWork : IDisposable
{
    IOrderRepository Orders { get; }
    ICustomerRepository Customers { get; }
    Task<int> SaveChangesAsync();
}

public class UnitOfWork : IUnitOfWork
{
    private readonly ApplicationDbContext _context;
    
    public UnitOfWork(ApplicationDbContext context)
    {
        _context = context;
        Orders = new OrderRepository(_context);
        Customers = new CustomerRepository(_context);
    }
    
    public IOrderRepository Orders { get; }
    public ICustomerRepository Customers { get; }
    
    public async Task<int> SaveChangesAsync()
    {
        return await _context.SaveChangesAsync();
    }
    
    public void Dispose() => _context.Dispose();
}

Use case example:

public class CreateOrderUseCase
{
    private readonly IUnitOfWork _unitOfWork;
    
    public async Task<OrderDto> ExecuteAsync(CreateOrderRequest request)
    {
        var customer = await _unitOfWork.Customers
            .GetByIdAsync(request.CustomerId);
            
        var order = Order.Create(customer, request.Items);
        
        await _unitOfWork.Orders.AddAsync(order);
        await _unitOfWork.SaveChangesAsync(); // Single transaction
        
        return OrderDto.FromEntity(order);
    }
}

CQRS (Command Query Responsibility Segregation)

CQRS separates read operations (queries) from write operations (commands). This aligns perfectly with Clean Architecture principles.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚           CLIENT REQUEST                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β”‚
       β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”
       β”‚                β”‚
       β–Ό                β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ COMMAND  β”‚      β”‚  QUERY  β”‚
β”‚ (Write)  β”‚      β”‚  (Read) β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
     β”‚                 β”‚
     β–Ό                 β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Write Model β”‚   β”‚  Read Model  β”‚
β”‚ (Normalized)β”‚   β”‚ (Optimized)  β”‚
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
      β”‚                  β”‚
      β–Ό                  β–Ό
   πŸ“ DB              πŸ“Š Cache/DB

Command example:

public record CreateOrderCommand(Guid CustomerId, List<OrderItemDto> Items);

public class CreateOrderCommandHandler
{
    private readonly IOrderRepository _repository;
    
    public async Task<Guid> HandleAsync(CreateOrderCommand command)
    {
        var order = new Order
        {
            CustomerId = command.CustomerId,
            Items = command.Items.Select(i => new OrderItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity
            }).ToList()
        };
        
        await _repository.AddAsync(order);
        return order.Id;
    }
}

Query example:

public record GetOrderQuery(Guid OrderId);

public class GetOrderQueryHandler
{
    private readonly ApplicationDbContext _context;
    
    public async Task<OrderDto?> HandleAsync(GetOrderQuery query)
    {
        // Optimized read-only query
        return await _context.Orders
            .AsNoTracking()
            .Where(o => o.Id == query.OrderId)
            .Select(o => new OrderDto
            {
                Id = o.Id,
                CustomerName = o.Customer.Name,
                Total = o.Items.Sum(i => i.Price * i.Quantity)
            })
            .FirstOrDefaultAsync();
    }
}

πŸ’‘ Benefits of CQRS:

  • πŸš€ Performance: Optimize reads separately from writes
  • πŸ“ˆ Scalability: Scale read and write databases independently
  • 🎯 Simplicity: Each operation has single responsibility

🎯 Practical Examples

Example 1: Minimal API with Clean Architecture

Here's how to structure a .NET 10 minimal API following Clean Architecture:

Program.cs (Presentation Layer):

var builder = WebApplication.CreateBuilder(args);

// Register dependencies
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<CreateOrderUseCase>();
builder.Services.AddScoped<GetOrdersQueryHandler>();

var app = builder.Build();

// Commands
app.MapPost("/api/orders", async (
    CreateOrderRequest request,
    CreateOrderUseCase useCase) =>
{
    var orderId = await useCase.ExecuteAsync(request);
    return Results.Created($"/api/orders/{orderId}", orderId);
});

// Queries
app.MapGet("/api/orders/{id:guid}", async (
    Guid id,
    GetOrdersQueryHandler handler) =>
{
    var order = await handler.HandleAsync(new GetOrderQuery(id));
    return order is not null ? Results.Ok(order) : Results.NotFound();
});

app.Run();

Domain Layer:

public class Order
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public List<OrderItem> Items { get; private set; } = new();
    public DateTime CreatedAt { get; private set; }
    public OrderStatus Status { get; private set; }
    
    // Domain logic
    public static Order Create(Guid customerId, List<OrderItem> items)
    {
        if (items.Count == 0)
            throw new DomainException("Order must have at least one item");
            
        return new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = customerId,
            Items = items,
            CreatedAt = DateTime.UtcNow,
            Status = OrderStatus.Pending
        };
    }
    
    public void MarkAsShipped()
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Only pending orders can be shipped");
            
        Status = OrderStatus.Shipped;
    }
    
    public decimal GetTotal() => Items.Sum(i => i.Price * i.Quantity);
}

public enum OrderStatus { Pending, Shipped, Delivered, Cancelled }

Example 2: Testing with Clean Architecture

Clean Architecture makes testing straightforward because dependencies are injected:

public class CreateOrderUseCaseTests
{
    [Fact]
    public async Task ExecuteAsync_ValidOrder_CreatesOrder()
    {
        // Arrange
        var mockRepository = new Mock<IOrderRepository>();
        var useCase = new CreateOrderUseCase(mockRepository.Object);
        
        var request = new CreateOrderRequest
        {
            CustomerId = Guid.NewGuid(),
            Items = new List<OrderItemDto>
            {
                new() { ProductId = Guid.NewGuid(), Quantity = 2 }
            }
        };
        
        // Act
        var result = await useCase.ExecuteAsync(request);
        
        // Assert
        Assert.NotEqual(Guid.Empty, result);
        mockRepository.Verify(
            r => r.AddAsync(It.IsAny<Order>()), 
            Times.Once);
    }
    
    [Fact]
    public async Task ExecuteAsync_EmptyItems_ThrowsException()
    {
        // Arrange
        var mockRepository = new Mock<IOrderRepository>();
        var useCase = new CreateOrderUseCase(mockRepository.Object);
        
        var request = new CreateOrderRequest
        {
            CustomerId = Guid.NewGuid(),
            Items = new List<OrderItemDto>()
        };
        
        // Act & Assert
        await Assert.ThrowsAsync<DomainException>(
            () => useCase.ExecuteAsync(request));
    }
}

Example 3: MediatR for CQRS Implementation

MediatR is a popular library that simplifies CQRS implementation:

// Install: dotnet add package MediatR

// Command
public record CreateOrderCommand : IRequest<Guid>
{
    public Guid CustomerId { get; init; }
    public List<OrderItemDto> Items { get; init; } = new();
}

public class CreateOrderCommandHandler 
    : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly IOrderRepository _repository;
    
    public CreateOrderCommandHandler(IOrderRepository repository)
    {
        _repository = repository;
    }
    
    public async Task<Guid> Handle(
        CreateOrderCommand request, 
        CancellationToken cancellationToken)
    {
        var order = Order.Create(request.CustomerId, request.Items);
        await _repository.AddAsync(order);
        return order.Id;
    }
}

// Query
public record GetOrderQuery(Guid OrderId) : IRequest<OrderDto?>;

public class GetOrderQueryHandler 
    : IRequestHandler<GetOrderQuery, OrderDto?>
{
    private readonly ApplicationDbContext _context;
    
    public async Task<OrderDto?> Handle(
        GetOrderQuery request,
        CancellationToken cancellationToken)
    {
        return await _context.Orders
            .Where(o => o.Id == request.OrderId)
            .Select(o => new OrderDto
            {
                Id = o.Id,
                Total = o.Items.Sum(i => i.Price * i.Quantity)
            })
            .FirstOrDefaultAsync(cancellationToken);
    }
}

// Usage in controller
app.MapPost("/api/orders", async (
    CreateOrderCommand command,
    IMediator mediator) =>
{
    var orderId = await mediator.Send(command);
    return Results.Created($"/api/orders/{orderId}", orderId);
});

Registration:

builder.Services.AddMediatR(cfg => 
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

Example 4: Vertical Slice Architecture Alternative

Vertical Slice Architecture is a modern alternative that organizes code by feature rather than layer:

// Instead of layers, organize by feature:
// Features/
//   Orders/
//     Create/
//       CreateOrderEndpoint.cs
//       CreateOrderCommand.cs
//       CreateOrderHandler.cs
//       CreateOrderValidator.cs
//     GetById/
//       GetOrderEndpoint.cs
//       GetOrderQuery.cs
//       GetOrderHandler.cs

// Features/Orders/Create/CreateOrderEndpoint.cs
public class CreateOrderEndpoint : IEndpoint
{
    public static void Map(IEndpointRouteBuilder app)
    {
        app.MapPost("/api/orders", HandleAsync)
            .WithName("CreateOrder")
            .WithTags("Orders");
    }
    
    private static async Task<IResult> HandleAsync(
        CreateOrderCommand command,
        IMediator mediator)
    {
        var result = await mediator.Send(command);
        return Results.Created($"/api/orders/{result}", result);
    }
}

// Features/Orders/Create/CreateOrderHandler.cs
public class CreateOrderHandler 
    : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly ApplicationDbContext _context;
    
    public async Task<Guid> Handle(
        CreateOrderCommand request,
        CancellationToken cancellationToken)
    {
        var order = new Order
        {
            CustomerId = request.CustomerId,
            Items = request.Items
        };
        
        _context.Orders.Add(order);
        await _context.SaveChangesAsync(cancellationToken);
        
        return order.Id;
    }
}

πŸ’‘ Vertical Slice Benefits:

  • 🎯 High cohesion: Related code stays together
  • πŸš€ Faster navigation: Find everything for a feature in one place
  • πŸ”§ Independent changes: Modify one slice without affecting others

⚠️ Common Mistakes

1. Leaking Infrastructure into Domain

❌ Wrong:

// Domain entity depending on EF Core
public class Order
{
    [Key]
    public Guid Id { get; set; }
    
    [Required]
    public string CustomerName { get; set; }
}

βœ… Right:

// Pure domain entity
public class Order
{
    public Guid Id { get; private set; }
    public string CustomerName { get; private set; }
    
    // Domain logic without infrastructure concerns
}

// Separate configuration in Infrastructure
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);
        builder.Property(o => o.CustomerName).IsRequired();
    }
}

2. Anemic Domain Model

❌ Wrong:

public class Order
{
    public Guid Id { get; set; }
    public decimal Total { get; set; }
    public OrderStatus Status { get; set; }
}

// Business logic in service layer
public class OrderService
{
    public void ShipOrder(Order order)
    {
        if (order.Status != OrderStatus.Pending)
            throw new Exception("Cannot ship");
        order.Status = OrderStatus.Shipped;
    }
}

βœ… Right:

public class Order
{
    public Guid Id { get; private set; }
    public decimal Total { get; private set; }
    public OrderStatus Status { get; private set; }
    
    // Business logic in domain
    public void Ship()
    {
        if (Status != OrderStatus.Pending)
            throw new DomainException("Only pending orders can ship");
        Status = OrderStatus.Shipped;
    }
}

3. Over-engineering Simple Applications

⚠️ Not every application needs full Clean Architecture! For simple CRUD apps, it might be overkill.

When to use Clean Architecture:

  • βœ… Complex business logic
  • βœ… Multiple data sources
  • βœ… Long-term maintenance expected
  • βœ… Large team collaboration

When simpler patterns suffice:

  • βœ… Simple CRUD operations
  • βœ… Prototypes or MVPs
  • βœ… Small team or solo developer
  • βœ… Short-lived applications

4. Not Using Interfaces Properly

❌ Wrong:

// Interface mirrors implementation exactly
public interface IOrderRepository
{
    Task<Order> GetByIdFromSqlAsync(Guid id);
    Task SaveToSqlAsync(Order order);
}

βœ… Right:

// Technology-agnostic interface
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id);
    Task SaveAsync(Order order);
}

5. Ignoring Transaction Boundaries

❌ Wrong:

public async Task CreateOrderAsync(CreateOrderRequest request)
{
    await _orderRepository.AddAsync(order);
    // If this fails, order is still saved!
    await _inventoryRepository.ReserveItemsAsync(order.Items);
}

βœ… Right:

public async Task CreateOrderAsync(CreateOrderRequest request)
{
    using var transaction = await _context.Database.BeginTransactionAsync();
    try
    {
        await _orderRepository.AddAsync(order);
        await _inventoryRepository.ReserveItemsAsync(order.Items);
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

🧠 Key Takeaways

πŸ“‹ Clean Architecture Quick Reference

Principle Implementation
Dependency Rule Dependencies point inward toward domain
Separation of Concerns Each layer has single responsibility
Dependency Injection Use IServiceCollection, constructor injection
Repository Pattern Abstract data access behind interfaces
CQRS Separate commands (write) from queries (read)
Domain Logic Keep business rules in domain entities

πŸ”‘ Remember the mnemonic: "SOLID CLEAN"

  • Separation of concerns
  • Open/Closed principle
  • Liskov substitution
  • Interface segregation
  • Dependency inversion
  • Commands and queries separated
  • Layers with clear boundaries
  • Entities with business logic
  • Abstract infrastructure details
  • No circular dependencies

Essential patterns to master:

  1. Repository Pattern: Abstract data access
  2. Unit of Work: Coordinate multiple repositories
  3. CQRS: Separate reads and writes
  4. Dependency Injection: Invert control flow
  5. MediatR: Simplify request/response handling

Project structure template:

YourProject/
β”œβ”€β”€ Domain/
β”‚   β”œβ”€β”€ Entities/
β”‚   β”œβ”€β”€ ValueObjects/
β”‚   β”œβ”€β”€ Interfaces/
β”‚   └── Exceptions/
β”œβ”€β”€ Application/
β”‚   β”œβ”€β”€ Commands/
β”‚   β”œβ”€β”€ Queries/
β”‚   β”œβ”€β”€ DTOs/
β”‚   └── Interfaces/
β”œβ”€β”€ Infrastructure/
β”‚   β”œβ”€β”€ Persistence/
β”‚   β”œβ”€β”€ Repositories/
β”‚   └── Services/
└── Presentation/
    β”œβ”€β”€ Controllers/
    └── Endpoints/

πŸ“š Further Study