What is the exact benefits on using Ports/Adapters in hexagonal architecture?

149 Views Asked by At

Lets say we have a typical implementation of the ports/adapter pattern:

@RestController
private class OrderController {

    private final CreateOrderPort createOrderPort;
    
    @PostMapping
    void createOrder(String customerId) {
        createOrderPort.createOrder(customerId);
    }
}


private class KafkaConsumer {

    private final CreateOrderPort createOrderPort;
    
    @PostMapping
    public void createOrder(CreateOrderEvent event) {
        createOrderPort.createOrder(event.getCustomerId());
    }
}

public interface CreateOrderPort {
    void createOrder(String customerId);
}

@Service
public class CreateOrderService implements CreateOrderPort {
    public void createOrder(String customerId){
        orderRepository.save(new Order(customerId));
    }
}

I am just wandering what is hier the exact benefit of having just an interface? What would be the difference if the Kafka consumer and the Controller use directly the CreateOrderService?

@RestController
private class OrderController {

    private final CreateOrderService createOrderService;
    
    @PostMapping
    void createOrder(String customerId) {
        createOrderService.createOrder(customerId);
    }
}


private class KafkaConsumer {

    private final CreateOrderService createOrderService;
    
    @PostMapping
    public void createOrder(CreateOrderEvent event) {
        createOrderService.createOrder(event.getCustomerId());
    }
}

@Service
public class CreateOrderService{
    public void createOrder(String customerId){
        orderRepository.save(new Order(customerId));
    }
}

We would still have the separation between the technical details (adapter or boundary) and the business details (core or domain). And if somethings change in the service that wouldn have any affect in the technical layer.

1

There are 1 best solutions below

0
René Link On

I am just wandering what is hier the exact benefit of having just an interface? What would be the difference if the Kafka consumer and the Controller use directly the CreateOrderService?

You can test it more easily, because you need less effort to create a mock.

class OrderControllerTest {

    private String customerId;

    @Test
    void createOrder() {
        CreateOrderPort createOrderPortMock = customerId -> OrderControllerTest.this.customerId = customerId;
        OrderController orderController = new OrderController(createOrderPortMock);

        orderController.createOrder("1234");

        assertEquals("1234", customerId);
    }
}

In a more complex scenario you might want to create a mock class that implements the input port or you might want to create a dedicated class for better readability.

class OrderControllerTest {

    class CreateOrderPortMock implements CreateOrderPort {

        private String customerId;

        @Override
        public void createOrder(String customerId) {
            this.customerId = customerId;
        }

        public void assertCreateOrderInvoked(String expectedCustomerId){
            assertEquals(expectedCustomerId, this.customerId);
        }
    }


    @Test
    void createOrder() {
        CreateOrderPortMock createOrderPortMock = new CreateOrderPortMock();
        OrderController orderController = new OrderController(createOrderPortMock);

        orderController.createOrder("1234");

        createOrderPortMock.assertCreateOrderInvoked("1234");
    }
}

Sure, one might argue that you can also just extend the real implementation and override the method. But this might be tricky, because of constructor args that the real implementation needs in order to be created. When you use the real implementation you have to deal with the real implementations dependencies.

public class CreateOrderService implements CreateOrderPort {

    private OrderRepository orderRepository;

    public CreateOrderService(OrderRepository orderRepository) {
        this.orderRepository = Objects.requireNonNull(orderRepository);
    }

    public void createOrder(String customerId){
        orderRepository.save(new Order(customerId));
    }
}

If the real implementation does checks in the constructor or executes some logic, you need to satisfy all these preconditions in order to create a mock just to override the createOrder method. If you do so your test is bound to all of those preconditions. This means that it will break even if it has nothing to do with the things it made it break. E.g. if someone changes the constructor logic your "create order controller" test would break.

Finally, if you use an interface you don't need fancy mocking frameworks that use cglib proxies or anything else to just mock the simple input port. So you have less dependencies and less trouble if some libraries (and transitive dependencies) are updated.

I usually try to introduce as less dependencies as necessary. Even if my project uses Mockito I do not use Mockito in all tests. Some tests don't need Mockito and if they don't really need it I don't see the need to use it. Some might argue that you should just use it if you already depend on it, but if an api changes I have less code to update. Think about the migration from Junit4 to JUnit5 or javax.persistence that is now jakarta.persistence. APIs will change for whatever reasons and we should be prepared.