A production-ready modular monolith template built with Kotlin, Spring Boot, and Spring Modulith
English | 한국어
- Features
- Getting Started
- Project Structure
- Module Communication
- Adding a New Module
- Testing
- Configuration Profiles
- Contributing
- License
- References
- 🧱 Enforced module boundaries —
ApplicationModules.verify()fails the build on any boundary violation or dependency cycle - 🚦 Startup verification — the same module verification runs again when the application boots (
spring-modulith-runtime); a violated architecture never reaches runtime - 🔄 Two communication patterns out of the box — synchronous facade calls and asynchronous domain events, demonstrated with working
member/ordermodules - 📬 Reliable eventing — Event Publication Registry persists every event to the
event_publicationtable and republishes incomplete ones on restart - 🧪 Module-level testing —
@ApplicationModuleTestbootstraps one module at a time, with theScenarioDSL for event flows and Testcontainers for PostgreSQL - 📐 Living architecture docs — C4 / PlantUML diagrams and module canvases generated from code by the Modulith
Documenter - 🛡️ Consistent API surface — global exception handling, unified
ApiResponse<T>envelope, request-ID (MDC) logging, Swagger UI - 🧹 Code quality gates — ktlint and detekt wired into the build
- 🐳 Zero-setup local run —
compose.yaml+ spring-boot-docker-compose starts PostgreSQL automatically
- Docker (for local PostgreSQL and Testcontainers)
- JDK 21 — auto-provisioned by the Gradle toolchain if missing
./gradlew bootRunPostgreSQL from compose.yaml starts automatically. Then visit:
- Swagger UI: http://localhost:8080/swagger-ui.html
- Health check: http://localhost:8080/actuator/health
# Register a member
curl -X POST localhost:8080/api/v1/members \
-H 'Content-Type: application/json' \
-d '{"name":"Jane","email":"jane@example.com"}'
# Place an order (order module validates the member via MemberApi)
curl -X POST localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{"memberId":1,"productName":"Keyboard","amount":120000}'
# Deactivate the member → MemberDeactivatedEvent → orders are cancelled asynchronously
curl -X POST localhost:8080/api/v1/members/1/deactivate
curl "localhost:8080/api/v1/orders?memberId=1" # status: CANCELLEDcom.template
├── TemplateApplication.kt # Root: global infra config (@EnableAsync, @EnableJpaAuditing)
├── shared/ # Shared module (OPEN) — common response, errors, config
│ ├── response/ # ApiResponse, ErrorResponse
│ ├── error/ # ErrorCode, BusinessException, GlobalExceptionHandler
│ ├── domain/ # BaseTimeEntity (JPA auditing)
│ └── config/ # OpenAPI config, MDC logging filter
├── member/ # Member module
│ ├── MemberApi.kt # Facade interface (public)
│ ├── MemberInfo.kt # Public DTO (public)
│ ├── MemberStatus.kt # Public enum (public)
│ ├── MemberDeactivatedEvent.kt # Domain event (public)
│ ├── application/ # Use cases (hidden)
│ ├── domain/ # Entity, repository (hidden)
│ └── presentation/ # Controller, DTOs (hidden)
└── order/ # Order module (one-way dependency on member)
├── OrderInfo.kt / OrderStatus.kt
├── application/ # OrderService, MemberEventListener
├── domain/
└── presentation/
Only the root package of each module is visible to other modules (facade
interfaces, public DTOs, events). The application / domain /
presentation sub-packages are hidden by Spring Modulith's default rules —
importing them from another module fails ModularityTests.
| Pattern | How | Example in this template |
|---|---|---|
| Synchronous call | Facade interface in the target module's root | OrderService → MemberApi.getMember() |
| Asynchronous notification | Domain event + @ApplicationModuleListener |
MemberDeactivatedEvent → order module cancels the member's orders |
Compile-time dependencies point in one direction only; business flows travel back through events at runtime:
graph LR
order["📦 order"]
member["📦 member"]
shared["📦 shared (OPEN)"]
order -- "MemberApi call<br/>(compile-time, sync)" --> member
member -. "MemberDeactivatedEvent<br/>(runtime, async)" .-> order
order --> shared
member --> shared
Rule of thumb: an event consumer compiles against the publisher's event type. To stay cycle-free, synchronous calls and event consumption must point in the same direction — in this template, strictly
order → member.
Events are persisted in the Event Publication Registry (event_publication
table). If a listener fails, the record remains incomplete, and
republish-outstanding-events-on-restart=true replays it on the next startup:
sequenceDiagram
autonumber
participant C as Client
participant M as member module
participant R as Event Publication Registry<br/>(event_publication table)
participant O as order module
C->>M: POST /api/v1/members/{id}/deactivate
M->>M: Member.deactivate()
M->>R: persist MemberDeactivatedEvent (same transaction)
M-->>C: 200 OK
Note over R,O: after commit — async, new transaction
R->>O: @ApplicationModuleListener MemberEventListener.on(event)
O->>O: cancel all PLACED orders of the member
O->>R: mark publication as completed
Note over R: incomplete publications are<br/>republished on restart
- Create a
com.template.<module>package - Put only the public contract in the module root: facade interface, public DTOs, events
- Implement inside
application/domain/presentationsub-packages - Reach other modules only via facades or events — never create cycles
- Write an
@ApplicationModuleTest(mock dependency facades with@MockitoBean) - Run
./gradlew test --tests "com.template.ModularityTests"to verify boundaries
./gradlew test # all tests (Testcontainers spins up PostgreSQL)
./gradlew ktlintCheck detekt # lint & static analysis
./gradlew ktlintFormat # auto-formatModularityTests— verifies module boundaries and generates architecture docs (C4 / PlantUML + module canvases) intobuild/spring-modulith-docs/MemberModuleTests/OrderModuleTests— module-scoped tests using theScenarioDSL to assert event publication and consumption
| Profile | Database | DDL | Notes |
|---|---|---|---|
| default (local) | auto-started via docker compose | update |
Swagger UI, SQL logging enabled |
prod |
DB_URL / DB_USERNAME / DB_PASSWORD env vars |
validate |
use a migration tool (e.g. Flyway) |
Contributions are welcome! Bug reports, feature suggestions, and pull requests all help make this template better.
-
Fork the repository and create your branch from
maingit checkout -b feat/amazing-feature
-
Make your changes — keep the module boundary rules in mind (see AGENTS.md for the full conventions)
-
Verify everything passes before opening a PR
./gradlew clean build # tests + ktlint + detekt + module boundary verification -
Commit using Conventional Commits
feat: add payment module fix: handle duplicate email on registration docs: clarify event direction rule -
Open a Pull Request with a clear description of what and why
For anything non-trivial, please open an issue first so we can discuss the direction before you invest time in it.
This project is licensed under the MIT License.
