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

LayerResponsibilityContainsMust not contain
DomainBusiness rulesEntities, value objects, domain exceptionsEF Core, ASP.NET Core, JSON, database code
ApplicationUse casesCommands, handlers, ports, DTOsConcrete database implementation
InfrastructureTechnical adaptersRepository implementation, clock, database, file system, external servicesBusiness decisions
ApiInput adapterMinimal APIs, dependency injection, HTTP mappingBusiness 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

TopicTraditional Layered ArchitectureClean Architecture
Dependency directionUI → Business → DataOuter adapters → Application → Domain
Database dependencyOften visible inside business logicHidden behind ports
TestabilityOften requires mocking infrastructureUse cases can be tested directly
Domain modelOften shaped by ORM concernsIndependent from persistence
Changing the databaseCan affect many layersUsually affects Infrastructure only
Initial complexityLowerSlightly higher
Long-term maintainabilityCan suffer as the app growsUsually 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

ExtensionBenefit
FluentValidationRequest validation before calling use cases
EF Core + PostgreSQLReal persistence
Domain EventsDecoupled business follow-up actions
Outbox PatternReliable event publishing
AuthenticationUser-specific tasks
OpenTelemetryTracing, metrics, and logging
Health ChecksProduction readiness for Kubernetes or OpenShift
API VersioningStable public API contracts
Vertical Slice ArchitectureAlternative 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

Clean Architecture with .NET 10: A Simple Practical Example

Johannes Rest


.NET Architekt und Entwickler


Beitragsnavigation


Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert