Clean Architecture in .NET: Building Scalable Project Structure

15 min readFebruary 9, 2026Updated: Mar 9, 2026
.NET clean architectureClean architecture C#.NET project structureSOLID .NETOnion architecture.NET layersDomain driven design .NETscalable .NET

# 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.

csharp
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.

csharp
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;
    }
}
csharp
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.

csharp
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);
    }
}
csharp
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.

csharp
[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

  • **Predictable boundaries reduce accidental coupling.** When every developer knows that database code goes into `Infrastructure` and business rules go into `Domain`, code reviews become faster and more focused.
  • **Unit testing becomes faster and cheaper.** The domain and application layers have no infrastructure dependencies, so tests run in milliseconds without databases or containers.
  • **Infrastructure changes have lower blast radius.** Swapping PostgreSQL for CosmosDB means rewriting repositories, not business logic. I have done this swap on a production system with zero changes to the application layer.
  • **Parallel development across teams gets easier.** One team can build the payment integration while another builds the order workflow, meeting at the interface boundary.
  • 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.

    csharp
    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.

    csharp
    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.

    csharp
    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.

    csharp
    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.

    csharp
    [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

  • **Avoid over-engineering early.** Start with two projects (API + Domain) and split out Application and Infrastructure when you have three or more use cases that justify the separation.
  • **Keep use cases explicit and small.** One command handler should do one thing. If a handler exceeds fifty lines, it is probably doing too much.
  • **Prefer feature-based folders over technical mega-folders.** A folder named `Orders/Commands/PlaceOrder/` is infinitely more navigable than a folder named `Handlers/` with forty files.
  • **Add architecture tests to enforce boundaries.** Humans forget rules. CI does not.
  • **Use MediatR pipeline behaviours for cross-cutting concerns.** Validation, logging, and transaction management belong in behaviours, not scattered across every handler.
  • 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

    Have a Flutter Project?

    I build high-performance Flutter applications for iOS, Android, and web.

    Get in Touch