Clean Architecture w .NET 10: refaktoryzacja ASP.NET Core krok po kroku

Dostajesz projekt do utrzymania, otwierasz pierwszy plik i widzisz kontroler na 150 linii, który waliduje dane, liczy kwoty, łączy się z bazą, wysyła maile i obsługuje błędy. W jednym miejscu. Brzmi znajomo?
W tym wpisie pokażę Ci praktyczną refaktoryzację do Clean Architecture w .NET 10 i ASP.NET Core, krok po kroku, z prawdziwym kodem, realnymi pułapkami EF Core i testami, które nie potrzebują bazy danych ani serwera SMTP.
To nie jest akademicki wykład o warstwach. To przejście od chaosu (Before) do czystej architektury (After) na konkretnym module zamówień, z naciskiem na decyzje projektowe, które realnie ułatwiają utrzymanie i testowanie kodu.

Spis treści
- Problem: kontroler, który robi wszystko
- Czym jest Clean Architecture (w praktyce)
- Refaktoryzacja warstwa po warstwie
- Testy bez bazy danych i bez SMTP
- Najczęstsze pułapki
- Kiedy NIE używać Clean Architecture
- Checklist refaktoryzacji
- Podsumowanie
Problem: kontroler, który robi wszystko
Zacznijmy od punktu wyjścia — typowego „spaghetti” w akcji POST:
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request)
{
// ❌ 1. Walidacja inline w kontrolerze
if (string.IsNullOrWhiteSpace(request.CustomerEmail))
return BadRequest("Email is required");
// ❌ 2. Logika biznesowa + magic string zamiast enuma
var order = new Order
{
CustomerEmail = request.CustomerEmail,
Status = "Pending",
CreatedAt = DateTime.UtcNow
};
// ❌ 3. Bezpośredni dostęp do DbContext w kontrolerze
_db.Orders.Add(order);
await _db.SaveChangesAsync();
// ❌ 4. Wysyłka maila inline — nie da się tego przetestować bez sieci
Console.WriteLine($"[SMTP] Sending to {request.CustomerEmail}");
return Ok(new { order.Id });
}
Ten kod działa i właśnie dlatego jest groźny. Problem nie jest funkcjonalny, tylko strukturalny: jeden plik ma wiele powodów do zmiany, a logikę biznesową da się przetestować dopiero po postawieniu prawdziwej bazy i SMTP.
Checklist: 7 sygnałów, że masz ten problem
- [ ] Logika biznesowa siedzi w kontrolerze
- [ ] Kontroler sięga bezpośrednio do DbContext
- [ ] Statusy jako „magic stringi” (“Pending”, “Confirmed”)
- [ ] Walidacja rozsiana po akcjach
- [ ] Wysyłka maili/integracje wołane wprost z kontrolera
- [ ] try/catch i BadRequest(…) powtórzone w każdej metodzie
- [ ] Nie masz gdzie napisać testu jednostkowego
Jeśli zaznaczasz choć kilka punktów, Clean Architecture realnie Ci pomoże.
Czym jest Clean Architecture (w praktyce)
Clean Architecture to cztery warstwy z jednokierunkowym przepływem zależności zawsze do środka.
| Warstwa | Odpowiedzialność | Zależy od |
| API | Kontrolery, middleware, modele request/response | Application + Infrastructure |
| Application | Przypadki użycia: komendy, zapytania, handlery | Domain |
| Domain | Encje, Value Objects, interfejsy, wyjątki | niczego |
| Infrastructure | EF Core, e‑mail, serwisy zewnętrzne | Domain + Application |
Reguła zależności (Dependency Rule)
Najważniejsza zasada całej architektury: Infrastructure wie o Domain, ale Domain nie wie, że Infrastructure istnieje. To Dependency Inversion Principle w skali całej aplikacji, wysokopoziomowa logika zależy od abstrakcji, a szczegóły (EF Core, SMTP) dopinają się od zewnątrz.

💡 Zasady SOLID w C#: 5 reguł na prawdziwych przykładach z .NET – Dependency Injection w ASP.NET Core
Refaktoryzacja warstwa po warstwie
Domain — serce aplikacji (zero zależności)
W Domain ląduje to, co najcenniejsze: encje, Value Objects i kontrakty. Kluczowy szczegół, projekt Domain.csproj nie ma ani jednego PackageReference. Gdybyś usunął wszystkie NuGet‑y, domena dalej się kompiluje.
Najpierw Value Object Money — niezmienny i samowalidujący się. Walidacja siedzi w inicjalizatorach init, więc odpala się także przy kopiowaniu przez with:
public record Money(decimal Amount, string Currency = "PLN")
{
// Walidacja w 'init' => obiekt nigdy nie powstanie w złym stanie
public decimal Amount { get; init; } = Amount >= 0
? Amount
: throw new ArgumentOutOfRangeException(nameof(Amount), "Amount cannot be negative");
public string Currency { get; init; } = !string.IsNullOrWhiteSpace(Currency)
? Currency.ToUpperInvariant()
: throw new ArgumentException("Currency is required", nameof(Currency));
}
Następnie encja Order z prywatnymi setterami i fabryką, jedyną bramą do stworzenia poprawnego zamówienia:
public static Order Create(string customerEmail, string productName,
int quantity, decimal unitPrice, string currency = "PLN")
{
if (quantity <= 0) throw new InvalidOrderException($"Quantity must be > 0, got: {quantity}");
if (unitPrice <= 0) throw new InvalidOrderException($"Unit price must be > 0, got: {unitPrice}");
var order = new Order
{
CustomerEmail = new EmailAddress(customerEmail), // VO waliduje email
ProductName = productName.Trim(),
Quantity = quantity,
UnitPrice = new Money(unitPrice, currency),
Status = OrderStatus.Pending, // enum, nie "magic string"
CreatedAt = DateTime.UtcNow
};
order._domainEvents.Add(new OrderCreatedEvent(order)); // zdarzenie domenowe
return order;
}
Przejścia stanów też są w domenie i rzucają wyjątki z kontekstem zamiast zwracać bool/null:
public void Confirm()
{
EnsureStatus(OrderStatus.Pending, OrderStatus.Confirmed); // rzuca, jeśli stan != Pending
Status = OrderStatus.Confirmed;
ConfirmedAt = DateTime.UtcNow;
_domainEvents.Add(new OrderConfirmedEvent(this));
}
I rzecz, która myli najczęściej, interfejs repozytorium jest w Domain, nie w Infrastructure:
// Plik: After/Domain/Orders/IOrderRepository.cs
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetActiveAsync(CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}
To domena definiuje kontrakt, a Infrastructure go implementuje. Tak właśnie odwracamy zależność.
Application — przypadki użycia
Warstwa Application zależy wyłącznie od Domain. Jeden handler = jeden przypadek użycia:
public sealed class CreateOrderHandler(
IOrderRepository repository,
IOrderEmailService emailService)
{
public async Task<CreateOrderResult> HandleAsync(
CreateOrderCommand command, CancellationToken ct = default)
{
// 1. Utwórz encję (walidacja dzieje się w Domenie)
var order = Order.Create(command.CustomerEmail, command.ProductName,
command.Quantity, command.UnitPrice, command.Currency);
// 2. Zapis przez interfejs (handler nie wie, że to EF Core)
await repository.AddAsync(order, ct);
await repository.SaveChangesAsync(ct);
// 3. Side effect — przez interfejs (handler nie wie, że to SMTP)
await emailService.SendOrderCreatedAsync(order, ct);
return new CreateOrderResult(order.Id, order.TotalPrice.Amount, order.TotalPrice.Currency);
}
}
Handler widzi tylko interfejsy (IOrderRepository z Domain, IOrderEmailService z Application). Konkretne implementacje dostarcza Infrastructure.
Infrastructure — szczegóły implementacji
Tu mieszka EF Core. Value Object Money mapujemy jako OwnsOne, a właściwości wyliczane i kanał zdarzeń wykluczamy z mapowania:
entity.OwnsOne(o => o.UnitPrice, money =>
{
money.Property(m => m.Amount).HasPrecision(18, 2);
money.Property(m => m.Currency).HasMaxLength(3);
});
entity.Ignore(o => o.TotalPrice); // wyliczana: UnitPrice * Quantity
⚠️ Pułapka: kuszące jest dopisanie tu .HasColumnName(…), ale to API providera relacyjnego. W projekcie korzystającym tylko z Microsoft.EntityFrameworkCore.InMemory ta metoda nie skompiluje się.
API — cienki kontroler i jeden middleware błędów
Kontroler robi już tylko trzy rzeczy: mapuje request na komendę, woła handler, zwraca wynik. Obsługę błędów wynosimy do jednego middleware, który tłumaczy wyjątki domenowe na semantyczne kody HTTP:
var (statusCode, message) = exception switch
{
OrderNotFoundException => (StatusCodes.Status404NotFound, exception.Message),
InvalidOrderStateException => (StatusCodes.Status409Conflict, exception.Message),
InvalidOrderException => (StatusCodes.Status400BadRequest, exception.Message),
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.")
};
| Wyjątek domenowy | Kod HTTP |
| OrderNotFoundException | 404 Not Found |
| InvalidOrderStateException | 409 Conflict |
| InvalidOrderException | 400 Bad Request |
| pozostałe | 500 (bez ujawniania szczegółów) |

Testy bez bazy danych i bez SMTP
To jest kulminacja całej refaktoryzacji. Skoro Application zależy tylko od interfejsów, w testach podstawiamy fake’i zamiast EF Core i SMTP:
[Fact]
public async Task Handle_ValidCommand_CreatesOrderAndSendsEmail()
{
// Arrange — zero infrastruktury
var repo = new InMemoryOrderRepository();
var email = new FakeOrderEmailService();
var handler = new CreateOrderHandler(repo, email);
// Act
var result = await handler.HandleAsync(
new CreateOrderCommand("jan@test.pl", "Laptop", 2, 2999m));
// Assert — zapis i mail bez bazy i bez sieci
var saved = await repo.GetByIdAsync(result.OrderId);
Assert.NotNull(saved);
Assert.Single(email.SentEmails);
}
Co zyskujesz:
- Szybkość — brak I/O, samo wykonanie testów to milisekundy (w Test Explorerze patrz na kolumnę Duration, start hosta i build to osobny narzut).
- Determinizm — żadnych „flaky” testów zależnych od sieci czy stanu bazy.
- Brak mocków — wystarczą proste, czytelne fake’i.

Co testujemy jednostkowo, a co osobno
- ✅ Reguły encji i przejścia stanów (Confirm, Cancel, Ship)
- ✅ Walidacja Value Objects (Money, EmailAddress)
- ✅ Handlery przypadków użycia na fake’ach
- ⛔ Mapowanie EF (OwnsOne/HasConversion) to test integracyjny (np. z Testcontainers), świadomie poza zakresem testów jednostkowych
Najczęstsze pułapki
Lista rzeczy, które realnie wywracają build albo zachowanie, zebrana z prawdziwych potknięć podczas tej refaktoryzacji:
- HasColumnName w projekcie InMemory. To metoda providera relacyjnego (Microsoft.EntityFrameworkCore.Relational). Jeśli referujesz tylko …InMemory, dostaniesz CS1061. Rozwiązanie: usuń HasColumnName (nazwy kolumn dorzucisz przy UseSqlServer/UseNpgsql) — HasPrecision i HasMaxLength to API core, więc zostają.
- Brak Ignore(o => o.TotalPrice). Właściwość wyliczana typu Money, bez wykluczenia EF spróbuje ją zmapować i rzuci przy budowaniu modelu (start aplikacji).
- Dwa namespace w jednym pliku. File‑scoped namespace X;, a niżej drugi namespace Y; to błąd kompilacji. Użyj namespace blokowych albo rozbij plik.
- Walidacja w rekordzie pozycyjnym. public Money : this(…) to nie jest poprawna składnia. Walidację wstaw w inicjalizatorach init właściwości (jak wyżej).
- Wywołanie metody z file‑klasy spoza tej klasy. file static class jest widoczna tylko w obrębie pliku, wywołanie jej składowej bez kwalifikatora z innej klasy to CS0103.
- Projekt testowy z <OutputType>Exe</OutputType>. Projekt testowy to biblioteka, hosta dostarcza Test SDK.
- Brak ProjectConfigurationPlatforms w .sln. Solution bez mapowania konfiguracji potrafi „nie budować” projektów w VS.
💡 Value Object w DDD — implementacja w C# z rekordem
💡 Repository Pattern w C# – Refaktoryzacja kodu do Clean Architecture
Kiedy NIE używać Clean Architecture
Dla równowagi: to nie jest złoty młotek.
- Małe CRUD‑y i prototypy — narzut warstw może przewyższyć korzyści. Czasem Minimal API + kilka serwisów wystarczy.
- Krótkożyjące skrypty/POC — jeśli kod ma żyć tydzień, struktura 4 warstw to przerost formy.
- Zespół bez zgody na konwencje — architektura działa, gdy zespół jej pilnuje; inaczej szybko zamienia się w „cargo cult”.
Clean Architecture zwraca się przy systemach, które żyją latami, mają realną logikę domenową i wymagają wysokiej testowalności.
Checklist refaktoryzacji
Praktyczna lista do przejścia z „Before” do „After”:
- [ ] Wyodrębnij encje i Value Objects do Domain
- [ ] Zamień „magic stringi” na enumy
- [ ] Przenieś logikę przejść stanów do metod domenowych
- [ ] Zdefiniuj interfejsy repozytoriów w Domain
- [ ] Stwórz handlery (komendy/zapytania) w Application
- [ ] Schowaj EF Core i SMTP za interfejsami w Infrastructure
- [ ] Dodaj jeden middleware mapujący wyjątki na kody HTTP
- [ ] Napisz testy na fake’ach — bez bazy i bez sieci
- [ ] Sprawdź kierunek zależności (Domain nie zależy od niczego)
Podsumowanie
Clean Architecture w .NET 10 to nie magia, tylko konsekwentne pilnowanie granic i kierunku zależności. Efekt? Logikę domenową testujesz w milisekundach bez infrastruktury, zmianę bazy czy dostawcy maili izolujesz do jednej warstwy, a struktura katalogów sama opowiada, co robi system.
Najwięcej wartości daje połączenie trzech rzeczy: bogatego modelu domenowego, odwrócenia zależności przez interfejsy i testów na fake’ach. Resztę (MediatR, FluentValidation, pełne CQRS, outbox, testy integracyjne z Testcontainers) dokładasz wtedy, gdy projekt realnie tego potrzebuje.
🚀 Co dalej?
Chcesz przejść przez tę refaktoryzację krok po kroku, na żywo, i zobaczyć pełną, produkcyjną wersję (MediatR, pipeline behaviors, FluentValidation, CQRS, testy integracyjne)?
➡️ Pobierz darmową Roadmapę Junior .NET Developer na dev-hobby.pl.
➡️ Kod z artykułu (solution Before + After) znajdziesz na GitHubie, Clean Architecture w .NET 10 — Przykład referencyjny zostaw ⭐, jeśli się przyda.
Masz pytanie albo własny przypadek do refaktoryzacji? Napisz w komentarzu — odpowiem na każdy.
🔗 Zobacz też
- C# Podstawy Programowania: Twój Pierwszy Krok w Świat Kodowania
- AI w .NET: Zostań Architektem Inteligentnych Aplikacji!
- C# Clean Architecture w Praktyce
- C# – Zbuduj Własnego Tetrisa! Kompletny Przewodnik
- 7 Dniowe Wyzwanie C# Tic Tac Toe
- C# Zbuduj Profesjonalny Portal Randkowy od Podstaw!
Zobacz także — powiązane artykuły
👉 MCP w .NET (C#) – jak zbudować serwer AI krok po kroku
👉 Tworzenie klas i obiektów w C# — kompletny przewodnik
👉 Pattern Matching w C# – switch expressions i type patterns
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ę →