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”:
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.
OrderController(in theordermodule) accepts the HTTP request and callsOrderApi.placeOrder().ordermodule persists the order inorder_schemaand publishesOrderPlacedon the in-process event bus, inside the same transaction’s after-commit hook.inventorymodule, which has no compile-time dependency onorder, listens forOrderPlaced, reserves stock ininventory_schema, and publishesStockReservedorStockUnavailable.paymentmodule listens forStockReserved, charges the customer, and publishesPaymentCapturedorPaymentFailed.ordermodule listens for both terminal events and updates its own order status — it never lets another module write toorder_schemadirectly.
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, andpaymentboundaries are hypotheses, not facts, until real traffic and real org growth test them. - SaaS, early-stage: a B2B tool where
billing,tenant-provisioning, andcore-productare 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, andarchivalstages 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
Bfrom depending on moduleA’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/paymentmodules 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.StockRepositoryfrom theordermodule because the publicInventoryApidoesn’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}/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:
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")}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:
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:
package `in`.o612.eng.northwind.inventory.internal
import `in`.o612.eng.northwind.inventory.api.InventoryApiimport `in`.o612.eng.northwind.inventory.api.StockReservationRequestimport `in`.o612.eng.northwind.inventory.api.StockReservedimport `in`.o612.eng.northwind.inventory.api.StockUnavailableimport org.springframework.context.ApplicationEventPublisherimport org.springframework.stereotype.Serviceimport org.springframework.transaction.annotation.Transactionalimport java.util.UUID
@Serviceinternal 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:
package `in`.o612.eng.northwind.order.internal
import `in`.o612.eng.northwind.inventory.api.StockReservedimport `in`.o612.eng.northwind.inventory.api.StockUnavailableimport `in`.o612.eng.northwind.payment.api.PaymentCapturedimport `in`.o612.eng.northwind.payment.api.PaymentFailedimport org.springframework.transaction.event.TransactionPhaseimport org.springframework.transaction.event.TransactionalEventListenerimport org.springframework.stereotype.Component
@Componentinternal 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:
services: postgres: image: postgres:16 environment: POSTGRES_DB: northwind POSTGRES_USER: northwind POSTGRES_PASSWORD: northwind ports: ["5432:5432"]spring: datasource: url: jdbc:postgresql://localhost:5432/northwind?currentSchema=order_schemaProduction 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:
package `in`.o612.eng.northwind.app
import org.junit.jupiter.api.Testimport org.springframework.beans.factory.annotation.Autowiredimport org.springframework.boot.test.context.SpringBootTestimport org.springframework.test.context.DynamicPropertyRegistryimport org.springframework.test.context.DynamicPropertySourceimport org.testcontainers.containers.PostgreSQLContainerimport org.testcontainers.junit.jupiter.Containerimport org.testcontainers.junit.jupiter.Testcontainersimport org.assertj.core.api.Assertions.assertThat
@Testcontainers@SpringBootTestclass 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”:
- Client action. The storefront frontend sends
POST /orderswith a customer ID and line items. - API request.
OrderController.placeOrder()validates the payload (Bean Validation on the request DTO) and callsOrderApi.placeOrder()— a plain Kotlin method call, no serialization, no network hop. - Service behavior.
OrderService(insideorder/internal) creates anOrderentity in statusPENDING. - Database interaction. The order and its line items are persisted to
order_schemain one JPA transaction. - Inter-service communication (in-process, but structured like inter-service communication will be later): on commit,
orderpublishesOrderPlaced.inventory’s listener picks it up, reserves stock againstinventory_schema, and publishesStockReserved.payment’s listener picks that up, charges a stored payment method, and publishesPaymentCaptured. - Error or failure handling. If
inventoryfinds insufficient stock, it publishesStockUnavailableinstead 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 orderFAILEDwith reasonOUT_OF_STOCK. No payment is ever attempted. - Observability signals. Actuator exposes
/actuator/healthand/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. - Final response. The original HTTP request returns
202 Acceptedwith 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 ofinventoryandpaymentinto 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 (
reserveStockshould be safe to call twice for the sameorderId). 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
MDCkey) per incoming request even now, and propagate it through theApplicationEventPublishercalls (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
internalclasses (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
inventoryinto its own deployable later means (a) giving it its own Postgres instance, (b) replacing the in-processApplicationEventPublishercalls with a message broker, and (c) replacing direct method calls onInventoryApiwith an HTTP or gRPC client implementing the same interface. The domain code insideinternaldoes not change.
8. Common Mistakes
- Letting modules share JPA entities as their communication contract. Passing a
@Entity-annotatedOrderobject intoInventoryApi.reserveStock()couplesinventorytoorder’s persistence model — a column rename inordernow breaksinventory’s compilation. Fix: communicate only through plain data classes and events defined in theapipackage, never through JPA entities. - No compile-time enforcement of the module boundary. Relying on a naming convention (“please don’t import
internalpackages”) 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. - Synchronous, transactional cross-module calls “just this once.” Wrapping a call to
InventoryApi.reserveStock()insideorder’s own@Transactionalmethod so both commit or roll back together seems safer, but it silently creates a distributed-transaction assumption that breaks the instantinventorybecomes a real service. Fix: useAFTER_COMMITevent listeners (Section 5) so cross-module effects are always eventually consistent, even in-process. - 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.
- 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.
- 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 signal | Use this pattern? | Why | Alternative |
|---|---|---|---|
| Green-field domain, boundaries not yet validated by real usage | Yes | Defers expensive network-boundary mistakes until you have evidence | — |
| Team smaller than ~15 engineers | Yes | Not enough people to independently own more than a handful of deployables | — |
| Single bounded context, no internal seams | No | Modularizing one cohesive concern is pure ceremony | Plain layered monolith |
| Deploys are blocked by cross-team coordination and the domain boundaries are proven | No | You’ve already paid the discovery cost; independent deployability is now the bottleneck | Extract real microservices by bounded context (next chapter) |
| One module has a distinct, bursty scaling profile (e.g., nightly batch reporting) | Maybe | Scaling isolation may justify extracting that module early, even if others stay together | Extract just that module as a service; keep the rest as a monolith |
| You already deeply understand the domain from an existing system being rebuilt | Maybe not | Boundary-discovery risk is already retired; going straight to services may be faster overall | Microservices 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.