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
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:
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
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; } }
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(Listitems) { 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
@Repository public class UserRepository { @PersistenceContext private EntityManager entityManager; // Złe podejście - N+1 problem public ListfindUsersWithOrdersBad() { 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); } }
Częste problemy i rozwiązania
LazyInitializationException
// Problem: Dostęp do lazy collection poza transakcją @GetMapping("/users/{id}/orders") public ListgetUserOrders(@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; } }
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.
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.
Każdy EntityManager/Session może pracować tylko z jednym datasource. Dla multiple datasources potrzebujesz distributed transactions (JTA) lub saga pattern dla eventual consistency.
Użyj Hibernate statistics, Micrometer metrics i SQL logging. Monitoruj session duration, flush count, number of entities per session i query count per transaction.
Tak, możesz implementować Interceptor lub EventListener w Hibernate. Możesz również użyć @DynamicUpdate żeby generować UPDATE tylko dla zmienionych kolumn.
Unit of Work sam w sobie nie rozwiązuje concurrency. Musisz używać optimistic locking (@Version), pessimistic locking lub application-level synchronization.
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:
- Hibernate 5.4 User Guide
- Spring Data JPA Reference
- Martin Fowler – Unit of Work Pattern
- JPA 2.2 Specification
- Vlad Mihalcea – Hibernate Performance Tips
🚀 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?