Czy kiedykolwiek musiałeś zmienić bazę danych w projekcie i okazało się, że to miesiące pracy? Albo testowanie logiki biznesowej wymagało uruchomienia całej infrastruktury? Hexagonal Architecture rozwiązuje te problemy przez radykalną separację warstw.
Dlaczego Hexagonal Architecture jest kluczowa dla skalowalnych systemów?
W tradycyjnej architekturze warstwowej, logika biznesowa często przeplata się z detalami technicznymi. Kontrolery Spring zawierają reguły biznesowe, serwisy bezpośrednio używają JPA, a testy wymagają pełnej konfiguracji Spring Context. Hexagonal Architecture odwraca te zależności – to infrastruktura zależy od domeny, nie na odwrót.
Co się nauczysz:
- Jak oddzielić domenę od infrastruktury używając portów i adapterów
- Implementacja Hexagonal Architecture w Spring Boot z praktycznymi przykładami
- Testowanie logiki biznesowej w pełnej izolacji od frameworków
- Strategie migracji z architektury warstwowej do heksagonalnej
- Trade-offs i kiedy NIE używać Hexagonal Architecture
Wymagania wstępne:
- Dobra znajomość Spring Boot i dependency injection
- Rozumienie Domain-Driven Design (agregaty, value objects)
- Doświadczenie z testowaniem jednostkowym i integracyjnym
- Znajomość wzorców projektowych (szczególnie Adapter, Strategy)
Anatomia Hexagonal Architecture – komponenty i przepływ
Hexagonal Architecture składa się z trzech głównych elementów:
1. Domain (Core) – serce aplikacji
Domena zawiera całą logikę biznesową i jest kompletnie niezależna od jakichkolwiek frameworków czy bibliotek zewnętrznych. Używa tylko czystej Javy i własnych abstrakcji.
// Przykład agregatu w domenie public class Order { private OrderId id; private CustomerId customerId; private Listitems; private Money totalAmount; private OrderStatus status; public Order(CustomerId customerId) { this.id = OrderId.generate(); this.customerId = Objects.requireNonNull(customerId); this.items = new ArrayList<>(); this.status = OrderStatus.DRAFT; this.totalAmount = Money.ZERO; } public void addItem(Product product, Quantity quantity) { if (status != OrderStatus.DRAFT) { throw new OrderAlreadyPlacedException(id); } OrderItem item = new OrderItem(product, quantity); items.add(item); recalculateTotal(); } public void place() { if (items.isEmpty()) { throw new EmptyOrderException(); } if (totalAmount.isLessThan(Money.of(10, "PLN"))) { throw new MinimumOrderValueException(); } this.status = OrderStatus.PLACED; // Domain events mogą być emitowane tutaj } private void recalculateTotal() { this.totalAmount = items.stream() .map(OrderItem::calculatePrice) .reduce(Money.ZERO, Money::add); } }
2. Ports – kontrakty komunikacji
Porty to interfejsy definiujące jak domena komunikuje się ze światem zewnętrznym. Dzielimy je na:
Driven Ports (Secondary): Interfejsy których domena potrzebuje do swojego działania (np. repozytoria)
// Driving Port - Use Case public interface PlaceOrderUseCase { OrderId placeOrder(PlaceOrderCommand command); } // Command - część domeny public class PlaceOrderCommand { private final CustomerId customerId; private final Listitems; // konstruktor, gettery public static class OrderItemData { private final ProductId productId; private final int quantity; // konstruktor, gettery } } // Driven Port - Repository public interface OrderRepository { void save(Order order); Optional findById(OrderId id); List findByCustomerId(CustomerId customerId); } // Driven Port - External Service public interface PaymentService { PaymentResult processPayment(Money amount, CustomerId customerId); }
3. Adapters – implementacje techniczne
Adaptery implementują porty używając konkretnych technologii. Mogą to być kontrolery REST, repozytoria JPA, klienty HTTP itp.
// Driving Adapter - REST Controller @RestController @RequestMapping("/api/orders") public class OrderController { private final PlaceOrderUseCase placeOrderUseCase; @PostMapping public ResponseEntityplaceOrder(@RequestBody PlaceOrderRequest request) { // Mapowanie z DTO na Command PlaceOrderCommand command = toCommand(request); // Wywołanie use case OrderId orderId = placeOrderUseCase.placeOrder(command); // Mapowanie na response return ResponseEntity.ok(new OrderResponse(orderId.getValue())); } private PlaceOrderCommand toCommand(PlaceOrderRequest request) { List items = request.getItems().stream() .map(item -> new PlaceOrderCommand.OrderItemData( new ProductId(item.getProductId()), item.getQuantity() )) .collect(Collectors.toList()); return new PlaceOrderCommand( new CustomerId(request.getCustomerId()), items ); } } // Driven Adapter - JPA Repository @Repository public class JpaOrderRepository implements OrderRepository { private final JpaOrderEntityRepository jpaRepository; private final OrderMapper mapper; @Override public void save(Order order) { OrderEntity entity = mapper.toEntity(order); jpaRepository.save(entity); } @Override public Optional findById(OrderId id) { return jpaRepository.findById(id.getValue()) .map(mapper::toDomain); } } // JPA Entity - część infrastruktury, nie domeny! @Entity @Table(name = "orders") public class OrderEntity { @Id private String id; @Column(name = "customer_id") private String customerId; @Enumerated(EnumType.STRING) private String status; @Embedded private MoneyEntity totalAmount; @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER) @JoinColumn(name = "order_id") private List items; // gettery, settery }
Implementacja Use Case – serce aplikacji
Use case orchestruje logikę biznesową, używając portów do komunikacji z zewnętrznymi systemami:
@UseCase // Własna adnotacja, może być @Service public class PlaceOrderService implements PlaceOrderUseCase { private final OrderRepository orderRepository; private final ProductRepository productRepository; private final PaymentService paymentService; private final EventPublisher eventPublisher; @Override @Transactional public OrderId placeOrder(PlaceOrderCommand command) { // 1. Tworzenie nowego zamówienia Order order = new Order(command.getCustomerId()); // 2. Dodawanie produktów for (PlaceOrderCommand.OrderItemData itemData : command.getItems()) { Product product = productRepository.findById(itemData.getProductId()) .orElseThrow(() -> new ProductNotFoundException(itemData.getProductId())); order.addItem(product, new Quantity(itemData.getQuantity())); } // 3. Finalizacja zamówienia order.place(); // 4. Przetwarzanie płatności PaymentResult paymentResult = paymentService.processPayment( order.getTotalAmount(), order.getCustomerId() ); if (!paymentResult.isSuccessful()) { throw new PaymentFailedException(paymentResult.getReason()); } // 5. Zapisywanie zamówienia orderRepository.save(order); // 6. Publikowanie eventów eventPublisher.publish(new OrderPlacedEvent( order.getId(), order.getCustomerId(), order.getTotalAmount() )); return order.getId(); } }
Testowanie w izolacji – największa zaleta
Dzięki separacji warstw, możemy testować logikę biznesową bez żadnych zależności zewnętrznych:
class PlaceOrderServiceTest { // Mockujemy tylko porty, nie konkretne implementacje! private OrderRepository orderRepository = mock(OrderRepository.class); private ProductRepository productRepository = mock(ProductRepository.class); private PaymentService paymentService = mock(PaymentService.class); private EventPublisher eventPublisher = mock(EventPublisher.class); private PlaceOrderService service = new PlaceOrderService( orderRepository, productRepository, paymentService, eventPublisher ); @Test void shouldPlaceOrderSuccessfully() { // Given CustomerId customerId = new CustomerId("customer-123"); ProductId productId = new ProductId("product-456"); Product product = new Product(productId, "Laptop", Money.of(3000, "PLN")); PlaceOrderCommand command = new PlaceOrderCommand( customerId, List.of(new PlaceOrderCommand.OrderItemData(productId, 2)) ); when(productRepository.findById(productId)) .thenReturn(Optional.of(product)); when(paymentService.processPayment(any(), eq(customerId))) .thenReturn(PaymentResult.success()); // When OrderId orderId = service.placeOrder(command); // Then assertNotNull(orderId); // Weryfikujemy interakcje ArgumentCaptororderCaptor = ArgumentCaptor.forClass(Order.class); verify(orderRepository).save(orderCaptor.capture()); Order savedOrder = orderCaptor.getValue(); assertEquals(OrderStatus.PLACED, savedOrder.getStatus()); assertEquals(Money.of(6000, "PLN"), savedOrder.getTotalAmount()); verify(eventPublisher).publish(any(OrderPlacedEvent.class)); } @Test void shouldFailWhenPaymentFails() { // Given when(paymentService.processPayment(any(), any())) .thenReturn(PaymentResult.failure("Insufficient funds")); // When & Then assertThrows(PaymentFailedException.class, () -> service.placeOrder(createValidCommand()) ); // Zamówienie nie powinno być zapisane verify(orderRepository, never()).save(any()); verify(eventPublisher, never()).publish(any()); } } // Testy domeny - bez żadnych mocków! class OrderTest { @Test void shouldCalculateTotalCorrectly() { // Given Order order = new Order(new CustomerId("123")); Product laptop = new Product(new ProductId("1"), "Laptop", Money.of(3000, "PLN")); Product mouse = new Product(new ProductId("2"), "Mouse", Money.of(100, "PLN")); // When order.addItem(laptop, new Quantity(2)); order.addItem(mouse, new Quantity(3)); // Then assertEquals(Money.of(6300, "PLN"), order.getTotalAmount()); } @Test void shouldNotAllowToModifyPlacedOrder() { // Given Order order = new Order(new CustomerId("123")); order.addItem(createProduct(), new Quantity(1)); order.place(); // When & Then assertThrows(OrderAlreadyPlacedException.class, () -> order.addItem(createProduct(), new Quantity(1)) ); } }
Konfiguracja Spring Boot dla Hexagonal Architecture
Struktura pakietów powinna odzwierciedlać architekturę:
com.example.shop/ ├── domain/ # Czysta domena - zero zależności │ ├── model/ │ │ ├── Order.java │ │ ├── Product.java │ │ └── Customer.java │ ├── valueobject/ │ │ ├── OrderId.java │ │ ├── Money.java │ │ └── Quantity.java │ └── exception/ │ └── DomainException.java │ ├── application/ # Use cases i porty │ ├── port/ │ │ ├── in/ # Driving ports │ │ │ └── PlaceOrderUseCase.java │ │ └── out/ # Driven ports │ │ ├── OrderRepository.java │ │ └── PaymentService.java │ └── service/ │ └── PlaceOrderService.java │ ├── adapter/ # Implementacje techniczne │ ├── in/ │ │ └── web/ │ │ └── OrderController.java │ └── out/ │ ├── persistence/ │ │ ├── JpaOrderRepository.java │ │ └── entity/ │ │ └── OrderEntity.java │ └── payment/ │ └── StripePaymentService.java │ └── configuration/ # Konfiguracja Spring └── BeanConfiguration.java
Konfiguracja beanów Spring powinna być scentralizowana:
@Configuration public class BeanConfiguration { // Use cases @Bean public PlaceOrderUseCase placeOrderUseCase( OrderRepository orderRepository, ProductRepository productRepository, PaymentService paymentService, EventPublisher eventPublisher) { return new PlaceOrderService( orderRepository, productRepository, paymentService, eventPublisher ); } // Możemy też użyć @ComponentScan z filtrem @Bean public OrderRepository orderRepository( JpaOrderEntityRepository jpaRepository, OrderMapper mapper) { return new JpaOrderRepository(jpaRepository, mapper); } }
Migracja z architektury warstwowej
Migracja istniejącej aplikacji do Hexagonal Architecture to proces stopniowy:
Krok 1: Identyfikacja granic domeny
// Przed - logika rozproszona w serwisie @Service public class OrderService { @Autowired private OrderRepository repository; public void placeOrder(OrderDto dto) { OrderEntity entity = new OrderEntity(); entity.setStatus("DRAFT"); // Logika biznesowa w serwisie if (dto.getItems().isEmpty()) { throw new RuntimeException("Order cannot be empty"); } BigDecimal total = dto.getItems().stream() .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); if (total.compareTo(BigDecimal.TEN) < 0) { throw new RuntimeException("Minimum order value is 10"); } entity.setTotal(total); entity.setStatus("PLACED"); repository.save(entity); } } // Po - logika w agregacie public class Order { // Logika przeniesiona do metody place() pokazanej wcześniej }
Krok 2: Wydzielenie portów
Zacznij od interfejsów use case'ów jako kontraktów:
// Definiujemy port public interface PlaceOrderUseCase { OrderId placeOrder(PlaceOrderCommand command); } // Implementujemy w istniejącym serwisie @Service public class OrderService implements PlaceOrderUseCase { // Stopniowo refaktoryzujemy implementację }
Krok 3: Wprowadzenie adapterów
Kontrolery stają się adapterami:
// Przed @RestController public class OrderController { @Autowired private OrderService orderService; @PostMapping("/orders") public void createOrder(@RequestBody OrderDto dto) { orderService.placeOrder(dto); } } // Po @RestController public class OrderController { private final PlaceOrderUseCase placeOrderUseCase; public OrderController(PlaceOrderUseCase placeOrderUseCase) { this.placeOrderUseCase = placeOrderUseCase; } @PostMapping("/orders") public ResponseEntityplaceOrder(@RequestBody PlaceOrderRequest request) { PlaceOrderCommand command = mapToCommand(request); OrderId orderId = placeOrderUseCase.placeOrder(command); return ResponseEntity.ok(new OrderResponse(orderId.getValue())); } }
Performance considerations
Hexagonal Architecture wprowadza dodatkowe warstwy abstrakcji, co może wpłynąć na wydajność:
Potencjalne problemy wydajnościowe:
- Mapowanie obiektów: Konwersja między Entity ↔ Domain Model ↔ DTO
- Dodatkowe wywołania metod: Przez porty i adaptery
- Lazy loading: Trudniejsze gdy Entity != Domain Model
Strategie optymalizacji:
// 1. Query models dla odczytu (CQRS) public interface OrderQueryService { OrderDetailsDto findOrderDetails(String orderId); ListfindCustomerOrders(String customerId); } @Repository public class JpaOrderQueryService implements OrderQueryService { @Query("SELECT new com.example.OrderDetailsDto(...) FROM OrderEntity o WHERE o.id = :id") public OrderDetailsDto findOrderDetails(@Param("id") String orderId) { // Bezpośrednie mapowanie w zapytaniu - bez pośrednich obiektów } } // 2. Batch loading dla agregatów @Repository public class JpaOrderRepository implements OrderRepository { @Override public List findByIds(List ids) { // Jedno zapytanie z JOIN FETCH zamiast N+1 List entities = jpaRepository.findByIdInWithItems( ids.stream().map(OrderId::getValue).collect(toList()) ); return entities.stream() .map(mapper::toDomain) .collect(toList()); } } // 3. Cache na poziomie domeny public class CachedProductRepository implements ProductRepository { private final ProductRepository delegate; private final Cache cache; @Override public Optional findById(ProductId id) { return Optional.ofNullable( cache.get(id, () -> delegate.findById(id).orElse(null)) ); } }
Kiedy NIE używać Hexagonal Architecture
Hexagonal Architecture nie jest silver bullet. Oto scenariusze gdzie może być przesadą:
- CRUD-owa aplikacja: Jeśli 90% to proste operacje CRUD bez logiki biznesowej
- Prototyp/MVP: Gdy liczy się time-to-market, nie długoterminowa maintainability
- Mały zespół: Poniżej 3-4 developerów overhead może być zbyt duży
- Stabilna technologia: Gdy wiesz że NIGDY nie zmienisz bazy danych czy frameworka
Alternatywy do rozważenia:
- Clean Architecture: Podobna idea, ale mniej restrykcyjna
- Modular Monolith: Dobre granice modułów bez pełnej separacji
- Package by Feature: Prosta organizacja dla małych projektów
Real-world case study: System e-commerce
Zobaczmy jak Hexagonal Architecture sprawdza się w rzeczywistym systemie e-commerce:
Bounded Contexts:
// Catalog Context com.shop.catalog/ ├── domain/ │ ├── Product.java │ ├── Category.java │ └── PricingPolicy.java ├── application/ │ └── port/ │ ├── in/ │ │ ├── SearchProductsUseCase.java │ │ └── UpdatePriceUseCase.java │ └── out/ │ └── ProductRepository.java └── adapter/ ├── in/ │ └── web/ProductSearchController.java └── out/ └── elasticsearch/ElasticProductRepository.java // Order Context com.shop.order/ ├── domain/ │ ├── Order.java │ ├── OrderItem.java │ └── ShippingPolicy.java ├── application/ │ └── port/ │ ├── in/ │ │ └── PlaceOrderUseCase.java │ └── out/ │ ├── OrderRepository.java │ └── InventoryService.java └── adapter/ └── out/ └── rest/RestInventoryService.java // Payment Context com.shop.payment/ ├── domain/ │ ├── Payment.java │ └── PaymentMethod.java └── [...]
Komunikacja między kontekstami:
// Anti-Corruption Layer między kontekstami public class OrderPaymentAdapter implements PaymentService { private final PaymentClient paymentClient; // Klient do Payment Context @Override public PaymentResult processPayment(Money amount, CustomerId customerId) { // Translacja z domeny Order na API Payment ProcessPaymentRequest request = ProcessPaymentRequest.builder() .amount(amount.getAmount()) .currency(amount.getCurrency().getCode()) .customerId(customerId.getValue()) .build(); ProcessPaymentResponse response = paymentClient.process(request); // Translacja odpowiedzi na model domeny Order return response.isSuccess() ? PaymentResult.success() : PaymentResult.failure(response.getError()); } }
Monitoring i observability
W Hexagonal Architecture łatwo dodać cross-cutting concerns bez modyfikacji domeny:
// Decorator pattern dla metryk public class MonitoringOrderRepository implements OrderRepository { private final OrderRepository delegate; private final MeterRegistry meterRegistry; @Override public void save(Order order) { Timer.Sample sample = Timer.start(meterRegistry); try { delegate.save(order); meterRegistry.counter("order.saved", "status", "success").increment(); } catch (Exception e) { meterRegistry.counter("order.saved", "status", "failure").increment(); throw e; } finally { sample.stop(meterRegistry.timer("order.save.duration")); } } } // Aspekty dla logowania @Aspect @Component public class UseCaseLoggingAspect { private static final Logger log = LoggerFactory.getLogger(UseCaseLoggingAspect.class); @Around("@annotation(UseCase)") public Object logUseCase(ProceedingJoinPoint joinPoint) throws Throwable { String useCaseName = joinPoint.getSignature().toShortString(); MDC.put("useCase", useCaseName); log.info("Executing use case: {}", useCaseName); long start = System.currentTimeMillis(); try { Object result = joinPoint.proceed(); long duration = System.currentTimeMillis() - start; log.info("Use case {} completed in {} ms", useCaseName, duration); return result; } catch (Exception e) { log.error("Use case {} failed: {}", useCaseName, e.getMessage()); throw e; } finally { MDC.remove("useCase"); } } }
Podsumowanie i najlepsze praktyki
Hexagonal Architecture to potężne narzędzie do budowania skalowalnych i testowalnych aplikacji. Kluczowe wnioski:
- Zacznij od domeny - modeluj logikę biznesową bez myślenia o technologii
- Testuj domenę w izolacji - zero frameworków w testach jednostkowych
- Używaj Value Objects - enkapsuluj logikę i walidację
- Definiuj jasne granice - jeden bounded context = jeden heksagon
DON'T:
- Nie wstrzykuj frameworków do domeny - żadnych @Entity w agregatach
- Nie pomijaj mapowania - Entity != Domain Model
- Nie twórz anemicznych modeli - logika należy do domeny
- Nie przesadzaj z abstrakcją - czasem prosty CRUD wystarczy
Często zadawane pytania
Obie architektury opierają się na Dependency Inversion, ale różnią się szczegółami. Clean Architecture definiuje więcej warstw (Entities, Use Cases, Interface Adapters, Frameworks) i jest bardziej prescriptive. Hexagonal Architecture jest prostsza - ma tylko domenę, porty i adaptery. W praktyce można używać ich zamiennie.
Transakcje powinny być zarządzane na poziomie use case'ów. Używaj @Transactional na implementacji use case, nie na adapterach. Jeśli potrzebujesz distributed transactions, rozważ Saga pattern zamiast 2PC.
Nie! To narusza główną zasadę Hexagonal Architecture. Domena musi być niezależna od infrastruktury. Stwórz osobne Entity classes w warstwie persistence i mapuj między nimi a modelami domenowymi.
Testuj każdą warstwę osobno: unit testy dla domeny (bez mocków), unit testy dla use case'ów (mockując porty), integration testy dla adapterów (z rzeczywistą infrastrukturą lub testcontainers).
Tak, szczególnie gdy mikrousługi mają znaczącą logikę biznesową. Dla prostych CRUD microservices może być overkill. Hexagonal Architecture świetnie separuje bounded contexts w ramach jednego mikrousługi.
Masz kilka opcji: 1) Eager loading w repository, 2) Explicit loading methods (findOrderWithItems), 3) Query models dla read operations (CQRS), 4) Specification pattern do określania co załadować.
Walidacja biznesowa zawsze w domenie. Walidacja techniczna (format, długość pól) może być w adapterach. Używaj Value Objects do enkapsulacji walidacji - np. Email value object sprawdza poprawność adresu.
Przydatne zasoby
- Hexagonal Architecture - artykuł Alistaira Cockburna
- Buckpal - przykładowa aplikacja w Hexagonal Architecture
- ArchUnit - biblioteka do testowania architektury
- Bounded Context - Martin Fowler
🚀 Zadanie dla Ciebie
Zrefaktoruj istniejący serwis Spring Boot do Hexagonal Architecture:
- Wybierz prosty serwis z 3-4 metodami biznesowymi
- Wydziel logikę biznesową do klasy domenowej
- Stwórz interfejs use case jako port
- Przekształć kontroler w adapter
- Napisz testy jednostkowe dla domeny BEZ Spring context
Porównaj: ile kodu możesz przetestować bez uruchamiania Spring? Jak łatwo byłoby zmienić bazę danych?
Czy stosujesz Hexagonal Architecture w swoich projektach? Jakie wyzwania napotkałeś podczas implementacji? Podziel się doświadczeniami w komentarzach!