Series overview
Part 1 of 284% complete
2026-06-03•24 min read

Modular monolith as a starting architecture

Assumptions for this chapter and the rest of the series: Kotlin 2.0, Spring Boot 3.3, Gradle 8.x with the Kotlin DSL, and Java 21. The example domain — an order-management platform with Orders, Inventory, and Payments — recurs across the series; later chapters decompose the exact system built here, so the module boundaries drawn in this chapter are not cosmetic. They’re the seams the rest of the series cuts along.

1. Problem the Pattern Solves

Northwind Commerce is a mid-sized online retailer replacing a fifteen-year-old PHP monolith. The new engineering lead has read enough conference talks to know that “monolith” is a dirty word, so the plan on file is: sixty engineers, twelve teams, forty planned microservices, delivered in eighteen months.

Four months in, the actual state is: three services that call each other synchronously over REST for every request, no clear owner for the “which service holds the canonical customer address” question, a shared staging environment that breaks whenever two teams deploy in the same afternoon, and a distributed tracing bill that costs more than the EC2 instances it’s tracing. The team that owns inventory-service spends more time debugging orders-service’s retry logic than building inventory features, because the two are so tightly coupled that a schema change in one breaks the other’s tests.

This is not a Northwind-specific failure. It’s the default outcome of drawing service boundaries before anyone has built the domain once. You cannot decompose a system by business capability if you don’t yet know, concretely, where the capabilities separate — and you only find that out by building the thing and watching where the seams actually want to go.

The forces in tension:

  • Coupling vs. discovery cost. A monolith couples code at compile time, which is cheap to fix (rename, extract, move a file). A premature microservice couples systems at the network boundary, which is expensive to fix (a wrong service boundary means a distributed transaction refactor, not a package move).
  • Latency and consistency. In-process calls are microseconds and can share a transaction. Network calls are milliseconds-to-seconds, fail independently, and force you to choose between eventual consistency and distributed transactions — before you’ve validated the boundary is even correct.
  • Operational complexity vs. team size. Each service is a unit of independent deployability, but also a unit of on-call burden, CI pipeline, dashboard, and alert. Forty services for sixty engineers means more service surface than engineers can hold in their heads.
  • Team ownership vs. domain maturity. Conway’s Law says your architecture will mirror your org chart. If the org chart is drawn before the domain is understood, the architecture inherits the org chart’s mistakes and they become expensive to undo.
  • Cost. Each service is a minimum viable footprint — a container, a database (if stateful), a CI pipeline, observability wiring — regardless of how much traffic it actually serves. Forty services means forty of those fixed costs before the product has proven it needs any of them.

Trade-off, stated plainly: the modular monolith defers the network-boundary decision until you have evidence for where it belongs, while paying for that deferral in reduced independent deployability and (if you’re not disciplined) the constant temptation to reach across a module boundary because it’s right there, one function call away.

2. Core Idea

A modular monolith is a single deployable unit whose internal code is organized into modules with explicit, enforced public APIs and no shared mutable state between them — designed so that each module could become an independent service without a redesign, but runs as one process until you have a concrete reason to split it.

Intent: get the benefits of clear domain boundaries (independent reasoning, testable seams, parallel team ownership of code) without paying network-boundary costs (latency, partial failure, distributed data consistency) until those costs are justified by an actual scaling, deployment, or organizational need.

Participants:

  • Modules — cohesive units of business capability (order, inventory, payment). Each owns its own package tree, its own data (in this chapter, its own Postgres schema), and exposes a small public API.
  • Public API — the only surface other modules may call. Everything else in a module is implementation detail, invisible to the rest of the codebase.
  • Internal event bus — in-process publish/subscribe (Spring’s ApplicationEventPublisher) that lets modules react to what happened elsewhere without calling each other’s internals directly.
  • Composition root — the top-level Spring Boot application module that wires everything together and is the only place allowed to depend on every module.

Request/data flow, using “customer places an order”:

event: OrderPlaced

event: StockReserved

event: PaymentCaptured

payment module

PaymentApi (public)

payment_schema.*

inventory module

InventoryApi (public)

inventory_schema.*

order module

OrderApi (public)

order_schema.*

app (composition root)

Spring Boot entry point, wiring

event: OrderPlaced

event: StockReserved

event: PaymentCaptured

payment module

PaymentApi (public)

payment_schema.*

inventory module

InventoryApi (public)

inventory_schema.*

order module

OrderApi (public)

order_schema.*

app (composition root)

Spring Boot entry point, wiring

Single JVM, single deployable JAR, single Postgres instance (three schemas). Modules never import each other’s internal packages — only the *Api interfaces — and never query each other’s tables, only their own schema.

  1. OrderController (in the order module) accepts the HTTP request and calls OrderApi.placeOrder().
  2. order module persists the order in order_schema and publishes OrderPlaced on the in-process event bus, inside the same transaction’s after-commit hook.
  3. inventory module, which has no compile-time dependency on order, listens for OrderPlaced, reserves stock in inventory_schema, and publishes StockReserved or StockUnavailable.
  4. payment module listens for StockReserved, charges the customer, and publishes PaymentCaptured or PaymentFailed.
  5. order module listens for both terminal events and updates its own order status — it never lets another module write to order_schema directly.

Commonly confused with:

  • Layered architecture (controller/service/repository). Layered architecture slices by technical role; a modular monolith slices by business capability. You can have a layered monolith where every layer touches every domain concept — that’s the tangled mess this pattern fixes. The two are orthogonal: each module in a modular monolith can, and usually does, have its own internal layers.
  • Shared-database microservices. Some teams run several deployable services against one shared database and call that “microservices.” It has the network cost of microservices and the coupling of a monolith — the worst of both. A modular monolith is honest about being one deployable, and gets the coupling isolation without paying for the network.
  • A “distributed monolith.” This is what Northwind actually built: multiple deployables, synchronous coupling, no independent releasability. A modular monolith is the antidote, not a variant — it’s what you build first so you don’t back into a distributed monolith by accident.

3. When to Use It

Strong indicators:

  • You’re building a new product or a green-field rewrite and don’t yet have production evidence for where the domain naturally separates.
  • Your team is smaller than the number of services your architecture diagram implies (a good rule of thumb: if you have fewer than 2–3 engineers per proposed service, you don’t have microservices, you have microservice-shaped operational debt).
  • You need to ship features fast during product-market fit search, where the cost of a wrong service boundary (a network-level refactor) is higher than the cost of a wrong module boundary (a package move).
  • You want independent deployability within the org (teams can own modules, review each other’s public APIs, and refactor internals freely) without independent deployability in production yet.

Concrete use cases:

  • E-commerce, as in this chapter: a new storefront platform where catalog, order, inventory, and payment boundaries are hypotheses, not facts, until real traffic and real org growth test them.
  • SaaS, early-stage: a B2B tool where billing, tenant-provisioning, and core-product are conceptually separate but the company has eight engineers total — one Postgres instance and one deployable is the entire infrastructure budget.
  • Government platforms: procurement and compliance timelines often make “we operate one deployable, audited, well-understood system” a feature, not a limitation, especially in early phases where the domain (benefits eligibility rules, document workflows) is still being codified from legislation.
  • Document workflow systems: intake, review, approval, and archival stages are natural module boundaries long before they need to be separate services with independent scaling profiles.

Prerequisites before adopting it:

  • A build tool that supports enforcing module visibility at compile time (Gradle multi-module, as used below, or Java Platform Module System). Package-private discipline alone is not enough — Kotlin’s own visibility modifiers don’t stop module B from depending on module A’s internal package if they’re compiled into the same Gradle module.
  • Agreement, in the team, on what a “public API” commitment means — a module’s public interface should be reviewed with the same rigor as a real service’s REST contract, because that’s what it will become.
  • A domain model sketch, even a rough one, so the initial module boundaries aren’t arbitrary. This pattern reduces the cost of getting boundaries wrong later; it doesn’t remove the value of thinking about them now.

4. When Not to Use It

A simpler design is better when:

  • The system genuinely has one bounded context. Splitting a small CRUD admin tool into order / inventory / payment modules when it’s really just “manage orders” is ceremony without benefit — one cohesive package is correct.
  • You already have strong, validated evidence of the domain boundaries — for instance, you’re rebuilding a system you’ve operated for years and know exactly where the seams are. In that case, extracting real services from day one carries less risk than in a green-field domain, because the boundary-discovery problem is already solved.

Costs, risks, and failure modes:

  • Boundary erosion. The single most common failure: a developer under deadline pressure adds import in.o612.eng.northwind.inventory.internal.StockRepository from the order module because the public InventoryApi doesn’t (yet) expose what they need, and nobody catches it in review. Six months later every module can reach every table, and you’ve rebuilt the tangled monolith with extra folders. This pattern only works with build-level enforcement (Section 5) and code review discipline — it is not self-enforcing by convention alone.
  • False sense of extraction-readiness. Modules that communicate through method calls returning fully-loaded JPA entities, or that share a single transaction across module boundaries “because it’s easier,” will not actually decompose cleanly later. If splitting a module into a service would require a distributed transaction to preserve current behavior, the module boundary is disguising a consistency boundary problem, not solving it.
  • Single scaling and failure domain. One slow module (a runaway query in inventory) can degrade the whole process — there’s no bulkhead between modules by default, only by discipline in code (thread pools, timeouts on any I/O within a module). A genuinely bursty module (say, a report-generation module hit by monthly batch jobs) may need to be a separate service specifically for scaling isolation, even early.

Common overengineering to watch for:

  • Adding an internal message bus with guaranteed delivery, retries, and dead-letter handling for in-process events. In-process pub/sub inside one JVM either delivers the event or the whole process is already down — don’t build Kafka-shaped ceremony for a method call.
  • Introducing per-module Docker containers or per-module CI pipelines “to prepare for microservices.” That’s operational cost paid today for a decomposition that may never happen, or may happen along different boundaries than you guessed.
  • Versioning internal module APIs (InventoryApiV1, InventoryApiV2) as if they were externally consumed. Internal APIs can be changed with a compiler-checked refactor across the whole codebase in one commit — that’s the whole point of not being a real service yet.

5. Implementation Example

Stack choice. Spring MVC, not WebFlux — this is a request/response CRUD-and-orchestration workload with no requirement for high-concurrency streaming or backpressure, and MVC’s synchronous programming model is easier to reason about and debug, especially for engineers newer to Kotlin coroutines. Spring Data JPA and PostgreSQL because the domain is genuinely relational (orders, line items, stock levels, payment records) with real foreign-key-shaped integrity needs within each module’s own schema. Testcontainers for integration tests, because “does the module boundary hold under a real Postgres instance and a real Spring context” is exactly the kind of thing that shouldn’t be trusted to mocks. No Kafka, no Resilience4j, no API gateway, no Keycloak in this chapter — there is no network yet, so there is nothing to retry, circuit-break, route, or authenticate service-to-service. Those tools show up in this series precisely when a chapter introduces the network boundary that needs them; introducing them here would be exactly the overengineering Section 4 warns about.

Project layout (Gradle multi-module, one deployable JAR):

northwind-platform/
├── settings.gradle.kts
├── build.gradle.kts (shared conventions)
├── app/ ← composition root, produces the runnable JAR
│ └── src/main/kotlin/in/o612/eng/northwind/app/Application.kt
├── order/
│ └── src/main/kotlin/in/o612/eng/northwind/order/
│ ├── api/ ← public: OrderApi, DTOs, domain events
│ └── internal/ ← package-private-by-convention, enforced by Gradle
├── inventory/
│ └── src/main/kotlin/in/o612/eng/northwind/inventory/{api,internal}/
└── payment/
└── src/main/kotlin/in/o612/eng/northwind/payment/{api,internal}/
settings.gradle.kts
rootProject.name = "northwind-platform"
include("app", "order", "inventory", "payment")

Gradle module boundaries are what make this pattern real rather than aspirational. Each domain module depends on nothing but shared kernel types (money, IDs) and Spring; crucially, order has no Gradle dependency on inventory or payment at all — cross-module communication happens only through Spring events, resolved at runtime, not at compile time:

order/build.gradle.kts
plugins {
id("org.springframework.boot") apply false
id("io.spring.dependency-management")
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa")
}
dependencies {
implementation(project(":shared-kernel"))
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.testcontainers:postgresql:1.20.1")
testImplementation("org.testcontainers:junit-jupiter:1.20.1")
}
app/build.gradle.kts
plugins {
id("org.springframework.boot") version "3.3.4"
id("io.spring.dependency-management") version "1.1.6"
kotlin("jvm") version "2.0.20"
kotlin("plugin.spring") version "2.0.20"
}
dependencies {
implementation(project(":order"))
implementation(project(":inventory"))
implementation(project(":payment"))
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
}

Only app is allowed to depend on all three domain modules — that dependency graph is enforced by Gradle itself (a order -> inventory dependency simply won’t compile unless someone deliberately adds it to order/build.gradle.kts, which is a one-line, reviewable diff).

The public API contract. This is the single most important file in the module — treat every change to it like a breaking API change, because eventually it will be one:

inventory/src/main/kotlin/in/o612/eng/northwind/inventory/api/InventoryApi.kt
package `in`.o612.eng.northwind.inventory.api
import java.util.UUID
/** Public contract for the inventory module. No caller outside this module
* may depend on anything in `in.o612.eng.northwind.inventory.internal`. */
interface InventoryApi {
fun reserveStock(orderId: UUID, items: List<StockReservationRequest>)
}
data class StockReservationRequest(
val sku: String,
val quantity: Int,
)
/** Domain events — the only channel other modules may react through. */
data class StockReserved(val orderId: UUID)
data class StockUnavailable(val orderId: UUID, val unavailableSkus: List<String>)

The implementation lives in internal and is wired as a Spring bean, but nothing outside the module references the concrete class — every collaborator depends on InventoryApi:

inventory/src/main/kotlin/in/o612/eng/northwind/inventory/internal/InventoryService.kt
package `in`.o612.eng.northwind.inventory.internal
import `in`.o612.eng.northwind.inventory.api.InventoryApi
import `in`.o612.eng.northwind.inventory.api.StockReservationRequest
import `in`.o612.eng.northwind.inventory.api.StockReserved
import `in`.o612.eng.northwind.inventory.api.StockUnavailable
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
@Service
internal class InventoryService(
private val stockRepository: StockRepository,
private val events: ApplicationEventPublisher,
) : InventoryApi {
@Transactional
override fun reserveStock(orderId: UUID, items: List<StockReservationRequest>) {
val shortages = items.filter { stockRepository.available(it.sku) < it.quantity }
if (shortages.isNotEmpty()) {
events.publishEvent(StockUnavailable(orderId, shortages.map { it.sku }))
return
}
items.forEach { stockRepository.decrement(it.sku, it.quantity) }
events.publishEvent(StockReserved(orderId))
}
}

StockRepository is internal (Kotlin’s visibility modifier, enforced at compile time within the JVM module boundary Gradle draws) — it cannot be imported from order or payment even if someone tries.

Reacting to events across the boundary — the order module listens for outcomes without ever importing inventory’s or payment’s internals, only their event types:

order/src/main/kotlin/in/o612/eng/northwind/order/internal/OrderEventListeners.kt
package `in`.o612.eng.northwind.order.internal
import `in`.o612.eng.northwind.inventory.api.StockReserved
import `in`.o612.eng.northwind.inventory.api.StockUnavailable
import `in`.o612.eng.northwind.payment.api.PaymentCaptured
import `in`.o612.eng.northwind.payment.api.PaymentFailed
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
import org.springframework.stereotype.Component
@Component
internal class OrderEventListeners(private val orderStatusUpdater: OrderStatusUpdater) {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun onStockReserved(event: StockReserved) = orderStatusUpdater.markAwaitingPayment(event.orderId)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun onStockUnavailable(event: StockUnavailable) = orderStatusUpdater.markFailed(event.orderId, "OUT_OF_STOCK")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun onPaymentCaptured(event: PaymentCaptured) = orderStatusUpdater.markPaid(event.orderId)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun onPaymentFailed(event: PaymentFailed) = orderStatusUpdater.markFailed(event.orderId, "PAYMENT_DECLINED")
}

AFTER_COMMIT matters: it guarantees the publishing module’s own transaction has already committed before a listener in another module reacts, so order never observes a StockReserved event for a reservation that gets rolled back. This is the in-process analogue of the delivery guarantee a real message broker gives you later in the series — building the habit here makes the eventual move to Kafka a change of transport, not a change of thinking.

Schema-per-module, one database. Each module gets its own Postgres schema and its own Flyway/Liquibase migration path, even though all three run against the same instance:

docker-compose.yml
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: northwind
POSTGRES_USER: northwind
POSTGRES_PASSWORD: northwind
ports: ["5432:5432"]
order/src/main/resources/application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/northwind?currentSchema=order_schema

Production note. Schema-per-module inside one database is not the same guarantee as database-per-service (covered later in this series) — a rogue query can still technically join across schemas in the same instance. Treat the schema boundary as a strong convention backed by code review and, ideally, a database-user-per-module permission grant, not as a hard security boundary.

Integration test proving the boundary works end to end:

app/src/test/kotlin/in/o612/eng/northwind/app/OrderPlacementIntegrationTest.kt
package `in`.o612.eng.northwind.app
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.assertj.core.api.Assertions.assertThat
@Testcontainers
@SpringBootTest
class OrderPlacementIntegrationTest {
companion object {
@Container
@JvmStatic
val postgres = PostgreSQLContainer("postgres:16")
@DynamicPropertySource
@JvmStatic
fun properties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
}
@Autowired lateinit var orderApi: `in`.o612.eng.northwind.order.api.OrderApi
@Autowired lateinit var orderQueryRepository: OrderStatusTestRepository
@Test
fun `order moves to PAID when stock and payment both succeed`() {
val orderId = orderApi.placeOrder(sampleOrderRequest())
awaitOrderStatus(orderId, "PAID")
assertThat(orderQueryRepository.statusOf(orderId)).isEqualTo("PAID")
}
}

This test boots the whole application context — all three modules, one real Postgres instance — and asserts on the observable outcome (order status), not on internal calls. That’s deliberate: it’s the same shape of test you’ll write once inventory becomes a real service and this becomes a contract or end-to-end test instead.

6. Step-by-Step Flow

Tracing “customer places an order for 2× SKU WIDGET-1”:

  1. Client action. The storefront frontend sends POST /orders with a customer ID and line items.
  2. API request. OrderController.placeOrder() validates the payload (Bean Validation on the request DTO) and calls OrderApi.placeOrder() — a plain Kotlin method call, no serialization, no network hop.
  3. Service behavior. OrderService (inside order/internal) creates an Order entity in status PENDING.
  4. Database interaction. The order and its line items are persisted to order_schema in one JPA transaction.
  5. Inter-service communication (in-process, but structured like inter-service communication will be later): on commit, order publishes OrderPlaced. inventory’s listener picks it up, reserves stock against inventory_schema, and publishes StockReserved. payment’s listener picks that up, charges a stored payment method, and publishes PaymentCaptured.
  6. Error or failure handling. If inventory finds insufficient stock, it publishes StockUnavailable instead of raising an exception across the module boundary — modules communicate outcomes as events, not as thrown exceptions leaking across a public API, because an exception type is itself a coupling point. order’s listener marks the order FAILED with reason OUT_OF_STOCK. No payment is ever attempted.
  7. Observability signals. Actuator exposes /actuator/health and /actuator/metrics; Micrometer records a custom counter, orders.placed{outcome=paid|failed}, tagged by module, so you can already see — before any service exists — which “service” (module) would be the one paged if failures spike. This is the seam that later becomes real distributed tracing.
  8. Final response. The original HTTP request returns 202 Accepted with the order ID immediately after step 4 — the client polls or subscribes for status, because steps 5–6 are asynchronous relative to the request even though they happen in the same process. This is a deliberate design choice: it means the eventual extraction of inventory and payment into real services changes nothing about the client-facing contract, because the contract was already eventually-consistent.

7. Production Concerns

  • Timeouts, retries, idempotency. There’s no network yet, so there’s nothing to time out — but design the event handlers to be idempotent anyway (reserveStock should be safe to call twice for the same orderId). You’ll need that idempotency the moment this becomes a Kafka consumer, and retrofitting it after a real duplicate-delivery incident is much more expensive than building it in now.
  • Data consistency and transaction boundaries. Each module’s own writes are transactional; the overall order-to-payment flow is not — it’s a sequence of independently-committed steps linked by events, i.e., already eventually consistent, on purpose. This is the single most important habit this chapter builds: never let a “temporary” cross-module transaction span two modules’ schemas, even though the database technically permits it. That temptation is exactly what makes later extraction painful.
  • Database-per-service and schema ownership. Schema-per-module now is a rehearsal for database-per-service later (a future chapter). The migration path is: extract the module’s JAR/schema pair into its own deployable and its own Postgres instance, then point its existing schema-scoped repository code at the new instance — no repository code changes, because it never queried outside its own schema.
  • API versioning. Internal module APIs (InventoryApi) don’t need versioning — the whole codebase upgrades in one commit. The moment a module extracts into a service, its API needs a version and backward-compatibility policy (covered later in this series with API gateway and contract-testing chapters).
  • Authentication and authorization. One process, one trust boundary — a single Spring Security filter chain authenticates the external HTTP request once, and every module trusts the authenticated principal passed through the call chain. There is no service-to-service trust problem yet, because there is no second service. Don’t build service-to-service auth (mTLS, token propagation) for calls that are actually just Kotlin function calls — that’s the OAuth2/Keycloak chapter’s job, later, when it’s real.
  • Logging, metrics, tracing, correlation IDs. Assign a correlation ID (e.g., in a MDC key) per incoming request even now, and propagate it through the ApplicationEventPublisher calls (carry it as an event field, not just as thread-local MDC, since event listeners may run on a different thread than the publisher). Doing this before you have multiple services means your first distributed trace, later, is a continuation of a habit rather than a new discipline.
  • Kubernetes deployment. One Deployment, one readiness probe (/actuator/health/readiness, which should check the Postgres connection), horizontal scaling by replica count since the app is stateless. No service mesh, no per-module scaling — that’s not available until modules are actually services.
  • Testing strategy. Unit tests per module against internal classes (fast, no Spring context). Module-level integration tests using Testcontainers scoped to one module’s schema. Full-stack integration tests (as shown above) that boot every module together and assert on cross-module outcomes — these are your rehearsal for the contract tests you’ll write once modules become services.
  • Migration strategy. This chapter is the starting point for the rest of the series’ migration strategy: because module boundaries are already enforced at the Gradle and event level, extracting inventory into its own deployable later means (a) giving it its own Postgres instance, (b) replacing the in-process ApplicationEventPublisher calls with a message broker, and (c) replacing direct method calls on InventoryApi with an HTTP or gRPC client implementing the same interface. The domain code inside internal does not change.

8. Common Mistakes

  1. Letting modules share JPA entities as their communication contract. Passing a @Entity-annotated Order object into InventoryApi.reserveStock() couples inventory to order’s persistence model — a column rename in order now breaks inventory’s compilation. Fix: communicate only through plain data classes and events defined in the api package, never through JPA entities.
  2. No compile-time enforcement of the module boundary. Relying on a naming convention (“please don’t import internal packages”) without Gradle module separation means the boundary erodes the first time someone’s under a deadline. Fix: put each module in its own Gradle subproject with an explicit dependency graph, as shown in Section 5 — a boundary violation should be a build failure, not a code review nitpick.
  3. Synchronous, transactional cross-module calls “just this once.” Wrapping a call to InventoryApi.reserveStock() inside order’s own @Transactional method so both commit or roll back together seems safer, but it silently creates a distributed-transaction assumption that breaks the instant inventory becomes a real service. Fix: use AFTER_COMMIT event listeners (Section 5) so cross-module effects are always eventually consistent, even in-process.
  4. Treating the modular monolith as the permanent architecture. Some teams adopt this pattern, then never revisit the decision, even after the team has grown past 40 engineers and deploys have become a scheduling bottleneck. Fix: track the leading indicators from Section 3 (deploy contention, team-to-module ratio, module-specific scaling needs) and treat “when do we extract our first service” as a standing architecture-review question, not a one-time decision.
  5. Over-modularizing from day one. Splitting a not-yet-understood domain into fifteen modules “to be safe” produces the same boundary-guessing problem as fifteen microservices, minus the network cost — you still pay the cost of guessing wrong, in the form of constant inter-module refactoring. Fix: start with the fewest modules that match your current, real understanding of the domain (three, here) and split further only when a module’s internal cohesion visibly breaks down.
  6. No schema separation “since it’s one database anyway.” Letting all three modules share one schema means there’s no rehearsal for database-per-service, and no way to notice cross-module coupling through direct table access. Fix: schema-per-module from the start, even against a single Postgres instance, as shown in Section 5.

9. Decision Guide

Problem signalUse this pattern?WhyAlternative
Green-field domain, boundaries not yet validated by real usageYesDefers expensive network-boundary mistakes until you have evidence—
Team smaller than ~15 engineersYesNot enough people to independently own more than a handful of deployables—
Single bounded context, no internal seamsNoModularizing one cohesive concern is pure ceremonyPlain layered monolith
Deploys are blocked by cross-team coordination and the domain boundaries are provenNoYou’ve already paid the discovery cost; independent deployability is now the bottleneckExtract real microservices by bounded context (next chapter)
One module has a distinct, bursty scaling profile (e.g., nightly batch reporting)MaybeScaling isolation may justify extracting that module early, even if others stay togetherExtract just that module as a service; keep the rest as a monolith
You already deeply understand the domain from an existing system being rebuiltMaybe notBoundary-discovery risk is already retired; going straight to services may be faster overallMicroservices decomposition by bounded context from day one

10. Hands-On Exercise

Extend it: add a fourth module, notification, that listens for PaymentCaptured and StockUnavailable and would eventually send a customer email. Give it its own api package and its own Gradle subproject, with zero compile-time dependency on order, inventory, or payment.

Simulate a failure: temporarily make PaymentService.charge() throw after StockReserved has already been published and committed. Run the integration test flow and observe what state the order ends up in. Is stock left reserved forever? What would need to change (a compensating event, a reservation timeout) to recover cleanly — and which later pattern in this series is that a preview of?

Decision question, with justification required: Northwind’s inventory module now needs to recalculate stock levels against a nightly batch feed from third-party warehouses, a job that takes 20 minutes and spikes CPU well above the rest of the application’s needs. Do you extract inventory into its own service now, or keep it in the monolith and run the batch job as a separate scheduled process within the same deployable? State which forces from Section 1 you’re weighing and why they point the way they do — there is no universally correct answer here, only a defensible one given Northwind’s team size and the actual cost of the CPU spike.

11. Key Takeaways

  • A modular monolith buys time to discover real domain boundaries before paying the cost of network boundaries — it’s a deferral strategy, not an anti-pattern.
  • The pattern only holds if module boundaries are enforced at compile time (Gradle multi-module in this chapter), not by convention or code-review vigilance alone.
  • Communicate across modules through events and public API interfaces, never through shared entities, shared transactions, or direct internal-package access.
  • Schema-per-module inside one database is a rehearsal for database-per-service, not a substitute for it — treat it as a strong convention, not a security boundary.
  • Design the eventual-consistency habits (event-driven state transitions, idempotent handlers, correlation IDs) now, because retrofitting them after a real distributed-systems incident is far more expensive.
  • This is not a permanent architecture decision — track team size, deploy contention, and per-module scaling needs as the signals that tell you when to extract your first real service.
  • Over-modularizing a not-yet-understood domain reproduces microservices’ boundary-guessing risk without the operational cost — it’s not automatically safer just because it’s still one process.
Spring BootKotlinMicroservices

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind