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
Domain – Entities, Value Objects, business invariants. No EF, no HTTP.
Application – Use cases (commands/queries), interfaces (ports) the domain needs (e.g., ITodoRepository
). No EF, no HTTP.
Infrastructure – EF Core/Dapper, external services. Implements the Application’s interfaces.
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
POST /todos
hits API
API calls TodoService.CreateAsync
in Application
Application creates a TodoItem
(rule in Domain)
Application saves via ITodoRepository
Infrastructure actually stores it (here, in memory; later, EF Core)
API returns the result