DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

Clean Architecture in .NET — From Pretty Diagrams to Production‑Ready Code

Clean Architecture in .NET — From Pretty Diagrams to Production‑Ready Code

Clean Architecture in .NET — From Pretty Diagrams to Production‑Ready Code

If you’ve been scrolling through Clean Architecture diagrams like the ones above—colorful boxes for Presentation, Domain, Data—and thinking:

“OK, but what does this look like in real .NET code?”

…this post is for you.

We’ll take the classic Clean Architecture ideas (layers, use cases, entities, repositories) and map them directly into a modern .NET solution that you can actually ship.

You’ll learn:

  • How to map the three core layers (Presentation, Domain, Data) into .NET projects
  • How to structure Entities, Use Cases, and Repositories in C#
  • How the Dependency Rule works in practice with DI
  • How the call flow looks from Controller → Use Case → Repository → Data Source
  • How to keep your architecture testable, maintainable, and framework‑independent

1. Clean Architecture in One Sentence (for .NET devs)

Business rules in the center, technology at the edges.

Inner layers know nothing about outer layers. Dependencies point inward.

In .NET terms:

  • Your Domain doesn’t know about ASP.NET Core, EF Core, SQL, Redis, or Serilog.
  • Your Application / Use Cases only depend on interfaces, not EF DbContexts or HttpClients.
  • Your Web API and Infrastructure glue everything together via Dependency Injection.

The diagrams you shared (concentric circles, horizontal slices by layer) all say the same thing:

  • Entities & Use Cases are the heart of the system.
  • Repositories & Data Sources sit at the edge and implement the interfaces the domain needs.
  • Presentation talks to the domain through use cases, not directly to the database.

2. Project Layout: A Practical .NET Clean Architecture

Here’s a simple 4‑project layout that works extremely well in .NET:

src/
  MyApp.Domain          // Entities, Value Objects, Interfaces, Use Cases
  MyApp.Application     // DTOs, Commands/Queries, Orchestrating Services
  MyApp.Infrastructure  // EF Core, external APIs, implementations
  MyApp.Api             // ASP.NET Core Web API (controllers / endpoints)
Enter fullscreen mode Exit fullscreen mode

You can align this with the diagrams like this:

  • Domain LayerMyApp.Domain
    • Entities, Value Objects, Repository interfaces, Use Case base interfaces
  • Application / Use CasesMyApp.Application
    • Use case implementations, command/query handlers, DTOs
  • Data LayerMyApp.Infrastructure
    • Repositories, Data Sources (EF, Dapper, HTTP, queues), mappers
  • Presentation LayerMyApp.Api
    • Controllers, Minimal APIs, filters, authentication, etc.

All arrows (project references) should point inward:

MyApp.Api          → MyApp.Application
MyApp.Application  → MyApp.Domain
MyApp.Infrastructure → MyApp.Application, MyApp.Domain
Enter fullscreen mode Exit fullscreen mode

ASP.NET Core never references Infrastructure directly by project reference—it only sees interfaces from Domain/Application and receives concrete implementations via DI.


3. Domain Layer — Entities, Value Objects, Repository Interfaces

The Domain is pure C#. No EF attributes, no HTTP, no logging. Just business rules.

3.1 Entity Example

namespace MyApp.Domain.Orders;

public sealed class Order
{
    public Guid Id { get; private set; }
    public string OrderNumber { get; private set; }
    public OrderStatus Status { get; private set; }
    public Money Total { get; private set; }

    private readonly List<OrderLine> _lines = new();
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    private Order() { } // For ORM only

    public Order(string orderNumber)
    {
        Id = Guid.NewGuid();
        OrderNumber = orderNumber ?? throw new ArgumentNullException(nameof(orderNumber));
        Status = OrderStatus.Draft;
        Total = Money.Zero("USD");
    }

    public void AddLine(string sku, int quantity, Money unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot modify a non-draft order.");

        var line = new OrderLine(sku, quantity, unitPrice);
        _lines.Add(line);
        RecalculateTotal();
    }

    public void Confirm()
    {
        if (!Lines.Any())
            throw new InvalidOperationException("Cannot confirm an empty order.");

        Status = OrderStatus.Confirmed;
    }

    private void RecalculateTotal()
        => Total = _lines.Aggregate(Money.Zero(Total.Currency),
            (acc, x) => acc + (x.UnitPrice * x.Quantity));
}
Enter fullscreen mode Exit fullscreen mode

Notice: zero references to EF, ASP.NET Core, SQL, JSON, or HTTP.

This is pure domain logic and can be unit‑tested without any infrastructure.

3.2 Value Object Example

namespace MyApp.Domain.Shared;

public readonly struct Money : IEquatable<Money>
{
    public string Currency { get; }
    public decimal Amount { get; }

    public Money(string currency, decimal amount)
    {
        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException("Currency is required.", nameof(currency));

        Currency = currency.ToUpperInvariant();
        Amount = amount;
    }

    public static Money Zero(string currency) => new(currency, 0m);

    public static Money operator +(Money left, Money right)
    {
        if (!string.Equals(left.Currency, right.Currency, StringComparison.OrdinalIgnoreCase))
            throw new InvalidOperationException("Cannot add different currencies.");

        return new Money(left.Currency, left.Amount + right.Amount);
    }

    public bool Equals(Money other)
        => Currency == other.Currency && Amount == other.Amount;

    public override bool Equals(object? obj) => obj is Money m && Equals(m);
    public override int GetHashCode() => HashCode.Combine(Currency, Amount);
}
Enter fullscreen mode Exit fullscreen mode

3.3 Repository Interfaces

This is where Dependency Inversion kicks in: the domain layer defines what it needs, not how to get it.

namespace MyApp.Domain.Orders;

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<Order?> GetByOrderNumberAsync(string orderNumber, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    Task SaveChangesAsync(CancellationToken ct = default);
}
Enter fullscreen mode Exit fullscreen mode

No EF Core, no connection strings. Just the abstraction.


4. Application Layer — Use Cases / Interactors

The Application layer orchestrates use cases. It depends on domain abstractions (IOrderRepository, entities) and nothing else.

Think of these like the Usecase classes in the Flutter example:

abstract interface class Usecase<SucessType, Params> {
  Future<Either<Failure, SucessType>> call(Params params);
}
Enter fullscreen mode Exit fullscreen mode

4.1 Use Case Contract

You can define a similar pattern in .NET:

namespace MyApp.Application.Abstractions;

public interface IUseCase<in TRequest, TResponse>
{
    Task<TResponse> HandleAsync(TRequest request, CancellationToken ct = default);
}
Enter fullscreen mode Exit fullscreen mode

4.2 Example Use Case: Confirm Order

using MyApp.Application.Abstractions;
using MyApp.Domain.Orders;

namespace MyApp.Application.Orders.ConfirmOrder;

public sealed record ConfirmOrderCommand(Guid OrderId);

public sealed class ConfirmOrderUseCase
    : IUseCase<ConfirmOrderCommand, ConfirmOrderResult>
{
    private readonly IOrderRepository _orderRepository;

    public ConfirmOrderUseCase(IOrderRepository orderRepository)
        => _orderRepository = orderRepository;

    public async Task<ConfirmOrderResult> HandleAsync(
        ConfirmOrderCommand request,
        CancellationToken ct = default)
    {
        var order = await _orderRepository.GetByIdAsync(request.OrderId, ct);

        if (order is null)
            return ConfirmOrderResult.NotFound(request.OrderId);

        order.Confirm();

        await _orderRepository.SaveChangesAsync(ct);

        return ConfirmOrderResult.Success(order.Id, order.Status.ToString());
    }
}

public sealed record ConfirmOrderResult(bool IsSuccess, Guid OrderId, string Status)
{
    public static ConfirmOrderResult Success(Guid id, string status)
        => new(true, id, status);

    public static ConfirmOrderResult NotFound(Guid id)
        => new(false, id, "NotFound");
}
Enter fullscreen mode Exit fullscreen mode

The use case:

  • Loads an Order via IOrderRepository
  • Runs business logic (order.Confirm())
  • Persists using the repository abstraction
  • Returns a small response model back to the caller

No controllers. No EF Core. No HTTP.


5. Infrastructure Layer — Data Sources, Models, Implementations

This layer does all the dirty work: EF Core, HTTP clients, queues, caching, etc.

It implements the abstractions defined in Domain/Application.

5.1 EF Core DbContext

using Microsoft.EntityFrameworkCore;
using MyApp.Domain.Orders;

namespace MyApp.Infrastructure.Persistence;

public sealed class AppDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();

    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}
Enter fullscreen mode Exit fullscreen mode

5.2 Entity Configuration (mapping domains to tables)

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using MyApp.Domain.Orders;
using MyApp.Domain.Shared;

namespace MyApp.Infrastructure.Persistence.Configurations;

internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.ToTable("Orders");
        builder.HasKey(o => o.Id);

        builder.Property(o => o.OrderNumber)
            .HasMaxLength(32)
            .IsRequired();

        builder.OwnsOne(o => o.Total, money =>
        {
            money.Property(m => m.Currency)
                .HasColumnName("Currency")
                .HasMaxLength(3);
            money.Property(m => m.Amount)
                .HasColumnName("TotalAmount")
                .HasColumnType("decimal(18,2)");
        });

        builder.OwnsMany(typeof(OrderLine), "_lines", navigationBuilder =>
        {
            navigationBuilder.ToTable("OrderLines");
            navigationBuilder.WithOwner().HasForeignKey("OrderId");
            navigationBuilder.Property<int>("Id");
            navigationBuilder.HasKey("Id");

            navigationBuilder.Property<string>("Sku")
                .HasColumnName("Sku")
                .HasMaxLength(64);

            navigationBuilder.Property<int>("Quantity");
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

5.3 Repository Implementation

using Microsoft.EntityFrameworkCore;
using MyApp.Domain.Orders;

namespace MyApp.Infrastructure.Persistence.Repositories;

internal sealed class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _db;

    public OrderRepository(AppDbContext db) => _db = db;

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

    public Task<Order?> GetByOrderNumberAsync(string orderNumber, CancellationToken ct = default)
        => _db.Orders
            .Include("_lines")
            .FirstOrDefaultAsync(o => o.OrderNumber == orderNumber, 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);
}
Enter fullscreen mode Exit fullscreen mode

From the perspective of Domain/Application, this is just one possible implementation. You could swap EF Core for Dapper, a remote API, or even an in‑memory fake, without modifying the use cases.


6. Presentation Layer — ASP.NET Core with Minimal APIs or Controllers

Now we connect the outside world (HTTP) to the inner layers (Use Cases).

6.1 Registering Dependencies

In MyApp.Api:

using Microsoft.EntityFrameworkCore;
using MyApp.Application.Abstractions;
using MyApp.Application.Orders.ConfirmOrder;
using MyApp.Domain.Orders;
using MyApp.Infrastructure.Persistence;
using MyApp.Infrastructure.Persistence.Repositories;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

// Domain/Application abstractions
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUseCase<ConfirmOrderCommand, ConfirmOrderResult>, ConfirmOrderUseCase>();

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
Enter fullscreen mode Exit fullscreen mode

6.2 Minimal API Endpoint

app.MapPost("/orders/{id:guid}/confirm",
    async (Guid id,
           IUseCase<ConfirmOrderCommand, ConfirmOrderResult> useCase,
           CancellationToken ct) =>
    {
        var result = await useCase.HandleAsync(new ConfirmOrderCommand(id), ct);

        return result.IsSuccess
            ? Results.Ok(new { result.OrderId, result.Status })
            : Results.NotFound(new { result.OrderId, result.Status });
    })
   .WithName("ConfirmOrder")
   .WithTags("Orders");
Enter fullscreen mode Exit fullscreen mode

Call flow now mirrors your diagrams:

  1. HTTP Request hits /orders/{id}/confirm
  2. ASP.NET Core endpoint resolves ConfirmOrderUseCase from DI
  3. Use case calls IOrderRepository
  4. OrderRepository uses DbContext to hit SQL
  5. Control returns back up to the API as a shaped response
Presentation (API) → Use Case → Repository → Data Source → DB
Enter fullscreen mode Exit fullscreen mode

Exactly like your diagrams for “call flow” between Presentation → Domain → Data Layer.


7. Testing the Architecture

Because the dependencies point inward, testing is straightforward.

7.1 Unit Testing the Use Case

public sealed class ConfirmOrderUseCaseTests
{
    [Fact]
    public async Task ConfirmOrder_SetsStatus_AndSaves()
    {
        // Arrange
        var order = new Order("SO-1001");
        var repo = new FakeOrderRepository(order);
        var useCase = new ConfirmOrderUseCase(repo);

        var command = new ConfirmOrderCommand(order.Id);

        // Act
        var result = await useCase.HandleAsync(command);

        // Assert
        result.IsSuccess.Should().BeTrue();
        order.Status.Should().Be(OrderStatus.Confirmed);
        repo.SaveChangesCalled.Should().BeTrue();
    }

    private sealed class FakeOrderRepository : IOrderRepository
    {
        private readonly Order _order;
        public bool SaveChangesCalled { get; private set; }

        public FakeOrderRepository(Order order) => _order = order;

        public Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
            => Task.FromResult<Order?>(_order);

        public Task<Order?> GetByOrderNumberAsync(string orderNumber, CancellationToken ct = default)
            => Task.FromResult<Order?>(_order);

        public Task AddAsync(Order order, CancellationToken ct = default)
            => Task.CompletedTask;

        public Task SaveChangesAsync(CancellationToken ct = default)
        {
            SaveChangesCalled = true;
            return Task.CompletedTask;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice: no database, no web server, no JSON. Just business logic and an in‑memory fake repository.


8. Practical Guidelines & Common Pitfalls

8.1 Keep the Domain Pure

  • No DbContext, IHttpClientFactory, loggers, or configuration inside the Domain.
  • Domain code should compile even if you delete MyApp.Infrastructure and MyApp.Api.

8.2 Don’t Over‑Engineer Small Apps

Clean Architecture shines on medium + large systems. For a small CRUD API, a lighter layering may be enough:

  • Api
  • Domain
  • Infrastructure

You can still adopt the Dependency Rule without creating 20 projects.

8.3 Avoid “Anemic” Use Cases

Use cases should encapsulate business logic, not just pass calls through to the repository.

Bad:

public Task<Order?> Handle(GetOrderQuery q, CancellationToken ct)
    => _repo.GetByIdAsync(q.Id, ct);
Enter fullscreen mode Exit fullscreen mode

Better:

  • Apply validation
  • Enforce business rules
  • Compose multiple repositories
  • Raise domain events

8.4 Accept That Diagrams Are Maps, Not the Territory

The diagrams (circles, boxes, arrows) are excellent for explaining concepts, but your .NET solution should be optimized for:

  • Discoverability (new devs can find things fast)
  • Testability
  • Evolution as features grow

Don’t be afraid to:

  • Add vertical slices per feature (Orders, Billing, Users)
  • Keep cross‑cutting concerns in shared modules (SharedKernel, Common, etc.)

9. Summary

If we translate the Flutter‑style Clean Architecture explanation into .NET, we get:

  • Domain Layer (MyApp.Domain)
    • Entities, Value Objects, Repository Interfaces, Use Case contracts
  • Application Layer (MyApp.Application)
    • Use Case implementations, DTOs, orchestration
  • Data Layer (MyApp.Infrastructure)
    • EF Core, HTTP, data sources, repository implementations, mappers
  • Presentation Layer (MyApp.Api)
    • Controllers / Minimal APIs, filters, auth, DI wiring

Dependencies always point inward.

Frameworks depend on your business rules, not the other way around.

When you respect that rule, you gain:

  • High testability
  • Real independence of frameworks
  • Easier refactoring (swap EF, change UI, move from REST to gRPC)
  • A codebase that matches those Clean Architecture diagrams and feels good to work in.

✍️ Written by: Cristian Sifuentes — .NET / C# & architecture enthusiast.

If you want more deep dives like this (Clean Architecture, Hexagonal, Vertical Slice, high‑performance C#), this article is ready to be your next dev.to post.

Top comments (0)