mscharhag, Programming and Stuff;

A blog about programming and software development topics, mostly focused on Java technologies including Java EE, Spring and Grails.

Monday, 1 November, 2021

Avoid leaking domain logic

Many software architectures try to separate domain logic from other parts of the application. To follow this practice we always need to know what actually is domain logic and what is not. Unfortunately this is not always that easy to separate. If we get this decision wrong, domain logic can easily leak into other components and layers.

We will go through this problem by looking at examples using a hexagonal application architecture. If you are not familiar with hexagonal architecture (also called ports and adapters architecture) you might be interested in the previous post about the transition from a traditional layered architecture to a hexagonal architecture.

Assume a shop system that publishes new orders to a messaging system (like Kafka). Our product owner now tells us that we have to listen for these order events and persist the corresponding order in the database.

Using hexagonal architecture the integration with a messaging system is implemented within an adapter. So, we start with a simple adapter implementation that listens for Kafka events:

@AllArgsConstructor
public class KafkaAdapter {
    private final SaveOrderUseCase saveOrderUseCase;

    @KafkaListener(topic = ...)
    public void onNewOrderEvent(NewOrderKafkaEvent event) {
        Order order = event.getOrder();
        saveOrderUseCase.saveOrder(order);
    }
}

In case you are not familiar with the @AllArgsConstructor annotation from project lombok: It generates a constructor which accepts each field (here saveOrderUseCase) as parameter.

The adapter delegates the saving of the order to a UseCase implementation.

UseCases are part of our domain core and implements domain logic, together with the domain model. Our simple example UseCase looks like this:

@AllArgsConstructor
public class SaveOrderUseCase {
    private final SaveOrderPort saveOrderPort;

    public void saveOrder(Order order) {
        saveOrderPort.saveOrder(order);
    }
}

Nothing special here. We simply use an outgoing Port interface to persist the passed order.

While the shown approach might work fine, we have a significant problem here: Our business logic has leaked into the Adapter implementation. Maybe you are wondering: what business logic?

We have a simple business rule to implement: Everytime a new order is retrieved it should be persisted. In our current implementation this rule is implemented by the adapter while our business layer (the UseCase) only provides a generic save operation.

Now assume, after some time, a new requirement arrives: Every time a new order is retrieved, a message should be written to an audit log.

With our current implementation we cannot write the audit log message within SaveOrderUseCase. As the name suggests the UseCase is for saving an order and not for retrieving a new order and therefore might be used by other components. So, adding the audit log message here might have undesired side-effects.

The solution is simple: We write the audit log message in our adapter:

@AllArgsConstructor
public class KafkaAdapter {

    private final SaveOrderUseCase saveOrderUseCase;
    private final AuditLog auditLog;

    @KafkaListener(topic = ...)
    public void onNewOrderEvent(NewOrderKafkaEvent event) {
        Order order = event.getOrder();
        saveOrderUseCase.saveOrder(order);
        auditLog.write("New order retrieved, id: " + order.getId());
    }
}

And now we have made it worse. Even more business logic has leaked into the adapter.

If the auditLog object writes messages into a database, we might also have screwed up transaction handling, which is usually not handled in an incoming adapter.

Using more specific domain operations

The core problem here is the generic SaveOrderUseCase. Instead of providing a generic save operation to adapters we should provide a more specific UseCase implementation.

For example, we can create a NewOrderRetrievedUseCase that accepts newly retrieved orders:

@AllArgsConstructor
public class NewOrderRetrievedUseCase {
    private final SaveOrderPort saveOrderPort;
    private final AuditLog auditLog;

    @Transactional
    public void onNewOrderRetrieved(Order newOrder) {
        saveOrderPort.saveOrder(order);
        auditLog.write("New order retrieved, id: " + order.getId());
    }
}

Now both business rules are implemented within the UseCase. Our adapter implementation is now simply responsible for mapping incoming data and passing it to the use case:

@AllArgsConstructor
public class KafkaAdapter {
    private final NewOrderRetrievedUseCase newOrderRetrievedUseCase;

    @KafkaListener(topic = ...)
    public void onNewOrderEvent(NewOrderKafkaEvent event) {
        NewOrder newOrder = event.toNewOrder();
        newOrderRetrievedUseCase.onNewOrderRetrieved(newOrder);
    }
}

This change only seems to be a small difference. However, for future requirements, we now have a specific location to handle incoming orders in our business layer. Otherwise, chances are high that with new requirements we leak more business logic into places where it should not be located.

Leaks like this happen especially often with too generic create, save/update and delete operations in the domain layer. So, try to be very specific when implementing business operations.

Leave a reply