DEV Community

Cover image for The Modular Monolith with Kotlin
SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

The Modular Monolith with Kotlin

---
title: "Modular Monolith in Kotlin: Enforcing Boundaries Without the Microservice Tax"
published: true
description: "Build a modular monolith in Kotlin with JPMS, internal visibility, and dependency inversion  compile-time isolation with a clear microservice extraction path."
tags: kotlin, architecture, api, backend
canonical_url: https://blog.mvpfactory.co/modular-monolith-kotlin-enforcing-boundaries
---

## What We Will Build

Let me show you a pattern I use in every project that needs clean module boundaries but does not need the operational pain of microservices on day one.

We will structure a Kotlin backend as a modular monolith using three enforcement layers: Kotlin's `internal` visibility modifier, JPMS `module-info.java` boundaries, and interface-driven contracts via dependency injection. By the end, you will have compile-time isolation between feature modules, shared ACID transactions, and a mechanical extraction path to microservices when your data actually demands it.

## Prerequisites

- Kotlin 1.9+ with Gradle (Kotlin DSL)
- JDK 17+ (for JPMS support)
- Familiarity with Ktor or Spring Boot
- Basic understanding of dependency injection

## Step 1: Structure Your Gradle Modules

Each feature becomes its own Gradle submodule. This is the foundation everything else builds on.

Enter fullscreen mode Exit fullscreen mode

├── modules/
│ ├── billing/
│ │ ├── src/main/kotlin/com/app/billing/
│ │ │ ├── internal/ # Hidden implementation
│ │ │ └── api/ # Public interfaces only
│ │ └── src/main/java/module-info.java
│ ├── users/
│ └── notifications/
├── app/ # Composition root


The `api/` package holds interfaces. The `internal/` package holds everything else. This distinction matters in the next step.

## Step 2: Lock Down Visibility with JPMS and `internal`

Here is the minimal setup to get this working. Your `module-info.java` exports only the API package:

Enter fullscreen mode Exit fullscreen mode


java
// modules/billing/src/main/java/module-info.java
module com.app.billing {
exports com.app.billing.api;
requires com.app.shared.kernel;
requires kotlin.stdlib;
}


Then define your public contract and its hidden implementation:

Enter fullscreen mode Exit fullscreen mode


kotlin
// api/BillingService.kt — this escapes the module
interface BillingService {
suspend fun chargeCustomer(customerId: UserId, amount: Money): ChargeResult
}


Enter fullscreen mode Exit fullscreen mode


kotlin
// internal/BillingServiceImpl.kt — this does NOT escape
internal class BillingServiceImpl(
private val repo: BillingRepository,
private val txManager: TransactionManager
) : BillingService {
override suspend fun chargeCustomer(customerId: UserId, amount: Money): ChargeResult {
return txManager.withinTransaction {
repo.createCharge(customerId, amount)
}
}
}


Kotlin's `internal` restricts `BillingServiceImpl` to the billing Gradle module. JPMS ensures only `com.app.billing.api` is visible externally. Another module literally cannot reference the implementation — it will not compile.

## Step 3: Share Transactions Without Sharing Coupling

The docs do not mention this, but shared database does not mean shared coupling — if you invert the dependency correctly.

Enter fullscreen mode Exit fullscreen mode


kotlin
// shared-kernel: defines the contract
interface TransactionManager {
suspend fun withinTransaction(block: suspend () -> T): T
}

// app (composition root): provides the single implementation
class JdbiTransactionManager(private val jdbi: Jdbi) : TransactionManager {
override suspend fun withinTransaction(block: suspend () -> T): T =
jdbi.inTransaction { block() }
}


Each module receives `TransactionManager` via constructor injection. The composition root wires one `Jdbi` instance, so billing and users 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 path is mechanical:

1. **Swap DI wiring** — replace the injected implementation with an HTTP/gRPC client that implements the same interface. This is a configuration change.
2. **Deploy independently** — the module becomes its own service.
3. **Replace transactions** — swap `TransactionManager` for a saga or outbox pattern.

Because every cross-module call already goes through an interface, step one costs almost nothing. Step three is the expensive part — which is exactly why you defer it until load data justifies it.

## Gotchas

- **Forgetting `module-info.java`**: Without JPMS, Kotlin `internal` alone does not prevent reflection-based access. You need both layers.
- **Leaking types in public APIs**: If your `BillingService` interface returns an `internal` data class, it will not compile. Keep API-surface types in the `api` package.
- **Circular module dependencies**: If billing needs users and users needs billing, you have a domain modeling problem. Extract the shared concept into `shared-kernel`.
- **Gradle module test isolation**: Tests in one module cannot see `internal` classes from another. This is correct behavior — test through the public interface.

## Wrapping Up

Here is the gotcha that will save you hours of architectural debate: enforce boundaries at compile time, not convention. If a boundary violation still compiles, your architecture is aspirational, not real.

Start monolithic. Instrument everything. Let production metrics — not architecture diagrams — tell you when a module earns its own deployment. The modular monolith gives you microservice-grade isolation with monolith-grade simplicity. Split only when the data says you must.

**Resources:**
- [Kotlin `internal` modifier docs](https://kotlinlang.org/docs/visibility-modifiers.html)
- [JPMS guide (Baeldung)](https://www.baeldung.com/java-9-modularity)
- [Gradle multi-project builds](https://docs.gradle.org/current/userguide/multi_project_builds.html)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)