Clean Architecture in C#: Practical Guide for .NET
Matteo Migliore

Matteo Migliore is an entrepreneur and software architect with over 25 years of experience developing .NET-based solutions and evolving enterprise-grade application architectures.

He has led enterprise projects, trained hundreds of developers, and helped companies of all sizes simplify complexity by turning software into profit for their business.

Clean Architecture in C# is one of the most cited and least understood topics in the .NET world. Every conference mentions it, every senior article references it as a standard, but real-world projects often present distorted versions that keep the complexity without the benefits. Too many abstractions in the wrong place, useless interfaces, 200-line handlers with business logic that does not belong there.

There are two opposite and equally harmful mistakes. The first: ignoring architecture entirely, ending up with 500-line controllers that call the database directly, business logic embedded in DTOs, and unit tests impossible without spinning up the entire stack. The second: applying Clean Architecture mechanically, creating layer upon layer for every application regardless of complexity, turning a simple CRUD into a system with twelve projects, twenty interfaces, and zero real benefits.

This article shows how to apply it pragmatically. The concrete structure of a real .NET project, the exact boundary between layers with working code, how to integrate CQRS with MediatR, how to test each layer in isolation, and when it makes sense and when it is overkill. Without dogma and with examples from systems in production.

If you are a .NET developer who wants to structure projects that hold up at scale and over time, this article is written for you. If you are an architect who needs to convince the team to adopt a more solid architecture, you will also find the arguments to do so here.

The Fundamental Rule of Clean Architecture: Dependencies Point Inward

All of Clean Architecture reduces to one rule, expressed as simply as possible: code in inner layers does not depend on code in outer layers. The domain does not know Entity Framework. Application logic does not know ASP.NET. Infrastructure details depend on the core, not the other way around.

Why is this rule so important? Because it breaks the most common problem in enterprise systems that grow over time: business logic that progressively entangles with implementation details until it becomes impossible to test, modify, or understand. When Entity Framework changes version, you do not want to touch your business rules. When you switch from REST to gRPC, you do not want domain logic to be rewritten.

When your domain model imports Microsoft.EntityFrameworkCore, you have already lost. You are writing business logic that you cannot test without a real database.

Robert C. Martin's concentric layer model is the clearest visualization of this rule. At the center are Entities (the pure domain model), then Use Cases (application logic), then Interface Adapters (controllers, presenters, gateways), and on the outside Frameworks and Drivers (database, UI, web framework). All dependency arrows point toward the center.

The practical realization of this rule in .NET is Dependency Inversion: inner layers declare interfaces that outer layers implement. The Application layer does not directly use SqlOrdersRepository: it uses IOrdersRepository, an interface that lives in Application and that Infrastructure implements. This is how the center "commands" without knowing the details.

The Concrete Solution Structure: Four Projects with Explicit Dependencies

The clearest way to implement Clean Architecture in .NET is to divide the solution into four projects with explicit dependencies declared in the .csproj files. It is not the only way to structure it, but it is the most readable and easiest to maintain over time.

MyApp.sln
├── MyApp.Domain/           → no NuGet dependencies, zero external references
├── MyApp.Application/      → depends only on Domain
├── MyApp.Infrastructure/   → depends on Application (and indirectly Domain)
└── MyApp.Api/              → depends on Application and Infrastructure (only for DI)

The dependency between projects is declared in .csproj files and verified by the compiler: if Infrastructure tries to depend directly on Api, the project does not compile. It is not a convention you can forget to respect: it is a structural constraint of the build system.

Internal Folder Structure of Each Project

MyApp.Domain/
├── Entities/               → Order.cs, Customer.cs, Product.cs
├── ValueObjects/           → Price.cs, ShippingAddress.cs
├── Enums/                  → OrderStatus.cs, CustomerType.cs
├── Exceptions/             → OrderNotModifiableException.cs
└── Events/                 → OrderConfirmed.cs (domain events)

MyApp.Application/
├── Orders/
│   ├── Commands/           → ConfirmOrderCommand.cs + Handler
│   ├── Queries/            → GetOrderByIdQuery.cs + Handler
│   └── DTOs/               → OrderDto.cs, OrderLineDto.cs
├── Interfaces/             → IOrdersRepository.cs, INotificationService.cs
└── Behaviors/              → ValidationBehavior.cs, LoggingBehavior.cs

MyApp.Infrastructure/
├── Persistence/
│   ├── AppDbContext.cs
│   ├── Configurations/     → OrderConfiguration.cs (Fluent API)
│   └── Repositories/       → OrdersRepository.cs
├── Notifications/          → EmailNotificationService.cs
└── DependencyInjection.cs  → extension method to register services

MyApp.Api/
├── Endpoints/              → OrdersEndpoints.cs
├── Program.cs
└── appsettings.json

The Domain Layer: Entities, Value Objects, and Pure Logic Without Dependencies

The Domain project is the heart of the application. It contains business entities, value objects, domain exceptions, domain enums, and domain events. No NuGet dependencies. No framework references. If someone adds an external dependency to the Domain project, it is an architectural violation that must be discussed.

The most important characteristic of domain entities is that they contain behavior, not just data. An entity that only exposes getters and setters and delegates all logic to services is an Anemic Domain Model: technically it does not violate Clean Architecture, but it loses one of its main benefits: the ability to reason about domain behavior in isolation.

// MyApp.Domain/Entities/Order.cs
public class Order
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();

    private readonly List<OrderLine> _lines = new();
    private readonly List<IDomainEvent> _domainEvents = new();

    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    private Order() { } // For EF Core (configured in Infrastructure)

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

    public void AddLine(Guid productId, int quantity, decimal unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new OrderNotModifiableException(Id, Status);

        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive", nameof(quantity));

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

    public void Confirm()
    {
        if (!_lines.Any())
            throw new EmptyOrderException(Id);

        if (Status != OrderStatus.Draft)
            throw new OrderNotModifiableException(Id, Status);

        Status = OrderStatus.Confirmed;
        _domainEvents.Add(new OrderConfirmed(Id, CustomerId, _lines.Sum(l => l.Total)));
    }

    public decimal OrderTotal => _lines.Sum(l => l.Total);
}

Value Objects: When Identity Does Not Matter

// MyApp.Domain/ValueObjects/Price.cs
public record Price
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Price(decimal amount, string currency)
    {
        if (amount < 0)
            throw new ArgumentException("Price cannot be negative", nameof(amount));
        if (string.IsNullOrEmpty(currency))
            throw new ArgumentException("Currency is required", nameof(currency));

        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }

    public Price ApplyDiscount(decimal discountPercentage)
    {
        if (discountPercentage is < 0 or > 100)
            throw new ArgumentOutOfRangeException(nameof(discountPercentage));

        return new Price(Amount * (1 - discountPercentage / 100), Currency);
    }
}

Using record in C# for Value Objects is almost always the right choice: semantic equality is implemented automatically, immutability is natural, and the code is more concise.

The Application Layer: Use Cases as Orchestrators with CQRS and MediatR

The Application layer contains the application's use cases: the operations the system exposes to the outside world. It does not contain business logic (that is in the Domain), and it does not contain infrastructure details (those are in Infrastructure). It contains orchestration: load the domain, call domain operations, save, notify.

The CQRS pattern integrates naturally in this layer. Write operations become Commands, read operations become Queries, each with their own handler. MediatR is the de facto library for implementing this pattern in .NET: it handles handler registration, pipeline behaviors, and notification publishing.

Command and Handler for Write Operations

// MyApp.Application/Orders/Commands/ConfirmOrderCommand.cs
public record ConfirmOrderCommand(Guid OrderId, Guid OperatorId)
    : IRequest<ConfirmOrderResult>;

public record ConfirmOrderResult(Guid OrderId, OrderStatus Status, DateTime ConfirmedAt);

// MyApp.Application/Orders/Commands/ConfirmOrderHandler.cs
public class ConfirmOrderHandler
    : IRequestHandler<ConfirmOrderCommand, ConfirmOrderResult>
{
    private readonly IOrdersRepository _repository;
    private readonly INotificationService _notifications;

    public ConfirmOrderHandler(
        IOrdersRepository repository,
        INotificationService notifications)
    {
        _repository = repository;
        _notifications = notifications;
    }

    public async Task<ConfirmOrderResult> Handle(
        ConfirmOrderCommand command,
        CancellationToken ct)
    {
        var order = await _repository.GetByIdAsync(command.OrderId, ct)
            ?? throw new OrderNotFoundException(command.OrderId);

        order.Confirm(); // Business logic lives in the entity, not here

        await _repository.SaveAsync(order, ct);
        await _notifications.SendConfirmationAsync(order.CustomerId, order.Id, ct);

        return new ConfirmOrderResult(order.Id, order.Status, DateTime.UtcNow);
    }
}

Query and Handler for Read Operations

Queries often should not go through the domain model: loading a complete aggregate just to project part of it is inefficient. For queries, you can access the database directly with optimized queries, returning DTOs without going through entities.

// MyApp.Application/Orders/Queries/GetOrderByIdQuery.cs
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;

// MyApp.Application/Orders/Queries/GetOrderByIdHandler.cs
public class GetOrderByIdHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
    private readonly IOrdersReadRepository _readRepository;

    public GetOrderByIdHandler(IOrdersReadRepository readRepository)
        => _readRepository = readRepository;

    public async Task<OrderDto?> Handle(
        GetOrderByIdQuery query,
        CancellationToken ct)
        => await _readRepository.GetDtoByIdAsync(query.OrderId, ct);
}

Pipeline Behavior: Cross-Cutting Concerns Without Boilerplate

// MyApp.Application/Behaviors/ValidationBehavior.cs
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
        => _validators = validators;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);
        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(r => r.Errors)
            .Where(e => e != null)
            .ToList();

        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

The Infrastructure Layer: Concrete EF Core, Email, and Messaging Implementations

Infrastructure implements the interfaces defined in Application with the specific chosen technologies: Entity Framework Core, Dapper for optimized read queries, SendGrid for emails, Azure Service Bus for asynchronous messaging. This is the layer where everything the domain must not know is configured.

The fundamental rule: Infrastructure depends on Application (to implement the interfaces), but Application does not depend on Infrastructure. In Program.cs the two connect through Dependency Injection.

// MyApp.Infrastructure/Persistence/Repositories/OrdersRepository.cs
public class OrdersRepository : IOrdersRepository
{
    private readonly AppDbContext _context;

    public OrdersRepository(AppDbContext context) => _context = context;

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

    public async Task SaveAsync(Order order, CancellationToken ct)
    {
        _context.Update(order);
        await _context.SaveChangesAsync(ct);
    }
}

Here Entity Framework Core is free to do whatever it wants: mapping attributes, Fluent API configurations, optimized queries with AsNoTracking, lazy or eager loading depending on the case. The domain model knows nothing about it. If one day you decide to switch from EF to Dapper or to a different database, only this layer is rewritten, not the domain.

Fluent API Configuration Separate from the Domain Model

// MyApp.Infrastructure/Persistence/Configurations/OrderConfiguration.cs
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);

        builder.Property(o => o.Status)
            .HasConversion<string>()
            .HasMaxLength(50);

        builder.OwnsMany(o => o.Lines, line =>
        {
            line.WithOwner();
            line.Property(l => l.UnitPrice).HasPrecision(18, 2);
        });

        builder.Metadata
            .FindNavigation(nameof(Order.Lines))!
            .SetPropertyAccessMode(PropertyAccessMode.Field);
    }
}

Read-Optimized Queries with Dapper

For read operations that should not go through the domain model (dashboards, reports, paginated lists), Dapper enables optimized SQL queries with direct DTO mapping:

// MyApp.Infrastructure/Persistence/Repositories/OrdersReadRepository.cs
public class OrdersReadRepository : IOrdersReadRepository
{
    private readonly IDbConnection _connection;

    public OrdersReadRepository(IDbConnection connection)
        => _connection = connection;

    public async Task<OrderDto?> GetDtoByIdAsync(Guid id, CancellationToken ct)
    {
        const string sql = @"
            SELECT o.Id, o.CustomerId, o.Status, o.CreatedAt,
                   l.ProductId, l.Quantity, l.UnitPrice
            FROM Orders o
            LEFT JOIN OrderLines l ON l.OrderId = o.Id
            WHERE o.Id = @Id";

        var result = await _connection.QueryAsync<OrderDto, OrderLineDto, OrderDto>(
            sql,
            (order, line) =>
            {
                order.Lines ??= new List<OrderLineDto>();
                order.Lines.Add(line);
                return order;
            },
            new { Id = id },
            splitOn: "ProductId");

        return result.FirstOrDefault();
    }
}

DI Extension Method for Infrastructure Registration

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

        services.AddScoped<IOrdersRepository, OrdersRepository>();
        services.AddScoped<IOrdersReadRepository, OrdersReadRepository>();
        services.AddScoped<INotificationService, EmailNotificationService>();

        return services;
    }
}

The Presentation Layer: Minimal API, Controllers, and DI Configuration

The Presentation layer is responsible for receiving HTTP requests, validating input format, delegating processing to Application via MediatR, and returning correctly formatted responses. It contains no business logic (that is in Domain) and no orchestration logic (that is in Application).

// MyApp.Api/Endpoints/OrdersEndpoints.cs
public static class OrdersEndpoints
{
    public static WebApplication MapOrdersEndpoints(this WebApplication app)
    {
        var group = app.MapGroup("/api/orders")
            .WithTags("Orders")
            .RequireAuthorization();

        group.MapGet("/{id:guid}", async (
            Guid id,
            IMediator mediator,
            CancellationToken ct) =>
        {
            var result = await mediator.Send(new GetOrderByIdQuery(id), ct);
            return result is null
                ? Results.NotFound()
                : Results.Ok(result);
        })
        .WithName("GetOrderById")
        .Produces<OrderDto>()
        .ProducesProblem(StatusCodes.Status404NotFound);

        group.MapPost("/{id:guid}/confirm", async (
            Guid id,
            ConfirmOrderRequest request,
            IMediator mediator,
            CancellationToken ct) =>
        {
            var command = new ConfirmOrderCommand(id, request.OperatorId);
            var result = await mediator.Send(command, ct);
            return Results.Ok(result);
        })
        .WithName("ConfirmOrder")
        .Produces<ConfirmOrderResult>();

        return app;
    }
}
// MyApp.Api/Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(ConfirmOrderHandler).Assembly);
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});

builder.Services.AddValidatorsFromAssembly(typeof(ConfirmOrderCommand).Assembly);
builder.Services.AddInfrastructure(builder.Configuration);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

app.MapOrdersEndpoints();
app.UseSwagger();
app.UseSwaggerUI();

app.Run();

Note how Program.cs knows both Application and Infrastructure (necessary for DI registration), but the dependency of Api on Infrastructure is accepted only here, at the composition root. Controllers and endpoints never directly import Infrastructure types.

Testing Clean Architecture: Unit Tests for Domain, Integration Tests for Infrastructure

One of the main benefits of Clean Architecture is testability: each layer can be tested in isolation with the appropriate strategy. The resulting test pyramid has a broad base of fast unit tests and a narrow peak of slower integration tests.

Domain Layer Unit Tests: Fast and Dependency-Free

// MyApp.Domain.Tests/Entities/OrderTests.cs
public class OrderTests
{
    [Fact]
    public void Confirm_EmptyOrder_ThrowsException()
    {
        var order = Order.Create(Guid.NewGuid());

        var act = () => order.Confirm();

        act.Should().Throw<EmptyOrderException>();
    }

    [Fact]
    public void Confirm_WithLines_ChangesStatusToConfirmed()
    {
        var order = Order.Create(Guid.NewGuid());
        order.AddLine(Guid.NewGuid(), 2, 49.99m);

        order.Confirm();

        order.Status.Should().Be(OrderStatus.Confirmed);
    }

    [Fact]
    public void Confirm_WithLines_GeneratesDomainEvent()
    {
        var order = Order.Create(Guid.NewGuid());
        order.AddLine(Guid.NewGuid(), 1, 100m);

        order.Confirm();

        order.DomainEvents.Should().ContainSingle()
            .Which.Should().BeOfType<OrderConfirmed>();
    }

    [Fact]
    public void AddLine_ConfirmedOrder_ThrowsException()
    {
        var order = Order.Create(Guid.NewGuid());
        order.AddLine(Guid.NewGuid(), 1, 50m);
        order.Confirm();

        var act = () => order.AddLine(Guid.NewGuid(), 1, 25m);

        act.Should().Throw<OrderNotModifiableException>();
    }
}

These tests run in milliseconds. A complete suite of 500 domain layer tests runs in under five seconds. Test speed is a critical factor for development pace: slow tests are run less frequently, reducing the feedback loop.

Application Layer Tests: Mocking Interfaces

// MyApp.Application.Tests/Orders/ConfirmOrderHandlerTests.cs
public class ConfirmOrderHandlerTests
{
    private readonly Mock<IOrdersRepository> _repositoryMock = new();
    private readonly Mock<INotificationService> _notificationsMock = new();

    [Fact]
    public async Task Handle_ExistingOrder_ConfirmsAndNotifies()
    {
        var order = Order.Create(Guid.NewGuid());
        order.AddLine(Guid.NewGuid(), 1, 100m);

        _repositoryMock
            .Setup(r => r.GetByIdAsync(order.Id, It.IsAny<CancellationToken>()))
            .ReturnsAsync(order);

        var handler = new ConfirmOrderHandler(
            _repositoryMock.Object,
            _notificationsMock.Object);

        var result = await handler.Handle(
            new ConfirmOrderCommand(order.Id, Guid.NewGuid()),
            CancellationToken.None);

        result.Status.Should().Be(OrderStatus.Confirmed);
        _repositoryMock.Verify(r => r.SaveAsync(order, It.IsAny<CancellationToken>()), Times.Once);
        _notificationsMock.Verify(n => n.SendConfirmationAsync(
            order.CustomerId, order.Id, It.IsAny<CancellationToken>()), Times.Once);
    }
}

Infrastructure Integration Tests with Testcontainers

Infrastructure tests require a real database. In 2026 the best solution is Testcontainers for .NET: it starts a Docker container with SQL Server (or any other database) during tests and destroys it at the end, guaranteeing isolated and repeatable tests in any environment, including CI/CD pipelines.

// MyApp.Infrastructure.Tests/Persistence/OrdersRepositoryTests.cs
public class OrdersRepositoryTests : IAsyncLifetime
{
    private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder().Build();
    private AppDbContext _context = null!;

    public async Task InitializeAsync()
    {
        await _sqlContainer.StartAsync();
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(_sqlContainer.GetConnectionString())
            .Options;
        _context = new AppDbContext(options);
        await _context.Database.MigrateAsync();
    }

    [Fact]
    public async Task SaveAsync_NewOrder_PersistsToDatabase()
    {
        var order = Order.Create(Guid.NewGuid());
        order.AddLine(Guid.NewGuid(), 2, 49.99m);
        var repository = new OrdersRepository(_context);

        await repository.AddAsync(order, CancellationToken.None);

        var found = await repository.GetByIdAsync(order.Id, CancellationToken.None);
        found.Should().NotBeNull();
        found!.Lines.Should().HaveCount(1);
    }

    public async Task DisposeAsync() => await _sqlContainer.DisposeAsync();
}

Common Mistakes When Implementing Clean Architecture in C#

After years of code reviews on .NET projects claiming to use Clean Architecture, recurring error patterns emerge. Knowing them is the fastest way to avoid them.

Anemic Domain Model: Logic Ends Up in Handlers

The most common and most costly mistake in the long run. Domain entities become simple data containers with getters and setters, and all business logic migrates to Application handlers. The warning sign: a handler that makes decisions based on entity state instead of delegating them to the entity. If you write if (order.Status == OrderStatus.Draft) order.Status = OrderStatus.Confirmed in a handler, you are bypassing the domain.

Leaking Infrastructure Types into Domain or Application

Finding using Microsoft.EntityFrameworkCore in a Domain layer file is a direct architectural violation. More subtle is finding EF attributes ([Key], [Required]) on domain entities: technically it works, but it couples the domain model to the persistence framework. Use EF Corès Fluent API in the Infrastructure layer, never attributes in the Domain.

Interfaces Everywhere Even When Not Needed

Not every class needs an interface. Interfaces in Application make sense for repositories, notification services, and any external dependency you want to mock in tests. It makes no sense to create IConfirmOrderHandler just to have the interface: MediatR handles resolution through the command type, not through handler interfaces.

The criterion for creating an interface in Application is: do I need to mock this dependency in Application tests? If yes, the interface makes sense. Otherwise, no.

Misplaced DTOs and Excessive Transformations

DTOs for UI communication live in Application or Presentation, never in Domain. AutoMapper is useful for repetitive transformations, but its overuse leads to unmanageable mappings where it is no longer clear what is being transformed into what. For systems with simple transformations, explicit mapping is often more readable and easier to maintain.

Ignoring Bounded Contexts in Complex Systems

Clean Architecture defines the vertical structure (layers), but does not say how to divide the system horizontally between different sub-domains. In complex systems, applying Clean Architecture without considering DDD bounded contexts leads to a monolithic domain model that grows indefinitely. To deepen how to combine Clean Architecture with DDD, the article on how to become a Software Architect provides the broader context.

Modular Monolith vs Clean Architecture vs Microservices: When to Choose What

One of the most frequent questions from those approaching architecture is: should I use Clean Architecture, a Modular Monolith, or Microservices? The correct answer: it depends on the problem, not on technological preference. Here is how to reason about it.

Clean Architecture: for Complex Domain, Medium Team, Long Life

Clean Architecture makes sense when business logic is complex with non-trivial rules, when the project will last years and the team will change, when automated testing of business logic is a priority, and when there is a concrete possibility of changing persistence technology or delivery over time.

Modular Monolith: The Smart Compromise for Many Cases

The Modular Monolith is an often-ignored architecture that solves 70% of problems with 30% of the complexity. It is a single process (monolith) divided into modules with explicit boundaries and controlled communication. Modules share the database but not direct access to other modules: they communicate through public interfaces or internal events. It makes sense when the system has no requirements for independent deployment between components, teams are small, and you want separation of responsibilities without the operational overhead of microservices. The article on microservices vs monolith deepens this trade-off.

Microservices: for Large Teams, Independent Deployment, Different Scale

Microservices make sense when different parts of the system have radically different scalability requirements and when independent deployment of services is a real requirement, not just an aspiration. For most organizations under 50-100 developers, a well-structured Modular Monolith is more efficient.

Real Case Studies: Clean Architecture Applied to Enterprise Systems in Production

Three typical scenarios where Clean Architecture proved to be the right choice, and one where it would have been excessive.

Case 1: B2B E-Commerce Platform with Complex Pricing Rules

A B2B e-commerce system with pricing rules based on contracts, volume discounts, time-limited promotions, and custom prices per customer. The pricing logic is the heart of the domain and changes frequently following commercial policies. Clean Architecture with a rich Domain layer allowed testing every combination of rules in isolation, adding new rules without touching infrastructure, and explaining business rules to the commercial team through the code itself. Result: 400 domain layer unit tests that run in 2 seconds.

Case 2: Document Management System with Approval Workflows

A document management system with multi-step approval workflows, routing rules based on document type and organizational structure, and integration with three external systems. When one of the three external systems changed its APIs, the update required changes only to the corresponding Infrastructure layer.

When Something Simpler Would Have Been Better

A backoffice application for managing advertisements on a small media platform. Mainly CRUD operations, no complex business logic, a two-developer team, expected project life of 18 months. Clean Architecture introduced structural complexity that was not justified by the problem. Refactoring toward Minimal API with Vertical Slices reduced code by 40% without losing anything substantial.

When Clean Architecture Is Overkill: Lighter Alternatives

Clean Architecture is not the answer to everything. Recognizing when it is excessive is architectural competence as much as knowing how to apply it well.

It does not make sense when: it is a CRUD application with no real logic; it is a prototype or MVP that might not survive the first review; the team is small and development speed is the only short-term metric; the domain is simple and will not change significantly.

Alternatives to consider: Vertical Slice Architecture (each feature is a file containing everything needed for that feature, no horizontal layer separation) and Minimal API with a simple service layer for systems dominated by CRUD operations with very little logic.

The maturity of an architect is also measured by the ability to resist the temptation to apply the most sophisticated architecture when the problem does not require it. To deepen this topic and understand how to choose the right architecture based on context, the article on software architectural patterns offers a complete view of the trade-offs.

Frequently asked questions

Clean Architecture is an architectural approach proposed by Robert C. Martin that organizes software in concentric layers with one fundamental rule: dependencies always point inward. Business code (at the center) does not depend on databases, frameworks, or UI. The outer layers (infrastructure, presentation) depend on the core, not the other way around.

No. Clean Architecture adds structure and separation that have a cost in terms of additional files and layers. For simple, CRUD-heavy applications or prototypes, it can be overkill. It makes sense when business logic is complex and needs to be testable independently of infrastructure, and when the project has a long life ahead and the team will change over time.

They are conceptually similar and often used as synonyms. Jeffrey Palermo's Onion Architecture and Martin's Clean Architecture share the principle of inward dependencies. Clean Architecture is more explicit in defining the four layers (Entities, Use Cases, Interface Adapters, Frameworks) and the role of Use Cases as orchestrators.

The most common pattern divides the solution into four projects: Domain (pure domain entities and logic), Application (use cases, service interfaces, DTOs), Infrastructure (database, external API, email implementations), and Presentation (API controllers or Blazor). Only Domain has no external dependencies. Application depends only on Domain. Infrastructure and Presentation depend on Application.

Domain layer tests are pure unit tests: no mocks, no database, no external dependencies. You instantiate the entity, call the method, and verify the resulting state. Application layer tests use mocks of repository and service interfaces. Only Infrastructure layer tests require a real database or test container. This approach makes core tests fast and stable.

CQRS (Command Query Responsibility Segregation) integrates naturally with Clean Architecture in the Application layer: write operations are Commands, read operations are Queries, each with their own handler. It makes sense when optimizations for reading and writing diverge significantly, or when you want to clearly separate use cases. With MediatR, handler registration is automatic and the code stays clean.

The most frequent: putting business logic in Application handlers instead of Domain entities (Anemic Domain Model), leaking Infrastructure types into Application or Domain, creating interfaces for everything even when not needed for testing, using AutoMapper where it adds no value, and not defining clear boundaries between bounded contexts in complex systems.

Leave your details in the form below

Matteo Migliore

Matteo Migliore is an entrepreneur and software architect with over 25 years of experience developing .NET-based solutions and evolving enterprise-grade application architectures.

Throughout his career, he has worked with organizations such as Cotonella, Il Sole 24 Ore, FIAT and NATO, leading teams in developing scalable platforms and modernizing complex legacy ecosystems.

He has trained hundreds of developers and supported companies of all sizes in turning software into a competitive advantage, reducing technical debt and achieving measurable business results.

Stai leggendo perché vuoi smettere di rattoppare software fragile.Scopri il metodo per progettare sistemi che reggono nel tempo.