From Modular Monolith to Microservices: Why Boundary Design Determines Whether Service Extraction Succeeds or Fails
"I feel like we need to migrate to microservices, but I have no idea where to start." Honestly, I felt the same way the first time I faced this problem. I thought separating the payment service made sense, so I scaled up the Kubernetes cluster and split the code — and two months later, all I had left was a skyrocketing AWS bill and an exhausted team. I only realized after the split that payments and orders were tangled together through hundreds of shared DB join queries.
According to various analyses of microservices adoption, 42% of organizations that adopted microservices have since consolidated some services back into larger deployment units. The Amazon Prime Video team switched from a distributed microservices architecture to a single process and cut infrastructure costs by 90%, and Shopify handled 30TB of traffic per minute on Black Friday without downtime using a modular monolith. The industry has started talking about "Microservices Regression."
In this post, I'll walk through a step-by-step strategy for designing boundaries in a modular monolith and safely extracting services when the time is right. This isn't just a backend story — designing module boundaries in an Nx monorepo, deciding whether to adopt Module Federation, and optimizing bundle sizes all stem from the same principles, so this will be directly relevant even if you work primarily on the frontend.
Core Concepts
Traditional Monolith vs. Modular Monolith — What's the Difference?
Monoliths aren't inherently bad. The problem is the way code becomes tangled like "spaghetti." In a traditional monolith, it's common for UserService to call OrderRepository directly, and for ProductModule to join directly against InventoryDB tables. It's fast at first, but six months later, touching one thing breaks something unexpected somewhere else.
For frontend developers, a useful analogy: it's like having a UserProfile component directly importing internal hooks from OrderHistory, or CartContext directly accessing internal state from UserContext. It works for now, but when you go to refactor, you can't tell what will break when you change something.
A modular monolith maintains a single deployment unit while strictly dividing the internals into independent modules. Each module communicates only through explicit Facade interfaces and owns only its own DB schema.
// ❌ Traditional monolith — directly referencing another module's internals
import { OrderRepository } from '../order/repositories/order.repository';
import { inventoryDb } from '../inventory/db/connection';
class UserService {
constructor(private orderRepo: OrderRepository) {}
async getUserWithOrders(userId: string) {
const inventory = await inventoryDb.query(
`SELECT * FROM inventory WHERE user_id = ?`, [userId]
);
return { user: await this.orderRepo.findByUser(userId), inventory };
}
}// ✅ Modular monolith — only explicit interfaces through Facades are allowed
// order/order.facade.ts (public interface)
export class OrderFacade {
constructor(private orderService: OrderService) {}
async getOrderSummaryByUser(userId: string): Promise<OrderSummaryDto> {
return this.orderService.getSummary(userId);
}
}
// user/user.service.ts
import { OrderFacade } from '../order/order.facade'; // ✅ Only import the Facade
class UserService {
constructor(private orderFacade: OrderFacade) {}
async getUserDashboard(userId: string) {
const orderSummary = await this.orderFacade.getOrderSummaryByUser(userId);
return { user: await this.findById(userId), orderSummary };
}
}Three Principles for Designing Boundaries
Bounded Context: In DDD (Domain-Driven Design), this refers to a clearly defined boundary within which a domain model is valid. Simply put, when the word "Order" means different things to different teams, you separate each meaning into its own independent context. For the payments team, an Order is primarily about amounts and approval status; for the shipping team, it's primarily about logistics addresses and delivery status — same word, entirely different models.
Just because you build a modular monolith doesn't mean it will automatically split into microservices later. You need to follow the three principles below during the boundary design phase to make extraction feasible.
| Principle | Bad Example | Good Example |
|---|---|---|
| No direct DB references between modules | SELECT * FROM orders JOIN inventory ON ... |
Call OrderFacade.getInventoryStatus() |
| Explicit Facade interfaces | import { InternalUserModel } from '../user/models' |
import { UserFacade } from '../user' |
| Logical data separation first | Attempt physical DB separation first | Separate by schema prefix, then physically separate |
A trap you'll commonly encounter in practice is attempting physical DB separation first, only to discover too late that there are hundreds of shared join queries. Logical separation first — clearly establish ownership within the same DB using schema prefixes or namespaces, then physically separate afterward. That order is far safer.
So when is the right time to extract a microservice? These are the four extraction criteria that Shopify actually applies in its Black Friday architecture. If even one of these doesn't apply, keeping well-drawn module boundaries is sufficient:
- When independent scaling is needed — the module alone experiences traffic spikes and must be scaled separately
- When privacy or security isolation is required — regulatory requirements like PCI-DSS demand complete isolation
- When it depends on a different technology stack — the component requires a different language or runtime than the main server
- When the feature is mature and stable — the domain model is stable enough that the boundaries are unlikely to change frequently
Module Boundaries on the Frontend: Enforcing with Nx
Since it would be a shame to only talk about the backend, let's look at the frontend side too. If you use an Nx monorepo, the enforce-module-boundaries rule lets you catch boundary violations at build time.
// libs/order-module/src/index.ts — only export the public API
export { OrderSummaryComponent } from './components/OrderSummary';
export { useOrderStatus } from './hooks/useOrderStatus';
export type { OrderDto } from './types';
// never export internal implementations// nx.json — boundary rules that only allow permitted dependencies between modules
{
"pluginsConfig": {
"@nx/eslint-plugin": {
"rules": {
"enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "scope:app",
"onlyDependOnLibsWithTags": ["scope:feature", "scope:ui", "scope:util"]
},
{
"sourceTag": "scope:feature",
"onlyDependOnLibsWithTags": ["scope:ui", "scope:util"]
},
{
"sourceTag": "scope:ui",
"onlyDependOnLibsWithTags": ["scope:util"]
}
]
}
]
}
}
}
}If a
scope:featurelibrary tries to directly import anotherscope:featurelibrary, the build breaks. Just as ArchUnit enforces architecture rules through tests in Java backends, Nx'senforce-module-boundariesplays the same role in frontend monorepos.
Practical Application
Incremental Service Extraction with the Strangler Fig Pattern
"Big-bang rewrites" fail most of the time. I experienced this firsthand — a six-month full rewrite project ended up half-finished and running in parallel with the original system. The Strangler Fig pattern keeps the existing monolith alive while gradually shifting traffic to new services. Like a strangler fig tree that wraps around its host tree and eventually replaces it, you insert new functionality without touching the existing code until it eventually takes over completely.
An API Gateway or NGINX acts as an Intercepting Facade, routing specific paths to the new service while sending everything else to the legacy monolith:
# /etc/nginx/conf.d/strangler-fig.conf
upstream monolith {
server legacy-app:3000;
}
upstream new-payment-service {
server payment-service:3001;
}
server {
listen 80;
# Route only the paths where the new service is ready to the new service
location /api/v2/payments {
proxy_pass http://new-payment-service;
proxy_set_header X-Migration-Phase "strangler-fig";
}
# Everything else still goes to the legacy monolith
location / {
proxy_pass http://monolith;
}
}The legacy monolith and the new service will inevitably have different data models. The Anti-Corruption Layer bridges this gap:
// payment-service/src/acl/legacy-order.adapter.ts
interface LegacyOrderModel {
order_id: number; // legacy uses numeric IDs + snake_case
user_id_fk: number;
total_amount: string; // amount stored as a string
created_dt: string;
}
interface PaymentOrderDto {
orderId: string; // new service uses UUID + camelCase
userId: string;
amount: Money; // can be implemented with a money library like dinero.js
createdAt: Date;
}
export class LegacyOrderAdapter {
translate(legacy: LegacyOrderModel): PaymentOrderDto {
return {
orderId: `order-${legacy.order_id}`,
userId: `user-${legacy.user_id_fk}`,
amount: Money.fromString(legacy.total_amount, 'KRW'),
createdAt: new Date(legacy.created_dt),
};
}
}Anti-Corruption Layer (ACL): A translation layer that prevents the legacy system's data model from contaminating the new service's domain model. The point where the legacy
user_id_fk(number) is converted to the new service'suserId(string UUID) is exactly the ACL.
| Phase | Traffic Split | Internal Implementation | Risk |
|---|---|---|---|
| Initial | 100% → legacy monolith | — | None |
| Pilot | Internal traffic → new service | Route internal users via cookie/request headers | Low |
| Gradual migration | 10% → 50% → 90% → new service | Feature Flags + canary deployments | Monitoring required |
| Complete | 100% → new service, legacy route removed | — | None |
Decoupling Modules with Domain Events
Using events instead of direct function calls means that when you physically separate services later, you only need to swap out the event broker for Kafka or RabbitMQ. You won't need to touch the OrderService or InventoryEventHandler code.
When we first introduced this pattern on our team, the most common question was "why do we need events even at the monolith stage?" — but once people experienced the interface staying intact at extraction time, the skepticism disappeared.
// shared/events/order-events.ts
export interface OrderPlacedEvent {
eventType: 'ORDER_PLACED';
orderId: string;
userId: string;
items: { productId: string; quantity: number; price: number }[];
placedAt: Date;
}
// order/order.service.ts
export class OrderService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly eventBus: EventBus, // depends only on the interface
) {}
async placeOrder(dto: PlaceOrderDto): Promise<Order> {
const order = await this.orderRepo.save(Order.create(dto));
// publish an event instead of calling InventoryService directly
await this.eventBus.publish<OrderPlacedEvent>({
eventType: 'ORDER_PLACED',
orderId: order.id,
userId: dto.userId,
items: order.items,
placedAt: new Date(),
});
return order;
}
}
// inventory/inventory.event-handler.ts
@EventHandler('ORDER_PLACED')
export class InventoryEventHandler {
constructor(private inventoryService: InventoryService) {}
async handle(event: OrderPlacedEvent): Promise<void> {
await this.inventoryService.decreaseStock(event.items);
}
}Abstracting the EventBus interface makes it easy to swap out implementations:
// shared/events/in-process-event-bus.ts (monolith phase)
export class InProcessEventBus implements EventBus {
private handlers = new Map<string, EventHandler[]>();
async publish<T>(event: T & { eventType: string }): Promise<void> {
const handlers = this.handlers.get(event.eventType) ?? [];
await Promise.all(handlers.map(h => h.handle(event)));
}
}
// shared/events/kafka-event-bus.ts (swap in after service extraction)
export class KafkaEventBus implements EventBus {
async publish<T>(event: T & { eventType: string }): Promise<void> {
await this.kafkaProducer.send({
topic: event.eventType,
messages: [{ value: JSON.stringify(event) }],
});
}
}During the monolith phase, InProcessEventBus calls handlers directly within the same process; when extraction is ready, you only need to swap in KafkaEventBus.
Enforcing Architectural Boundaries in Code
Maintaining boundaries through code review alone has limits. When deadlines approach or new team members join, boundaries quietly erode — catching violations automatically in CI is far safer.
For TypeScript / Node.js teams: Nx + ESLint
The depConstraints in the nx.json we configured earlier handles this. It catches unauthorized imports between modules as errors at build time:
# This error appears when a boundary violation is detected
ESLint: A project tagged with 'scope:feature' can only depend on libs
tagged with 'scope:ui', 'scope:util' (@nx/enforce-module-boundaries)For Java / Kotlin backend teams: ArchUnit
// src/test/java/com/example/ArchitectureTest.java
// @AnalyzeClasses: specifies the packages to analyze / @ArchTest: architecture validation test that works like JUnit
@AnalyzeClasses(packages = "com.example")
public class ArchitectureTest {
@ArchTest
static final ArchRule order_should_not_access_inventory_internals =
noClasses()
.that().resideInAPackage("..order..")
.should().accessClassesThat()
.resideInAPackage("..inventory.internal..")
.because("All inter-module communication must go through a Facade");
}You don't need to understand every detail of this Java code. The key insight is that "architectural rules can be expressed as test code." When this test runs in CI, the build automatically breaks at the PR stage whenever someone accidentally adds an import that crosses a boundary.
Pros and Cons Analysis
Advantages
| Item | Description |
|---|---|
| Low operational complexity | Single deployment unit with no network overhead or distributed transactions |
| Clear extraction path | Module boundaries become service boundaries, making extraction natural when ready |
| Incremental transition | Extract services based on proven need — no need to prepare for hypothetical scale in advance |
| Cost efficiency | Infrastructure and operational costs are dramatically lower than microservices (3.75–6x difference) |
| Testability | In-process testing simplifies writing integration tests |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Boundary design failure | Wrong boundaries dramatically increase extraction costs later | Use EventStorming to derive boundaries from domain events first |
| Deployment coupling | All modules deploy simultaneously — no independent deployment | Use Feature Flags (LaunchDarkly, Unleash) to separate feature-level releases |
| Team boundary conflicts | If module boundaries don't match team structure, Conway's Law erodes them | Design team structure and module boundaries together |
| Timing the transition | It's hard to judge when to move to microservices | Extract only when all four Shopify conditions described above are met |
| Data separation difficulty | Separating per-module schemas in a shared DB is the hardest part | Logical separation (schema prefix) first, physical separation later |
Conway's Law: "Software structure mirrors the communication structure of the organization that built it." Imagine both the backend team and frontend team modifying the same order module — both teams gradually crossing boundaries for convenience, and even the most carefully designed boundary slowly blurs. This is why module boundaries = team boundaries is the ideal.
The Most Common Mistakes in Practice
-
Extracting services too early — physically separating before module boundaries have been validated permanently locks in the wrong boundaries. It's recommended to extract only when all four Shopify conditions (independent scaling needed, security isolation needed, depends on different tech stack, feature is mature and stable) are met.
-
Introducing events without Facades — if you use domain events to reduce coupling but expose internal module types directly in event payloads, the events become just another form of coupling. Design Facades and events together.
-
Planning to deal with shared DB join queries later — "we'll separate the DB after we split the service" is the most common trap. I made this mistake twice; the second time, there were over 300 shared joins and we ended up abandoning the service split and starting over with logical separation from scratch. Logical separation first is non-negotiable.
Closing Thoughts
The core of the modular monolith is this: "It's okay not to be microservices right now — but the boundaries need to be drawn now."
The realistic pattern many teams choose is "modular monolith core + extract 2–5 bottleneck services that need independent scaling." Given that 42% of teams are rolling back microservices, starting with a well-bounded modular monolith often turns out to be the better path for future scaling.
Three steps you can start right now:
-
Visualize your current codebase dependencies — on the backend, use
madge(Node.js) or ArchUnit's PlantUML diagram output; on frontend monorepos, runnx graphto see your dependency graph. You'll start noticing unexpected circular dependencies. -
Pick the most independent module and formalize its Facade — export only the public API from
index.tsormoduleName.facade.ts, then add Nxenforce-module-boundariesrules or ArchUnit tests that forbid imports of internal implementations. -
Design domain event interfaces → wire to InProcessEventBus → replace with KafkaEventBus later — by abstracting the interface in advance so you only need to swap the implementation when service extraction is ready, you're building a major asset for later.
References
- The Modular Monolith 2026 Complete Guide — Spring Modulith, ArchUnit Fitness Functions, and Lessons from Shopify's 30TB/min Architecture
- Modular Monolith: 42% Ditch Microservices in 2026 | byteiota
- Rethinking Microservices in 2026: When Modular Monolith Architecture Actually Wins
- Strangler Fig Pattern Explained: The Safer Path from Monolith to Microservices
- Strangler fig pattern — AWS Prescriptive Guidance
- Strangler Fig Pattern — Azure Architecture Center | Microsoft Learn
- Is the Modular Monolith Shopify's Best-kept Secret to Scaling? | Educative
- How to Use Domain-Driven Design Boundaries When Splitting a Monolith on GCP
- A Comparative Study of Micro-Frontend and Modular Monolith Frontend Architectures
- MSA의 Trade-Off 분석: 모듈러 모놀리스와 마이크로커널 아키텍처 | SK Devocean
- 플랫폼 아키텍처 의사결정 가이드: 모놀리스와 마이크로서비스 사이
- GitHub — kgrzybek/modular-monolith-with-ddd