modern-app-patterns

Clean Architecture (.NET)

Separate concerns into API, Application, Domain, Infrastructure projects.

Example structure

Example

// Domain/Entities/Todo.cs
public sealed record Todo(Guid Id, string Title, bool Done);

// Domain/Abstractions/ITodoRepository.cs
public interface ITodoRepository { Task<IReadOnlyList<Todo>> ListAsync(CancellationToken ct); }
// Application/Todos/Queries/ListTodos.cs
public sealed record ListTodosQuery() : IRequest<IReadOnlyList<Todo>>;
public sealed class ListTodosHandler(ITodoRepository repo) : IRequestHandler<ListTodosQuery, IReadOnlyList<Todo>>
{
    public Task<IReadOnlyList<Todo>> Handle(ListTodosQuery request, CancellationToken ct) => repo.ListAsync(ct);
}
// Infrastructure/Data/TodoRepository.cs
public sealed class TodoRepository(AppDbContext db) : ITodoRepository
{
    public async Task<IReadOnlyList<Todo>> ListAsync(CancellationToken ct) =>
        await db.Todos.AsNoTracking().Select(e => new Todo(e.Id, e.Title, e.Done)).ToListAsync(ct);
}
// API/Program.cs
builder.Services.AddDbContext<AppDbContext>(...);
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ListTodosHandler).Assembly));
builder.Services.AddScoped<ITodoRepository, TodoRepository>();

Why it works


Live end-to-end example (copy/paste)

Minimal four-project setup: Domain, Application (with MediatR), Infrastructure (EF Core), and API.

// Domain/Entities/Todo.cs
namespace Domain.Entities;
public sealed record Todo(Guid Id, string Title, bool Done);

// Domain/Abstractions/ITodoRepository.cs
namespace Domain.Abstractions;
public interface ITodoRepository { Task<IReadOnlyList<Todo>> ListAsync(CancellationToken ct); }
// Application/Todos/Queries/ListTodos.cs
using Domain.Abstractions;
using Domain.Entities;
using MediatR;

public sealed record ListTodosQuery() : IRequest<IReadOnlyList<Todo>>;

public sealed class ListTodosHandler(ITodoRepository repo)
    : IRequestHandler<ListTodosQuery, IReadOnlyList<Todo>>
{
    public Task<IReadOnlyList<Todo>> Handle(ListTodosQuery request, CancellationToken ct)
        => repo.ListAsync(ct);
}
// Infrastructure/Data/AppDbContext.cs
using Domain.Entities;
using Microsoft.EntityFrameworkCore;

public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Todo> Todos => Set<Todo>();
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Todo>().HasKey(t => t.Id);
        base.OnModelCreating(modelBuilder);
    }
}

// Infrastructure/Data/TodoRepository.cs
using Domain.Abstractions;
using Domain.Entities;
using Microsoft.EntityFrameworkCore;

public sealed class TodoRepository(AppDbContext db) : ITodoRepository
{
    public async Task<IReadOnlyList<Todo>> ListAsync(CancellationToken ct)
        => await db.Todos.AsNoTracking().ToListAsync(ct);
}
// API/Program.cs
using MediatR;
using Microsoft.EntityFrameworkCore;
using Domain.Abstractions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseInMemoryDatabase("app"));

builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(ListTodosHandler).Assembly));

builder.Services.AddScoped<ITodoRepository, TodoRepository>();

var app = builder.Build();

app.MapGet("/todos", async (IMediator mediator, CancellationToken ct)
    => await mediator.Send(new ListTodosQuery(), ct));

app.Run();

Notes

Sandbox copy map

Paste into a Minimal API solution (see sandboxes/dotnet-minimal-api):