From Monolith to Microservices: Lessons from the Trenches

  • java
  • microservices
  • architecture
  • spring-boot

Everyone says microservices are the answer. They're rarely the first answer.

I've been through two large monolith-to-microservices migrations, one that went reasonably well and one that nearly destroyed the team. The difference wasn't technology — both used Spring Boot, both had competent engineers. The difference was understanding what problem we were actually solving.


Why Monoliths Are Underrated

The monolith gets a bad reputation it mostly doesn't deserve. When you deploy together, you debug together. Cross-cutting concerns — transactions, consistent reads, structured logging — are straightforward because everything runs in one process. Refactoring is cheap because the compiler tells you about broken dependencies immediately.

I've worked on a Spring Boot monolith that handled 50,000 requests per minute without incident for three years. The deployment was a single ./gradlew build && docker push and a rolling update. On-call was bearable because there was one service to look at, one log stream to follow, one set of metrics to understand.

When that monolith started causing problems, it wasn't because it was a monolith. It was because the team had grown to 30 engineers and every feature branch was colliding with every other one at the database level.


The Real Breaking Points

A monolith genuinely needs to be split when at least one of these is true:

  1. Team topology demands it. Conway's Law is real. If four independent teams all need to deploy the same artifact, they will step on each other. Microservices create organizational boundaries as much as technical ones.

  2. Scaling requirements are genuinely divergent. If one subsystem needs 50 instances and the rest need 2, you're wasting money and adding operational complexity running them together.

  3. Release cadences are incompatible. A fraud detection service that needs multiple deploys per day should not be blocked by the quarterly release cycle of the billing module.

If none of these apply, you probably don't need microservices yet.


Strangler Fig in Practice

The strangler fig pattern is the only sane way to migrate a live system. You don't rewrite — you wrap.

The approach: stand up Spring Cloud Gateway in front of your monolith and start routing specific bounded contexts to new services as you extract them.

# application.yml — Spring Cloud Gateway routing
spring:
  cloud:
    gateway:
      routes:
        - id: notifications-service
          uri: http://notifications-service:8080
          predicates:
            - Path=/api/notifications/**
        - id: monolith-fallback
          uri: http://monolith:8080
          predicates:
            - Path=/**

Everything still hits the gateway. The monolith handles everything until a new service is ready, then the gateway routes that prefix away. The monolith never knows the service was extracted — it just stops receiving those requests.

We migrated one bounded context at a time, starting with the lowest-coupling ones. The notifications module had clean boundaries and no shared database tables with core domain entities. It was extracted in three weeks. The billing module, which had 12 foreign key relationships into the user schema, took four months and caused two production incidents.


The Traps Nobody Warns You About Enough

Distributed transactions. The moment you split a database, you lose ACID. If order creation and inventory reservation used to happen in one transaction, you now have two services that can fail independently. You need sagas, compensating transactions, or you need to accept eventual consistency — none of which are simple.

Chatty services. I've seen architectures where an API call results in 15 synchronous service-to-service calls before a response is returned. The latency compounds, the error surface multiplies, and you've built a distributed monolith with extra steps.

Shared databases. The worst pattern: two services that call themselves independent but talk to the same PostgreSQL schema. Service A changes a column, Service B breaks silently. You have the operational complexity of microservices with none of the isolation benefits.

Observability gaps. A log line in a monolith carries implicit context — request ID, user ID, session. In a distributed system, you need to propagate that context explicitly across service boundaries via trace headers. If you don't invest in distributed tracing on day one (Jaeger, Tempo, Zipkin — pick one and commit), you will spend your first three on-call incidents staring at logs that tell you nothing useful.


What I Would Do Differently

Start with three or four large services, not twenty small ones. "Micro" is a marketing term, not an architectural constraint. A service that owns a coherent bounded context and has a team responsible for it is the right size.

Establish contract testing with Spring Cloud Contract before you split anything. If services can independently break each other's API contracts, you've traded one problem for a worse one.

Invest in centralized logging on day one. Not "we'll add it later." ELK or Loki with Grafana, structured JSON logs, correlation IDs propagated via Spring Cloud Sleuth or Micrometer Tracing. The first time production breaks at 2 AM across three services, you'll be grateful.

And before you start: write down the specific problem the migration is solving. If you can't articulate it in one sentence, you're not ready.


Microservices solve real organizational and operational problems. They also create real complexity that your team needs to manage indefinitely. The question is never "should we use microservices?" — it's "is the problem we're solving worth the complexity we're taking on?"

Sometimes the answer is yes. Make sure it is before you start.

← back to blog