🔥 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: refaktoryzacja ASP.NET Core krok po kroku

Clean Architecture

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.

diagram 4 warstw z kierunkiem zależności „do środka"
diagram 4 warstw z kierunkiem zależności „do środka”

Spis treści


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.

WarstwaOdpowiedzialnośćZależy od
APIKontrolery, middleware, modele request/responseApplication + Infrastructure
ApplicationPrzypadki użycia: komendy, zapytania, handleryDomain
DomainEncje, Value Objects, interfejsy, wyjątkiniczego
InfrastructureEF Core, e‑mail, serwisy zewnętrzneDomain + 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.

Reguła zależności - Domain w centrum
Reguła zależności – Domain w centrum

💡 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 domenowyKod HTTP
OrderNotFoundException404 Not Found
InvalidOrderStateException409 Conflict
InvalidOrderException400 Bad Request
pozostałe500 (bez ujawniania szczegółów)
Swagger UI z listą endpointów apiorders
Swagger UI z listą endpointów /api/orders

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.
Test Explorer z zielonymi testami i widoczną kolumną Duration
Test Explorer z zielonymi testami i widoczną kolumną Duration

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:

  1. 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ą.
  2. 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).
  3. 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.
  4. 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).
  5. 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.
  6. Projekt testowy z <OutputType>Exe</OutputType>. Projekt testowy to biblioteka, hosta dostarcza Test SDK.
  7. 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ż

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

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ę →