Po co w ogóle czysta architektura w projektach ASP.NET Core
Monolit kontrolerów – szybki start, późniejszy chaos
Typowy projekt w ASP.NET Core zaczyna się niewinnie: kilka kontrolerów, parę modeli, jedna konfiguracja DbContext i entuzjazm, że „nareszcie coś działa”. Po kilku sprintach pojawia się jednak znajomy obrazek: kontrolery puchną, logika biznesowa miesza się z walidacją, mapowaniem i wywołaniami bazy, a każdy nowy endpoint wymaga otwierania pięciu plików, żeby zrozumieć, co się właściwie dzieje.
Taki monolit kontrolerów jest kuszący, bo pozwala bardzo szybko dowieźć MVP. Problem pojawia się przy dłuższym rozwoju: brak jasnych granic, mocne zależności od frameworka (ASP.NET Core, EF Core), trudne testowanie bez wstawania całego środowiska oraz narastający koszt zmian. Refaktoring jest odkładany, bo „teraz nie ma czasu”, a każda poprawka jednego fragmentu potrafi zepsuć coś zupełnie innego.
Czysta architektura w .NET jest odpowiedzią na ten stan. Zamiast budować aplikację wokół kontrolerów i DbContextu, struktura jest układana wokół domeny i przypadków użycia. Framework staje się detalem technicznym na obrzeżach systemu, a nie jego centrum. To zmienia nie tylko sposób pisania kodu, ale też sposób myślenia o projekcie.
Trójwarstwowiec, clean architecture i anemiczny model – porównanie
W świecie .NET najczęściej spotykane są trzy podejścia:
- klasyczny trójwarstwowiec (UI–BLL–DAL),
- anemiczny model domeny (płaskie klasy z właściwościami i serwisy z logiką),
- czysta architektura (koncentryczne pierścienie, zależności skierowane do domeny).
Trójwarstwowiec porządkuje projekt na wysokim poziomie: kontrolery rozmawiają z serwisami (BLL), serwisy korzystają z repozytoriów (DAL). Na początku daje to poczucie ładu, jednak w praktyce łatwo o „warstwę BLL jako śmietnik”, gdzie wszystko ląduje w jednym worku, a logika biznesowa przenika się z infrastrukturą (np. logowaniem, cachingiem, szczegółami DTO).
Anemiczny model domenowy to wariant, w którym encje to głównie zbiory właściwości, bez logiki, a cała rzeczywista praca odbywa się w serwisach. Taki model jest prosty, łatwy do zrozumienia dla początkujących i dobrze sprawdza się przy bardzo prostych systemach CRUD. Minusem jest brak ochrony invariants – obiekty można modyfikować w dowolny sposób, a reguły biznesowe są rozsiane po wielu miejscach.
Czysta architektura stawia domenę w centrum. Reguły biznesowe, encje i agregaty są niezależne od technologii. Warstwy zewnętrzne (aplikacja, infrastruktura, prezentacja) zależą od domeny, ale domena nie wie o ich istnieniu. Dzięki temu logikę można testować bez bazy, bez HTTP, bez faktycznego uruchamiania serwera. Granice odpowiedzialności są ostrzejsze, co zapobiega powolnemu rozmywaniu modelu i wciąganiu frameworka do środka.
Realne cele: separacja, testowalność, wymiana interfejsów
Czysta architektura w ASP.NET Core nie jest celem samym w sobie, tylko narzędziem do osiągnięcia konkretnych efektów:
- Separacja domeny od frameworka – domena nie powinna zależeć od ASP.NET Core, EF Core, bibliotek do HTTP czy cache. W efekcie reguły biznesowe da się uruchomić w dowolnym środowisku i przetestować bez ciężkich zależności.
- Testowalność – przypadki użycia i serwisy domenowe są zwykłymi klasami .NET, do których można wstrzyknąć fałszywe repozytoria i serwisy. Testy jednostkowe stają się szybkie i stabilne, a testy integracyjne nie muszą obejmować całego stosu.
- Łatwiejsza wymiana interfejsów – gdy UI (np. API, Blazor, gRPC) i persystencja (SQL, NoSQL, pliki) są na obrzeżach, ich wymiana nie wywraca systemu do góry nogami. Wiele systemów zaczyna jako API i później dostaje dodatkowo procesor kolejkowy albo inną formę frontu.
Kluczowa jest świadomość, że czysta architektura nie rozwiąże problemów domenowych ani organizacyjnych. Daje jednak szkielet, który ułatwia kontrolowanie złożoności aplikacji i ogranicza wpływ decyzji technicznych na rdzeń biznesu.
Kiedy inwestycja w czystą architekturę ma sens
Nie każde API ASP.NET Core potrzebuje czystej architektury w pełnym wydaniu. Inwestycja w złożony podział warstw i projektów ma sens przede wszystkim wtedy, gdy:
- projekt ma dłuższą perspektywę życia (lata, nie tygodnie),
- domena biznesowa nie jest trywialna (złożone reguły, wiele przypadków użycia, rosnąca liczba funkcjonalności),
- zespół liczy co najmniej kilka osób, które będą równolegle rozwijać różne fragmenty systemu,
- planowana jest rozbudowa interfejsów (np. dodatkowe kanały komunikacji, integracje, nowe UI).
Dla prostego narzędzia administracyjnego, kilku endpointów do raportowania czy małego serwisu pomocniczego, pełne clean architecture może być przerostem formy nad treścią. W takich miejscach często wystarczy lżejsza struktura z jasnym podziałem na domenę i resztę, bez rozbijania solution na wiele projektów.
Fundamenty czystej architektury w wersji .NET – co jest naprawdę ważne
Zależności skierowane do środka: domena jako rdzeń
Najważniejsza zasada czystej architektury to kierunek zależności: wszystko zależy od domeny, domena nie zależy od niczego. W praktyce oznacza to, że:
- warstwa domeny (Domain) nie referuje żadnego innego projektu,
- warstwa aplikacji (Application) zna tylko domenę,
- warstwa infrastruktury (Infrastructure) zależy od Application/Domain, a nie odwrotnie,
- warstwa prezentacji (Web/API) łączy wszystko, ale sama również nie jest widziana przez niższe warstwy.
W ASP.NET Core to się przekłada na referencje między projektami .csproj i konfigurację DI. Interfejsy repozytoriów i serwisów domenowych żyją w warstwach wewnętrznych (Domain/Application), natomiast ich implementacje – w Infrastructure. Framework ASP.NET Core jest tylko w projekcie Web/API. MediatR i FluentValidation zwykle pojawiają się w Application, a zewnętrzne klienty HTTP, integracje z kolejkami i baza danych w Infrastructure.
Przy takim podejściu każda nowa zależność zewnętrzna musi się „ukryć” za interfejsem w wewnętrznej warstwie. To wymusza myślenie o kontraktach i przepływie danych, ale w zamian daje większą kontrolę i odporność na zmiany technologiczne.
Warstwa kontra pierścień – dlaczego nazwy katalogów to detal
Często pojawia się pytanie, czy trzymać się sztywno nazewnictwa „warstwa domeny”, „warstwa aplikacji”, czy raczej mówić o „pierścieniach”. W teorii clean architecture mowa o koncentrycznych okręgach (Entities, Use Cases, Interface Adapters, Frameworks & Drivers). W .NET zwykle przekłada się to na:
- Domain (encje, value objects, reguły biznesowe),
- Application (use cases, serwisy aplikacyjne, porty),
- Infrastructure (implementacje portów, persystencja, integracje),
- Web/API (UI, API, host).
Większość problemów nie bierze się jednak z nazwy folderu, tylko z łamania zasad zależności. Można mieć idealny podział na katalogi „Domain, Application, Infrastructure”, a i tak wstrzykiwać DbContext bezpośrednio do encji albo mieszać kontrolery z logiką biznesową. Priorytetem jest to, żeby:
- domena nie używała klas z ASP.NET Core, EF Core ani bibliotek infrastrukturalnych,
- logika biznesowa była zamknięta w domenie (lub domenie + aplikacji), a nie w kontrolerach,
- infrastruktura implementowała kontrakty z warstw wewnętrznych, nie odwrotnie.
Struktura solution i katalogów ma wspierać te zasady, ale nie jest celem sama w sobie. Dlatego lepiej zacząć od prostego, spójnego podziału i konsekwentnie trzymać się reguł, niż projektować rozbudowaną hierarchię nazw, której nikt później nie rozumie.
SOLID w kontekście ASP.NET Core: SRP, DIP, ISP
Czysta architektura w .NET opiera się na zasadach SOLID, ale w praktyce trzy z nich są szczególnie istotne:
- SRP (Single Responsibility Principle) – każda klasa ma jeden powód do zmiany. Kontroler nie powinien zajmować się walidacją biznesową, mapowaniem encji czy obsługą transakcji. Handler komendy nie powinien znać szczegółów cache’owania. Dobrze zdefiniowane use case’y wydzielają pojedyncze operacje biznesowe.
- DIP (Dependency Inversion Principle) – warstwy wyższe nie powinny zależeć bezpośrednio od implementacji warstw niższych. Zamiast korzystać z konkretnych klas repozytoriów EF, warstwa Application używa interfejsów, a ich implementacje są wstrzykiwane przez DI.
- ISP (Interface Segregation Principle) – lepiej mieć kilka mniejszych interfejsów niż jeden ogromny. W praktyce, zamiast jednego IApplicationService z dziesiątkami metod, lepiej zdefiniować wyspecjalizowane porty per przypadek użycia lub per agregat.
Przykład zastosowania DIP w ASP.NET Core: zamiast rejestrować w DI konkretny DbContext w handlerze, definiuje się np. interfejs IOrderRepository w domenie lub aplikacji, a implementacja EfOrderRepository w Infrastructure używa EF Core i DbContext. Handler komendy „PlaceOrder” widzi tylko IOrderRepository, co ułatwia testowanie i wymianę persystencji.
Domain-driven design „light” vs. prosty CRUD w czystej architekturze
Czysta architektura nie wymusza pełnego DDD. Można z powodzeniem stosować podejście „DDD light”, gdzie:
- domena ma encje i value objects z podstawową logiką,
- część prostych operacji pozostaje w stylu CRUD, ale z zachowaniem granic warstw,
- tam, gdzie logika robi się złożona, wprowadza się agregaty i eventy domenowe.
Podejście CRUD w czystej architekturze zwykle sprowadza się do tego, że każdy use case odpowiada za jedną operację (dodanie, aktualizacja, usunięcie, odczyt) na danej encji. Przepływ danych jest jednak taki sam jak w bardziej rozbudowanym DDD: kontroler → DTO/command → handler → domena → repozytorium → domena → DTO/response → kontroler.
Pełne DDD (wraz z bounded contexts, event stormingiem, zaawansowanymi agregatami) ma sens w dużych systemach, z intensywnym udziałem biznesu i wieloma regułami. W wielu projektach ASP.NET Core wystarcza lżejsza wersja: oddzielenie domeny, czytelne use case’y, unikanie anemicznego modelu tam, gdzie wymagania zaczynają się komplikować.

Propozycja struktury solution dla ASP.NET Core z czystą architekturą
Standardowy podział na projekty: Domain, Application, Infrastructure, Web
Najczęściej stosowany układ solution w .NET z czystą architekturą wygląda następująco:
- MyApp.Domain – encje, value objects, agregaty, interfejsy repozytoriów, eventy domenowe.
- MyApp.Application – przypadki użycia (komendy, zapytania), interfejsy serwisów aplikacyjnych, DTO, ew. profile mapowania, porty do usług zewnętrznych.
- MyApp.Infrastructure – EF Core DbContext, implementacje repozytoriów, serwisy integracyjne (HTTP, kolejki, pliki), konfiguracja persystencji.
- MyApp.Web (lub MyApp.Api) – projekt ASP.NET Core: kontrolery lub minimal APIs, konfiguracja DI, middleware, konfiguracja hosta.
Ten podział wystarcza na zaskakująco długo. Z czasem można zacząć dzielić go na moduły (np. MyApp.Orders.Domain, MyApp.Orders.Application itd.), ale początkowo ważniejsze niż liczba projektów jest to, czy zależności między nimi są zgodne z zasadami clean architecture.
Prosty przykład zależności projektów:
- MyApp.Domain – brak referencji do innych projektów,
- MyApp.Application – referencja do MyApp.Domain,
- MyApp.Infrastructure – referencja do MyApp.Domain i MyApp.Application,
- MyApp.Web – referencja do wszystkich powyższych.
Jedna solucja czy kilka? Bounded contexts a podział na solution
W większych systemach dochodzi temat bounded contexts. Możliwe są dwa główne warianty:
- Jedna solucja z modułami – każdy kontekst ma swoje projekty domeny i aplikacji, ale wszystko siedzi w jednym solution, z jednym hostem Web/API. Przykład:
Orders.Domain,Billing.Domain,Users.Domainitd. - Kilka solucji – każdy bounded context funkcjonuje jako osobne solution (czasem osobny serwis), z własnym Web/API, Domain, Application, Infrastructure. Komunikacja między kontekstami odbywa się przez zdarzenia lub API.
Jedna solucja jest wygodniejsza organizacyjnie, zwłaszcza na początku: jeden proces deployu, jedna konfiguracja hosta, łatwiejsze debugowanie. Minusem jest ryzyko „przeskakiwania” granic między modułami: łatwo zreferencjonować projekt, który tak naprawdę powinien być tylko partnerem komunikującym się poprzez kontrakt.
Podział na moduły funkcjonalne a „warstwy poziome”
Dwa najpopularniejsze sposoby organizacji kodu w solution to podział po warstwach (Domain/Application/Infrastructure/Web) oraz podział po modułach/bounded contexts (Orders, Billing, UserManagement itd.). Zderzenie tych podejść daje kilka wariantów, z których każdy ma inne konsekwencje.
Najprostszy układ to klasyczny „poziomy” podział na cztery projekty, bez dalszego rozdrabniania. Sprawdza się w małych i średnich systemach, gdzie:
- zespoły są niewielkie,
- działa jeden, wspólny proces wdrożeniowy,
- nie ma silnie izolowanych domen biznesowych.
Druga opcja to dołożenie modułów w środku warstw, np. w Domain powstają katalogi Orders, Billing, Users, a w Application – odpowiadające im przypadki użycia. Struktura solution pozostaje prosta, ale wewnątrz projektów domain/application widać przejrzyste granice modułów. To zwykle dobry etap pośredni między prostym monolitem a pełnym podziałem na wiele serwisów.
Najdalej idący wariant to osobne projekty per moduł i per warstwa, np. MyApp.Orders.Domain, MyApp.Orders.Application, MyApp.Orders.Infrastructure. Wtedy „warstwa” staje się bardziej konceptem niż faktycznym projektem, a moduły zyskują silną izolację (oddzielne referencje, własne migracje bazy, czasem własne API). Ten układ ułatwia późniejsze wyciąganie osobnych serwisów, ale podnosi koszt początkowej konfiguracji i utrzymania solution.
Przy wyborze schematu zwykle decyduje kilka czynników:
- Rozmiar systemu – im większy, tym mocniej opłaca się inwestować w moduły jako oddzielne projekty.
- Niezależność zespołów – jeśli nad Orders i Billing pracują niezależne zespoły, warto im dać wyraźne granice w solution.
- Prawdopodobieństwo rozcięcia monolitu – kiedy wiadomo, że za rok–dwa część modułów będzie mikroserwisem, lepiej od razu podzielić solution modularnie.
Jak zacząć od prostego układu i nie ugrzęznąć w refaktorze
Rzadko udaje się trafić idealny podział od pierwszego dnia projektu. Znacznie sensowniejsze podejście to zacząć od prostszego wariantu i dopiero przy rosnącej złożoności wydzielać kolejne projekty lub moduły. Kluczowe jest, żeby granice architektoniczne istniały w kodzie logicznie wcześniej, niż zaczną istnieć fizycznie w solution.
Dobrym sygnałem do wydzielenia oddzielnego projektu dla modułu jest moment, w którym:
- moduł ma własny, spójny model domenowy (słownictwo, reguły, agregaty),
- w Application pojawia się kilkanaście–kilkadziesiąt przypadków użycia tylko dla tego obszaru,
- w Infrastructure rosną osobne konfiguracje EF Core, migracje, integracje zewnętrzne.
Przy takim podejściu refaktor na „więcej projektów” sprowadza się głównie do przeniesienia katalogów do nowych projektów i poprawienia referencji. Jeśli wcześniej domain i application były już logicznie podzielone na moduły, nie trzeba zmieniać samej logiki.
Warstwa domeny (Domain): serce systemu bez zależności od ASP.NET Core
Co realnie powinno znaleźć się w projekcie Domain
Projekt domenowy jest najbardziej wrażliwym miejscem w całej solution, ale i najczęściej „zanieczyszczanym”. Typowy, zdrowy zestaw elementów w Domain to:
- encje i agregaty (np.
Order,Customer,Invoice), - value objects (np.
Money,Email,Address), - interfejsy repozytoriów i innych portów (np.
IOrderRepository,IEmailSender– jeśli ściśle powiązane z domeną), - eventy domenowe (np.
OrderPlacedDomainEvent,PaymentFailedDomainEvent), - niezależne od frameworków reguły biznesowe (np. serwisy domenowe).
Częsty antywzorzec to przenoszenie do domeny wszystkiego, co wygląda „biznesowo”, łącznie z DTO, klasami do komunikacji z API czy modelami EF Core. Domain powinno zostać możliwie „czyste”: żadnych atrybutów EF Core, brak zależności od Microsoft.AspNetCore.*, brak referencji do klienta HTTP czy bibliotek do serializacji JSON.
Encje i agregaty: kiedy wystarczy prosty model, a kiedy potrzebny jest agregat
Encja domenowa reprezentuje obiekt identyfikowalny w czasie (np. zamówienie, użytkownik). Agregat natomiast to grupa encji, która jest modyfikowana jako całość przez jeden główny korzeń (aggregate root). W prostych systemach wiele encji jest jednocześnie korzeniami agregatów, więc różnica bywa czysto koncepcyjna.
Trzy sytuacje, w których opłaca się zdefiniować wyraźny agregat:
- biznes wymaga niepodzielnych operacji na kilku encjach jednocześnie (np. dodanie pozycji do zamówienia i przeliczenie rabatu),
- reguły spójności dotyczą całego „klastra” obiektów, a nie pojedynczej encji,
- w bazie danych i tak będziesz używać wspólnej transakcji dla kilku tabel.
Przykład z życia: w systemie zamówień przez kilka lat istniał model z encjami Order i OrderItem, ale logika w praktyce dotyczyła tylko Order, a pozycje były niemal pasywną listą. Wystarczyły proste metody typu AddItem, RemoveItem na encji Order. W innym projekcie (system logistyczny) każda zmiana pojedynczej pozycji wpływała na harmonogram wysyłek, rezerwację stanów magazynowych i koszty transportu. Tam agregat stał się naturalną granicą – nie dało się już bezpiecznie modyfikować encji „pod spodem” spoza korzenia.
Value objects: prosty sposób na spójność i walidację
Value object to niewielki typ, którego tożsamość jest określona przez wartości, a nie identyfikator (np. kwota, zakres dat, e-mail, numer NIP). Zamiast przechowywać string email w kilku encjach, lepiej wprowadzić Email jako własny typ i zamknąć w nim walidację i normalizację.
W praktyce różnica między prostym stringiem a value objectem jest podobna, jak między „luźnym” JSON-em a silnie typowaną klasą: trudniej wprowadzić błędne dane, bo ograniczenia są pilnowane w jednym miejscu. Korzystają na tym zarówno testy, jak i czytelność kodu.
Serwisy domenowe: kiedy logika nie pasuje do encji
Nie każda reguła powinna trafiać do encji. Jeśli logika biznesowa:
- dotyczy kilku agregatów jednocześnie,
- ma charakter policzalny/algorytmiczny, ale nie „należy” do pojedynczego obiektu,
- wymaga współpracy z portami domenowymi (np. kalkulacja wymaga odczytu kursów walut z
ICurrencyRateProvider),
– dobrym miejscem jest serwis domenowy. Powinien on nadal używać tylko interfejsów z domeny (lub prostych typów wbudowanych), bez sięgania do EF Core czy HTTP.
Eventy domenowe i model reaktywny w Domain
Eventy domenowe to narzędzie do „rozluźnienia” powiązań między fragmentami logiki. Zamiast wywoływać bezpośrednio inny serwis domenowy, korzeń agregatu publikuje zdarzenie, które później obsłużą odpowiednie handlery po stronie Application. Dzięki temu:
- model domenowy koncentruje się na własnych regułach i konsekwencjach,
- reakcje poboczne (np. wysłanie maila, zapis do loga audytowego, aktualizacja raportów) można rozbudowywać bez dotykania encji,
- łatwiej później wysłać część zdarzeń na zewnętrzną kolejkę czy event bus.
Różnica między „zwykłym” eventem domenowym a message z EventBus polega głównie na zasięgu: event domenowy żyje w granicach procesu i kontekstu, natomiast zdarzenia systemowe mają zewnętrznych subskrybentów (inne serwisy, moduły).

Warstwa aplikacji (Application): przypadki użycia, CQRS i orkiestracja logiki
Rola Application między Web a Domain
Warstwa Application bywa mylona z „drugim UI”, bo często zawiera DTO i mappingi. W rzeczywistości pełni rolę orkiestratora przypadków użycia. To tutaj:
- definiuje się komendy i zapytania (use cases),
- decyduje o transakcjach (jedna operacja – jedna transakcja),
- łączy się wywołania domeny z portami (repozytoria, serwisy zewnętrzne),
- przygotowuje dane dla Web/API w postaci prostych modeli odczytu.
W Domain nie ma pojęcia HTTP, kontrolera, autoryzacji na poziomie roli użytkownika. Application natomiast zna już kontekst żądania: wie, co użytkownik chce zrobić („złożyć zamówienie”), z jakim zestawem danych i w jakiej sekwencji kroków.
CQRS w wersji pragmatycznej
W .NET pojęcie CQRS często oznacza po prostu rozdzielenie ścieżek zapisu i odczytu w warstwie Application. Nie musi iść za tym osobna baza danych czy osobny model odczytu. Najczęściej wykorzystywany wzorzec to:
- Command – opisuje operację zmieniającą stan (np.
PlaceOrderCommand,UpdateCustomerDetailsCommand), - Query – opisuje operację odczytu (np.
GetOrderDetailsQuery,ListCustomerOrdersQuery), - Handler – implementacja logiki dla danego polecenia/zapytania.
MediatR ułatwia organizację takiego podejścia, ale sam wzorzec CQRS da się zaimplementować i bez niego. Kluczowe jest, aby:
- komendy były jasno ukierunkowane na efekt („co ma się wydarzyć”),
- zapytania nie zmieniały stanu systemu,
- po stronie Web/API kontrolery delegowały logikę do Application, zamiast robić wszystko samodzielnie.
Komendy kontra serwisy aplikacyjne
Dwa popularne style w Application to:
- Serwisy aplikacyjne – klasy typu
OrderAppServicez metodamiPlaceOrder,CancelOrder,GetOrderDetails. - Komendy + handlery – osobne klasy reprezentujące każdy use case, np.
PlaceOrderCommandHandler.
Serwisy aplikacyjne są prostsze na start, ale dużo łatwiej wyrastają w szerz: jedna klasa z dziesiątkami metod. Komendy + handlery lepiej skaluje się przy większej liczbie przypadków użycia, szczególnie w połączeniu z MediatR i pipeline behaviors. Kosztem jest większa liczba plików i typów – coś za coś.
Dobry kompromis to podejście: dla prostego CRUD – serwisy aplikacyjne, dla kluczowych i bardziej skomplikowanych operacji – komendy i handlery. W pewnym momencie, kiedy logiki przybywa, naturalnym krokiem staje się przeniesienie wszystkiego do stylu CQRS.
DTO, mapowanie i separacja modeli
Application zwykle operuje na dwóch rodzajach modeli:
- modelach domenowych (encje, value objects),
- modelach transportowych (request/response, read models).
Używanie tych samych klas w domenie i Web/API wydaje się wygodne, ale bardzo szybko prowadzi do przecieków szczegółów technicznych (np. atrybuty JSON w encjach) lub biznesowych (wystawianie wewnętrznych pól encji na API). Separacja modeli poprzez DTO bywa na początku nużąca, ale silnie ogranicza „zlepianie się” warstw.
Do mapowania można użyć AutoMappera, MapSter lub prostych, ręcznych mapowań. Różnice:
- AutoMapper – wygoda przy wielu polach, ale trudniejsze śledzenie, czasem „magia” profili.
- MapSter – bardziej wydajny i przyjazny dla codegen, ale wymaga odrobiny konfiguracji.
- Ręczne mapowania – najmniej „magiczne”, łatwe do debugowania, czasem więcej powtarzalnego kodu.
W małych projektach zwykle wystarczą ręczne mapowania. W większych – automatyczne narzędzie z wyraźnie wydzielonym miejscem na konfigurację (np. katalog Mappings w Application).
Pipeliny, cross-cutting concerns i MediatR
Jedną z największych zalet wzorca komenda + handler w połączeniu z MediatR jest możliwość wprowadzenia pipeline behaviors. Pozwalają one w jednym miejscu ogarnąć:
- walidację (np. FluentValidation),
- logowanie i metryki,
- transakcje (otwarcie/commit/rollback),
- cache’owanie wyników zapytań,
- obsługę retry dla operacji zewnętrznych.
Transakcje, Unit of Work i granice przypadków użycia
Najprostsze podejście do transakcji w Application to wzorzec „jeden use case = jedna transakcja”. Command handler:
- ładuje potrzebne agregaty z repozytoriów,
- wykonuje operacje domenowe,
- publikuje eventy domenowe,
- na końcu wywołuje
SaveChangesAsync(lubUnitOfWork.CommitAsync).
Różnica między „gołym” DbContext a osobnym IUnitOfWork jest głównie organizacyjna. W prostych projektach wystarczy wstrzykiwać kontekst EF Core do handlerów, ale przy większych rozwiązaniach interfejs IUnitOfWork:
- spina kilka repozytoriów w jedną „sesję”,
- upraszcza mockowanie w testach (bez znajomości szczegółów EF),
- pozwala później podmienić EF na inny mechanizm (teoretycznie) bez zmiany Application.
Granica transakcji powinna przebiegać w obrębie jednego przypadku użycia. Łączenie kilku command handlerów w jedną transakcję (np. przez wywołanie Send wewnątrz innego handlera) szybko robi bałagan: trudniej śledzić, co właściwie się dzieje, i dobrze obsłużyć błędy. Zwykle lepiej:
- albo połączyć logikę w jednym handlerze,
- albo rozbić ją na niezależne kroki, spięte eventami domenowymi lub sagą/procesem.
Walidacja: gdzie kończy się Application, a zaczyna Domain
Walidacja w stylu „czy format e-maila jest poprawny” i walidacja biznesowa typu „czy klient może złożyć kolejne zamówienie” mają różne miejsce w architekturze.
Techniczno-wejściową walidację (required, długość, zakres liczbowy) wygodnie załatwić w Application, np. FluentValidation podpiętym jako pipeline behavior. Przykład:
public class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>
{
public PlaceOrderCommandValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Items).NotEmpty();
RuleForEach(x => x.Items)
.ChildRules(item =>
{
item.RuleFor(i => i.ProductId).NotEmpty();
item.RuleFor(i => i.Quantity).GreaterThan(0);
});
}
}
Z kolei reguły biznesowe, które zależą od stanu domeny („czy klient nie ma przeterminowanych płatności”, „czy produkt jest dostępny w danym magazynie”), lepiej umieszczać w metodach domenowych lub serwisach domenowych. Dzięki temu testy logiki nie muszą przechodzić przez pipeline MediatR ani udawać requestów HTTP.
Typowy podział wygląda tak:
- Application – walidacja kontraktu wejściowego (komenda/zapytanie),
- Domain – niepodważalne reguły biznesowe, bez znajomości kontekstu transportu.
Obsługa eventów domenowych po stronie Application
Eventy domenowe zwykle są publikowane w Domain, ale obsługiwane w Application, która potrafi:
- sięgnąć do repozytoriów,
- wywołać zewnętrzny serwis (np. e-mail, fakturowanie),
- zapisać dodatkowy stan (historie, logi biznesowe).
Częsty wzorzec w projektach z EF Core:
- Agregat w Domain dodaje event do własnej listy (np.
List<IDomainEvent> DomainEvents). - Unit of Work (lub interceptor EF) po
SaveChangeszbiera niewyemitowane eventy. - Application (np.
IDomainEventDispatcher) mapuje eventy domenowe naINotificationz MediatR i woła odpowiednie handlery.
Zaletą takiego podejścia jest wyraźny podział odpowiedzialności: encja publikuje, ale nie wie, kto się zasubskrybuje; Application wyciąga eventy i decyduje, co z nimi zrobić (czas, miejsce, mechanizm wykonania).
Warstwa infrastruktury (Infrastructure): EF Core, integracje, szczegóły techniczne
Rola Infrastructure jako „adaptera do świata zewnętrznego”
Infrastructure w czystej architekturze bywa najgrubszą warstwą. Zawiera:
- implementacje repozytoriów oparte o EF Core lub inny ORM,
- klientów HTTP, integracje z kolejkami, systemami plików, cache,
- konfigurację dostępu do bazy (migracje, mapping encji – jeśli nie używasz pure POCO),
- implementacje portów z Domain i Application.
W przeciwieństwie do Domain i Application ta warstwa ma pełne prawo „znać” ASP.NET Core, EF Core, Serilog, Redis, gRPC i cokolwiek jeszcze jest potrzebne. Kluczowy jest jednak kierunek zależności: Domain i Application odwołują się tylko do interfejsów, które Infrastructure implementuje.
Repozytoria i EF Core: trzy popularne style
W okolicach EF Core często ścierają się trzy podejścia:
- DbContext jako repozytorium – w Application wstrzykujesz bezpośrednio
DbContexti używaszDbSet. - Generyczne repozytoria – np.
IRepository<T>z podstawowymi metodami CRUD. - Specjalizowane repozytoria domenowe – np.
IOrderRepositoryz metodami dopasowanymi do agregatu.
Kilka różnic w praktyce:
- DbContext bez repozytoriów – najmniej warstw, najszybszy start, mniejszy zysk z czystej architektury. Application zaczyna wiedzieć zbyt wiele o EF Core (trackowanie, Include, AsNoTracking).
- Generyczne repozytoria – na papierze eleganckie, w rzeczywistości szybko stają się zbyt ogólne. Kończysz z masą specyfikacji, predykatów i helperów, które i tak trzeba gdzieś „podpiąć”.
- Repozytoria domenowe – pasują do DDD:
IOrderRepository.GetByNumberAsync,FindPendingForShippingAsync. Więcej typów, ale logika zapytań jest skupiona w jednym miejscu i przystosowana do potrzeb domeny.
Jeśli projekt jest mocno domenowy, specjalizowane repozytoria dają najwięcej korzyści. W prostym CRUD-owym API z jedną bazą danych i bez skomplikowanej logiki, bezpośredni DbContext bywa całkiem sensownym kompromisem.
Mapowanie encji do bazy: „anemiczne” EF kontra bogaty model domenowy
Przy bogatym modelu domenowym naturalnie powstaje pytanie: jak zmapować encje z prywatnymi seterami, kolekcjami tylko do odczytu, value objectami itp. na relacyjne tabele. Dwa podejścia:
- Encje EF Core = encje domenowe – jedna klasa pełni obie role. Zaletą jest brak podwójnego modelu; wadą konieczność kompromisów (np. konstruktor bezparametrowy, settery
private, obostrzenia EF). - Osobny model persystencji – Domain ma swoje encje, a Infrastructure – klasy „płaskie” (czasem nazywane EF entities). Między nimi jest mapowanie (ręczne lub automatyczne). Więcej kodu, ale pełna swoboda w modelu domenowym.
Przy prostych encjach, gdzie EF Core radzi sobie bez większych wygibasów, pierwszy wariant jest bardziej praktyczny. Jeśli model domenowy ma dużo logiki, prywatnych pól i kolekcji, rozdzielenie encji pozwala uniknąć „walki z narzędziem”. Coraz częściej pojawia się też hybryda: większość encji jest wspólna, a tylko kilka bardziej wymagających ma osobne klasy persystencyjne.
Integracje zewnętrzne: anti-corruption layer i porty
Klient REST/APIs czy gRPC można napisać na wiele sposobów. Z punktu widzenia czystej architektury ważniejsze od wyboru technologii jest zachowanie przejrzystych granic. Dwa skrajne style:
- Bezpośrednie użycie HttpClient w Application – mniejszy narzut, ale Application zaczyna zawierać kod specyficzny dla konkretnego API, jego modeli i błędów.
- Port + adapter – w Domain/Application definiujesz
ICurrencyRateProvider, <code.IContainerTrackingService itd., a w Infrastructure powstaje implementacja oparta o typy DTO danego serwisu.
Drugi model dobrze współgra z anti-corruption layer: mapping warstwy zewnętrznej na wewnętrzne value objecty i modele zdarzeń. Dzięki temu zmiana z jednego dostawcy API na innego oznacza głównie prace w Infrastructure, a logika w Application i Domain przeważnie pozostaje nietknięta.
Konfiguracja EF Core, migracje i środowiska
W Infrastructure zazwyczaj ląduje:
- klasa
AppDbContextz konfiguracją schematu, - konfiguracje encji (np. przez
IEntityTypeConfiguration<T>), - migracje wygenerowane przez
dotnet ef, - seedy danych startowych, jeśli to konieczne.
Można spotkać dwa warianty zarządzania migracjami:
- Migracje w tym samym projekcie co DbContext – prościej, EF znajduje kontekst bez dodatkowej konfiguracji.
- Osobny projekt migracyjny – przy większym monolicie pozwala lepiej kontrolować referencje i wersjonowanie bazy, kosztem trudniejszej konfiguracji.
Niezależnie od wyboru dobrze, aby to Web/API odpalało migracje przy starcie (jeśli organizacja na to pozwala) lub aby istniał spójny proces CI/CD, który uruchamia migracje z poziomu Infrastructure. Lokalne „ręczne” migracje na każdym środowisku prowadzą do chaosu i niespójności.
Warstwa prezentacji (Web/API): ASP.NET Core jako interfejs do świata zewnętrznego
Web jako cienka warstwa nad Application
Rolą Web/API jest:
- przyjąć żądanie (HTTP, gRPC, SignalR),
- przetłumaczyć je na komendę lub zapytanie Application,
- obsłużyć szczegóły protokołu (statusy HTTP, nagłówki, cookies, CORS),
- zwrócić odpowiedź w formacie oczekiwanym przez klienta (JSON, gRPC, HTML).
Różnica między „grubym” a „cienkim” Web jest widoczna już po kilku miesiącach rozwoju. W pierwszym przypadku kontrolery rosną do setek linii, mieszając walidację, logikę biznesową i mapowania; w drugim – sprowadzają się do kilku wywołań MediatR lub serwisu aplikacyjnego plus mapowanie request/response.
Minimal APIs, MVC czy gRPC – a czysta architektura
ASP.NET Core daje kilka opcji budowy interfejsu:
- MVC (kontrolery + atrybuty routingu) – dobre dla rozbudowanych API, z filtrami, autoryzacją atrybutową, wersjonowaniem.
- Minimal APIs – prostsza, funkcyjna składnia; świetna dla mikroserwisów i prostych endpointów.
- gRPC – gdy oba końce kontroluje ten sam zespół i zależy na kontrakcie binarnym.
Z perspektywy czystej architektury różnice są głównie stylistyczne. W każdym wariancie Web:
- tworzy komendę/zapytanie (DTO wejściowe specyficzne dla API),
- woła Application (MediatR lub serwis aplikacyjny),
- tłumaczy wynik na DTO wyjściowe i status HTTP/gRPC.
Minimal APIs zachęcają do szybkiego dorzucania logiki „tu i teraz” wewnątrz lambdy – to wygodne, ale łatwo przekroczyć cienką granicę i stworzyć „soup” endpointów z logiką pomieszaną z infrastrukturą. MVC, przez sam fakt rozdzielenia kontrolerów i metod, zazwyczaj naturalniej wymusza chudsze akcje.
Modele request/response a DTO z Application
W mniejszych projektach kuszące jest używanie tych samych typów DTO w Web i Application. Działa to przez pewien czas, dopóki:
- API nie zacznie ewoluować niezależnie od logiki wewnętrznej,
- nie pojawi się potrzeba ukrycia części pól lub zmiany nazwy właściwości tylko dla API,
- nie wejdą drugie UI (np. aplikacja mobilna), które oczekują innych kontraktów.
Rozdzielenie:
PlaceOrderHttpRequest/PlaceOrderHttpResponsew Web,PlaceOrderCommand/OrderDtow Application
dodaje trochę mapowania, ale daje swobodę w modyfikowaniu endpointów bez naruszania struktur używanych wewnątrz. Gdy pojawi się drugi interfejs (np. komunikacja asynchroniczna z innym systemem), często wystarczy tylko dołożyć nowy adapter, a Application pozostaje w spokoju.
Błędy, wyjątki i polityka zwracania statusów HTTP
W Web warto mieć spójny mechanizm:
- mapowania wyjątków domenowych na odpowiednie statusy HTTP (404, 409, 422),
- obsługi walidacji (400 z listą błędów),
- logowania błędów technicznych (500),
Najczęściej zadawane pytania (FAQ)
Czym czysta architektura w .NET różni się od klasycznego trójwarstwowego podejścia?
W trójwarstwowym podejściu (UI–BLL–DAL) logika biznesowa zazwyczaj ląduje w „warstwie serwisów”, która szybko staje się miejscem na wszystko: reguły biznesowe, logowanie, mapowanie, szczegóły bazodanowe. Zależności zwykle są poziome: UI zna BLL, BLL zna DAL, a zmiany w infrastrukturze łatwo „przesiąkają” do środka.
W czystej architekturze kluczowy jest kierunek zależności do środka: domena nie zależy od niczego, a warstwy zewnętrzne (aplikacja, infrastruktura, UI) zależą od domeny. Encje i reguły biznesowe nie wiedzą o ASP.NET Core ani EF Core. Dzięki temu logikę biznesową da się testować i rozwijać w dużej mierze niezależnie od technologii i szkieletu aplikacji.
Kiedy warto stosować clean architecture w projektach ASP.NET Core, a kiedy to przesada?
Najbardziej zyskują projekty, które mają żyć długo, rozwijać się przez lata, posiadają nietrywialną domenę (dużo reguł, dużo przypadków użycia) i są tworzone przez kilka lub kilkanaście osób równolegle. W takim środowisku czysta architektura pomaga ograniczać chaos, separuje odpowiedzialności i zmniejsza koszt zmian, gdy dochodzą nowe interfejsy (np. dodatkowe API, kolejki, inne UI).
Dla małego narzędzia administracyjnego, prostego raportowania czy jednorazowego serwisu pomocniczego pełne clean architecture z rozbiciem na wiele projektów może być przerostem formy. W takich sytuacjach często wystarczy prostsza struktura z wydzieloną domeną i resztą kodu, bez rozbudowanego „pierścienia” infrastruktury.
Jak powinna wyglądać podstawowa struktura solution pod czystą architekturę w .NET?
Najczęściej spotykany układ to cztery projekty: Domain, Application, Infrastructure i Web/API. Domain zawiera encje, value objects i reguły biznesowe, Application – przypadki użycia, porty (interfejsy) i logikę aplikacyjną, Infrastructure – implementacje portów (np. repozytoria EF Core, klienty HTTP, integracje), a Web/API – kontrolery i konfigurację hosta ASP.NET Core.
Kluczowe są referencje między projektami: Domain nie referuje niczego, Application zna tylko Domain, Infrastructure zna Domain/Application, a Web/API zna wszystko. Taki kierunek zależności wymusza „chowanie” technologii (baza, kolejki, HTTP) za interfejsami zdefiniowanymi bliżej domeny, zamiast wpychania frameworków do środka.
Czy czysta architektura oznacza rezygnację z Entity Framework Core w ASP.NET Core?
Nie. Chodzi raczej o przesunięcie EF Core na obrzeża systemu. W czystej architekturze EF Core zwykle pojawia się w warstwie Infrastructure jako implementacja interfejsów repozytoriów lub portów zdefiniowanych w Application/Domain. DbContext nie powinien być wstrzykiwany bezpośrednio do encji domenowych ani do przypadków użycia.
Taki podział pozwala w razie potrzeby wymienić sposób persystencji (np. z SQL na NoSQL) lub przynajmniej ograniczyć wpływ zmian w modelu bazy na logikę domenową. Dodatkowo przypadki użycia można testować z użyciem fałszywych repozytoriów, bez uruchamiania prawdziwej bazy czy całego hosta ASP.NET Core.
Jak clean architecture wpływa na testowanie aplikacji ASP.NET Core?
Przy dobrze ułożonej strukturze większość reguł biznesowych i przypadków użycia znajduje się w Domain i Application. To zwykłe klasy .NET, które można testować jednostkowo, wstrzykując fałszywe repozytoria lub serwisy. Testy są lekkie, szybkie i nie wymagają uruchamiania serwera HTTP ani prawdziwej bazy danych.
Testy integracyjne nadal mają sens – sprawdzają konfigurację DI, routing, filtry, integrację z bazą – ale nie muszą obejmować każdego drobnego wariantu logiki biznesowej. W praktyce mniej logiki w kontrolerach i w infrastrukturze oznacza prostsze, bardziej przewidywalne scenariusze testowe.
Czym różni się czysta architektura od anemicznego modelu domeny w .NET?
W anemicznym modelu encje są głównie zbiorem właściwości, bez istotnej logiki. Reguły biznesowe lądują w serwisach, często w wielu różnych miejscach. To podejście jest bardzo proste w zrozumieniu i pasuje do małych CRUD-ów, ale słabo chroni invariants – obiekty można zmieniać niemal dowolnie, bo niewiele sprawdza się „w środku” modelu.
Czysta architektura stara się umieszczać reguły jak najbliżej domeny: encje i agregaty pilnują swojego stanu, a aplikacja orkiestruje przypadki użycia. Dzięki temu łatwiej utrzymać spójność reguł i trudniej „obejść” logikę, modyfikując obiekty w nieprzewidziany sposób. W zamian dostaje się większą złożoność początkową, ale lepszą kontrolę przy rozbudowanych systemach.
Czy muszę sztywno trzymać się nazw Domain/Application/Infrastructure, żeby mieć clean architecture?
Nie. Nazwy katalogów i projektów są drugorzędne wobec zasad zależności. Można mieć idealne „Domain/Application/Infrastructure” w solution, a jednocześnie łamać podstawowe reguły, np. wstrzykiwać DbContext w głąb domeny albo umieszczać logikę biznesową w kontrolerach.
Istotne jest to, by domena nie zależała od ASP.NET Core, EF Core ani innych frameworków, logika biznesowa była oddzielona od szczegółów technicznych, a infrastruktura implementowała kontrakty zdefiniowane bliżej środka. Struktura katalogów ma to ułatwiać zespołowi – jeśli jakiś inny, czytelny podział lepiej te zasady wspiera, jest jak najbardziej dopuszczalny.






