Clean Architecture in .NET is a way to structure a solution so that business rules sit at the center and frameworks, databases and user interfaces sit at the edges, with every source code dependency pointing inward. It suits teams building ASP.NET Core applications that must stay testable and easy to change for years, not weeks. This guide covers the four layers, the dependency rule, a concrete .NET 10 solution layout, where use cases, validation and mapping belong, a layered testing strategy, and an honest comparison with vertical slice architecture, including the over-abstraction traps that give the style a bad reputation.

What Is Clean Architecture?#

Robert C. Martin described Clean Architecture in a 2012 blog post that consolidated older ideas, most notably Alistair Cockburn's hexagonal architecture (ports and adapters) and Jeffrey Palermo's onion architecture. Microsoft's architecture guidance for ASP.NET Core treats these names as variations of one principle: put the business logic and application model at the center, and make infrastructure depend on the core instead of the other way around.

The central rule is the dependency rule: source code dependencies may only point inward. Code in an inner circle must not mention anything declared in an outer circle, whether a class, a function or a data format. The payoff is that business rules become independent of frameworks, the UI, the database and external services, and therefore testable without any of them.

In .NET the circles map naturally onto projects, and the compiler enforces project references. If Shop.Domain has no project or package references, nobody can quickly call a DbContext from an entity, because the build fails. That guarantee is far stronger than a folder convention.

Clean Architecture is a set of dependency rules, not a folder template: four projects, two projects or one project guarded by architecture tests can all satisfy it.

How Clean Architecture Works#

The four layers#

  • Domain holds enterprise business rules: entities, value objects, aggregates, domain events, domain services and domain exceptions. It references no other project and, ideally, no infrastructure packages.
  • Application holds the use cases: commands, queries and their handlers, the interfaces (ports) that use cases need from the outside world, DTOs, use case validation and transaction boundaries. It references only Domain.
  • Infrastructure holds the adapters: the EF Core DbContext, migrations, repository implementations, email gateways, message broker clients, blob storage and typed HTTP clients. It references Application so it can implement the ports.
  • Presentation is the delivery mechanism: Minimal API endpoints or controllers, Blazor components, gRPC services, request and response contracts, middleware and the composition root in Program.cs.
LayerTypical contentsMay referenceMust not reference
DomainEntities, value objects, aggregates, domain events, domain exceptionsNothing beyond the base class libraryEF Core, ASP.NET Core, any other layer
ApplicationUse case handlers, ports, DTOs, validators, result typesDomainInfrastructure, Presentation, concrete I/O libraries
InfrastructureDbContext, migrations, repositories, external service clientsApplication, DomainPresentation
PresentationEndpoints, contracts, middleware, composition rootApplication, plus Infrastructure for DI registration onlyData access that bypasses the use cases

Dependency inversion makes the arrows point inward#

Control flow and source code dependencies travel in opposite directions. At run time a request flows outward, from the endpoint to the handler to the repository to PostgreSQL. At compile time the handler only knows an IOrderRepository interface declared in Application, and the EF Core implementation in Infrastructure depends on that interface. This is the dependency inversion principle applied at architectural scale.

The composition root is the deliberate exception. As Microsoft's guidance puts it, the UI layer works with Application Core interfaces at compile time, while the implementation types must be present at run time and wired up through dependency injection. Program.cs therefore references Infrastructure, but only to call a registration method such as AddInfrastructure(). For lifetimes and registration patterns, see the dependency injection guide.

Across boundaries, pass the form most convenient for the inner layer: commands in, DTOs or result objects out, and domain objects (never an IQueryable) from repositories.

Getting Started: A Clean Architecture Solution Layout in .NET 10#

Create the projects by hand once to see the moving parts. The .NET 10 SDK creates solution files in the XML-based .slnx format by default; run dotnet new sln --format sln if your tooling still needs the classic format.

Bash
dotnet new sln -n Shop                  # creates Shop.slnx with the .NET 10 SDK
dotnet new classlib -n Shop.Domain -o src/Shop.Domain
dotnet new classlib -n Shop.Application -o src/Shop.Application
dotnet new classlib -n Shop.Infrastructure -o src/Shop.Infrastructure
dotnet new webapi -n Shop.Api -o src/Shop.Api
dotnet sln add src/*/*.csproj

# References point inward: Api -> Infrastructure -> Application -> Domain
cd src
dotnet add Shop.Application/Shop.Application.csproj reference Shop.Domain/Shop.Domain.csproj
dotnet add Shop.Infrastructure/Shop.Infrastructure.csproj reference \
  Shop.Application/Shop.Application.csproj
dotnet add Shop.Api/Shop.Api.csproj reference \
  Shop.Application/Shop.Application.csproj Shop.Infrastructure/Shop.Infrastructure.csproj

Group the Application layer by feature: folders named Commands, Queries and Validators scatter one use case across three places, while a PlaceOrder folder keeps it together.

Text
Shop.slnx
src/
  Shop.Domain/
    Orders/            Order.cs, OrderLine.cs, OrderStatus.cs
    Common/            DomainException.cs
  Shop.Application/
    Abstractions/      IOrderRepository.cs, IProductCatalog.cs, IUnitOfWork.cs
    Common/            Result.cs
    Orders/
      PlaceOrder/      PlaceOrderCommand.cs, PlaceOrderHandler.cs, PlaceOrderValidator.cs
      GetOrder/        GetOrderHandler.cs, OrderDto.cs, OrderMappings.cs
    DependencyInjection.cs
  Shop.Infrastructure/
    Persistence/       ShopDbContext.cs, OrderConfiguration.cs, OrderRepository.cs, Migrations/
    Catalog/           CatalogHttpClient.cs
    DependencyInjection.cs
  Shop.Api/
    Program.cs
tests/
  Shop.Domain.Tests/
  Shop.Application.Tests/
  Shop.Architecture.Tests/
  Shop.Api.IntegrationTests/

Two community templates are popular starting points. Jason Taylor's template (dotnet new install Clean.Architecture.Solution.Template, then dotnet new ca-sln) targets .NET 10 and adds Aspire AppHost and ServiceDefaults projects to the four layers. Steve Smith's Ardalis.CleanArchitecture.Template offers a full clean-arch template with a FastEndpoints-based Web project and a single-project min-clean variant organized by vertical slices.

The Domain Layer: Entities and Invariants#

The Domain layer holds the rules that would exist even without software: an order needs at least one line before submission, and a submitted order cannot change. These rules belong inside the entity, behind intention-revealing methods, so no caller can put the object into an invalid state.

C#
namespace Shop.Domain.Orders;

public sealed class Order
{
    private readonly List<OrderLine> _lines = [];

    private Order() { } // Used by EF Core when materializing.

    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public DateTimeOffset CreatedAt { get; private set; }
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
    public decimal Total => _lines.Sum(line => line.UnitPrice * line.Quantity);

    public static Order Create(Guid customerId, DateTimeOffset now) => new()
    {
        Id = Guid.CreateVersion7(now),
        CustomerId = customerId,
        Status = OrderStatus.Draft,
        CreatedAt = now
    };

    public void AddLine(string sku, int quantity, decimal unitPrice)
    {
        EnsureDraft();
        var existing = _lines.Find(line => line.Sku == sku);
        var newQuantity = (existing?.Quantity ?? 0) + quantity;
        if (quantity < 1 || newQuantity > 100)
            throw new DomainException("Each SKU must have between 1 and 100 units.");

        if (existing is null)
            _lines.Add(new OrderLine(sku, quantity, unitPrice));
        else
            existing.Increase(quantity);
    }

    public void Submit()
    {
        EnsureDraft();
        if (_lines.Count == 0)
            throw new DomainException("An order needs at least one line.");
        Status = OrderStatus.Submitted;
    }

    private void EnsureDraft()
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException($"Order {Id} is {Status} and can no longer change.");
    }
}

public sealed class OrderLine
{
    private OrderLine() { }

    internal OrderLine(string sku, int quantity, decimal unitPrice) =>
        (Sku, Quantity, UnitPrice) = (sku, quantity, unitPrice);

    public string Sku { get; private set; } = "";
    public int Quantity { get; private set; }
    public decimal UnitPrice { get; private set; }

    internal void Increase(int quantity) => Quantity += quantity;
}

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

public sealed class DomainException(string message) : Exception(message);

The factory receives the current time instead of reading the clock, so tests stay deterministic. Guid.CreateVersion7 (available since .NET 9) produces time-ordered identifiers that index well. The private constructor exists only for EF Core, and lines are exposed as IReadOnlyCollection<T> over a private list, so they can only change through AddLine. Value objects, aggregate boundaries and domain events are covered in the Domain-Driven Design guide.

The Application Layer: Use Cases and Handlers#

A use case represents one user intention: place an order, cancel an order, fetch an order. Each gets a request type and a handler that orchestrates: load aggregates through ports, call domain methods, persist through a unit of work and return a result. If a handler starts checking order statuses with if statements, a rule has leaked out of the domain.

The Application layer also declares the ports it needs, named in the language of the use case (IProductCatalog), not of a technology (IHttpService). The clock needs no custom interface, because TimeProvider has been part of the base class library since .NET 8 and has a fake implementation for tests.

C#
namespace Shop.Application.Orders.PlaceOrder;

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

public interface IProductCatalog
{
    Task<decimal?> FindPriceAsync(string sku, CancellationToken ct);
}

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

public sealed record PlaceOrderCommand(Guid CustomerId, IReadOnlyList<PlaceOrderLine> Lines);

public sealed record PlaceOrderLine(string Sku, int Quantity);

public sealed class PlaceOrderHandler(
    IValidator<PlaceOrderCommand> validator,
    IOrderRepository orders,
    IProductCatalog catalog,
    IUnitOfWork unitOfWork,
    TimeProvider clock)
{
    public async Task<Result<Guid>> HandleAsync(PlaceOrderCommand command, CancellationToken ct)
    {
        var validation = await validator.ValidateAsync(command, ct);
        if (!validation.IsValid)
            return Result<Guid>.Invalid(validation.ToDictionary());

        var order = Order.Create(command.CustomerId, clock.GetUtcNow());

        foreach (var line in command.Lines)
        {
            var price = await catalog.FindPriceAsync(line.Sku, ct);
            if (price is null)
                return Result<Guid>.NotFound($"Unknown SKU '{line.Sku}'.");

            order.AddLine(line.Sku, line.Quantity, price.Value);
        }

        order.Submit();
        orders.Add(order);
        await unitOfWork.SaveChangesAsync(ct);
        return Result<Guid>.Success(order.Id);
    }
}

public enum ResultStatus { Success, Invalid, NotFound, Conflict }

public sealed record Result<T>(
    ResultStatus Status, T? Value = default, string? Error = null,
    IDictionary<string, string[]>? Errors = null)
{
    public static Result<T> Success(T value) => new(ResultStatus.Success, value);
    public static Result<T> NotFound(string error) => new(ResultStatus.NotFound, Error: error);
    public static Result<T> Invalid(IDictionary<string, string[]> errors) =>
        new(ResultStatus.Invalid, Errors: errors);
}

In a real solution the interfaces live in Abstractions/ and Result<T> in Common/. The handler is a plain class with a primary constructor: you do not need a mediator library to implement Clean Architecture. A mediator becomes attractive when you want uniform pipeline behaviors for logging, validation and transactions across dozens of handlers; the trade-offs, including MediatR's 2025 move to a commercial license, are covered in the CQRS and Mediator guide.

Where Does Validation Live in Clean Architecture?#

Validation causes confusion because the word covers three kinds of rules, each with a natural home:

  1. Input validation checks the shape of a request: required fields, lengths, formats and ranges. It belongs in Presentation, so malformed requests never reach a use case. ASP.NET Core 10 added built-in Minimal API validation: call builder.Services.AddValidation() and DataAnnotations on request types, including record parameters, are enforced with a 400 ProblemDetails response. A source generator discovers types in the assembly where AddValidation is called.
  2. Use case validation checks rules that need context, such as whether a SKU exists. It belongs in Application, often as a FluentValidation validator plus lookups through ports.
  3. Domain invariants must always hold, no matter who calls. They live in the entities and are enforced unconditionally. Treat outer validation as a user-experience optimization and the domain as the last line of defense.
C#
namespace Shop.Application.Orders.PlaceOrder;

public sealed class PlaceOrderValidator : AbstractValidator<PlaceOrderCommand>
{
    public PlaceOrderValidator()
    {
        RuleFor(c => c.CustomerId).NotEmpty();
        RuleFor(c => c.Lines).NotEmpty();
        RuleForEach(c => c.Lines).ChildRules(line =>
        {
            line.RuleFor(l => l.Sku).NotEmpty().MaximumLength(32);
            line.RuleFor(l => l.Quantity).InclusiveBetween(1, 100);
        });
    }
}

public static class DependencyInjection
{
    public static IServiceCollection AddApplication(this IServiceCollection services)
    {
        services.AddValidatorsFromAssemblyContaining<PlaceOrderValidator>();
        services.AddScoped<PlaceOrderHandler>();
        services.AddScoped<GetOrderHandler>();
        return services;
    }
}

FluentValidation 12 targets .NET 8 and later. Its maintainers no longer support the FluentValidation.AspNetCore auto-validation package and recommend calling validators explicitly, as the handler does. Explicit calls are easy to find, support asynchronous rules and behave the same in endpoints, controllers and background workers.

Where Does Mapping Live?#

Each boundary owns its own translation. Presentation maps HTTP contracts to commands and results to responses. Application maps domain objects to DTOs, so entities never leak to serializers. Infrastructure maps domain objects to tables through EF Core configuration classes, so the Domain carries no ORM attributes.

C#
namespace Shop.Application.Orders.GetOrder;

public sealed record OrderDto(
    Guid Id, string Status, decimal Total, IReadOnlyList<OrderLineDto> Lines);

public sealed record OrderLineDto(string Sku, int Quantity, decimal UnitPrice);

public static class OrderMappings
{
    public static OrderDto ToDto(this Order order) => new(
        order.Id,
        order.Status.ToString(),
        order.Total,
        [.. order.Lines.Select(l => new OrderLineDto(l.Sku, l.Quantity, l.UnitPrice))]);
}

public sealed class GetOrderHandler(IOrderRepository orders)
{
    public async Task<OrderDto?> HandleAsync(Guid id, CancellationToken ct) =>
        (await orders.FindAsync(id, ct))?.ToDto();
}

Hand-written mapping is explicit, fast and compiler-checked. If mapping volume grows, Riok.Mapperly generates equivalent code at build time from a [Mapper] partial class, with no runtime reflection. AutoMapper remains an option, but its commercial licensing started with version 15 in July 2025.

For read-heavy screens, loading aggregates only to flatten them wastes work, so query handlers often project straight into DTOs. The pragmatic option, used by Jason Taylor's template, exposes an IApplicationDbContext with DbSet<T> properties from Application, which gives Application a package dependency on EF Core. The stricter option declares a read port such as IOrderReadService and implements it with EF Core or Dapper projections in Infrastructure. Pick one deliberately and apply it consistently.

The Infrastructure Layer: EF Core and External Services#

Infrastructure implements the ports. The DbContext can act as the unit of work directly, and repositories stay thin because EF Core already implements the repository and unit of work patterns internally.

C#
namespace Shop.Infrastructure.Persistence;

public sealed class ShopDbContext(DbContextOptions<ShopDbContext> options)
    : DbContext(options), IUnitOfWork
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(ShopDbContext).Assembly);
}

internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("orders");
        builder.Property(o => o.Status).HasConversion<string>().HasMaxLength(20);
        builder.OwnsMany(o => o.Lines, line =>
        {
            line.ToTable("order_lines");
            line.Property(l => l.Sku).HasMaxLength(32);
            line.Property(l => l.UnitPrice).HasPrecision(18, 2);
        });
        builder.Navigation(o => o.Lines).UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}

internal sealed class OrderRepository(ShopDbContext db) : IOrderRepository
{
    // Owned order lines are loaded automatically with their owner.
    public Task<Order?> FindAsync(Guid id, CancellationToken ct) =>
        db.Orders.SingleOrDefaultAsync(o => o.Id == id, ct);

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

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services, IConfiguration configuration)
    {
        services.AddDbContext<ShopDbContext>(options =>
            options.UseNpgsql(configuration.GetConnectionString("shop")));
        services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<ShopDbContext>());
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddHttpClient<IProductCatalog, CatalogHttpClient>(client =>
            client.BaseAddress = new Uri(configuration["Catalog:BaseUrl"]!));
        return services;
    }
}

Should you wrap EF Core in repositories at all? A repository per aggregate root with intention-revealing methods is useful: it keeps loading rules in one place and gives handlers a seam for fakes. A generic IRepository<T> with GetAll, Update and Delete re-implements DbSet<T> badly and hides projections, split queries and compiled queries. If you keep adding IQueryable escape hatches to a generic repository, delete it.

The Presentation Layer and the Composition Root#

Presentation translates between HTTP and use cases and nothing else: bind the request, map it to a command, call the handler, map the result to a status code. Authentication, rate limiting, ProblemDetails and exception translation are configured here.

C#
using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddValidation();          // .NET 10: DataAnnotations for Minimal APIs
builder.Services.AddProblemDetails();

var app = builder.Build();
app.UseExceptionHandler();

var orders = app.MapGroup("/orders").WithTags("Orders");

orders.MapPost("/", async (PlaceOrderRequest request, PlaceOrderHandler handler,
    CancellationToken ct) =>
{
    var command = new PlaceOrderCommand(
        request.CustomerId!.Value,
        [.. request.Lines!.Select(l => new PlaceOrderLine(l.Sku, l.Quantity))]);

    var result = await handler.HandleAsync(command, ct);
    return result.Status switch
    {
        ResultStatus.Success => Results.Created($"/orders/{result.Value}", result.Value),
        ResultStatus.Invalid => Results.ValidationProblem(result.Errors!),
        ResultStatus.NotFound => Results.Problem(result.Error, statusCode: 404),
        _ => Results.Problem(result.Error, statusCode: 409)
    };
});

orders.MapGet("/{id:guid}", async (Guid id, GetOrderHandler handler, CancellationToken ct) =>
    await handler.HandleAsync(id, ct) is { } order ? Results.Ok(order) : Results.NotFound());

app.Run();

public sealed record PlaceOrderRequest(
    [Required] Guid? CustomerId,
    [Required, MinLength(1)] List<PlaceOrderLineRequest>? Lines);

public sealed record PlaceOrderLineRequest(
    [Required, MaxLength(32)] string Sku,
    [Range(1, 100)] int Quantity);

The request contract and the command look alike today, but they change for different reasons: the contract when API consumers need something, the command when the use case does. Domain exceptions that slip past validation can be translated into 409 or 422 responses by an IExceptionHandler. Since ASP.NET Core 10, exceptions an IExceptionHandler reports as handled are no longer logged at error level by default, which keeps expected rule violations out of error dashboards.

Testing Strategy for Clean Architecture#

The dependency rule pays off most visibly in testing, because each layer can use the cheapest technique that gives real confidence.

Test typeWhat it coversTypical toolsSpeed
Domain unit testsInvariants and state transitionsxUnit, NUnit or MSTest with no mocksMilliseconds
Application testsHandler orchestration with fake portsHand-written fakes, FakeTimeProvider, NSubstitute or MoqMilliseconds
Architecture testsThe dependency rule itselfNetArchTest.Rules or ArchUnitNETMilliseconds
Integration testsEF Core mappings, queries, endpointsWebApplicationFactory, Testcontainers, RespawnSeconds
End-to-end testsCritical user journeysPlaywright against a test environmentMinutes

Domain tests are fast and document the business rules; architecture tests turn the dependency rule into a failing build.

C#
using NetArchTest.Rules;

public class ArchitectureTests
{
    [Fact]
    public void Domain_has_no_outward_dependencies()
    {
        var result = Types.InAssembly(typeof(Order).Assembly)
            .ShouldNot()
            .HaveDependencyOnAny("Shop.Application", "Shop.Infrastructure", "Shop.Api",
                "Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore")
            .GetResult();

        Assert.True(result.IsSuccessful);
    }

    [Fact]
    public void Application_does_not_depend_on_infrastructure()
    {
        var result = Types.InAssembly(typeof(PlaceOrderHandler).Assembly)
            .ShouldNot()
            .HaveDependencyOnAny("Shop.Infrastructure", "Shop.Api")
            .GetResult();

        Assert.True(result.IsSuccessful);
    }
}

public class OrderTests
{
    [Fact]
    public void Submitted_order_rejects_new_lines()
    {
        var order = Order.Create(Guid.NewGuid(), DateTimeOffset.UtcNow);
        order.AddLine("SKU-1", 2, 9.99m);
        order.Submit();

        Assert.Throws<DomainException>(() => order.AddLine("SKU-2", 1, 5m));
    }
}

For Infrastructure and endpoints, test against the real database engine. The EF Core documentation discourages the in-memory provider for testing and recommends coverage against your production database system, which Testcontainers makes practical. In .NET 10 a source generator makes the top-level Program class public, so WebApplicationFactory<Program> works without the public partial class Program declaration that .NET 8 and 9 projects still need.

C#
public sealed class ShopApiFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:17").Build();

    protected override void ConfigureWebHost(IWebHostBuilder builder) =>
        builder.UseSetting("ConnectionStrings:shop", _postgres.GetConnectionString());

    public async ValueTask InitializeAsync()
    {
        await _postgres.StartAsync();
        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<ShopDbContext>();
        await db.Database.MigrateAsync();
    }

    public override async ValueTask DisposeAsync()
    {
        await _postgres.DisposeAsync();
        await base.DisposeAsync();
    }
}

This factory uses the xUnit v3 shape of IAsyncLifetime, where both methods return ValueTask; xUnit v2 uses Task. The integration testing guide covers database resets with Respawn, parallelization and authentication in tests.

Clean Architecture vs Vertical Slice Architecture#

Jimmy Bogard popularized vertical slice architecture in a 2018 post, and it is the alternative .NET teams most often consider. Instead of organizing code by technical layer, each request owns everything it needs from endpoint to database. Coupling is minimized between slices and maximized within one, and each slice picks its own approach: the domain model with EF Core for one, a raw SQL query for another.

C#
namespace Shop.Api.Features.Orders;

public static class GetOrderSummary
{
    public sealed record Response(Guid Id, string Status, decimal Total, int LineCount);

    public static void Map(IEndpointRouteBuilder app) =>
        app.MapGet("/orders/{id:guid}/summary", Handle);

    private static async Task<IResult> Handle(Guid id, ShopDbContext db, CancellationToken ct)
    {
        var summary = await db.Orders
            .Where(o => o.Id == id)
            .Select(o => new Response(o.Id, o.Status.ToString(),
                o.Lines.Sum(l => l.UnitPrice * l.Quantity), o.Lines.Count))
            .SingleOrDefaultAsync(ct);

        return summary is null ? Results.NotFound() : Results.Ok(summary);
    }
}

The two styles are less opposed than online debates suggest. Clean Architecture is about the direction of dependencies; vertical slices are about the axis along which you group code. Many teams keep a rich Domain project for complex invariants and organize everything else by feature.

CriterionClean ArchitectureVertical Slice Architecture
Organizing axisTechnical layers with inward dependenciesFeatures or requests, end to end
AbstractionsPorts declared by Application, adapters in InfrastructureFew; slices use the DbContext or SQL directly
Cost of adding a featureTouches several projectsUsually one folder or file
Protection of business rulesStrong, enforced by project referencesDepends on discipline and refactoring
Swapping infrastructureLocalized to adaptersTouches every slice that uses it
Best fitComplex domains, long-lived systems, many teamsCRUD-heavy apps, fast-moving products, small teams
Main riskCeremony and indirectionDuplicated logic and inconsistent patterns

Pros and Cons of Clean Architecture#

On the plus side, business rules are isolated and fast to test, infrastructure changes stay localized, and compiler-enforced boundaries survive team turnover better than documentation. On the minus side, every feature touches several projects, single-implementation interfaces add navigation hops, and simple CRUD screens pay the same structural price as complex workflows. The layout also says nothing about module boundaries, which is why large systems often combine it with a modular monolith that has one clean core per module.

Best Practices#

  • Enforce the dependency rule twice. Project references catch most violations; architecture tests catch the rest, such as an Application type that touches HttpContext.
  • Organize the Application layer by feature. Keep each use case's command, handler, validator and DTOs in one folder.
  • Keep handlers thin and entities rich. Handlers orchestrate; entities decide.
  • Name ports after capabilities. IPaymentGateway survives a vendor change; IStripeService does not.
  • Validate in three places, each for its own reason. Input shape at the edge, use case rules in Application, invariants in the domain.
  • Inject TimeProvider and pass time into the domain. Deterministic time makes tests trivial.
  • Target .NET 10 LTS for new solutions. .NET 8 and .NET 9 both reach end of support on November 10, 2026, and .NET 11, a standard-term release now at release candidate stage, is due in November 2026.

Common Pitfalls#

Over-abstraction. An interface for every class, a generic repository over EF Core, a custom clock interface, a mapping library for five DTOs and a mediator for a dozen endpoints each look harmless; together they double the code and halve the readability. Add an abstraction when there is a second implementation, a testing need or a boundary to protect.

Anemic domain model. If entities are property bags and all logic lives in handlers, you have a transaction script with extra projects. That can suit a simple domain, but then the four-project layout pays for protection you are not using.

Leaking infrastructure inward. EF Core attributes on entities, IQueryable returned from repositories and HttpContext used in handlers all bend the dependency rule. Architecture tests catch most of these.

Sharing one model across layers. Returning entities from endpoints couples your API contract to your table design. Map at the boundaries.

Splitting projects too early. Separate Contracts, Interfaces, Common and Shared assemblies for one bounded context slow builds without adding protection.

When to Use Clean Architecture#

SituationRecommendation
Complex business rules that change oftenClean Architecture, ideally with a rich domain model
Long-lived product with several teamsClean Architecture per module, inside a modular monolith or services
Mostly CRUD over a database, small teamVertical slices or a simple layered app
Short-lived prototype or internal toolSingle project; add structure only if it survives
Many integrations likely to change vendorsClean Architecture, because ports and adapters localize the churn

Frequently Asked Questions#

Is Clean Architecture the same as onion or hexagonal architecture?#

They are close relatives built on the same idea: business logic at the center, infrastructure at the edges, dependencies pointing inward. Hexagonal architecture emphasizes ports and adapters, onion architecture emphasizes concentric layers, and Clean Architecture adds an explicit dependency rule and separates entities from use cases. In .NET solutions the practical result is nearly identical.

Should the Application layer reference EF Core?#

Strictly, no, because EF Core is an infrastructure detail. Pragmatically, many teams let Application reference the EF Core package to expose DbSet<T> through an IApplicationDbContext, which simplifies read-side projections. If you do, keep the DbContext class, configurations and migrations in Infrastructure and record the trade-off in an architecture decision record.

Do I need MediatR to implement Clean Architecture?#

No. Handlers can be plain classes registered in dependency injection and injected into endpoints. A mediator helps when you want uniform pipeline behaviors across many handlers, but it adds indirection, and MediatR has required a commercial license for larger companies since version 13 in July 2025.

How many projects should a Clean Architecture solution have?#

Four production projects (Domain, Application, Infrastructure, Presentation) is the common default. Smaller services can merge Domain and Application, and a single project with architecture tests can enforce the same rules. Add projects only when they reflect real deployment or ownership boundaries.

Can I combine Clean Architecture with vertical slices?#

Yes, and it is often the best of both. Keep a Domain project for invariants, organize Application into feature folders, and let simple read-only slices query the database directly. The dependency rule still holds, while most changes touch a single folder.

Summary#

  • Clean Architecture is defined by the dependency rule: source code dependencies point inward, toward business rules.
  • Map the circles to Domain, Application, Infrastructure and Presentation projects and let project references enforce direction.
  • Handlers orchestrate, entities enforce invariants, Infrastructure implements ports and Presentation translates HTTP.
  • Validate at three levels, map at every boundary, and test Infrastructure against real databases.
  • Watch for over-abstraction, and borrow from vertical slices when features are mostly CRUD.

Further Reading#