Clean Architecture in .NET: A Practical Guide
How to structure .NET applications using Clean Architecture — with real project structure, dependency injection, and CQRS patterns.
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 setupDomain Layer
This is your core. No NuGet packages, no framework references. Pure 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):
// 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;
}
}// 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:
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:
[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
- Putting business logic in controllers — Controllers should only map HTTP to use cases.
- Anemic domain models — If your entities are just property bags, you're doing it wrong.
- Over-abstracting — You don't need a repository for every entity. Start simple.
- 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
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.