ADR-008: Backend Module Structure
Field |
Value |
|---|---|
Status |
|
Date |
2026-05-12 |
Deciders |
Full Team |
Supersedes |
– |
Superseded by |
– |
Context
With Spring Modulith (ADR-002) and event-based communication (ADR-003) in place, we need a consistent internal structure for every module. Without a defined layout, developers will organise packages differently across modules, making the codebase harder to navigate as it grows. The structure must make each module’s public surface, entry points, and external dependencies immediately visible to any developer.
Decision Drivers
Any developer should be able to navigate an unfamiliar module without guidance
The structure must clearly separate what is public (usable by other modules) from what is internal
Adding a new module should have no ambiguity about where things go
The layout should reflect the event-based communication model from ADR-003
Considered Options
Option A – Custom pragmatic structure (domain-first, layer-second)
Option B – Hexagonal Architecture (Ports & Adapters)
Option C – Classic 3-Layer Architecture (global Presentation / Business / Data packages)
Decision
Chosen option: Option A – Each module follows a consistent package layout with an api/ package,
controller/, service/, repository/, which hold the classic 3 layers of logic, but separated by logic,
a model/ package, and an EventListener class.
Rationale
We organise by module (domain) first, and by layer second. Everything belonging to a domain
lives together. The api/ package is the only part of a module that is visible to the rest of
the application — it contains exclusively the events this module publishes (ADR-003). All
other packages are internal. A EventListener class per module handles all
incoming events, making each module’s dependencies on the rest of the system visible in one
place. This structure is intentionally low-ceremony: it does not require ports, adapters, or
use-case classes, and maps directly to what most Spring Boot developers already know, therefore
keeping a clean split of logic while not introducing too much boilerplate.
Standard module layout:
com.example.app
└── order/
├── api/ ← PUBLIC: events this module publishes
│ ├── OrderPlacedEvent.kt
│ └── OrderCancelledEvent.kt
├── controller/ ← INTERNAL: HTTP entry points, input validation
│ └── OrderController.kt
├── service/ ← INTERNAL: business logic, event publishing
│ └── OrderService.kt
├── repository/ ← INTERNAL: data access only
│ └── OrderRepository.kt
├── model/ ← INTERNAL: domain classes and DTOs
│ ├── Order.kt
│ └── OrderRequest.kt
└── OrderEventListener.kt ← INTERNAL: handles all incoming events from other modules
Layer responsibilities:
api/— published events only. No services, repositories, or model classes.controller/— request mapping and input validation. No business logic.service/— all business logic. The only placeApplicationEventPublisheris called.repository/— data access only. No business logic, no event publishing.model/— domain/entity classes and DTOs, strictly internal to the module. Data crossing a module boundary is always carried by an event class inapi/, never by a model class.OrderEventListener.kt— a single class per module for all incoming cross-module events.
Pros and Cons of the Options
Option A – Custom pragmatic structure
✅ Low ceremony — no ports, adapters, or use-case classes required
✅ Familiar to Spring Boot developers — minimal learning curve
✅ Module’s public surface (
api/), entry points (controller/), and external dependencies (OrderEventListener) are immediately visible✅ Consistent structure across all modules — adding a new module has no ambiguity
❌
model/can become a catch-all if the convention (domain classes vs. DTOs) is not enforced❌ No strict enforcement of layer responsibilities within a module — relies on code review
Option B – Hexagonal Architecture (Ports & Adapters)
✅ Strict separation between domain logic and infrastructure concerns
✅ Domain layer has no framework dependencies — highly testable in isolation
❌ Significant structural overhead — ports, adapters, and use-case classes for every feature
❌ Ceremony outweighs benefit for our current scope and team size
Option C – Classic 3-Layer Architecture (global packages)
✅ Familiar to most developers
❌ Organises by layer globally, not by domain — module cohesion is invisible
❌ Cross-cutting changes touch files spread across many top-level packages
❌ Module boundaries are not visible in the package structure at all
Consequences
Positive:
A new developer opening any module can immediately understand its structure
The single
EventListenerclass per module makes inter-module dependencies auditable@ApplicationModuleTestenforces that nothing outsideapi/is accessed by other modules
Negative / Trade-offs:
model/requires active discipline: domain objects and DTOs may coexist there, but both must remain strictly internal and must not be referenced by other modulesLayer responsibilities within a module (e.g. no business logic in controllers) are enforced by code review, not by tooling
Follow-up actions:
Document the distinction between domain objects and DTOs within
model/in the team wikiEstablish a code review checklist item: no cross-module references outside
api/Use
@ApplicationModuleTestper module to verify boundary compliance