Knee-deep in Spring Boot, Transactional Event Listeners and CGLIB proxies

peholmst

Petter Holmström

Posted on May 7, 2021

Knee-deep in Spring Boot, Transactional Event Listeners and CGLIB proxies

A colleague and I spent two hours tracking down a strange bug in our Spring Boot application today. The cause was so interesting that I have to write about it here. Since I obviously can't write about customer projects here, I'm presenting the problem using a sample application instead. So here we go.

Let's say we have a domain-driven application with two aggregates: Order and Invoice. We also have an orchestrator that automatically should create new invoices once an order transitions into the SHIPPED state.

We start with the following application service for creating new orders:

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public Long createOrder() {
        var order = new Order();
        return orderRepository.saveAndFlush(order).getId();
    }

    @Transactional
    public void shipOrder(Long orderId) {
        orderRepository.findById(orderId).ifPresent(order -> {
            order.ship();
            orderRepository.saveAndFlush(order);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

The Order.ship() method will change the state of the order to SHIPPED and publish an OrderStateChangedEvent.

Then we need another application service for creating new invoices:

@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;

    InvoiceService(InvoiceRepository invoiceRepository) {
        this.invoiceRepository = invoiceRepository;
    }

    @Transactional
    Long createInvoiceForOrder(Order order) {
        var invoice = new Invoice(order);
        return invoiceRepository.saveAndFlush(invoice).getId();
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need an orchestrator that creates the invoice when the order is shipped:

@Component
class InvoiceCreationOrchestrator {

    private final OrderRepository orderRepository;
    private final InvoiceService invoiceService;

    InvoiceCreationOrchestrator(OrderRepository orderRepository, InvoiceService invoiceService) {
        this.orderRepository = orderRepository;
        this.invoiceService = invoiceService;
    }

    @TransactionalEventListener
    public void onOrderStateChangedEvent(OrderStateChangedEvent event) {
        if (event.getNewOrderState().equals(OrderState.SHIPPED)) {
            orderRepository
                .findById(event.getOrderId())
                .ifPresent(invoiceService::createInvoiceForOrder);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

There! Now we just run the application, create a new order, ship it and... no invoice gets created. There are no exceptions in the log either. So what went wrong?

It turns out the problem is in the @TransactionalEventListener. It is by default configured to run after the transaction has been committed. This is exactly what we want, but there is a caveat in how Spring actually implements this.

Domain events are published using the ordinary application event publisher. Spring will actually catch them using an ordinary @EventListener as well. However, instead of invoking the transactional event listener directly, Spring will register a TransactionSynchronization with the TransactionSynchronizationManager. This will invoke the transactional event listener after the transaction has successfully committed, but before the transaction synchronization manager has cleaned itself up.

Now, our event listener is invoking the createInvoiceForOrder method, which has the @Transactional annotation. The default propagation for @Transactional is REQUIRED. This means that if there already is an active transaction, the method should participate in it; otherwise it should create its own transaction.

Because this method is being invoked inside a TransactionSynchronization, there actually is an "active" transaction but it has already been committed. Thus, the call to saveAndFlush will result in a TransactionRequiredException. This exception is swallowed by TransactionSynchronizationUtils (another Spring class) and logged using the DEBUG level. Thus, the only way to detect this exception is by having DEBUG logging turned on for the org.springframework.transaction.support package.

The solution to this problem is to make sure that the InvoiceService always runs inside its own transaction. So we change the method like this:

@Service
public class InvoiceService {
    @Transactional(propagation = REQUIRES_NEW)
    Long createInvoiceForOrder(Order order) {
      // Rest of the method omitted
    }
}
Enter fullscreen mode Exit fullscreen mode

It is anyway a good practice to configure all your application services to always use REQUIRES_NEW since they are responsible for controlling the transactions.

Now we run the application again and... it still does not work. The application behaves exactly the same. What's wrong now?

It turns out that createInvoiceForOrder is not actually running inside a transaction at all. The transaction is started and committed by the call to saveAndFlush() in the repository, and that method still uses REQUIRED transaction propagation. How come?

The InvoiceService is not implementing any interfaces, so Spring is using a CGLIB proxy to add the transaction interceptors. However, the method createInvoiceForOrder happens to have package visibility and the transaction interceptor is only applied to public methods. So we need to change the method to be public:

@Service
public class InvoiceService {
    @Transactional(propagation = REQUIRES_NEW)
    public Long createInvoiceForOrder(Order order) {
      // Rest of the method omitted
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we run the application once more and it finally works!

💖 💪 🙅 🚩
peholmst
Petter Holmström

Posted on May 7, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related