Problem
The backend is a modular monolith built on Spring Modulith. Each domain (workspaces, tasks, billing, automation) is a module, and the rule is that modules communicate through events. One domain never reaches directly into another's internals.
As the codebase grew, the automated module-structure test started failing on circular dependencies. Nothing was broken at runtime: every request still worked. But each cycle meant two domains could no longer change, test or eventually be extracted independently.
Approach
01Make the architecture executable
The cycles were only caught because the module rules are verified on every build rather than written in a wiki. That test is what turned a slow, invisible erosion into a red build that someone had to deal with.
@Test
void verifiesModularStructure() {
ApplicationModules.of(Application.class).verify();
}02Turn a synchronous call into an after-commit event
The tightest cycle came from a synchronous cleanup step: deleting a workspace called directly into another module to clean up its data, and that module already depended back on workspaces.
I moved the cleanup into an event listener that runs only after the triggering transaction commits. The workspace module now just publishes the fact that a workspace was deleted. It no longer knows or cares who reacts, and the dependency points one way.
// @ApplicationModuleListener = @TransactionalEventListener(AFTER_COMMIT)
// + @Async + @Transactional(REQUIRES_NEW)
@ApplicationModuleListener
void on(WorkspaceDeleted event) {
taskCleanup.purgeFor(event.workspaceId());
}03Invert the deeper cycles with owned contracts
Some cycles couldn't be solved with events, because the upstream domain genuinely needed an answer back. The clearest case was permissions: the permission module has to check that a form, automation or record exists in a workspace before granting access to it, and those modules already depended on permissions.
So the permission module defines a registry port for what it needs, and the form, automation and record modules each implement it with an adapter. Permissions never imports a single type from the modules it guards.
// identity/permission - the module that needs the answer owns the contract.
public interface ResourceRegistryPort {
ResourceType resourceType();
boolean existsInWorkspace(UUID resourceId, UUID workspaceId);
}
// record/api/lookup - depends on permission, never the reverse.
@Component
class RecordRegistryAdapter implements RecordGroupScopedResourceRegistryPort {
/* answers from RecordRepository */
}04Find the bug hiding next to the cycle
While tracing events I noticed audit log entries were silently duplicated. One event was handled in-process by the audit listener and also externalized to Kafka, where a consumer wrote the same entry again. Two correct handlers added up to one incorrect result. Externalization was removed for events that already have an in-process consumer.
Outcome
The module-structure test is green and stays green, so each domain can again be reasoned about, tested and changed on its own.
Audit logs record each action once, which matters for a log whose whole purpose is to be trusted.
Lessons
- Architecture rules that aren't executed are suggestions. Make the build enforce them.
- When an event crosses a process boundary, ask who else already handles it in-process.