Clean Architecture: the .NET project layout
After 5+ years building production platforms, I've learned that a poorly structured codebase compounds pain faster than any bug can. Clean Architecture: the .NET project layout is not about following rules for their own sake—it's about keeping your domain logic testable, your infrastructure swappable, and your team sane as the system grows.
Why structure matters at scale
I've inherited monoliths where business logic lived inside Entity Framework migrations, email templates were hardcoded in controllers, and unit testing felt impossible. I've also seen greenfield projects balloon from "just a simple CRUD API" into unmaintainable mud because someone skipped the architecture conversation upfront.
The good news: a deliberate four-project structure—applied thoughtfully—prevents most of these disasters. The catch: you have to know when to apply it and when to resist over-engineering.
The four-project pattern: dependencies only point inward
Imagine a bullseye with four rings, from center to outside:
- Domain — Core business entities and rules (no dependencies)
- Application — Use cases and interface contracts (depends only on Domain)
- Infrastructure — EF Core, email, storage, external services (depends on Application and Domain)
- Api — HTTP endpoints and dependency injection wiring (depends on all three)
The rule is absolute: dependencies only point inward. The Domain layer never sees Application, Infrastructure, or Api. Application never sees Infrastructure or Api. This creates a wall that keeps your business logic isolated from technology choices.
Why? Because one day your boss says "we need to add Slack notifications" or "we're switching from SQL Server to PostgreSQL." If your domain logic is entangled with those details, you're rewriting. With this structure, you add a new implementation and wire it in the Api layer.
Domain: zero dependencies, pure logic
Your Domain project has no NuGet references except maybe a small utilities package you own. It contains:
- Entities (Order, Customer, Product)
- Value objects (Money, Email, Address)
- Domain events (OrderPlaced, PaymentFailed)
- Business rules and invariants
Here's what a Domain entity looks like—no Entity Framework attributes, no ASP.NET, just C#:
namespace MyApp.Domain.Orders;
public class Order
{
public required Guid Id { get; init; }
public required Guid CustomerId { get; init; }
public required List<OrderLine> Lines { get; init; }
public OrderStatus Status { get; set; }
public Money Total { get; private set; }
public void AddLine(Product product, int quantity)
{
if (quantity <= 0)
throw new InvalidOperationException("Quantity must be positive");
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot add lines to a confirmed order");
var line = new OrderLine { ProductId = product.Id, Quantity = quantity };
Lines.Add(line);
RecalculateTotal();
}
private void RecalculateTotal()
{
Total = Lines.Aggregate(Money.Zero, (sum, line) => sum + line.Subtotal);
}
}
public enum OrderStatus { Draft, Confirmed, Shipped, Delivered }
public record Money(decimal Amount)
{
public static Money Zero => new(0);
public static Money operator +(Money a, Money b) => new(a.Amount + b.Amount);
}This is unit-test gold. No mocks, no databases, no HTTP. You test the invariant in milliseconds:
[Test]
public void AddLine_WithNegativeQuantity_ThrowsException()
{
var order = new Order { Id = Guid.NewGuid(), CustomerId = Guid.NewGuid(), Lines = [] };
var product = new Product { Id = Guid.NewGuid(), Price = new Money(10) };
Assert.Throws<InvalidOperationException>(() => order.AddLine(product, -1));
}The Domain is where you encode your business expertise. It should read like requirements documentation.
Application: use cases and interface contracts
Application translates Domain concepts into use cases. It defines what your system *does*, not *how* it does it. The key insight: interfaces live here because they express what the Application needs, not what the Infrastructure provides.
Here's a typical Application project structure:
namespace MyApp.Application.Orders;
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id);
Task SaveAsync(Order order);
}
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
public class CreateOrderUseCase
{
private readonly IOrderRepository _repository;
private readonly IEmailSender _emailSender;
public CreateOrderUseCase(IOrderRepository repository, IEmailSender emailSender)
{
_repository = repository;
_emailSender = emailSender;
}
public async Task<CreateOrderResponse> ExecuteAsync(CreateOrderRequest request)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = request.CustomerId,
Lines = []
};
foreach (var item in request.Items)
{
var product = await _repository.GetProductAsync(item.ProductId);
order.AddLine(product, item.Quantity);
}
await _repository.SaveAsync(order);
await _emailSender.SendAsync(
request.CustomerEmail,
"Order Confirmation",
$"Your order {order.Id} has been created"
);
return new CreateOrderResponse { OrderId = order.Id };
}
}
public record CreateOrderRequest(Guid CustomerId, string CustomerEmail, List<OrderItem> Items);
public record CreateOrderResponse(Guid OrderId);Notice: no Entity Framework, no ASP.NET Core, no email library. The use case orchestrates Domain logic and asks for what it needs via interfaces. The Application layer doesn't know if IEmailSender sends via SendGrid, AWS SES, or prints to console.
Infrastructure: implementations and external concerns
Infrastructure is where you implement those interfaces. This is where Entity Framework, Npgsql, SendGrid, S3, and other packages live:
namespace MyApp.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using MyApp.Domain.Orders;
using MyApp.Application.Orders;
public class EfOrderRepository : IOrderRepository
{
private readonly OrderDbContext _context;
public EfOrderRepository(OrderDbContext context)
{
_context = context;
}
public async Task<Order?> GetByIdAsync(Guid id)
{
return await _context.Orders.FirstOrDefaultAsync(o => o.Id == id);
}
public async Task SaveAsync(Order order)
{
_context.Orders.Add(order);
await _context.SaveChangesAsync();
}
}
namespace MyApp.Infrastructure.Email;
using SendGrid;
using SendGrid.Helpers.Mail;
using MyApp.Application.Orders;
public class SendGridEmailSender : IEmailSender
{
private readonly SendGridClient _client;
public SendGridEmailSender(string apiKey)
{
_client = new SendGridClient(apiKey);
}
public async Task SendAsync(string to, string subject, string body)
{
var msg = new SendGridMessage()
{
From = new EmailAddress("noreply@myapp.com"),
Subject = subject,
HtmlContent = body
};
msg.AddTo(new EmailAddress(to));
await _client.SendEmailAsync(msg);
}
}Infrastructure depends on Application to know what interfaces to implement. It also depends on Domain to work with entities. But Application never needs to know about Infrastructure details—that's the whole point.
Api: endpoints and dependency injection composition
The Api layer is where HTTP meets your clean architecture. It's thin: controllers receive requests, call use cases, return responses. Dependency injection is wired here:
namespace MyApp.Api.Orders;
using Microsoft.AspNetCore.Mvc;
using MyApp.Application.Orders;
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly CreateOrderUseCase _createOrderUseCase;
public OrdersController(CreateOrderUseCase createOrderUseCase)
{
_createOrderUseCase = createOrderUseCase;
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request)
{
var response = await _createOrderUseCase.ExecuteAsync(request);
return CreatedAtAction(nameof(Get), new { id = response.OrderId }, response);
}
[HttpGet("{id}")]
public IActionResult Get(Guid id)
{
// Implementation
return Ok();
}
}
namespace MyApp.Api;
using Microsoft.EntityFrameworkCore;
using MyApp.Infrastructure.Persistence;
using MyApp.Infrastructure.Email;
using MyApp.Application.Orders;
public static class ServiceRegistration
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration config)
{
// Infrastructure
services.AddDbContext<OrderDbContext>(opts =>
opts.UseNpgsql(config.GetConnectionString("Default")));
services.AddScoped<IOrderRepository, EfOrderRepository>();
services.AddScoped<IEmailSender>(sp =>
new SendGridEmailSender(config["SendGrid:ApiKey"]));
// Application
services.AddScoped<CreateOrderUseCase>();
return services;
}
}
// In Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplicationServices(builder.Configuration);
var app = builder.Build();
app.MapControllers();
app.Run();The Api layer is the only place where you glue everything together. It knows about SendGrid keys, database connection strings, and which HTTP methods to expose. Your business logic never does.
Project file layout
A typical folder structure looks like this:
| MyApp.Domain | .csproj with zero dependencies |
| MyApp.Application | .csproj references only Domain |
| MyApp.Infrastructure | .csproj references Domain + Application + EF, SendGrid, etc. |
| MyApp.Api | .csproj references all three + ASP.NET Core |
| MyApp.Tests | .csproj references Domain + Application + xUnit |
Do not over-layer small apps
Here's where I see teams fail: they apply Clean Architecture to a CRUD service with 10 endpoints and no complex business logic. Suddenly they have four projects, six layers, and eight interfaces for what should be a single controller.
My rule: start simple. If your app is a straightforward read-write service with minimal domain logic, you can skip Application or even merge Domain and Application into one project. The structure I've described is a target, not a law.
When do you actually need four projects? When:
- Your domain logic is complex enough to deserve its own layer (pricing, fulfillment, subscriptions)
- You're building multiple clients (web API, mobile API, background jobs)
- You anticipate swapping infrastructure (another team might own the database layer)
- Your team is large enough that clear boundaries prevent merge conflicts
A startup's MVP might live in two projects (Domain and Api) and graduate to four as it grows. That's not a failure—that's pragmatism.
Common mistakes
- Putting EF attributes in Domain entities — Use Fluent API in your DbContext instead. Keep Domain pure C#.
- Defining interfaces in Infrastructure — Interfaces belong in Application (or Domain for domain-specific contracts). Infrastructure implements them.
- Leaking HTTP types into Application — Never reference `IActionResult` or `HttpContext` in your use cases. Always convert at the Api boundary.
- Mixing infrastructure concerns in Application — If your use case directly instantiates `new SendGridClient()`, you've broken the boundary. Inject the interface instead.
- Creating a generic repository base class too early — Wait until you've written three repositories and can spot the real duplication. Otherwise you're guessing.
Key takeaways
- Clean Architecture: the .NET project layout enforces a dependency rule—inward only—that keeps your domain logic testable and your infrastructure replaceable.
- Domain has zero external dependencies; Application defines interfaces; Infrastructure implements them; Api wires everything together.
- Unit test your Domain in isolation. It should be boring—no databases, no mocks, no frameworks.
- Interfaces live in Application because they express what the application needs, not what the infrastructure provides.
- Don't over-architect. A simple CRUD service can live in two projects. Extract to four when complexity demands it.
- The structure is not dogma—it's a thinking tool that saves you pain as your codebase grows.






Comments
Loading comments…