Skip to content

Instantly share code, notes, and snippets.

@odrotbohm
Last active November 22, 2022 15:35
Show Gist options
  • Save odrotbohm/ad3def8de91a8d6d89f3b106cffa7c1e to your computer and use it in GitHub Desktop.
Save odrotbohm/ad3def8de91a8d6d89f3b106cffa7c1e to your computer and use it in GitHub Desktop.

tl;dr

We would like to simplify the event-based module integration and make sure the default transactional setup is correct by introducing a custom @ModuleIntegrationListener annotation.

Context

Classic Spring applications use bean references and invocations to orchestrate functionality, even if the functionality triggered resides in different application modules. In those cases, the default approach to consistency is a transaction that spans the original business method and all transitive functionality:

@Service
@RequiredArgsConstructor
class OrderApplicationService {

  private final Inventory inventory;
  private final RewardsProgram rewards;

  @Transactional
  void complete(Order order) {

    // Change state of the order and conclude unit of work
    inventory.updateStockFor(order);
    rewards.registerBonusPointsFor(order);
  }

We usually recommend replacing these kinds of interactions with letting the OrderApplicationService rather publish an event and letting the previously actively invoked components listen to that event:

@Service
@RequiredArgsConstructor
class OrderApplicationService {

  private final ApplicationEventPublisher events;

  @Transactional
  void complete(Order order) {

    // Change state of the order and conclude unit of work
    events.publishEvent(OrderCompleted.of(order.getId()));
  }
}

@Service
class Inventory {

  @EventListener
  void on(OrderCompleted event) { … }
}

@Service
class RewardsProgram {

  @EventListener
  void on(OrderCompleted event) { … }
}

This arrangement elegantly fixes the cyclic dependency between the components, simplifies testability but still keeps the consistency model of the previous approach, with all its pros and cons. However, to further decouple the modules from each other, and to limit the scope of the original business transaction, it might make sense to rather turn the event listeners, into asynchronous, transaction-bound ones.

@Service
class RewardsProgram {

  @Async
  @TransactionalEventListener
  void on(OrderCompleted event) { … }
}

This declaration will cause the even listener method to be invoked during the cleanup of the original business method's transaction (hence the name @*Transactional*EventListener).

Problem

One problem with this arrangement is that @*Transactional*EventListener might create the impression that the listener itself is transactional, which it – declared like this – is not. Users understanding that will very likely go ahead and tweak the declaration to this:

@Service
class RewardsProgram {

  @Async
  @Transactional
  @TransactionalEventListener
  void on(OrderCompleted event) { … }
}

This is quite a buit of low-level demarcation and it's easy to miss a detail about the setup. Developers might not actively consider the asynchronous execution. In that case a transactionally correct setup would actually require the transaction propagation settings to be set to REQUIRES_NEW as otherwise, the already running, committed transaction would be reused and the listener running in undefined (likely auto-commit) mode.

Solution

To simplify the declaration and prevent users from misconfiguring their transactional arrangement, it makes sense to provide a custom annotation @ApplicationModuleIntegrationListener, meta-annotated with the set of annotations shown above.

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener
// … other annotations
public @interface ApplicationModuleIntegrationListener { }

@Service
class RewardsProgram {

  @ApplicationModuleIntegrationListener
  void on(OrderCompleted event) { … }
}

We could also provide verification for a variety of cases:

  • Reject @AMIL listening to a non-foreign application module's events (via ArchUnit)
  • Reject non-async transactional event listeners not using REQUIRES_NEW as propagation (via ArchUnit, at runtime)
  • @AMIL used in applications that do not have transactions activated at all (runtime)
  • @TransactionalEventListener registered for an event that's published but while no transaction is running

Consequences / Alternatives

Application code would need to use a Spring Modulith specific annotation in a very fundamental use case. Traditionally, we've tried to avoid that by making the defaults sane and only require Spring Modulith-specific annotations for specialized cases. Also, one would have to inspect the custom annotation for its meta annotations to actually understand what's going on. In other words, we would be introducing an indirection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment