Modular monolith in Kotlin: microservice boundaries without the tax
Meta description: Build a modular monolith in Kotlin using JPMS, internal visibility, and dependency inversion — with clear extraction seams for when you actually need microservices.
Tags: kotlin, architecture, cleanarchitecture, backend, api
TL;DR
You don’t need microservices to get modularity. A Kotlin modular monolith using JPMS module-info.java boundaries, Kotlin’s internal visibility modifier, and interface-driven contracts gives you compile-time isolation, shared transactions, and a clear extraction path without the operational overhead of distributed systems. Start monolithic, enforce boundaries, split only when the data says you must.
Most teams extract too early
Paul Graham’s “Do Things That Don’t Scale” is typically cited for product advice, but the principle applies directly to architecture. In my experience, premature decomposition into microservices is the most expensive architectural mistake teams make in years one through three. Full stop.
A 2023 InfoQ survey found that 63% of teams that adopted microservices early reported increased development time. Not decreased. Network calls replaced function calls. Distributed transactions replaced ACID guarantees. Debugging a stack trace became debugging a distributed trace across twelve services.
The modular monolith gives you the boundary enforcement of microservices with the operational simplicity of a single deployable.
How boundary enforcement actually works
We enforce module isolation at three levels:
| Layer | Mechanism | Enforced at | What it prevents |
|---|---|---|---|
| Visibility | Kotlin internal | Compile time | Direct class access across modules |
| Module system | JPMS module-info.java | Compile time + runtime | Reflective access, transitive leaks |
| Contract | Interface + DI | Compile time | Implementation coupling |
Each layer catches different violations. Together, they make accidental coupling a compiler error, not a code review comment.
Step 1: project structure with Gradle modules
Each feature becomes a Gradle submodule with its own module-info.java:
├── modules/
│ ├── billing/
│ │ ├── src/main/kotlin/com/app/billing/
│ │ │ ├── internal/ # Implementation details
│ │ │ ├── api/ # Public contracts (interfaces)
│ │ │ └── BillingModule.kt
│ │ └── src/main/java/module-info.java
│ ├── users/
│ └── notifications/
├── app/ # Composition root (Ktor/Spring Boot)
Step 2: JPMS + Kotlin internal for compile-time walls
// modules/billing/src/main/java/module-info.java
module com.app.billing {
exports com.app.billing.api; // Only interfaces escape
requires com.app.shared.kernel;
requires kotlin.stdlib;
}
// modules/billing/src/main/kotlin/com/app/billing/api/BillingService.kt
interface BillingService {
suspend fun chargeCustomer(customerId: UserId, amount: Money): ChargeResult
}
// modules/billing/src/main/kotlin/com/app/billing/internal/BillingServiceImpl.kt
internal class BillingServiceImpl(
private val repo: BillingRepository,
private val txManager: TransactionManager
) : BillingService {
override suspend fun chargeCustomer(customerId: UserId, amount: Money): ChargeResult {
// Implementation hidden from all other modules
}
}
The internal modifier restricts BillingServiceImpl to the billing Gradle module. JPMS ensures only com.app.billing.api is visible externally. Another module literally cannot reference the implementation class. It won’t compile.
Step 3: shared transactions without coupling
This is what most teams get wrong about modular monoliths: they assume shared databases mean shared coupling. They don’t, if you invert the dependency.
// shared-kernel: defines the contract
interface TransactionManager {
suspend fun <T> withinTransaction(block: suspend () -> T): T
}
// app (composition root): provides the implementation
class JdbiTransactionManager(private val jdbi: Jdbi) : TransactionManager {
override suspend fun <T> withinTransaction(block: suspend () -> T): T =
jdbi.inTransaction<T, Exception> { block() }
}
Each module receives TransactionManager via constructor injection. The composition root wires a single Jdbi instance, so billing and users can participate in the same ACID transaction while neither knows the other exists.
Step 4: design the extraction seam
When a module genuinely needs independent scaling, the extraction path is mechanical:
| Step | Action | Effort |
|---|---|---|
| 1 | Replace DI wiring with HTTP/gRPC client implementing the same interface | Low |
| 2 | Deploy the module as a standalone service | Medium |
| 3 | Replace TransactionManager with saga/outbox pattern | High |
Because every cross-module call already goes through an interface, step one is a configuration change. The expensive step is always the transaction boundary, which is exactly why you should defer it until load data justifies the complexity.
When to split
Don’t split on intuition. Split when you observe concrete pressure:
- One module needs 10x the compute of others
- One team ships five times per day while others ship weekly
- A module’s failure must not cascade to the rest of the system
If none of these apply, the modular monolith wins on throughput, latency, and debuggability. It’s not even close.
What I’d take away from this
Enforce boundaries at compile time, not convention. Use JPMS module-info.java and Kotlin internal together. If a violation compiles, your architecture is aspirational, not real.
Share transactions now, split them later. Inject a TransactionManager interface into every module. You get ACID guarantees today and a clear seam for sagas when extraction is justified by data.
Treat extraction as an optimization, not a default. Build the monolith, instrument it, and let production metrics tell you when a module earns its own deployment unit. Not architecture diagrams. Not conference talks. Your actual traffic.