From Monolith to Microservices: A Migration Playbook Using Spring Boot & Docker

July 6, 20266 min readTeam Five
architecturedevops
An abstract geometric banner of one large solid block decomposing into several smaller connected nodes

The failure mode we see most often in monolith-to-microservices migrations isn't technical — it's that the team ends up with a "distributed monolith": a dozen services that all still deploy together, share a database, and fail together, but now with network calls and serialization overhead added on top of the original coupling. That's strictly worse than the monolith it replaced. A migration that doesn't remove the coupling isn't a migration, it's a more expensive way to run the same system.

Here's the playbook we actually follow, using Spring Boot and Docker as the concrete stack (the principles transfer, but the specifics below assume that pairing).

Decompose along bounded contexts, not along technical layers

The single most consequential decision is where the service boundaries go, and the wrong instinct is decomposing by technical layer (an "auth service," a "database service," a "notification service") rather than by business domain. Domain-driven design's bounded context is the right unit: a service should own a coherent piece of the business (Orders, Inventory, Billing), including its own data, not a thin technical slice that every other service has to call constantly to do anything useful.

We run this as a working session with whoever actually understands the domain, not just the engineering team — draw the entities, draw who owns each one, and draw which operations need to be transactionally consistent within a boundary (those stay together) versus which can tolerate eventual consistency across boundaries (those are safe to split). If you can't answer "which service owns the canonical record of a customer's order status" in one sentence, the boundary isn't drawn yet.

The strangler fig pattern, not a rewrite

We don't recommend a parallel rewrite-and-cutover for anything beyond a small monolith — the risk of the new system diverging from the old one's actual (often undocumented) behavior is too high, and "big bang" cutovers are exactly the kind of high-stakes, hard-to-roll-back event that turns a migration into an incident.

Instead: put a routing layer (an API gateway, or in a Spring Boot context often a simple reverse proxy rule) in front of the monolith, and extract one bounded context at a time into its own Spring Boot service, with the router sending traffic for that context's endpoints to the new service and everything else still to the monolith. The monolith shrinks incrementally; users never experience a cutover event, because there isn't one — just an increasing fraction of traffic quietly moving to new services over weeks or months, one bounded context at a time, each individually low-risk and independently revertible if something's wrong.

Data consistency: the part everyone underestimates

The monolith's shared database was providing you something for free that microservices make you build deliberately: transactional consistency across everything. Split the Orders and Inventory tables into two services with two databases, and "place an order, decrement inventory" is no longer one transaction — it's two operations that can partially fail.

The saga pattern is the standard answer: model the multi-step operation as a sequence of local transactions, each with a defined compensating action if a later step fails (if inventory decrement fails after the order was created, the compensating action cancels the order). Sagas can be orchestrated (a central coordinator service directs each step) or choreographed (each service reacts to events from the previous one) — we default to orchestration for anything with more than three steps, because choreographed sagas become genuinely hard to trace during an incident once you're past a handful of participating services.

The outbox pattern solves a narrower but equally common problem: a service needs to update its own database and publish an event about that change, and doing both reliably (not publishing an event for a transaction that later rolled back, and not losing an event for one that committed) requires writing the event to an outbox table in the same local transaction as the data change, then a separate process publishing from that outbox to the message broker. It's more moving parts than "just publish an event after the save," and it's the difference between an event stream you can trust and one that quietly diverges from reality under failure conditions.

The database wasn't just storage — it was your consistency guarantee. Microservices don't remove that need, they just make you build it explicitly instead of getting it for free.

Common pitfalls, in order of how often we see them

Shared database, separate services. Two Spring Boot services pointing at the same schema is not microservices — it's a monolith with extra network hops and no actual data ownership boundary. If two services need the same data, one owns it and exposes an API; the other calls that API or consumes events, it doesn't read the table directly.

Synchronous call chains that mirror the old function-call chain. A migration that turns orderService.create() → inventoryService.reserve() → billingService.charge() (all in-process) into the same three calls over HTTP, synchronously, in sequence, hasn't decoupled anything — it's added network latency and three new failure modes to an operation that behaves identically to before, just slower and less reliable. This is usually the sign a boundary was drawn wrong, or that an operation which should be async (via an event) was kept synchronous out of habit.

No contract testing between services. Once services deploy independently, an API change in Orders that silently breaks Inventory's client code won't be caught by either service's own test suite — each passes independently while the integration between them is broken. Consumer-driven contract testing (Pact is the common choice in the Spring ecosystem) catches this at CI time instead of in production.

CI/CD changes the migration requires

A monolith has one pipeline. N microservices need N independently deployable pipelines — each service gets its own build, test, and Docker image, versioned and deployed independently, which is the entire point (deploying Orders shouldn't require redeploying Billing). This is more pipeline infrastructure to build and maintain, and it's the part of the migration cost most project plans forget to budget time for.

It also requires a shift in how the team thinks about releases: with independent deploys, "what's actually running in production" for a given feature is no longer a single git commit, it's a combination of whichever version of each service happens to be deployed at that moment. Feature flags and careful API versioning (never break a contract a still-deployed service depends on) become load-bearing in a way they weren't before, and canary or blue-green deploys per service — not just for the whole system — become worth the investment once independent deploys are frequent.

A realistic decomposition of a mid-size Spring Boot monolith into 6–8 services, done with this playbook, typically runs 4–7 months for a team of 4–6 engineers who are also still shipping features — slower than a project plan optimized purely for migration speed would like, and considerably faster than the alternative of a stalled six-month "big bang" rewrite that never quite finishes.