The Choice of a Team That Combined 140 Services into One — Why Modular Monoliths Are Gaining Attention Again
To be honest, I also blindly believed in microservices for quite a long time. The argument that "even if one service goes down, the others are still alive" sounded so convincing. However, it wasn't until three of us managed 40 services in the field and spent an entire day debugging deployment pipelines that I started to have doubts. When the CNCF 2025 survey revealed that 42% of organizations that adopted microservices are consolidating services back into larger units, I realized that this wasn't just my experience.
This article is written for backend developers who are currently operating microservices or considering their adoption. Using real-world examples and code, I will explain why Modular Monoliths are gaining renewed attention in the industry these days, how to design and enforce boundaries through code, and when microservices are still the right choice. If you read this article to the end, you will gain concrete methods and tools to draw boundaries in your codebase right now.
Modular Monolith does not mean "giving up on microservices." It is a method of separating internal code as clearly as microservices while maintaining the simplicity of a single deployment. Twilio Segment dramatically boosted development productivity by merging 140 services into one, and Amazon Prime Video reduced infrastructure costs by 90% by transitioning distributed microservices into a single process.
The 3 Key Points of This Article
- Modular Monolith ≠ Big Ball of Mud — Boundary Design is Key
- You can see how to enforce actual boundaries using NestJS, Spring, and Rails code.
- You can obtain criteria for judging when to use and when not to use.
Key Concepts
Big Ball of Mud vs. Modular Monolith — Boundaries Are Everything
The difference between a traditional monolith and a modular monolith is not the amount of code, but the existence of boundaries. In a Big Ball of Mud, code references each other freely, and DB tables can be accessed from anywhere. A modular monolith is different.
| Characteristics | Big Ball of Mud | Modular Monolith | Microservices |
|---|---|---|---|
| Deployment Unit | Single | Single | Independent |
| Code Boundaries | None | Explicit Module Boundaries | Service Boundaries |
| DB Access | Sharing | Module Isolation | Service-specific DB |
| Inter-module communication | Direct reference | Public API | Network (HTTP/gRPC) |
| Module-based selective scaling | Not available | Not available | Available |
Key Definition Atlassian defines a Modular Monolith as "an architecture in which a single process is composed of separate modules, each capable of working independently but requiring them to be merged for deployment." If microservices represent separation at the deployment level, a Modular Monolith represents separation at the logical code level.
Team Size Determines Architecture
The industry consensus for 2025 is converging quite clearly.
| Team Size | Recommended Architecture | Reason |
|---|---|---|
| 1–10 people | Monolith | Tuning overhead > Benefits of independent deployment |
| 10–50 | Modular Monolith | Balance between boundary design and single deployment simplicity |
| 50+ | Microservices | Independent deployments between teams effectively resolve bottlenecks |
The Gartner 2025 report revealed that 60% of teams that chose microservices for small and medium-sized apps regret it. If this number is shocking, you are probably one of that 60% right now, or you are lucky enough to have avoided it yet.
Principles of Module Boundary Design
The starting point for modules is to divide them based on domains.
What is a Bounded Context? As a core concept of Domain-Driven Design (DDD), it refers to the explicit boundaries within which a specific domain model is valid. The concept of an "Order" might be a "request with an amount charged" in a payment context, and an "item with an address" in a shipping context. When the same word has different meanings like this, separating each into a distinct Bounded Context (= Module) is the starting point of module design.
src/
├── modules/
│ ├── order/ ← 주문 도메인 모듈
│ │ ├── api/ ← 외부에 노출하는 퍼블릭 API
│ │ ├── domain/ ← 내부 도메인 로직
│ │ ├── infra/ ← DB, 외부 통신
│ │ └── index.ts ← 모듈 진입점 (이것만 public)
│ ├── payment/
│ │ ├── api/
│ │ ├── domain/
│ │ └── index.ts
│ └── inventory/
│ ├── api/
│ ├── domain/
│ └── index.ts
└── shared/ ← 전체 공유 유틸리티 (최소화)Key Rule Communication between modules is strictly allowed only through index.ts (or the Public API). The moment the order module directly imports payment/domain/PaymentProcessor.ts, the boundary begins to break down.
Now that we have grasped the concept, let's examine how to enforce these boundaries in actual code for each ecosystem.
Practical Application
Example 1: Enforcing Module Boundaries with NestJS
NestJS has a module system built into the language level, making it quite natural for implementing Modular Monoliths. This is a situation frequently encountered in practice; initially, it is easy to mistakenly think that "units defined by NestJS's @Module() decorator equal Modular Monolith modules." In reality, you need to group them more broadly based on the domain.
In the example below, to use a path alias like @modules/order, a path mapping is required for tsconfig.json first.
// tsconfig.json — @modules/* alias 설정 (이 설정 없이는 임포트가 동작하지 않음)
{
"compilerOptions": {
"paths": {
"@modules/*": ["src/modules/*"]
}
}
}// modules/order/index.ts — 이것만 public
export { OrderService } from './api/order.service';
export { CreateOrderDto } from './api/dto/create-order.dto';
export { OrderCreatedEvent } from './api/events/order-created.event';
// domain/, infra/ 내부 구현체는 절대 export하지 않음
// modules/order/order.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([OrderEntity]), // Order 모듈만의 DB 테이블 등록
EventEmitterModule,
],
controllers: [OrderController],
providers: [OrderService, OrderRepository],
exports: [OrderService], // 외부에는 Service만 노출
})
export class OrderModule {}// modules/payment/api/payment.service.ts
// ✅ OK: Order 모듈의 퍼블릭 API만 사용
import { OrderService } from '@modules/order';
// ❌ 경계 위반: 내부 구현체 직접 참조
import { OrderRepository } from '@modules/order/infra/order.repository';It won't work to expect humans to simply remember this rule. You can automatically check it with ESLint.
// .eslintrc.js — 모듈 간 직접 참조 금지
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['@modules/*/domain/*', '@modules/*/infra/*'],
message: '모듈 내부 구현체는 직접 참조할 수 없습니다. index.ts를 통해 접근하세요.'
}
]
}]
}
};| Code Pattern | Allowed | Reason |
|---|---|---|
import { OrderService } from '@modules/order' |
✅ | Via Public API |
import { OrderEntity } from '@modules/order/domain' |
❌ | Direct reference to internal implementation |
Publish/Subscribe to Event(OrderCreatedEvent) |
✅ | Keep Loosely Coupled |
Example 2: Validating Boundaries with Spring Modules
If NestJS caught the Order → Payment boundary violation using ESLint static analysis with TypeScript, Spring Modularity 2.0 in the Java ecosystem solves the same problem through package structure and tests. In other words, the two examples solve the same domain (the boundary between order and payment) using different languages and tools.
I was confused at first too, but Spring Modules recognize module boundaries based solely on the package structure without any separate configuration, and automatically detect violations in tests.
// 패키지 구조만으로 모듈 정의
com.example.shop
├── order/ ← Order 모듈 (패키지 = 모듈)
│ ├── OrderService.java ← public: 외부 접근 가능
│ ├── internal/
│ │ └── OrderRepository.java ← package-private: 모듈 내부만
│ └── OrderCreatedEvent.java ← public: 이벤트 공개
├── payment/
│ ├── PaymentService.java
│ └── internal/
│ └── PaymentGateway.java// 모듈 경계 위반을 테스트로 검증
@Test
void 모듈_경계_위반이_없어야_한다() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
modules.verify(); // 경계 위반 시 테스트 실패
}# application.properties — 런타임 검증 활성화 (Spring Modulith 2.0 신기능)
spring.modulith.runtime-verification-enabled=trueEnabling runtime-verification-enabled=true automatically detects dependency violations between modules at application startup and throws exceptions. This provides a safety net to catch issues during the startup verification phase before production deployment, even if build-time tests are missed.
import org.springframework.modulith.events.ApplicationModuleListener;
// 모듈 간 이벤트 기반 통신
@ApplicationModuleListener
public void on(OrderCreatedEvent event) {
// Payment 모듈이 Order 모듈을 직접 의존하지 않고 이벤트로 반응
paymentService.initiate(event.getOrderId(), event.getAmount());
}Spring Module 2.0 Key New Features Runtime validation (runtime-verification-enabled), an enhanced event system, and Observability (Micrometer integration) have been added. JetBrains IntelliJ IDEA began official integration support in February 2026, allowing you to visually check module boundaries within the IDE.
There is one frequently asked question here: "If you isolate the database by module, how do you maintain data consistency between modules?" — The key is to handle this using the Eventual Consistency pattern. Since this topic is beyond the scope of this article, it is explained separately in the 'Next Article' section below.
Example 3: Shopify’s Packwerk — Enforcing Boundaries in Rails
Shopify enforces component boundaries through static analysis using Packwerk in a 2.8 million line Rails codebase. This is a way of utilizing Rails Engines like a mini-application.
# package.yml — 각 패키지(모듈)의 경계 선언
enforce_dependencies: true
enforce_privacy: true
dependencies:
- packs/shared_kernel # 허용된 의존성만 명시# 실행: 의존성 위반 감지
$ bin/packwerk check
No offenses detected 🎉
# 위반이 있을 경우
packs/payment/app/services/payment_service.rb:15:3
Dependency violation: ::Order::InternalRepository
'packs/payment' does not specify a dependency on 'packs/order'Shopify has walked this journey for six years and candidly reflected on it at the 2024 Rails World keynote. "Did we actually solve the problem we set out to solve?" — this question is key.
All three ecosystems ultimately implement the same principle using different tools. "Preventing direct external access to internal implementations and automatically detecting violations." Only the tools differ; the direction is the same. The following is the most dramatic example demonstrating how this principle works at a real organizational scale.
Example 4: Segment Reversal — 140 → 1
The case of Twilio Segment is the most dramatic. During the hypergrowth period of 2016–2017, the code repository exploded as three new destinations were added every month. As a result, three engineers had to maintain over 140 services, and most of their time was spent firefighting the infrastructure.
문제 상황:
- 140+ 마이크로서비스
- 3명 엔지니어
- 인프라 관리 비용 > 기능 개발 시간
- 서비스 간 조율 복잡도: O(n²) 증가
해결책:
- 140개 서비스 → 단일 서비스로 통합
- 모노레포(Monorepo) 이전
- 내부는 destination별 모듈로 격리 유지Lesson from Segment "Microservices are suitable for solving coordination problems between teams. Segment's problem was that the team was conflicting with itself." — The essence of architecture selection is to align it with the organizational structure.
Amazon Prime Video followed a similar path. By transitioning its Video Quality Analysis system from a distributed microservice to a single process, it achieved a 90% reduction in infrastructure costs. This difference is achieved simply by eliminating network serialization and service discovery overhead.
Pros and Cons Analysis
After reviewing the code examples, a question naturally arises: "So, is this always the right choice?" To be honest, it is not. I have summarized the situations in which it shines and the situations in which it becomes a trap.
Advantages
| Item | Content |
|---|---|
| Operational Simplicity | Single Deployment Artifact — CI/CD Pipelines Become Much More Concise |
| Debugging Efficiency | Tracing the entire flow within a single process without distributed tracing (2025 forecast: 35% savings compared to microservices) |
| Development Productivity | IDE refactoring, type-safe direct calling is possible |
| Incremental Evolution | If boundaries are well-designed, the cost of extracting into individual services later is low |
| Transaction | Can be processed as a local DB transaction — Distributed transactions (Saga, etc.) are not required |
Disadvantages and Precautions
| Item | Content | Response Plan |
|---|---|---|
| Difficulty of Bounded Design | Setting the correct domain boundaries is the most difficult and time-consuming | Utilizing DDD Bounded Context workshops and Eventstorming |
| Architectural Discipline | Without Team Will, Gradually Degenerates into a Big Ball of Mud | Automatically Enforced by Packwerk/ArchUnit/ESLint Rules |
| Selective scaling at the module level is not possible | Specific modules cannot be scaled out; the entire system scales together | Suitable for services with relatively uniform traffic patterns |
| Vulnerability to defect isolation | A single module bug (OOM, etc.) affects the entire process | Per-module resource limits, internal application of circuit breakers |
| Unified technology stack | Cannot use different languages/runtimes per module | Microservices are suitable if multiple languages are essential |
The Most Common Mistakes in Practice
- Dividing Boundaries into Technical Layers —
controllers/,services/,repositories/Dividing into packages is not a modular monolith. You must divide into domains (Order, Payment, Inventory). - Shared Package Deconstruction — If you put anything into
shared/just because it is "common," you will eventually end up with hidden tight coupling where all modules depend onshared/. It is recommended to only include truly general-purpose code inshared/, such as date utilities and logging settings. - Starting without boundary enforcement tools — The assumption that "team members will take care of it" does not work. We recommend including one of Packwerk, ArchUnit, or ESLint custom rules in your CI. Boundary violations accumulate silently and quickly.
In Conclusion
If you have read this far, you have now learned three things: that a modular monolith is not simply a "return to the old ways," that there are specific ways to enforce boundaries using code and tools, and that this architecture may or may not be right for you depending on your team size and circumstances. A Modular Monolith is not "giving up on microservices," but rather a choice to properly identify the sources of complexity and apply distribution only where necessary.
Here are 3 steps you can start right now.
- Draw the domain boundaries of your current codebase — It is a good idea to discuss "what the core domain of our service is" with your team lead or colleagues using a whiteboard or Miro. Orders, payments, users, notifications — these boundaries will serve as the drafts for your modules. If you are starting alone, you can begin by visualizing the dependency graph of your current codebase.
- Adding a boundary enforcement tool to CI — This is a step you can start immediately on your own. If you are using Java, you can begin by adding a single
ApplicationModules.of(App.class).verify()test; if you are using TypeScript, you can start by adding an ESLintno-restricted-importsrule. If you are using Spring Modules, it is recommended to enable thespring.modulith.runtime-verification-enabled=truesetting as well. - Refactor starting with the most independent module — It is safe to start with modules that have fewer dependencies on other domains, such as Notifications. Like the Strangler Fig pattern, moving one step at a time rather than trying to change the whole thing at once is the way to actually succeed.
Reference Materials
- Under Deconstruction: The State of Shopify's Monolith | Shopify Engineering
- Deconstructing the Monolith | Shopify Engineering
- How Shopify Migrated to a Modular Monolith | InfoQ
- Goodbye Microservices: Segment Case Study | Twilio Blog
- To Microservices and Back Again | InfoQ
- Spring Modulith Official Documentation
- Migrating to Modular Monolith using Spring Modulith and IntelliJ IDEA | JetBrains Blog
- What Is a Modular Monolith? | Milan Jovanović
- Modular Monolith Architecture in Cloud Environments | MDPI Future Internet
- Modular Monolith Architecture with .NET | ABP.IO
- 2-Tier to 3-Tier Architecture Migration with Modular Monolith and GraphQL | TheT-Shaped Dev
- NestJS Modular Monolith with DDD | GitHub