Skip to main content

๐Ÿ”„ Interface Adapters Layer in Clean Architecture

1. Overview and Purposeโ€‹

Definitionโ€‹

The Interface Adapters layer converts data between the formats most convenient for use cases/entities and the formats most convenient for external agencies (UI, databases, external services).

Key Responsibilitiesโ€‹

  • Data format conversion
  • External interface implementation
  • Framework integration
  • UI/Database adaptation
  • External service integration
  • Request/Response handling

Business Valueโ€‹

  • Clean separation of concerns
  • Framework independence
  • Enhanced maintainability
  • Simplified testing
  • Easy technology migration
  • Consistent interfaces

2. ๐Ÿ—๏ธ Core Componentsโ€‹

3. ๐Ÿ’ป Implementation Examplesโ€‹

Controllersโ€‹

// Controller Interface
public interface OrderController {
ResponseEntity<OrderResponse> createOrder(OrderRequest request);
ResponseEntity<OrderResponse> getOrder(String orderId);
ResponseEntity<List<OrderResponse>> getOrders(OrderSearchCriteria criteria);
}

// REST Controller Implementation
@RestController
@RequestMapping("/api/orders")
public class OrderRestController implements OrderController {
private final CreateOrderUseCase createOrderUseCase;
private final GetOrderUseCase getOrderUseCase;
private final SearchOrdersUseCase searchOrdersUseCase;
private final OrderPresenter presenter;

@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
try {
CreateOrderCommand command = mapToCommand(request);
OrderId orderId = createOrderUseCase.execute(command);
Order order = getOrderUseCase.execute(orderId);
return ResponseEntity.ok(presenter.present(order));
} catch (ValidationException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
e.getMessage()
);
}
}

private CreateOrderCommand mapToCommand(OrderRequest request) {
return new CreateOrderCommand(
new CustomerId(request.getCustomerId()),
request.getItems().stream()
.map(this::mapToOrderItem)
.collect(Collectors.toList())
);
}
}

Presentersโ€‹

// Presenter Interface
public interface OrderPresenter {
OrderResponse present(Order order);
OrderListResponse presentList(List<Order> orders);
ErrorResponse presentError(Exception error);
}

// REST Presenter Implementation
@Component
public class OrderRestPresenter implements OrderPresenter {
private final MessageSource messageSource;

@Override
public OrderResponse present(Order order) {
return new OrderResponse(
order.getId().toString(),
formatCustomerInfo(order.getCustomer()),
mapOrderItems(order.getItems()),
formatMoney(order.getTotal()),
formatStatus(order.getStatus()),
formatDateTime(order.getCreatedAt())
);
}

@Override
public OrderListResponse presentList(List<Order> orders) {
List<OrderResponse> orderResponses = orders.stream()
.map(this::present)
.collect(Collectors.toList());

return new OrderListResponse(
orderResponses,
orders.size(),
calculateTotalValue(orders)
);
}

@Override
public ErrorResponse presentError(Exception error) {
return new ErrorResponse(
translateErrorCode(error),
getLocalizedMessage(error),
getErrorDetails(error)
);
}

private String formatMoney(Money money) {
return NumberFormat.getCurrencyInstance()
.format(money.amount());
}

private String formatDateTime(Instant dateTime) {
return DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
.format(dateTime.atZone(ZoneId.systemDefault()));
}
}

Repository Adaptersโ€‹

// Repository Implementation
@Repository
public class JpaOrderRepository implements OrderRepository {
private final JpaOrderEntityRepository jpaRepository;
private final OrderMapper mapper;

@Override
public Order save(Order order) {
OrderEntity entity = mapper.toEntity(order);
OrderEntity savedEntity = jpaRepository.save(entity);
return mapper.toDomain(savedEntity);
}

@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.toString())
.map(mapper::toDomain);
}

@Override
public List<Order> findByCustomer(CustomerId customerId) {
return jpaRepository.findByCustomerId(customerId.toString())
.stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
}
}

// Entity Mapper
@Component
public class OrderMapper {
public OrderEntity toEntity(Order order) {
OrderEntity entity = new OrderEntity();
entity.setId(order.getId().toString());
entity.setCustomerId(order.getCustomerId().toString());
entity.setStatus(order.getStatus().name());
entity.setTotal(order.getTotal().amount());
entity.setCurrency(order.getTotal().currency().getCurrencyCode());
entity.setCreatedAt(order.getCreatedAt());
entity.setItems(mapItems(order.getItems()));
return entity;
}

public Order toDomain(OrderEntity entity) {
return Order.reconstitute(
new OrderId(entity.getId()),
new CustomerId(entity.getCustomerId()),
mapToDomainItems(entity.getItems()),
OrderStatus.valueOf(entity.getStatus()),
Money.of(entity.getTotal(), Currency.getInstance(entity.getCurrency())),
entity.getCreatedAt()
);
}
}

Gateway Adaptersโ€‹

// Payment Gateway Implementation
@Component
public class StripePaymentGateway implements PaymentGateway {
private final StripeClient stripeClient;
private final PaymentMapper mapper;

@Override
public PaymentResult processPayment(Payment payment) {
try {
StripePaymentIntent intent = stripeClient.paymentIntents().create(
PaymentIntentCreateParams.builder()
.setAmount(payment.getAmount().longValue())
.setCurrency(payment.getCurrency().getCode())
.setPaymentMethod(payment.getPaymentMethodId())
.build()
);

return mapper.toResult(intent);
} catch (StripeException e) {
throw translateException(e);
}
}

private PaymentException translateException(StripeException e) {
return switch (e) {
case CardException ce -> new PaymentDeclinedException(ce.getMessage());
case RateLimitException re -> new PaymentGatewayException("Rate limit exceeded");
case InvalidRequestException ire -> new InvalidPaymentException(ire.getMessage());
default -> new PaymentGatewayException("Payment processing failed");
};
}
}

4. ๐Ÿ”„ Data Flow & Transformationโ€‹

Request/Response Flowโ€‹

// Input Data Transformation
@Component
public class OrderRequestMapper {
public CreateOrderCommand toCommand(OrderRequest request) {
return new CreateOrderCommand(
mapCustomerId(request.getCustomerId()),
mapOrderItems(request.getItems()),
mapPaymentInfo(request.getPayment())
);
}

private List<OrderItemCommand> mapOrderItems(List<OrderItemRequest> items) {
return items.stream()
.map(item -> new OrderItemCommand(
new ProductId(item.getProductId()),
item.getQuantity(),
Money.of(item.getPrice(), Currency.getInstance(item.getCurrency()))
))
.collect(Collectors.toList());
}
}

// Output Data Transformation
@Component
public class OrderResponseMapper {
public OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId().toString(),
mapCustomerInfo(order.getCustomer()),
mapOrderItems(order.getItems()),
mapMoney(order.getTotal()),
order.getStatus().toString(),
formatDateTime(order.getCreatedAt())
);
}

private List<OrderItemResponse> mapOrderItems(List<OrderItem> items) {
return items.stream()
.map(this::mapOrderItem)
.collect(Collectors.toList());
}
}

5. ๐Ÿงช Testing Strategiesโ€‹

Controller Testsโ€‹

@WebMvcTest(OrderRestController.class)
public class OrderControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private CreateOrderUseCase createOrderUseCase;
@MockBean private OrderPresenter presenter;

@Test
void shouldCreateOrder() throws Exception {
// Arrange
OrderRequest request = createTestRequest();
OrderResponse response = createTestResponse();

when(createOrderUseCase.execute(any()))
.thenReturn(new OrderId("test-id"));
when(presenter.present(any()))
.thenReturn(response);

// Act & Assert
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(asJson(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderId").value("test-id"));
}
}

Presenter Testsโ€‹

public class OrderPresenterTest {
private OrderPresenter presenter;

@Test
void shouldPresentOrderSuccessfully() {
// Arrange
Order order = createTestOrder();

// Act
OrderResponse response = presenter.present(order);

// Assert
assertEquals("$99.99", response.getTotal());
assertEquals("March 15, 2024", response.getOrderDate());
assertEquals("PENDING", response.getStatus());
}

@Test
void shouldPresentErrorAppropriately() {
// Arrange
ValidationException error = new ValidationException("Invalid order");

// Act
ErrorResponse response = presenter.presentError(error);

// Assert
assertEquals("VALIDATION_ERROR", response.getCode());
assertEquals("Invalid order", response.getMessage());
}
}

6. ๐ŸŽฏ Best Practicesโ€‹

1. Clean Separationโ€‹

// Good: Clear separation of concerns
public class OrderController {
private final CreateOrderUseCase createOrderUseCase;
private final OrderRequestMapper requestMapper;
private final OrderPresenter presenter;

public ResponseEntity<OrderResponse> createOrder(OrderRequest request) {
CreateOrderCommand command = requestMapper.toCommand(request);
Order order = createOrderUseCase.execute(command);
return ResponseEntity.ok(presenter.present(order));
}
}

// Bad: Mixed concerns
public class OrderController {
private final OrderRepository repository;

public ResponseEntity<OrderResponse> createOrder(OrderRequest request) {
// Business logic in controller
Order order = new Order(request.getCustomerId());
order.addItems(request.getItems());
order.validate();

// Direct repository access
repository.save(order);

// Manual response mapping
return ResponseEntity.ok(new OrderResponse(/*...*/));
}
}

2. Framework Isolationโ€‹

// Good: Framework-agnostic interface
public interface OrderPresenter {
OrderResponse present(Order order);
ErrorResponse presentError(Exception error);
}

// Bad: Framework-specific interface
public interface OrderPresenter {
@ResponseBody
ResponseEntity<OrderDTO> present(Order order);

@ExceptionHandler
ResponseEntity<ErrorDTO> presentError(Exception error);
}

7. ๐Ÿšซ Anti-patternsโ€‹

Common Mistakes to Avoidโ€‹

  1. Leaking Domain Logic
// Wrong: Domain logic in controller
@RestController
public class OrderController {
public ResponseEntity<OrderResponse> createOrder(OrderRequest request) {
if (request.getTotal().compareTo(BigDecimal.ZERO) <= 0) {
throw new ValidationException("Invalid total");
}
// More business rules...
}
}

// Better: Domain logic in entities/use cases
public class Order {
private void validateTotal() {
if (total.isLessThanOrEqualToZero()) {
throw new InvalidOrderException("Invalid total");
}
}
}
  1. Direct Domain Object Exposure
// Wrong: Exposing domain objects
@RestController
public class OrderController {
@GetMapping("/{id}")
public Order getOrder(@PathVariable String id) {
return orderRepository.findById(id);
}
}

// Better: Using DTOs
@RestController
public class OrderController {
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable String id) {
Order order = orderRepository.findById(id);
return presenter.present(order);
}
}

8. ๐Ÿ“š Referencesโ€‹

Booksโ€‹

  • "Clean Architecture" by Robert C. Martin
  • "Implementing Domain-Driven Design" by Vaughn Vernon
  • "Building Evolutionary Architectures" by Neal Ford, Rebecca Parsons, and Patrick Kua

Articlesโ€‹