Skip to main content

Clean Architecture in C#: Build Maintainable .NET Apps

Learn clean architecture in C# with practical .NET examples. Build maintainable enterprise applications step by step — start structuring better code today.

If you've ever inherited a C# codebase where business rules were tangled inside controllers, Entity Framework calls leaked into every layer, and changing one feature broke three others, you already understand why clean architecture in C# has become the go-to pattern for enterprise .NET development. Clean architecture isn't about folders or fancy diagrams — it's about making your business logic independent of frameworks, databases, and UI so your application stays testable and maintainable for years, not months.

In this tutorial, you'll learn what clean architecture is, how to structure a real ASP.NET Core solution with it, and — most importantly — why each rule exists. We'll build a working example, cover best practices, and call out the pitfalls that trip up most teams.

What Is Clean Architecture in C#?

Clean architecture, popularized by Robert C. Martin ("Uncle Bob"), organizes code into concentric layers with one iron rule: dependencies always point inward. The innermost layers know nothing about the outer ones. In a typical .NET solution, that translates to four projects:

  • Domain — entities, value objects, domain events, and business rules. Zero dependencies on anything else. No NuGet packages except perhaps a base library.
  • Application — use cases (commands, queries, services) that orchestrate the domain. It defines interfaces for infrastructure concerns (repositories, email, clock) but never implements them.
  • Infrastructure — implementations of those interfaces: Entity Framework Core, SQL Server, Redis, SendGrid, file systems. This is where third-party code lives.
  • Presentation (API/UI) — ASP.NET Core controllers or minimal APIs, Blazor pages, or a console host. It wires everything together via dependency injection.

The dependency flow looks like this:

// Solution structure
// MyApp.Domain          --> (no dependencies)
// MyApp.Application     --> MyApp.Domain
// MyApp.Infrastructure  --> MyApp.Application, MyApp.Domain
// MyApp.Api             --> MyApp.Application, MyApp.Infrastructure (for DI registration only)

Why does this matter? Because frameworks change. Databases get swapped. UIs get rewritten. But your business rules — how an order is priced, when a discount applies, what makes an invoice valid — are the reason your application exists. Clean architecture protects that core from the churn around it.

Clean Architecture C# Example: Step by Step

Let's build a slice of an order management system. Create the solution:

dotnet new sln -n MyApp
dotnet new classlib -n MyApp.Domain
dotnet new classlib -n MyApp.Application
dotnet new classlib -n MyApp.Infrastructure
dotnet new webapi   -n MyApp.Api
dotnet sln add MyApp.Domain MyApp.Application MyApp.Infrastructure MyApp.Api

1. The Domain Layer: Pure Business Rules

Entities in the domain layer enforce their own invariants. Notice there's no [Key] attribute, no EF Core, no JSON annotations — nothing but C# and business logic:

namespace MyApp.Domain.Orders;

public class Order
{
    private readonly List<OrderLine> _lines = new();

    public Guid Id { get; private set; } = Guid.NewGuid();
    public string CustomerEmail { get; private set; }
    public OrderStatus Status { get; private set; } = OrderStatus.Draft;
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
    public decimal Total => _lines.Sum(l => l.UnitPrice * l.Quantity);

    public Order(string customerEmail)
    {
        if (string.IsNullOrWhiteSpace(customerEmail))
            throw new ArgumentException("Customer email is required.", nameof(customerEmail));
        CustomerEmail = customerEmail;
    }

    public void AddLine(string sku, decimal unitPrice, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify a submitted order.");
        if (quantity <= 0)
            throw new ArgumentOutOfRangeException(nameof(quantity));

        _lines.Add(new OrderLine(sku, unitPrice, quantity));
    }

    public void Submit()
    {
        if (_lines.Count == 0)
            throw new InvalidOperationException("An order must have at least one line.");
        Status = OrderStatus.Submitted;
    }
}

public record OrderLine(string Sku, decimal UnitPrice, int Quantity);

public enum OrderStatus { Draft, Submitted, Shipped, Cancelled }

Why private setters and guard clauses? Because an Order should never exist in an invalid state. If any code anywhere can set Status to Shipped on an empty order, your business rules live in code review comments instead of the compiler. Rich domain models turn invariants into things that cannot go wrong rather than things you hope don't.

2. The Application Layer: Use Cases and Interfaces

The application layer defines what the system does. It depends on abstractions, not implementations — this is the Dependency Inversion Principle in action:

namespace MyApp.Application.Orders;

// The Application layer OWNS this interface. Infrastructure implements it.
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    Task SaveChangesAsync(CancellationToken ct = default);
}

public record CreateOrderCommand(string CustomerEmail, List<OrderLineDto> Lines);
public record OrderLineDto(string Sku, decimal UnitPrice, int Quantity);

public class CreateOrderHandler
{
    private readonly IOrderRepository _orders;

    public CreateOrderHandler(IOrderRepository orders) => _orders = orders;

    public async Task<Guid> HandleAsync(CreateOrderCommand command, CancellationToken ct)
    {
        var order = new Order(command.CustomerEmail);

        foreach (var line in command.Lines)
            order.AddLine(line.Sku, line.UnitPrice, line.Quantity);

        order.Submit();

        await _orders.AddAsync(order, ct);
        await _orders.SaveChangesAsync(ct);

        return order.Id;
    }
}

Why does the application layer own the repository interface? This is the single most misunderstood part of clean architecture. If IOrderRepository lived in the Infrastructure project, the Application layer would have to reference Infrastructure — and your dependencies would point outward, defeating the entire design. By owning the interface, the use case dictates what persistence must provide; the database becomes a plugin.

3. The Infrastructure Layer: EF Core as a Detail

namespace MyApp.Infrastructure.Persistence;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(b =>
        {
            b.HasKey(o => o.Id);
            b.OwnsMany(typeof(OrderLine), "_lines"); // maps the private field
        });
    }
}

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _db;
    public OrderRepository(AppDbContext db) => _db = db;

    public Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
        _db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);

    public async Task AddAsync(Order order, CancellationToken ct = default) =>
        await _db.Orders.AddAsync(order, ct);

    public Task SaveChangesAsync(CancellationToken ct = default) =>
        _db.SaveChangesAsync(ct);
}

EF Core configuration lives entirely here. The domain entity never learned it was being persisted — that's the point.

4. The API Layer: Thin Controllers

// Program.cs (MyApp.Api)
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<CreateOrderHandler>();

app.MapPost("/orders", async (CreateOrderCommand cmd, CreateOrderHandler handler, CancellationToken ct) =>
{
    var id = await handler.HandleAsync(cmd, ct);
    return Results.Created($"/orders/{id}", new { id });
});

The endpoint does three things: accept input, invoke a use case, return output. If your controllers contain if statements about business rules, that logic belongs one layer down.

Why Clean Architecture in C# Pays Off: Testability

Here's the payoff. Because CreateOrderHandler depends only on an interface, you can unit test your entire business flow with no database, no web server, and no mocking framework gymnastics:

[Fact]
public async Task Submitting_an_empty_order_throws()
{
    var order = new Order("dev@example.com");
    Assert.Throws<InvalidOperationException>(() => order.Submit());
}

[Fact]
public async Task CreateOrder_persists_and_returns_id()
{
    var repo = new InMemoryOrderRepository(); // ~15 lines of test code
    var handler = new CreateOrderHandler(repo);

    var id = await handler.HandleAsync(
        new CreateOrderCommand("dev@example.com",
            new() { new("SKU-1", 9.99m, 2) }),
        CancellationToken.None);

    Assert.NotNull(await repo.GetByIdAsync(id));
}

These tests run in milliseconds and never flake. Teams that adopt clean architecture consistently report that test suites become faster and cheaper to write — which means tests actually get written.

Best Practices for Clean Architecture in .NET

  • Enforce dependencies with project references, not discipline. Because Domain physically cannot reference Infrastructure, violations become compile errors. Consider architecture tests with NetArchTest.Rules to lock rules in CI.
  • Keep the domain persistence-ignorant. No EF attributes on entities; use Fluent API configuration in Infrastructure instead.
  • Use CQRS-lite where it helps. Separating commands from queries (with or without MediatR) keeps handlers small. You don't need event sourcing to benefit.
  • Return results, not exceptions, for expected failures. A Result<T> type for validation errors keeps exceptions for truly exceptional cases and makes error paths explicit.
  • One use case, one handler. Fat "service" classes with 30 methods recreate the ball of mud inside a nicer folder structure.

Common Pitfalls (and How to Avoid Them)

  • Anemic domain models. If entities are just property bags and all logic sits in services, you have layered architecture, not clean architecture. Push behavior into entities.
  • Leaking EF entities to the API. Returning domain entities from endpoints couples your public contract to your database schema. Map to DTOs at the boundary.
  • Interface-for-everything syndrome. Don't create IOrderService with exactly one implementation for a class with no external dependencies. Abstract things that cross the boundary (database, email, time, HTTP) — not your own pure logic.
  • Over-engineering small apps. A three-endpoint internal tool doesn't need four projects. Clean architecture earns its keep when the codebase and team grow; for a prototype, a well-organized single project is fine. Architecture is a trade-off, not a religion.
  • Generic repositories that fight EF Core. IRepository<T> with GetAll() often hides queryability and causes performance problems. Prefer purpose-built repositories per aggregate, with methods named after use cases.

Conclusion: Key Takeaways

Clean architecture in C# gives your enterprise applications a stable core that outlives frameworks, databases, and UI trends. The essentials to remember:

  • Dependencies point inward: Domain ← Application ← Infrastructure/Presentation.
  • The Application layer owns its interfaces; Infrastructure implements them. That inversion is the whole trick.
  • Rich domain entities enforce invariants so invalid states are unrepresentable.
  • Testability isn't a side effect — it's the proof your architecture is actually decoupled.
  • Apply it where complexity justifies it; don't ceremony-tax a weekend project.

Start small: pick one feature in your current ASP.NET Core app, extract its business rules into a persistence-ignorant class, and write a fast unit test against it. Once you feel how cheap change becomes, you'll never want to go back. Happy coding!

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...