Architektura cebulowa w .NET - jak poukładać warstwy?

Radosław Krajewski 13 sierpnia 2026
Ogromna hala wypełniona cebulami, przypominająca warstwy cebulowej architektury. W tle widać sprzęt i skrzynie.

Spis treści

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.cs

Referencje projektów powinny odzwierciedlać kierunek zależności:

  • Shop.Domain nie zależy od pozostałych projektów.
  • Shop.Application zależy od Shop.Domain.
  • Shop.Infrastructure zależy od Shop.Application i Shop.Domain.
  • Shop.WebApi korzysta z Shop.Application i 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.

  1. Spisz reguły biznesowe i przenieś je do encji, value objects lub serwisów domenowych.
  2. Zdefiniuj przypadek użycia w Application, bez odwołań do DbContext i klas infrastruktury.
  3. Utwórz interfejsy dla bazy, płatności, wiadomości lub innych zewnętrznych usług.
  4. Zaimplementuj adaptery w Infrastructure.
  5. Podłącz dependency injection w projekcie startowym.
  6. 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 DbContext w 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.

FAQ - Najczęstsze pytania

Typowy podział obejmuje Domain, Application, Infrastructure i WebApi. Domain zawiera reguły biznesowe, Application koordynuje przypadki użycia, Infrastructure obsługuje bazę danych i integracje, a WebApi odpowiada za HTTP. Zależności powinny wskazywać do środka, dlatego Domain nie może zależeć od EF Core ani ASP.NET Core.

Kontrakt, taki jak IOrderRepository, powinien znajdować się w Application, ponieważ opisuje potrzebę przypadku użycia. Implementacja oparta na Entity Framework Core trafia do Infrastructure. Kontener dependency injection łączy oba elementy na brzegu aplikacji, na przykład przez rejestrację AddScoped.

W Domain umieszcza się encje, value objects, agregaty i zdarzenia domenowe oraz reguły pilnujące ich stanu. Przykładowo encja Order może sama blokować anulowanie wysłanego zamówienia. Dzięki temu ta sama reguła działa z kontrolera, workera, kolejki i testu jednostkowego.

Pełny podział może być nieopłacalny w małym CRUD-zie z kilkoma tabelami, gdzie dodatkowe projekty i interfejsy zwiększają liczbę plików bez realnej korzyści. Warto stosować ten wzorzec przy złożonych regułach, wielu integracjach, długim cyklu życia lub kilku kanałach wejścia. Dobrym podejściem jest rozpoczęcie od jednego przypadku użycia i sprawdzenie, czy granice faktycznie ułatwiają zmiany oraz testowanie.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

asp.net core
entity framework core
wstrzykiwanie zależności
testy jednostkowe
architektura cebulowa
Autor Radosław Krajewski
Radosław Krajewski
Nazywam się Radosław Krajewski i od 6 lat zgłębiam tajniki programowania .NET, chmury Azure oraz sztucznej inteligencji. Moja przygoda z tymi technologiami zaczęła się od fascynacji tym, jak złożone problemy można rozwiązywać za pomocą kodu i innowacyjnych narzędzi. Staram się przekazywać tę wiedzę w sposób zrozumiały, dzieląc się swoimi doświadczeniami i spostrzeżeniami na kursdotnet.pl. W moich artykułach skupiam się na praktycznych aspektach, porównuję różne rozwiązania i analizuję najnowsze trendy, aby dostarczyć Wam rzetelne i aktualne informacje, które pomogą Wam rozwijać się w tej dynamicznie zmieniającej się dziedzinie.

Udostępnij artykuł

Napisz komentarz