Event-Driven Side Effects
How ADR-001 established the event-driven pattern for cross-cutting concerns, including the mutable event pattern for synchronous results.
The Problem
As Vivolar grew, cross-cutting concerns started creeping in: audit logging, notifications, analytics, usage tracking. The natural tendency was to inject these dependencies directly into the services that triggered them. But this led to services with 6, 7, 8 constructor dependencies — a clear sign of mixed responsibilities.
ADR-001: Event-Driven Architecture
Architecture Decision Record 001 established a clear rule: new cross-cutting concerns must be implemented as event listeners, not as direct service-to-service calls.
Transaction Phases
Spring provides two types of event listeners, and choosing the right one matters:
@EventListener (default, in-transaction) — used when the publisher needs the result synchronously. The listener runs in the same transaction as the publisher, so if the listener fails, the whole transaction rolls back.
@TransactionalEventListener(phase = AFTER_COMMIT) — used when the side effect must not fire if the transaction rolls back. Perfect for audit logs, notifications, and usage tracking — you don’t want to log “item added” if the insert was rolled back.
The Mutable Event Pattern
One interesting challenge: what happens when a publisher needs a result from a synchronous listener? Spring events are fire-and-forget by default.
The solution is the mutable event pattern. Instead of using a Java record (immutable), we use a mutable class with a result field:
public class ProductResolvedEvent {
private final String productName;
private final Long householdId;
private CategoryMatchResult result; // set by listener
// constructor, getters, setter for result
}
The flow:
- Service creates the event and publishes it
- Synchronous listener processes it and calls
event.setResult(...) - Publisher reads
event.getResult()afterpublishEvent()returns
This keeps the publisher decoupled from the listener while still getting the data it needs.
Event Catalog
ADR-001 includes an event catalog — a registry of all planned and implemented events in the system. Before adding a new event, developers check the catalog to see if an existing event already covers the use case. This prevents event proliferation and keeps the system coherent.
Results
After implementing ADR-001:
- No service has more than 5-6 constructor dependencies
- Cross-cutting concerns are isolated in their own listener classes
- Adding new side effects (e.g., tracking AI usage) doesn’t require modifying existing services
- The event catalog serves as living documentation of the system’s integration points
Lessons
- Events are great for decoupling, but overusing them can make the flow hard to follow. We only use events for cross-cutting concerns, not for core domain logic within a module.
- The mutable event pattern is a pragmatic trade-off. It’s not pure event-driven design, but it avoids the complexity of a full event bus with return channels.
- Transaction phase selection is critical. Getting it wrong means either phantom side effects (AFTER_COMMIT for something that needs rollback protection) or silent data loss (in-tx for something that shouldn’t block the main operation).