W wielu projektach .NET kod biznesowy szybko zaczyna zależeć od Entity Framework Core, kontrolerów, Azure czy konkretnego dostawcy płatności. Właśnie w takich sytuacjach onion architecture pomaga odwrócić kierunek zależności i umieścić reguły biznesowe w centrum aplikacji. Pokażę, jak działa ten wzorzec, jak podzielić solution w ASP.NET Core, gdzie stosować interfejsy i dependency injection oraz kiedy taka organizacja kodu może być przesadą.
Najważniejsze zasady architektury cebulowej w praktyce
- Rdzeń aplikacji zawiera reguły biznesowe i nie zna bazy danych ani frameworka webowego.
- Zależności wskazują do środka, czyli warstwy zewnętrzne mogą korzystać z wewnętrznych, ale nie odwrotnie.
- Interfejsy definiuje rdzeń, a ich implementacje trafiają do infrastruktury.
- Testy jednostkowe są prostsze, ponieważ logikę biznesową można uruchamiać bez SQL Servera i sieci.
- Podział na projekty nie wystarczy, jeśli kod nadal łamie kierunek zależności.
Na czym polega architektura cebulowa
Architektura cebulowa organizuje kod w kilku koncentrycznych warstwach. W samym środku znajduje się model domenowy, czyli pojęcia i reguły opisujące problem biznesowy. Im dalej od środka, tym więcej szczegółów technicznych: komunikacja HTTP, baza danych, kolejki, logowanie czy integracje z zewnętrznymi usługami.
Najważniejsza zasada brzmi prosto: zależności mają kierować się do centrum. Klasa domenowa może istnieć bez ASP.NET Core i Entity Framework Core, natomiast kontroler lub adapter bazy danych może korzystać z kodu domeny. Dzięki temu wymiana technologii na brzegu aplikacji nie wymusza przepisywania jej najważniejszych reguł.
Nie chodzi jednak o samo narysowanie kilku okręgów. Sednem jest Dependency Inversion Principle, czyli zasada odwrócenia zależności. Rdzeń definiuje abstrakcję, na przykład IOrderRepository, a infrastruktura dostarcza jej konkretną implementację opartą na EF Core.
Co powinno znajdować się w centrum
W domenie umieszczam encje, value objects, reguły biznesowe, agregaty i zdarzenia domenowe. Encja to obiekt mający tożsamość, a value object opisuje wartość bez własnego identyfikatora, na przykład adres e-mail albo kwotę z walutą.
Domena nie powinna wiedzieć, czy dane zostaną zapisane w PostgreSQL, SQL Serverze, Cosmos DB czy pliku. Nie powinna też znać kontrolera, formatu JSON ani szczegółów tokenu JWT. Im mniej techniki w środku, tym łatwiej utrzymać logikę przez kolejne lata.
Dlaczego kierunek zależności ma znaczenie
Wyobraźmy sobie regułę, która pozwala anulować zamówienie tylko przed wysyłką. Jeśli znajduje się ona w kontrolerze, trudno użyć jej z kolejki, zadania cyklicznego i testu jednostkowego. Gdy trafia do agregatu Order, każda ścieżka uruchomienia korzysta z tej samej, centralnej reguły.
Z mojego doświadczenia wynika, że największą różnicę widać przy zmianie infrastruktury. Sama wymiana dostawcy płatności nie powinna zmieniać kodu, który wylicza rabat albo sprawdza limit kredytowy. Jeżeli zmiana adaptera wymaga edycji domeny, granica między warstwami została wyznaczona zbyt słabo.
Jak podzielić warstwy w aplikacji .NET
W typowym projekcie ASP.NET Core stosuję cztery logiczne warstwy. Ich nazwy mogą być inne, ale odpowiedzialności powinny pozostać podobne. Najczęściej solution składa się z projektów Domain, Application, Infrastructure i WebApi.
| Warstwa | Odpowiedzialność | Czego nie powinna znać |
|---|---|---|
| Domain | Reguły biznesowe, encje, value objects i zdarzenia domenowe | EF Core, ASP.NET Core, Azure, SQL |
| Application | Przypadki użycia, komendy, zapytania i kontrakty | Szczegóły konkretnej bazy lub dostawcy zewnętrznego |
| Infrastructure | Persistencja, integracje, e-mail, kolejki i implementacje interfejsów | Decyzje prezentacyjne i reguły domenowe |
| Presentation | HTTP, autoryzacja, mapowanie wejścia i odpowiedzi API | Bezpośrednie szczegóły bazy danych |
Domain jako niezależny rdzeń
Projekt domenowy powinien mieć możliwie niewiele zależności. Przykładowa encja może pilnować własnego stanu bez odwoływania się do frameworka:
public class Order
{
private readonly List _items = new();
public OrderStatus Status { get; private set; }
public void Cancel()
{
if (Status == OrderStatus.Shipped)
throw new BusinessRuleException("Wysłanego zamówienia nie można anulować.");
Status = OrderStatus.Cancelled;
}
} Ważne jest nie samo użycie prywatnego pola, lecz fakt, że reguła anulowania należy do zamówienia. Kontroler nie powinien samodzielnie sprawdzać statusu i zmieniać go przez publiczny setter.
Application jako orkiestrator przypadków użycia
Warstwa aplikacyjna odpowiada za scenariusze takie jak złożenie zamówienia, anulowanie subskrypcji czy wygenerowanie raportu. Koordynuje kilka elementów, ale nie powinna przejmować reguł należących do domeny.
To tutaj mogą znaleźć się handlery komend, modele wejściowe, walidacja i interfejsy portów. Port jest kontraktem opisującym potrzebę aplikacji, na przykład zapis zamówienia lub wysłanie wiadomości. Application mówi, czego potrzebuje, a nie jak dana operacja zostanie technicznie wykonana.
Infrastructure jako wymienne adaptery
W infrastrukturze umieszczam DbContext, konfiguracje encji, repozytoria, klienta Azure Blob Storage, integrację z dostawcą płatności i publikowanie komunikatów. Ten projekt może zależeć od Application i Domain, ponieważ znajduje się na zewnętrznej warstwie.
To dobry moment, aby zachować rozsądek. Nie każda prosta aplikacja potrzebuje osobnego repozytorium nad DbSet. Jeżeli abstrakcja niczego nie ukrywa i tylko powtarza metody Entity Framework Core, może stać się ceremonialnym kodem, który zwiększa liczbę plików bez realnej korzyści.
Presentation jako cienka warstwa wejściowa
Kontroler powinien przyjąć żądanie, przekazać dane do przypadku użycia i zwrócić odpowiedź HTTP. Nie powinien zawierać obliczeń biznesowych, zapytań SQL ani decyzji dotyczących transakcji.
W większych systemach rolę prezentacji może pełnić nie tylko Web API. Tak samo traktuję konsolowy worker, funkcję Azure Functions i konsumenta komunikatów. Wszystkie są wejściami do tej samej aplikacji, więc mogą korzystać z Application bez kopiowania logiki.
Jak wygląda przykładowe solution
Dla systemu obsługi zamówień sensowny układ może wyglądać tak:
src/
Shop.Domain/
Orders/
Customers/
Common/
Shop.Application/
Orders/
Commands/
Queries/
IOrderRepository.cs
Common/
Behaviors/
Shop.Infrastructure/
Persistence/
ShopDbContext.cs
Configurations/
Payments/
Messaging/
Shop.WebApi/
Controllers/
DependencyInjection.csReferencje projektów powinny odzwierciedlać kierunek zależności:
-
Shop.Domainnie zależy od pozostałych projektów. -
Shop.Applicationzależy odShop.Domain. -
Shop.Infrastructurezależy odShop.ApplicationiShop.Domain. -
Shop.WebApikorzysta zShop.Applicationi zwykle rejestruje infrastrukturę.
Przykładowy kontrakt repozytorium może należeć do Application:
public interface IOrderRepository
{
Task GetByIdAsync(OrderId id, CancellationToken cancellationToken);
Task AddAsync(Order order, CancellationToken cancellationToken);
} Implementacja trafia do Infrastructure:
internal sealed class EfOrderRepository : IOrderRepository
{
private readonly ShopDbContext _db;
public EfOrderRepository(ShopDbContext db) => _db = db;
public Task GetByIdAsync(
OrderId id,
CancellationToken cancellationToken) =>
_db.Orders.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
public Task AddAsync(
Order order,
CancellationToken cancellationToken) =>
_db.Orders.AddAsync(order, cancellationToken).AsTask();
} Połączenie obu elementów odbywa się przez kontener dependency injection:
services.AddScoped(); W ten sposób przypadek użycia otrzymuje interfejs, a nie klasę zależną od EF Core. Kontener składa aplikację na brzegu, podczas uruchamiania programu. Nie trzeba umieszczać konfiguracji bazy w domenie tylko dlatego, że domena korzysta z repozytorium.
Co naprawdę zyskujesz dzięki temu podejściu
Najbardziej namacalną korzyścią jest testowalność. Test reguły biznesowej może utworzyć encję i wywołać metodę bez uruchamiania serwera, migracji i połączenia z bazą. Taki test jest zwykle szybki, stabilny i czytelny.
Drugą zaletą jest kontrola zmian. Gdy aplikacja korzysta z kilku kanałów wejścia, logika nie rozlewa się po kontrolerach, handlerach i workerach. Zmiana reguły w domenie ma jedno naturalne miejsce.
Trzecia korzyść dotyczy pracy zespołowej. Jasne granice ułatwiają ustalenie, gdzie powinien trafić nowy kod. Programista implementujący integrację z Azure nie musi edytować modelu domenowego, a osoba rozwijająca proces biznesowy nie musi znać szczegółów serializacji wiadomości.
Ta architektura nie przyspiesza jednak każdego projektu. Dla małego CRUD-a z kilkoma tabelami cztery projekty, osobne kontrakty i kilkanaście klas pośrednich mogą kosztować więcej czasu, niż oszczędzą. Wzorzec ma sens wtedy, gdy koszt zmian i złożoność domeny są realne, a nie tylko dlatego, że tak wygląda popularny szablon.
Onion, Clean i klasyczne warstwy nie znaczą dokładnie tego samego
Te nazwy często pojawiają się obok siebie, ponieważ wszystkie promują ochronę logiki biznesowej przed szczegółami infrastruktury. Różnią się przede wszystkim akcentami i sposobem przedstawienia struktury, a nie podstawową intencją.
| Podejście | Najważniejszy akcent | Typowe ryzyko |
|---|---|---|
| Architektura cebulowa | Rdzeń domenowy i zależności skierowane do środka | Traktowanie warstw jako sztywnego schematu folderów |
| Clean Architecture | Reguły biznesowe niezależne od frameworków i szczegółów | Przepisanie przykładowego template’u bez zrozumienia granic |
| Hexagonal Architecture | Porty i adaptery komunikujące rdzeń ze światem zewnętrznym | Tworzenie zbyt wielu abstrakcji dla prostych operacji |
| Klasyczna architektura warstwowa | Podział na UI, logikę aplikacji i dostęp do danych | Przepływ zależności w dół aż do bazy danych |
W praktyce projekt .NET może łączyć te idee. Układ Domain, Application, Infrastructure i WebApi będzie jednocześnie przypominał wariant cebulowy, Clean Architecture i rozwiązanie portów oraz adapterów. Nazwa jest mniej istotna niż reguła zależności i łatwość wymiany zewnętrznych szczegółów.
Największa różnica względem prostego N-tier pojawia się przy bazie danych. W klasycznym układzie warstwa biznesowa często odwołuje się bezpośrednio do warstwy danych. Tutaj to Application definiuje kontrakt, a Infrastructure go implementuje, więc szczegół techniczny nie wyznacza kształtu rdzenia.
Jak wdrożyć wzorzec bez tworzenia nadmiarowej komplikacji
Nie zaczynałbym od przenoszenia każdego pliku do nowych projektów. Najpierw wybrałbym jeden konkretny przypadek użycia, na przykład anulowanie zamówienia, i przeprowadził go przez wszystkie granice. Taki mały wycinek szybko pokaże, czy podział pomaga, czy tylko dodaje warstwy.
- Spisz reguły biznesowe i przenieś je do encji, value objects lub serwisów domenowych.
- Zdefiniuj przypadek użycia w Application, bez odwołań do DbContext i klas infrastruktury.
- Utwórz interfejsy dla bazy, płatności, wiadomości lub innych zewnętrznych usług.
- Zaimplementuj adaptery w Infrastructure.
- Podłącz dependency injection w projekcie startowym.
- Dodaj testy dla reguł domenowych i osobne testy integracyjne dla infrastruktury.
Przy migracji istniejącego systemu zacząłbym od miejsc, które najczęściej się zmieniają albo generują błędy. Przeniesienie całej aplikacji jednorazowo zwykle kończy się długim okresem pracy bez widocznego efektu. Stopniowe wydzielanie przypadków użycia pozwala zachować działający produkt i mierzyć korzyści po każdym kroku.
Przeczytaj również: Pamięć podręczna in-memory w .NET - od IMemoryCache do Redis
Błędy, które psują cały zamysł
- Umieszczanie
DbContextw Domain lub Application. - Wstrzykiwanie konkretnego klienta Azure bezpośrednio do logiki biznesowej.
- Przenoszenie reguł domenowych do kontrolerów i handlerów.
- Tworzenie interfejsu dla każdej klasy, nawet gdy nie istnieje realna granica.
- Używanie encji jako modeli odpowiedzi API bez kontroli nad ujawnianymi polami.
- Udawanie niezależności przy referencji infrastruktury z projektu domenowego.
Szczególnie zdradliwe jest nazwanie projektu Domain, który nadal importuje pakiety ORM i zawiera atrybuty infrastrukturalne. Nazwa folderu nie tworzy architektury. O jej jakości decydują rzeczywiste zależności, miejsce reguł biznesowych i sposób testowania.
Kiedy ten wzorzec będzie dobrą decyzją
Wybrałbym go dla systemu, który ma złożone reguły, kilka integracji, długi cykl życia albo więcej niż jeden kanał wejścia. Dobrze pasuje do systemów zamówień, finansów, logistyki, subskrypcji i aplikacji, które mają rozwijać się przez kilka lat.
Ostrożniej podchodzę do jednorazowych narzędzi administracyjnych i prostych paneli CRUD. Jeżeli główną wartością aplikacji jest wyświetlenie kilku tabel i zapis formularza, pełna separacja może być zwyczajnie nieopłacalna. W takim przypadku prostszy układ nadal może być profesjonalny, o ile nie miesza reguł biznesowych z przypadkowym kodem w kontrolerach.
Nie traktuję też architektury cebulowej jako przepisu na mikroserwisy. Można stosować ją w modularnym monolicie, który jest jednym wdrożeniem, ale ma wyraźnie odseparowane moduły. Taki start często daje lepszy kompromis niż rozdzielanie systemu na wiele usług tylko po to, aby zastosować modną nazwę.
Najprostszy test decyzji jest praktyczny. Jeżeli potrafisz uruchomić najważniejsze testy domenowe bez bazy, zmienić adapter płatności bez edycji reguł zamówienia i dodać nowe wejście bez kopiowania logiki, granice prawdopodobnie działają. Gdy do osiągnięcia tych celów potrzebujesz dziesiątek pustych interfejsów, uprość rozwiązanie.
Rdzeń powinien chronić decyzje biznesowe
Najważniejsza lekcja tego wzorca nie dotyczy liczby projektów ani nazw katalogów. Chodzi o to, aby kod opisujący dlaczego system działa w określony sposób był ważniejszy i trwalszy niż kod opisujący, przez jakie API lub bazę danych działa.
W aplikacji .NET zacząłbym od jednej reguły, jednego przypadku użycia i jednego testu, a dopiero później rozbudowywał strukturę. Taka architektura przynosi wartość wtedy, gdy ułatwia zmianę, testowanie i rozumienie systemu. Jeśli tylko zwiększa liczbę warstw, bez wahania ograniczyłbym ją do poziomu odpowiadającego faktycznej złożoności projektu.
