Clean Architecture

Intro

Clean Architecture, popularized by Robert C Martin, organizes software so business policy is protected from technical details. The core rule is the Dependency Rule: source code dependencies point inward toward business rules, and inner layers know nothing about frameworks, databases, or UI. This matters because it keeps critical domain behavior testable and stable even when delivery technologies change. Reach for it when business logic is non-trivial and the system is expected to outlive current infrastructure choices.

Mechanism

The Dependency Rule in practice

Inward means code at the center defines policy, and code farther out implements details. A use case can depend on an IOrderRepository abstraction, but the EF Core repository implementation must depend on that abstraction, not the other way around. This reverses the typical framework first dependency chain and keeps business rules independent from storage, web, or messaging tools.

If you enforce this consistently:

Four concentric layers

graph LR
    F[Frameworks and Drivers] --> I[Interface Adapters]
    I --> U[Use Cases Application]
    U --> E[Entities]

The arrows point inward because outer circles implement details for inner policy. Inner layers can be compiled and reasoned about without referencing outer packages.

Clean Architecture vs simple Layered Architecture

Traditional layered systems often become UI to business to data access, where business logic ends up coupled to repository implementation details and ORM behavior. Clean Architecture keeps similar responsibilities but changes ownership of dependencies: domain and use cases define interfaces, outer infrastructure implements them. Interview shorthand: Layered often describes responsibility stacking, while Clean Architecture adds a strict dependency direction contract. See Layered Architecture for the baseline model this approach refines.

.NET project structure

src
  Ordering Domain
    Entities
    ValueObjects
    Exceptions
  Ordering Application
    Abstractions
    UseCases
    DTOs
  Ordering Infrastructure
    Persistence
    ExternalServices
  Ordering WebAPI
    Controllers
    Contracts
    CompositionRoot

The project references should follow this direction:

C# use case example

namespace Ordering.Application.Abstractions;

public interface IOrderRepository
{
    Task AddAsync(Order order, CancellationToken cancellationToken);
    Task<bool> ExistsByExternalIdAsync(string externalId, CancellationToken cancellationToken);
    Task SaveChangesAsync(CancellationToken cancellationToken);
}
namespace Ordering.Domain.Entities;

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

    public Guid Id { get; }
    public string ExternalId { get; }
    public string CustomerId { get; }
    public decimal TotalAmount => _lines.Sum(x => x.UnitPrice * x.Quantity);
    public IReadOnlyCollection<OrderLine> Lines => _lines;

    private Order(Guid id, string externalId, string customerId)
    {
        Id = id;
        ExternalId = externalId;
        CustomerId = customerId;
    }

    public static Order Create(Guid id, string externalId, string customerId, IEnumerable<OrderLineInput> lines)
    {
        var materialized = lines.ToList();
        if (string.IsNullOrWhiteSpace(externalId))
            throw new DomainException("External id is required");
        if (string.IsNullOrWhiteSpace(customerId))
            throw new DomainException("Customer id is required");
        if (materialized.Count == 0)
            throw new DomainException("Order must contain at least one line");

        var order = new Order(id, externalId, customerId);
        foreach (var line in materialized)
        {
            if (line.Quantity <= 0)
                throw new DomainException($"Invalid quantity for sku {line.Sku}");

            order._lines.Add(new OrderLine(line.Sku, line.Quantity, line.UnitPrice));
        }

        return order;
    }
}

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

public sealed class DomainException : Exception
{
    public DomainException(string message) : base(message) { }
}
using Ordering.Application.Abstractions;
using Ordering.Domain.Entities;

namespace Ordering.Application.UseCases;

public sealed record PlaceOrderCommand(
    string ExternalId,
    string CustomerId,
    IReadOnlyList<OrderLineInput> Lines);

public sealed class PlaceOrderUseCase
{
    private readonly IOrderRepository _orderRepository;

    public PlaceOrderUseCase(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task<Guid> ExecuteAsync(PlaceOrderCommand command, CancellationToken cancellationToken)
    {
        if (await _orderRepository.ExistsByExternalIdAsync(command.ExternalId, cancellationToken))
            throw new InvalidOperationException($"Order {command.ExternalId} already exists");

        var order = Order.Create(
            Guid.NewGuid(),
            command.ExternalId,
            command.CustomerId,
            command.Lines);

        if (order.TotalAmount > 100000m)
            throw new InvalidOperationException("Manual approval required for high value orders");

        await _orderRepository.AddAsync(order, cancellationToken);
        await _orderRepository.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}
using Microsoft.EntityFrameworkCore;
using Ordering.Application.Abstractions;
using Ordering.Domain.Entities;

namespace Ordering.Infrastructure.Persistence;

public sealed class EfOrderRepository : IOrderRepository
{
    private readonly OrderingDbContext _dbContext;

    public EfOrderRepository(OrderingDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public Task AddAsync(Order order, CancellationToken cancellationToken)
        => _dbContext.Orders.AddAsync(order, cancellationToken).AsTask();

    public Task<bool> ExistsByExternalIdAsync(string externalId, CancellationToken cancellationToken)
        => _dbContext.Orders.AnyAsync(x => x.ExternalId == externalId, cancellationToken);

    public Task SaveChangesAsync(CancellationToken cancellationToken)
        => _dbContext.SaveChangesAsync(cancellationToken);
}

The dependency arrow is the key: PlaceOrderUseCase depends on IOrderRepository, while EfOrderRepository depends on Application and Domain contracts.

Pitfalls

Over engineering simple CRUD

Framework leakage into domain and use cases

Treating clean architecture as folder naming

Premature abstraction everywhere

Tradeoffs

Criterion Clean Architecture Layered Vertical Slice
Dependency direction Strict inward rule with policy at center Usually top down layering Feature scoped dependency chains per slice
Domain protection Strong for rich business rules and invariants Moderate and often erodes over time Strong per feature if slices keep domain boundaries
Delivery speed for simple CRUD Slower due to more boundaries and wiring Fastest to start Fast for incremental feature delivery
Change isolation High for framework or database swaps Medium because data concerns often leak upward High for localized feature changes
Cognitive load Higher at first due to ports adapters and composition root Lower initial mental model Medium with many slices and duplicated patterns
Best fit Long lived systems with complex policy Small medium apps with simple behavior Product teams optimizing for independent feature flow

Decision rule: start with Layered or Vertical Slice for simple domains, and move to Clean Architecture when policy complexity and longevity make framework independence and domain protection worth the indirection cost.

Questions

References


Whats next