Table of contents
- The Real Problem with Monoliths
- What Happens When You Go Straight to Microservices
- What Is a Modular Monolith?
- How to Create Module Boundaries
- Spring Modulith
- Inter-Module Communication Structure
- Module-Scoped Testing
- From Modular Monolith to Microservices
- Limitations of Modular Monoliths
- Series Retrospective
The Real Problem with Monoliths
You hear it often: “monoliths are bad.” Everything goes into a single deployment unit, so as the system grows, builds slow down, even small changes require deploying everything, and code conflicts between teams become frequent. The logic that follows is: therefore, we need microservices.
But stepping back, it’s worth questioning whether these problems truly stem from “a single deployment unit.” Slow builds may partly be due to the volume of code, but more often it’s because tangled dependencies prevent incremental builds. Full deployments for small changes happen because there are no module boundaries to isolate the scope of change. Cross-team code conflicts occur because multiple teams touch the same packages, not because they share the same repository.
The real problem with monoliths is not size but coupling. Order code directly referencing payment code’s internal classes, product code directly JOINing the member table, and all code mixed together in one giant service package — that’s the root of the problem.
What Happens When You Go Straight to Microservices
The most dramatic way to solve the coupling problem is microservices. Physically separating services forces boundaries. You can’t import another service’s internal classes, so coupling naturally breaks.
But distributed systems aren’t free.
The network is unreliable. Local method calls take milliseconds, but inter-service HTTP calls take tens to hundreds of milliseconds, and you must implement resilience patterns like timeouts, retries, and Circuit Breakers yourself.
Distributed transactions are hard. Work that was bundled in a single @Transactional in a monolith must be handled with Saga patterns or Compensating Transactions in microservices. Implementation complexity spikes dramatically.
Data consistency is difficult to guarantee. In a structure where each service has its own database, there’s no guarantee that Service A’s data and Service B’s data are simultaneously up-to-date. You must accept eventual consistency and design UI/UX accordingly.
Operational complexity explodes. With 10 services, that’s 10 CI/CD pipelines, 10 monitoring targets, and 10 sets of logs to correlate when tracing failures. It’s not easy for a small team to handle all of this.
Even Sam Newman, a pioneer of microservices, said in Building Microservices: don’t make microservices the default choice. Start with a monolith, and split when there’s a justified reason.
The modular monolith is that middle ground — for “when there’s a justified reason.”
What Is a Modular Monolith?
A Modular Monolith is an architecture that enforces clear module boundaries within a single deployment unit. Deployment is monolithic — a single unit — but internally, the structure is divided into modules like microservices. Each module encapsulates its own domain logic and communicates with other modules only through defined interfaces.
Traditional monolith — all modules reference each other directly. No boundaries.
flowchart LR
M1["Order"] <--> M2["Payment"]
M1 <--> M3["Product"]
M1 <--> M4["Member"]
M2 <--> M3
M2 <--> M4
M3 <--> M4
Modular monolith — inter-module communication only through public APIs. Internal implementations are encapsulated.
flowchart LR
subgraph OM["Order Module"]
O1["API"] --- O2["Internal logic"]
end
subgraph PM["Payment Module"]
P1["API"] --- P2["Internal logic"]
end
subgraph CM["Product Module"]
C1["API"] --- C2["Internal logic"]
end
subgraph UM["Member Module"]
U1["API"] --- U2["Internal logic"]
end
O1 --> P1
O1 --> C1
P1 --> U1
Microservices — each service deploys as a separate process. Network communication is required.
flowchart LR
MS1["Order Service"] -.->|HTTP/gRPC| MS2["Payment Service"]
MS1 -.->|HTTP/gRPC| MS3["Product Service"]
MS2 -.->|HTTP/gRPC| MS4["Member Service"]
The traditional monolith has everything mixed together. Microservices are physically separated. The modular monolith sits between them. Physically one, but logically separated.
How to Create Module Boundaries
The most basic way to create module boundaries is through package structure.
com.example.shop/
├── order/ # Order module
│ ├── api/ # Interface exposed to other modules
│ │ ├── OrderApi.kt
│ │ └── OrderDto.kt
│ ├── domain/ # Internal domain model
│ │ ├── Order.kt
│ │ └── OrderItem.kt
│ ├── application/ # Internal services
│ │ └── OrderService.kt
│ └── infrastructure/ # Internal infrastructure
│ └── JpaOrderRepository.kt
├── payment/ # Payment module
│ ├── api/
│ │ ├── PaymentApi.kt
│ │ └── PaymentDto.kt
│ ├── domain/
│ ├── application/
│ └── infrastructure/
├── product/ # Product module
│ ├── api/
│ ├── domain/
│ ├── application/
│ └── infrastructure/
└── member/ # Member module
├── api/
├── domain/
├── application/
└── infrastructure/
The core rule is one: Only the api package of other modules may be referenced. The order module must not directly import classes from the payment module’s domain or application packages. It must communicate only through payment.api.PaymentApi.
The module’s public API is defined like this.
// payment/api/PaymentApi.kt — Payment module's public interface
interface PaymentApi {
fun requestPayment(request: PaymentRequest): PaymentResult
fun cancelPayment(paymentId: String): CancelResult
fun getPaymentStatus(paymentId: String): PaymentStatus
}
// payment/api/PaymentDto.kt — Data transfer objects between modules
data class PaymentRequest(
val orderId: String,
val amount: Long,
val method: PaymentMethod
)
data class PaymentResult(
val paymentId: String,
val status: PaymentStatus
)
The order module uses only this interface.
// order/application/OrderService.kt
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val paymentApi: PaymentApi, // Depends only on payment module's public API
private val productApi: ProductApi // Depends only on product module's public API
) {
@Transactional
fun placeOrder(command: PlaceOrderCommand): OrderId {
// Fetch product info — through the product module's API
val products = command.items.map { item ->
productApi.getProduct(item.productId)
?: throw ProductNotFoundException(item.productId)
}
val order = Order.create(command.customerId, products)
orderRepository.save(order)
// Request payment — through the payment module's API
val paymentResult = paymentApi.requestPayment(
PaymentRequest(
orderId = order.id.value,
amount = order.totalAmount().amount,
method = command.paymentMethod
)
)
order.updatePaymentStatus(paymentResult.status)
orderRepository.save(order)
return order.id
}
}
In this structure, no matter how the payment module’s internal implementation changes, the order module is unaffected. As long as the PaymentApi interface is maintained.
Spring Modulith
While package structure can establish module boundaries, it can’t enforce the rules in code. If a developer accidentally imports an internal class from another module, the compiler gives no warning.
Spring Modulith is a Spring project designed to solve this problem. It verifies inter-module dependencies, supports event-based communication between modules, and provides module-scoped integration testing.
To use Spring Modulith, first add the dependencies.
// build.gradle.kts
dependencies {
implementation("org.springframework.modulith:spring-modulith-starter-core")
testImplementation("org.springframework.modulith:spring-modulith-starter-test")
}
Without additional configuration, Spring Modulith recognizes the application’s package structure as modules. If the main class is in com.example.shop, then the sub-packages order, payment, product, and member each become a module. Only public classes in each module’s root package are accessible from other modules; classes in sub-packages are considered internal.
Architecture Verification
You can verify through tests that module boundaries are properly maintained.
@Test
fun `verify module structure`() {
val modules = ApplicationModules.of(ShopApplication::class.java)
modules.verify()
}
verify() fails the test if there are circular dependencies between modules or if internal classes are referenced from outside. Including this test in CI catches module boundary violations before they’re committed.
Documenting the module structure is also possible.
@Test
fun `document module structure`() {
val modules = ApplicationModules.of(ShopApplication::class.java)
Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml()
}
Event-Based Communication Between Modules
To further reduce direct dependencies between modules, events can be used. Spring Modulith leverages Spring’s ApplicationEventPublisher as-is while adding features to track event completion status and re-publish on failure.
Define an event.
// order/api/OrderEvents.kt — Events published by the order module
data class OrderCompleted(
val orderId: String,
val customerId: String,
val totalAmount: Long
)
The order module publishes the event.
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val eventPublisher: ApplicationEventPublisher
) {
@Transactional
fun completeOrder(orderId: String) {
val order = orderRepository.findById(orderId)
?: throw OrderNotFoundException(orderId)
order.complete()
orderRepository.save(order)
eventPublisher.publishEvent(
OrderCompleted(order.id.value, order.customerId.value, order.totalAmount().amount)
)
}
}
Other modules subscribe to this event.
// member/application/MemberPointHandler.kt
@Component
class MemberPointHandler {
@ApplicationModuleListener
fun onOrderCompleted(event: OrderCompleted) {
// Point accrual logic
addPoints(event.customerId, calculatePoints(event.totalAmount))
}
}
@ApplicationModuleListener is an annotation provided by Spring Modulith. Similar to @EventListener, it adds the ability to track event processing status. If an event handler fails, Spring Modulith records this and enables later reprocessing.
In this structure, the order module doesn’t know the member module exists. It just publishes events. Later, when a coupon module is added and subscribes to the same event, not a single line of the order module’s code changes.
Inter-Module Communication Structure
flowchart LR
subgraph ORDER["Order Module"]
OS["OrderService"]
OE["Publish OrderCompleted event"]
OS --> OE
end
subgraph EVENT["Spring ApplicationEventPublisher"]
EB["Event Bus"]
end
subgraph PAYMENT["Payment Module"]
PH["PaymentHandler"]
PH -->|"@ApplicationModuleListener"| PS["Process payment"]
end
subgraph MEMBER["Member Module"]
MH["MemberPointHandler"]
MH -->|"@ApplicationModuleListener"| MP["Accrue points"]
end
subgraph NOTI["Notification Module"]
NH["NotificationHandler"]
NH -->|"@ApplicationModuleListener"| NS["Send notification"]
end
OE --> EB
EB --> PH
EB --> MH
EB --> NH
style ORDER fill:#4ecdc4,color:#fff
style EVENT fill:#f7c948,color:#333
style PAYMENT fill:#ff6b6b,color:#fff
style MEMBER fill:#45b7d1,color:#fff
style NOTI fill:#a78bfa,color:#fff
The event publisher and receivers are not directly connected. Even when new modules are added, the publisher code doesn’t need to change.
Module-Scoped Testing
Spring Modulith supports module-scoped integration testing. Instead of spinning up the entire application context, you can bootstrap only a specific module and its dependencies.
@ApplicationModuleTest
class OrderModuleTest {
@Test
fun `creating an order publishes an OrderCompleted event`(
scenario: Scenario
) {
scenario.stimulate { orderService.placeOrder(testCommand()) }
.andWaitForEventOfType(OrderCompleted::class.java)
.toArriveAndVerify { event ->
assertThat(event.totalAmount).isEqualTo(30_000)
}
}
}
@ApplicationModuleTest configures the Spring context only within the scope of that module. Since the entire application isn’t started, tests are fast and they also verify module independence.
From Modular Monolith to Microservices
The greatest strategic advantage of a modular monolith is that it provides a gradual transition path to microservices. When module boundaries are clearly established, extracting a specific module into a separate service becomes straightforward.
The transition sequence is roughly as follows.
-
Convert inter-module communication to event-based. Replace direct method calls with asynchronous communication through events. Start with Spring Modulith’s event system.
-
Separate data per module. Even if using the same database, split schemas or stop directly referencing other modules’ tables.
-
Extract the most independent module first. Start by separating the module with the fewest dependencies on other modules. Typically, auxiliary function modules like notification or logging are the candidates.
-
Replace in-process events with a message broker. Swap
ApplicationEventPublisherfor Kafka or RabbitMQ. Since the event publish/subscribe structure is already in place, the scope of change is small.
The key is that all these steps happen gradually. It’s not a big-bang conversion to microservices all at once, but separating necessary parts at necessary times. And not every module needs to be separated into microservices. Only extract modules where the benefit of separation exceeds the cost.
Limitations of Modular Monoliths
A modular monolith doesn’t solve everything.
No deployment independence. Even if only the payment module was modified, the entire application must be redeployed. In organizations where deployment frequency is high and modules have different release cycles, this constraint can become a bottleneck.
Technology stack uniformity is enforced. Since everything runs on a single JVM, you can’t build a specific module in a different language or framework. In most cases, this is actually an advantage, but it’s a constraint when technological flexibility is needed.
A single point of failure exists. If a memory leak occurs in one module, the entire application is affected. In microservices, only the affected service would go down while the rest continues operating, but such isolation is impossible in a monolith.
Despite these limitations, for most small to mid-sized projects, a modular monolith is a more rational choice than microservices. You can break coupling between modules without the complexity of distributed systems, and if microservices truly become necessary later, you can split then.
Series Retrospective
Across 6 parts, we’ve surveyed the major patterns of software architecture. Part 1 established why architecture matters, starting with Part 2’s layered architecture, through Part 3’s hexagonal, Part 4’s clean and onion, Part 5’s CQRS and event-driven, arriving at this part’s modular monolith.
One principle runs through all these architectures: Reduce coupling and increase cohesion. Layered separated concerns by vertical layers, hexagonal separated inside from outside with ports and adapters, clean and onion forced dependency direction inward. CQRS separated the concerns of reads and writes, and the modular monolith divided modules by domain boundaries. The names and forms differ, but they share the same root in the attempt to separate what changes from what doesn’t.
There is no correct answer in architecture selection. Team size, domain complexity, traffic patterns, organizational structure — these factors combine to determine the optimal architecture. Applying hexagonal to a simple CRUD application results in excessive abstraction, while solving a complex domain with only layered architecture bloats the service layer. The essence is not picking an architecture, but understanding the problem and designing a structure that fits.




Loading comments...