Loading content...
Ports define contracts. Adapters fulfill them. If ports are the USB specification, adapters are the actual USB cables and devices that implement that specification to connect real hardware.
In Hexagonal Architecture, adapters are the bridge between your pure, abstract domain and the messy, concrete reality of databases, web frameworks, message queues, and external services. They translate between the domain's language and the outside world's protocols.
Adapters are where you write boilerplate, where you handle infrastructure quirks, where you deal with serialization and network failures. Critically, adapters keep all of this complexity out of your domain, preserving the domain's purity and testability.
By the end of this page, you will understand how to implement both driving adapters (that invoke your application) and driven adapters (that your application invokes). You'll learn patterns for translating between domain objects and infrastructure representations, handling failures gracefully, and keeping adapter code clean and focused.
An Adapter is a component that connects a port to an external actor. It implements the translation between the domain's language (as defined by the port) and the external system's language (HTTP, SQL, AMQP, etc.).
Adapters come in two flavors:
Driving Adapters (Primary Adapters)
Driving adapters invoke input ports. They adapt external triggers (HTTP requests, CLI commands, GUI events) into calls on your application's use cases.
Examples:
SubmitOrderUseCaseGenerateReportUseCaseProcessPaymentUseCaseGetOrderDetailsQueryDriven Adapters (Secondary Adapters)
Driven adapters implement output ports. They adapt your domain's requirements into concrete infrastructure operations.
Examples:
OrderRepositoryPaymentGatewayNotificationServiceCacheServiceThink of 'driving' as 'drives the application' (the user drives it through a REST request). 'Driven' means 'driven by the application' (the database is driven by the app to store data). This clarifies which side initiates the interaction.
Driving adapters are the entry points to your application. They translate external requests into domain operations and translate domain responses back into external formats.
Responsibilities of a Driving Adapter:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
// ============================================// DRIVING ADAPTER: REST Controller// ============================================package com.example.infrastructure.adapters.input.rest; import org.springframework.web.bind.annotation.*;import org.springframework.http.*; @RestController@RequestMapping("/api/orders")public class OrderController { // Depends on INPUT PORT, not implementation private final SubmitOrderUseCase submitOrderUseCase; private final GetOrderDetailsQuery getOrderDetailsQuery; private final CancelOrderUseCase cancelOrderUseCase; public OrderController( SubmitOrderUseCase submitOrderUseCase, GetOrderDetailsQuery getOrderDetailsQuery, CancelOrderUseCase cancelOrderUseCase ) { this.submitOrderUseCase = submitOrderUseCase; this.getOrderDetailsQuery = getOrderDetailsQuery; this.cancelOrderUseCase = cancelOrderUseCase; } @PostMapping public ResponseEntity<SubmitOrderResponse> submitOrder( @RequestBody @Valid SubmitOrderRequest request, // External DTO @RequestHeader("X-Customer-Id") String customerId ) { // 1. Translate external DTO to domain command SubmitOrderCommand command = mapToCommand(request, customerId); // 2. Invoke input port (pure domain operation) SubmitOrderResult result = submitOrderUseCase.execute(command); // 3. Translate domain result to HTTP response return switch (result) { case SubmitOrderResult.Success success -> ResponseEntity.status(HttpStatus.CREATED) .body(new SubmitOrderResponse( success.orderId().value(), success.total().amount().toString(), success.delivery().toString() )); case SubmitOrderResult.ValidationFailed failed -> ResponseEntity.badRequest() .body(new SubmitOrderResponse(failed.errors())); case SubmitOrderResult.PaymentDeclined declined -> ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED) .body(new SubmitOrderResponse(declined.reason())); }; } @GetMapping("/{orderId}") public ResponseEntity<OrderDetailsResponse> getOrder( @PathVariable String orderId, @RequestHeader("X-Customer-Id") String customerId ) { var query = new GetOrderDetailsQuery.Query( new OrderId(orderId), new CustomerId(customerId) ); OrderDetailsResult result = getOrderDetailsQuery.execute(query); return switch (result) { case OrderDetailsResult.Found found -> ResponseEntity.ok(mapToResponse(found.details())); case OrderDetailsResult.NotFound notFound -> ResponseEntity.notFound().build(); case OrderDetailsResult.NotAuthorized notAuth -> ResponseEntity.status(HttpStatus.FORBIDDEN).build(); }; } @DeleteMapping("/{orderId}") public ResponseEntity<CancelOrderResponse> cancelOrder( @PathVariable String orderId, @RequestBody @Valid CancelOrderRequest request, @RequestHeader("X-Customer-Id") String customerId ) { var command = new CancelOrderCommand( new OrderId(orderId), request.reason(), new CustomerId(customerId) ); CancelOrderResult result = cancelOrderUseCase.execute(command); return switch (result) { case CancelOrderResult.Success success -> ResponseEntity.ok(new CancelOrderResponse(success.refund())); case CancelOrderResult.OrderNotFound notFound -> ResponseEntity.notFound().build(); case CancelOrderResult.OrderNotCancellable notCancellable -> ResponseEntity.status(HttpStatus.CONFLICT) .body(new CancelOrderResponse(notCancellable.reason())); case CancelOrderResult.NotAuthorized notAuth -> ResponseEntity.status(HttpStatus.FORBIDDEN).build(); }; } // -------------------------------------------------------- // Translation methods (DTO <-> Domain) // -------------------------------------------------------- private SubmitOrderCommand mapToCommand(SubmitOrderRequest request, String customerId) { return new SubmitOrderCommand( new CustomerId(customerId), request.items().stream() .map(item -> new LineItemData( new ProductId(item.productId()), item.quantity() )) .toList(), new ShippingAddress( request.shipping().street(), request.shipping().city(), request.shipping().postalCode() ), mapPaymentMethod(request.payment()) ); } private OrderDetailsResponse mapToResponse(OrderDetails details) { return new OrderDetailsResponse( details.orderId().value(), details.status().name(), details.total().amount().toString(), details.items().stream() .map(item -> new OrderItemResponse( item.productId().value(), item.productName(), item.quantity(), item.price().amount().toString() )) .toList() ); }} // External DTOs (part of the adapter, not domain)record SubmitOrderRequest( List<OrderItemRequest> items, ShippingAddressRequest shipping, PaymentRequest payment) {} record OrderItemRequest(String productId, int quantity) {}record ShippingAddressRequest(String street, String city, String postalCode) {}record PaymentRequest(String type, String cardNumber, String expiryDate) {}Notice that driving adapters import both domain types (OrderId, SubmitOrderCommand) and framework types (HTTP request/response, annotations). This is expected—adapters are the translation layer. The key is that the domain never imports framework types, only adapters do.
Driven adapters implement output ports. They translate between your domain's requirements and concrete infrastructure APIs.
Responsibilities of a Driven Adapter:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
// ============================================// DRIVEN ADAPTER: JPA Repository Implementation// ============================================package com.example.infrastructure.adapters.output.persistence; import org.springframework.data.jpa.repository.JpaRepository;import org.springframework.stereotype.Repository; @Repositorypublic class JpaOrderRepository implements OrderRepository { private final OrderJpaRepository jpaRepository; private final OrderMapper mapper; public JpaOrderRepository(OrderJpaRepository jpaRepository, OrderMapper mapper) { this.jpaRepository = jpaRepository; this.mapper = mapper; } @Override public Order save(Order order) { // 1. Translate domain entity to JPA entity OrderEntity entity = mapper.toEntity(order); // 2. Invoke JPA infrastructure OrderEntity savedEntity = jpaRepository.save(entity); // 3. Translate JPA entity back to domain return mapper.toDomain(savedEntity); } @Override public Optional<Order> findById(OrderId orderId) { return jpaRepository.findById(orderId.value()) .map(mapper::toDomain); } @Override public List<Order> findByCustomerId(CustomerId customerId) { return jpaRepository.findByCustomerId(customerId.value()) .stream() .map(mapper::toDomain) .toList(); } @Override public List<Order> findByStatus(OrderStatus status) { return jpaRepository.findByStatus(status.name()) .stream() .map(mapper::toDomain) .toList(); } @Override public boolean exists(OrderId orderId) { return jpaRepository.existsById(orderId.value()); } @Override public OrderId nextId() { return new OrderId(UUID.randomUUID().toString()); }} // JPA Entity (part of the adapter, not domain)@Entity@Table(name = "orders")class OrderEntity { @Id private String id; @Column(name = "customer_id", nullable = false) private String customerId; @Column(nullable = false) @Enumerated(EnumType.STRING) private String status; @Column(name = "total_amount", precision = 19, scale = 4) private BigDecimal totalAmount; @Column(name = "total_currency", length = 3) private String totalCurrency; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<LineItemEntity> lineItems = new ArrayList<>(); @Column(name = "created_at") private LocalDateTime createdAt; @Version private Long version; // Getters/setters...} // Spring Data JPA Repositoryinterface OrderJpaRepository extends JpaRepository<OrderEntity, String> { List<OrderEntity> findByCustomerId(String customerId); List<OrderEntity> findByStatus(String status);} // Mapper: Domain <-> JPA Entity@Componentclass OrderMapper { public OrderEntity toEntity(Order order) { OrderEntity entity = new OrderEntity(); entity.setId(order.getId().value()); entity.setCustomerId(order.getCustomerId().value()); entity.setStatus(order.getStatus().name()); entity.setTotalAmount(order.calculateTotal().amount()); entity.setTotalCurrency(order.calculateTotal().currency().code()); entity.setCreatedAt(order.getCreatedAt()); entity.setLineItems( order.getLineItems().stream() .map(this::toLineItemEntity) .toList() ); return entity; } public Order toDomain(OrderEntity entity) { Order order = new Order( new OrderId(entity.getId()), new CustomerId(entity.getCustomerId()) ); // Reconstruct order state from entity... entity.getLineItems().forEach(itemEntity -> { order.addLineItem( toProduct(itemEntity), new Quantity(itemEntity.getQuantity()) ); }); // Set status, etc. return order; } // Additional mapping methods...}Payment Gateway Adapter:
Let's see another driven adapter—this time for an external payment service:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
// ============================================// DRIVEN ADAPTER: Stripe Payment Implementation// ============================================package com.example.infrastructure.adapters.output.payment; import com.stripe.Stripe;import com.stripe.model.Charge;import com.stripe.exception.*; @Componentpublic class StripePaymentAdapter implements PaymentGateway { private final StripeClient stripeClient; public StripePaymentAdapter(StripeConfig config) { Stripe.apiKey = config.getApiKey(); this.stripeClient = new StripeClient(); } @Override public PaymentAuthorizationResult authorize(Money amount, PaymentMethod method) { try { // 1. Translate domain types to Stripe API types var chargeParams = Map.of( "amount", toStripeAmount(amount), "currency", amount.currency().code().toLowerCase(), "source", ((CardPaymentMethod) method).getToken(), "capture", false // Authorize only, don't capture yet ); // 2. Call Stripe API Charge charge = Charge.create(chargeParams); // 3. Translate Stripe response to domain result if (charge.getStatus().equals("succeeded")) { return new PaymentAuthorizationResult.Authorized( new AuthorizationId(charge.getId()), new TransactionId(charge.getBalanceTransaction()) ); } else { return new PaymentAuthorizationResult.Declined( "Payment not successful: " + charge.getStatus(), DeclineCode.UNKNOWN ); } } catch (CardException e) { // Card was declined return new PaymentAuthorizationResult.Declined( e.getMessage(), mapStripeDeclineCode(e.getDeclineCode()) ); } catch (RateLimitException e) { // Too many requests return new PaymentAuthorizationResult.ServiceUnavailable( "Payment service rate limited", Duration.ofSeconds(30) ); } catch (InvalidRequestException e) { // Invalid parameters return new PaymentAuthorizationResult.InvalidRequest( List.of(new ValidationError("payment", e.getMessage())) ); } catch (AuthenticationException | ApiConnectionException | ApiException e) { // Network/service issues return new PaymentAuthorizationResult.ServiceUnavailable( "Payment service unavailable: " + e.getMessage(), Duration.ofMinutes(1) ); } } @Override public PaymentCaptureResult capture(AuthorizationId authorizationId) { try { Charge charge = Charge.retrieve(authorizationId.value()); charge.capture(); return new PaymentCaptureResult.Success( new TransactionId(charge.getBalanceTransaction()) ); } catch (StripeException e) { return new PaymentCaptureResult.Failed(e.getMessage()); } } @Override public RefundResult refund(TransactionId transactionId, Money amount) { try { var refundParams = Map.of( "charge", transactionId.value(), "amount", toStripeAmount(amount) ); Refund refund = Refund.create(refundParams); return new RefundResult.Success( new RefundId(refund.getId()), amount ); } catch (StripeException e) { return new RefundResult.Failed(e.getMessage()); } } // -------------------------------------------------------- // Translation helpers // -------------------------------------------------------- private Long toStripeAmount(Money money) { // Stripe uses cents/smallest unit return money.amount().multiply(new BigDecimal("100")).longValue(); } private DeclineCode mapStripeDeclineCode(String stripeCode) { return switch (stripeCode) { case "insufficient_funds" -> DeclineCode.INSUFFICIENT_FUNDS; case "lost_card" -> DeclineCode.LOST_CARD; case "stolen_card" -> DeclineCode.STOLEN_CARD; case "expired_card" -> DeclineCode.EXPIRED_CARD; case "incorrect_cvc" -> DeclineCode.INCORRECT_CVC; default -> DeclineCode.UNKNOWN; }; }}A significant portion of adapter code involves translating between domain objects and external representations. Several patterns help manage this translation effectively.
Pattern 1: Dedicated Mapper Classes
Separate mapping logic into dedicated mapper classes. This keeps adapters focused on orchestration and mappers focused on transformation.
Pattern 2: Anti-Corruption Layer
When integrating with external systems that have their own complex models, create an Anti-Corruption Layer (ACL). This is a set of adapters and mappers that translate between the external model and your domain model, preventing "corruption" of your domain by external concepts.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
// ============================================// ANTI-CORRUPTION LAYER: Legacy ERP Integration// ============================================ /** * The legacy ERP system uses bizarre naming and structure. * We don't want these concepts leaking into our domain. */package com.example.infrastructure.adapters.output.legacy; // Legacy ERP client (provided by vendor)import com.legacyerp.api.*; public class LegacyInventoryAdapter implements InventoryService { private final LegacyErpClient erpClient; private final LegacyInventoryMapper mapper; @Override public InventoryCheckResult checkAvailability(List<LineItem> items) { // 1. Translate our domain items to legacy format List<LegacyMaterialRequest> erpRequests = items.stream() .map(item -> mapper.toLegacyMaterialRequest(item)) .toList(); // 2. Call legacy system with its bizarre API LegacyAvailabilityResponse erpResponse = erpClient.checkMtrlAvlblty(erpRequests); // Yes, that's really the method name // 3. Translate legacy response to our domain types return mapper.toInventoryCheckResult(erpResponse, items); } @Override public ReservationResult reserve(OrderId orderId, List<LineItem> items) { try { // Legacy uses "purchase order" concept for reservations LegacyPurchaseOrder legacyPO = new LegacyPurchaseOrder(); legacyPO.setPoNumber("RES-" + orderId.value()); legacyPO.setType("RESERVATION"); legacyPO.setMaterials( items.stream() .map(mapper::toLegacyMaterial) .toList() ); LegacyPoResponse response = erpClient.createPurchaseOrder(legacyPO); // Translate legacy response to our clean domain result return new ReservationResult.Reserved( new ReservationId(response.getPoNumber()), Instant.now().plus(Duration.ofMinutes(15)) // Our expiry, not legacy's ); } catch (LegacyMaterialNotAvailableException e) { return new ReservationResult.InsufficientInventory( mapper.toUnavailableProducts(e.getMaterials()) ); } } @Override public void release(ReservationId reservationId) { // Legacy system uses "cancel purchase order" for releases erpClient.cancelPurchaseOrder(reservationId.value()); }} /** * Mapper isolates all the legacy insanity */@Componentclass LegacyInventoryMapper { LegacyMaterialRequest toLegacyMaterialRequest(LineItem item) { LegacyMaterialRequest req = new LegacyMaterialRequest(); // Legacy uses "SKU_NUMBER" field for what we call ProductId req.setSkuNumber(item.getProduct().getId().value()); // Legacy uses "REQD_QTY" for quantity req.setReqdQty(item.getQuantity().value()); // Legacy requires plant code (we default to main warehouse) req.setPlantCode("MAIN"); return req; } InventoryCheckResult toInventoryCheckResult( LegacyAvailabilityResponse response, List<LineItem> originalItems ) { if (response.getStatus().equals("ALL_AVAILABLE")) { return new InventoryCheckResult.Available(); } // Map legacy unavailable materials back to our domain products List<ProductId> unavailable = response.getUnavailableMaterials() .stream() .map(m -> new ProductId(m.getSkuNumber())) .toList(); return new InventoryCheckResult.Unavailable(unavailable); } // ... more mapping methods}The Anti-Corruption Layer is particularly valuable when integrating with legacy systems, third-party APIs with poor design, or systems that might change. It creates a firewall that lets your domain stay clean regardless of external messiness.
Adapters must translate infrastructure errors into domain-meaningful results. This prevents low-level exceptions from leaking into domain code and provides meaningful error handling.
Error Handling Responsibilities:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
// Robust error handling in a driven adapterpublic class ResilientPaymentAdapter implements PaymentGateway { private final StripeClient stripeClient; private final Logger logger = LoggerFactory.getLogger(getClass()); private final RetryTemplate retryTemplate; @Override public PaymentAuthorizationResult authorize(Money amount, PaymentMethod method) { try { // Retry for transient failures return retryTemplate.execute(context -> { try { return attemptAuthorization(amount, method); } catch (RateLimitException e) { // Log and rethrow for retry logger.warn("Rate limited, attempt {}", context.getRetryCount()); throw e; } }); } catch (CardException e) { // User error - don't retry, return domain result logger.info("Card declined: {} (code: {})", e.getMessage(), e.getDeclineCode()); return new PaymentAuthorizationResult.Declined( friendlyMessage(e.getDeclineCode()), mapDeclineCode(e.getDeclineCode()) ); } catch (InvalidRequestException e) { // Programming error - log at error level logger.error("Invalid payment request: {}", e.getMessage(), e); return new PaymentAuthorizationResult.InvalidRequest( List.of(new ValidationError("payment", e.getMessage())) ); } catch (Exception e) { // Unexpected error - log full stack, return service unavailable logger.error("Unexpected payment error", e); return new PaymentAuthorizationResult.ServiceUnavailable( "Payment service temporarily unavailable", Duration.ofMinutes(1) ); } } private PaymentAuthorizationResult attemptAuthorization(Money amount, PaymentMethod method) throws StripeException { // Actual Stripe API call Charge charge = stripeClient.authorize(amount, method); return new PaymentAuthorizationResult.Authorized( new AuthorizationId(charge.getId()), new TransactionId(charge.getTransactionId()) ); } private String friendlyMessage(String declineCode) { return switch (declineCode) { case "insufficient_funds" -> "Your card has insufficient funds. Please try another payment method."; case "expired_card" -> "Your card has expired. Please update your payment method."; case "incorrect_cvc" -> "The security code (CVV/CVC) is incorrect. Please verify and try again."; default -> "Your card was declined. Please try another payment method."; }; }} // Repository adapter with error handlingpublic class ResilientOrderRepository implements OrderRepository { private final OrderJpaRepository jpaRepository; private final Logger logger = LoggerFactory.getLogger(getClass()); @Override public Order save(Order order) { try { OrderEntity entity = mapper.toEntity(order); OrderEntity saved = jpaRepository.save(entity); return mapper.toDomain(saved); } catch (DataIntegrityViolationException e) { // Unique constraint violation, concurrent modification, etc. logger.warn("Data integrity violation for order {}: {}", order.getId(), e.getMessage()); throw new OrderPersistenceException( "Could not save order due to data conflict", e ); } catch (OptimisticLockingFailureException e) { // Concurrent modification logger.warn("Optimistic lock failure for order {}", order.getId()); throw new ConcurrentModificationException( "Order was modified by another transaction", e ); } catch (DataAccessException e) { // Database connectivity issues logger.error("Database error saving order {}", order.getId(), e); throw new RepositoryUnavailableException( "Database temporarily unavailable", e ); } }}Error handling in adapters doesn't mean swallowing exceptions silently. It means translating them to appropriate domain-level results. Critical errors should still be logged, and some errors (like database unavailable) might need to propagate up for circuit breakers or fallback mechanisms.
Adapters require integration testing because they interact with real infrastructure. However, there are strategies to make these tests manageable and reliable.
Testing Driving Adapters:
Driving adapters (like REST controllers) should be tested with the real web framework but mock input ports:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// Testing a driving adapter (REST Controller)@WebMvcTest(OrderController.class)class OrderControllerTest { @Autowired private MockMvc mockMvc; @MockBean private SubmitOrderUseCase submitOrderUseCase; @MockBean private GetOrderDetailsQuery getOrderDetailsQuery; @Test void submitOrder_validRequest_returns201Created() throws Exception { // Arrange - mock the input port response when(submitOrderUseCase.execute(any(SubmitOrderCommand.class))) .thenReturn(new SubmitOrderResult.Success( new OrderId("O123"), Money.of(99.99), EstimatedDelivery.in(3, ChronoUnit.DAYS) )); // Act & Assert - test HTTP layer mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .header("X-Customer-Id", "C456") .content(""" { "items": [{"productId": "P1", "quantity": 2}], "shipping": {"street": "123 Main", "city": "NYC", "postalCode": "10001"}, "payment": {"type": "CARD", "cardNumber": "4111111111111111"} } """)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.orderId").value("O123")) .andExpect(jsonPath("$.total").value("99.99")); } @Test void submitOrder_paymentDeclined_returns402() throws Exception { when(submitOrderUseCase.execute(any())) .thenReturn(new SubmitOrderResult.PaymentDeclined("Insufficient funds")); mockMvc.perform(post("/api/orders") .contentType(MediaType.APPLICATION_JSON) .header("X-Customer-Id", "C456") .content(validOrderJson())) .andExpect(status().isPaymentRequired()) .andExpect(jsonPath("$.reason").value("Insufficient funds")); } @Test void getOrder_notFound_returns404() throws Exception { when(getOrderDetailsQuery.execute(any())) .thenReturn(new OrderDetailsResult.NotFound()); mockMvc.perform(get("/api/orders/nonexistent") .header("X-Customer-Id", "C456")) .andExpect(status().isNotFound()); }}Testing Driven Adapters:
Driven adapters (like repositories) need to test against real infrastructure. Use test containers or in-memory alternatives:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// Testing a driven adapter (Repository)@DataJpaTest@Testcontainersclass JpaOrderRepositoryIntegrationTest { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15"); @Autowired private OrderJpaRepository jpaRepository; private JpaOrderRepository repository; @BeforeEach void setUp() { repository = new JpaOrderRepository(jpaRepository, new OrderMapper()); } @Test void save_newOrder_persistsAllFields() { // Arrange - create domain order Order order = new Order(new OrderId("O123"), new CustomerId("C456")); order.addLineItem( new Product(new ProductId("P1"), "Widget", Money.of(10)), new Quantity(2) ); order.submit(); // Act Order saved = repository.save(order); // Assert - fetch directly and verify Order found = repository.findById(new OrderId("O123")).orElseThrow(); assertEquals(OrderStatus.SUBMITTED, found.getStatus()); assertEquals(1, found.getLineItems().size()); assertEquals(Money.of(20), found.calculateTotal()); } @Test void findByCustomerId_multipleOrders_returnsAll() { // Arrange CustomerId customerId = new CustomerId("C456"); repository.save(createOrder("O1", customerId)); repository.save(createOrder("O2", customerId)); repository.save(createOrder("O3", new CustomerId("OTHER"))); // Act List<Order> orders = repository.findByCustomerId(customerId); // Assert assertEquals(2, orders.size()); assertTrue(orders.stream().allMatch(o -> o.getCustomerId().equals(customerId) )); } @Test void save_existingOrder_updatesFields() { // Arrange Order order = createOrder("O123", new CustomerId("C456")); repository.save(order); // Act - modify and save again Order fetched = repository.findById(new OrderId("O123")).orElseThrow(); fetched.addLineItem( new Product(new ProductId("P2"), "Gadget", Money.of(25)), new Quantity(1) ); repository.save(fetched); // Assert Order updated = repository.findById(new OrderId("O123")).orElseThrow(); assertEquals(2, updated.getLineItems().size()); }}| Adapter Type | Test Strategy | Infrastructure |
|---|---|---|
| REST Controller | MockMvc + mock input ports | None (mocked) |
| GraphQL Resolver | GraphQL test client + mock ports | None (mocked) |
| Repository | Testcontainers | Real database in container |
| External API | WireMock / mock server | Simulated HTTP |
| Message Consumer | Embedded broker | In-memory broker |
We've explored Adapters—the concrete implementations that connect your domain to the real world. Let's consolidate the key concepts:
What's Next:
With Ports and Adapters explained, we've covered the structural elements of Hexagonal Architecture. But there's a crucial principle that makes it all work: Dependency Inversion. In the next page, we'll explore how inverting dependencies ensures that your domain remains pure while still integrating with real infrastructure. This is the "magic" that enables the architecture.
You now understand Adapters—the implementations that connect ports to real infrastructure. You know how to implement driving adapters (controllers, handlers) and driven adapters (repositories, gateways), how to handle translation and errors, and how to test adapters effectively. Next, we'll explore the dependency inversion principle that ties everything together.