Introduction
Clean Architecture is not a framework and not just a folder structure. It is mainly an architectural rule:
Dependencies point inward.
The business logic should not depend on whether it is called from a REST API, a database, a message queue, a CLI tool, or a test project.
In this article, we will build a small Task API with .NET 10. The example is intentionally simple so that the core Clean Architecture principles remain visible.
We will build the following API:
POST /tasks
GET /tasks/open
POST /tasks/{id}/complete
The example demonstrates:
Domain logic without ASP.NET Core
Application use cases without database dependencies
Infrastructure as replaceable technical detail
Minimal APIs as a thin outer adapter
Unit tests without web server or database
Problem Statement
We want to build a small task management API.
A task has the following properties:
Id
Title
IsCompleted
CreatedAt
CompletedAt
The business rules are simple:
A task must have a title.
The title must not be empty.
The title must not be longer than 120 characters.
A task can only be completed once.
These rules should not live in the API endpoint, controller, database model, or request validation layer.
They belong in the domain model.
Architecture / Approach
Our solution will have the following structure:
CleanArchitectureNet10
├── src
│ ├── Tasks.Domain
│ ├── Tasks.Application
│ ├── Tasks.Infrastructure
│ └── Tasks.Api
└── tests
├── Tasks.Application.Tests
└── Tasks.Api.Tests
The dependency direction looks like this:
Tasks.Api
↓
Tasks.Infrastructure → Tasks.Application → Tasks.Domain
↑
Use Cases / Ports
Tasks.Domain
knows nobody
The most important rule is:
The API may know the Application layer.
The Infrastructure layer may know the Application layer.
The Application layer may know the Domain layer.
The Domain layer must not know any technical layer.
Layer Overview
| Layer | Responsibility | Contains | Must not contain |
|---|---|---|---|
Domain | Business rules | Entities, value objects, domain exceptions | EF Core, ASP.NET Core, JSON, database code |
Application | Use cases | Commands, handlers, ports, DTOs | Concrete database implementation |
Infrastructure | Technical adapters | Repository implementation, clock, database, file system, external services | Business decisions |
Api | Input adapter | Minimal APIs, dependency injection, HTTP mapping | Business logic |
Implementation
1. Create the Solution
mkdir CleanArchitectureNet10
cd CleanArchitectureNet10
dotnet new sln -n CleanArchitectureNet10
mkdir src tests
dotnet new classlib -n Tasks.Domain -o src/Tasks.Domain -f net10.0
dotnet new classlib -n Tasks.Application -o src/Tasks.Application -f net10.0
dotnet new classlib -n Tasks.Infrastructure -o src/Tasks.Infrastructure -f net10.0
dotnet new web -n Tasks.Api -o src/Tasks.Api -f net10.0
dotnet sln add src/Tasks.Domain/Tasks.Domain.csproj
dotnet sln add src/Tasks.Application/Tasks.Application.csproj
dotnet sln add src/Tasks.Infrastructure/Tasks.Infrastructure.csproj
dotnet sln add src/Tasks.Api/Tasks.Api.csproj
Now add the project references:
dotnet add src/Tasks.Application/Tasks.Application.csproj reference src/Tasks.Domain/Tasks.Domain.csproj
dotnet add src/Tasks.Infrastructure/Tasks.Infrastructure.csproj reference src/Tasks.Application/Tasks.Application.csproj
dotnet add src/Tasks.Infrastructure/Tasks.Infrastructure.csproj reference src/Tasks.Domain/Tasks.Domain.csproj
dotnet add src/Tasks.Api/Tasks.Api.csproj reference src/Tasks.Application/Tasks.Application.csproj
dotnet add src/Tasks.Api/Tasks.Api.csproj reference src/Tasks.Infrastructure/Tasks.Infrastructure.csproj
2. Domain Layer
The Domain layer contains the business rules.
It does not know anything about:
HTTP
JSON
ASP.NET Core
EF Core
SQL
Dependency Injection
DomainException.cs
namespace Tasks.Domain;
public sealed class DomainException : Exception
{
public DomainException(string message)
: base(message)
{
}
}
TaskTitle.cs
namespace Tasks.Domain;
public sealed record TaskTitle
{
public const int MaxLength = 120;
public string Value { get; }
private TaskTitle(string value)
{
Value = value;
}
public static TaskTitle Create(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new DomainException("A task title must not be empty.");
}
var normalized = value.Trim();
if (normalized.Length > MaxLength)
{
throw new DomainException(
$"A task title must not be longer than {MaxLength} characters.");
}
return new TaskTitle(normalized);
}
public override string ToString()
{
return Value;
}
}
TaskTitle is a small value object.
Instead of checking title rules everywhere in the application, the rules are centralized in one place.
That gives us two benefits:
The title can never be created in an invalid state.
The validation logic is close to the business concept.
TaskItem.cs
namespace Tasks.Domain;
public sealed class TaskItem
{
public Guid Id { get; }
public TaskTitle Title { get; private set; }
public bool IsCompleted { get; private set; }
public DateTimeOffset CreatedAt { get; }
public DateTimeOffset? CompletedAt { get; private set; }
private TaskItem(
Guid id,
TaskTitle title,
bool isCompleted,
DateTimeOffset createdAt,
DateTimeOffset? completedAt)
{
Id = id;
Title = title;
IsCompleted = isCompleted;
CreatedAt = createdAt;
CompletedAt = completedAt;
}
public static TaskItem Create(TaskTitle title, DateTimeOffset now)
{
return new TaskItem(
id: Guid.NewGuid(),
title: title,
isCompleted: false,
createdAt: now,
completedAt: null);
}
public void Complete(DateTimeOffset now)
{
if (IsCompleted)
{
throw new DomainException("The task is already completed.");
}
if (now < CreatedAt)
{
throw new DomainException("A task cannot be completed before it was created.");
}
IsCompleted = true;
CompletedAt = now;
}
}
The rule that a task can only be completed once is implemented inside the entity itself.
That is important.
The API should not do this:
if (task.IsCompleted)
{
return Results.BadRequest();
}
The API is not responsible for deciding what is allowed from a business perspective. The domain model is.
3. Application Layer
The Application layer contains use cases.
It describes what the system can do, but it does not decide how data is stored.
ITaskRepository.cs
using Tasks.Domain;
namespace Tasks.Application.Abstractions;
public interface ITaskRepository
{
Task AddAsync(TaskItem task, CancellationToken cancellationToken);
Task<TaskItem?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<IReadOnlyList<TaskItem>> ListOpenAsync(CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}
This interface is a port.
The Application layer says:
I need a way to store and load tasks.
But it does not say:
Use SQL Server.
Use PostgreSQL.
Use MongoDB.
Use EF Core.
Use an in-memory dictionary.
Those are infrastructure decisions.
IClock.cs
namespace Tasks.Application.Abstractions;
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
Even time is treated as an external dependency.
Why?
Because this is hard to test:
DateTimeOffset.UtcNow
But this is easy to test:
_clock.UtcNow
A test can provide a fixed clock.
Error.cs
namespace Tasks.Application.Common;
public sealed record Error(string Code, string Message);
Result.cs
namespace Tasks.Application.Common;
public sealed record Result<T>
{
private Result(bool isSuccess, T? value, Error? error)
{
IsSuccess = isSuccess;
Value = value;
Error = error;
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T? Value { get; }
public Error? Error { get; }
public static Result<T> Success(T value)
{
return new Result<T>(
isSuccess: true,
value: value,
error: null);
}
public static Result<T> Failure(Error error)
{
return new Result<T>(
isSuccess: false,
value: default,
error: error);
}
}
Instead of throwing exceptions for expected application errors, we return a Result<T>.
This makes use cases explicit and easier to map to HTTP responses later.
TaskDto.cs
using Tasks.Domain;
namespace Tasks.Application.Tasks;
public sealed record TaskDto(
Guid Id,
string Title,
bool IsCompleted,
DateTimeOffset CreatedAt,
DateTimeOffset? CompletedAt)
{
public static TaskDto FromDomain(TaskItem task)
{
return new TaskDto(
Id: task.Id,
Title: task.Title.Value,
IsCompleted: task.IsCompleted,
CreatedAt: task.CreatedAt,
CompletedAt: task.CompletedAt);
}
}
The DTO is not the domain entity.
That separation is useful because the public API contract can evolve independently from the internal domain model.
4. Use Case: Create a Task
CreateTaskCommand.cs
namespace Tasks.Application.Tasks.CreateTask;
public sealed record CreateTaskCommand(string Title);
CreateTaskHandler.cs
using Tasks.Application.Abstractions;
using Tasks.Application.Common;
using Tasks.Domain;
namespace Tasks.Application.Tasks.CreateTask;
public sealed class CreateTaskHandler
{
private readonly ITaskRepository _repository;
private readonly IClock _clock;
public CreateTaskHandler(
ITaskRepository repository,
IClock clock)
{
_repository = repository;
_clock = clock;
}
public async Task<Result<TaskDto>> Handle(
CreateTaskCommand command,
CancellationToken cancellationToken)
{
try
{
var title = TaskTitle.Create(command.Title);
var task = TaskItem.Create(
title: title,
now: _clock.UtcNow);
await _repository.AddAsync(task, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TaskDto>.Success(TaskDto.FromDomain(task));
}
catch (DomainException exception)
{
return Result<TaskDto>.Failure(
new Error(
Code: "tasks.validation",
Message: exception.Message));
}
}
}
This handler knows only two abstractions:
ITaskRepository
IClock
It does not know:
HTTP
JSON
Minimal APIs
SQL
Entity Framework Core
That is exactly what we want.
5. Use Case: Complete a Task
CompleteTaskCommand.cs
namespace Tasks.Application.Tasks.CompleteTask;
public sealed record CompleteTaskCommand(Guid TaskId);
CompleteTaskHandler.cs
using Tasks.Application.Abstractions;
using Tasks.Application.Common;
using Tasks.Domain;
namespace Tasks.Application.Tasks.CompleteTask;
public sealed class CompleteTaskHandler
{
private readonly ITaskRepository _repository;
private readonly IClock _clock;
public CompleteTaskHandler(
ITaskRepository repository,
IClock clock)
{
_repository = repository;
_clock = clock;
}
public async Task<Result<TaskDto>> Handle(
CompleteTaskCommand command,
CancellationToken cancellationToken)
{
var task = await _repository.GetByIdAsync(
command.TaskId,
cancellationToken);
if (task is null)
{
return Result<TaskDto>.Failure(
new Error(
Code: "tasks.not_found",
Message: "The task was not found."));
}
try
{
task.Complete(_clock.UtcNow);
await _repository.SaveChangesAsync(cancellationToken);
return Result<TaskDto>.Success(TaskDto.FromDomain(task));
}
catch (DomainException exception)
{
return Result<TaskDto>.Failure(
new Error(
Code: "tasks.validation",
Message: exception.Message));
}
}
}
The handler coordinates the use case.
The entity still owns the rule:
task.Complete(_clock.UtcNow);
That line is important. The handler asks the domain object to perform a business operation instead of modifying properties directly.
6. Use Case: Get Open Tasks
GetOpenTasksHandler.cs
using Tasks.Application.Abstractions;
namespace Tasks.Application.Tasks.GetOpenTasks;
public sealed class GetOpenTasksHandler
{
private readonly ITaskRepository _repository;
public GetOpenTasksHandler(ITaskRepository repository)
{
_repository = repository;
}
public async Task<IReadOnlyList<TaskDto>> Handle(
CancellationToken cancellationToken)
{
var tasks = await _repository.ListOpenAsync(cancellationToken);
return tasks
.Select(TaskDto.FromDomain)
.ToList();
}
}
This use case is simple, but it still follows the same rule:
The Application layer defines the operation.
The Infrastructure layer provides the data access implementation.
7. Infrastructure Layer
Now we implement the technical details.
For this example, we use an in-memory repository. Later, we could replace it with EF Core, Dapper, MongoDB, or an external API.
SystemClock.cs
using Tasks.Application.Abstractions;
namespace Tasks.Infrastructure;
public sealed class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
InMemoryTaskRepository.cs
using Tasks.Application.Abstractions;
using Tasks.Domain;
namespace Tasks.Infrastructure;
public sealed class InMemoryTaskRepository : ITaskRepository
{
private readonly object _gate = new();
private readonly Dictionary<Guid, TaskItem> _tasks = new();
public Task AddAsync(TaskItem task, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
_tasks.Add(task.Id, task);
}
return Task.CompletedTask;
}
public Task<TaskItem?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
_tasks.TryGetValue(id, out var task);
return Task.FromResult(task);
}
}
public Task<IReadOnlyList<TaskItem>> ListOpenAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_gate)
{
IReadOnlyList<TaskItem> result = _tasks
.Values
.Where(task => !task.IsCompleted)
.OrderBy(task => task.CreatedAt)
.ToList();
return Task.FromResult(result);
}
}
public Task SaveChangesAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// There is nothing to persist in the in-memory implementation.
// An EF Core repository would call DbContext.SaveChangesAsync here.
return Task.CompletedTask;
}
}
The repository is just an adapter.
The Application layer does not care whether the data is stored in memory, in PostgreSQL, in SQL Server, or in another service.
8. API Layer with Minimal APIs
The API layer is the outermost layer.
It receives HTTP requests, translates them into application commands, calls the use case, and maps the result back to HTTP.
It should not contain business rules.
CreateTaskRequest.cs
namespace Tasks.Api.Contracts;
public sealed record CreateTaskRequest(string Title);
TaskEndpoints.cs
using Tasks.Api.Contracts;
using Tasks.Application.Common;
using Tasks.Application.Tasks;
using Tasks.Application.Tasks.CompleteTask;
using Tasks.Application.Tasks.CreateTask;
using Tasks.Application.Tasks.GetOpenTasks;
namespace Tasks.Api;
public static class TaskEndpoints
{
public static IEndpointRouteBuilder MapTaskEndpoints(
this IEndpointRouteBuilder app)
{
var group = app
.MapGroup("/tasks")
.WithTags("Tasks");
group.MapPost(
"/",
async (
CreateTaskRequest request,
CreateTaskHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.Handle(
new CreateTaskCommand(request.Title),
cancellationToken);
return result.ToHttpCreatedResult();
});
group.MapGet(
"/open",
async (
GetOpenTasksHandler handler,
CancellationToken cancellationToken) =>
{
var tasks = await handler.Handle(cancellationToken);
return Results.Ok(tasks);
});
group.MapPost(
"/{id:guid}/complete",
async (
Guid id,
CompleteTaskHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.Handle(
new CompleteTaskCommand(id),
cancellationToken);
return result.ToHttpOkResult();
});
return app;
}
private static IResult ToHttpCreatedResult(
this Result<TaskDto> result)
{
if (result.IsSuccess && result.Value is not null)
{
return Results.Created(
$"/tasks/{result.Value.Id}",
result.Value);
}
return ToProblemResult(result.Error);
}
private static IResult ToHttpOkResult(
this Result<TaskDto> result)
{
if (result.IsSuccess && result.Value is not null)
{
return Results.Ok(result.Value);
}
return ToProblemResult(result.Error);
}
private static IResult ToProblemResult(Error? error)
{
if (error is null)
{
return Results.Problem(
title: "Unexpected error",
statusCode: StatusCodes.Status500InternalServerError);
}
return error.Code switch
{
"tasks.not_found" => Results.NotFound(new
{
error.Code,
error.Message
}),
"tasks.validation" => Results.BadRequest(new
{
error.Code,
error.Message
}),
_ => Results.Problem(
title: error.Message,
statusCode: StatusCodes.Status500InternalServerError)
};
}
}
This endpoint class is intentionally thin.
It does not validate task titles.
It does not complete tasks by changing properties.
It does not know how tasks are stored.
It only translates between HTTP and the Application layer.
Program.cs
using Tasks.Api;
using Tasks.Application.Abstractions;
using Tasks.Application.Tasks.CompleteTask;
using Tasks.Application.Tasks.CreateTask;
using Tasks.Application.Tasks.GetOpenTasks;
using Tasks.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddScoped<CreateTaskHandler>();
builder.Services.AddScoped<CompleteTaskHandler>();
builder.Services.AddScoped<GetOpenTasksHandler>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton<ITaskRepository, InMemoryTaskRepository>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapTaskEndpoints();
app.Run();
public partial class Program;
The partial Program declaration is useful for integration tests with WebApplicationFactory<Program>.
9. Try the API
Start the API:
dotnet run --project src/Tasks.Api
Create a task:
curl -i \
-X POST http://localhost:5000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Learn Clean Architecture with .NET 10"}'
Example response:
{
"id": "b9e6a9df-6fd6-4f3b-9f11-f4e9eaf8c4a0",
"title": "Learn Clean Architecture with .NET 10",
"isCompleted": false,
"createdAt": "2026-05-10T12:00:00+00:00",
"completedAt": null
}
Get open tasks:
curl http://localhost:5000/tasks/open
Complete a task:
curl -i \
-X POST http://localhost:5000/tasks/b9e6a9df-6fd6-4f3b-9f11-f4e9eaf8c4a0/complete
Try to create an invalid task:
curl -i \
-X POST http://localhost:5000/tasks \
-H "Content-Type: application/json" \
-d '{"title":" "}'
Example response:
{
"code": "tasks.validation",
"message": "A task title must not be empty."
}
Tests
1. Create Test Projects
dotnet new xunit -n Tasks.Application.Tests -o tests/Tasks.Application.Tests -f net10.0
dotnet new xunit -n Tasks.Api.Tests -o tests/Tasks.Api.Tests -f net10.0
dotnet sln add tests/Tasks.Application.Tests/Tasks.Application.Tests.csproj
dotnet sln add tests/Tasks.Api.Tests/Tasks.Api.Tests.csproj
dotnet add tests/Tasks.Application.Tests/Tasks.Application.Tests.csproj reference src/Tasks.Application/Tasks.Application.csproj
dotnet add tests/Tasks.Application.Tests/Tasks.Application.Tests.csproj reference src/Tasks.Domain/Tasks.Domain.csproj
dotnet add tests/Tasks.Api.Tests/Tasks.Api.Tests.csproj reference src/Tasks.Api/Tasks.Api.csproj
dotnet add tests/Tasks.Api.Tests/Tasks.Api.Tests.csproj package Microsoft.AspNetCore.Mvc.Testing
2. Application Test: Create Task
using Tasks.Application.Abstractions;
using Tasks.Application.Tasks.CreateTask;
using Tasks.Domain;
namespace Tasks.Application.Tests;
public sealed class CreateTaskHandlerTests
{
[Fact]
public async Task Handle_WithValidTitle_CreatesTask()
{
var now = new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero);
var repository = new FakeTaskRepository();
var clock = new FixedClock(now);
var handler = new CreateTaskHandler(repository, clock);
var result = await handler.Handle(
new CreateTaskCommand("Learn Clean Architecture"),
CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.Equal("Learn Clean Architecture", result.Value.Title);
Assert.False(result.Value.IsCompleted);
Assert.Equal(now, result.Value.CreatedAt);
Assert.NotNull(repository.AddedTask);
}
[Fact]
public async Task Handle_WithEmptyTitle_ReturnsValidationError()
{
var repository = new FakeTaskRepository();
var clock = new FixedClock(DateTimeOffset.UtcNow);
var handler = new CreateTaskHandler(repository, clock);
var result = await handler.Handle(
new CreateTaskCommand(" "),
CancellationToken.None);
Assert.True(result.IsFailure);
Assert.Equal("tasks.validation", result.Error?.Code);
Assert.Null(repository.AddedTask);
}
private sealed class FixedClock : IClock
{
public FixedClock(DateTimeOffset utcNow)
{
UtcNow = utcNow;
}
public DateTimeOffset UtcNow { get; }
}
private sealed class FakeTaskRepository : ITaskRepository
{
private readonly Dictionary<Guid, TaskItem> _tasks = new();
public TaskItem? AddedTask { get; private set; }
public Task AddAsync(TaskItem task, CancellationToken cancellationToken)
{
AddedTask = task;
_tasks.Add(task.Id, task);
return Task.CompletedTask;
}
public Task<TaskItem?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{
_tasks.TryGetValue(id, out var task);
return Task.FromResult(task);
}
public Task<IReadOnlyList<TaskItem>> ListOpenAsync(CancellationToken cancellationToken)
{
IReadOnlyList<TaskItem> result = _tasks
.Values
.Where(task => !task.IsCompleted)
.ToList();
return Task.FromResult(result);
}
public Task SaveChangesAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
}
This test does not need:
A web server
A database
A real HTTP request
A real system clock
That is one of the major benefits of Clean Architecture.
3. Application Test: Complete Task
using Tasks.Application.Abstractions;
using Tasks.Application.Tasks.CompleteTask;
using Tasks.Domain;
namespace Tasks.Application.Tests;
public sealed class CompleteTaskHandlerTests
{
[Fact]
public async Task Handle_WithExistingTask_CompletesTask()
{
var createdAt = new DateTimeOffset(2026, 5, 10, 10, 0, 0, TimeSpan.Zero);
var completedAt = new DateTimeOffset(2026, 5, 10, 12, 0, 0, TimeSpan.Zero);
var task = TaskItem.Create(
TaskTitle.Create("Write blog post"),
createdAt);
var repository = new FakeTaskRepository(task);
var clock = new FixedClock(completedAt);
var handler = new CompleteTaskHandler(repository, clock);
var result = await handler.Handle(
new CompleteTaskCommand(task.Id),
CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.True(result.Value.IsCompleted);
Assert.Equal(completedAt, result.Value.CompletedAt);
}
[Fact]
public async Task Handle_WithUnknownTask_ReturnsNotFound()
{
var repository = new FakeTaskRepository();
var clock = new FixedClock(DateTimeOffset.UtcNow);
var handler = new CompleteTaskHandler(repository, clock);
var result = await handler.Handle(
new CompleteTaskCommand(Guid.NewGuid()),
CancellationToken.None);
Assert.True(result.IsFailure);
Assert.Equal("tasks.not_found", result.Error?.Code);
}
private sealed class FixedClock : IClock
{
public FixedClock(DateTimeOffset utcNow)
{
UtcNow = utcNow;
}
public DateTimeOffset UtcNow { get; }
}
private sealed class FakeTaskRepository : ITaskRepository
{
private readonly Dictionary<Guid, TaskItem> _tasks = new();
public FakeTaskRepository(params TaskItem[] tasks)
{
foreach (var task in tasks)
{
_tasks.Add(task.Id, task);
}
}
public Task AddAsync(TaskItem task, CancellationToken cancellationToken)
{
_tasks.Add(task.Id, task);
return Task.CompletedTask;
}
public Task<TaskItem?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{
_tasks.TryGetValue(id, out var task);
return Task.FromResult(task);
}
public Task<IReadOnlyList<TaskItem>> ListOpenAsync(CancellationToken cancellationToken)
{
IReadOnlyList<TaskItem> result = _tasks
.Values
.Where(task => !task.IsCompleted)
.ToList();
return Task.FromResult(result);
}
public Task SaveChangesAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
}
4. API Integration Test
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Tasks.Application.Tasks;
namespace Tasks.Api.Tests;
public sealed class TaskApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public TaskApiTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task PostTasks_WithValidTitle_ReturnsCreatedTask()
{
var response = await _client.PostAsJsonAsync(
"/tasks",
new
{
Title = "Test Clean Architecture API"
});
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var task = await response.Content.ReadFromJsonAsync<TaskDto>();
Assert.NotNull(task);
Assert.Equal("Test Clean Architecture API", task.Title);
Assert.False(task.IsCompleted);
}
[Fact]
public async Task PostTasks_WithEmptyTitle_ReturnsBadRequest()
{
var response = await _client.PostAsJsonAsync(
"/tasks",
new
{
Title = " "
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
The integration test verifies the real HTTP layer.
The application tests verify the use cases directly.
Both test types have a different purpose.
Deployment / Operations
Publish Locally
dotnet publish src/Tasks.Api/Tasks.Api.csproj \
-c Release \
-o ./publish \
/p:UseAppHost=false
Run the published app:
dotnet ./publish/Tasks.Api.dll
Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish src/Tasks.Api/Tasks.Api.csproj \
-c Release \
-o /app/publish \
/p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
ENV ASPNETCORE_URLS=http://+:8080
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Tasks.Api.dll"]
Build the image:
docker build -t clean-architecture-net10 .
Run the container:
docker run --rm -p 8080:8080 clean-architecture-net10
Test the API:
curl http://localhost:8080/tasks/open
Why This Example Demonstrates Clean Architecture
1. The Dependency Rule
The Domain layer does not depend on outer layers.
Tasks.Domain
no reference to ASP.NET Core
no reference to EF Core
no reference to Infrastructure
no reference to API
This is the core idea.
Technical details depend on the business model, not the other way around.
2. Business Rules Are in the Right Place
This rule belongs in the entity:
public void Complete(DateTimeOffset now)
{
if (IsCompleted)
{
throw new DomainException("The task is already completed.");
}
IsCompleted = true;
CompletedAt = now;
}
It should not be hidden in an API endpoint:
group.MapPost("/{id:guid}/complete", async (...) =>
{
// This would be the wrong place for business logic:
// if (task.IsCompleted) ...
});
The API should not decide what is allowed from a business perspective.
3. Use Cases Are Explicit
Instead of having one large service class with many unrelated methods, we define clear use cases:
CreateTaskHandler
CompleteTaskHandler
GetOpenTasksHandler
This makes the application easier to understand, test, and extend.
4. Infrastructure Is Replaceable
Today we use this:
builder.Services.AddSingleton<ITaskRepository, InMemoryTaskRepository>();
Tomorrow we could use this:
builder.Services.AddScoped<ITaskRepository, EfCoreTaskRepository>();
The Application layer would not need to change.
That is a major benefit of depending on abstractions instead of concrete infrastructure.
5. Tests Become Easier
The CreateTaskHandler only needs:
ITaskRepository
IClock
So the test can provide simple fake implementations.
No web server.
No real database.
No test container.
No network call.
This is a direct consequence of clean dependencies.
Comparison with Traditional Layered Architecture
| Topic | Traditional Layered Architecture | Clean Architecture |
|---|---|---|
| Dependency direction | UI → Business → Data | Outer adapters → Application → Domain |
| Database dependency | Often visible inside business logic | Hidden behind ports |
| Testability | Often requires mocking infrastructure | Use cases can be tested directly |
| Domain model | Often shaped by ORM concerns | Independent from persistence |
| Changing the database | Can affect many layers | Usually affects Infrastructure only |
| Initial complexity | Lower | Slightly higher |
| Long-term maintainability | Can suffer as the app grows | Usually better for business-heavy systems |
Clean Architecture is especially useful when your system contains more than simple CRUD operations.
Possible Extensions
Replace In-Memory Storage with EF Core
Later, the in-memory repository could be replaced by an EF Core implementation.
Example AppDbContext:
using Microsoft.EntityFrameworkCore;
using Tasks.Application.Abstractions;
using Tasks.Domain;
namespace Tasks.Infrastructure.Persistence;
public sealed class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
public DbSet<TaskItem> Tasks => Set<TaskItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TaskItem>(builder =>
{
builder.HasKey(task => task.Id);
builder.Property(task => task.Id)
.ValueGeneratedNever();
builder.OwnsOne(task => task.Title, title =>
{
title.Property(t => t.Value)
.HasColumnName("Title")
.HasMaxLength(TaskTitle.MaxLength)
.IsRequired();
});
builder.Property(task => task.IsCompleted)
.IsRequired();
builder.Property(task => task.CreatedAt)
.IsRequired();
builder.Property(task => task.CompletedAt);
});
}
}
Example repository:
using Microsoft.EntityFrameworkCore;
using Tasks.Application.Abstractions;
using Tasks.Domain;
namespace Tasks.Infrastructure.Persistence;
public sealed class EfCoreTaskRepository : ITaskRepository
{
private readonly AppDbContext _dbContext;
public EfCoreTaskRepository(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task AddAsync(TaskItem task, CancellationToken cancellationToken)
{
await _dbContext.Tasks.AddAsync(task, cancellationToken);
}
public async Task<TaskItem?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{
return await _dbContext.Tasks
.SingleOrDefaultAsync(task => task.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<TaskItem>> ListOpenAsync(CancellationToken cancellationToken)
{
return await _dbContext.Tasks
.Where(task => !task.IsCompleted)
.OrderBy(task => task.CreatedAt)
.ToListAsync(cancellationToken);
}
public Task SaveChangesAsync(CancellationToken cancellationToken)
{
return _dbContext.SaveChangesAsync(cancellationToken);
}
}
Then the dependency injection registration could change from:
builder.Services.AddSingleton<ITaskRepository, InMemoryTaskRepository>();
to:
builder.Services.AddScoped<ITaskRepository, EfCoreTaskRepository>();
The use cases remain untouched.
Other Useful Extensions
| Extension | Benefit |
|---|---|
| FluentValidation | Request validation before calling use cases |
| EF Core + PostgreSQL | Real persistence |
| Domain Events | Decoupled business follow-up actions |
| Outbox Pattern | Reliable event publishing |
| Authentication | User-specific tasks |
| OpenTelemetry | Tracing, metrics, and logging |
| Health Checks | Production readiness for Kubernetes or OpenShift |
| API Versioning | Stable public API contracts |
| Vertical Slice Architecture | Alternative feature-based organization |
Summary
Clean Architecture protects your business logic from technical details.
In this .NET 10 example, we separated the application into four clear areas:
Domain
Application
Infrastructure
API
The Domain layer contains the business rules.
The Application layer defines use cases.
The Infrastructure layer implements technical details.
The API layer translates HTTP requests into application calls.
The most important principle is the dependency direction:
Technology depends on business logic.
Business logic does not depend on technology.
This makes the system easier to test, easier to change, and easier to reason about.
For very small CRUD applications, Clean Architecture can feel like extra structure. But as soon as business rules, testing, external systems, persistence choices, or long-term maintainability become important, the separation pays off quickly.
The key takeaway is simple:
Keep your business rules at the center, and push technical details to the outside.
Views: 0
