Clean Architecture in .NET: Building Scalable Project Structure
# Clean Architecture in .NET: Building Scalable Project Structure
Clean Architecture is a practical way to keep large .NET systems change-friendly. The key idea is simple: business rules should not depend on framework or infrastructure details. Dependencies always point inward, toward the domain core, and never the other way around.
In enterprise .NET projects I've architected, this approach has consistently been the difference between codebases that age well and ones that collapse under their own weight after a year or two. The pattern is not about dogma; it is about giving your team a shared mental model for where code belongs.
Project Structure
A well-organized solution makes the architecture visible at a glance. Here is the folder and file layout I recommend:
src/
├── MyApp.Domain/
│ ├── Entities/
│ │ ├── Order.cs
│ │ ├── OrderItem.cs
│ │ └── Customer.cs
│ ├── ValueObjects/
│ │ ├── Money.cs
│ │ └── Address.cs
│ ├── Enums/
│ │ └── OrderStatus.cs
│ ├── Events/
│ │ └── OrderPlacedEvent.cs
│ ├── Exceptions/
│ │ └── InsufficientStockException.cs
│ └── MyApp.Domain.csproj
│
├── MyApp.Application/
│ ├── Common/
│ │ ├── Interfaces/
│ │ │ ├── IOrderRepository.cs
│ │ │ ├── IPaymentGateway.cs
│ │ │ └── IUnitOfWork.cs
│ │ ├── Behaviours/
│ │ │ ├── ValidationBehaviour.cs
│ │ │ └── LoggingBehaviour.cs
│ │ └── Mappings/
│ │ └── OrderMappingProfile.cs
│ ├── Orders/
│ │ ├── Commands/
│ │ │ ├── PlaceOrder/
│ │ │ │ ├── PlaceOrderCommand.cs
│ │ │ │ ├── PlaceOrderCommandHandler.cs
│ │ │ │ └── PlaceOrderCommandValidator.cs
│ │ │ └── CancelOrder/
│ │ │ ├── CancelOrderCommand.cs
│ │ │ └── CancelOrderCommandHandler.cs
│ │ └── Queries/
│ │ └── GetOrderById/
│ │ ├── GetOrderByIdQuery.cs
│ │ ├── GetOrderByIdQueryHandler.cs
│ │ └── OrderDto.cs
│ └── MyApp.Application.csproj
│
├── MyApp.Infrastructure/
│ ├── Persistence/
│ │ ├── AppDbContext.cs
│ │ ├── Configurations/
│ │ │ └── OrderConfiguration.cs
│ │ └── Repositories/
│ │ └── OrderRepository.cs
│ ├── Services/
│ │ └── StripePaymentGateway.cs
│ ├── DependencyInjection.cs
│ └── MyApp.Infrastructure.csproj
│
├── MyApp.API/
│ ├── Controllers/
│ │ └── OrdersController.cs
│ ├── Middleware/
│ │ └── ExceptionHandlingMiddleware.cs
│ ├── Program.cs
│ └── MyApp.API.csproj
│
tests/
├── MyApp.Domain.Tests/
├── MyApp.Application.Tests/
├── MyApp.Infrastructure.Tests/
└── MyApp.API.Tests/The rule is strict: each project can only reference layers deeper in the circle, never outward. `Domain` references nothing. `Application` references only `Domain`. `Infrastructure` and `API` reference `Application` (and transitively `Domain`).
Layer Responsibilities
Domain
The domain layer is the heart of the system. It contains entities, value objects, domain events, and business rules with zero external dependencies. Not even a NuGet package for logging belongs here.
public class Order
{
private readonly List<OrderItem> _items = new();
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money TotalAmount { get; private set; }
public DateTime CreatedAt { get; private set; }
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
private Order() { } class=class="code-string">"code-comment">// EF Core constructor
public static Order Create(Guid customerId)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Status = OrderStatus.Draft,
TotalAmount = Money.Zero(class="code-string">"USD"),
CreatedAt = DateTime.UtcNow
};
order.AddDomainEvent(new OrderPlacedEvent(order.Id));
return order;
}
public void AddItem(string productName, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException(
class="code-string">"Items can only be added to draft orders.");
if (quantity <= class="code-number">0)
throw new ArgumentException(
class="code-string">"Quantity must be positive.", nameof(quantity));
var item = new OrderItem(Id, productName, quantity, unitPrice);
_items.Add(item);
RecalculateTotal();
}
public void Confirm()
{
if (_items.Count == class="code-number">0)
throw new InvalidOperationException(
class="code-string">"Cannot confirm an order with no items.");
Status = OrderStatus.Confirmed;
}
private void RecalculateTotal()
{
TotalAmount = _items
.Aggregate(Money.Zero(class="code-string">"USD"),
(sum, item) => sum.Add(item.LineTotal));
}
}Notice how all business rules live inside the entity itself. The order knows that it cannot be confirmed without items. The order knows how to recalculate its total. No service or controller should duplicate these rules.
Application
The application layer orchestrates use cases. It defines commands, queries, and the interfaces that infrastructure must implement. I use MediatR for dispatching, but the pattern works with any mediator or even plain method calls.
class=class="code-string">"code-comment">// Command definition
public record PlaceOrderCommand(
Guid CustomerId,
List<OrderItemDto> Items) : IRequest<Guid>;
class=class="code-string">"code-comment">// Command handler
public class PlaceOrderCommandHandler
: IRequestHandler<PlaceOrderCommand, Guid>
{
private readonly IOrderRepository _orderRepository;
private readonly IUnitOfWork _unitOfWork;
public PlaceOrderCommandHandler(
IOrderRepository orderRepository,
IUnitOfWork unitOfWork)
{
_orderRepository = orderRepository;
_unitOfWork = unitOfWork;
}
public async Task<Guid> Handle(
PlaceOrderCommand request,
CancellationToken cancellationToken)
{
var order = Order.Create(request.CustomerId);
foreach (var item in request.Items)
{
order.AddItem(
item.ProductName,
item.Quantity,
new Money(item.Price, item.Currency));
}
order.Confirm();
_orderRepository.Add(order);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return order.Id;
}
}class=class="code-string">"code-comment">// Repository interface lives in Application, not Infrastructure
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
void Add(Order order);
void Remove(Order order);
}
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}The handler reads like a recipe: create an order, add items, confirm, save. If the business rules change, you change the domain entity. If the workflow changes, you change the handler. If the database changes, nothing here is affected.
Infrastructure
Infrastructure implements the contracts defined in the application layer. This is where Entity Framework, external API clients, message brokers, and file storage live.
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public OrderRepository(AppDbContext context)
{
_context = context;
}
public async Task<Order?> GetByIdAsync(
Guid id, CancellationToken ct = default)
{
return await _context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
public void Add(Order order)
{
_context.Orders.Add(order);
}
public void Remove(Order order)
{
_context.Orders.Remove(order);
}
}class=class="code-string">"code-comment">// Registering infrastructure services
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(
configuration.GetConnectionString(class="code-string">"Default")));
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IUnitOfWork>(sp =>
sp.GetRequiredService<AppDbContext>());
return services;
}
}Presentation
Controllers or minimal API endpoints handle HTTP concerns only: deserializing requests, dispatching commands/queries, and shaping responses. No business logic belongs here.
[ApiController]
[Route(class="code-string">"api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly ISender _sender;
public OrdersController(ISender sender)
{
_sender = sender;
}
[HttpPost]
public async Task<IActionResult> PlaceOrder(
PlaceOrderRequest request,
CancellationToken ct)
{
var command = new PlaceOrderCommand(
request.CustomerId, request.Items);
var orderId = await _sender.Send(command, ct);
return CreatedAtAction(
nameof(GetOrder),
new { id = orderId },
new { id = orderId });
}
[HttpGet(class="code-string">"{id:guid}")]
public async Task<IActionResult> GetOrder(Guid id, CancellationToken ct)
{
var query = new GetOrderByIdQuery(id);
var result = await _sender.Send(query, ct);
return result is null ? NotFound() : Ok(result);
}
}Why It Works in Real Teams
Vertical Slices vs Layered: When to Choose What
This is the debate that comes up in every architecture discussion, and the honest answer is: they are not mutually exclusive.
Traditional layered architecture organizes code by technical concern. All controllers in one folder, all services in another, all repositories in a third. This works well when layers have high internal cohesion, but in large projects you end up scrolling across five folders to understand a single feature.
Vertical slice architecture organizes code by feature. Everything related to "Place Order" lives in one folder: the endpoint, the handler, the validator, the data access. Each slice is independent and self-contained.
In enterprise .NET projects I've architected, I use a hybrid: Clean Architecture layers for the macro structure and project boundaries, with feature-based folders inside each layer. The project structure above already reflects this. The `Orders/Commands/PlaceOrder/` folder is a vertical slice within the application layer.
| Aspect | Pure Layered | Pure Vertical Slice | Hybrid (Recommended) |
|--------|-------------|--------------------|--------------------|
| Feature discoverability | Low | High | High |
| Cross-cutting concerns | Easy | Requires conventions | Managed via behaviours |
| Team scaling | Moderate | High | High |
| Refactoring safety | High (with tests) | Moderate | High |
| Learning curve | Low | Moderate | Moderate |
The key insight: start with Clean Architecture boundaries between projects, then organize files by feature within those projects. You get the dependency safety of layers with the navigability of slices.
Testing Each Layer
Each layer has a distinct testing strategy. Getting this right makes the difference between a test suite that runs in seconds and one that takes twenty minutes.
Domain Tests
Domain tests are pure unit tests. No mocking frameworks needed, no test containers, no setup. Just instantiate the entity and assert behavior.
public class OrderTests
{
[Fact]
public void Confirm_WithItems_ShouldSetStatusToConfirmed()
{
var order = Order.Create(Guid.NewGuid());
order.AddItem(class="code-string">"Widget", class="code-number">2, new Money(class="code-number">10.00m, class="code-string">"USD"));
order.Confirm();
Assert.Equal(OrderStatus.Confirmed, order.Status);
}
[Fact]
public void Confirm_WithoutItems_ShouldThrow()
{
var order = Order.Create(Guid.NewGuid());
Assert.Throws<InvalidOperationException>(() => order.Confirm());
}
[Fact]
public void AddItem_ShouldRecalculateTotal()
{
var order = Order.Create(Guid.NewGuid());
order.AddItem(class="code-string">"Widget", class="code-number">3, new Money(class="code-number">10.00m, class="code-string">"USD"));
Assert.Equal(class="code-number">30.00m, order.TotalAmount.Amount);
}
}Application Tests
Application tests verify use case orchestration. Mock the repository and unit of work interfaces, then assert the handler calls them correctly.
public class PlaceOrderCommandHandlerTests
{
[Fact]
public async Task Handle_ValidOrder_ShouldPersistAndReturnId()
{
var repo = new Mock<IOrderRepository>();
var uow = new Mock<IUnitOfWork>();
var handler = new PlaceOrderCommandHandler(repo.Object, uow.Object);
var command = new PlaceOrderCommand(
Guid.NewGuid(),
new List<OrderItemDto>
{
new(class="code-string">"Widget", class="code-number">2, class="code-number">15.00m, class="code-string">"USD")
});
var orderId = await handler.Handle(command, CancellationToken.None);
repo.Verify(r => r.Add(It.Is<Order>(o =>
o.Items.Count == class="code-number">1 && o.Status == OrderStatus.Confirmed)),
Times.Once);
uow.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()),
Times.Once);
Assert.NotEqual(Guid.Empty, orderId);
}
}Infrastructure Tests
Infrastructure tests use a real database, typically an in-memory provider or a Testcontainers instance. These are integration tests that verify your EF Core mappings and queries work correctly.
public class OrderRepositoryTests : IDisposable
{
private readonly AppDbContext _context;
public OrderRepositoryTests()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_context = new AppDbContext(options);
}
[Fact]
public async Task GetByIdAsync_ExistingOrder_ShouldReturnWithItems()
{
var order = Order.Create(Guid.NewGuid());
order.AddItem(class="code-string">"Widget", class="code-number">1, new Money(class="code-number">9.99m, class="code-string">"USD"));
_context.Orders.Add(order);
await _context.SaveChangesAsync();
var repo = new OrderRepository(_context);
var result = await repo.GetByIdAsync(order.Id);
Assert.NotNull(result);
Assert.Single(result!.Items);
}
public void Dispose() => _context.Dispose();
}API / Integration Tests
End-to-end tests use `WebApplicationFactory` to spin up the full pipeline and verify HTTP behavior.
public class OrdersEndpointTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersEndpointTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task PlaceOrder_ShouldReturn201()
{
var request = new { CustomerId = Guid.NewGuid(),
Items = new[] { new { ProductName = class="code-string">"Widget",
Quantity = class="code-number">1, Price = class="code-number">9.99, Currency = class="code-string">"USD" } } };
var response = await _client.PostAsJsonAsync(class="code-string">"/api/orders", request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
}Common Architecture Mistakes
These are patterns I have seen repeatedly in consulting engagements and production codebases. Each one seems harmless at first but compounds into serious problems.
1. Business Logic in Controllers
The most common violation. A controller that calculates discounts, validates stock levels, or sends emails is doing the work of the domain and application layers. The result: logic that cannot be tested without spinning up an HTTP server, and logic that gets duplicated when a second endpoint needs the same behavior.
2. Anemic Domain Model
Entities that are nothing but property bags with public setters, paired with "service" classes that contain all the behavior. This defeats the purpose of having a domain layer. If `OrderService.Confirm(order)` exists, ask yourself why `order.Confirm()` does not.
3. Leaking Infrastructure into Application
The moment your command handler references `DbContext` directly instead of `IOrderRepository`, you have coupled your use case to Entity Framework. This makes testing harder and migration to another data store nearly impossible without rewriting business logic.
4. Over-Abstracting Everything
Creating `IOrderService`, `IOrderManager`, `IOrderProcessor`, and `IOrderHandler` when a single command handler would suffice. Every interface should exist because something needs to be swapped or tested in isolation. Abstractions without a consumer are noise.
5. Ignoring the Dependency Rule
The dependency rule says that inner layers must not know about outer layers. If your `Domain` project has a `using Microsoft.EntityFrameworkCore` statement, something has gone wrong. Use architecture tests (NetArchTest or ArchUnitNET) to enforce this automatically in CI.
[Fact]
public void Domain_ShouldNotReference_Infrastructure()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.ShouldNot()
.HaveDependencyOn(class="code-string">"MyApp.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful);
}6. Not Using CQRS Where It Helps
Using the same model for reads and writes leads to bloated DTOs and awkward queries. Separating read models (optimized projections) from write models (rich domain entities) simplifies both sides dramatically. You do not need event sourcing to benefit from CQRS.
Implementation Advice
Conclusion
Clean Architecture is most valuable when your product scope and team size grow. It trades a small amount of short-term ceremony for long-term stability and clarity. The layered structure is not a straitjacket; it is a set of guardrails that keep your codebase navigable and your deployments predictable.
In enterprise .NET projects I've architected, the teams that adopted this structure consistently shipped faster after the initial learning curve, because every developer knew exactly where to put new code and where to find existing logic.
I can help you migrate an existing .NET codebase to a pragmatic Clean Architecture setup.
Related Articles
What is .NET? A Modern Backend Development Guide
Learn what .NET is, how it works, and why enterprise teams choose it for backend development.
Building RESTful APIs with ASP.NET Core
Learn the fundamentals of building production-ready REST APIs with ASP.NET Core. Controllers, routing, and best practices.
Dependency Injection in .NET: Core Concepts and Implementation
Understand and implement Dependency Injection in .NET correctly. Service lifetimes, registration patterns, and best practices.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch