Hexagonal Architecture w praktyce

TL;DR: Hexagonal Architecture (znana też jako Ports and Adapters) pozwala budować aplikacje niezależne od zewnętrznych technologii. Dzięki separacji logiki biznesowej od infrastruktury, możesz łatwo testować kod w izolacji i wymieniać komponenty bez modyfikacji core’u aplikacji. W tym artykule pokażę praktyczną implementację w Spring Boot.

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 List items;
    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:

Driving Ports (Primary): Interfejsy używane przez warstwy zewnętrzne do komunikacji z domeną (np. use case’y)

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 List items;
    
    // 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 ResponseEntity placeOrder(@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();
    }
}
Pro tip: Use case powinien być cienką warstwą orchestracji. Jeśli zawiera dużo logiki biznesowej, prawdopodobnie należy ją przenieść do agregatów lub domain services.

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
        ArgumentCaptor orderCaptor = 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);
    }
}
Uwaga: Pakiety domeny NIE powinny zawierać żadnych adnotacji Spring! Domena musi być niezależna od frameworka. Wyjątkiem mogą być adnotacje walidacji (javax.validation), ale to też jest dyskusyjne.

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 ResponseEntity placeOrder(@RequestBody PlaceOrderRequest request) {
        PlaceOrderCommand command = mapToCommand(request);
        OrderId orderId = placeOrderUseCase.placeOrder(command);
        return ResponseEntity.ok(new OrderResponse(orderId.getValue()));
    }
}
Pułapka: Nie próbuj migrować wszystkiego naraz! Wybierz jeden bounded context i przeprowadź pełną migrację. Dopiero potem przejdź do następnego. Mieszanie architektur w jednym kontekście prowadzi do chaosu.

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);
    List findCustomerOrders(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ą:

Nie używaj gdy:

  • 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:

DO:

  • 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

Czym różni się Hexagonal Architecture od Clean Architecture?

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.

Jak radzić sobie z transakcjami w Hexagonal Architecture?

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.

Czy mogę używać adnotacji JPA w domenie?

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.

Jak organizować testy w Hexagonal Architecture?

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).

Czy warto używać Hexagonal Architecture w mikrousługach?

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.

Jak radzić sobie z lazy loading gdy Entity != Domain Model?

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ć.

Gdzie umieścić walidację - w domenie czy adapterach?

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.

Następne kroki w nauce:

Przydatne zasoby

🚀 Zadanie dla Ciebie

Zrefaktoruj istniejący serwis Spring Boot do Hexagonal Architecture:

  1. Wybierz prosty serwis z 3-4 metodami biznesowymi
  2. Wydziel logikę biznesową do klasy domenowej
  3. Stwórz interfejs use case jako port
  4. Przekształć kontroler w adapter
  5. 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!

Zostaw komentarz

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

Przewijanie do góry