Clean Architecture in C#: Build Maintainable .NET Apps

Learn clean architecture in C# with a practical ASP.NET Core example. Layers, dependency rule, code samples, and best practices. Start building better apps today.

If you have ever opened a five-year-old .NET solution and found Entity Framework queries inside a controller, business rules buried in a Razor view, and a “Utilities” project that every other project references, you already understand why clean architecture in C# matters. Clean architecture is a way of organizing a .NET solution so that your business rules sit at the center, completely independent of frameworks, databases, and user interfaces. The result is an application that is easier to test, easier to change, and far less likely to collapse under its own weight as it grows.

This guide walks through the principles behind clean architecture, shows a complete project structure for an ASP.NET Core application, and provides runnable C# code for each layer. Along the way we cover the common mistakes teams make and the best practices that keep enterprise applications maintainable for years rather than months.

What Is Clean Architecture in C#?

Clean architecture was popularized by Robert C. Martin (“Uncle Bob”) in 2012, but the ideas trace back to Hexagonal Architecture (Alistair Cockburn) and Onion Architecture (Jeffrey Palermo). All three share one central concept: the Dependency Rule. Source code dependencies must point inward, toward higher-level policies. Nothing in an inner layer may know anything about an outer layer.

In a typical C# solution this translates into four concentric layers:

  • Domain – Entities, value objects, domain events, and business rules. Zero external dependencies. Not even Entity Framework.
  • Application – Use cases, commands, queries, DTOs, validation, and interfaces (ports) that the outer layers implement. Depends only on Domain.
  • Infrastructure – Concrete implementations: EF Core DbContext, repositories, email senders, file storage, third-party API clients. Depends on Application and Domain.
  • Presentation – ASP.NET Core Web API, Blazor, MVC, or a console host. Wires everything together with dependency injection. Depends on Application (and references Infrastructure only for DI registration).

The key insight is that the database is a detail. So is the web framework. Your domain model should be able to run in a unit test with no SQL Server, no HTTP pipeline, and no configuration files. When that is true, you can swap PostgreSQL for Cosmos DB, or replace a REST API with gRPC, without touching a single business rule.

Why Clean Architecture Matters for Enterprise .NET Applications

Many developers ask whether the extra projects are worth it. For a weekend prototype, probably not. For an application that a team of six will maintain for a decade, absolutely. Here is why:

  • Testability. Business logic in the Domain and Application layers has no I/O, so unit tests run in milliseconds without mocking a database.
  • Framework independence. ASP.NET Core releases a major version every year. When your controllers are thin adapters, upgrading is a small task instead of a rewrite.
  • Parallel development. One developer can build the use case against an interface while another implements the EF Core repository. Neither blocks the other.
  • Explicit boundaries. Project references enforce the Dependency Rule at compile time. A junior developer physically cannot call DbContext from the Domain project because the reference does not exist.
  • Lower cognitive load. Every feature follows the same path: request → handler → domain → repository. New team members find code where they expect it.

Clean Architecture Project Structure in .NET

Here is a solution layout that works well for ASP.NET Core 8 and later. Create it with the .NET CLI:

dotnet new sln -n OrderManagement
dotnet new classlib -n OrderManagement.Domain
dotnet new classlib -n OrderManagement.Application
dotnet new classlib -n OrderManagement.Infrastructure
dotnet new webapi  -n OrderManagement.Api
dotnet new xunit   -n OrderManagement.Application.Tests

dotnet sln add **/*.csproj

dotnet add OrderManagement.Application reference OrderManagement.Domain
dotnet add OrderManagement.Infrastructure reference OrderManagement.Application
dotnet add OrderManagement.Api reference OrderManagement.Application OrderManagement.Infrastructure
dotnet add OrderManagement.Application.Tests reference OrderManagement.Application

Notice the direction of every reference. Domain references nothing. Application references Domain. Infrastructure references Application. The API references both, but only so it can register services at startup. The resulting folder structure looks like this:

src/
  OrderManagement.Domain/
    Entities/Order.cs
    ValueObjects/Money.cs
    Exceptions/DomainException.cs
  OrderManagement.Application/
    Abstractions/IOrderRepository.cs
    Abstractions/IUnitOfWork.cs
    Orders/Commands/PlaceOrderCommand.cs
    Orders/Commands/PlaceOrderHandler.cs
    Orders/Queries/GetOrderByIdQuery.cs
  OrderManagement.Infrastructure/
    Persistence/AppDbContext.cs
    Persistence/OrderRepository.cs
    DependencyInjection.cs
  OrderManagement.Api/
    Controllers/OrdersController.cs
    Program.cs
tests/
  OrderManagement.Application.Tests/

Step 1: The Domain Layer

The Domain layer holds entities with real behavior, not anemic property bags. Business rules live here so they cannot be bypassed by any outer layer.

namespace OrderManagement.Domain.Entities;

public sealed class Order
{
    private readonly List<OrderLine> _lines = new();

    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public DateTime CreatedAtUtc { get; private set; }
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    private Order() { } // Required by EF Core

    public static Order Create(Guid customerId)
    {
        if (customerId == Guid.Empty)
            throw new DomainException("Customer id is required.");

        return new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = customerId,
            Status = OrderStatus.Draft,
            CreatedAtUtc = DateTime.UtcNow
        };
    }

    public void AddLine(Guid productId, int quantity, decimal unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Cannot modify an order that has been placed.");
        if (quantity <= 0)
            throw new DomainException("Quantity must be greater than zero.");

        _lines.Add(new OrderLine(productId, quantity, unitPrice));
    }

    public void Place()
    {
        if (_lines.Count == 0)
            throw new DomainException("An order must contain at least one line.");

        Status = OrderStatus.Placed;
    }

    public decimal Total => _lines.Sum(l => l.Quantity * l.UnitPrice);
}

public enum OrderStatus { Draft, Placed, Shipped, Cancelled }

public sealed record OrderLine(Guid ProductId, int Quantity, decimal UnitPrice);

Every invariant is enforced inside the entity. There is no way to create an order with an empty customer id or place one with no lines. This is the difference between a domain model and a database schema with getters and setters.

Step 2: The Application Layer and Use Cases

The Application layer defines what the system does. Each use case is a command or query with a dedicated handler. It also declares the interfaces it needs from the outside world, following the Dependency Inversion Principle.

namespace OrderManagement.Application.Abstractions;

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    void Add(Order order);
}

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}

Now the use case itself. This example uses MediatR, which is the most common choice in the .NET community, but a plain handler class works just as well.

namespace OrderManagement.Application.Orders.Commands;

public sealed record PlaceOrderCommand(
    Guid CustomerId,
    IReadOnlyList<PlaceOrderLineDto> Lines) : IRequest<Guid>;

public sealed record PlaceOrderLineDto(Guid ProductId, int Quantity, decimal UnitPrice);

public sealed class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Guid>
{
    private readonly IOrderRepository _orders;
    private readonly IUnitOfWork _unitOfWork;

    public PlaceOrderHandler(IOrderRepository orders, IUnitOfWork unitOfWork)
    {
        _orders = orders;
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(PlaceOrderCommand request, CancellationToken ct)
    {
        var order = Order.Create(request.CustomerId);

        foreach (var line in request.Lines)
            order.AddLine(line.ProductId, line.Quantity, line.UnitPrice);

        order.Place();

        _orders.Add(order);
        await _unitOfWork.SaveChangesAsync(ct);

        return order.Id;
    }
}

Input validation belongs here too. FluentValidation integrates cleanly through a MediatR pipeline behavior:

public sealed class PlaceOrderValidator : AbstractValidator<PlaceOrderCommand>
{
    public PlaceOrderValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Lines).NotEmpty();
        RuleForEach(x => x.Lines).ChildRules(line =>
        {
            line.RuleFor(l => l.Quantity).GreaterThan(0);
            line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0);
        });
    }
}

Step 3: The Infrastructure Layer

Infrastructure is where Entity Framework Core finally appears. It implements the interfaces declared in Application. Because the Domain entity uses private setters and a private constructor, EF Core configuration is done with the Fluent API rather than data annotations, which keeps the Domain free of persistence concerns.

namespace OrderManagement.Infrastructure.Persistence;

public sealed class AppDbContext : DbContext, IUnitOfWork
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(builder =>
        {
            builder.HasKey(o => o.Id);
            builder.Property(o => o.Status).HasConversion<string>();
            builder.OwnsMany(o => o.Lines, lines =>
            {
                lines.WithOwner().HasForeignKey("OrderId");
                lines.Property<int>("Id");
                lines.HasKey("Id");
            });
        });
    }
}

public sealed class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _db;

    public OrderRepository(AppDbContext db) => _db = db;

    public Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
        _db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);

    public void Add(Order order) => _db.Orders.Add(order);
}

Keep DI registration inside Infrastructure so the API project does not need to know about every concrete class:

namespace OrderManagement.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services, IConfiguration configuration)
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(configuration.GetConnectionString("Default")));

        services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<AppDbContext>());
        services.AddScoped<IOrderRepository, OrderRepository>();

        return services;
    }
}

Step 4: The Presentation Layer (ASP.NET Core API)

Controllers become thin. They translate HTTP into commands and commands into HTTP. No business logic, no data access.

[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
    private readonly ISender _sender;

    public OrdersController(ISender sender) => _sender = sender;

    [HttpPost]
    public async Task<IActionResult> Place(PlaceOrderCommand command, CancellationToken ct)
    {
        var id = await _sender.Send(command, ct);
        return CreatedAtAction(nameof(GetById), new { id }, new { id });
    }

    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
    {
        var order = await _sender.Send(new GetOrderByIdQuery(id), ct);
        return order is null ? NotFound() : Ok(order);
    }
}

And the composition root in Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(PlaceOrderCommand).Assembly));
builder.Services.AddValidatorsFromAssembly(typeof(PlaceOrderValidator).Assembly);
builder.Services.AddInfrastructure(builder.Configuration);

var app = builder.Build();
app.MapControllers();
app.Run();

Testing Clean Architecture in C#

Because the handler depends only on interfaces, a unit test needs no database at all:

public class PlaceOrderHandlerTests
{
    [Fact]
    public async Task Handle_WithValidLines_PlacesOrderAndSaves()
    {
        var repo = Substitute.For<IOrderRepository>();
        var uow = Substitute.For<IUnitOfWork>();
        var handler = new PlaceOrderHandler(repo, uow);

        var command = new PlaceOrderCommand(
            Guid.NewGuid(),
            new[] { new PlaceOrderLineDto(Guid.NewGuid(), 2, 19.99m) });

        var id = await handler.Handle(command, CancellationToken.None);

        Assert.NotEqual(Guid.Empty, id);
        repo.Received(1).Add(Arg.Is<Order>(o => o.Status == OrderStatus.Placed));
        await uow.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
    }

    [Fact]
    public void Place_WithNoLines_ThrowsDomainException()
    {
        var order = Order.Create(Guid.NewGuid());
        Assert.Throws<DomainException>(() => order.Place());
    }
}

The second test does not even need mocks. That is the payoff of a rich domain model: the most important rules are testable with a few lines of plain C#.

Clean Architecture Best Practices for .NET Developers

  • Keep the Domain project dependency-free. If you find yourself adding a NuGet package to Domain, stop and ask whether it belongs in Application or Infrastructure instead.
  • Return DTOs from queries, not entities. Exposing entities through the API couples your public contract to your internal model and invites over-posting attacks.
  • Separate reads from writes (CQRS-lite). Queries can use Dapper or projected EF Core queries for speed. Commands go through the domain model to enforce invariants.
  • Use a Result type for expected failures. Throwing exceptions for “order not found” is expensive and hides the control flow. Reserve exceptions for genuinely exceptional conditions.
  • Enforce the dependency rule with tests. Libraries such as NetArchTest let you assert that Domain never references Infrastructure, so the rule survives future refactoring.
  • Organize by feature inside each layer. A folder named Orders that contains commands, queries, and DTOs is easier to navigate than top-level Commands and Queries folders with hundreds of files.

Common Pitfalls and How to Avoid Them

  • Anemic domain models. If every entity is just public getters and setters and all logic lives in “service” classes, you have layered architecture with extra projects, not clean architecture. Move behavior into the entities.
  • Generic repositories that leak IQueryable. An IRepository<T> that returns IQueryable<T> lets Application code write EF-specific LINQ, which defeats the abstraction. Prefer intention-revealing methods.
  • Over-abstraction on small projects. Four projects, MediatR, FluentValidation, and AutoMapper for a three-endpoint microservice is overkill. Match the ceremony to the lifespan and team size of the application.
  • Referencing Infrastructure from Application “just this once.” The first exception is the beginning of the end. Add an architecture test and let the build fail.
  • Putting cross-cutting concerns in controllers. Logging, transactions, and validation belong in pipeline behaviors or middleware, applied once, not copied into every endpoint.

Clean Architecture vs Onion vs Hexagonal: Which Should You Use?

In practice they are the same idea drawn differently. Onion architecture uses the same concentric rings. Hexagonal architecture talks about “ports and adapters,” where a port is the interface in your Application layer and an adapter is the Infrastructure implementation. Choose the vocabulary your team already knows and focus on the Dependency Rule, because that is the part that actually protects your codebase.

Conclusion: Key Takeaways

Clean architecture in C# is not about having four projects. It is about making sure dependencies point inward so that your business rules never depend on a database, a web framework, or a third-party SDK. When you get that right, testing becomes trivial, upgrades become routine, and the codebase stays maintainable as the team and requirements grow.

  • Domain at the center with zero dependencies and real behavior in entities.
  • Application defines use cases and the interfaces it needs; Infrastructure implements them.
  • Presentation is a thin adapter that translates HTTP to commands and queries.
  • Enforce the Dependency Rule with project references and architecture tests.
  • Scale the ceremony to the project. Enterprise applications benefit most; small tools may not.

Start by extracting one use case from an existing controller into an Application handler with a repository interface. Once you see how easy it is to unit test, the rest of the migration will follow naturally.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

This article is part of the ASP.NET Core tutorials guide (APIs, security, observability and deployment).

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *