.NET Core  

Clean Architecture in .NET Core

Keep your business rules independent from frameworks (web, EF Core, UI). That makes code easier to test, change, and grow.

Dependency Rule: Code dependencies always point inward.

[ Web/API ] → [ Infrastructure adapters ] → [ Application (use cases) ] → [ Domain (rules) ]

What goes where

  1. Domain – Entities, Value Objects, business invariants. No EF, no HTTP.

  2. Application – Use cases (commands/queries), interfaces (ports) the domain needs (e.g., ITodoRepository). No EF, no HTTP.

  3. Infrastructure – EF Core/Dapper, external services. Implements the Application’s interfaces.

  4. API/UI – Controllers/endpoints, DI wiring, filters. Thin; calls Application; no business rules here.

1. Domain (the rules)

// Domain/TodoItem.cs
namespace Sample.Domain;

public class TodoItem
{
    public Guid Id { get; } = Guid.NewGuid();
    public string Title { get; private set; }
    public bool IsDone { get; private set; }
    public void MarkDone() => IsDone = true;
}

2. Application (the use case + an interface/port)

// Application/ITodoRepository.cs
using Sample.Domain;

namespace Sample.Application;

public interface ITodoRepository
{
    Task AddAsync(TodoItem item, CancellationToken ct);
    Task<TodoItem?> GetAsync(Guid id, CancellationToken ct);
}

// Application/TodoService.cs
using Sample.Domain;

namespace Sample.Application;

public class TodoService(ITodoRepository repo)
{
    public async Task<Guid> CreateAsync(string title, CancellationToken ct)
    {
        var item = new TodoItem(title);          // use business rules
        await repo.AddAsync(item, ct);           // store via interface
        return item.Id;
    }
}

3. Infrastructure (a simple adapter; here, in‑memory)

// Infrastructure/InMemoryTodoRepository.cs
using System.Collections.Concurrent;
using Sample.Application;
using Sample.Domain;

namespace Sample.Infrastructure;

public class InMemoryTodoRepository : ITodoRepository
{
    private readonly ConcurrentDictionary<Guid, TodoItem> _db = new();

    public Task AddAsync(TodoItem item, CancellationToken ct)
    { _db[item.Id] = item; return Task.CompletedTask; }

    public Task<TodoItem?> GetAsync(Guid id, CancellationToken ct)
        => Task.FromResult(_db.TryGetValue(id, out var v) ? v : null);
}

4. API (thin endpoints that call the use case)

// Api/Program.cs
using Sample.Application;
using Sample.Infrastructure;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ITodoRepository, InMemoryTodoRepository>(); // choose adapter
builder.Services.AddScoped<TodoService>();                              // use case

var app = builder.Build();

app.MapPost("/todos", async (TodoService svc, CreateTodoDto dto, CancellationToken ct) =>
{
    var id = await svc.CreateAsync(dto.Title, ct);
    return Results.Created($"/todos/{id}", new { id, dto.Title, isDone = false });
});

app.MapGet("/todos/{id:guid}", async (ITodoRepository repo, Guid id, CancellationToken ct) =>
{
    var item = await repo.GetAsync(id, ct);
    return item is null ? Results.NotFound() : Results.Ok(new { item.Id, item.Title, item.IsDone });
});

app.Run();

public record CreateTodoDto(string Title);

How a request flows

  1. POST /todos hits API

  2. API calls TodoService.CreateAsync in Application

  3. Application creates a TodoItem (rule in Domain)

  4. Application saves via ITodoRepository

  5. Infrastructure actually stores it (here, in memory; later, EF Core)

  6. API returns the result