Learnixo
Back to blog
Backend Systemsintermediate

Clean Architecture with .NET — Key Lessons from the Book by Crouse & Smith

A deep guide inspired by 'Clean Architecture with .NET' by Casey Crouse and Steve Ardalis Smith (Packt). Covers recognising tightly coupled architectures, adapting Clean Architecture to .NET, implementing use cases, MediatR composition, and deploying to Azure.

LearnixoJune 4, 202613 min read
.NETC#Clean ArchitectureMediatRCQRSAzureArdalisBook
Share:𝕏

About the Book

"Clean Architecture with .NET" by Casey Crouse and Steve "Ardalis" Smith (Packt) is one of the most practical guides to applying Robert C. Martin's Clean Architecture principles specifically in the .NET ecosystem.

Steve Smith (known as "Ardalis") is a Microsoft MVP and one of the primary contributors to the Ardalis.CleanArchitecture template — the starting point for thousands of .NET projects worldwide.

The book is organised around five major themes:

  1. Chapter 1 — Recognising Tightly Coupled Architectures
  2. Chapter 3 — Adapting Clean Architecture to .NET
  3. Chapter 5 — Implementing Core Use Cases
  4. Chapters 10 & 12 — Structured Service Composition with MediatR
  5. Chapter 13 — Deploying and Monitoring in Azure

This guide walks through each theme with real .NET code.


Chapter 1: Recognising Tightly Coupled Architectures

Before you can fix coupling, you need to recognise it. The book opens by identifying the most common anti-patterns that cause .NET projects to become unmaintainable.

The Big Ball of Mud

The most common architecture pattern in enterprise software — despite never being intentional.

C#
// Classic tightly coupled controller — everything knows about everything
[HttpPost("orders")]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
    // ❌ Controller directly uses DbContext
    var customer = await _dbContext.Customers.FindAsync(request.CustomerId);
    if (customer == null) return BadRequest("Customer not found");

    // ❌ Business logic in controller
    if (customer.CreditLimit < request.Total)
        return BadRequest("Insufficient credit");

    // ❌ Infrastructure concern in controller
    var order = new Order
    {
        Id         = Guid.NewGuid(),
        CustomerId = request.CustomerId,
        Total      = request.Total,
        CreatedAt  = DateTime.UtcNow
    };

    _dbContext.Orders.Add(order);
    await _dbContext.SaveChangesAsync();

    // ❌ Email sending in the controller
    await _smtpClient.SendMailAsync(new MailMessage
    {
        To      = { customer.Email },
        Subject = $"Order {order.Id} confirmed"
    });

    return Ok(order.Id);
}

What makes this tightly coupled:

  • The controller knows about the database schema
  • Business rules (CreditLimit check) are in the wrong layer — can't be reused or tested without an HTTP context
  • Infrastructure details (SmtpClient, DbContext) are imported directly — replacing email provider requires editing the controller
  • A test for the credit check requires a full HTTP stack and a database

The Three Signs of a Tightly Coupled Codebase

The book identifies three concrete signs:

Sign 1: Tests require infrastructure

C#
// If your unit tests look like this, coupling is the problem
public class OrderTests
{
    [Fact]
    public async Task CreateOrder_WithSufficientCredit_Succeeds()
    {
        // ❌ A unit test that needs a real database and SMTP server
        var dbContext = new AppDbContext(new DbContextOptionsBuilder()
            .UseSqlServer("Server=localhost;...")
            .Options);
        var smtp = new SmtpClient("mail.example.com");

        var controller = new OrdersController(dbContext, smtp);
        // ... test
    }
}

Sign 2: Changing the database requires touching business logic

If you change from SQL Server to PostgreSQL and need to modify files in your business logic layer — that layer depends on the database.

Sign 3: Business rules can't be understood without reading infrastructure code

If you can't describe the credit check rule without mentioning DbContext, the rule is buried in the wrong place.


Chapter 3: Adapting Clean Architecture to .NET

The book's central contribution is how to map Robert Martin's abstract onion diagram to .NET project conventions and tooling.

The .NET-Specific Layer Mapping

Clean Architecture (abstract)       .NET Project
─────────────────────────────       ────────────────────────────
Entities                        →   YourApp.Core / Domain
Use Cases                       →   YourApp.Application
Interface Adapters              →   YourApp.Infrastructure (adapters)
Frameworks & Drivers            →   YourApp.Web / API

The Dependency Rule in Practice

The book is emphatic: the dependency rule is binary. Either it's obeyed, or it isn't.

Allowed:
  Web → Application → Core/Domain
  Infrastructure → Core/Domain (implements interfaces defined there)

Forbidden:
  Core/Domain → Infrastructure        ❌ (domain knows about EF Core)
  Core/Domain → Application           ❌ (domain knows about use cases)
  Application → Infrastructure        ❌ (use cases know about SQL)
  Application → Web                   ❌ (use cases know about HTTP)

Enforcing it in code:

XML
<!-- Core.csproj  no external references allowed -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
  </PropertyGroup>
  <!-- No PackageReferences to EF Core, HTTP, anything infrastructure -->
</Project>

<!-- Application.csproj  depends only on Core -->
<Project Sdk="Microsoft.NET.Sdk">
  <ItemGroup>
    <ProjectReference Include="..\Core\Core.csproj" />
    <!-- MediatR is allowed here  it's an application concern -->
    <PackageReference Include="MediatR" Version="12.*" />
    <PackageReference Include="FluentValidation" Version="11.*" />
  </ItemGroup>
</Project>

<!-- Infrastructure.csproj — depends on Core, never on Application or Web -->
<Project Sdk="Microsoft.NET.Sdk">
  <ItemGroup>
    <ProjectReference Include="..\Core\Core.csproj" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.*" />
  </ItemGroup>
</Project>

The book recommends enforcing this with NetArchTest in CI:

C#
// Architecture tests — run in CI, fail the build if violated
public class ArchitectureTests
{
    [Fact]
    public void Core_Should_Not_DependOn_Infrastructure()
    {
        var result = Types.InAssembly(typeof(Order).Assembly)
            .Should()
            .NotHaveDependencyOn("OrderFlow.Infrastructure")
            .GetResult();

        Assert.True(result.IsSuccessful);
    }

    [Fact]
    public void Application_Should_Not_DependOn_Infrastructure()
    {
        var result = Types.InAssembly(typeof(CreateOrderCommand).Assembly)
            .Should()
            .NotHaveDependencyOn("OrderFlow.Infrastructure")
            .GetResult();

        Assert.True(result.IsSuccessful);
    }
}

The Ardalis Template Structure

The book aligns with the Ardalis Clean Architecture template, which has become the standard starting point:

src/
├── YourApp.Core/
│   ├── Entities/           ← domain entities with behaviour
│   ├── ValueObjects/       ← immutable value types
│   ├── Interfaces/         ← repository and service interfaces
│   ├── Specifications/     ← query specifications (Ardalis.Specification)
│   └── DomainEvents/       ← domain event records
├── YourApp.Application/
│   ├── UseCases/           ← one folder per use case
│   │   └── Orders/
│   │       ├── Create/
│   │       ├── Submit/
│   │       └── Cancel/
│   └── Interfaces/         ← app-level interfaces (ICurrentUser, IEmailSender)
├── YourApp.Infrastructure/
│   ├── Data/               ← EF Core DbContext, migrations, configurations
│   ├── Identity/           ← ASP.NET Core Identity
│   └── Services/           ← email, blob storage, payment gateway implementations
└── YourApp.Web/
    ├── Controllers/        ← thin controllers
    ├── Endpoints/          ← minimal API endpoints
    └── Program.cs

Chapter 5: Implementing Core Use Cases

The book dedicates a full chapter to implementing use cases correctly — the most misunderstood layer.

What Belongs in a Use Case

A use case (application service / MediatR handler) should:

  • Load data from repositories
  • Invoke domain logic on aggregates
  • Persist changes
  • Publish events if needed

A use case should not:

  • Contain business rules (those belong in the domain)
  • Know about HTTP status codes
  • Know about SQL queries
  • Know about email templates
C#
// ✅ Correctly implemented use case
public class SubmitOrderHandler : IRequestHandler<SubmitOrderCommand, SubmitOrderResult>
{
    private readonly IRepository<Order> _orders;  // Ardalis.Specification repository
    private readonly IEmailService _email;
    private readonly ICurrentUserService _currentUser;

    public SubmitOrderHandler(
        IRepository<Order> orders,
        IEmailService email,
        ICurrentUserService currentUser)
    {
        _orders      = orders;
        _email       = email;
        _currentUser = currentUser;
    }

    public async Task<SubmitOrderResult> Handle(
        SubmitOrderCommand request,
        CancellationToken ct)
    {
        // Load
        var order = await _orders.GetByIdAsync(request.OrderId, ct)
            ?? throw new NotFoundException(nameof(Order), request.OrderId);

        // Verify ownership — application-level guard
        if (order.CustomerId != _currentUser.UserId)
            throw new ForbiddenAccessException();

        // Domain logic — the ORDER knows how to submit itself
        order.Submit();  // throws DomainException if business rule violated

        // Persist
        await _orders.UpdateAsync(order, ct);

        // Notify — via interface, not direct SMTP
        await _email.SendOrderConfirmationAsync(order.CustomerId, order.Id, ct);

        return new SubmitOrderResult(order.Id, order.Status.ToString());
    }
}

Ardalis.Specification Pattern

The book promotes the Specification pattern over raw repository queries:

C#
// Specification — encapsulates a query as a named, reusable object
public class OrdersWithLinesByCustomerSpec : Specification<Order>
{
    public OrdersWithLinesByCustomerSpec(Guid customerId)
    {
        Query
            .Where(o => o.CustomerId == customerId)
            .Include(o => o.Lines)
                .ThenInclude(l => l.Product)
            .OrderByDescending(o => o.CreatedAt);
    }
}

// Use it
var orders = await _orders.ListAsync(
    new OrdersWithLinesByCustomerSpec(customerId), ct);

// A more specific spec
public class PendingOrdersOlderThanSpec : Specification<Order>
{
    public PendingOrdersOlderThanSpec(TimeSpan age)
    {
        Query
            .Where(o => o.Status == OrderStatus.Pending
                     && o.CreatedAt < DateTime.UtcNow - age);
    }
}

The book's argument: specifications make queries reusable, named, and testable in isolation. A PendingOrdersOlderThan24HoursSpec is self-documenting; _dbContext.Orders.Where(o => o.Status == 1 && o.CreatedAt < DateTime.UtcNow.AddHours(-24)) is not.


Chapters 10 & 12: Structured Service Composition with MediatR

Two chapters are dedicated to wiring the application together using MediatR — one of the most important practical sections.

The Mediator Pattern

Instead of controllers calling services directly, everything goes through MediatR. Controllers depend on IMediator, not on dozens of service interfaces.

C#
// Before MediatR — controller has many dependencies
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;
    private readonly IInventoryService _inventory;
    private readonly IEmailService _email;
    private readonly IAuditService _audit;
    private readonly ICurrentUserService _user;
    // ... adding a feature = adding another constructor parameter
}

// After MediatR — controller has one dependency
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    public OrdersController(IMediator mediator) => _mediator = mediator;

    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderCommand command, CancellationToken ct)
    {
        var result = await _mediator.Send(command, ct);
        return CreatedAtAction(nameof(GetById), new { id = result.OrderId }, result);
    }
}

Pipeline Behaviours — The Book's Central Pattern

The book spends considerable time on pipeline behaviours — the most powerful feature of MediatR for production applications.

Request → [LoggingBehaviour] → [ValidationBehaviour] → [AuthorisationBehaviour] → Handler → Response
C#
// Validation behaviour — every command is validated before the handler runs
public class ValidationBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehaviour(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(f => f is not null)
            .ToList();

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

        return await next();
    }
}

// Authorisation behaviour
public class AuthorisationBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IAuthorisedRequest<TResponse>
{
    private readonly ICurrentUserService _user;
    private readonly IIdentityService _identity;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var authorisationAttributes = request
            .GetType()
            .GetCustomAttributes<AuthoriseAttribute>()
            .ToList();

        if (!authorisationAttributes.Any()) return await next();

        foreach (var attribute in authorisationAttributes)
        {
            var authorised = await _identity.IsInRoleAsync(
                _user.UserId, attribute.Roles, ct);

            if (!authorised)
                throw new ForbiddenAccessException();
        }

        return await next();
    }
}

Request/Response Segregation

The book strongly advocates for separating commands from queries (CQRS):

C#
// Commands — mutate state, return minimal result
public record CreateOrderCommand(Guid CustomerId, IReadOnlyList<OrderLineDto> Lines)
    : IRequest<CreateOrderResult>;

public record CreateOrderResult(Guid OrderId);

// Queries — read state, never mutate, optimised for the UI
public record GetOrderQuery(Guid OrderId) : IRequest<OrderDetailDto>;

// Queries can bypass the domain model entirely — read from DB directly for performance
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDetailDto>
{
    private readonly AppDbContext _db;  // ← reads directly from DbContext, no repository

    public async Task<OrderDetailDto> Handle(GetOrderQuery request, CancellationToken ct)
    {
        return await _db.Orders
            .Where(o => o.Id == request.OrderId)
            .Select(o => new OrderDetailDto(
                o.Id,
                o.CustomerId,
                o.Status.ToString(),
                o.Total,
                o.CreatedAt,
                o.Lines.Select(l => new OrderLineDto(l.ProductId, l.Quantity, l.UnitPrice)).ToList()))
            .FirstOrDefaultAsync(ct)
            ?? throw new NotFoundException(nameof(Order), request.OrderId);
    }
}

Chapter 13: Deploying and Monitoring in Azure

The final chapter the book highlights covers taking the clean architecture to production — specifically in Azure.

Deployment Architecture

GitHub → CI Pipeline → Azure Container Registry → Azure Container Apps
                     → Run tests
                     → Build Docker image
                     → Push to ACR
                     → Deploy to ACA (staging → production)

Health Checks (Ardalis Pattern)

The book shows a health check setup that reflects the clean architecture layers:

C#
builder.Services.AddHealthChecks()
    // Infrastructure health
    .AddSqlServer(
        connectionString: builder.Configuration.GetConnectionString("Default")!,
        name: "sql-server",
        tags: ["db", "infrastructure"])
    .AddRedis(
        redisConnectionString: builder.Configuration.GetConnectionString("Redis")!,
        name: "redis",
        tags: ["cache", "infrastructure"])
    // Application health (business-level)
    .AddCheck<OrderProcessingHealthCheck>("order-processing", tags: ["app"])
    // External dependency health
    .AddUrlGroup(
        uri: new Uri(builder.Configuration["Services:Payments"] + "/health"),
        name: "payment-service",
        tags: ["external"]);

// Different endpoints for different purposes
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false  // liveness: is the process running?
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = c => c.Tags.Contains("db") || c.Tags.Contains("cache")
});
app.MapHealthChecks("/health/full", new HealthCheckOptions
{
    // All checks — for monitoring dashboards
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

Application Insights Integration

C#
builder.Services.AddApplicationInsightsTelemetry(options =>
{
    options.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"];
});

// Custom telemetry in use cases
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, CreateOrderResult>
{
    private readonly TelemetryClient _telemetry;

    public async Task<CreateOrderResult> Handle(
        CreateOrderCommand request, CancellationToken ct)
    {
        var order = await CreateOrderInternalAsync(request, ct);

        // Track business event — visible in Application Insights
        _telemetry.TrackEvent("OrderCreated", new Dictionary<string, string>
        {
            ["orderId"]    = order.Id.ToString(),
            ["customerId"] = order.CustomerId.ToString(),
            ["channel"]    = request.Channel
        });

        _telemetry.TrackMetric("OrderTotal", (double)order.Total);

        return new CreateOrderResult(order.Id);
    }
}

The Book's Core Thesis

The book's central argument — which runs through every chapter — is this:

Architecture is about managing dependencies.

Every coupling decision is a maintenance decision. Every time your domain knows about Entity Framework, you've made a maintenance decision: changing your ORM requires touching your business logic. Every time your controller contains a business rule, you've made a testability decision: testing that rule requires spinning up HTTP infrastructure.

Clean Architecture doesn't make code faster to write. It makes code safer to change — which matters far more over the lifetime of a real system.

The book's three-step diagnostic for any file:

  1. If I delete EF Core, does this file compile? If no — it's in the wrong layer.
  2. If I change this business rule, how many files change? If more than one — coupling exists.
  3. Can I test this class without a database or HTTP server? If no — extract the dependency.

Key Patterns Summary

| Pattern | Chapter | Purpose | |---|---|---| | Dependency Rule | 3 | Dependencies only point inward | | Specification Pattern | 5 | Named, reusable, testable queries | | MediatR Pipeline | 10 | Cross-cutting concerns without inheritance | | CQRS Separation | 12 | Commands mutate, queries optimise | | Architecture Tests (NetArchTest) | 3 | Enforce layer boundaries in CI | | Repository with Ardalis.Specification | 5 | Decouple use cases from EF Core | | Health Checks by layer | 13 | Liveness, readiness, full monitoring |


Interview Questions Based on the Book

Q: What is the Dependency Rule and how does it differ from Layered Architecture? In traditional layered architecture, each layer depends on the one below — so the business layer depends on the data layer. The Dependency Rule reverses this: the domain depends on nothing. Infrastructure implements interfaces defined in the domain. The data layer depends on the domain — not the other way around.

Q: What is the Specification pattern and what problem does it solve? A named class encapsulating a query predicate. Instead of scattering Where(o => o.Status == "Pending" && o.CreatedAt < ...) across use cases, you write new PendingExpiredOrdersSpec(). The specification is reusable, self-documenting, testable in isolation, and can be composed. It keeps use cases readable and repositories free of business-specific query logic.

Q: Why does the book recommend MediatR pipeline behaviours over base classes or decorators? Pipeline behaviours compose in order (logging → validation → auth → handler) without inheritance coupling. A new cross-cutting concern is a new class registered in one place — it doesn't require modifying every handler or base class. Handlers stay focused on their single use case; cross-cutting concerns are truly separated.

Q: What is the Ardalis Clean Architecture template and why is it a useful starting point? An opinionated .NET solution template by Steve Smith that implements Clean Architecture from day one: correct project references, Ardalis.Specification, MediatR with pipeline behaviours, architecture tests, and health checks. It encodes the book's patterns into a template — teams start from a correct foundation rather than building it from scratch.

Q: How do you enforce architectural layer boundaries in .NET CI/CD? NetArchTest — a library that lets you write xUnit/NUnit tests asserting dependency rules: "types in Core should not depend on Infrastructure". These tests run in CI and fail the build if a developer accidentally adds a forbidden reference. Architecture drift is caught at code review time, not during a painful refactor six months later.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.