Quick Summary / Direct Answer: Migrating from microservices to a modular monolith requires a phased strangler fig refactoring strategy combined with transactional outbox patterns to consolidate distributed databases back into isolated database schemas within a single deployment unit. This eliminates network latency and distributed transaction overhead while preserving domain boundaries.
Key Takeaways:
- Microservices often introduce accidental complexity, soaring cloud bills, and brittle distributed transactions that a modular monolith can easily solve.
- Use the database-per-module pattern within a single monolithic codebase to maintain strict domain encapsulation.
- The transactional outbox pattern guarantees eventual consistency during data consolidation without heavy distributed locking.
The Distributed Systems Hangover
We built too many services. It started innocently. A growing engineering team needed velocity, so we broke the monolith apart. Every domain got its own repository, pipeline, and Kubernetes cluster. Fast forward two years. Simple feature requests now require changes across five microservices, distributed tracing looks like a modern art project, and our cloud provider bill looks like a phone number.
It failed. Or, more accurately, the organizational overhead outweighed the architectural benefits. When your team size shrinks or your domain model stabilizes, microservices become an anchor rather than a rocket booster. Reversing this trend isn’t a failure; it is engineering maturity. We are shifting toward the modular monolith.
The Strangler Fig Migration Blueprint
You cannot rewrite a distributed system overnight. Big bang migrations are career-limiting moves. Instead, we use a modified Strangler Fig pattern to collapse microservices back into a unified runtime.
Step 1: Establishing Module Boundaries
Before moving a single line of code, define strict package boundaries within your target repository. If you use Java, leverage JPMS modules or package-private visibility modifiers. In Go, use internal packages and strict directory structures. If your modules can freely import database models from other domains, your migration has already failed.
Step 2: Routing via an Internal Facade
Introduce a lightweight internal API gateway or proxy layer inside your new monolith. Route incoming traffic to the remaining microservices while gradually pulling service logic directly into the monolith packages. Once a domain is fully integrated, update the internal router to invoke the local module directly.
| Architecture Metric | Microservices | Modular Monolith |
|---|---|---|
| Deployment Complexity | High (Orchestration, Service Mesh) | Low (Single Binary) |
| Network Latency | High (Inter-service REST/gRPC) | Zero (In-Memory Function Calls) |
| Transaction Management | Distributed (Saga Pattern, Complex) | Local ACID (Database Transactions) |
| Domain Enforcement | Network Boundaries | Code Boundaries & Visibility Rules |
Solving Data Consistency Without Distributed Transactions
The hardest part of moving away from microservices isn’t moving the code—it is merging the databases. Microservices teach us to cherish database isolation. When collapsing back to a monolith, developers often make the fatal mistake of sharing a single database schema across all modules. Do not do this.
Maintain strict database schema isolation per module, even if they reside in the same physical database instance. Cross-module queries must go through explicit domain service APIs, never via raw SQL joins.
For asynchronous data synchronization between modules, implement the transactional outbox pattern:
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
@Transactional
public void createOrder(OrderCommand command) {
Order order = orderRepository.save(new Order(command));
OutboxEvent event = new OutboxEvent("OrderCreated", order.getId());
outboxRepository.save(event);
}
}
A background worker polls the outbox table, dispatching domain events to other modules within the monolith via synchronous or asynchronous in-memory event buses. This replaces complex message brokers like Kafka or RabbitMQ with local event dispatchers, massively simplifying your infrastructure footprint while maintaining loose coupling.
Refactoring the Network Callbacks
When services talk over HTTP, failures are guaranteed. Inside a modular monolith, network calls turn into direct method invocations. However, you must resist the temptation to couple error handling. If Module A calls Module B, wrap the call in proper resilience boundaries. Treat internal module boundaries with the same respect you treated microservice boundaries.
Avoid shared mutable state at all costs. If Module Inventory needs user details from Module Identity, pass immutable data transfer objects (DTOs), never live entity models.
Frequently Asked Questions
- Is a modular monolith just a stepping stone back to microservices?
Not necessarily. For many medium-scale applications, a modular monolith is a permanent architectural destination that offers the development speed of a monolith with the organizational clarity of microservices. - How do we handle independent deployments in a modular monolith?
You don’t. That is the explicit trade-off. The entire application deploys as a single unit, which eliminates distributed release coordination nightmares.
The Bottom Line: Actionable Next Steps
Start small. Pick a low-risk, peripheral microservice. Spin up your new monolithic repository structure. Define clear package visibility rules. Implement the database schema separation locally, wire up an internal event bus, and strangle the microservice out of existence. Measure your deployment times and cloud costs drop. You will wonder why you didn’t do this sooner.
