1. The Problem Context β€” When Layering Becomes a Cargo Cult

Open ten random .NET solutions and seven of them have folders called Domain, Application, Infrastructure, and Web (or API). Three of those seven actually benefit from the structure. The other four copied a template, ended up with anaemic domain models, an Application layer that does nothing but pass DTOs through, and a hidden coupling to EF Core via β€œrepository” interfaces that exist only to satisfy the dependency rule on paper.

πŸ€” Sound familiar?
Clean Architecture isn't a project template; it's a constraint on the direction of source-code dependencies. Adopt it without understanding the constraint, and you get ceremony with no payoff. Adopt it for the wrong size of system and the abstraction tax outweighs the benefit. The trick is knowing which problem you actually have.

This article is about treating Clean Architecture as a tool, not a religion: when its rules earn their keep, when Vertical Slice Architecture is a better fit, and how to wire the layers in modern .NET without inventing interfaces nobody needs.

2. The Concept Explanation β€” One Rule, Four Rings

Robert Martin's Clean Architecture (and its predecessors β€” Hexagonal, Onion, Ports-and-Adapters) all distil to The Dependency Rule: source-code dependencies point inward. Inner layers know nothing of outer layers. Inner layers express what they need from the outside via interfaces; outer layers implement them.

flowchart TB
  subgraph "Outer: Web/Hosts (Composition Root)"
    W[ASP.NET Core Endpoints]
    H[BackgroundService Hosts]
  end
  subgraph "Outer: Infrastructure"
    I1[EF Core Repositories]
    I2[HTTP Clients]
    I3[Message Bus Adapters]
    I4[File / Blob storage]
  end
  subgraph "Application"
    U[Use cases / Handlers]
    P[Port interfaces: IOrderRepository, IClock, IEventBus]
  end
  subgraph "Domain"
    D[Entities, Value Objects, Domain Services]
  end

  W --> U
  H --> U
  U --> D
  U --> P
  I1 -. implements .-> P
  I2 -. implements .-> P
  I3 -. implements .-> P
  I4 -. implements .-> P

The four rings, mapped to .NET projects:

  • Domain β€” entities, value objects, domain services, domain events. Depends on nothing but the BCL. No EF Core attributes, no JSON attributes, no DI.
  • Application β€” use-case handlers, command/query records, port interfaces the handlers depend on (IOrderRepository, IClock, IEmailSender). Depends on Domain only.
  • Infrastructure β€” EF Core DbContext, repository implementations, HTTP clients, message bus adapters. Depends on Application (to implement ports) and Domain.
  • Hosts (Web API, Worker, CLI) β€” the composition root. Wires everything via DI. Depends on all of the above.

That's the entire pattern. Everything else β€” MediatR, AutoMapper, generic repositories β€” is optional and frequently harmful.

3. The Implementation β€” A Pragmatic .NET Solution

3.1 The solution layout

src/
  Acme.Orders.Domain/            // class library, no deps
  Acme.Orders.Application/       // ref Domain
  Acme.Orders.Infrastructure/    // ref Application + EF Core + provider packages
  Acme.Orders.Api/               // ref Application + Infrastructure (composition root)
  Acme.Orders.Worker/            // ref Application + Infrastructure (background jobs)

tests/
  Acme.Orders.Domain.Tests/
  Acme.Orders.Application.Tests/
  Acme.Orders.Infrastructure.Tests/  // Testcontainers + real DB
  Acme.Orders.Api.Tests/             // WebApplicationFactory

The dependency rule is enforced by project references. If Domain.csproj ever gains a reference to anything outside the BCL, fail the build. ArchUnitNET or NetArchTest can guard this with a single test.

[Fact]
public void Domain_does_not_reference_Infrastructure() =>
    Types.InAssembly(typeof(Order).Assembly)
        .ShouldNot()
        .HaveDependencyOn("Acme.Orders.Infrastructure")
        .GetResult().IsSuccessful.Should().BeTrue();

3.2 The domain β€” pure, attribute-free, testable

namespace Acme.Orders.Domain;

public sealed class Order
{
    public OrderId Id { get; }
    public CustomerId CustomerId { get; }
    public OrderStatus Status { get; private set; }
    private readonly List<OrderLine> _lines = [];
    public IReadOnlyList<OrderLine> Lines => _lines;

    private Order(OrderId id, CustomerId customer)
    {
        Id = id; CustomerId = customer; Status = OrderStatus.Open;
    }

    public static Order Place(CustomerId customer, IEnumerable<OrderLine> lines, TimeProvider clock)
    {
        var order = new Order(OrderId.New(), customer);
        foreach (var l in lines) order.AddLine(l);
        if (order._lines.Count == 0)
            throw new DomainException("An order must have at least one line.");
        return order;
    }

    public void Cancel() =>
        Status = Status switch
        {
            OrderStatus.Open      => OrderStatus.Cancelled,
            OrderStatus.Shipped   => throw new DomainException("Cannot cancel a shipped order."),
            OrderStatus.Cancelled => OrderStatus.Cancelled,
            _ => throw new DomainException($"Unknown status: {Status}")
        };

    private void AddLine(OrderLine line) => _lines.Add(line);
}

3.3 Application layer β€” handlers + ports

namespace Acme.Orders.Application.Orders.Place;

public sealed record PlaceOrderCommand(CustomerId Customer, IReadOnlyList<OrderLine> Lines);

public interface IOrderRepository
{
    Task AddAsync(Order order, CancellationToken ct);
    Task SaveChangesAsync(CancellationToken ct);
}

public sealed class PlaceOrderHandler(IOrderRepository repo, TimeProvider clock)
{
    public async Task<OrderId> HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
    {
        var order = Order.Place(cmd.Customer, cmd.Lines, clock);
        await repo.AddAsync(order, ct);
        await repo.SaveChangesAsync(ct);
        return order.Id;
    }
}

3.4 Infrastructure β€” implements the ports

namespace Acme.Orders.Infrastructure.Persistence;

internal sealed class OrderRepository(AppDb db) : IOrderRepository
{
    public Task AddAsync(Order order, CancellationToken ct) =>
        db.Orders.AddAsync(order, ct).AsTask();

    public Task SaveChangesAsync(CancellationToken ct) =>
        db.SaveChangesAsync(ct);
}

public static class InfrastructureModule
{
    public static IServiceCollection AddInfrastructure(this IServiceCollection s, string cs) =>
        s.AddDbContext<AppDb>(o => o.UseSqlServer(cs))
         .AddScoped<IOrderRepository, OrderRepository>()
         .AddSingleton(TimeProvider.System);
}

3.5 Composition root β€” the Web project

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddInfrastructure(builder.Configuration.GetConnectionString("Sql")!)
    .AddScoped<PlaceOrderHandler>()
    .AddOpenApi();

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

app.MapPost("/orders", async (PlaceOrderCommand cmd, PlaceOrderHandler h, CancellationToken ct) =>
    Results.Created($"/orders/{await h.HandleAsync(cmd, ct)}", null));

app.Run();

4. When Clean Architecture Is the Wrong Choice

  • The CRUD admin tool. If 80% of your endpoints are list/get/create/update on a few tables, the dependency-rule ceremony costs more than it saves. A two-project solution (Web + Tests) with EF Core directly in handlers is honest and fast.
  • The microservice with one bounded context. If the service owns one aggregate and a handful of use cases, Vertical Slice Architecture (one folder per feature, no cross-project layering) is faster to navigate and refactor.
  • You don't have a domain. Some services areintegration glue β€” a webhook receiver that translates a JSON shape into a Service Bus message has no business logic. Don't invent a domain layer to hold zero rules.

5. Pitfalls Inside the Pattern

  • Anaemic domain.Entities with public setters, all logic in handlers. You've built a procedural app dressed up in OO clothing. Push behaviour into the aggregate; if you can't, the abstraction isn't earning its keep.
  • Ports for everything. Don't wrap HttpClient behind IHttpService just to satisfy the rule. Wrap it when the application has a domain concept (IInvoiceFetcher) the HTTP call serves.
  • EF Core attributes on domain entities. [Required], [Column], [Key] on a domain entity reverses the dependency. Use Fluent API in the Infrastructure layer to keep the domain pure.
  • AutoMapper between every layer. Three mapping passes for one DTO turns a stack trace into archaeology. Map at the boundary you actually need (typically only at the API β†’ DTO edge), and prefer hand-written records.
  • One project per layer per service in a small monorepo. Five microservices Γ— four projects = twenty .csproj files for a team of four. Collapse layers into folders inside one project until the size justifies splitting.
  • Cross-context calls through interfaces. If BillingService depends on IInventoryServicedefined in a shared library, you don't have microservices β€” you have a distributed monolith. Cross boundaries through messages or HTTP, not in-process interfaces.

6. Practical Takeaways

  • The pattern is one rule: dependencies point inward. Everything else is a tactic, not a law.
  • Use it when you have real domain logic that needs protection from infrastructure churn. Skip it for CRUD and integration services.
  • Keep the Domain free of attributes, DI, and frameworks. Enforce it with NetArchTest.
  • The Application layer is for orchestration, not business rules. Rules belong in the aggregate.
  • Vertical Slice Architecture is a perfectly respectable alternative β€” and they compose: Clean Architecture β€œin the large”, vertical slices β€œin the small”.
  • Project count is a tax. Pay it only when the design pressure justifies the overhead.