consdata.com
Blog techniczny Blog biznesowy Dział HR
EN
java

Pułapki adnotacji @Transactional

author Kamil Dudek
10 kwietnia 2026

Wykorzystanie adnotacji @Transactional w frameworku Spring stanowi jedno z podstawowych narzędzi w zarządzaniu transakcjami bazodanowymi. Choć jej zastosowanie jest wygodne i upraszcza kod, niesie ze sobą również potencjalne pułapki, które mogą powodować trudne do zdiagnozowania błędy.

Na potrzeby tego wpisu załóżmy, że mamy następujący schemat bazy danych:

@Entity
public class Car {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String vin;

    @Column
    private String model;

    @Column
    private int mileage;

    @ManyToOne
    @JoinColumn(name = "car_owner_id")
    private CarOwner carOwner;

    // konstruktory, gettery, settery    

}

@Entity
public class CarOwner {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String name;

    @OneToMany(mappedBy = "carOwner", cascade = CascadeType.ALL)
    private List<Car> cars = new ArrayList<>();

    // konstruktory, gettery, settery

}

1. @Transactional domyślnie jest realizowany przez proxy

Domyślnie Spring dla wszystkich klas lub metod oznaczonych adnotacją @Transactional tworzy proxy (dynamiczne proxy JDK albo proxy CGLIB w zależności od sytuacji oraz ustawień), co umożliwia zastosowanie logiki transakcyjnej (np. rozpoczęcia i commitowania transakcji) przed oraz po wykonaniu wywoływanej metody. Oznacza to, że adnotacja ta będzie działała tylko w przypadku metod publicznych - metody o innej widoczności po prostu zignorują tę adnotację bez żadnych ostrzeżeń, ponieważ nie są one obsługiwane przez proxy. Dodatkowo przechwytywane będą tylko zewnętrzne wywołania metod, czyli takie, które przechodzą przez proxy. Wszelkie wywołania metod wewnątrz tego samego komponentu nie spowodują rozpoczęcia transakcji, nawet jeśli metoda jest oznaczona adnotacją @Transactional. W przypadku gdy chcemy, aby wewnętrzna metoda działała jednak w sposób transakcyjny, możemy posiłkować się wstrzyknięciem beana do samego siebie (self-injection), co pozwala nam na użycie proxy stworzonego przez Springa.

@Service
public class WrongTransactionalCarService {

    private final CarRepository carRepository;

    public WrongTransactionalCarService(CarRepository carRepository) {
        this.carRepository = carRepository;
    }

    public void createCar() {
        Car polonez = Car.builder()
                .model("FSO Polonez")
                .vin("1D4GP24R75B188657")
                .mileage(100)
                .build();
        saveCar(polonez);
    }

    @Transactional
    public void saveCar(Car car) {
        carRepository.save(car);

        throw new IllegalStateException("Some exception - rollback transaction!");
    }

    /* 
        Wynik metody createCar():
        > SELECT id, model FROM car;
        id | model      
        ---+------------
        1  | FSO Polonez
    */

}

W powyższym przykładzie wywołanie metody createCar() nie spowoduje wycofania transakcji pomimo rzucenia wyjątku IllegalStateException, ponieważ metoda saveCar() z adnotacją @Transactional została wywołana bezpośrednio wewnątrz beana - a nie przez proxy.

@Service
public class WorkingTransactionalCarService {

    private final CarRepository carRepository;

    private final WorkingTransactionalCarService workingTransactionalCarService;

    public WorkingTransactionalCarService(CarRepository carRepository,
                                          @Lazy WorkingTransactionalCarService workingTransactionalCarService) {
        this.carRepository = carRepository;
        this.workingTransactionalCarService = workingTransactionalCarService;
    }

    public void createCar() {
        Car polonez = Car.builder()
                .model("FSO Polonez")
                .vin("1D4GP24R75B188657")
                .mileage(100)
                .build();
        workingTransactionalCarService.saveCar(polonez);
    }

    @Transactional
    public void saveCar(Car car) {
        carRepository.save(car);

        throw new IllegalStateException("Some exception - rollback transaction!");
    }

    /*
        Wynik metody createCar():
        > SELECT id, model FROM car;
        id | model
        ---+------
    */

}

Powyższy przykład reprezentuje ten sam proces, jednak tym razem wywołanie metody saveCar() odbyło się poprzez użycie wstrzykniętego proxy - w takiej sytuacji wycofanie transakcji działa poprawnie.

AdviceMode.ASPECTJ - @Transactional jako aspekt

Spring w trybie AdviceMode.ASPECTJ zamiast domyślnego AdviceMode.PROXY realizuje @Transactional poprzez aspekty AspectJ oraz modyfikacje kodu bajtowego, a nie przez proxy. Pozwala na ominięcie powyższych ograniczeń - w takiej formie oprócz modyfikatora publicznego obsługiwane są również inne modyfikatory dostępu, a także działa wewnętrzne wywoływanie metod. Minusem takiego rozwiązania jest jednak skomplikowanie procesu budowania aplikacji (wymagany jest kompilator AspectJ albo konfiguracja load-time weavingu) oraz trudniejsze jej utrzymywanie względem domyślnego trybu z proxy.

@SpringBootApplication
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)
public class AspectJExampleApp {

	public static void main(String[] args) {
		SpringApplication.run(AspectJExampleApp.class, args);
	}

}

@Service
public class AspectJTransactionalService {

    private final CarRepository carRepository;

    public AspectJTransactionalService(CarRepository carRepository) {
        this.carRepository = carRepository;
    }

    public void createCar() {
        Car polonez = Car.builder()
                .model("FSO Polonez")
                .vin("1D4GP24R75B188657")
                .mileage(100)
                .build();
        saveCar(polonez);
    }

    @Transactional
    private void saveCar(Car car) {
        carRepository.save(car);

        throw new IllegalStateException("Some exception - rollback transaction!");
    }

    /*
        Wynik metody createCar():
        > SELECT id, model, mileage FROM car;
        id | model
        ---+------------
    */

}

Przedstawiona klasa AspectJTransactionalService poprawnie wycofa transakcję, pomimo że metoda saveCar() jest prywatna oraz jest wywoływana wewnętrznie.

2. @Transactional, Hibernate i dirty checking

Hibernate aktualizuje zarządzane obiekty nie wprost

Cykl życia encji w Hibernate składa się z czterech stanów:

  1. Transient - obiekt został stworzony w aplikacji, ale nie jest jeszcze zarządzany przez Hibernate
  2. Persistent - obiekt jest zarządzany przez Hibernate i jest powiązany z sesją
  3. Detached - obiekt był w stanie persistent, ale sesja, z którą był powiązany została zamknięta lub obiekt został ręcznie od niej odłączony
  4. Removed - obiekt został usunięty; Hibernate usunie odpowiadający mu rekord z bazy danych w stosownym momencie (np. przy commicie)

Wszelkie operacje (takie jak pobieranie z bazy oraz zapisywanie do niej), które powodują przejście obiektu do stanu persistent oraz powiązanie go z sesją, sprawiają, że kopia tego obiektu zostaje umieszczona w kontekście persystencji, czyli cache’u L1 (cache’u sesji). Hibernate poprzez mechanizm dirty checking porównuje tę kopię obiektu z obiektem oryginalnym, śledząc w ten sposób wszystkie zmiany, którym została poddana encja, aby odwzorować je w bazie danych po zakończeniu transakcji. Innymi słowy, wszystkie zmiany, wykonane np. przez użycie setterów, zostaną automatycznie zapisane do bazy danych w momencie zakończenia transakcji, nawet jeśli wprost nie została wywołana metoda wykonująca zapis (np. repository.save(entity)).

@Service
public class MileageUpdater {

    private final CarRepository carRepository;

    public MileageUpdater(CarRepository carRepository) {
        this.carRepository = carRepository;
    }

    @Transactional
    public void updateMileage(Long carId, int newMileage) {
        Car car = carRepository.findById(carId)
                .orElseThrow(() -> new IllegalArgumentException("Unknown car!"));

        var oldMileage = car.getMileage();

        car.setMileage(newMileage);

        if (isMileageCorrect(oldMileage, newMileage)) {
            carRepository.save(car);
        }
    }

    private boolean isMileageCorrect(int oldMileage, int newMileage) {
        return newMileage >= oldMileage;
    }

    /*
        Stan przed wywołaniem metody updateMileage()
        > SELECT id, model, mileage FROM car;
        id | model            | mileage
        ---+------------------+--------
        1  | Volkswagen Jetta | 197000
    */
    /*
        Po wywołaniu metody updateMileage(1L, 10000)
        > SELECT id, model, mileage FROM car;
        id | model            | mileage
        ---+------------------+--------
        1  | Volkswagen Jetta | 10000
    */
}

W tym przykładowym serwisie walidacja opiera się na sprawdzeniu, czy nowy przebieg samochodu nie jest niższy od starego. Jeśli nowy przebieg jest niepoprawny, to metoda carRepository.save(car), która wprost zapisuje tę encję do bazy, nie zostanie wykonana. Ponieważ jednak znajdujemy się w transakcji, a encja ta znajduje się w kontekście persystencji (została wcześniej wyciągnięta z bazy za pośrednictwem carRepository.findById(carId)), w przypadku ustawienia niepoprawnej wartości poprzez car.setMileage() zmiana ta zostanie odzwierciedlona na bazie danych, nawet jeśli kod naszego serwisu nie wskazuje na to wprost.

Rozwiązaniem tego problemu jest pilnowanie, aby wszelkie zmiany stanu obiektu znajdującego się w kontekście persystencji odbywały się w momencie, w którym dozwolony jest jego zapis. Jeśli z jakiegoś powodu nie jest to możliwe, możemy ratować się, np. odłączając obiekt z kontekstu persystencji lub korzystając z obiektu pośredniczącego, który na końcu zostanie zsynchronizowany z oryginalnym obiektem. Poniżej znajduje się przykładowa implementacja MileageUpdater z poprawioną logiką.

@Service
public class CorrectMileageUpdater {

    private final CarRepository carRepository;

    public CorrectMileageUpdater(CarRepository carRepository) {
        this.carRepository = carRepository;
    }

    @Transactional
    public void updateMileage(Long carId, int newMileage) {
        Car car = carRepository.findById(carId)
                .orElseThrow(() -> new IllegalArgumentException("Unknown car!"));

        var oldMileage = car.getMileage();

        if (isMileageCorrect(oldMileage, newMileage)) {
            car.setMileage(newMileage);
            // carRepository.save(car); nie jest potrzebne - samo car.setMileage() spowoduje zapis encji do bazy
        }

    }

    private boolean isMileageCorrect(int oldMileage, int newMileage) {
        return newMileage >= oldMileage;
    }

    /*
        Stan przed wywołaniem metody updateMileage()
        > SELECT id, model, mileage FROM car;
        id | model            | mileage
        ---+------------------+--------
        1  | Volkswagen Jetta | 197000
    */
    /*
        Po wywołaniu metody updateMileage(1L, 10000)
        > SELECT id, model, mileage FROM car;
        id | model            | mileage
        ---+------------------+--------
        1  | Volkswagen Jetta | 197000
    */
}

Hibernate porównuje obiekty z tym co znajduje się w kontekście persystencji

W ramach transakcji dirty checking jest podstawowym mechanizmem, dzięki któremu Hibernate wie, jakie encje muszą zostać zaktualizowane, a które tego nie wymagają. Warto mieć na uwadze, że Hibernate załaduje do kontekstu persystencji wszystkie obiekty zwrócone przez zapytania bazodanowe, nawet jeśli zwrócą one rekordy, które w praktyce nie istnieją.

@Repository
public interface CarRepository extends JpaRepository<Car, Long> {

    @Query(value = """
                SELECT 100 AS id, 'Volvo S70' AS model, 'YV1LS55A3X1588402' AS vin, 192311 AS mileage, null AS car_owner_id
            """,
            nativeQuery = true)
    List<Car> returnNotExistingCars();

}
@Service
public class NotExistingCarService {

    private final CarRepository carRepository;

    public NotExistingCarService(CarRepository carRepository) {
        this.carRepository = carRepository;
    }

    @Transactional
    public void tryToAddNotExistingCars() {
        List<Car> cars = carRepository.returnNotExistingCars();
        assert !cars.isEmpty();
        carRepository.saveAll(cars);
    }

    /*
        Stan bazy przed wykonaniem tryToAddNotExistingCars():
        > SELECT * FROM car;
        mileage | car_owner_id | id | model | vin
        --------+--------------+----+-------+----
    */
    /*
        Stan bazy po wykonaniu tryToAddNotExistingCars():
        > SELECT * FROM car;
        mileage | car_owner_id | id | model | vin
        --------+--------------+----+-------+----
    */
}

Zapytanie returnNotExistingCars() zwraca samochód, który został stworzony bezpośrednio w tym zapytaniu i nie jest zapisany w bazie danych. Metoda tryToAddNotExistingCars() próbuje zapisać ten samochód, jednak tabela pozostanie pusta pomimo tego, że używamy carRepository.saveAll(cars). Z punktu widzenia Hibernate, nieistniejący samochód istnieje w kontekście persystencji (jest zarządzany przez Hibernate) i jego stan nie został zmieniony, dlatego nie ma potrzeby wykonania operacji INSERT do bazy danych. W tej sytuacji, aby dodać tę encję, trzeba np. odłączyć ją od kontekstu za pośrednictwem EntityManager.detach().

3. @Transactional, a checked exceptions

Jednym z kluczowych mechanizmów transakcji jest rollback, który wycofuje wszystkie zmiany w przypadku natrafienia na wyjątek. Domyślnie mechanizm ten wyzwalany jest przy wyjątkach unchecked exceptions dziedziczących po RuntimeException oraz przy wyjątkach dziedziczących po klasie Error, natomiast nie działa w przypadku checked exceptions, czyli wyjątków dziedziczących po klasie Exception. Aby mechanizm ten działał także w tym przypadku, należy wprost określić wyjątki, które mają uruchamiać rollback, używając parametru rollbackFor w adnotacji @Transactional - przykładowo @Transactional(rollbackFor = SomeBusinessException.class)

4. @Transactional w testach

Popularną praktyką w testach integracyjnych zahaczających o bazę danych jest użycie w nich adnotacji @Transactional (bezpośrednio lub pośrednio, np. przez adnotację @DataJpaTest). Z pozoru jest to wygodne rozwiązanie, które np. gwarantuje nam czyszczenie bazy po każdym teście, jednak w praktyce może prowadzić do kilku pułapek. Istnieje ryzyko, że transakcje i ich zakresy obecne w naszych testach będą różniły się od tych obecnych w kodach produkcyjnych, przez co nasze testy nie będą działały zgodnie z oczekiwaniami.

Załóżmy, że chcemy wyciągnąć z bazy właścicieli samochodów z konkretnymi numerami VIN. Dodatkowo chcemy, żeby zwrócone rekordy właścicieli (klasa CarOwner) zawierały jedynie wyfiltrowane samochody posiadające te numery VIN. Przykładowo dla właściciela o id=1 oraz tabeli car:

 mileage | car_owner_id | id |    model     |   vin   
---------+--------------+----+--------------+---------
  199989 |            1 |  1 | Toyota Yaris | SZUKANY
  199989 |            1 |  2 | Audi A6      | INNY

Chcemy otrzymać rekord CarOwner zawierający w polu CarOwner.cars listę z jednym elementem - samochodem z numerem VIN o wartości “SZUKANY”. W tym celu możemy napisać zapytanie:

@Repository
public interface CarOwnerRepository extends JpaRepository<CarOwner, Long> {

    @Query("SELECT co, c FROM CarOwner co JOIN FETCH co.cars c WHERE c.vin IN :vinNumbers")
    List<CarOwner> findOwnersByCarVinNumbersAndFilterCars(List<String> vinNumbers);

}

Zapytanie to zostanie przekonwertowane na następujący SQL:

    select
        c1_0.id,
        c2_0.car_owner_id,
        c2_0.id,
        c2_0.mileage,
        c2_0.model,
        c2_0.vin,
        c1_0.name 
    from
        car_owner c1_0 
    join
        car c2_0 
            on c1_0.id=c2_0.car_owner_id 
    where
        c2_0.vin in ('SZUKANY')

Wykonanie tego zapytania bezpośrednio na bazie zwraca oczekiwany wynik:

 id | car_owner_id | id | mileage |    model     |   vin   | name 
----+--------------+----+---------+--------------+---------+------
  1 |            1 |  1 |  199989 | Toyota Yaris | SZUKANY | John

Napiszmy test integracyjny, który podniesie kontekst Springa i zweryfikuje poprawność działania tego zapytania:

@SpringBootTest
class CarRepositoryTest {

    @Autowired
    CarOwnerRepository carOwnerRepository;

    @BeforeEach
    void populateDb() {
        CarOwner owner = new CarOwner();
        owner.setName("John");

        Car toyota = Car.builder()
                .vin("SZUKANY")
                .model("Toyota Yaris")
                .carOwner(owner)
                .mileage(199989)
                .build();
        Car audi = Car.builder()
                .vin("INNY")
                .model("Audi A6")
                .carOwner(owner)
                .mileage(199989)
                .build();

        owner.setCars(List.of(toyota, audi));

        carOwnerRepository.save(owner);
    }

    @Test
    @Transactional    
    void shouldFindOwnerWithFilteredCars() {
        List<CarOwner> owners = carOwnerRepository.findOwnersByCarVinNumbersAndFilterCars(List.of("SZUKANY"));
        assertThat(owners).hasSize(1);
        CarOwner carOwner = owners.get(0);
        assertThat(carOwner.getCars())
                .hasSize(1)
                .extracting(Car::getVin)
                .isEqualTo(List.of("SZUKANY"));
    }
}

Powyższy test wypełnia bazę danych w metodzie populateDb(), po czym wykonuje testowane zapytanie do bazy. Następnie robi asercje, aby upewnić się, że zwrócony został właściciel z dokładnie jednym, szukanym samochodem. Uruchomienie tego testu zakończy się jednak błędem:

Expected size: 1 but was: 2 in:
[Car(id=1, vin=SZUKANY, model=Toyota Yaris, mileage=199989),
    Car(id=2, vin=INNY, model=Audi A6, mileage=199989)]

Pierwszą reakcją może być myśl, że zapytanie jest niepoprawne, jednak w tym przypadku powód jest inny. Powyższy test jest opakowany w @Transactional - oznacza to, że cały jego przebieg (uzupełnienie bazy danych testowymi danymi w populateDb() oraz sam właściwy test zapytania shouldFindOwnerWithFilteredCars()) wykonuje się w jednej transakcji, a tym samym w jednej sesji Hibernate. Ponieważ zapis do bazy wiąże się z wrzuceniem obiektu do kontekstu persystencji, Hibernate nie uwzględnia w pełni treści zapytania i zamiast pobrać wartości z bazy, pobiera je ze swojego cache L1. Taka sytuacja nie miałaby miejsca w przypadku kodów produkcyjnych, w których zapis do bazy oraz odczyt byłby najprawdopodobniej w zupełnie odrębnych transakcjach.

Jeśli pozbędziemy się adnotacji @Transactional, test będzie przechodził zgodnie z oczekiwaniami. W takim przypadku jednak musimy sami zadbać o wyczyszczenie bazy po wykonanym teście - np. używając adnotacji @AfterEach i metody carRepository.deleteAll().

Więcej o @Transactional

Więcej informacji o transakcjach w Javie można znaleźć w Zarządzanie transakcjami w Java - jak to robić dobrze?, a o innym ciekawym problemie z adnotacją @Transactional w testach można przeczytać we wpisie: Czy wiesz dlaczego nie powinno się stosować adnotacji @Transactional w testach integracyjnych z Hibernate?.

Źródła

Spring - Declarative Transation Management

Hibernate ORM User Guide

Najnowsze wpisy

  • Pułapki adnotacji @Transactional
  • Czy wiesz, czym jest i jak działa Browserslist?
  • Czy wiesz, że zależności w Springu powinniśmy wstrzykiwać przez konstruktor?
Dołącz do nas

  • SENIOR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 14 900 - 20 590 PLN brutto
    B2B 19 680 - 27 220 PLN netto
  • REGULAR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 11 300 - 15 900 PLN brutto
    B2B 14 950 - 21 000 PLN netto
  • ZOBACZ WSZYSTKIE OGŁOSZENIA

Podobne wpisy

post-image
java

Pułapki adnotacji @Transactional

Najczęstsze pułapki związane z @Transactional w Springu i Hibernate - od proxy i self-invocation, przez dirty checking, po cache Hibernate.

author
Kamil Dudek 10 kwi 2026
post-image
frontend

Czy wiesz, czym jest i jak działa Browserslist?

Od czasu do czasu każda osoba pracująca nad frontendem natrafia na plik o nazwie `browserslist`. Kto z niego korzysta i na co wpływają dokonywane w nim zmiany?

author
Piotr Grobelny 27 mar 2026
post-image
spring boot

Czy wiesz, że zależności w Springu powinniśmy wstrzykiwać przez konstruktor?

Poznaj zalety tego podejścia, przykłady kodu i wskazówki dotyczące testowania oraz bezpieczeństwa aplikacji.

author
Bartosz Pietrowiak 16 mar 2026
Dołącz do nas

  • SENIOR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 14 900 - 20 590 PLN brutto
    B2B 19 680 - 27 220 PLN netto
  • REGULAR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 11 300 - 15 900 PLN brutto
    B2B 14 950 - 21 000 PLN netto
  • ZOBACZ WSZYSTKIE OGŁOSZENIA
consdata.com
  • Kontakt

    • sales@consdata.com
    • +48 61 41 51 000

  • Biuro

    • K9Office
      Krysiewicza 9/14
      61-825 Poznań
      Polska

  • Rozwiązania

    • Eximee
    • Kouncil
  • Blog Dołącz do nas
Copyright © 2024 Consdata. All rights reserved. Privacy Policy & Cookies
Chcemy używać plików cookie oraz skryptów podmiotów trzecich do polepszania funkcjonowania tej strony Zgadzam się