Skip to content

ferderer/recursive-modulith

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

6 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Recursive-Modulith

– 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.


The Problem

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.

The Solution

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.

Quick Reference

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.

Full Example

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/

What a Use Case Looks Like

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.

Key Rules

Dependency Rules

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

Naming Conventions

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.

When to Escalate

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

Error Handling (3 Layers)

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

CI Verification

@Test
void verifyModulithStructure() {
    ApplicationModules.of(Application.class).verify();
}

Documentation

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

Why "Matryoshka"?

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

Status

  • Architecture analysis & comparison
  • Architecture Decision Records (ADR-001 to ADR-023)
  • arc42 documentation
  • Practical ruleset
  • Reference implementation
  • Custom Spring Initializer (generator)
  • Article series

License

MIT

About

Recursive-Modulith (Matryoshka Architecture) πŸͺ† β€” A pragmatic, recursive package structure for Spring Boot. Same pattern at every level: config/ + common/ + domain. With ADRs, arc42 docs, and CI-verifiable rules via Spring Modulith + ArchUnit.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors