7 lipca 2026 · 9 min czytania · Kamil Sułek

Sześć senioralnych pytań o architekturę — liczy się cena, nie definicja

Dual-write, idempotencja, eventual consistency, granice kontekstów, CQRS, transakcje rozproszone. Sześć pytań z rozmowy, które oddzielają seniora od mida — bo sprawdzają nie znajomość terminu, lecz świadomość kompromisu.

Te sześć pytań wraca na rozmowach senioralnych. Nie sprawdzają, czy znasz słowo „outbox" albo „saga" — tego można się nauczyć na pamięć. Sprawdzają, czy widzisz cenę: każdy z tych wzorców kupuje jakąś własność systemu rozproszonego (niezawodność, luźne spięcie, skalę) i płaci za nią czymś konkretnym — opóźnieniem, złożonością, ostateczną spójnością, kosztem operacyjnym. Mid nazwie wzorzec. Senior nazwie cenę, zanim go o nią zapytają — i wie, kiedy po wzorzec nie sięgać.

Poniżej moja odpowiedź na każde z nich. Pytania 1, 2 i 6 różnicują najmocniej, bo to w praktyce ten sam problem oglądany z trzech stron.

1. Dual-write: baza commituje, publish pada

Pytanie: zapisujesz encję do bazy i publikujesz event na kolejkę. Baza commituje, publikacja pada. Co się dzieje i jak to naprawiasz?

To są dwa zapisy do dwóch systemów bez wspólnej transakcji — i to jest cała pułapka. Baza się zacommitowała, broker nie dostał eventu → masz dane, o których reszta systemu nigdy się nie dowie. Albo odwrotnie: opublikujesz najpierw, transakcja się cofnie → event o danych, których nie ma. Oba przypadki to cicha korozja spójności na poziomie całego systemu, nie jednej usługi.

Owinięcie publish w retry nie ratuje — proces może paść między commitem a publikacją. Dopóki są to dwa zapisy do dwóch systemów, zawsze istnieje okno, w którym jeden się udał, a drugi nie.

Rozwiązanie: nie rób dwóch commitów. Zrób jeden — oba zapisy w tej samej transakcji: obok encji wstaw wiersz do tabeli outbox:

BEGIN;
  INSERT INTO orders (...) VALUES (...);
  INSERT INTO outbox (id, type, payload, created_at)
    VALUES (gen_random_uuid(), 'OrderPlaced', '{...}', now());
COMMIT;

Osobny proces czyta outbox — odpytując tabelę w pętli albo śledząc log transakcyjny bazy — publikuje na broker i oznacza wiersz jako wysłany. Teraz jest atomowo: commit → event trwale zakolejkowany w outboxie; rollback → nie ma ani encji, ani eventu. Publikacja staje się at-least-once (ten proces po restarcie może wysłać event dwa razy) — i właśnie dlatego istnieje pytanie 2.

To jest lustrzane odbicie zasady z poprzedniego wpisu „decyzja wyprzedza persystencję": tutaj event nie może wyprzedzić commitu.

2. Idempotencja: ten sam event dwa razy

Pytanie: konsument dostaje ten sam event dwukrotnie. Jak zabezpieczasz się przed podwójnym przetworzeniem?

Skoro outbox (i każdy broker at-least-once) potrafi dostarczyć duplikat, konsument musi przetworzyć ten sam event dwa razy bez podwójnego efektu. Odruch seniora: dedup po identyfikatorze wiadomości. Konsument trzyma zbiór przetworzonych message_id (tabela inbox) i wstawia-i-sprawdza wynik w tej samej transakcji co efekt uboczny:

odbierz(msg):
  BEGIN
    INSERT INTO inbox(message_id) VALUES (msg.id)   -- UNIQUE(message_id)
      ON CONFLICT DO NOTHING                         -- duplikat → 0 wierszy
    if wstawiono 0 wierszy: ROLLBACK; return         -- już przetworzone, pomiń
    zastosuj efekt biznesowy
  COMMIT

Strażnikiem nie jest tu if, tylko unikalny indeks na message_id — samo „sprawdź, potem wstaw" bez niego to race condition: przy dwóch konsumentach mielących ten sam duplikat równolegle oba contains zwracają false (na READ COMMITTED) i efekt leci dwa razy. Dopiero insert w unikalny indeks, który pada / zwraca 0 wierszy, jest realnym dedupem. (Tabela inbox nie rośnie w nieskończoność — trzymasz okno deduplikacji z TTL rzędu maksymalnego czasu redelivery brokera, starsze message_id czyścisz.) Całość zamienia dostarczanie at-least-once w przetwarzanie effectively-once. Alternatywnie — zrób operację naturalnie idempotentną (upsert po kluczu biznesowym, „ustaw", nie „zwiększ"). Sedno: w systemie rozproszonym nie wyeliminujesz duplikatów, możesz tylko uczynić ponowne dostarczenie tanim. Dlatego senior nie goni za „exactly-once delivery" (mit — Kafkowe „exactly-once" to effectively-once processing wewnątrz pipeline'u, nie delivery do dowolnego konsumenta) — robi konsumenta idempotentnym. Zastrzeżenie: dedup w transakcji działa, dopóki efekt to zapis w tej samej bazie co inbox; gdy efektem jest wywołanie zewnętrznego systemu (płatność, mail), transakcja go nie obejmuje — idempotencję przenosisz wtedy na jego granicę (idempotency key u odbiorcy).

3. Spójność natychmiastowa vs ostateczna

Pytanie: czym różni się spójność natychmiastowa od ostatecznej? Gdzie świadomie wybierzesz tę drugą?

Natychmiastowa: po powrocie z zapisu każdy czytelnik widzi nową wartość — jedna granica transakcyjna. Ostateczna: czytelnik może przez chwilę zobaczyć stan sprzed zmiany, system zbiega do spójności. To, co kupujesz eventual consistency, to dostępność, luźne spięcie i skala — usługi nie blokują się nawzajem. Cena: okno nieświeżości, które biznes musi tolerować.

Przykład, w którym wybieram ostateczną świadomie: aktualizacja modelu odczytu / indeksu wyszukiwania / innego kontekstu po złożeniu zamówienia. Samo zamówienie musi zacommitować natychmiast (pieniądze), ale „licznik zamówień na dashboardzie" czy indeks rekomendacji może spóźnić się o sekundę. Wciśnięcie ich w transakcję zamówienia sprzęgłoby wszystko, pogorszyło latencję i sprawiło, że zamówienie padnie, gdy padnie indeks. Więc: natychmiastowa tam, gdzie niezmiennik jest naprawdę transakcyjny (saldo w obrębie jednego rejestru/agregatu), ostateczna w poprzek granic kontekstów. Ruch seniora: wybieram per niezmiennik, nie globalnie.

4. Granice bounded contextów i mikroserwisów

Pytanie: jak wyznaczasz granice? Po czym poznajesz, że przecięcie jest złe?

Tnę po zdolności biznesowej i języku, nie po warstwie technicznej. Granica jest dobra, gdy kontekst posiada swoje dane i może zmieniać się wewnątrz bez koordynacji z innymi. Granica kontekstu to granica modelu i języka, niekoniecznie osobny serwis — bounded context świetnie żyje jako moduł w monolicie; mikroserwis to dopiero decyzja o deploymencie nałożona na tę granicę. Sygnały, że przecięcie jest złe:

  • chatty calls — dwie usługi wymieniają serię synchronicznych wywołań, żeby wykonać jedną operację (to naprawdę jeden kontekst),
  • współdzielona encja, którą obie muszą trzymać w zgodzie,
  • potrzeba transakcji rozproszonej, by utrzymać niezmiennik (niezmiennik przecina granicę → granica jest w złym miejscu),
  • zmiana w jednej usłudze zawsze wymusza zmianę w drugiej (wysoki coupling).

Heurystyka: jeśli dwie rzeczy zmieniają się razem i z tego samego powodu biznesowego — należą do siebie. Podział techniczny („wszystkie kontrolery tu, wszystkie repozytoria tam") to klasyczne złe cięcie: każda zmiana biznesowa przecina wszystkie warstwy naraz — zero autonomii zmiany (złamane Common Closure Principle).

5. CQRS: kiedy się opłaca, a kiedy to overengineering

Pytanie: kiedy CQRS ma sens, a kiedy to przerost? Co realnie kosztuje?

Opłaca się, gdy odczyt i zapis są naprawdę asymetryczne: złożone modele odczytu, które nie pasują do modelu zapisu; bardzo różne skalowanie czytania i pisania; wiele kształtów odczytu z jednego modelu zapisu (raporty, wyszukiwanie, dashboardy). Wtedy rozdzielenie modelu komend i zapytań pozwala każdy zoptymalizować osobno.

Overengineering, gdy to CRUD o symetrycznych potrzebach — dokładasz drugi model bez korzyści. Ale uwaga na mit, który sam się tu wkrada: CQRS nie znaczy z automatu eventual consistency. W bazowej formie (osobne ścieżki/modele komend i zapytań nad tą samą bazą, read model jako widoki SQL albo projekcja w tej samej transakcji) odczyt jest w pełni spójny i tani. Nieświeżość wchodzi dopiero, gdy rozdzielasz też magazyny — osobna baza odczytu + asynchroniczna projekcja. CQRS to spektrum: od osobnych klas w jednej bazie (spójne, tanie) po osobne magazyny z projekcjami (skala, ale eventual consistency, „zapisałem i od razu czytam, a tego jeszcze nie ma", plus infrastruktura projekcji) — koszt rośnie z każdym krokiem, więc zatrzymaj się na najtańszym, który wystarcza. Najczęstszym uzasadnieniem są rozjeżdżające się potrzeby odczytu; sam zapis rzadko wystarcza za powód. I drobiazg, który zdradza mida: CQRS ≠ event sourcing.

6. Transakcje rozproszone bez klasycznej transakcji

Pytanie: operacja biznesowa spina kilka serwisów, każdy może paść. Jak zapewniasz spójność bez klasycznej transakcji?

W praktyce nie robisz 2PC przez granice usług (blokady, coupling, spadek dostępności). Rezygnujesz z atomowej transakcji i modelujesz operację jako sagę: ciąg lokalnych transakcji plus akcje kompensujące cofające wcześniejsze kroki, gdy późniejszy padnie; kolejny krok wyzwala event (choreografia) albo komenda koordynatora (orkiestracja). To rollback semantyczny, nie bazodanowy — nie „od-obciążysz" karty, robisz zwrot. I pamiętaj o cenie, o której się milczy: saga nie ma „I" z ACID — między krokami inne transakcje widzą stan pośredni, więc bronisz się środkami zaradczymi (semantic lock, wersjonowanie), a niektórych kroków (pivot) nie da się już skompensować.

Dwa style:

  • choreografia — każda usługa reaguje na eventy, brak centralnego koordynatora. Proste, ale przepływ jest emergentny, trudny do zobaczenia w całości.
  • orkiestracja — usługa-koordynator prowadzi kroki. Przepływ jawny i „posiadany", kosztem centralnego komponentu.

Choreografia przy kilku krokach i luźnym spięciu; orkiestracja, gdy przepływ jest złożony i musisz go widzieć. Klocki: każdy krok musi być idempotentny (pytanie 2) i niezawodnie opublikowany (pytanie 1, outbox). Dlatego 1, 2 i 6 to ten sam problem z trzech stron.

Nić przez wszystkie sześć

Każdy z tych wzorców kupuje własność systemu rozproszonego i płaci konkretną cenę: nieświeżością, złożonością, logiką kompensacji, kosztem operacyjnym. Mid nazywa wzorzec. Senior nazywa cenę, zanim go o nią zapytają — i wie, kiedy po wzorzec nie sięgać, bo najdroższy jest ten, który wprowadzono bez potrzeby.

Tak podchodzę do architektury: pytanie nigdy nie brzmi „który wzorzec", tylko „co za to kupujemy i ile to kosztuje".