Adnotacja @Transactional jest jednym z tych mechanizmów, które bardzo łatwo polubić za szybko. Dodajesz ją, zapis do bazy działa, test przechodzi, temat wydaje się zamknięty. Problem w tym, że wygoda JPA i Springa potrafi przykryć moment, w którym transakcje przestają być prostym detalem technicznym, a zaczynają wpływać na poprawność danych, wydajność i przewidywalność całego systemu.
W wielu projektach kłopoty z transakcjami nie biorą się z braku frameworka, tylko z fałszywego poczucia, że framework załatwi wszystko sam. Nie załatwi. Nadal trzeba rozumieć, gdzie zaczyna się i kończy jednostka pracy, co dzieje się z encjami po drodze i kiedy zapis do bazy naprawdę staje się trwały.
Jeśli pracujesz w Javie, używasz Springa, Hibernate albo JPA i masz poczucie, że „to zwykle działa, ale czasem dzieją się dziwne rzeczy”, to najczęściej nie masz problemu z samą bazą. Masz problem z ukrytym modelem działania transakcji.
Dlaczego temat jest zdradliwy
Transakcje brzmią prosto. Albo cała operacja się powiedzie, albo nic się nie zapisze. Taki model działa dobrze jako intuicja biznesowa, ale w kodzie szybko pojawiają się niuanse:
- jedna metoda woła drugą i nie jest już jasne, gdzie naprawdę jest granica transakcji,
- encja wygląda na zapisaną, ale SQL jeszcze nie poszedł do bazy,
- wyjątek został złapany, więc aplikacja działa dalej, ale transakcja i tak jest oznaczona do rollbacku,
- asynchroniczne wywołanie traci kontekst i nagle kod działa poza transakcją,
- leniwe relacje odpalają się w miejscu, w którym sesja już nie istnieje.
Każdy z tych przypadków jest bardzo typowy. Co gorsza, większość z nich wychodzi dopiero w integracji, pod obciążeniem albo po czasie, kiedy trzeba wyjaśnić, dlaczego dane są niespójne.
1. Adnotacja @Transactional to nie magia, tylko konkretny kontrakt
Najczęstszy błąd polega na traktowaniu @Transactional jak zaklęcia. W praktyce ta adnotacja oznacza: „ten fragment kodu ma działać w określonych granicach transakcji zarządzanych przez framework”. To ważne rozróżnienie, bo od razu ustawia właściwe pytania:
- kto otwiera transakcję,
- na jak długo,
- na jakim wątku,
- jakie wyjątki powodują rollback,
- czy wywołanie przechodzi przez proxy frameworka.
W Springu transakcje często opierają się na proxy. To oznacza, że nie każde wywołanie metody z @Transactional rzeczywiście przejdzie przez mechanizm transakcyjny. Jeśli wywołujesz metodę transakcyjną z innej metody tej samej klasy, możesz ominąć proxy i nie dostać zachowania, którego się spodziewasz.
To jeden z powodów, dla których pozornie niewinna refaktoryzacja potrafi zmienić zachowanie systemu bez żadnego błędu kompilacji.
@Service
class OrderService {
@Transactional
public void placeOrder(Order order) {
validate(order);
saveOrder(order); // self-invocation moze ominac proxy
}
@Transactional
void saveOrder(Order order) {
repository.save(order);
}
}
Sam fakt, że obie metody mają tę samą adnotację, nie gwarantuje jeszcze oczekiwanego zachowania. Liczy się to, czy wywołanie naprawdę przechodzi przez warstwę transakcyjną Springa.
2. Granica transakcji powinna wynikać z use case’u, nie z wygody repozytorium
Bardzo wiele problemów zaczyna się wtedy, gdy granice transakcji ustawia się zbyt nisko. Na przykład bezpiecznie wydaje się oznaczyć transakcyjnie pojedynczą metodę w serwisie albo nawet w repozytorium. Tyle że transakcja ma reprezentować spójną jednostkę pracy biznesowej, a nie tylko wygodny blok kodu.
Jeśli w jednym use case’ie:
- pobierasz dane,
- walidujesz stan,
- modyfikujesz kilka encji,
- zapisujesz historię,
- publikujesz zdarzenie,
to sensowne pytanie brzmi nie „gdzie najłatwiej dodać adnotację”, tylko „co w tej operacji musi być atomowe”.
Zbyt szeroka transakcja też szkodzi. Jeśli trzymasz ją przez długie operacje, wywołania HTTP, komunikację z brokerem albo ciężką logikę, zwiększasz ryzyko blokad, timeoutów i trudnych do przewidzenia konfliktów. Transakcja nie powinna obejmować wszystkiego. Powinna obejmować to, co naprawdę musi zostać zapisane razem.
3. Flush i commit to nie to samo
JPA potrafi dać zdradliwe poczucie, że zapis do encji od razu oznacza zapis do bazy. Nie oznacza. W grze są co najmniej trzy poziomy:
- stan obiektu w pamięci,
- stan persistence context,
- faktyczny stan bazy po zatwierdzeniu transakcji.
Flush synchronizuje zmiany z bazą na poziomie SQL, ale jeszcze nie kończy transakcji. Commit dopiero ją zatwierdza. To rozróżnienie ma znaczenie, gdy:
- liczysz na szybkie wykrycie naruszenia constraintów,
- chcesz wiedzieć, kiedy SQL naprawdę został wysłany,
- debugujesz sytuację, w której „przecież save już był”.
Jeśli zespół nie rozumie tej różnicy, łatwo dochodzi do błędnych założeń. Ktoś widzi wywołanie save i zakłada, że rekord już istnieje w stabilnym stanie. Tymczasem transakcja może się jeszcze wycofać, a cała operacja nigdy nie stanie się trwała.
orderRepository.save(order);
entityManager.flush(); // SQL moze juz poleciec do bazy
publishDomainEvent(order);
// dopiero commit na koncu transakcji robi zapis trwaly
4. Lazy loading jest wygodny do momentu, w którym zaczyna sterować architekturą
To klasyczna pułapka projektów opartych o JPA. Na początku wszystko wygląda elegancko: relacje są leniwe, nie pobierasz za dużo danych, kod jest zwięzły. Potem przychodzi moment, w którym:
- mapper DTO odpala dodatkowe zapytania bez Twojej wiedzy,
- widok albo serializer dotyka relacji już poza sesją,
- pojawia się wyjątek LazyInitializationException,
- albo jeszcze gorzej: nie ma wyjątku, ale masz problem N+1 i dramatyczny spadek wydajności.
To nie jest argument przeciwko lazy loadingowi jako takiemu. To argument przeciwko nieświadomemu opieraniu przepływu danych na zachowaniu ORM-a. Jeśli architektura aplikacji zależy od tego, czy sesja „jeszcze żyje”, to prędzej czy później zaczynasz gonić skutki uboczne zamiast panować nad przepływem danych.
Dobrym nawykiem jest jawne definiowanie, jakie dane są potrzebne w konkretnym use case’ie, zamiast liczyć, że encja sama „dociągnie sobie resztę”.
5. Złapany wyjątek nie zawsze ratuje transakcję
Kolejna bardzo częsta pułapka wygląda tak: w środku metody transakcyjnej łapiesz wyjątek, logujesz go, może nawet zwracasz kontrolowany wynik, i zakładasz, że skoro aplikacja nie wybuchła, wszystko jest pod kontrolą.
Niekoniecznie.
W zależności od miejsca i typu błędu transakcja może już być oznaczona jako przeznaczona do rollbacku. Z zewnątrz kod wygląda poprawnie, ale na końcu dostajesz niespodziankę: brak zapisu, wyjątek przy commit albo stan, który trudno zrozumieć bez wejścia w szczegóły działania menedżera transakcji.
Tu wraca podstawowa zasada: jeśli obsługujesz wyjątek w transakcji, musisz wiedzieć, czy tylko porządkujesz błąd na poziomie aplikacji, czy faktycznie próbujesz kontynuować jednostkę pracy, która być może już jest logicznie zepsuta.
6. Asynchroniczność rozcina to, co wcześniej wydawało się jednym flow
Problemy robią się jeszcze ciekawsze, gdy do gry wchodzi @Async, eventy domenowe, scheduler albo komunikacja z kolejką. Wtedy bardzo łatwo nieświadomie założyć, że skoro jakiś kod jest wywołany „zaraz po”, to nadal działa w tym samym kontekście transakcyjnym.
Nie działa.
Przejście do innego wątku albo innego procesu zwykle kończy prostą, lokalną intuicję: „wszystko dzieje się w jednej operacji”. Jeśli po commit chcesz wykonać efekt uboczny, warto to modelować świadomie. Często lepiej:
- odseparować zapis od komunikacji z systemem zewnętrznym,
- użyć wzorca outbox, jeśli spójność zdarzeń ma znaczenie,
- uruchamiać logikę po zatwierdzeniu transakcji, a nie w jej trakcie.
Najgorsze, co można zrobić, to mieszać zapis do bazy z wywołaniem zewnętrznego API w jednej metodzie i udawać, że to jedna atomowa operacja. Baza może zrobić rollback. Zewnętrzne API zwykle nie.
7. JPA daje wygodę, ale nie zastępuje myślenia o modelu danych
W dojrzałych projektach problem rzadko polega na tym, że ktoś nie zna adnotacji. Problem polega na tym, że encje, relacje i transakcje zaczynają przykrywać realny model biznesowy.
Jeśli każda zmiana stanu oznacza przejście przez duży graf encji, kaskady i ukryte side effecty, to system staje się trudny do przewidzenia. Z zewnątrz wygląda obiektowo i elegancko. W praktyce trudno odpowiedzieć na proste pytanie: jakie rekordy i w jakiej kolejności naprawdę się zmieniają.
To właśnie moment, w którym warto zadać sobie kilka niewygodnych pytań:
- czy ten fragment naprawdę potrzebuje pełnego ORM-a,
- czy nie lepiej jawnie kontrolować zapytania i aktualizacje,
- czy model encji nie jest zbyt sprzężony z warstwą zapisu,
- czy granice agregatów są sensowne.
Czasem najlepszą decyzją nie jest wyrzucenie JPA, tylko przestanie używać go wszędzie tak samo.
Jak pracować z transakcjami trochę dojrzalej
Nie trzeba od razu robić rewolucji architektonicznej. Wystarczy kilka praktycznych zasad:
- ustalaj granice transakcji na poziomie use case’ów, nie przypadkowych metod technicznych,
- nie trzymaj transakcji przez wywołania zewnętrzne i długie operacje,
- traktuj encje jako narzędzie zapisu, a nie uniwersalny model do wszystkiego,
- projektuj jawnie, jakie dane pobierasz i kiedy,
- testuj scenariusze integracyjne z prawdziwą bazą, a nie tylko zachowanie mocków,
- loguj i obserwuj SQL tam, gdzie zachowanie systemu robi się nieoczywiste,
- oddzielaj zapis stanu od efektów ubocznych wobec innych systemów.
To są mało widowiskowe rzeczy, ale właśnie one odróżniają projekt, który „zwykle działa”, od projektu, który da się spokojnie rozwijać.
Kiedy warto wyjść poza domyślne JPA
Nie każdy problem transakcyjny oznacza, że trzeba porzucić Hibernate. Ale są sytuacje, w których warto świadomie sięgnąć po prostsze albo bardziej jawne podejście:
- gdy fragment systemu jest mocno raportowy i zależy Ci głównie na czytelnych zapytaniach,
- gdy aktualizacje są złożone i chcesz pełnej kontroli nad SQL-em,
- gdy problemem staje się wydajność ukryta za grafem encji,
- gdy logika domenowa wymaga bardzo precyzyjnego modelowania granic spójności.
Dojrzała architektura nie polega na religijnym trzymaniu się jednego stylu. Polega na świadomym doborze narzędzi do problemu.
Wniosek
Największa zaleta JPA i Springa jest jednocześnie ich pułapką. Dają szybki start i wygodę, ale przez to łatwo zapomnieć, że transakcje nadal są jednym z najbardziej wrażliwych elementów backendu.
Jeśli chcesz uniknąć problemów, nie wystarczy wiedzieć, gdzie dodać @Transactional. Trzeba rozumieć, co w Twoim systemie naprawdę ma być atomowe, kiedy stan staje się trwały i gdzie framework przestaje być pomocą, a zaczyna ukrywać zbyt wiele.
To właśnie tu kończy się wygoda JPA, a zaczyna prawdziwa inżynieria.







