.NET Clean Architecture .NET 8 MVC SOLID

Clean Architecture in .NET 8: A Practical Guide

How we structure our MVC and minimal API projects for long-term maintainability.

Q

Quantums Team

March 31, 2026

12 min read

Why Architecture Matters More Than You Think

We've inherited a lot of codebases over the years. The ones that are hardest to work with all share the same trait: business logic scattered through controllers, views, and database queries with no clear boundaries between concerns. Adding a feature means touching six different files that were never meant to be related.

Clean Architecture (Robert C. Martin's name for a set of principles rooted in Separation of Concerns) gives you a systematic way to keep that from happening. After applying it to a dozen projects, here's our practical take on how to implement it in .NET 8.

The Four Layers

Clean Architecture organises code into concentric circles where inner layers know nothing about outer layers:

  • Domain — Entities, value objects, business rules. No dependencies on anything.
  • Application — Use cases (commands/queries). Depends only on Domain.
  • Infrastructure — Database, email, external APIs. Implements interfaces defined in Application.
  • Presentation — Controllers, Razor views, minimal API endpoints. Depends on Application.

Project Structure

We use a multi-project solution. Each layer is a separate class library:

MyApp.sln
├── MyApp.Domain/
│   ├── Entities/
│   ├── ValueObjects/
│   └── Interfaces/         ← IRepository<T>, IDomainEvent
├── MyApp.Application/
│   ├── Commands/           ← CreateOrderCommand, ICommandHandler
│   ├── Queries/            ← GetOrderQuery, IQueryHandler
│   └── Interfaces/         ← IEmailService, IFileStorage
├── MyApp.Infrastructure/
│   ├── Persistence/        ← EF Core DbContext, repositories
│   ├── Email/              ← EmailService (implements IEmailService)
│   └── DependencyInjection.cs
└── MyApp.Web/              ← ASP.NET Core MVC or minimal API
    ├── Controllers/
    └── Program.cs

The Repository Pattern

Repositories abstract data access so your Application layer never references EF Core directly:

// Domain layer
public interface IOrderRepository {
    Task<Order?> GetByIdAsync(int id);
    Task<IReadOnlyList<Order>> GetByCustomerAsync(int customerId);
    Task AddAsync(Order order);
    Task SaveChangesAsync();
}

// Infrastructure layer
public class EfOrderRepository(AppDbContext db) : IOrderRepository {
    public Task<Order?> GetByIdAsync(int id) =>
        db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id);
    // ...
}

Commands and Queries (CQRS Lite)

We don't use full CQRS with separate read/write databases, but we do separate commands (state changes) from queries (reads). This keeps handlers focused and testable:

// Application layer
public record CreateOrderCommand(int CustomerId, List<OrderItemDto> Items);

public class CreateOrderHandler(IOrderRepository orders, IEmailService email)
{
    public async Task<int> HandleAsync(CreateOrderCommand cmd)
    {
        var order = Order.Create(cmd.CustomerId, cmd.Items);
        await orders.AddAsync(order);
        await orders.SaveChangesAsync();
        await email.SendOrderConfirmationAsync(order);
        return order.Id;
    }
}

The controller is now trivially thin:

public class OrdersController(CreateOrderHandler handler) : Controller
{
    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderCommand cmd)
    {
        var id = await handler.HandleAsync(cmd);
        return RedirectToAction("Detail", new { id });
    }
}

Testing Becomes Easy

Because the Application layer depends on interfaces, not concrete implementations, you can unit test every use case with simple mocks — no database, no SMTP server, no HTTP calls:

[Fact]
public async Task CreateOrder_SendsConfirmationEmail()
{
    var orders   = new Mock<IOrderRepository>();
    var email    = new Mock<IEmailService>();
    var handler  = new CreateOrderHandler(orders.Object, email.Object);

    await handler.HandleAsync(new CreateOrderCommand(customerId: 1, items: []));

    email.Verify(e => e.SendOrderConfirmationAsync(It.IsAny<Order>()), Times.Once);
}

When to Skip the Ceremony

Clean Architecture adds real structure overhead. For a simple CRUD admin panel or an internal tool, a direct EF Core context in the controller is often fine. We apply full Clean Architecture when: the project will be maintained for more than a year, there are multiple developers, or the business rules are complex enough to need independent testing. Pragmatism over purity.