MVP Factory
ai startup development

Modular monolith in Kotlin: microservice boundaries without the tax

KW
Krystian Wiewiór · · 5 min read

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:

LayerMechanismEnforced atWhat it prevents
VisibilityKotlin internalCompile timeDirect class access across modules
Module systemJPMS module-info.javaCompile time + runtimeReflective access, transitive leaks
ContractInterface + DICompile timeImplementation 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:

StepActionEffort
1Replace DI wiring with HTTP/gRPC client implementing the same interfaceLow
2Deploy the module as a standalone serviceMedium
3Replace TransactionManager with saga/outbox patternHigh

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.


Share: Twitter LinkedIn