β Architecture is perfect when nothing can be taken away.
A pragmatic, recursive package structure for Spring Boot modular monoliths.
Also known as Matryoshka Architecture πͺ β because every level contains the same pattern, just smaller.
Spring Boot has no official guidance for package structures beyond tutorials. Existing approaches each solve part of the puzzle:
| Approach | Strength | Weakness |
|---|---|---|
| Package-by-Layer | Easy to start | No cohesion, no encapsulation |
| Package-by-Feature | High cohesion | No answer for cross-cutting concerns |
| Hexagonal / Clean | Strong boundaries | Massive boilerplate, over-engineered for most projects |
| Spring Modulith | Module verification | No guidance for internal structure |
None of them address all levels consistently. That's the gap this project fills.
One recursive pattern, applied at every level:
{level}/
βββ config/ β framework setup (top-level only)
βββ common/ β shared code (any level)
βββ {domain}/ β bounded context, use case, sub-module
App β Bounded Context β Use Case β Action β same structure, all the way down.
config/ β Framework setup. Top-level only. Never import from domain code.
common/ β Shared code. Any level. Visible downward.
{bc}/ β Bounded context. Public API = Service + Events.
common/ β BC-internal shared code. domain/, error/, persistence/.
{usecase}/ β One endpoint = one class. No service layer.
Class β Action name. Request/Response as inner records.
Entity β JPA. Postfix "Entity". Domain class without postfix.
com.acme.insuranceapp
βββ Application.java
β
βββ config/
β βββ security/
β β βββ SecurityConfig.java
β β βββ JwtTokenService.java
β βββ web/
β β βββ CorsConfig.java
β β βββ JacksonConfig.java
β βββ error/
β β βββ GlobalExceptionHandler.java
β βββ persistence/
β β βββ AuditingConfig.java
β βββ openapi/
β βββ OpenApiConfig.java
β
βββ common/
β βββ Tsid.java
β βββ domain/
β β βββ Money.java
β β βββ Address.java
β β βββ Currency.java
β βββ error/
β β βββ AppError.java
β βββ persistence/
β βββ BaseEntity.java
β
βββ policy/ β Bounded Context
β βββ PolicyService.java β Public API (facade only)
β βββ PolicyActivatedEvent.java β Public Event
β βββ common/
β β βββ domain/
β β β βββ PolicyDraft.java β Domain class (clean name)
β β β βββ PolicyDraftEntity.java β JPA entity (postfix)
β β β βββ PolicyStatus.java
β β βββ error/
β β β βββ PolicyError.java β Guard4j error enum
β β βββ persistence/
β β βββ PolicyDraftRepository.java β shared by β₯2 use cases
β βββ creation/
β β βββ CreatePolicyDraft.java β POST endpoint
β β βββ GetPolicyDraft.java β GET endpoint
β β βββ submitpolicydraft/ β escalated (complex)
β β βββ SubmitPolicyDraft.java
β β βββ SubmitValidator.java
β β βββ UnderwritingResult.java
β βββ renewal/
β βββ RenewPolicy.java
β
βββ claims/ β Bounded Context
β βββ ClaimsService.java
β βββ common/
β β βββ error/
β β β βββ ClaimsError.java
β β βββ persistence/
β β βββ ClaimRepository.java
β βββ filing/
β β βββ FileClaim.java
β β βββ GetClaim.java
β βββ policycancelled/
β βββ HandlePolicyCancelled.java β Event listener = use case
β
βββ billing/
βββ BillingService.java
βββ common/
β βββ error/
β βββ BillingError.java
βββ invoice/
βββ payment/
One endpoint, one class, no service layer:
@RestController
@RequestMapping("/api/v1/policies/drafts")
@Transactional
class CreatePolicyDraft {
record Request(String holderName, Coverage coverage) {}
record Response(UUID id, String holderName, Status status) {}
private final PolicyDraftRepository repo;
private final TsidGenerator tsid;
@PostMapping
Response handle(@RequestBody Request req) {
var draft = PolicyDraft.create(tsid.next(), req.holderName(), req.coverage());
repo.save(draft);
return new Response(draft.id(), draft.holderName(), draft.status());
}
}Extract a service only when a second caller appears.
| Rule | Enforcement |
|---|---|
Domain code must not import config.* |
ArchUnit |
BC-to-BC access only via {Bc}Service or Events |
Modulith verify() |
| No direct use-case-to-use-case references | ArchUnit |
@Transactional only on use-case classes |
ArchUnit |
| Postfix | When | Example |
|---|---|---|
| (none) | Domain class, DTO, value object | PolicyDraft, Money |
| (none) | Endpoint (action name) | CreatePolicyDraft |
Entity |
JPA class | PolicyDraftEntity |
Repository |
Spring Data | PolicyDraftRepository |
Service |
BC public API (facade) | PolicyService |
Error |
Guard4j error enum | PolicyError |
No Controller postfix. No Dto postfix.
| Signal | Threshold | Action |
|---|---|---|
| Classes in use-case package | Mapper/Validator or β₯3 | Sub-package for endpoint |
| Use cases per BC | >25β30 | Resource grouping |
| Classes per BC | >60β80 | Consider sub-BC |
| Aggregates per BC | >12β15 | Consider sub-BC |
| ArchUnit cycles | Any | Resolve immediately |
common/error/AppError.java β App-wide errors (Guard4j enum)
{bc}/common/error/{Bc}Error.java β BC-specific errors (Guard4j enum)
config/error/GlobalExceptionHandler.java β Exception β ProblemDetail mapping
@Test
void verifyModulithStructure() {
ApplicationModules.of(Application.class).verify();
}| Document | Purpose |
|---|---|
| Architecture Decision Records | All 23 ADRs with context, decision, rationale |
| arc42 Documentation | Full architecture documentation |
| Ruleset | Practical reference (German) |
| Analysis | Comparison of existing approaches |
Like Russian nesting dolls:
- πͺ Every doll has the same shape β every level follows
config/+common/+{domain}/ - πͺ Dolls are nested inside each other β App β [Domain] β [Subdomain] β Bounded Context β Use Case β Action
- πͺ Each doll is self-contained β every BC is extractable to a microservice
- πͺ From outside, you only see the outer shell β public API
- Architecture analysis & comparison
- Architecture Decision Records (ADR-001 to ADR-023)
- arc42 documentation
- Practical ruleset
- Reference implementation
- Custom Spring Initializer (generator)
- Article series