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,
IQueryablefiltrują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
- Cztery warstwy i kierunek zależności
- Domain: encja, która pilnuje samej siebie
- Application: CQRS-lite bez MediatR
- Infrastructure: IQueryable kontra IEnumerable
- API: cienkie kontrolery i poprawne kody HTTP
- Dwa edge case’y, które wywalą Ci build
- Testy: 38 przypadków bez bazy i bez sieci
- Checklista: czy Twoje API jest gotowe na review
- Podsumowanie
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ście | Plusy | Minusy |
|---|---|---|
| Płaski CRUD (kontroler → DbContext) | Szybki start, mało plików, zero ceremonii | Logika rozmyta po kontrolerach, testy wymagają bazy, zmiana bazy = refaktor wszędzie |
| Clean Architecture | Testy w milisekundy, wymienialna infrastruktura, jasny podział odpowiedzialności | Wię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→ referencujeDomain(implementujeITaskRepository)Application→ referencujeDomainAPI→ referencuje wszystko i składa to razemDomain→ 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 istniejeTaskItemw 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 —
CompletedAtustawia się zawsze razem zStatus = Done. Nie da się ich rozjechać.
Maszyna stanów, którą encja egzekwuje:
Todo ──StartProgress()──> InProgress ──Complete()──> Done
Todo ──Cancel()─────────> Cancelled
Done, Cancelled ──Reopen()──> TodoAlternatywa — 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ście | Plusy | Minusy |
|---|---|---|
| Ręczny CQRS (ten projekt) | Zero pośrednictwa — F12 na handlerze prowadzi wprost do implementacji; łatwo wytłumaczyć na rozmowie rekrutacyjnej | Brak wbudowanych pipeline behaviors (logging, walidacja, transakcje) — dopisujesz je sam |
| MediatR | Pipeline behaviors za darmo; spójne z dużą częścią komercyjnych codebase’ów | Dodatkowa 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 +
LocationprzyPOST(nie200 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
EfTaskRepositoryi InMemoryDbContext
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.EntityFrameworkCorew 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— żadnegoToList()przedWhere() - Poprawne kody HTTP — 201 + Location, 204, 404, 409 tam, gdzie trzeba
- Globalny exception middleware zamiast
try/catchw 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 buildz 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.
IQueryablefiltruje po stronie bazy — to jedna z najczęstszych pułapek wydajnościowych i pytań rekrutacyjnych.- Poprawne kody HTTP + jeden middleware zamiast
try/catchwszędzie. - Uważaj na
TaskStatus(CS0104) iEnum.TryParsena 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.
💬 A teraz Twoja kolej: co dodałbyś do tego API jako kolejny krok — JWT, TestContainers czy MediatR? Napisz w komentarzu.
🚀 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.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
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ę →