Clean Architecture ā Layers, the Dependency Rule, and Why It Matters
Clean Architecture fundamentals: the four layers, the Dependency Rule, what belongs where, and why the architecture makes large .NET codebases maintainable over years.
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.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.