Learnixo

Clean Architecture in .NET · Lesson 1 of 1

Clean Architecture — Layers, the Dependency Rule, and Why It Matters

What Clean Architecture Solves

Most applications start simple and accumulate coupling over time. A controller calls a repository. The repository imports a third-party SDK. The SDK's version pins your runtime. Now every layer knows about every other layer, and changing anything requires understanding everything.

Clean Architecture solves this by enforcing a single rule: source code dependencies must point inward only.


The Four Layers

┌─────────────────────────────────────────┐
│               Api Layer                  │  ← HTTP, controllers, DI registration
├─────────────────────────────────────────┤
│          Infrastructure Layer            │  ← EF Core, Redis, email, external APIs
├─────────────────────────────────────────┤
│           Application Layer              │  ← Use cases, CQRS, validation, interfaces
├─────────────────────────────────────────┤
│              Domain Layer                │  ← Entities, value objects, domain events
└─────────────────────────────────────────┘
         Dependencies flow inward ↑
Domain       → knows nothing about anyone
Application  → knows Domain only
Infrastructure → knows Domain and Application (implements interfaces)
Api          → knows Application and Infrastructure (wires up DI)

The Dependency Rule

C#
// ✓ ALLOWED: Application depends on Domain
// Application/Handlers/CreatePatientCommandHandler.cs
public sealed class CreatePatientCommandHandler
{
    private readonly IPatientRepository _repository;   // interface defined in Application

    public CreatePatientCommandHandler(IPatientRepository repository)
    {
        _repository = repository;
    }

    public async Task<Result<PatientId>> Handle(
        CreatePatientCommand command,
        CancellationToken cancellationToken)
    {
        var patient = Patient.Create(command.Name, command.DateOfBirth);
        await _repository.AddAsync(patient, cancellationToken);
        return Result.Success(patient.Id);
    }
}

// ✓ ALLOWED: Infrastructure implements the Application interface
// Infrastructure/Persistence/PatientRepository.cs
public sealed class PatientRepository : IPatientRepository
{
    private readonly AppDbContext _context;

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

    public async Task AddAsync(Patient patient, CancellationToken ct)
        => await _context.Patients.AddAsync(patient, ct);
}

// ✗ NOT ALLOWED: Domain depending on anything outside itself
// Domain/Entities/Patient.cs — do NOT do this
using Infrastructure.Persistence;   // ← violates Dependency Rule
using Microsoft.EntityFrameworkCore; // ← violates Dependency Rule

Why This Matters

C#
// Before Clean Architecture: controller knows everything
[ApiController]
public class PatientController : ControllerBase
{
    private readonly SqlConnection _connection;  // direct DB dependency
    private readonly SmtpClient _smtp;           // direct email dependency

    [HttpPost]
    public async Task<IActionResult> Create(CreatePatientDto dto)
    {
        // DB logic, validation, email — all mixed together
        using var cmd = _connection.CreateCommand();
        cmd.CommandText = "INSERT INTO Patients ...";
        await cmd.ExecuteNonQueryAsync();
        await _smtp.SendMailAsync(...);
        return Ok();
    }
    // Consequence: to test this, you need a real database AND a real SMTP server
}

// After Clean Architecture: controller delegates to a command handler
[ApiController]
public class PatientController : ControllerBase
{
    private readonly CreatePatientCommandHandler _handler;

    [HttpPost]
    public async Task<IActionResult> Create(
        CreatePatientDto dto,
        CancellationToken ct)
    {
        var command = new CreatePatientCommand(dto.Name, dto.DateOfBirth);
        var result = await _handler.Handle(command, ct);
        return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Error);
    }
    // Consequence: handler is testable in isolation with a fake repository
}

The 8-Project Solution

SystemForge.sln (or .slnx)
│
├── src/
│   ├── SystemForge.Domain/           ← Layer 1 (innermost)
│   ├── SystemForge.Application/      ← Layer 2
│   ├── SystemForge.Infrastructure/   ← Layer 3
│   ├── SystemForge.Api/              ← Layer 4 (outermost)
│   ├── SystemForge.AppHost/          ← .NET Aspire host
│   └── SystemForge.ServiceDefaults/  ← Shared Aspire defaults
│
└── tests/
    ├── SystemForge.Architecture.Tests/   ← Enforce layer rules at CI time
    └── SystemForge.Application.UnitTests/ ← Test handlers in isolation
C#
// Project references enforce the rule at compile time:

// Domain.csproj — no project references
<ItemGroup />

// Application.csproj
<ItemGroup>
  <ProjectReference Include="..\Domain\Domain.csproj" />
</ItemGroup>

// Infrastructure.csproj
<ItemGroup>
  <ProjectReference Include="..\Domain\Domain.csproj" />
  <ProjectReference Include="..\Application\Application.csproj" />
</ItemGroup>

// Api.csproj
<ItemGroup>
  <ProjectReference Include="..\Application\Application.csproj" />
  <ProjectReference Include="..\Infrastructure\Infrastructure.csproj" />
</ItemGroup>

Architecture Tests Enforce the Rule

C#
// tests/Architecture.Tests/LayerDependencyTests.cs
using NetArchTest.Rules;
using Xunit;

public class LayerDependencyTests
{
    private const string DomainNamespace        = "SystemForge.Domain";
    private const string ApplicationNamespace   = "SystemForge.Application";
    private const string InfrastructureNamespace = "SystemForge.Infrastructure";
    private const string ApiNamespace           = "SystemForge.Api";

    [Fact]
    public void Domain_should_not_depend_on_application()
    {
        var result = Types.InAssembly(typeof(Domain.AssemblyReference).Assembly)
            .ShouldNot()
            .HaveDependencyOn(ApplicationNamespace)
            .GetResult();

        Assert.True(result.IsSuccessful, string.Join("\n", result.FailingTypeNames ?? []));
    }

    [Fact]
    public void Domain_should_not_depend_on_infrastructure()
    {
        var result = Types.InAssembly(typeof(Domain.AssemblyReference).Assembly)
            .ShouldNot()
            .HaveDependencyOn(InfrastructureNamespace)
            .GetResult();

        Assert.True(result.IsSuccessful, string.Join("\n", result.FailingTypeNames ?? []));
    }

    [Fact]
    public void Application_should_not_depend_on_infrastructure()
    {
        var result = Types.InAssembly(typeof(Application.AssemblyReference).Assembly)
            .ShouldNot()
            .HaveDependencyOn(InfrastructureNamespace)
            .GetResult();

        Assert.True(result.IsSuccessful, string.Join("\n", result.FailingTypeNames ?? []));
    }
}
// These tests run on every CI build — the rule cannot be accidentally broken

When to Use Clean Architecture

Use it when:
  ✓ The application will grow over months/years
  ✓ Multiple developers work on it simultaneously
  ✓ Business logic is complex and changes frequently
  ✓ You need to swap infrastructure (DB, email provider) without touching business logic
  ✓ You want testable use cases without spinning up a database

Skip it when:
  ✗ You're building a CRUD API with fewer than 10 endpoints
  ✗ The team is 1 person building an internal tool
  ✗ The deadline is 2 weeks and the feature set is fixed
  ✗ Vertical Slice Architecture already fits your team's workflow better

Key Takeaway

Clean Architecture is not about folder structure — it is about the direction of dependencies. The Dependency Rule (inner layers know nothing about outer layers) is the only rule that matters. Everything else — whether you use MediatR, minimal APIs, or EF Core — is an implementation detail. The architecture tests enforce that rule at compile time so no one can accidentally break it.