🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

Clean Architecture w .NET 10: buduj REST API gotowe do produkcji

Clean Architecture w .NET 10 to nie ozdobnik do CV — to konkretny sposób organizacji kodu, który sprawia, że reguły biznesowe testujesz w milisekundy, a wymianę bazy danych ograniczasz do jednej linijki. W tym wpisie przejdziemy przez kompletne REST API w ASP.NET Core 10 (Task Manager API): od encji domenowej z prawdziwą logiką, przez CQRS-lite w warstwie aplikacji, po EF Core, poprawne kody HTTP i 38 testów uruchamianych bez bazy i bez sieci.

To nie jest kolejny scaffold „ToDo App”. Każda decyzja architektoniczna ma tu uzasadnienie i kompromis — bo w realnym projekcie dlaczego jest ważniejsze niż co. Jeśli budujesz projekt do portfolio albo chcesz uporządkować własny backend, znajdziesz tu gotowe wzorce i kilka edge case’ów, które łatwo przeoczyć.

Cztery warstwy z zależnościami skierowanymi do środka, encja pilnująca własnych przejść stanów, IQueryable filtrujący po stronie bazy, wyjątki domenowe mapowane w jednym middleware i testy na InMemory EF Core. Kod kompiluje się na .NET 8 z zerem ostrzeżeń.


Spis treści


Dlaczego Clean Architecture, a nie kolejny CRUD

Zanim wejdziemy w kod — konkret, po co ta cała struktura. Płaski projekt, w którym kontroler woła DbContext bezpośrednio, działa świetnie do momentu pierwszej realnej zmiany: podmiany bazy, dodania walidacji w trzech miejscach naraz albo napisania testu, który wymaga postawienia całego pipeline’u HTTP.

Clean Architecture rozwiązuje to jedną zasadą: zależności wskazują do środka. Warstwa Domain nie wie, że istnieje EF Core, SQLite czy ASP.NET Core. Konsekwencje są bardzo praktyczne:

  • Testowalność — cały zestaw reguł biznesowych sprawdzasz bez bazy, bez kontenera DI i bez serwera HTTP.
  • Wymienialność — przejście z SQLite na PostgreSQL to zmiana jednej linijki w kompozycji, a nie refaktor Domain i Application.
  • Czytelność dla recenzenta — rekruter albo senior od razu widzi, gdzie leży logika, a gdzie tylko „hydraulika”.
PodejściePlusyMinusy
Płaski CRUD (kontroler → DbContext)Szybki start, mało plików, zero ceremoniiLogika rozmyta po kontrolerach, testy wymagają bazy, zmiana bazy = refaktor wszędzie
Clean ArchitectureTesty w milisekundy, wymienialna infrastruktura, jasny podział odpowiedzialnościWięcej plików i projektów na start; nadmiar przy naprawdę trywialnych API

Kiedy NIE używać: przy jednorazowym skrypcie, prototypie na jeden dzień albo API z dwoma endpointami płaski układ jest szybszy i uczciwszy. Clean Architecture zwraca inwestycję dopiero tam, gdzie projekt ma żyć i rosnąć.

💡 Jeśli dopiero zaczynasz z tym wzorcem, zobacz wcześniejszy wpis „Clean Architecture od podstaw — 4 warstwy w praktyce”


Cztery warstwy i kierunek zależności

Struktura solution wygląda tak — każdy typ w osobnym pliku, foldery odpowiadają odpowiedzialnościom:

src/
├── Domain/            # Zero zależności zewnętrznych — serce projektu
│   ├── Entities/      # TaskItem (encja z logiką)
│   ├── Enums/         # Priority, TaskState
│   ├── Exceptions/    # DomainException + wyjątki konkretne
│   └── Repository/    # ITaskRepository (kontrakt), TaskFilter
├── Application/       # Przypadki użycia — CQRS-lite
│   ├── Command/       # CreateTaskCommand, UpdateTaskCommand...
│   ├── Query/         # GetTasksQuery, GetTaskStatsQuery...
│   ├── DTOs/          # TaskDto, PagedResult<T>...
│   └── Handlers/      # Jeden handler = jeden przypadek użycia
├── Infrastructure/    # EF Core, SQLite
│   ├── Persistence/   # TaskDbContext, DbSeeder
│   └── Repository/    # EfTaskRepository (implementacja kontraktu)
└── API/
    ├── Controllers/   # Cienkie kontrolery
    ├── Middleware/    # ExceptionMiddleware
    ├── Extensions/    # ServiceCollectionExtensions (rejestracja DI)
    └── Program.cs     # Composition root

Kluczowa reguła referencji między projektami:

  • Infrastructure → referencuje Domain (implementuje ITaskRepository)
  • Application → referencuje Domain
  • API → referencuje wszystko i składa to razem
  • Domain → nie referencuje niczego

To właśnie ta reguła — a nie nazwa namespace’u — egzekwuje Dependency Inversion. Infrastructure fizycznie nie skompiluje się bez Domain, a Domain nie ma pojęcia, że Infrastructure istnieje.


Domain: encja, która pilnuje samej siebie

Najczęstszy błąd juniora: anemiczna encja z publicznymi setterami i całą logiką rozrzuconą po serwisach. Tutaj TaskItem sam pilnuje swoich przejść stanów — nikt z zewnątrz nie ustawi Status = Done na skróty.

public class TaskItem
{
    // Wszystkie settery prywatne — stan zmienia się TYLKO przez metody domenowe
    public int        Id          { get; private set; }
    public string     Title       { get; private set; } = null!;
    public Priority   Priority    { get; private set; }
    public TaskState  Status      { get; private set; }
    public DateTime?  CompletedAt { get; private set; }

    // EF Core wymaga konstruktora bezparametrowego
    private TaskItem() { }

    // Fabryka — jedyna brama do stworzenia poprawnego obiektu.
    // Walidacja dzieje się TU, a nie w kontrolerze czy handlerze.
    public static TaskItem Create(
        string    title,
        Priority  priority = Priority.Medium,
        DateTime? dueDate  = null)
    {
        if (string.IsNullOrWhiteSpace(title))
            throw new TaskValidationException("title", "Title cannot be empty.");

        if (dueDate.HasValue && dueDate.Value.Date < DateTime.UtcNow.Date)
            throw new TaskValidationException("dueDate", "Due date cannot be in the past.");

        return new TaskItem
        {
            Title     = title.Trim(),
            Priority  = priority,
            Status    = TaskState.Todo,   // obiekt rodzi się w poprawnym stanie
            CreatedAt = DateTime.UtcNow
        };
    }

    // Metoda domenowa — enkapsuluje regułę przejścia stanu
    public void Complete()
    {
        if (Status == TaskState.Done)
            throw new InvalidTaskOperationException($"Task '{Title}' is already completed.");
        if (Status == TaskState.Cancelled)
            throw new InvalidTaskOperationException($"Cannot complete cancelled task '{Title}'.");

        Status      = TaskState.Done;
        CompletedAt = DateTime.UtcNow;   // niezmiennik: Done zawsze ma CompletedAt
    }
}

Dlaczego to jest poprawne:

  • Factory Method (Create) gwarantuje, że nie istnieje TaskItem w niepoprawnym stanie. Nie ma publicznego konstruktora, którym ktoś obejdzie walidację.
  • Private setters sprawiają, że jedyna droga do zmiany statusu prowadzi przez metodę domenową, która sprawdza dozwolone przejścia.
  • Niezmienniki w jednym miejscu — CompletedAt ustawia się zawsze razem z Status = Done. Nie da się ich rozjechać.

Maszyna stanów, którą encja egzekwuje:

Todo ──StartProgress()──> InProgress ──Complete()──> Done
Todo ──Cancel()─────────> Cancelled
Done, Cancelled ──Reopen()──> Todo

Alternatywa — wzorzec State jako osobne klasy? Dla czterech statusów to over-engineering: metody z guardami są czytelniejsze i łatwiejsze do przetestowania. Wzorzec State zaczyna się opłacać, gdy przejść jest kilkanaście i mają złożoną logikę przejściową.


Application: CQRS-lite bez MediatR

Warstwa aplikacji trzyma przypadki użycia. Rozdzielamy komendy (modyfikują stan) od zapytań (odczytują, zwracają DTO — nigdy encje). Świadomie bez MediatR.

// Jeden handler = jeden przypadek użycia. Zależy tylko od interfejsu repozytorium.
public class CreateTaskHandler(ITaskRepository repository)
{
    public async Task<TaskDto> HandleAsync(
        CreateTaskCommand command,
        CancellationToken ct = default)
    {
        if (!Enum.TryParse<Priority>(command.Priority, ignoreCase: true, out var priority))
            throw new TaskValidationException("priority",
                $"Unknown priority '{command.Priority}'.");

        // Cała walidacja siedzi w fabryce domenowej — handler tylko orkiestruje
        var task = TaskItem.Create(command.Title, priority, command.DueDate);

        await repository.AddAsync(task, ct);
        await repository.SaveChangesAsync(ct);

        return task.ToDto();   // zwracamy DTO, nie encję — nie wyciekamy modelu domenowego
    }
}

Zwróć uwagę: handler zna tylko ITaskRepository. Nie wie, że pod spodem jest EF Core. Dzięki temu testujesz go na InMemory bez żadnych zmian w kodzie produkcyjnym.

Dlaczego bez MediatR?

PodejściePlusyMinusy
Ręczny CQRS (ten projekt)Zero pośrednictwa — F12 na handlerze prowadzi wprost do implementacji; łatwo wytłumaczyć na rozmowie rekrutacyjnejBrak wbudowanych pipeline behaviors (logging, walidacja, transakcje) — dopisujesz je sam
MediatRPipeline behaviors za darmo; spójne z dużą częścią komercyjnych codebase’ówDodatkowa warstwa abstrakcji i zależność; Send(command) ukrywa graf wywołań

Na tej skali ręczny dispatch to właściwy wybór. MediatR zaczyna się opłacać, gdy masz tyle handlerów, że cross-cutting concerns (walidacja, logowanie, cache) przestają być opcjonalne — to naturalny kolejny krok w roadmapie projektu.


Infrastructure: IQueryable kontra IEnumerable

To jest fragment, który najczęściej pada na rozmowach na mid-level .NET — i miejsce, gdzie płaski CRUD najczęściej zabija wydajność. Różnica jest subtelna w kodzie, ale dramatyczna w produkcji.

// ✅ DOBRZE — IQueryable: każdy warunek dokłada WHERE do zapytania SQL
private IQueryable<TaskItem> BuildQuery(TaskFilter? filter)
{
    IQueryable<TaskItem> query = _db.Tasks;

    if (filter?.Status is not null)
        query = query.Where(t => t.Status == filter.Status.Value);

    if (filter?.Priority is not null)
        query = query.Where(t => t.Priority == filter.Priority.Value);

    // ToListAsync() wykona się DOPIERO na końcu — jedno zapytanie do bazy
    return query.OrderByDescending(t => t.Priority);
}
// ❌ ŹLE — materializacja przed filtrowaniem
var all = await _db.Tasks.ToListAsync();          // ściąga CAŁĄ tabelę do RAM
var result = all.Where(t => t.Status == status);  // filtruje w pamięci aplikacji

Konsekwencja: wariant z IQueryable przy 2 milionach rekordów wysyła do bazy SELECT ... WHERE Status = 'Todo' i pobiera tylko pasujące wiersze. Wariant z ToList() najpierw ściąga wszystkie 2 miliony do pamięci aplikacji, a dopiero potem filtruje — to prosta droga do OutOfMemoryException i timeoutów.

Do tego enum zapisujemy jako string, żeby baza była czytelna:

// OnModelCreating — enum jako string zamiast 0/1/2/3 w kolumnie
entity.Property(t => t.Status)
      .HasConversion<string>()
      .HasMaxLength(20);

entity.HasIndex(t => t.Status);   // indeks pod najczęstszy filtr

API: cienkie kontrolery i poprawne kody HTTP

Kontroler ma jedną robotę: przyjąć request, wywołać handler, zwrócić odpowiedź z właściwym kodem. Zero logiki biznesowej.

[HttpPost]
[ProducesResponseType(typeof(TaskDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<TaskDto>> Create(
    [FromBody] CreateTaskRequest request,
    CancellationToken ct)
{
    var task = await createHandler.HandleAsync(
        new CreateTaskCommand(request.Title, request.Priority, request.DueDate), ct);

    // 201 Created + nagłówek Location wskazujący na nowy zasób — REST best practice
    return CreatedAtAction(nameof(GetById), new { id = task.Id }, task);
}

Poprawne kody HTTP to sygnał dojrzałości API:

  • 201 Created + Location przy POST (nie 200 OK)
  • 204 No Content przy DELETE
  • 404 Not Found, gdy zasób nie istnieje
  • 409 Conflict przy niedozwolonym przejściu stanu (nie generyczny 400)

Zamiast try/catch w każdym kontrolerze — jeden middleware mapujący wyjątki domenowe na kody HTTP:

private static async Task HandleAsync(HttpContext ctx, Exception ex)
{
    var (status, message) = ex switch
    {
        TaskNotFoundException e         => (404, e.Message),
        TaskValidationException e       => (400, e.Message),
        InvalidTaskOperationException e => (409, e.Message),
        _                                => (500, "An unexpected error occurred.")
    };

    ctx.Response.StatusCode = status;
    await ctx.Response.WriteAsJsonAsync(new { Status = status, Message = message });
}

⚠️ Ważne: app.UseMiddleware<ExceptionMiddleware>() musi być pierwszy w pipeline — inaczej błąd rzucony w Swaggerze, CORS czy kontrolerze nigdy do niego nie dotrze.


Dwa edge case’y, które wywalą Ci build

To są dokładnie te pułapki, o których nie przeczytasz w podstawowym tutorialu, a które realnie zatrzymają kompilację. Oba wyłapaliśmy dopiero przy realnym dotnet build.

1. Nazwa TaskStatus koliduje z BCL

Jeśli nazwiesz swój enum TaskStatus, dostaniesz błąd CS0104 w każdym pliku poza Domain:

// ❌ Kolizja: System.Threading.Tasks.TaskStatus trafia do zasięgu
//    przez ImplicitUsings — "TaskStatus" staje się niejednoznaczne
public enum TaskStatus { Todo, InProgress, Done, Cancelled }
// ✅ Rozwiązanie: nazwa, która nie koliduje z niczym w BCL
public enum TaskState { Todo, InProgress, Done, Cancelled }

Przy włączonym <ImplicitUsings>enable</ImplicitUsings> globalny using System.Threading.Tasks dokłada wbudowany TaskStatus. Kompilator nie wie, którego chcesz. Zmiana nazwy na TaskState jest czystsza niż alias namespace w każdym pliku.

2. Enum.TryParse na typie nullable — CS0453

// ❌ CS0453 — TaskState? nie spełnia ograniczenia where TEnum : struct
TaskState? status = null;
Enum.TryParse(input, ignoreCase: true, out status);   // nie kompiluje się
// ✅ Parsuj do zmiennej nienullowalnej, potem przypisz do nullable
TaskState? status = null;
if (!string.IsNullOrWhiteSpace(input))
{
    if (!Enum.TryParse<TaskState>(input, ignoreCase: true, out var parsed))
        throw new TaskValidationException("status", $"Unknown status '{input}'.");
    status = parsed;
}

Enum.TryParse<TEnum> ma ograniczenie where TEnum : struct, a TaskState? (czyli Nullable<TaskState>) nie może być użyte jako jawny argument generyczny w tym kontekście — mimo że samo jest structem. Trik: parsujesz do TaskState, dopiero potem przypisujesz do pola nullable.


Testy: 38 przypadków bez bazy i bez sieci

Największa wartość Clean Architecture ujawnia się w testach. Domain testujesz bez niczego, Application na InMemory EF Core z izolacją per test.

// Test domenowy — czysta logika, zero EF Core, zero DI, zero I/O
[Fact]
public void Complete_AlreadyDone_ThrowsInvalidTaskOperationException()
{
    var task = TaskItem.Create("Test");
    task.Complete();

    // Druga próba ukończenia rzuca wyjątek — reguła przejścia stanu działa
    Assert.Throws<InvalidTaskOperationException>(() => task.Complete());
}
// Izolacja: każdy test dostaje świeżą bazę InMemory pod unikalnym Guid
public static TaskDbContext Create() =>
    new(new DbContextOptionsBuilder<TaskDbContext>()
        .UseInMemoryDatabase(Guid.NewGuid().ToString())   // zero współdzielonego stanu
        .Options);

Podział zestawu:

  • 24 testy domenowe — guardy przejść stanów, walidacja fabryki, właściwości obliczane (IsOverdue)
  • 14 testów aplikacyjnych — handlery end-to-end na realnym EfTaskRepository i InMemory DbContext

Całość: 38 przypadków (35 [Fact] + 1 [Theory] × 3 [InlineData]), poniżej sekundy, bez pliku bazy i bez połączenia sieciowego.


Checklista: czy Twoje API jest gotowe na review

Zanim wrzucisz projekt na GitHub albo pokażesz go rekruterowi:

  •  Domain nie ma żadnych zależności zewnętrznych (żadnego using Microsoft.EntityFrameworkCore w encji)
  •  Encje mają prywatne settery i metody domenowe zamiast publicznych property do zmiany stanu
  •  Walidacja w jednym miejscu (fabryka domenowa), nie zduplikowana w kontrolerze i handlerze
  •  Filtrowanie na IQueryable — żadnego ToList() przed Where()
  •  Poprawne kody HTTP — 201 + Location, 204, 404, 409 tam, gdzie trzeba
  •  Globalny exception middleware zamiast try/catch w kontrolerach
  •  DTO na wyjściu z API, nigdy gołe encje domenowe
  •  Testy uruchamiają się bez bazy i bez sieci (InMemory / mock)
  •  README z uzasadnieniem decyzji — nie tylko „co”, ale „dlaczego” i jakie są kompromisy
  •  dotnet build z zerem ostrzeżeń, nie tylko zerem błędów

Podsumowanie

Clean Architecture w .NET 8 to nie akademicka teoria, tylko konkretny zwrot z inwestycji: testy w milisekundy, wymienialna infrastruktura i kod, w którym recenzent od razu widzi, gdzie leży logika. Kluczowe punkty do zapamiętania:

  • Kierunek zależności do środka egzekwuj przez referencje projektów, nie przez nazwy namespace’ów.
  • Encja pilnuje własnych niezmienników — Factory Method, prywatne settery, metody domenowe.
  • CQRS-lite bez MediatR jest właściwy na tej skali; MediatR dokładaj, gdy cross-cutting concerns przestają być opcjonalne.
  • IQueryable filtruje po stronie bazy — to jedna z najczęstszych pułapek wydajnościowych i pytań rekrutacyjnych.
  • Poprawne kody HTTP + jeden middleware zamiast try/catch wszędzie.
  • Uważaj na TaskStatus (CS0104) i Enum.TryParse na nullable (CS0453) — dwa edge case’y, które wywalą build.

🚀 Co dalej?

Chcesz zbudować to API krok po kroku, z pełnym kodem i live-codingiem na ekranie? Cały tutorial znajdziesz na moim kanale YouTube, a kompletny kod źródłowy jest gotowy do sklonowania z GitHuba.

👉 Obejrzyj pełny tutorial na YouTube i pobierz kod z repozytorium.

👉 Chcesz 7 gotowych projektów portfolio z omówieniem architektury? Wypróbuj 7 dni darmowego dostępu do pełnej biblioteki na dev-hobby.pl.

🔗 Kod źródłowy (GitHub)

💬 A teraz Twoja kolej: co dodałbyś do tego API jako kolejny krok — JWT, TestContainers czy MediatR? Napisz w komentarzu.


👨‍💻
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 Co dalej?

Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.

Dodaj komentarz

czytanie to początek

Zamień wiedzę w umiejętności

Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.

Pobieram roadmapę →