Unit of Work Pattern – Zarządzanie Transakcjami w Aplikacjach Java

TL;DR: Unit of Work to wzorzec projektowy który grupuje operacje bazodanowe w jedną transakcję, śledzi zmiany w obiektach i automatyzuje proces commitowania. W ekosystemie Java najczęściej spotykamy go w Hibernate jako Session lub w Spring Data JPA jako EntityManager.

Czy kiedykolwiek zastanawiałeś się, jak aplikacje zarządzają setkami operacji bazodanowych w sposób spójny i wydajny? Unit of Work to wzorzec, który rozwiązuje ten problem poprzez inteligentne grupowanie zmian i optymalizację dostępu do bazy danych.

Dlaczego Unit of Work jest ważny

W współczesnych aplikacjach biznesowych często musimy wykonać wiele powiązanych operacji na bazie danych – dodać użytkownika, utworzyć jego profil, przypisać role i wysłać email powitalny. Bez odpowiedniego zarządzania transakcjami możemy skończyć z niespójnymi danymi lub problemami wydajnościowymi.

Co się nauczysz:

  • Jak działa wzorzec Unit of Work i dlaczego jest niezbędny w aplikacjach enterprise
  • Implementację wzorca w Hibernate i Spring Data JPA
  • Optymalizację wydajności poprzez batching i lazy loading
  • Zarządzanie transakcjami w aplikacjach wielowątkowych
  • Rozwiązywanie typowych problemów z dirty checking i cache’owaniem
Wymagania wstępne: Znajomość Java (2+ lata), podstawy JPA/Hibernate, rozumienie koncepcji transakcji bazodanowych, doświadczenie z Spring Framework.

Czym jest Unit of Work Pattern

Unit of Work to wzorzec projektowy opisany przez Martina Fowlera, który zarządza obiektami biznesowymi w kontekście pojedynczej transakcji biznesowej. Główne zadania wzorca to:

Unit of Work – wzorzec który śledzi wszystkie obiekty dotknięte przez transakcję biznesową, wykrywa konflikty i koordynuje zapisywanie zmian do bazy danych.

Kluczowe funkcjonalności

**Object Tracking:** Wzorzec śledzi wszystkie obiekty załadowane z bazy danych i wykrywa zmiany w ich stanie:

// Hibernate automatycznie śledzi zmiany
Session session = sessionFactory.getCurrentSession();
User user = session.get(User.class, 1L);
user.setEmail("new@email.com"); // Zmiana zostanie wykryta
// Hibernate automatycznie wygeneruje UPDATE podczas flush()

**Dirty Checking:** Mechanizm porównuje stan aktualny obiektów z ich stanem pierwotnym:

@Entity
public class Product {
    private String name;
    private BigDecimal price;
    
    // Hibernate porównuje wartości pól przy flush()
    // Generuje SQL tylko dla zmienionych kolumn
}

Zalety wzorca

Unit of Work eliminuje potrzebę ręcznego śledzenia zmian w obiektach i drastycznie upraszcza kod biznesowy. Zamiast wywoływać save() po każdej zmianie, wszystkie operacje są automatycznie zsynchronizowane z bazą danych.

Implementacja w Hibernate

Hibernate implementuje Unit of Work przez **Session** – kontekst pierwszego poziomu, który śledzi wszystkie encje:

Podstawowa konfiguracja

@Service
@Transactional
public class UserService {
    
    @Autowired
    private SessionFactory sessionFactory;
    
    public void updateUserProfile(Long userId, UserProfileDto profileDto) {
        Session session = sessionFactory.getCurrentSession();
        
        // Załadowanie obiektu - automatyczne śledzenie
        User user = session.get(User.class, userId);
        
        // Modyfikacje - dirty checking wykryje zmiany
        user.setFirstName(profileDto.getFirstName());
        user.setLastName(profileDto.getLastName());
        user.getProfile().setBio(profileDto.getBio());
        
        // Dodanie nowych obiektów
        if (profileDto.getNewSkills() != null) {
            profileDto.getNewSkills().forEach(skillName -> {
                Skill skill = new Skill(skillName);
                user.getSkills().add(skill);
                session.persist(skill); // Dodanie do Unit of Work
            });
        }
        
        // Commit automatyczny przy zakończeniu @Transactional
        // Hibernate wygeneruje wszystkie potrzebne SQL statements
    }
}

Cykl życia Session

@Configuration
@EnableTransactionManagement
public class HibernateConfig {
    
    @Bean
    public LocalSessionFactoryBean sessionFactory() {
        LocalSessionFactoryBean factory = new LocalSessionFactoryBean();
        factory.setDataSource(dataSource());
        factory.setPackagesToScan("com.example.domain");
        
        Properties props = new Properties();
        // Konfiguracja flush mode
        props.put("hibernate.flushMode", "COMMIT");
        // Batch size dla optymalizacji
        props.put("hibernate.jdbc.batch_size", "20");
        props.put("hibernate.order_inserts", "true");
        props.put("hibernate.order_updates", "true");
        
        factory.setHibernateProperties(props);
        return factory;
    }
}
Pro tip: Używaj hibernate.jdbc.batch_size i hibernate.order_inserts do grupowania operacji SQL. To może poprawić wydajność nawet o 300% przy masowych operacjach.

Spring Data JPA i EntityManager

Spring Data JPA ukrywa Unit of Work za prostszym API, ale mechanizm pozostaje ten sam:

@Service
@Transactional
public class OrderService {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Autowired
    private OrderRepository orderRepository;
    
    public Order processComplexOrder(OrderRequestDto request) {
        // Unit of Work śledzi wszystkie operacje w tej transakcji
        
        // 1. Utworzenie głównego zamówienia
        Order order = new Order();
        order.setCustomerId(request.getCustomerId());
        order.setOrderDate(LocalDateTime.now());
        
        // 2. Dodanie pozycji zamówienia
        for (OrderItemDto itemDto : request.getItems()) {
            OrderItem item = new OrderItem();
            item.setProductId(itemDto.getProductId());
            item.setQuantity(itemDto.getQuantity());
            item.setPrice(itemDto.getPrice());
            
            order.addItem(item); // Hibernate wykryje kaskadę
        }
        
        // 3. Aktualizacja stanu magazynowego
        updateInventory(request.getItems());
        
        // 4. Zapisanie zamówienia - Unit of Work zarządza kolejnością SQL
        Order savedOrder = orderRepository.save(order);
        
        // 5. Flush dla zapewnienia spójności przed dalszymi operacjami
        entityManager.flush();
        
        return savedOrder;
    }
    
    private void updateInventory(List items) {
        for (OrderItemDto item : items) {
            // EntityManager śledzi te zmiany również
            Product product = entityManager.find(Product.class, item.getProductId());
            product.setStockQuantity(
                product.getStockQuantity() - item.getQuantity()
            );
            // Automatyczny UPDATE przy commit
        }
    }
}

Optymalizacja wydajności

Uwaga: Unit of Work może prowadzić do N+1 problem jeśli nie zarządzamy lazy loading poprawnie. Zawsze używaj JOIN FETCH lub @EntityGraph dla powiązanych encji.
@Repository
public class UserRepository {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    // Złe podejście - N+1 problem
    public List findUsersWithOrdersBad() {
        return entityManager
            .createQuery("SELECT u FROM User u", User.class)
            .getResultList();
        // Każde wywołanie getOrders() = dodatkowe query
    }
    
    // Dobre podejście - fetch join
    public List findUsersWithOrdersGood() {
        return entityManager
            .createQuery("SELECT DISTINCT u FROM User u JOIN FETCH u.orders", User.class)
            .getResultList();
        // Jedna query z JOIN
    }
    
    // Najlepsze podejście - EntityGraph
    @EntityGraph(attributePaths = {"orders", "profile", "addresses"})
    public List findUsersWithCompleteData() {
        return entityManager
            .createQuery("SELECT u FROM User u", User.class)
            .setHint("javax.persistence.fetchgraph", 
                     entityManager.getEntityGraph("User.complete"))
            .getResultList();
    }
}

Zarządzanie transakcjami wielopoziomowymi

W złożonych aplikacjach często mamy zagnieżdżone transakcje. Unit of Work musi współpracować z menedżerem transakcji:

@Service
public class ECommerceService {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private PaymentService paymentService;
    
    @Autowired
    private NotificationService notificationService;
    
    @Transactional(rollbackFor = Exception.class)
    public OrderConfirmation processOrder(OrderRequest request) {
        try {
            // Każda z tych metod może mieć własną @Transactional
            Order order = orderService.createOrder(request);
            Payment payment = paymentService.processPayment(order);
            
            // Unit of Work śledzi wszystkie zmiany w tej transakcji
            order.setPaymentId(payment.getId());
            order.setStatus(OrderStatus.CONFIRMED);
            
            // To wykona się tylko jeśli wszystko powyżej się powiodło
            notificationService.sendConfirmation(order);
            
            return new OrderConfirmation(order.getId(), payment.getId());
        } catch (PaymentException e) {
            // Całość zostanie wycofana przez Unit of Work
            throw new OrderProcessingException("Payment failed", e);
        }
    }
}

@Service
public class PaymentService {
    
    // REQUIRES_NEW - nowa transakcja niezależnie od parent
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Payment processPayment(Order order) {
        // Ten Unit of Work jest niezależny od parent transaction
        Payment payment = new Payment();
        payment.setOrderId(order.getId());
        payment.setAmount(order.getTotalAmount());
        
        // Wywołanie external payment gateway
        ExternalPaymentResponse response = paymentGateway.charge(
            order.getTotalAmount(), 
            order.getCustomer().getPaymentMethod()
        );
        
        payment.setExternalId(response.getTransactionId());
        payment.setStatus(response.isSuccessful() ? 
            PaymentStatus.COMPLETED : PaymentStatus.FAILED);
        
        return paymentRepository.save(payment);
        // Commit niezależnie od parent transaction
    }
}

Testowanie Unit of Work

Testowanie aplikacji używających Unit of Work wymaga specjalnej uwagi na transakcje:

@SpringBootTest
@Transactional
@Rollback
class UserServiceTest {
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private TestEntityManager testEntityManager;
    
    @Test
    void shouldUpdateUserProfileInSingleTransaction() {
        // Given
        User user = new User("john@example.com");
        user.setProfile(new UserProfile());
        testEntityManager.persistAndFlush(user);
        Long userId = user.getId();
        
        UserProfileDto updateDto = new UserProfileDto();
        updateDto.setFirstName("John");
        updateDto.setLastName("Doe");
        updateDto.setBio("Software Developer");
        
        // When
        userService.updateUserProfile(userId, updateDto);
        
        // Then - Flush wymagany do sprawdzenia zmian
        testEntityManager.flush();
        
        User updatedUser = testEntityManager.find(User.class, userId);
        assertThat(updatedUser.getFirstName()).isEqualTo("John");
        assertThat(updatedUser.getLastName()).isEqualTo("Doe");
        assertThat(updatedUser.getProfile().getBio()).isEqualTo("Software Developer");
    }
    
    @Test
    void shouldHandleConstraintViolationProperly() {
        // Given
        User existingUser = new User("existing@example.com");
        testEntityManager.persistAndFlush(existingUser);
        
        // When & Then
        assertThatThrownBy(() -> {
            User duplicateUser = new User("existing@example.com");
            testEntityManager.persist(duplicateUser);
            testEntityManager.flush(); // Wymusza wywołanie SQL
        }).isInstanceOf(DataIntegrityViolationException.class);
    }
}
Pułapka: W testach Unit of Work może nie wywołać SQL do momentu flush(). Zawsze używaj testEntityManager.flush() jeśli chcesz przetestować ograniczenia bazodanowe.

Częste problemy i rozwiązania

LazyInitializationException

// Problem: Dostęp do lazy collection poza transakcją
@GetMapping("/users/{id}/orders")
public List getUserOrders(@PathVariable Long id) {
    User user = userService.findById(id); // Transakcja kończy się tutaj
    return user.getOrders().stream() // LazyInitializationException!
        .map(OrderDto::from)
        .collect(toList());
}

// Rozwiązanie 1: Fetch join w repository
@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
User findByIdWithOrders(@Param("id") Long id);

// Rozwiązanie 2: @Transactional na controller method
@GetMapping("/users/{id}/orders")
@Transactional(readOnly = true)
public List getUserOrders(@PathVariable Long id) {
    User user = userService.findById(id);
    return user.getOrders().stream()
        .map(OrderDto::from)
        .collect(toList());
}

Flush Timing Issues

@Service
@Transactional
public class SequenceService {
    
    public Order createOrderWithSequentialNumber() {
        Order order = new Order();
        
        // Problem: ID nie jest jeszcze wygenerowane
        // order.getId() zwróci null
        orderRepository.save(order);
        
        // Rozwiązanie: Wymuszenie flush
        entityManager.flush();
        
        // Teraz ID jest dostępne
        String orderNumber = "ORD-" + String.format("%06d", order.getId());
        order.setOrderNumber(orderNumber);
        
        return order;
    }
}
Czy mogę wyłączyć dirty checking dla lepszej wydajności?

Tak, możesz użyć @Immutable na encji lub session.setReadOnly(entity, true) dla konkretnych obiektów. To wyłącza dirty checking i może poprawić wydajność przy read-only operacjach.

Jak działa Unit of Work w aplikacjach reactive?

W Spring WebFlux z R2DBC nie ma klasycznego Unit of Work. Każda operacja jest natychmiast commitowana. Musisz używać programmatic transactions lub @Transactional z reactive streams.

Czy Unit of Work działa z multiple datasources?

Każdy EntityManager/Session może pracować tylko z jednym datasource. Dla multiple datasources potrzebujesz distributed transactions (JTA) lub saga pattern dla eventual consistency.

Jak monitoring Unit of Work w produkcji?

Użyj Hibernate statistics, Micrometer metrics i SQL logging. Monitoruj session duration, flush count, number of entities per session i query count per transaction.

Czy można customizować dirty checking logic?

Tak, możesz implementować Interceptor lub EventListener w Hibernate. Możesz również użyć @DynamicUpdate żeby generować UPDATE tylko dla zmienionych kolumn.

Jak Unit of Work radzi sobie z concurrent modifications?

Unit of Work sam w sobie nie rozwiązuje concurrency. Musisz używać optimistic locking (@Version), pessimistic locking lub application-level synchronization.

Czy warto implementować własny Unit of Work?

Raczej nie. Hibernate i JPA mają bardzo dojrzałe implementacje. Własny Unit of Work ma sens tylko w bardzo specyficznych scenariuszach lub przy custom persistence layers.

Przydatne zasoby:

🚀 Zadanie dla Ciebie

Stwórz serwis e-commerce który w jednej transakcji: tworzy zamówienie, aktualizuje stan magazynowy, aplikuje rabat oraz generuje fakturę. Zastanów się nad optymalizacją wydajności (batch operations) i zarządzaniem błędami (co zrobić jeśli tylko jedna operacja się nie powiedzie?). Dodaj testy które weryfikują że wszystkie operacje są atomowe.

Jak radzisz sobie z zarządzaniem transakcjami w swoich projektach? Czy spotkałeś się z problemami wydajnościowymi związanymi z Unit of Work?

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry