Back to Blog
.NETArchitectureC#

Clean Architecture in .NET: A Practical Guide

Matthew Clendening

When building enterprise .NET applications, the decisions you make about project structure in the first week can echo for years. Clean Architecture provides a proven pattern for organizing code so that business logic stays independent of frameworks, databases, and UI concerns.

In this post, I'll walk through a practical implementation using .NET 10 and share lessons learned from applying this pattern on real consulting engagements.

Why Clean Architecture?

Most .NET projects start simple: an endpoint calls a service, which calls a repository. But as features grow, these layers can blur together. Business rules leak into controllers. Database concerns creep into services. Testing becomes painful because everything depends on everything else.

Clean Architecture solves this by enforcing a dependency rule: source code dependencies can only point inward. The inner layers know nothing about the outer layers.

UI → Application → Domain
Infrastructure → Application → Domain

The Domain layer sits at the center with zero dependencies. The Application layer orchestrates use cases. Infrastructure and UI are outer-layer details that can be swapped without touching business logic.

LayerResponsibilityCan depend onExamples
DomainBusiness entities, rules, and invariantsNothingEntities, value objects, domain events, repository interfaces
ApplicationOrchestrates use cases; coordinates domain objectsDomainCommands, queries, handlers, DTOs
InfrastructureImplements technical concerns defined by inner layersApplication, DomainEF Core, external APIs, email, blob storage
WebApiAccepts requests and delegates to the application layerApplicationMinimal API endpoints, middleware, Program.cs

Project Structure

I like to start with a modular monolith: a single deployable unit organized by business capability rather than technical layer. Each module owns its domain, application logic, and infrastructure concerns internally. If a module needs to scale independently or be owned by a separate team, it can be extracted into a microservice without restructuring everything around it.

src/
├── Modules/
│   ├── Orders/
│   │   ├── Domain/
│   │   ├── Application/
│   │   └── Infrastructure/
│   ├── Products/
│   │   ├── Domain/
│   │   ├── Application/
│   │   └── Infrastructure/
│   └── Customers/
│       ├── Domain/
│       ├── Application/
│       └── Infrastructure/
├── Shared/
│   └── Kernel/          ← shared value objects, base types
└── WebApi/
    ├── Endpoints/
    ├── Middleware/
    └── Program.cs

Not every module needs all three layers — a simple reference-data module may only need Domain and Infrastructure. The layers within a module scale with its complexity.

Some teams also collapse Domain and Application into a single layer, particularly in simpler modules where the distinction feels artificial. That's a reasonable trade-off — the boundary that matters most is the outer one, keeping infrastructure and UI out of your business logic.

Modules communicate through well-defined contracts — typically events or public application interfaces — but never by reaching into another module's internals. This discipline is what makes future extraction possible. Each module is a separate .NET project (.csproj), so the compiler enforces boundaries from day one.

The Domain Layer

The domain layer contains your business entities and the interfaces that define what the outside world must provide. No NuGet packages, no framework references — just pure C#.

namespace Orders.Domain;

public class Order
{
    public Guid Id { get; private set; }
    public string CustomerEmail { get; private set; }
    public OrderStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }

    public static Order Create(string customerEmail) =>
        new()
        {
            Id = Guid.NewGuid(),
            CustomerEmail = customerEmail,
            Status = OrderStatus.Draft,
            CreatedAt = DateTime.UtcNow
        };

    public void Submit()
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException(
                "Only draft orders can be submitted.");

        Status = OrderStatus.Submitted;
    }
}

Order.Create() is a factory method that ensures every order starts in a valid state. Submit() is the business logic: it encodes the rule that only a draft order can be submitted, and it owns the status transition. This logic lives in the domain — not in a handler, not in a validator — so it's enforced wherever Submit() is called, regardless of how the request arrived.

The Application Layer

The application layer defines use cases using the CQRS pattern (Command Query Responsibility Segregation). A mediator library routes commands to their handlers — the exact interfaces vary by library (Brighter, Darker, MediatR, Wolverine, etc.) but the shape is the same:

namespace Orders.Application;

public record SubmitOrderCommand(Guid OrderId);

public class SubmitOrderHandler
{
    private readonly IOrderRepository _orders;

    public SubmitOrderHandler(IOrderRepository orders) =>
        _orders = orders;

    public async Task Handle(SubmitOrderCommand request, CancellationToken ct)
    {
        // NotFoundException and DomainException propagate to exception-handling
        // middleware, which maps them to HTTP responses — covered in a future post.
        var order = await _orders.GetByIdAsync(request.OrderId, ct)
            ?? throw new NotFoundException($"Order {request.OrderId} not found.");

        order.Submit(); // throws DomainException if not in Draft status
        await _orders.SaveAsync(ct);
    }
}

The handler's job is orchestration: fetch the order, tell it to submit. The business rule — that only a draft order can be submitted — is enforced inside Submit() on the domain entity. The handler doesn't know or care about that rule. IOrderRepository is defined here in the Application layer and implemented by Infrastructure, so the handler never references a database or any other infrastructure detail.

Exceptions thrown by the domain (DomainException) or handler (NotFoundException) are not caught here — they propagate to exception-handling middleware in the WebApi layer, which translates them into appropriate HTTP responses. This keeps error-handling logic in one place rather than repeated across every handler.

The Infrastructure Layer

Infrastructure implements the interfaces defined by inner layers. Here's where Entity Framework, external APIs, and other "details" live:

namespace Orders.Infrastructure;

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(
        Guid id, CancellationToken ct)
    {
        return await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == id, ct);
    }

    public async Task SaveAsync(CancellationToken ct) =>
        await _context.SaveChangesAsync(ct);
}

Swapping from SQL Server to PostgreSQL? Change this layer. The domain and application layers don't know or care. The same applies to the ORM itself — because IOrderRepository is defined in the Application layer, you could replace Entity Framework with Dapper, Marten, or any other data access library without touching a line of domain or application code.

Dependency Injection Wiring

Each outer layer registers its own services. This keeps Program.cs clean:

// Orders/Infrastructure/DependencyInjection.cs
namespace Orders.Infrastructure;

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

        services.AddScoped<IOrderRepository, OrderRepository>();

        return services;
    }
}

Then in Program.cs:

builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

The WebApi Layer

The WebApi layer is intentionally thin — it receives the HTTP request and dispatches a command. No business logic, no data access, no domain knowledge:

// The mediator interface name varies by library (IMediator, IMessageBus, etc.)
app.MapPost("/orders/{id}/submit", async (Guid id, IMediator mediator, CancellationToken ct) =>
{
    await mediator.Send(new SubmitOrderCommand(id), ct);
    return Results.NoContent();
});

That's it. The endpoint doesn't know what "submitting" means — it just hands off to the application layer and returns.

When to Use Clean Architecture

Clean Architecture adds upfront structure. For a quick prototype or a simple CRUD API, it's overkill. But for systems that will be maintained for years, worked on by multiple developers, or need to evolve their tech stack — the investment pays for itself.

Use it when:

  • The project will be maintained for years
  • Multiple developers will contribute
  • Business logic is non-trivial
  • You need comprehensive test coverage
  • Technology choices may change (database, cloud provider, UI framework)

Skip it when:

  • Building a quick prototype or proof of concept
  • The app is primarily CRUD with minimal business logic
  • You're the only developer and the project is short-lived

Key Takeaways

  1. Dependency rule is everything — dependencies point inward, always
  2. Domain layer has zero dependencies — pure business logic
  3. Interfaces in inner layers, implementations in outer — Dependency Inversion
  4. Each layer is a separate project — the compiler enforces boundaries
  5. Start with the domain — model your business before picking a database

Clean Architecture isn't about prescribed folders. It's about making the important parts of your system — the business rules — independent of the parts that change most often.

What's Next

This post covers the starting template — enough to get a modular monolith off the ground with the right boundaries in place. In future posts I'll go deeper into the topics that come up on every real project:

  • Shared Kernel — domain primitives like DomainException, EntityBase, and value objects that modules share without coupling to each other
  • Logging and observability — structured logging, correlation IDs, and tracing across module boundaries
  • Exception handling middleware — mapping domain and application exceptions to HTTP responses in one place
  • Publishing events — how modules communicate asynchronously without reaching into each other's internals
  • Module scaffolding — conventions and tooling for spinning up new modules consistently

If you're planning a .NET project and want to discuss whether Clean Architecture is the right fit, get in touch.

Want to Discuss This Further?

I consult on the technologies and patterns covered in this article. If you're facing similar challenges, let's connect.