Skip to content

Repository files navigation

Kotlin Spring Modulith Template

A production-ready modular monolith template built with Kotlin, Spring Boot, and Spring Modulith

CI Kotlin Spring Boot Spring Modulith JDK PostgreSQL License: MIT

English | 한국어


Spring Modulith module diagram Package boundaries are module boundaries — and they are enforced by tests. Modules talk to each other only through facade interfaces (sync) and domain events (async), so you get microservice-grade boundaries with monolith-grade simplicity.

Table of Contents

Features

  • 🧱 Enforced module boundariesApplicationModules.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/order modules
  • 📬 Reliable eventing — Event Publication Registry persists every event to the event_publication table and republishes incomplete ones on restart
  • 🧪 Module-level testing@ApplicationModuleTest bootstraps one module at a time, with the Scenario DSL 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 runcompose.yaml + spring-boot-docker-compose starts PostgreSQL automatically

Getting Started

Prerequisites

  • Docker (for local PostgreSQL and Testcontainers)
  • JDK 21 — auto-provisioned by the Gradle toolchain if missing

Run

./gradlew bootRun

PostgreSQL from compose.yaml starts automatically. Then visit:

Try the API

# 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: CANCELLED

Project Structure

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

Module Communication

Pattern How Example in this template
Synchronous call Facade interface in the target module's root OrderServiceMemberApi.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
Loading

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
Loading

Adding a New Module

  1. Create a com.template.<module> package
  2. Put only the public contract in the module root: facade interface, public DTOs, events
  3. Implement inside application / domain / presentation sub-packages
  4. Reach other modules only via facades or events — never create cycles
  5. Write an @ApplicationModuleTest (mock dependency facades with @MockitoBean)
  6. Run ./gradlew test --tests "com.template.ModularityTests" to verify boundaries

Testing

./gradlew test                 # all tests (Testcontainers spins up PostgreSQL)
./gradlew ktlintCheck detekt   # lint & static analysis
./gradlew ktlintFormat         # auto-format
  • ModularityTests — verifies module boundaries and generates architecture docs (C4 / PlantUML + module canvases) into build/spring-modulith-docs/
  • MemberModuleTests / OrderModuleTests — module-scoped tests using the Scenario DSL to assert event publication and consumption

Configuration Profiles

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)

Contributing

Contributions are welcome! Bug reports, feature suggestions, and pull requests all help make this template better.

  1. Fork the repository and create your branch from main

    git checkout -b feat/amazing-feature
  2. Make your changes — keep the module boundary rules in mind (see AGENTS.md for the full conventions)

  3. Verify everything passes before opening a PR

    ./gradlew clean build   # tests + ktlint + detekt + module boundary verification
  4. Commit using Conventional Commits

    feat: add payment module
    fix: handle duplicate email on registration
    docs: clarify event direction rule
    
  5. 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.

License

This project is licensed under the MIT License.

References

About

Kotlin-based Spring Boot template using Spring Modulith for building modular monolith applications.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages