7 Patterns for Migrating Microservices to a Monolith
Many articles explain how to split a monolith into microservices. I didn't see a guide for the opposite direction, so I wanted to write one. This guide draws on case studies, not trends, since the trend is still to build microservices.
When to migrate from Microservice to Monolith?
As I wrote in AbstractLess. You split a part out when it has unique scaling needs, when teams need independent release cycles, or when the bounded contexts are clear. The merge test is the same list, read backward.
| Question | Merge | Keep separate |
|---|---|---|
| Who owns the services? | One team owns both | Different teams, each with its own backlog |
| How do they release? | Always together, in a fixed order | Each on its own schedule |
| How do they scale? | Same load curve, or one hot path dominates the cost | One needs many replicas, the other needs two |
| What can they access? | The same data with the same permissions | A real security or compliance boundary sits between them |
| What happens when one crashes? | The other cannot do its job anyway | The other keeps serving users |
Sam Newman gave a shorter version of the second row at QCon London in 2020, as The Register reported:
"if you have a full-time release coordination manager, chances are you have a distributed monolith."
Independent deployability is what pays for microservices. A group of services that cannot deploy independently pays the network cost and gets nothing back.
I treat merging services back as a normal refactoring, not as an admission of failure. I would also merge selectively. Merge the services that pass the test, and leave alone the ones that really deploy or scale on their own. The target is a modular monolith: one deployable unit with enforced boundaries between modules, plus one or two services that stay outside.

If your system is too large to merge, Uber shows the alternative. In 2020, Uber grouped about 2,200 microservices into 70 domains and gave each domain a gateway as its single entry point. Uber grouped the services but didn't merge them. Callers stopped depending on how many services sat behind each gateway.
Seven patterns for merging services back to monolith
1. One repository, one version of every dependency

As an example, Twilio Segment started here when it merged more than 140 destination services in 2018. The team moved all destination code into one repository. Then it brought more than 120 dependencies to a single version.
So if you want to start migrating microservices to a monolith, migrate the repo before you merge any runtime, because two services that need different major versions of one library cannot share a process without extra work.
Cost. Every dependency upgrade now touches every module. Segment named this trade-off itself: one upgrade can break several destinations at once.
2. Record the traffic before you move anything

Before moving a microservice into the monolith, you need to know how the old service behaves.
Suppose your Orders service receives this request: GET /orders/123
and returns: 200 OK
{
"id": 123,
"status": "paid"
}Save this request and response. Now move the Orders logic into the monolith and send the same request again. If the monolith returns the same result, the behavior has been preserved. If the result differs, you found a regression.
This gives you a reference point. Instead of guessing how the new implementation should behave, you compare it with the system that already works.
Limit. Recorded traffic covers only scenarios you recorded. Rare cases might be missing. For example, you might never record a refund for an order paid in two currencies. Important edge cases still need explicit tests.
3. Strangler Fig in reverse
This is the same Strangler pattern you would use to leave a monolith. In the forward direction, a facade sends a growing share of requests to the new services. In reverse, a proxy sends a growing share of requests to the monolith.
InfluxData did exactly this. The team put a proxy in front of all ingress. At first, the proxy forwarded every request to the old services. Then the team moved endpoints one at a time, one pull request at a time. Five engineers finished in three months with zero downtime.
/orders/* -> monolith (moved)
/inventory/* -> monolith (moved)
/billing/* -> billing-service (next)
/search/* -> search-service (stays outside)
Cost. The proxy is temporary code and one more network hop, and you operate both systems until the last endpoint moves. A caveat about the evidence: InfluxData also rewrote the code from Go to Rust, and its post reports no problems at all. Read the post as a vendor success story.
4. Branch by Abstraction on the client side

The Strangler Fig moves the public endpoints. Branch by Abstraction moves the calls between services. Martin Fowler describes it as a way to make a large change gradually while you keep releasing.
Most services already call each other through a client interface. Keep the interface, add an in-process implementation, switch with a flag, and delete the HTTP client when the flag is on everywhere. Here is the idea in C#:
public interface IInventoryClient
{
Task<bool> ReserveAsync(Guid orderId, IReadOnlyList<OrderLine> lines, CancellationToken ct);
}
// Before: a network call to inventory-service
public sealed class HttpInventoryClient(HttpClient http) : IInventoryClient
{
public async Task<bool> ReserveAsync(Guid orderId, IReadOnlyList<OrderLine> lines, CancellationToken ct)
{
var response = await http.PostAsJsonAsync($"/reservations/{orderId}", lines, ct);
return response.IsSuccessStatusCode;
}
}
// After: the same contract, served by the Inventory module in the same process
public sealed class InProcessInventoryClient(IInventoryModule inventory) : IInventoryClient
{
public Task<bool> ReserveAsync(Guid orderId, IReadOnlyList<OrderLine> lines, CancellationToken ct)
=> inventory.ReserveAsync(orderId, lines, ct);
}The Orders code does not change. Only the registration changes:
if (builder.Configuration.GetValue<bool>("Merge:InventoryInProcess"))
builder.Services.AddScoped<IInventoryClient, InProcessInventoryClient>();
else
builder.Services.AddHttpClient<IInventoryClient, HttpInventoryClient>();Cost. An in-process call has no network timeout, so the retry and circuit breaker policies around it become dead code. Delete them. The network also forced a serialized contract between the two services. In one process, a caller can pass a live object and couple the two modules by accident. Pattern 5 exists because of that.
5. One module per bounded context, checked in CI

A network boundary stops a team from calling another service's internals. A project reference does not, so a test must do it. In .NET, NetArchTest does the job:
[Fact]
public void Orders_does_not_reference_Inventory_internals()
{
var result = Types.InAssembly(typeof(OrdersModule).Assembly)
.ShouldNot()
.HaveDependencyOn("Shop.Inventory.Internal")
.GetResult();
Assert.True(result.IsSuccessful);
}Limit. A boundary rule holds only while a failed check breaks the build in CI.
6. Merge the data last
Data is the hardest part to merge and the hardest part to undo, so it goes last. Three steps work in this order.
First, move each service's database into one database with one schema per module. Forbid joins and foreign keys across schemas. Kamil Grzybek's .NET reference application states the rule: "Each Module has its own data in a separate schema - shared data is not allowed."
Second, replace a saga with a single local transaction when two modules need strict consistency. The saga between Orders and Inventory existed because two databases cannot share a transaction. One database can. Use this rarely, because a shared transaction couples the two modules again.
Third, keep the Outbox pattern for every event that still leaves the process, for example, an event that goes through a message broker to a service that stayed outside.

Cost. One database server means shared load and shared failure. A heavy report query in one module now slows every module. InfluxData made the same move when it went from one database per service to a single shared database.
7. Scale the whole unit, and shard only the hot part
A monolith scales by adding replicas of the whole unit. That works until one part needs different resources.

Limit. Sharding the hot part brings back an orchestration layer. If most of the system needs that treatment, you are rebuilding the services you merged.