.NET & C# Development · Lesson 9 of 11

Clean Architecture

Why Clean Architecture?

Most .NET projects start clean and end as spaghetti. The controller knows about the database. The service layer knows about HTTP. Business logic is scattered everywhere.

Clean Architecture fixes this by enforcing a simple rule: dependencies point inward. The business logic never depends on infrastructure.

The Layers

┌─────────────────────────┐
│      Presentation       │  ← Controllers, API
├─────────────────────────┤
│     Infrastructure      │  ← EF Core, External APIs
├─────────────────────────┤
│       Application       │  ← Use Cases, CQRS
├─────────────────────────┤
│         Domain          │  ← Entities, Value Objects
└─────────────────────────┘

Each layer can only depend on layers below it. The Domain layer depends on nothing.

Project Structure

src/
├── MyApp.Domain/           # Entities, interfaces, value objects
├── MyApp.Application/      # Use cases, DTOs, CQRS handlers
├── MyApp.Infrastructure/   # EF Core, email, file storage
└── MyApp.API/              # Controllers, middleware, DI setup

Domain Layer

This is your core. No NuGet packages, no framework references. Pure C#.

C#
public class Project
{
    public Guid Id { get; private set; }
    public string Name { get; private set; }
    public ProjectStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }

    private Project() { } // EF Core

    public static Project Create(string name)
    {
        return new Project
        {
            Id = Guid.NewGuid(),
            Name = name,
            Status = ProjectStatus.Draft,
            CreatedAt = DateTime.UtcNow,
        };
    }

    public void Activate()
    {
        if (Status != ProjectStatus.Draft)
            throw new DomainException("Only draft projects can be activated.");

        Status = ProjectStatus.Active;
    }
}

Notice: business rules live in the entity. Not in a service. Not in a controller.

Application Layer with CQRS

Use MediatR to separate commands (writes) from queries (reads):

C#
// Command
public record CreateProjectCommand(string Name) : IRequest<Guid>;

// Handler
public class CreateProjectHandler : IRequestHandler<CreateProjectCommand, Guid>
{
    private readonly IProjectRepository _repository;

    public CreateProjectHandler(IProjectRepository repository)
    {
        _repository = repository;
    }

    public async Task<Guid> Handle(
        CreateProjectCommand request,
        CancellationToken cancellationToken)
    {
        var project = Project.Create(request.Name);
        await _repository.AddAsync(project, cancellationToken);
        return project.Id;
    }
}
C#
// Query
public record GetProjectQuery(Guid Id) : IRequest<ProjectDto>;

// Handler
public class GetProjectHandler : IRequestHandler<GetProjectQuery, ProjectDto>
{
    private readonly IProjectRepository _repository;

    public GetProjectHandler(IProjectRepository repository)
    {
        _repository = repository;
    }

    public async Task<ProjectDto> Handle(
        GetProjectQuery request,
        CancellationToken cancellationToken)
    {
        var project = await _repository.GetByIdAsync(request.Id, cancellationToken)
            ?? throw new NotFoundException("Project", request.Id);

        return new ProjectDto(project.Id, project.Name, project.Status.ToString());
    }
}

Infrastructure Layer

This is where Entity Framework Core lives:

C#
public class ProjectRepository : IProjectRepository
{
    private readonly AppDbContext _context;

    public ProjectRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task AddAsync(Project project, CancellationToken ct)
    {
        _context.Projects.Add(project);
        await _context.SaveChangesAsync(ct);
    }

    public async Task<Project?> GetByIdAsync(Guid id, CancellationToken ct)
    {
        return await _context.Projects.FindAsync(new object[] { id }, ct);
    }
}

The API Layer

Thin controllers that just delegate to MediatR:

C#
[ApiController]
[Route("api/[controller]")]
public class ProjectsController : ControllerBase
{
    private readonly IMediator _mediator;

    public ProjectsController(IMediator mediator)
    {
        _mediator = mediator;
    }

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

    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetById(Guid id)
    {
        var project = await _mediator.Send(new GetProjectQuery(id));
        return Ok(project);
    }
}

Common Mistakes

  1. Putting business logic in controllers — Controllers should only map HTTP to use cases.
  2. Anemic domain models — If your entities are just property bags, you're doing it wrong.
  3. Over-abstracting — You don't need a repository for every entity. Start simple.
  4. Ignoring the dependency rule — If your Domain references EF Core, the architecture is broken.

When NOT to Use Clean Architecture

  • Small CRUD apps with no business logic
  • Prototypes and MVPs where speed matters more than structure
  • Projects with one developer and a short lifespan

Clean Architecture is an investment. It pays off when the system grows, the team grows, or the requirements change frequently.

Next Steps

In the next article, we'll add:

  • Validation with FluentValidation
  • Global error handling
  • Unit testing the Application layer
  • Integration testing with TestContainers