The context: microservices before the product had earned them

When I joined the project, the architecture was already in place: seven microservices, a RabbitMQ broker, separate deployment pipelines, and a team of four developers trying to keep the system moving.

The original decision to go distributed made a certain kind of sense in 2020. Microservices were everywhere: conference talks, blog posts, job descriptions, hiring plans. The previous team had followed the current architectural default and designed the product from a distributed mindset before the product itself had been validated by enough users.

This is more common than people admit.

Microservices are often adopted as an architectural aspiration rather than as a response to a specific, proven constraint. The pattern solves real problems: independent deployability, isolated scaling, team autonomy at scale, regulatory boundaries, and fault isolation. But those benefits only materialize when the organization, product maturity, and traffic profile justify the overhead.

In this case, they did not.

The symptoms: distributed complexity had become the product bottleneck

The system was not failing dramatically. There was no major outage, no single catastrophic incident, no obvious rewrite trigger.

It worked. But working and working well are different states.

Latency had crept into ordinary user flows. Every meaningful user-facing operation crossed at least two service boundaries over HTTP. Sending a confirmation email, for example, involved an API call to a mailer service, a queue, retry logic, and a separate deployment lifecycle. None of that was absurd in isolation. Together, it made the system slower than it needed to be.

Infrastructure complexity was disproportionate. Four developers were maintaining seven deployment configurations, seven sets of environment variables, seven health-check setups, and a RabbitMQ topology that needed careful handling every time a feature touched more than one service.

The local development experience had degraded. Onboarding a new developer meant explaining not only the domain, but the topology. Running the full stack locally meant orchestrating seven processes. Debugging a cross-service bug meant jumping between codebases, logs, queues, and deployment states.

The cost was hard to defend. Each application needed a minimum of three pods for production resilience. Seven applications meant roughly twenty-one pods per environment. With staging, UAT, and production, the platform was carrying that overhead three times. For the traffic we had at the time, this was not scale. It was architecture cost.

The decision: choose proportion over purity

Proposing a move from microservices to a monolith in 2023 felt almost contrarian. The industry had spent years evangelizing distribution, and suggesting the reverse needed a defensible argument.

The case was simple: the architecture no longer matched the team, the product, or the operational reality.

Team size. Conway's Law cuts both ways. With four developers sharing context daily, there was no organizational reason to split the system into seven deployable units. The team was not divided into autonomous service-owning groups. The architecture was pretending it had an organization that did not exist.

Time to market. Some features required changes across several microservices. That meant multiple pull requests, coordinated releases, API sequencing, and more places for the delivery flow to stall. Moving the logic into one application made those same changes possible in one pull request and one deployment.

Operational simplicity. One deployable unit meant one CI pipeline, one rollback path, one health-check surface, and one set of runtime configuration. Even the front-end application could be served as static assets from the backend, which removed another deployment lane from the delivery path.

We were not abandoning distributed systems because they are wrong. We were acknowledging that their cost must be proportionate to their benefit. Ours was not.

The migration strategy: one route group at a time

The migration had one hard constraint: no big bang.

The product was live. The team could not afford a freeze, a parallel rewrite, or a risky cutover.

The existing architecture gave us a useful migration boundary. The main application already exposed HTTP routes with service-specific prefixes: /mailer, /auth, /notifications, and so on. Each prefix corresponded to a downstream microservice. That gave us a clean migration unit: one route group at a time.

For each service, the process stayed the same.

First, we reimplemented the service logic inside the main application behind the existing route prefix. The external interface did not change. Callers could not tell whether the handler was local or remote.

Second, we introduced a feature flag. With the flag disabled, traffic still routed to the external service. With it enabled, traffic hit the new local handler. That gave us controlled rollout and an immediate rollback path.

Third, once the local handler had run in production without issues, we flipped the flag permanently, removed the outbound HTTP call, drained the RabbitMQ queue, and decommissioned the downstream service.

We repeated that pattern across the seven services over several weeks. At no point did we deploy a breaking change. At no point did we take the system down.

The results: fewer moving parts, faster delivery

The first service merge made the impact visible. The full migration made it structural.

AreaBeforeAfter
Deployable units7 services1 application
CI/deployment lanes7 separate paths1 CI pipeline
Runtime footprintAbout 21 pods per environment3 pods per environment
EnvironmentsStaging, UAT, production all paid the distributed overheadSame environments, much smaller operational surface
Local setup7 processes to coordinate1 process
Feature deliverySeveral pull requests for cross-service work1 pull request for most features
Front-end deploymentSeparate delivery laneStatic assets served by the backend

Time to market improved because the coordination cost collapsed. Features that previously required changes across two or three services could now be shipped in a single pull request and a single deployment.

Latency dropped because inter-service HTTP calls became in-process function calls. The network round-trips, serialization, and retry hops disappeared from ordinary user flows.

The team operated with less friction. Running the full stack locally became boring in the right way. Debugging a bug no longer required correlating logs across multiple deployables. Onboarding shifted back toward understanding the product instead of memorizing the topology.

Infrastructure cost also became easier to justify. Three pods per environment matched the real traffic and resilience needs far better than maintaining seven applications with three pods each across staging, UAT, and production.

One deployment sounds trivial until a team has lived without it. A single deployable unit means a single rollback, a single health-check path, and a single answer to the question: what is currently running in production?

What I would do differently

Two lessons stand out.

First, I would have redesigned the internal architecture while migrating. We moved behavior from external services into the monolith, but we preserved too much of the inherited structure. The migration was an opportunity to define stronger internal boundaries: domain logic isolated from framework code, infrastructure adapters kept at the edges, and use cases expressed in a way the team could test without booting the full application.

In other words, I would have used the migration to make the monolith modular by design, not only smaller by deployment topology.

Second, I would have written the integration test suite before starting. We migrated with too much manual validation and production observation. It worked, but it was uncomfortable. A solid integration suite would have made behavior preservation objective. The definition of done for each route group should have been: the old behavior is covered, the new local handler passes the same checks, and the feature flag can switch traffic without changing the contract.

Both lessons point in the same direction: migrations are architectural moments. Use them fully.

When microservices are the right answer

This is not an argument against microservices. It is an argument against premature distribution.

Microservices are the right answer when they solve a problem the product actually has.

They make sense when teams are large enough that coordination costs more than distribution. They make sense when components have genuinely different scaling profiles, such as video processing compared with authentication. They make sense when regulatory, security, or data-isolation requirements demand hard runtime boundaries. They make sense when independent deployment is not just convenient, but necessary for team autonomy.

A four-developer team shipping a B2B SaaS product does not have the same architectural needs as a thousand-person engineering organization running a global platform.

Copying the patterns of the latter when you are the former is not ambition. It is overhead.

The decision checklist

Before choosing microservices, prove at least one of these constraints:

  • Independent teams need independent ownership and release cycles.
  • One part of the system has a scaling profile that the rest does not share.
  • Regulation, security, or data isolation requires a hard runtime boundary.
  • Failure isolation matters enough to pay the operational cost.
  • The organization can operate multiple services without slowing delivery.

If none of these is true, start with a modular monolith.

That is not a step backward. It is a decision to spend architectural complexity only where it buys something real.