Clean Architecture in C#: Structuring Your Next Business Application for Longevity

Your last .NET project started clean. A few controllers, a service layer, Entity Framework at the bottom. Six months later, the service layer had 40 classes, half of them calling each other in circles. A year in, nobody wanted to touch the order processing code because changing one method broke three unrelated tests. The intern who joined in month eight described the architecture as "a lasagna that someone dropped."

This is the default trajectory for business applications built by small teams without deliberate architecture decisions. And it is worse at SMB scale — because you do not have 15 developers who can each own a bounded context. You have three or four people who need to work on the same codebase for the next five to ten years without wanting to quit.

Clean Architecture is not the only way to structure a C# business application, but it is the one I keep coming back to after 16 years of building .NET systems for Nordic SMBs. Not because it is theoretically elegant — because it makes specific, practical problems go away.

Why Architecture Matters More at SMB Scale

At a large company, you can afford to rewrite modules. You have specialists, dedicated architects, and a budget for "technical modernization initiatives." At a Nordic SMB with 10–50 employees, the application your three-person dev team builds this year is the one you will maintain for the next decade.

Bad architecture compounds. Every shortcut today becomes a tax on every feature tomorrow. When your service layer depends directly on Entity Framework and your controllers contain business logic, you get three specific problems:

  1. Testing is painful. You cannot test business rules without spinning up a database. So you either write slow integration tests for everything or you skip testing the complex logic entirely.
  2. Changes ripple unpredictably. Modifying the database schema forces changes in business logic classes that should not care about persistence. A UI change requires modifying code three layers deep.
  3. New developers need months to become productive. Without clear boundaries, the only way to understand the system is to read all of it. In a 100k-line codebase, that is not realistic.

Clean Architecture addresses all three. It is not free — there is an upfront cost in structure and ceremony. But for a business application that needs to live longer than two years, the investment pays off within the first six months.

Clean Architecture in Practice: The Dependency Rule

The core idea is simple: dependencies point inward. Your business logic does not know about your database, your API framework, or your UI. Those outer layers depend on the inner layers — never the reverse.

Here is how that maps to a real .NET solution structure:

OrderManager/
├── OrderManager.sln
├── src/
│   ├── OrderManager.Domain/           # Innermost — entities, value objects, domain events
│   ├── OrderManager.Application/      # Use cases, interfaces, DTOs
│   ├── OrderManager.Infrastructure/   # EF Core, email, file storage, external APIs
│   └── OrderManager.Api/             # ASP.NET Core controllers, middleware, DI setup
└── tests/
    ├── OrderManager.Domain.Tests/
    ├── OrderManager.Application.Tests/
    └── OrderManager.Integration.Tests/

Four projects. Not twelve. I have seen teams create separate projects for every cross-cutting concern — OrderManager.Logging, OrderManager.Validation, OrderManager.Mapping — until the solution had 25 projects and took 45 seconds to build. That is architecture astronautics. Four projects give you the dependency boundaries you need without the overhead.

Domain Layer

This is where your business rules live. No NuGet packages. No framework references. Just C#.

// OrderManager.Domain/Entities/Order.cs
namespace OrderManager.Domain.Entities;

public class Order
{
    public Guid Id { get; private set; }
    public string CustomerReference { get; private set; }
    public OrderStatus Status { get; private set; }
    public List<OrderLine> Lines { get; private set; } = new();
    public DateTime CreatedAt { get; private set; }

    private Order() { } // EF Core needs this

    public static Order Create(string customerReference)
    {
        if (string.IsNullOrWhiteSpace(customerReference))
            throw new DomainException("Customer reference is required.");

        return new Order
        {
            Id = Guid.NewGuid(),
            CustomerReference = customerReference,
            Status = OrderStatus.Draft,
            CreatedAt = DateTime.UtcNow
        };
    }

    public void AddLine(string productCode, int quantity, decimal unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Cannot modify a submitted order.");

        if (quantity <= 0)
            throw new DomainException("Quantity must be positive.");

        Lines.Add(new OrderLine(productCode, quantity, unitPrice));
    }

    public void Submit()
    {
        if (!Lines.Any())
            throw new DomainException("Cannot submit an order with no lines.");

        Status = OrderStatus.Submitted;
    }

    public decimal Total => Lines.Sum(l => l.Quantity * l.UnitPrice);
}

Notice what is not here: no [Required] attributes, no INotifyPropertyChanged, no DbSet references. The domain layer knows nothing about how it will be persisted or displayed. The Create factory method and AddLine method enforce business rules — an order must have a customer reference, you cannot modify a submitted order, quantities must be positive.

This is code you can test with plain unit tests. No mocking frameworks, no database, no DI container.

Application Layer

The application layer orchestrates use cases. It defines what the system does, but not how the infrastructure works. It depends on the Domain layer and defines interfaces that the Infrastructure layer implements.

// OrderManager.Application/Interfaces/IOrderRepository.cs
namespace OrderManager.Application.Interfaces;

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id);
    Task AddAsync(Order order);
    Task SaveChangesAsync();
}

// OrderManager.Application/UseCases/SubmitOrder/SubmitOrderCommand.cs
namespace OrderManager.Application.UseCases.SubmitOrder;

public record SubmitOrderCommand(Guid OrderId);

// OrderManager.Application/UseCases/SubmitOrder/SubmitOrderHandler.cs
namespace OrderManager.Application.UseCases.SubmitOrder;

public class SubmitOrderHandler
{
    private readonly IOrderRepository _orders;
    private readonly INotificationService _notifications;

    public SubmitOrderHandler(IOrderRepository orders, INotificationService notifications)
    {
        _orders = orders;
        _notifications = notifications;
    }

    public async Task HandleAsync(SubmitOrderCommand command)
    {
        var order = await _orders.GetByIdAsync(command.OrderId)
            ?? throw new NotFoundException($"Order {command.OrderId} not found.");

        order.Submit();

        await _orders.SaveChangesAsync();
        await _notifications.SendOrderConfirmationAsync(order);
    }
}

The handler calls order.Submit() — which contains the business rule — and then coordinates the infrastructure concerns (save to database, send notification). It depends on interfaces, not implementations. Swapping PostgreSQL for SQL Server, or SendGrid for SMTP, does not touch this code.

Infrastructure Layer

This is where the real world leaks in. EF Core, SMTP clients, file storage, third-party API integrations — all of it lives here.

// OrderManager.Infrastructure/Persistence/OrderRepository.cs
namespace OrderManager.Infrastructure.Persistence;

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context) => _context = context;

    public async Task<Order?> GetByIdAsync(Guid id)
        => await _context.Orders
            .Include(o => o.Lines)
            .FirstOrDefaultAsync(o => o.Id == id);

    public async Task AddAsync(Order order)
        => await _context.Orders.AddAsync(order);

    public async Task SaveChangesAsync()
        => await _context.SaveChangesAsync();
}

Nothing surprising. The infrastructure implements the interfaces defined in the Application layer. EF Core configuration, database migrations, email sending — it all goes here.

API Layer

The outermost layer wires everything together and exposes HTTP endpoints.

// OrderManager.Api/Controllers/OrdersController.cs
namespace OrderManager.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly SubmitOrderHandler _submitHandler;

    public OrdersController(SubmitOrderHandler submitHandler)
        => _submitHandler = submitHandler;

    [HttpPost("{id}/submit")]
    public async Task<IActionResult> Submit(Guid id)
    {
        await _submitHandler.HandleAsync(new SubmitOrderCommand(id));
        return NoContent();
    }
}

Thin controllers. No business logic. The controller's only job is to translate HTTP into application commands and application results back into HTTP.

Testing Strategy That Fits a 3-Person Team

The most common objection I hear: "We're a small team, we don't have time for comprehensive tests." Fair enough. Here is what actually works when you have three developers and deadlines.

Unit test the Domain layer aggressively. This is where your business rules live, and these tests are fast — milliseconds per test, no setup required.

[Fact]
public void Submit_WithNoLines_ThrowsDomainException()
{
    var order = Order.Create("CUST-001");

    var ex = Assert.Throws<DomainException>(() => order.Submit());

    Assert.Equal("Cannot submit an order with no lines.", ex.Message);
}

[Fact]
public void AddLine_ToSubmittedOrder_ThrowsDomainException()
{
    var order = Order.Create("CUST-001");
    order.AddLine("PROD-A", 2, 100m);
    order.Submit();

    Assert.Throws<DomainException>(
        () => order.AddLine("PROD-B", 1, 50m));
}

Unit test the Application layer with fakes. The interfaces make this straightforward — inject in-memory implementations of your repositories and services.

Integration test the critical paths. Use WebApplicationFactory with a real test database (PostgreSQL in Docker via Testcontainers) for the handful of flows that must work end-to-end: creating an order, submitting it, and verifying the result. Five to ten integration tests covering the happy path and the most dangerous edge cases.

Skip UI tests unless you have a dedicated QA person. Selenium and Playwright tests are expensive to write and maintain. A three-person team's testing budget is better spent on domain and integration tests.

This gives you a test pyramid that a small team can actually maintain: 50+ fast domain tests, 20–30 application tests, and 5–10 integration tests. Total test suite runs in under 30 seconds.

When YAGNI Beats Architecture

I have talked about when Clean Architecture pays off. Now for the uncomfortable part: when it is overkill.

Internal tools with a short lifespan. Building an admin dashboard that three people will use for 18 months before the business switches to a SaaS product? A single-project ASP.NET Core app with Razor Pages and EF Core directly in the page models is fine. Ship it in two weeks, not six.

Prototypes and MVPs. If you are not sure the product will survive, do not invest in architecture. A messy monolith that validates the business idea is infinitely more valuable than a beautifully layered application that never ships.

CRUD-heavy apps with minimal business logic. If your application is mostly data in, data out — no complex calculations, no workflow orchestration, no business rules beyond "this field is required" — the Application layer adds ceremony without value. EF Core entities in a service class behind a controller is fine.

When the team does not understand it. Clean Architecture requires the team to understand and enforce the dependency rule. If your developers keep putting EF Core references in the Domain project because "it's easier," the architecture degrades into a mess with extra folders. Training first, architecture second.

The key question is: does this application have business logic worth protecting? If yes, Clean Architecture earns its keep. If the application is mostly plumbing — moving data between a UI and a database — simpler is better.

A Scoring Framework for the Decision

When clients ask me whether their next C# business application needs Clean Architecture, I walk through five questions:

Question Yes = +1 No = 0
Will this application be maintained for 3+ years? +1 0
Does it contain business rules beyond basic CRUD? +1 0
Will more than two developers work on it simultaneously? +1 0
Is testability a stated requirement? +1 0
Is there a reasonable chance the persistence layer or UI framework will change? +1 0

Score 4–5: Clean Architecture is a strong fit. The upfront cost will save you pain later.

Score 2–3: Consider a lighter version — maybe just Domain and Infrastructure separation without a full Application layer. Use your judgment.

Score 0–1: Keep it simple. A well-organized single-project app with good naming conventions will serve you better than an over-engineered layered solution.

For the order management system I showed in this article, the score was 5/5: long-lived, complex business rules, three developers, testability required, and a planned migration from SQL Server to PostgreSQL within two years. Clean Architecture was the obvious choice.

Common Mistakes I See in Nordic .NET Teams

After reviewing dozens of C# codebases for Nordic SMBs, a few anti-patterns keep showing up:

Anemic domain models. Entities are just property bags, and all business logic lives in services. This defeats the purpose of having a domain layer. If your Order class has no methods — just public getters and setters — your domain model is a data transfer object with extra steps.

Over-abstracting the repository. A generic IRepository<T> with 15 methods sounds clean until you need a query that does not fit the generic interface. Specific repositories (IOrderRepository) with domain-relevant methods work better in practice.

Injecting the DbContext into application handlers directly. This ties your use cases to EF Core. When you want to test the handler, you need a database. The repository interface keeps that boundary clean.

Creating too many projects. I mentioned this before, but it bears repeating. Four projects. Not twelve. Every additional project adds build time, cognitive overhead, and cross-project dependency management. A Shared.Kernel project is only justified when you actually have multiple bounded contexts sharing domain primitives.

Next Steps

If you are starting a new C# business application, try the four-project structure I described. Clone the layout, set up the project references so dependencies point inward, and build your first use case through all four layers. You will feel the structure guide your decisions — and you will notice immediately when something wants to violate the dependency rule.

If you have an existing application that has outgrown its architecture, a re-platform project is a natural time to introduce Clean Architecture. I covered the migration process in detail in my previous article on .NET modernization.


Ready to migrate off the cloud?

I put together a Cloud Exit Starter Kit ($49) — Ansible playbooks, Docker Compose production templates, and the migration checklist I use on real projects. Everything you need to go from Azure/AWS to your own hardware.

Or if you just want to talk it through: book a free 30-minute cloud exit assessment. No sales pitch — just an honest look at whether on-prem makes sense for your situation.

Read more