Jak zaprojektować warstwę serwisu w aplikacji Spring Boot: dobre praktyki i typowe pułapki

0
19
Rate this post

Z artykuły dowiesz się:

Po co w ogóle osobna warstwa serwisu

Oddzielenie logiki biznesowej od HTTP i bazy danych

Warstwa serwisu w aplikacji Spring Boot ma być miejscem, gdzie żyje logika biznesowa. Nie kontroler, nie repozytorium, tylko serwis. Kontroler wie, jak rozmawiać po HTTP. Repozytorium wie, jak rozmawiać z bazą. Serwis wie, co ma się wydarzyć, gdy użytkownik wykona konkretną akcję.

Jeśli logika biznesowa zostanie w kontrolerach, każda zmiana interfejsu (REST, GraphQL, gRPC) będzie wymagała przenoszenia lub kopiowania tych samych zasad. Jeśli logika wyląduje w repozytoriach, nagle zaczynają one decydować o tym, jak działa domena, zamiast tylko udostępniać dane. Warstwa serwisu rozwiązuje ten konflikt.

Dzięki temu:

  • kontrolery są cienkie i łatwe do wymiany lub rozszerzenia,
  • repozytoria są proste i ograniczają się do CRUD oraz zapytań,
  • logika biznesowa jest skumulowana w jednym miejscu i można ją testować bez HTTP i bazy.

Elastyczna zmiana interfejsu bez naruszania zasad biznesowych

Warstwa serwisu działa jak stabilny kontrakt wewnątrz aplikacji. Interfejsy zewnętrzne zmieniają się szybciej niż reguły biznesowe. Dzisiaj REST, jutro dodatkowo kolejka, pojutrze batch importów – a biznesowy „use case” aktywacji użytkownika pozostaje ten sam.

Jeśli serwis definiuje metody w stylu activateUser(userId, token), to z poziomu różnych wejść (REST, scheduler, integracja z innym systemem) można po prostu użyć tej samej funkcji. Znika pokusa dublowania logiki w różnych kontrolerach lub komponentach.

Serwis ma także tę zaletę, że jest niezależny od detali protokołu. W środku serwisu nie operujesz na HttpServletRequest, kodach statusu ani nagłówkach. To znacząco redukuje szum w logice biznesowej i upraszcza jej testowanie.

Miejsce na reguły biznesowe, transakcje i bezpieczeństwo

Warstwa serwisu jest naturalnym miejscem na:

  • walidację reguł domenowych (np. „użytkownik nie może mieć dwóch aktywnych koszyków”),
  • zarządzanie transakcjami (kilka repozytoriów w jednym scenariuszu),
  • reguły bezpieczeństwa na poziomie biznesowym (np. „tylko właściciel zamówienia może je anulować”).

Adnotacja @Transactional rzadko powinna pojawiać się w kontrolerach. Kontroler nie ma pełnej wiedzy o granicach logiki biznesowej. Serwis ją ma, bo zamyka w sobie cały przypadek użycia. To serwis decyduje, co musi wykonać się w jednym spójnym kroku.

Podobnie z bezpieczeństwem: autoryzacja techniczna może dziać się w warstwie HTTP (filtry, Spring Security), ale reguły typu „manager może zatwierdzić fakturę tylko powyżej konkretnej kwoty” mają sens tylko w serwisie.

Kontrolery + repozytoria vs kontrolery + serwisy + repozytoria

Model „tylko kontrolery + repozytoria” bywa kuszący przy bardzo małych projektach. Kończy się zwykle:

  • kontrolerami po kilkaset linii,
  • duplikacją warunków if i walidacji w wielu endpointach,
  • brakiem spójnych transakcji między kilkoma repozytoriami.

Trójwarstwowy układ (controller → service → repository) rozbija te odpowiedzialności. Koszt na początku to kilka dodatkowych plików i interfejsów. Zysk po kilku miesiącach rozwoju – ogromny. Łatwiej refaktoryzować, łatwiej testować, łatwiej wdrażać nowych ludzi do projektu.

W praktyce widać to dobrze przy rozwoju API w czasie. Gdy dochodzą nowe wymagania, serwis często trzeba tylko rozbudować o kolejne metody lub wewnętrzne prywatne funkcje. Kontrolery pozostają stosunkowo cienkie i służą wyłącznie jako „adapter” pomiędzy światem HTTP, a logiką skumulowaną w usługach.

Podstawowy schemat warstw w aplikacji Spring Boot

Typowy przepływ: controller → service → repository → database

Standardowa architektura aplikacji Spring Boot zakłada następujący łańcuch:

  1. Żądanie HTTP trafia do kontrolera.
  2. Kontroler mapuje dane wejściowe na obiekt wejściowy dla serwisu.
  3. Serwis wykonuje logikę biznesową, wywołując repozytoria i inne serwisy.
  4. Repozytoria komunikują się z bazą danych (lub innym systemem składowania).
  5. Serwis zwraca wynik do kontrolera, ten mapuje go na odpowiedź HTTP.

Każda warstwa ma inny typ odpowiedzialności. Poziom HTTP nie powinien wiedzieć, jak wygląda model encji JPA. Repozytorium nie powinno wiedzieć, że jego wynik trafi na front-end jako JSON. Serwis wiąże te światy, zachowując izolację.

Serwis vs kontroler vs repozytorium – czym się różnią

Kontroler:

  • przekształca HTTP na typy Javy i z powrotem,
  • ustawia kody statusów, nagłówki, format odpowiedzi,
  • nie powinien zawierać reguł biznesowych poza drobnymi warunkami technicznymi.

Serwis:

  • realizuje konkretny przypadek użycia (use case),
  • podejmuje decyzje biznesowe, wywołuje repozytoria i inne serwisy,
  • zarządza transakcją dla danego przypadku (często przez @Transactional).

Repozytorium:

  • odpowiada za trwałość danych,
  • udostępnia metody typu findBy..., save, delete,
  • nie zna reguł biznesowych wykraczających poza pobieranie i zapisywanie.

Taki rozkład obowiązków obniża sprzężenie między warstwami. Łatwiej wymienić bazę danych (zmienia się repozytorium), łatwiej przejść z REST na GraphQL (zmienia się kontroler), bez naruszania serwisów.

Kiedy dopuszczalne są skróty i kiedy zaczynają boleć

Dla małej aplikacji wewnętrznej z kilkoma endpointami można czasem pominąć pełny rozbudowany podział. Na przykład prosty CRUD, gdzie kontroler deleguje niemal wprost do repozytorium, bywa akceptowalny. Koszt zbudowania dodatkowych warstw jest wówczas większy niż zysk.

Problem pojawia się szybko, gdy:

  • zaczynają się pojawiać niestandardowe reguły biznesowe,
  • ten sam model występuje w kilku kontekstach (np. panel admina, API publiczne, integracje),
  • pojawiają się pierwsze złożone transakcje łączące kilka repozytoriów.

Od tej chwili brak osobnej warstwy serwisu zwykle prowadzi do rozlewania się logiki po kontrolerach, komponentach pomocniczych i repozytoriach. Refaktoryzacja bywa wtedy trudniejsza niż stopniowe wprowadzanie serwisów od samego początku.

Przykładowy przepływ żądania: tworzenie zamówienia

Załóżmy, że użytkownik tworzy zamówienie w sklepie internetowym. Typowy przepływ:

  1. OrderController przyjmuje POST /orders z JSON-em.
  2. JSON mapowany jest na CreateOrderRequest (DTO wejściowe).
  3. Kontroler wywołuje orderService.createOrder(request).
  4. Serwis:
    • waliduje koszyk i klienta,
    • pobiera produkty z ProductRepository,
    • liczy ceny, rabaty, sprawdza stany magazynowe,
    • tworzy encję Order, zapisuje przez OrderRepository.
  5. Serwis zwraca OrderSummaryDTO, kontroler wystawia go jako JSON i ustawia status 201.

W tym schemacie każda warstwa robi tylko swoją część. Gdy jutro pojawi się potrzeba wyzwalania zamówienia z kolejki, wystarczy z innego miejsca zawołać tę samą metodę serwisu.

Kobieta pisze Use APIs na białej tablicy podczas planowania architektury
Źródło: Pexels | Autor: ThisIsEngineering

Jak dobrać zakres odpowiedzialności pojedynczego serwisu

Serwis jako moduł logiki dla jednego obszaru domeny

Najprostszy i wciąż skuteczny podział serwisów w Spring Boot to powiązanie ich z głównymi obszarami domeny. Zwykle kończy się to klasami typu UserService, OrderService, InvoiceService, CatalogService.

Ważne, aby nazwy serwisów odnosiły się do pojęć z domeny, a nie do czynności technicznych. Lepszy jest PaymentService niż PaymentManager czy PaymentHelper. Nazwa powinna jasno komunikować, za jaki fragment biznesu odpowiada dana klasa.

Jeśli domena jest większa, często zachodzi potrzeba dalszego rozdrobnienia: np. CustomerAccountService, CustomerNotificationService, zamiast jednego ogromnego CustomerService. Kluczem jest czytelny kontekst – co ten serwis wie i czego ma prawo dotykać.

Jedna odpowiedzialność zamiast „Bóg–serwisów”

„Bóg–serwis” to klasa, która:

  • ma kilkaset lub więcej linii kodu,
  • zawiera kilkadziesiąt metod o zupełnie różnych celach,
  • wstrzykuje 8–10 innych zależności.

Efekt: żadna zmiana nie jest bezpieczna, bo trudno ogarnąć całość. Testy stają się skomplikowane, nowe funkcje dokładane są „gdzieś na końcu” zamiast w logicznym miejscu. Taki serwis jest symptomem słabego podziału domeny.

Zdrowy sygnał to sytuacja, gdy dla nowej funkcji naturalnie przychodzi na myśl istniejący serwis i jego wyraźny obszar odpowiedzialności. Jeśli przy każdym nowym wymaganiu zastanawiasz się, do którego „worka” to wrzucić, to znak, że fragmenty domeny trzeba rozdzielić.

Jak rozpoznać, że serwis robi za dużo

Kilka praktycznych sygnałów:

Jeśli interesują Cię konkrety i przykłady, rzuć okiem na: State management na froncie – porównanie Redux, MobX i innych.

  • plik serwisu trudno objąć wzrokiem (ciągłe przewijanie),
  • duża liczba metod typu getX, setY, processZ bez wspólnego motywu,
  • serwis używany jest przez niemal wszystkie kontrolery w module,
  • ręce opadają, gdy trzeba dodać test jednostkowy (zbyt dużo zależności do zamockowania).

Dobrym pragmatycznym kryterium jest liczba zależności wstrzykiwanych przez konstruktor. Jeśli serwis potrzebuje więcej niż 4–5 beanów, zwykle warto podzielić go na kilka mniejszych. Podobnie z liczbą metod publicznych – powyżej 10–15 warto przyjrzeć się, czy nie powstały w nim podręczne „mikromoduły”.

Często wystarczy wyodrębnić wewnętrzne serwisy domenowe (np. OrderPricingService, OrderValidationService), a główny serwis pełni tylko rolę orkiestratora, który spina te składniki w jeden use case.

Serwisy aplikacyjne, domenowe i techniczne

Przy większych systemach przydaje się podział serwisów na trzy kategorie:

  • serwisy aplikacyjne – obsługują przypadki użycia z punktu widzenia systemu (np. PlaceOrderService),
  • serwisy domenowe – zawierają reguły związane z jednym konceptem domenowym (np. OrderPricingService),
  • serwisy techniczne – opakowują zewnętrzne systemy lub funkcje techniczne (np. EmailSender, FileStorageService).

Serwis aplikacyjny często korzysta z kilku domenowych i technicznych. Dzięki temu zawiera stosunkowo mało szczegółów, a dużo orkiestracji. Serwisy domenowe są dobre do testowania reguł biznesowych w izolacji – bez baz, HTTP i zewnętrznych integracji.

Serwisy techniczne dobrze jest trzymać jak najbliżej adapterów do świata zewnętrznego. Gdy trzeba zmienić provider e-maili, dotykasz tylko EmailSender i ewentualnie konfiguracji, a nie każdej klasy, która kiedykolwiek wysyłała wiadomość.

Interfejs serwisu: sygnatury metod i typy zwracane

Metody odzwierciedlające przypadki użycia

Serwis powinien mówić językiem domeny, nie bazy danych czy HTTP. Zamiast metod:

  • update(User user),
  • setActiveFlag(Long id, boolean active),

lepiej nazwać je:

  • activateUser(Long userId),
  • deactivateUser(Long userId),
  • changeUserEmail(Long userId, String newEmail).

Parametry wejściowe: prymitywy, encje czy DTO?

Na wejściu do serwisu dobrze ograniczać się do danych, które są potrzebne do wykonania przypadku użycia. Wiele problemów bierze się z przyjmowania encji JPA prosto z kontrolera.

Kilka praktycznych podejść:

  • dla prostych operacji – prymitywy lub proste obiekty wartości (np. Email, Money),
  • dla złożonych komend – dedykowane obiekty komend/żądań (np. RegisterUserCommand, CreateOrderCommand),
  • dla operacji stricte domenowych wywoływanych z innego serwisu – encje lub agregaty domenowe.

Podawanie do serwisu encji z kontrolera często zaciera granicę między warstwami. Kontroler nie powinien decydować, którą encję utworzyć ani jak ją wypełnić – to rola serwisu lub obiektu domenowego.

Co serwis powinien zwracać

Na wyjściu są trzy typowe warianty:

  • identyfikator nowo utworzonego zasobu (np. Long userId),
  • obiekt DTO z podsumowaniem operacji,
  • brak wyniku (void) dla czystych komend typu „zrób coś”, gdy odpowiedź nie jest potrzebna.

Zwracanie encji JPA z serwisu wprost do kontrolera wiąże API z modelem bazy. Każda zmiana modelu (np. lazy/ eager, dodatkowe relacje) może przypadkowo wypłynąć na API. Bezpieczniej jest mapować wynik na DTO, nawet jeśli na początku jest to niemal kopia encji.

Jeżeli operacja jest komendą (zmienia stan) – serwis zwykle nie musi zwracać pełnego obiektu, wystarczy identyfikator i ewentualnie proste podsumowanie. Dla zapytań (czytaj-only) można rozważyć osobne serwisy odczytowe zwracające DTO zoptymalizowane pod widok.

Unikanie typów transportowych Springa w interfejsie serwisu

Typy specyficzne dla Springa (np. ResponseEntity, Pageable, HttpServletRequest) powinny zostać w warstwie web. Serwis nie powinien ich znać.

Jeśli serwis ma obsługiwać paginację, lepiej przekazać prostą strukturę typu:

record PageRequest(int page, int size) {}

i zwracać własny typ:

record PageResult<T>(List<T> content, long totalElements) {}

Dzięki temu logika nie zależy od konkretnego frameworka. Łatwiej przetestować serwis bez uruchamiania kontekstu web, łatwiej też wymienić Spring MVC na inny adapter.

Sygnatury wyraźnie rozróżniające komendy od zapytań

Komendy (zmieniają stan) dobrze oznaczać czasownikami w trybie rozkazującym: placeOrder, cancelOrder, changePassword. Często zwracają void lub prosty wynik.

Zapytania (tylko odczyt) zwykle zaczynają się od get lub find: getOrderDetails, findActiveUsers. Powinny być wolne od efektów ubocznych.

Rozdzielenie tych dwóch rodzajów operacji pozwala na późniejsze rozbudowanie architektury (CQRS, cache, osobne źródła danych dla odczytu) bez większych zmian w kodzie serwisów.

Inżynier przy biurku projektuje zaporę na komputerze w biurze
Źródło: Pexels | Autor: ThisIsEngineering

Transakcje i spójność danych w warstwie serwisu

Gdzie umieszczać adnotację @Transactional

Najbezpieczniej jest traktować serwis aplikacyjny jako granicę transakcji. Typowa konfiguracja:

  • metody serwisu aplikacyjnego z @Transactional,
  • serwisy domenowe bez transakcji (logika w pamięci),
  • repozytoria wywoływane wewnątrz istniejącej transakcji.

Umieszczanie @Transactional na poziomie repozytoriów bywa mylące. Pojedyncza operacja CRUD prawie nigdy nie reprezentuje pełnego przypadku użycia, a transakcja powinna obejmować cały proces, nie tylko zapis jednego rekordu.

Propagacja transakcji i pułapka wywołań wewnątrz klasy

Domyślnie Spring owija beany serwisów w proxy. Gdy metoda A z tej samej klasy wywołuje metodę B oznaczoną @Transactional, adnotacja na B nie zadziała – wywołanie omija proxy.

Jeśli potrzebne są różne ustawienia propagacji (np. REQUIRES_NEW) dla części operacji, lepiej wydzielić je do osobnego serwisu i wstrzyknąć jako zależność. Wtedy wywołanie przejdzie przez proxy i adnotacje będą respektowane.

Do kompletu polecam jeszcze: Refaktoryzacja na poziomie modułów – od wzajemnych zależności do plug-inów — znajdziesz tam dodatkowe wskazówki.

Dobrą praktyką jest także jawne oznaczanie operacji odczytowych jako @Transactional(readOnly = true). Oprócz komunikatu dla programisty daje to czasem optymalizacje po stronie JPA.

Granulacja transakcji a wydajność

Zbyt duża transakcja (obejmująca długi, złożony proces) blokuje zasoby bazy danych i utrudnia skalowanie. Zbyt mała (rozbita na kilka niezależnych transakcji) zwiększa ryzyko niespójności.

Przy projektowaniu serwisu warto zadać kilka pytań:

  • czy wszystkie kroki muszą być atomowe z punktu widzenia biznesu?
  • czy część operacji może być asynchroniczna (np. wysyłka e-maila, logowanie zdarzeń)?
  • czy któryś krok może zostać wykonany przez inny proces (np. worker z kolejki)?

Tam, gdzie spójność jest krytyczna (np. obciążanie konta, rezerwacja miejsc), trzymaj cały proces w jednej transakcji. Operacje drugorzędne odłóż do asynchronicznych zadań lub zdarzeń domenowych.

Idempotencja i ponawianie operacji

Serwis powinien być odporny na ponowne wywołania, szczególnie gdy komunikacja przebiega przez sieć (REST, kolejki). Dobrze zaprojektowana metoda:

  • rozpoznaje, że operacja została już wykonana (np. po unikalnym identyfikatorze polecenia),
  • w bezpieczny sposób zwraca ten sam wynik lub neutralną odpowiedź.

Przykład: confirmPayment(paymentId, requestId). Jeśli ten sam requestId trafi dwa razy, serwis powinien odnotować duplikat i nie naliczać opłaty ponownie.

Relacja serwisów z repozytoriami i innymi serwisami

Repozytoria jako szczegół implementacyjny serwisu

Serwis powinien traktować repozytorium jak zależność techniczną. Interfejs repozytorium nie powinien wyciekać do warstwy wyżej (kontrolera).

Dobrze jest ograniczać liczbę używanych repozytoriów w pojedynczej metodzie serwisu. Jeśli metoda potrzebuje 3–4 repozytoria jednocześnie, sygnalizuje to zwykle zbyt szeroką odpowiedzialność lub źle dobrane granice agregatów.

Wywoływanie innych serwisów: orkiestracja vs logika

Serwis może korzystać z innych serwisów, ale struktura takich wywołań powinna być świadoma. Dobrze sprawdza się układ:

  • serwis aplikacyjny orkiestruje: woła kilka serwisów domenowych/technicznych,
  • serwisy domenowe rzadko wołają inne serwisy – raczej pracują na encjach i wartościach,
  • serwisy techniczne koncentrują się na jednym typie integracji (np. płatności, e-mail).

Łańcuchowe zależności typu AServiceBServiceCServiceAService szybko prowadzą do trudnych do debugowania cykli. Jeśli pojawia się ryzyko pętli, warto zastanowić się nad wydzieleniem logiki do wspólnego serwisu domenowego lub użyciem zdarzeń.

Unikanie „serwisów narzędziowych” o niejasnym celu

Nazwy typu CommonService, UtilsService, HelperService to zazwyczaj sygnał, że brakuje dobrego podziału. Taki serwis szybko staje się śmietnikiem dla „małych” funkcji.

Zamiast tego:

  • drobne funkcje techniczne pakuj w klasy narzędziowe bez komponentów Springa,
  • logikę biznesową grupuj w serwisach nazwanych po domenie,
  • wspólne fragmenty wyodrębniaj w osobne, wąsko wyspecjalizowane beany.
Inżynier przy dwóch monitorach projektuje model 3D w biurze
Źródło: Pexels | Autor: ThisIsEngineering

Przetwarzanie danych w serwisie: DTO, mapowanie, walidacja

Warstwa serwisu a granica DTO

W typowej aplikacji JSON ↔ Java granica DTO przebiega między kontrolerem a serwisem. Kontroler przyjmuje i zwraca DTO, serwis pracuje głównie na encjach i modelu domenowym.

Czasem jednak opłaca się wprowadzić osobne DTO na granicy serwisu, gdy:

  • ten sam use case wystawia kilka różnych API (REST, gRPC, wiadomości z kolejki),
  • logika serwisu ma znacząco inny kształt niż struktura publicznego API.

W takim przypadku kontroler mapuje z web-DTO na service-DTO, a dopiero serwis decyduje, jak ułożyć encje i wartości.

Rola mapperów i gdzie je trzymać

Mapowanie DTO ↔ encje szybko puchnie. Zamiast powtarzać kod, lepiej wydzielić dedykowane klasy mapujące, np. OrderMapper, UserMapper.

Praktyczny układ:

  • mapper jako zwykła klasa lub bean Springa,
  • serwis korzysta z mappera, ale nie wie, czy to MapStruct, ręczne mapowanie czy inny mechanizm,
  • mapper nie zawiera logiki biznesowej – tylko czyste przepisywanie danych i proste transformacje.

Dzięki temu przy zmianie struktury DTO, większość modyfikacji ogranicza się do mappera, a serwis pozostaje stabilny.

Walidacja w kontrolerze, w serwisie i w domenie

Walidację da się podzielić na trzy poziomy:

  • walidacja wejścia HTTP – adnotacje Bean Validation na DTO (@NotNull, @Email); ochrona przed śmieciowym requestem,
  • walidacja biznesowa – serwis sprawdza reguły typu „max 3 aktywne karty na użytkownika”,
  • niezmienniki domenowe – obiekty domenowe nie dopuszczają do powstania nieprawidłowego stanu (np. konstruktor nie pozwala na ujemną ilość).

Bean Validation w serwisie (@Validated na klasie) jest użyteczne dla prostych reguł (np. zakres liczb), ale nie zastąpi logiki biznesowej. Poważniejsze warunki powinny być opisane jawnie w kodzie serwisu lub domeny, nie tylko adnotacjami.

Idempotentne przetwarzanie danych wejściowych

W przypadku integracji z zewnętrznymi systemami warto, by serwis potrafił:

  • rozpoznać, czy dany komunikat/żądanie zostało już przetworzone,
  • uniknąć duplikatów rekordów,
  • zwrócić poprzedni wynik lub łagodne potwierdzenie.

Często wystarczy prosty mechanizm z tabelą „processed_messages” z unikalnym identyfikatorem żądania, do której serwis zagląda przed wykonaniem logiki.

Błędy, wyjątki i wzorce obsługi w warstwie serwisu

Jakie wyjątki rzucać z serwisu

Serwis powinien komunikować problemy na poziomie domeny, nie technologii. Zamiast:

  • IllegalArgumentException,
  • RuntimeException z ogólnym komunikatem,

lepsze są własne wyjątki:

  • UserNotFoundException,
  • InsufficientFundsException,
  • OrderAlreadyConfirmedException.

Kontroler lub globalny handler (@ControllerAdvice) mapuje je na odpowiednie kody HTTP. Serwis nie musi wiedzieć, czy to będzie 400, 404 czy 409 – jego zadaniem jest tylko wskazać problem.

Checked vs unchecked w logice biznesowej

W Springu przeważają wyjątki niekontrolowane (unchecked). Dobrze jest trzymać się tej konwencji także dla logiki biznesowej – checked exceptions słabo współpracują z transakcjami i potrafią niepotrzebnie brudzić sygnatury metod.

Własne wyjątki biznesowe jako podklasy RuntimeException są zazwyczaj wystarczające. Ułatwiają też globalne logowanie i mapowanie na odpowiedzi HTTP.

Dobrą inspiracją do myślenia o przejrzystej architekturze może być sposób, w jaki na blogu Programista Java rozbijane są złożone tematy na jasno wydzielone moduły. Ta sama idea skalowania dotyczy warstw w Spring Boot.

Logowanie w serwisie – ile i gdzie

Serwis nie powinien zasypywać logów każdą drobną operacją. Kilka sensownych miejsc na logi:

  • wejście/wyjście z krytycznych use case’ów (id użytkownika, id zasobu),
  • wyjątki biznesowe na poziomie ostrzeżenia (WARN),
  • niespodziewane sytuacje techniczne (ERROR).

Dane wrażliwe (hasła, tokeny, dane kart) nie powinny trafiać do logów. Jeżeli trzeba je identyfikować, loguj tylko skróty, maski lub identyfikatory techniczne.

Najczęściej zadawane pytania (FAQ)

Po co mi w ogóle warstwa serwisu w aplikacji Spring Boot?

Warstwa serwisu oddziela logikę biznesową od HTTP i bazy danych. Dzięki temu kontrolery zajmują się tylko transportem danych (żądania/odpowiedzi), a repozytoria – tylko trwałością.

Logika biznesowa umieszczona w serwisach jest w jednym miejscu, łatwiej ją testować (bez HTTP i bazy), refaktoryzować i wielokrotnie wykorzystywać. Gdy zmienisz interfejs (REST → GraphQL → kolejka), zasady biznesowe i tak zostają w serwisach, więc nie trzeba ich przepisywać.

Gdzie trzymać logikę biznesową: w kontrolerze, serwisie czy repozytorium?

Logika biznesowa powinna być w serwisach. Kontroler służy do mapowania HTTP na obiekty Javy i z powrotem. Repozytorium odpowiada za zapytania i operacje CRUD na bazie.

W kontrolerze możesz mieć drobne warunki techniczne (np. wybór formatu odpowiedzi). Repozytoria nie powinny decydować o tym, czy coś „wolno” w domenie – tylko zwracają dane. Decyzje typu „czy użytkownik może złożyć kolejne zamówienie” podejmuje serwis.

Kiedy mogę pominąć warstwę serwisu i łączyć kontroler bezpośrednio z repozytorium?

Przy bardzo prostych, krótkotrwałych projektach (np. wewnętrzny CRUD z kilkoma endpointami) kontroler → repozytorium bywa akceptowalne. Taki układ szybko jednak zaczyna uwierać, gdy pojawiają się pierwsze nietrywialne reguły biznesowe czy złożone transakcje.

Jeśli zaczynasz kopiować te same if-y i walidacje do kilku kontrolerów lub endpointów, to sygnał, że czas wydzielić serwis. Im później to zrobisz, tym bardziej bolesna będzie refaktoryzacja.

Jak poprawnie zaprojektować zakres odpowiedzialności pojedynczego serwisu?

Najprościej powiązać serwis z konkretnym obszarem domeny, np. UserService, OrderService, InvoiceService. Nazwa serwisu ma opisywać pojęcie biznesowe, a nie techniczne (PaymentService zamiast PaymentHelper).

Jeśli serwis puchnie i robi „wszystko”, rozbij go na mniejsze, ale nadal domenowe: np. CustomerAccountService i CustomerNotificationService. Każdy serwis powinien mieć spójny kontekst i jasno określony zestaw przypadków użycia.

Gdzie umieszczać @Transactional – w kontrolerze czy w serwisie?

@Transactional powinna być zwykle na metodach serwisu, nie w kontrolerze. Serwis wie, jaki jest pełny przypadek użycia i jakie repozytoria trzeba objąć jedną transakcją.

Kontroler nie ma pełnego obrazu logiki biznesowej – tylko przekazuje dane dalej. Transakcje na poziomie kontrolera prowadzą do „przeciekania” szczegółów technicznych HTTP do domeny i utrudniają testy bez warstwy webowej.

Jak w praktyce wygląda przepływ żądania w architekturze controller → service → repository?

Typowy scenariusz tworzenia zamówienia: kontroler przyjmuje POST z JSON-em, mapuje go na CreateOrderRequest, a następnie woła orderService.createOrder(request). Serwis waliduje dane, pobiera produkty, liczy ceny, sprawdza stany, tworzy encję i zapisuje ją przez repozytorium.

Serwis zwraca np. OrderSummaryDTO, a kontroler zamienia go na JSON i ustawia odpowiedni kod statusu. Gdy w przyszłości zamówienia będą tworzone także z kolejki lub batcha, wystarczy wywołać ten sam serwis z innego wejścia.

Jak uniknąć „Bóg–serwisu”, który zawiera całą logikę aplikacji?

„Bóg–serwis” powstaje, gdy do jednego serwisu doklejasz każdy nowy przypadek użycia, bez patrzenia na spójność. Objawia się setkami linii kodu, dziesiątkami metod i mieszaniem różnych kontekstów biznesowych.

Aby tego uniknąć, wprowadzaj nowe serwisy, gdy widzisz inny obszar domenowy lub inny „typ” operacji. Zamiast jednego gigantycznego CustomerService wydziel np. serwis do zarządzania kontem, inny do rozliczeń, jeszcze inny do powiadomień. Dzięki temu kod pozostaje prostszy i czytelniejszy dla nowych osób w zespole.

Najważniejsze punkty

  • Warstwa serwisu jest miejscem na logikę biznesową – kontroler obsługuje HTTP, repozytorium bazę danych, a serwis decyduje, co ma się wydarzyć w danym przypadku użycia.
  • Oddzielenie serwisu od kontrolera i repozytorium pozwala zmieniać interfejs (REST, GraphQL, kolejki, batch) bez przepisywania zasad biznesowych i bez dublowania logiki w wielu wejściach.
  • Serwis jest naturalnym miejscem na reguły domenowe, transakcje (@Transactional) i autoryzację biznesową (np. kto może anulować zamówienie), bo widzi cały scenariusz, a nie tylko pojedyncze zapytanie HTTP.
  • Model „kontroler + repozytorium, bez serwisu” szybko prowadzi do grubych kontrolerów, duplikacji warunków i problemów z transakcjami, gdy tylko pojawiają się bardziej złożone reguły.
  • Trójwarstwowy podział (controller → service → repository) rozluźnia sprzężenie: można wymienić bazę (zmiana w repozytoriach) albo protokół (zmiana w kontrolerach) bez ruszania logiki w serwisach.
  • Serwis powinien realizować konkretne use case’y (np. activateUser), orkiestrując wywołania repozytoriów i innych serwisów, podczas gdy kontroler redukuje się do roli adaptera HTTP ↔ logika biznesowa.
  • Pomijanie serwisu bywa akceptowalne tylko w bardzo prostych CRUD-ach; gdy pojawiają się niestandardowe reguły, wiele kontekstów użycia tego samego modelu lub złożone transakcje, brak warstwy serwisu szybko zaczyna przeszkadzać.

Źródła

  • Patterns of Enterprise Application Architecture. Addison-Wesley (2002) – Warstwy aplikacji, rozdział Service Layer i Transaction Script
  • Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley (2003) – Model domeny, logika biznesowa, granice warstw
  • Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Pearson (2017) – Architektura warstwowa, separacja interfejsów i logiki biznesowej
  • Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall (2008) – Zasady utrzymywalnego kodu, SRP, rozdział odpowiedzialności
  • Building Microservices: Designing Fine-Grained Systems. O’Reilly Media (2015) – Granice serwisów, kontrakty, logika biznesowa vs transport
  • Spring in Action (6th Edition). Manning Publications (2022) – Warstwa serwisu w Spring, @Service, @Transactional, wzorce użycia
  • Pro Spring 6. Apress (2023) – Zaawansowane użycie Spring, transakcje, architektura warstwowa
  • Spring Framework Reference Documentation. VMware – Oficjalne zalecenia dot. @Service, @Transactional, warstw i beanów