Jak zaprojektować skalowalną architekturę mikroserwisów w Javie krok po kroku

0
5
Rate this post

Spis Treści:

Punkt startowy – kiedy mikroserwisy w Javie mają sens, a kiedy nie

Realne kryteria: skala zespołu, systemu i zmian

Architektura mikroserwisów w Javie brzmi efektownie, ale sens ma tylko tam, gdzie rozwiązuje konkretne problemy: skali, złożoności i tempa rozwoju. Pierwsze kryterium to skala zespołu. Jeśli nad systemem pracują dwie–trzy osoby, trudno efektywnie utrzymać kilkanaście serwisów, każdy z własnym cyklem życia, wdrożeniami, monitoringiem i bezpieczeństwem. Mikroserwisy zaczynają być naturalnym wyborem zwykle wtedy, gdy pojawia się kilka niezależnych zespołów, a harmonogram wdrożeń staje się wąskim gardłem.

Drugie kryterium to złożoność domeny i częstotliwość zmian. Gdy różne obszary biznesowe zmieniają się w różnym tempie (np. płatności, logistyka, katalog produktów, raportowanie), warto rozdzielić je na odrębne komponenty, by uniknąć sytuacji, w której zmiana w module raportowania blokuje wdrożenie nowej wersji modułu płatności. Monolit można modularnie porządkować, ale od pewnego momentu granice organizacyjne (zespoły, odpowiedzialność) i tak wypychają architekturę w stronę mikroserwisów.

Trzeci aspekt to wymagania dostępności i skalowania. Jeśli system musi wytrzymać duże, skokowe obciążenia tylko w wybranych częściach (np. koszyk i zamówienia przed Black Friday), mikroserwisy umożliwiają skalowanie wybranych komponentów niezależnie. W monolicie skalujesz całość, nawet jeśli problem dotyczy 10% funkcjonalności. Przy dużej skali to po prostu kosztuje za dużo.

Czwarte kryterium to dojrzałość organizacji. Mikroserwisy przerzucają część złożoności z kodu na infrastrukturę i operacje: monitoring, logowanie, CI/CD, Kubernetes, systemy kolejkowe. Bez dojrzałych procesów DevOps i dobrego ogarnięcia operacji zespół szybko utonie w problemach niezwiązanych z samą Javą czy logiką biznesową.

Antywzorce: mikroserwisy jako modne hasło

Największym antywzorcem jest wdrożenie architektury mikroserwisów w Javie dlatego, że „wszyscy tak robią” albo „tak będzie łatwiej z Kubernetesem”. Mikroserwisy nie upraszczają; one zamieniają typ problemów. Zamiast walki z modularnością w jednym codebase dostaje się problemy z siecią, spójnością danych, transakcjami rozproszonymi i spójnością konfiguracji.

Popularny błąd to rozbijanie monolitu bez zrozumienia domeny. Zespół bierze istniejący monolit i mechanicznie rozpina go na kilka repozytoriów: user-service, order-service, email-service, payment-service. Często powstają serwisy techniczne, które wcale nie oddzielają logiki biznesowej, a tylko generują narzut komunikacyjny. Integracja między nimi zamienia się w pajęczynę wywołań HTTP i kolejek, które trzeba utrzymywać.

Inny antywzorzec to budowanie mikroserwisów, gdy organizacja ma nadal centralny model zarządzania: jeden zespół DBA, jedna kolejka zadań, jedno repozytorium kodu, wspólny cykl wydawniczy. Taka pseudo-architektura łączy wady monolitu (zależności organizacyjne) z wadami mikroserwisów (złożona komunikacja i obsługa błędów), a nie daje żadnej z ich realnych korzyści.

Monolit modularny vs źle zaprojektowane mikroserwisy

Monolit ma złą prasę, ale dobrze zaprojektowany monolit modularny często jest lepszy niż toporne mikroserwisy. W modularnym monolicie w Javie (np. Spring Boot + podział na moduły Maven/Gradle) można wydzielić wyraźne moduły domenowe, osobne pakiety, warstwy i kontrakty. Logika pozostaje w jednym procesie, co upraszcza transakcje, debugowanie, testy integracyjne i wdrażanie.

Źle zaprojektowane mikroserwisy to z kolei „rozproszony monolit”: mnóstwo serwisów, każdy zna każdego, wywołują się kaskadowo, wymagają wspólnego wdrażania, a przy błędzie jednego pada całość. Pojawiają się problemy typu: cykliczne zależności między serwisami, brak izolacji danych, brak jasnej właścicielskiej odpowiedzialności za dane domenowe.

Jeśli monolit modularny jest w stanie zapewnić zespołowi niezależne rozwijanie modułów, krótkie cykle wdrożeń i sensowną skalowalność – nie ma powodu, by przeskakiwać na mikroserwisy. Zwłaszcza na początku projektu, gdy domena biznesowa dopiero się krystalizuje, znacznie rozsądniej jest budować czysty monolit, a dopiero później wydzielać mikroserwisy na bazie doświadczeń niż zgadywać ich granice w ciemno.

Przykład z praktyki: 3 osoby i 15 mikroserwisów

Niewielki zespół (3 programistów) i 15 mikroserwisów w Javie to niemal gwarantowane problemy. Każdy serwis wymaga konfiguracji CI/CD, osobnego monitoringu, logów, dashboardów w Grafanie, reguł alertów, polityk bezpieczeństwa. Nagle okazuje się, że połowa sprintu to nie rozwój funkcji, tylko walka z infrastrukturą. Każde wdrożenie to testowanie współdziałania kilku wersji serwisów na stagingu i poprawianie niespodzianek w kontraktach API.

Takie zespoły często kończą z „mikroserwisową wersją monolitu”: jeden duży SQL-owy schemat współdzielony przez wszystkie serwisy, cross-service JOIN-y i mutacje jednych danych przez wiele serwisów. W efekcie nie można bezboleśnie zdeployować pojedynczego serwisu, bo wszystko jest ze sobą tak splecione, że każde wdrożenie przypomina restart całego monolitu.

W praktyce mały zespół osiągnie więcej, zaczynając od porządnego monolitu, stosując zasady DDD i czystej architektury, pilnując kontroli zależności między modułami. Mikroserwisy pojawiają się dopiero wtedy, gdy modularny monolit przestaje wystarczać – np. jeden moduł staje się krytycznym bottleneckiem wydajności lub wymaga innych parametrów skalowania.

Cyrkiel leżący na planach architektonicznych symbolizujący planowanie systemu
Źródło: Pexels | Autor: Tima Miroshnichenko

Definiowanie domeny i granic serwisów – fundament całej układanki

Podejście DDD w wersji „dla praktyków”

Architektura mikroserwisów w Javie bez sensownego podziału domeny jest jak rozkręcenie silnika na części bez instrukcji złożenia. Domain-Driven Design (DDD) pomaga zdefiniować obszary odpowiedzialności i granice serwisów. Nie chodzi o przeczytanie wszystkich książek Erica Evansa, tylko o kilka praktycznych idei: język domenowy, bounded contexts i agregaty.

Bounded context to wydzielony obszar znaczenia pojęć biznesowych. W module „Sprzedaż” pojęcie „Klient” może oznaczać kogoś innego niż „Klient” w module „Billing”. Mikroserwis nie musi odwzorowywać jednego bounded contextu 1:1, ale zwykle granice serwisów pokrywają się z nimi w 70–80%. Tam, gdzie zmienia się język biznesu, często powinna zmienić się granica serwisu.

W praktycznym DDD chodzi o to, by rozmawiać z biznesem językiem domeny i z tej rozmowy wydestylować procesy, reguły i poddomeny: płatności, zamówienia, logistyka, marketing, fakturowanie. Na tej podstawie można zacząć myśleć, które obszary naturalnie żyją własnym życiem, mają własne dane, własne procesy i mogą stać się oddzielnymi mikroserwisami.

Identyfikacja modułów biznesowych i granic mikroserwisów

Dobry punkt wyjścia to mapa procesów: jak klient trafia do systemu, jak przegląda ofertę, zamawia produkt, opłaca, odbiera, reklamuje. Z takiej mapy wyłaniają się typowe moduły biznesowe, np.: katalog produktów, koszyk, zamówienia, płatności, magazyn, wysyłka, fakturowanie, obsługa zwrotów. Każdy z nich ma własne reguły i dane, których nie musi znać reszta świata.

Granice serwisów często pokrywają się z tymi modułami. Serwis płatności zarządza integracją z bramkami, obsługuje stan transakcji, zapisuje historię płatności. Serwis zamówień przyjmuje zamówienie, utrzymuje jego statusy, komunikuje się z magazynem i płatnościami. Serwis użytkowników może zarządzać profilami, adresami, preferencjami. Każdy ma swoje dane i nie dzieli tabeli z innymi.

Kluczowe jest, by granice serwisów odzwierciedlały zmianę odpowiedzialności biznesowej, a nie przypadkowy podział techniczny. „Email-service” wysyłający maile dla całej organizacji często staje się wąskim gardłem, zamiast być częścią procesu np. powiadomień w obrębie konkretnego kontekstu domenowego (zamówienia, płatności).

Wysoka spójność i niskie sprzężenie w praktyce

Zasada „wysoka spójność, niskie sprzężenie” oznacza, że wewnątrz jednego mikroserwisu znajdują się elementy, które naturalnie muszą współdziałać ze sobą w ramach jednego modelu i jednej transakcji. Serwis powinien robić jedną rzecz biznesowo, ale dobrze. W praktyce: jeśli większość przypadków użycia wymaga wywołań do dwóch różnych serwisów, może to oznaczać, że granice zostały źle dobrane.

Niskie sprzężenie oznacza natomiast, że serwisy komunikują się przez jasno zdefiniowane kontrakty, a nie bezpośredni dostęp do baz danych czy współdzielone modele encji. Serwis A powinien wiedzieć o serwisie B tylko tyle, ile wynika z publicznego API lub zdarzeń domenowych. Im mniej wiedzy o wnętrzu innego serwisu, tym łatwiej utrzymać niezależny rozwój i wdrożenia.

Dobra praktyka: każdy serwis ma swoje wewnętrzne modele domenowe, a światu wystawia uproszczone kontrakty (DTO, eventy). Dzięki temu wewnętrzna refaktoryzacja nie wymusza zmian po drugiej stronie API – o ile kontrakt zewnętrzny pozostaje stabilny. Właśnie to jest jeden z filarów skalowalnej architektury mikroserwisów w Javie.

Typowe błędy w podziale na mikroserwisy

Najczęstszy błąd to tworzenie serwisów technicznych: „email-service”, „pdf-service”, „notification-service”, „cron-service”, które świadczą ogólne, techniczne funkcje. Z czasem każdy moduł biznesowy zaczyna i tak implementować własną logikę wokół tych serwisów, a integracja rośnie wykładniczo. Zamiast jednego miejsca odpowiedzialności za proces powiadomienia klienta, powstaje kilkanaście miejsc, gdzie trzeba coś ustawiać i debugować.

Inny problem to granice cięte po encjach: osobny serwis dla użytkowników, osobny dla adresów, osobny dla ról, osobny dla zamówień, osobny dla pozycji zamówień. Taka „atomizacja” prowadzi do tego, że jedno proste zapytanie (np. „pokaż podsumowanie zamówienia”) wymaga 4–5 wywołań międzyserwisowych. System staje się zbyt gadatliwy, a latencja rośnie.

Prosty warsztat: od mapy procesów do kandydatów na serwisy

Dobrym, prostym podejściem warsztatowym jest event storming w wersji „light”. Zespół siada z biznesem i spisuje zdarzenia: „Klient złożył zamówienie”, „Płatność została autoryzowana”, „Produkt został wysłany”, „Faktura została wystawiona”. Następnie grupuje się zdarzenia w obszary, które naturalnie się do siebie odnoszą. To zgrubne szkice bounded contexts.

Kolejny krok to identyfikacja komend („Złóż zamówienie”, „Anuluj zamówienie”, „Zarezerwuj towar w magazynie”) oraz agregatów, które nimi operują (Zamówienie, Produkt, Magazyn, Płatność). Zestaw komend i zdarzeń wokół jednego agregatu często wskazuje, że to dobry kandydat na serwis lub moduł monolitu. Tam, gdzie interakcje są intensywne i wymagają silnych transakcji, warto utrzymać je w jednym serwisie.

Błędem jest też zbyt wczesne rozbijanie systemu. Na początku lepiej budować modularny monolit z wyraźnymi granicami domeny (np. pakiety i moduły: payments, orders, catalog), a dopiero później wydzielać mikroserwisy, gdy moduły są dobrze zrozumiane, a organizacja gotowa na ich utrzymanie. Ten model jest spójny z podejściem prezentowanym m.in. w materiałach edukacyjnych typu więcej o programowanie, gdzie podkreśla się znaczenie stopniowego dojrzewania architektury.

Ostatni krok to sprawdzenie, czy proponowane granice spełniają kryteria: spójność wewnętrzna, niskie sprzężenie zewnętrzne, jasny właściciel domeny, osobna baza danych lub logicznie wydzielony model danych. Jeśli dany moduł wymaga ciągłych zmian razem z innym – może to sygnał, że oba powinny pozostać w jednym kontekście.

Wybór stosu technologicznego i stylów komunikacji

Frameworki i narzędzia w ekosystemie Java

W świecie JVM dominuje kilka rozwiązań do budowy mikroserwisów. Spring Boot to najpopularniejszy wybór: ogromny ekosystem, integracje z praktycznie każdym narzędziem (Kafka, RabbitMQ, bazy SQL/NoSQL, Kubernetes, Prometheus), ogromna społeczność i wsparcie komercyjne (Spring Cloud, Spring Security, Spring Data). Wadą może być narzut pamięci i czas startu, ale w większości przypadków serwisów biznesowych nie jest to krytyczne.

Quarkus i Micronaut powstały jako odpowiedź na potrzebę szybszego startu i mniejszego zużycia zasobów. Świetnie sprawdzają się w środowiskach serverless i gęsto upakowanych klastrach Kubernetes. Quarkus mocno wspiera GraalVM i native images, co daje błyskawiczny start aplikacji oraz mały footprint RAM. Micronaut stawia na kompilację czasu builda (annotation processing), redukując refleksję w runtime.

Dobór bibliotek do komunikacji i integracji

Sam framework to dopiero początek. W architekturze mikroserwisów kluczowa staje się warstwa komunikacji i integracji z infrastrukturą. W ekosystemie Java pojawiają się powtarzalne klocki, które warto mieć na radarze od pierwszego dnia:

  • Spring Cloud (w świecie Spring Boot) – zestaw rozszerzeń do budowy rozproszonych systemów: konfig centralny (Spring Cloud Config), service discovery (Eureka, Consul), circuit-breakery (dawniej Hystrix, dziś Resilience4j), routing (Spring Cloud Gateway), integracja z message brokerami.
  • Resilience4j – biblioteka cross-framework do wdrażania wzorców odpornościowych: circuit breaker, retry, bulkhead, rate limiter, time limiter. Łatwo ją podłączyć zarówno w Spring Boot, jak i w Micronaut czy Quarkusie.
  • Spring Cloud Stream / SmallRye Reactive Messaging (Quarkus) – abstrakcja nad brokerami (Kafka, RabbitMQ), która upraszcza implementację eventów i przetwarzania asynchronicznego.
  • OpenFeign / RESTEasy Reactive / Micronaut HTTP Client – klienci HTTP, którzy pozwalają opisać wywołania między serwisami deklaratywnie, zamiast składać ręcznie zapytania w RestTemplate/WebClient.

Na początku kuszące jest „zaciągnięcie” całego Spring Cloud w jednym kroku. Rozsądniej jest dobrać tylko to, czego faktycznie potrzeba: np. najpierw Resilience4j na brzegach, później gateway, na końcu discovery – gdy naprawdę bez niego nie da się żyć.

Monitoring, logowanie i observability jako element stosu

W architekturze rozproszonej same mikroserwisy to za mało – bez solidnego observability debugowanie przypomina szukanie igły w stogu logów. W Javie naturalnie układa się trio: logi, metryki i trace’y.

  • Logowanie – SLF4J + Logback/Log4j2 to standard. Kluczowe jest ujednolicenie formatu (JSON), strukturalne logi oraz przekazywanie correlation-id / trace-id między serwisami (np. w nagłówkach HTTP). To pozwala w narzędziu typu Elasticsearch/Kibana prześledzić ścieżkę jednego requestu.
  • Metryki – Micrometer (Spring Boot) lub wbudowane integracje Quarkusa/Micronauta z Prometheusem. Dobrze wystawić od razu podstawowe metryki: czas odpowiedzi, liczba błędów 5xx, długość kolejek, lag Kafki. Kilka prostych dashboardów w Grafanie oszczędza godziny „ślepego” debugowania.
  • Distributed tracing – OpenTelemetry (z Jaegerem lub Tempo) lub rozwiązania komercyjne. Włączenie trace’ów na początku projektu jest dużo tańsze niż podpinanie ich po roku, gdy ruch robi się spory, a zależności – skomplikowane.

Typowy błąd: zostawienie obserwowalności „na później”. Potem przy pierwszym większym incydencie wszyscy wiedzą, że „później” przyszło zdecydowanie za szybko.

Młody programista planuje architekturę mikroserwisów na białej tablicy
Źródło: Pexels | Autor: Startup Stock Photos

Projektowanie kontraktów API i zarządzanie zależnościami między serwisami

Kontrakty API jako umowa, a nie szkic ołówkiem

Kontrakt między serwisami to nie tylko format JSON-a. To umowa o zachowaniu: jak serwis reaguje w różnych sytuacjach, jakie kody statusu zwraca, jakie błędy może wygenerować, jakie są gwarancje dotyczące idempotencji czy kolejności zdarzeń.

W świecie HTTP/REST podstawą są dobre specyfikacje OpenAPI (Swagger). W praktyce opłaca się utrzymywać kontrakt jako pierwszy artefakt: albo w podejściu contract-first (najpierw YAML/JSON, potem generacja stubów), albo przynajmniej w formie generowanego opisu, który jest recenzowany na code review jak zwykły kod.

Przykładowo, dla serwisu zamówień można zdefiniować:

  • POST /orders – utworzenie zamówienia, z gwarancją idempotencji (nagłówek Idempotency-Key),
  • GET /orders/{id} – odczyt danych zamówienia,
  • POST /orders/{id}/cancel – anulowanie zamówienia, jeśli stan pozwala.

W OpenAPI od razu opisuje się możliwe kody odpowiedzi (201, 400, 404, 409), struktury błędów (np. jednolity obiekt ErrorResponse) oraz wymagane nagłówki. To eliminuje „zgadywanie”, jak dany endpoint działa.

Unikanie „wspólnego modelu” i współdzielonych bibliotek domenowych

Naturalny odruch wielu zespołów: „Zróbmy common-model.jar z encjami i share’ujmy je między serwisami, żeby nie powielać kodu”. To prosta droga do ukrytego monolitu. Zależności między serwisami przestają być jawne, a zmiana w jednym module pociąga konieczność przebudowy całego ekosystemu.

Rozsądniejsza praktyka to:

  • utrzymywanie osobnych modeli domenowych wewnątrz serwisów,
  • definiowanie kontraktów API (DTO, event payload) jako oddzielnych typów – nawet jeśli strukturalnie podobnych do encji,
  • dzielenie się jedynie bibliotekami techniczno-infrastrukturalnymi (np. klient do Kafki, standardowy wrapper na metryki, biblioteka do obsługi trace-id), ale nie logiką biznesową.

Duplikacja małych struktur (np. AddressDTO) między serwisami jest zdrowsza niż ich „magiczne” współdzielenie. Daje zespołom swobodę ewolucji modelu bez wizji globalnego refaktoringu.

Kontraktowe testy integracyjne i weryfikacja zgodności

Nawet najlepiej opisany kontrakt nic nie znaczy, jeśli jego implementacja odbiega od specyfikacji. Zamiast polegać wyłącznie na testach end-to-end, można wprowadzić testy kontraktowe (consumer-driven contracts).

Popularne narzędzia w Javie:

  • Spring Cloud Contract – generuje stuby po stronie providera i weryfikuje, że implementacja API zgodna jest z oczekiwaniami konsumenta.
  • Pact – podejście niezależne od frameworka, z centralnym brokerem kontraktów i integracjami z wieloma językami.

Wzorzec jest podobny: konsument opisuje, jakiego zachowania oczekuje (np. jakie pole jest wymagane, jaki status zwracany w danej sytuacji), a provider ma testy, które muszą te oczekiwania spełnić, zanim kod trafi na produkcję. Zmiana kontraktu wymusza weryfikację po obu stronach – dzięki temu modyfikacje API nie „psują” cicho klientów.

Zależności między serwisami a autonomia zespołów

Architektura mikroserwisów ma sens, gdy za serwisami stoją autonomiczne zespoły. Jeśli każdy pull request w jednym serwisie wymaga akceptacji wszystkich innych zespołów, cały pomysł się sypie. Projektując kontrakty, dobrze dać zespołom przestrzeń na niezależną ewolucję.

Pomagają w tym:

  • wersjonowanie API (np. /api/v1, nagłówki wersji, osobne ścieżki eventów),
  • utrzymywanie przez pewien czas dwóch wersji kontraktu (v1 i v2) – z jasnym planem wygaszenia starej,
  • stosowanie rozszerzalnych struktur (np. unikanie łamania kompatybilności przez usuwanie pól; zamiast tego oznaczanie ich jako przestarzałe).

Jeśli wprowadzanie zmiany w jednym serwisie wymaga równoczesnego deploymentu pięciu innych – to sygnał, że kontrakty są zbyt sztywne albo granice domenowe nie trzymają się kupy.

Jeśli interesują Cię konkrety i przykłady, rzuć okiem na: Zarządzanie schematami i migracjami danych w środowisku rozproszonym.

Komunikacja między mikroserwisami – synchroniczna, asynchroniczna i hybrydowa

Synchroniczne REST/gRPC – kiedy „telefon na żywo” ma sens

Najbardziej intuicyjne jest wywołanie synchroniczne: serwis A wywołuje HTTP/gRPC w serwisie B, czeka na odpowiedź i dopiero wtedy kontynuuje. W Javie najczęściej używa się REST-over-HTTP (Spring Web, JAX-RS, WebFlux), choć w środowiskach o wysokich wymaganiach wydajnościowych pojawia się gRPC.

Synchroniczna komunikacja sprawdza się, gdy:

  • potrzebna jest natychmiastowa odpowiedź dla użytkownika (np. sprawdzenie danych logowania),
  • operacja jest krótka i mało ryzykowna (szybki odczyt danych, lekkie walidacje),
  • ilość wywołań między serwisami jest rozsądna (brak „łańcuchów” typu A→B→C→D dla jednego requestu UI).

Przy projektowaniu synchronicznych wywołań kluczowe są:

  • time-outy ustawione explicite (brak czasu oczekiwania „w nieskończoność”),
  • retry z backoffem tylko dla operacji idempotentnych,
  • circuit breaker, który odcina ruch do serwisu „w opałach” i pozwala na szybkie fail-fast zamiast kaskady time-outów.

W praktyce przydatne są adnotacje typu @CircuitBreaker, @Retry, @Timeout (Resilience4j/MicroProfile Fault Tolerance), które spina się w konfiguracji, zamiast rozpraszać logikę odpornościową po kodzie.

Asynchroniczna komunikacja zdarzeniowa – gdy system ma „oddychać”

Gdy procesy biznesowe nie wymagają natychmiastowej odpowiedzi, można sięgnąć po asynchroniczne zdarzenia – Kafka, RabbitMQ, AWS SQS, Google Pub/Sub i podobne. W Javie Spring Cloud Stream, Quarkus i Micronaut oferują gotowe integracje z brokerami.

Typowe przykłady:

  • „Zamówienie zostało złożone” – serwis zamówień publikuje event, magazyn rezerwuje towar, email-service (lub moduł powiadomień w danym kontekście) wysyła potwierdzenie, analytics zlicza statystyki.
  • „Płatność została zaksięgowana” – serwis płatności publikuje zdarzenie, zamówienia aktualizują status, fakturowanie wystawia fakturę.

DDD nazywa takie zdarzenia eventami domenowymi. Ich payload powinien być stabilny, zawierać minimalnie potrzebne dane (np. identyfikatory, kluczowe pola) i być samowyjaśniający – nazwa PaymentAuthorizedEvent jest lepsza niż SomethingHappened. Niby oczywistość, a jednak logi w produkcji lubią opowiadać inne historie.

Idempotencja i obsługa powtórek w komunikacji asynchronicznej

W systemach rozproszonych duplikaty wiadomości są normą, nie wyjątkiem. Broker może dostarczyć event ponownie, konsument może zostać zrestartowany w trakcie przetwarzania. Dlatego operacje wywoływane przez eventy powinny być idempotentne.

Praktyczne techniki:

  • utrzymywanie tabeli przetworzonych wiadomości (np. processed_message(id_eventu, timestamp)) i pomijanie powtórek,
  • stosowanie naturalnych kluczy idempotencji (np. paymentId, orderId) – jeśli zapis już istnieje, nie wykonujemy operacji ponownie,
  • łączenie logiki biznesowej z warstwą persystencji tak, aby „powtórne” wykonanie nie zmieniało skutku (np. ustawienie statusu „SHIPPED” na „SHIPPED” nie jest błędem).

W Javie często łączy się to z mechanizmami transakcyjnymi (Spring @Transactional, JPA) i wzorcem outbox, o którym niżej.

Wzorzec outbox i spójna publikacja zdarzeń

Typowy problem: zapis wewnętrznego stanu serwisu (np. utworzenie zamówienia w bazie) oraz publikacja eventu na zewnątrz (np. do Kafki). Jeśli zapis się uda, a publikacja zdarzenia nie – system wpada w niespójność. W rozproszonym świecie klasyczne 2PC (two-phase commit) to raczej droga przez mękę.

Rozwiązaniem jest wzorzec outbox:

  1. W jednej transakcji z logiką biznesową zapisujesz rekord outbox w tej samej bazie (np. tabela outbox_events z payloadem eventu).
  2. Osobny proces/komponent (np. scheduler, Debezium CDC) odczytuje rekordy z outboxa i publikuje je do brokera.
  3. Po udanej publikacji zaznacza rekord jako przetworzony lub usuwa go.

Dzięki temu zapis danych i „zobowiązanie” do wysłania eventu są atomowe, a samą publikację można powtarzać aż do skutku. W świecie Java pojawiają się gotowe biblioteki (np. Debezium Outbox), ale implementacja bazująca na prostych encjach JPA i schedulerze też jest do ogarnięcia.

Hybrydowe podejście: request-response + eventy

Realne systemy rzadko są „czysto” synchroniczne lub asynchroniczne. Znacznie częściej mamy miks:

  • operacje „krytyczne” dla UX realizowane synchronicznie,
  • powiadomienia, integracje zewnętrzne, długotrwałe procesy – asynchronicznie.

Obsługa błędów i fallbacki w komunikacji między serwisami

W monolicie wyjątek leci „w górę stosu” i ktoś go w końcu obsłuży. W mikroserwisach wyjątek często ląduje w logach innego zespołu, trzy serwisy dalej. Dlatego sposób obsługi błędów i strategia fallbacków jest elementem projektu architektury, a nie „detalem implementacyjnym”.

Dla komunikacji synchronicznej dobrze mieć spójny, świadomie zaprojektowany mechanizm:

  • mapowanie wyjątków domenowych na kody HTTP/gRPC (np. brak środków → 422 Unprocessable Entity, brak uprawnień → 403, chwilowa niedostępność → 503),
  • spójny format błędów (np. { "errorCode": "...", "message": "...", "details": {...} }),
  • fallback logiczny – nie zawsze ma sens „spróbować jeszcze raz”; czasem lepiej zwrócić dane częściowe albo „ostatni znany stan”,
  • kategoryzacja błędów na: stałe (błąd wejścia/domenowy), chwilowe (czasowo niedostępny serwis), niespodzianki (bugi).

W warstwie technicznej (Resilience4j, MicroProfile Fault Tolerance) można zdefiniować politykę retry/fallback per klient. Przykład w Spring Boot z Resilience4j:

@CircuitBreaker(name = "inventoryService", fallbackMethod = "fallbackInventory")
@Retry(name = "inventoryService")
public InventoryResponse getInventory(String productId) {
    return inventoryClient.getInventory(productId);
}

private InventoryResponse fallbackInventory(String productId, Throwable ex) {
    // Możesz zwrócić np. „brak danych, ale nie blokuj zamówienia”
    return InventoryResponse.unknown(productId);
}

W części przypadków lepiej jasno odpowiedzieć „nie wiem, spróbuj później”, niż trzymać użytkownika w nieskończonym spinnerze i modlić się do time-outu.

Obsługa błędów w systemach zdarzeniowych

Przy asynchronicznych eventach błąd nie wróci użytkownikowi wprost, ale i tak ma swoje konsekwencje. Kluczowe są dwa pytania: co robimy z wiadomością, która się „wysypuje” oraz czy system może iść dalej bez jej poprawnego przetworzenia.

Typowe podejścia:

  • dead-letter queue (DLQ) – wiadomości, których nie da się przetworzyć po określonej liczbie prób, trafiają na specjalną kolejkę do ręcznej lub półautomatycznej analizy,
  • parking lot queue – osobna kolejka na trudne przypadki, które będą ponownie przetwarzane np. nocą, gdy ruch jest mniejszy,
  • poison message detection – wykrywanie wiadomości, które zawsze powodują błąd (np. niezgodny schemat) i odsuwanie ich, aby nie blokowały całego strumienia.

W Javie obsługa DLQ bywa wbudowana w klienta (np. Spring Kafka, Spring AMQP), ale częściej trzeba dopiąć politykę samodzielnie: metryki, alarmy, dashboard do podglądu „uwięzionych” eventów. Bez tego DLQ szybko zamienia się w czarną dziurę.

Koordynacja procesów między serwisami – sagas i orkiestracja

Gdy proces biznesowy wymaga kilku kroków w różnych serwisach (np. zamówienie → płatność → rezerwacja magazynu → wysyłka), pojawia się pytanie: kto tym wszystkim steruje i jak się „odkręcić”, gdy krok nr 3 się nie uda.

W świecie mikroserwisów stosuje się sagi – długotrwałe transakcje złożone z serii lokalnych operacji, które mają akcje kompensujące. Zamiast jednego globalnego commit/rollback mamy sekwencję „zrób – odkręć w razie czego”.

Są dwa główne style:

  • choreografia – brak centralnego koordynatora, serwisy „tańczą” reagując na eventy (np. OrderCreated → PaymentAuthorized → StockReserved). Każdy serwis po błędzie publikuje event kompensacyjny (np. PaymentFailedEvent),
  • orkiestracja – osobny komponent (orchestrator / workflow engine) decyduje, który krok wykonać dalej i kogo zawołać, przechowuje też stan całej sagi.

Choreografia jest prostsza na start, ale w większych procesach łatwo się zgubić w sieci eventów. Orkiestracja wprowadza dodatkowy komponent, za to daje czytelny model procesu – nierzadko nawet w postaci BPMN.

W Javie sporą popularność zyskały narzędzia typu Camunda, Temporal, Zeebe czy Durable Functions w chmurach. Zamiast pisać własnego „minikamunda” w bazie, często rozsądniej jest użyć gotowego rozwiązania, które ogarnia retry, time-outy i wizualizację stanu procesu.

Abstrakcyjna, monochromatyczna grafika symbolizująca sieć mikroserwisów
Źródło: Pexels | Autor: Google DeepMind

Projektowanie danych – niezależność baz, spójność i integracja

Niezależność schematów i zakaz „wspólnej bazy prawie-monolitu”

Mikroserwisy z jedną, współdzieloną bazą danych dzielą ten sam los co monolit – tylko debugowanie jest trudniejsze. Każdy serwis powinien zarządzać swoim schematem i posiadać własną bazę (fizycznie osobną lub przynajmniej logicznie wydzieloną).

Praktyczne konsekwencje:

  • brak JOINów między serwisami – każdy serwis może łączyć tylko swoje tabele,
  • brak bezpośredniego czytania cudzej bazy (nawet „tylko read-only do raportów”),
  • integracja wyłącznie przez API lub eventy, ewentualnie mechanizmy typu CDC (Change Data Capture) z wyraźnymi granicami odpowiedzialności.

W Javie standardem jest łączenie frameworków typu Spring Data / JPA / jOOQ z narzędziami migracji schematu (Flyway, Liquibase). Każdy serwis ma własny zestaw migracji, wersjonowany w tym samym repo co kod.

Eventual consistency zamiast globalnych transakcji

Globalna, rozproszona transakcja ACID w poprzek pięciu serwisów brzmi pięknie w prezentacji sprzedażowej, ale w praktyce oznacza problemy z wydajnością, lockami i dostępnością. Dlatego w architekturze mikroserwisów przyjmuje się spójność ostateczną (eventual consistency).

Oznacza to, że po zmianie danych w jednym serwisie inne serwisy zobaczą aktualny stan z opóźnieniem. Zwykle jest to kilka sekund, czasem dłużej. Kluczem jest dobre zaprojektowanie procesów biznesowych, aby takie opóźnienie było akceptowalne – oraz jasna komunikacja wobec biznesu („status zamówienia może zmienić się z opóźnieniem, ale nie zgubimy żadnej operacji”).

Typowy wzorzec:

  1. Serwis A aktualizuje swój stan (np. zamówienie → status „PAID”) i publikuje event domenowy.
  2. Serwis B (np. magazyn) konsumuje event i aktualizuje swój lokalny model (np. rezerwuje towar).
  3. UI może łączyć dane z dwóch różnych serwisów – albo przez kompozycję w BFF/API-gateway, albo przez osobny serwis zapytań.

Jeśli proces wymaga „mocniejszej” spójności (np. operacje finansowe), trzeba dokładnie zdefiniować, dla jakich momentów użytkownik musi widzieć natychmiastowy efekt, a gdzie wystarczy krótkie opóźnienie przy raportowaniu.

Read model i projekcje – CQRS w praktyce

Gdy serwisy posiadają własne bazy i modele domenowe, zapytania cross-domenowe (np. „pokaż listę zamówień z aktualnym statusem płatności i shipmentu”) mogą stać się mało przyjemne. Wtedy pojawia się potrzeba dedykowanych modeli do odczytu.

Wzorzec CQRS (Command Query Responsibility Segregation) podpowiada: rozdziel to, co zmienia stan (commands), od tego, co czyta dane (queries). W mikroserwisach często oznacza to:

  • serwisy domenowe skupione na logice zmian i eventach,
  • osobne „serwisy zapytań” lub moduły, które budują projekcje (denormalizowane widoki) na potrzeby UI, raportów czy eksportów.

Przykład: serwis order-service i payment-service publikują eventy. Serwis reporting-service konsoliduje je w swojej bazie z tabelą order_summary, z której łatwo wyciągnąć dane w stylu „ostatnie zamówienia klienta wraz z kwotą i statusem płatności”.

W Javie projekcje można zbudować używając zwykłego Spring Data + consumerów Kafki. Logika przetwarzania eventu aktualizuje model odczytowy (czasem bardzo „płaski”), zoptymalizowany pod konkretne ekrany w aplikacji.

Do kompletu polecam jeszcze: Jak liczyć ROI z projektów Data Science – perspektywa programisty — znajdziesz tam dodatkowe wskazówki.

Wzorce integracji danych: CDC, replikacja, batch

Nie każde źródło danych da się łatwo „opiąć” eventami domenowymi. Przy integracji z systemami legacy albo bazami raportowymi pojawiają się inne wzorce:

  • CDC (Change Data Capture) – narzędzia typu Debezium podsłuchują logi transakcyjne bazy i zamieniają zmiany w strumień eventów. Potem te eventy są konsumowane przez serwisy,
  • replikacja jednokierunkowa – dane z systemu A są okresowo kopiowane do systemu B (np. read-only do raportów),
  • batch – regularne zadania (np. Spring Batch), które eksportują/importują porcje danych, zwykle poza krytyczną ścieżką użytkownika.

CDC dobrze działa, gdy nie da się „wstrzyknąć” eventów domenowych do starej aplikacji. Trzeba jednak pilnować, aby na podstawie czystych zmian w tabelach nie budować „nowej logiki biznesowej”, bo szybko skończy się to jedną wielką integracyjną kobyłą.

Wersjonowanie schematów i migracje bez przestojów

Zmiana struktury danych w jednym serwisie to jeszcze pół biedy. Schody zaczynają się wtedy, gdy eventy lub API opierają się na tym schemacie, a konsumenci już od dawna żyją własnym życiem. Bezpieczna migracja wymaga zasady „expand and contract”:

  1. Expand – dodajesz nowe kolumny/pola/eventy w sposób kompatybilny wstecz (niczego nie usuwasz), klienci jeszcze ich nie używają.
  2. Migracja danych – w tle migrujesz dane, uzupełniasz nowe pola, przepisujesz payloady, jeśli trzeba.
  3. Switch – konsumentów przełączasz na nowy schema/event/API (często wersjonowany – v2), monitorujesz.
  4. Contract – gdy wszyscy klienci nie używają starej wersji, dopiero wtedy można ją usunąć.

Flyway/Liquibase wspierają wersjonowanie migracji, ale część pracy to zwykła dyscyplina: brak „breaking changes” w jednym deployu i jasno rozpisany plan migracji, szczególnie dla kolumn typu NOT NULL, zmian typu pól czy kluczy głównych.

Przeciwdziałanie „mikroserwisowemu spaghetti” – wzorce architektoniczne

API Gateway, BFF i kompozycja odpowiedzi

Chaos zaczyna się w momencie, gdy frontend musi wywołać siedem serwisów, żeby zbudować jeden ekran. Po stronie klienta jest wtedy więcej wiedzy o architekturze niż po stronie architektów. Lekiem na to jest warstwa API Gateway i/lub Backend for Frontend (BFF).

Gateway pełni role techniczne: autoryzacja, routowanie, limity, cache. BFF łączy po stronie serwera dane z wielu mikroserwisów w jeden skrojony pod UI kontrakt. Dzięki temu:

  • frontend zna tylko BFF, a nie cały las serwisów,
  • logika kompozycji (np. łączenie zamówień z płatnościami i shipmentami) jest w jednym miejscu,
  • łatwiej zmienić backend bez psucia frontu – zmienia się kompozycja, niekoniecznie kontrakt BFF.

W Javie BFF można zbudować jak zwykłą aplikację Spring Boot (REST/GraphQL) z klientami do innych serwisów (WebClient, Feign), spinając to Resilience4j, aby chronić się przed lawiną błędów.

Anticorruption Layer – gdy sąsiedni świat jest „dziwny”

Czasem trzeba integrować się z systemem, którego model danych nijak nie pasuje do naszej domeny (np. stary ERP lub SaaS z osobliwym API). Bez specjalnej warstwy z czasem cały nasz kod zaczyna pachnieć tym dziwnym modelem.

Anticorruption Layer (ACL) to bufor, który:

  • tłumaczy obce struktury danych na nasze obiekty domenowe i odwrotnie,
  • izoluje resztę systemu od „specyfiki” zewnętrznego API (dziwne kody błędów, brak transakcji, inne pojęcia biznesowe),
  • pozwala w jednym miejscu zaadresować wszystkie „hacki” integracyjne.

Przykładowo: serwis billing, który integruje się z bramką płatności, może mieć moduł paymentGatewayClient z własnymi DTO i mapowaniem na lokalne Payment, PaymentStatus. Reszta systemu widzi już tylko „swoje” typy, a nie szczegóły obcego API.

Modułowość w kodzie jednego serwisu

To, że rozbijamy system na mikroserwisy, nie zwalnia z obowiązku zrobienia porządku wewnątrz serwisu. Jedna aplikacja Spring Boot może stać się małym monolitem-bagno, jeśli każdy pakiet zależy od każdego.

Pomagają w tym:

Najczęściej zadawane pytania (FAQ)

Kiedy mikroserwisy w Javie mają sens, a kiedy lepiej zostać przy monolicie?

Mikroserwisy mają sens wtedy, gdy rośnie skala: kilka niezależnych zespołów, duży i szybko zmieniający się system, różne tempo zmian w poszczególnych obszarach (np. płatności zmieniają się co sprint, a raportowanie raz na kwartał). Drugim mocnym powodem są wymagania wydajnościowe – gdy trzeba skalować tylko wybrane fragmenty systemu, a nie całość.

Jeśli masz mały zespół (2–3 osoby), dopiero startujesz z produktem, domena biznesowa nie jest jeszcze dobrze poznana, a procesy DevOps raczkują, znacznie bezpieczniej jest zacząć od porządnego, modularnego monolitu. Mikroserwisy nie zdej­mują złożoności – one ją przesuwają z kodu na infrastrukturę i operacje.

Jakie są typowe antywzorce przy wdrażaniu mikroserwisów w Javie?

Najczęstszy antywzorzec to „mikroserwisy, bo są modne”. Zespół rozcina monolit na user-service, order-service, email-service, ale granice są techniczne, a nie biznesowe. Powstaje sieć wywołań HTTP i kolejek bez jasnych odpowiedzialności i z masą zależności między serwisami.

Drugi klasyk to rozproszone wszystko, ale organizacja nadal jest centralna: jeden zespół DBA, jeden release train, jedno repozytorium. W efekcie zyskujesz wady monolitu (blokujące zależności) i wady mikroserwisów (złożona komunikacja, trudne debugowanie), bez realnych korzyści. Często kończy się też wspólną bazą danych dla wszystkich serwisów, co całkowicie podcina sens ich wydzielania.

Czym różni się monolit modularny od „rozproszonego monolitu” z mikroserwisów?

Monolit modularny to jeden proces uruchomieniowy (np. jedna aplikacja Spring Boot), ale z wyraźnym podziałem na moduły domenowe w kodzie: osobne pakiety, moduły Maven/Gradle, kontrakty między warstwami. Logika biznesowa jest uporządkowana, a przy tym masz prostsze transakcje, debugowanie i wdrażanie – jedna aplikacja, jedna konfiguracja.

„Rozproszony monolit” to wiele serwisów, które zachowują się jak jeden wielki monolit: znają się nawzajem, mają cykliczne zależności, wymagają wspólnego wdrażania i często współdzielą jedną bazę danych. Formalnie to mikroserwisy, praktycznie – jeden organizm, który trzeba deployować i ratować w całości.

Jaką wielkość zespołu i projektu uznać za sensowną dla mikroserwisów w Javie?

Przy 2–3 osobach w zespole utrzymywanie kilkunastu serwisów zwykle jest strzałem w kolano: osobne pipeline’y CI/CD, monitoring, logi, alerty, polityki bezpieczeństwa – to wszystko zjada połowę sprintu. Mały zespół szybciej dowozi funkcje, startując od jednego, dobrze uporządkowanego monolitu.

Mikroserwisy zaczynają mieć sens, gdy pojawia się kilka niezależnych zespołów, które pracują w różnym tempie i chcą wydawać funkcje bez wzajemnego blokowania. Dobrym sygnałem jest też sytuacja, gdy pojedyncze moduły mają zupełnie inne wymagania dotyczące skalowania i dostępności niż reszta systemu.

Jak wykorzystać DDD do wyznaczania granic mikroserwisów?

Praktyczne DDD pomaga rozrysować biznes na sensowne kawałki. Kluczowe pojęcia to bounded contexts i język domenowy. Tam, gdzie zmienia się znaczenie pojęć (np. „Klient” w sprzedaży vs „Klient” w billing’u), zwykle powinna zmienić się też granica modułu, a często i mikroserwisu.

W praktyce dobrze działa podejście: najpierw rozmowa z biznesem, potem mapa procesów (od wejścia klienta do systemu po zwrot lub reklamację), a na tej podstawie wyłonienie poddomen: katalog, koszyk, zamówienia, płatności, magazyn, wysyłka, fakturowanie, zwroty. Granice serwisów powinny odzwierciedlać zmianę odpowiedzialności biznesowej, a nie przypadkowy podział techniczny.

Jak zacząć, jeśli mam istniejący monolit w Javie i myślę o mikroserwisach?

Najrozsądniej zacząć od uporządkowania istniejącego monolitu: wyraźny podział na moduły domenowe, ograniczenie zależności między modułami, wprowadzenie sensownych granic odpowiedzialności w kodzie. To często już samo w sobie rozwiązuje sporo problemów z „monolitem nie do ogarnięcia”.

Dopiero gdy pojawią się konkretne powody (np. konkretny moduł jest wąskim gardłem wydajności lub organizacyjnie jest rozwijany przez osobny zespół), można wydzielać go jako pierwszy mikroserwis. Dobrze jest robić to iteracyjnie: jeden moduł na raz, z jasnym właścicielem i osobną bazą danych, a nie hurtem „przepoławiać” całego systemu w jednym projekcie migracyjnym.

Jak skalować tylko część systemu w architekturze mikroserwisów?

Przewagą mikroserwisów jest możliwość niezależnego skalowania serwisów. Jeśli największe obciążenie generują np. koszyk i zamówienia przed dużą akcją promocyjną, można zwiększyć liczbę instancji tylko tych serwisów (np. w Kubernetesie), nie dotykając reszty systemu.

W monolicie zwykle skalujesz całą aplikację, nawet jeśli problem dotyczy 10% funkcjonalności, co przy dużej skali przekłada się na realne koszty. W mikroserwisach każdy serwis może mieć inne parametry autoskalowania, limitów zasobów czy strategii cache’owania – o ile jego granice są sensownie zdefiniowane i nie wymaga on ścisłej współpracy „na żywo” z połową pozostałych serwisów.

Poprzedni artykułDomowa mieszanka kompotowa z suszonych owoców: jak dobrać składniki i w czym przechowywać aromatyczne mieszanki
Tomasz Czarnecki
Kucharz i konsultant kulinarny, który od lat eksperymentuje z wykorzystaniem świeżych i suszonych warzyw oraz owoców w domowej kuchni. Na pph-agrol.com.pl tworzy przepisy i poradniki pokazujące, jak z prostych składników wyczarować dania na co dzień i od święta. Każdy przepis kilkukrotnie testuje, dopracowując proporcje i technikę tak, by był możliwy do odtworzenia w zwykłej kuchni. Zwraca uwagę na sezonowość, ograniczanie marnowania jedzenia i ekonomiczne podejście do gotowania. Inspiruje się kuchniami świata, ale zawsze tłumaczy techniki w przystępny, uporządkowany sposób.