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
// ✓ 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 RuleWhy This Matters
// 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// 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
// 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 brokenWhen 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 betterKey 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.