Monolith to Microservices Migration: A Behavior-Preserving, Test-First Approach

One of the biggest risks in a monolith-to-microservices migration is quietly dropping a business rule nobody remembers writing down. The technology stack is rarely the hardest part. The harder problem is proving that the new implementation preserves the behavior the business still depends on — including edge cases that surface only under production conditions. 

Across several legacy modernization projects — from a decade-old PHP platform transformed into a hybrid architecture with a modernized core and independent Laravel services to a core insurance workflow system rebuilt from Haskell on .NET 8 — we've converged on the same risk-reduction sequence. The destination wasn't always a fully decomposed microservice landscape. What stayed consistent was the need to map the existing system, preserve its behavior, and move functionality across controlled boundaries.

What we mean by "behavior contract" in this article: it is not just an API specification, schema, or interface definition. Here, the contract is the legacy system's externally observable behavior — its inputs, outputs, side effects, validation rules, authorization decisions, events, and failure scenarios. This approach is built around preserving the required parts of that behavior, and proving they have been preserved.

Key takeaways

  • Behavior preservation is the first migration goal, not the only one. The risk in a monolith-to-microservices migration isn't picking the wrong framework — it's silently losing business rules nobody remembers writing down. Architectural redesign should begin only after the required legacy behavior has been identified and protected.
  • Mapping comes before decomposition. Service boundaries should reflect the dependencies and domains you actually found in the legacy system, not a theoretical clean-slate architecture.
  • Tests are the proof, not the paperwork. Porting and validating tests before writing new code turns "does this work?" into a pass/fail check instead of a judgment call — and catches gaps before they reach production instead of after.
  • Parallel execution requires real decoupling. Migrating and shipping product features at the same time only works if service boundaries are genuinely independent; otherwise teams end up with contract drift and incompatible changes made to the old and new systems at the same time.
  • Incremental rollout de-risks the cutover. Validating on a contained slice — a module, a sub-brand, a staging environment — before extending to the whole system helps avoid a big-bang failure.
  • Full decomposition isn't always the goal. A hybrid architecture can be the right destination when only selected domains benefit from independent evolution and scaling. Define completion against that target: full replacement means the legacy implementation is no longer required, while a hybrid target means stable, owned boundaries between the retained core and extracted services. 
  • Data access can preserve coupling after the code is split. Shared tables, data models, and write paths need to be mapped explicitly; otherwise, supposedly independent services may still require coordinated changes and behave like a distributed monolith.

The short version — the five steps:

  1. Map the monolith's domains, dependencies, and hidden couplings before writing any new code.
  2. Design target boundaries around what you actually found — not a theoretical clean-slate structure. 
  3. Port the tests first, so the new implementation is validated against the defined behavior contract, not code review judgment. 
  4. Run migration and product development in parallel, without freezing the existing roadmap.
  5. Roll out in controlled stages — test and staging first, followed by a contained or phased production release appropriate to the system.

5 steps of a monolith-to-microservices migration

The rest of this article walks through why each step exists and what happens if you skip it.

Why “Rewrite From Scratch” Usually Fails

The intuitive approach to migrating a monolith to microservices is to read the old code, understand what it does, and write a cleaner, decomposed version. The problem is that "what it does" and "what the team believes it does" tend to diverge after years of patches, workarounds, and undocumented fixes. A rewrite based on current understanding, rather than on the system's actual behavior, silently drops the parts nobody remembers adding — until a customer hits them in production.

The fix isn't more careful reading. It's replacing "understanding by reading" with "understanding by mapping and testing" before a single service gets extracted.

Before You Start: Do You Actually Need Microservices?

Everything below assumes that microservices is the right destination. That's worth checking first, because the same behavior-preserving discipline applies whether you're extracting services or just cleaning up an existing monolith — and the wrong destination is expensive to reverse.

In our PHP modernization project, the case for architectural decomposition wasn't based on the age or size of the monolith alone. The platform had accumulated duplicated module behavior, implicit dependencies, inconsistent data-handling approaches, outdated libraries, and changes that required rebuilding and retesting large parts of the system. The architecture also limited horizontal scaling, making new CRM, analytics, mailing, and automation integrations increasingly difficult to maintain.

Before choosing microservices, identify the specific constraint you're trying to remove: coordinated releases, scaling limitations, integration friction, maintenance risk, or a domain that needs to evolve independently.

The answer doesn't have to be a full microservice decomposition. In the PHP project, the resulting architecture combined a refactored PHP 8.3 core with new Laravel services. The remaining core received explicit module boundaries and standardized interfaces, while only functional areas requiring greater flexibility were implemented as independent services. For other systems, the appropriate destination may be a modular monolith, selective replatforming, or the extraction of only one or two domains.

PHP monolth: before and after

Once the target architecture is defined, the work shifts from deciding what the system should become to planning how to get there safely. The steps below outline the transition, starting with an accurate map of the current monolith and progressing through decomposition, extraction, validation, and rollout, preserving existing behavior throughout the modernization process.

Step 1: Map the monolith before you touch anything

Before any new code is written, the existing system gets inventoried: modules, dependencies, integration points, and — critically — the implicit couplings between subsystems that were never documented. This step exists specifically to surface cyclic dependencies and hidden assumptions, which are what naive migrations of monolithic applications miss most often.

In the PHP platform project, this took the form of a full architectural and domain audit: cataloguing every module and dependency, then classifying which pieces could be isolated, optimized, or extracted as standalone services. That inventory — not intuition — is what determined which parts of the monolith became independent microservices and which stayed in a shared core.

Choosing what to extract. In the PHP project, the architectural inventory was used to classify components into three groups: those that could be isolated, those that first needed internal optimization, and those that were suitable for extraction as standalone services. This prevented the team from treating every module as an equally good microservice candidate.

The same assessment should guide the first extraction. A useful candidate has a recognizable business boundary, limited dependencies on the remaining core, and a concrete reason to evolve independently. The point isn't to start with the easiest module, but to choose a contained domain that can demonstrate whether the migration architecture, testing process, and integration model work in practice.

Step 2: Design target boundaries from what you found, not from a blank page

Once the existing boundaries are mapped, the target interfaces should reflect the behavior and responsibilities actually found in the system rather than an imagined clean-slate structure. In the insurance transformation, we first analyzed the Haskell modules, their dependencies, and their role in the business workflows. The resulting .NET interfaces reflected those original functional boundaries, allowing the team to migrate and validate behavior before implementing the new code.

The PHP modernization required a different outcome. Some functional areas were extracted into independent Laravel services, while the remaining core was reorganized into bounded modules. In both cases, mapping determined the target boundary. Microservice extraction was one possible result, not the predetermined one.

This is a subtle but important distinction when migrating monolithic to microservices architecture. The goal at this stage is translation, not redesign — but translation isn't the same as copying the old code's structure wholesale. Legacy behavior should shape these transitional boundaries. The final service boundaries should also account for business capabilities, data ownership, change patterns, and independent deployability. Otherwise, the old system's coupling just moves onto the network instead of disappearing. Redesign comes later, once behavior is locked in.

Step 3: Port the tests before you port the code

This is the step that turns behavior preservation from an intention into a verifiable engineering process. 

Before implementing the corresponding logic in the target architecture, existing tests are migrated and adapted to the new interfaces first. Only after those tests are in place does implementation of the new code begin. In the insurance system project, this order was strict: interfaces were defined, tests were migrated to validate against those interfaces, and only then was the .NET implementation written against both.

Existing tests are a starting point, not necessarily a complete picture — they capture only the behavior that was already covered. In the insurance project, the existing unit tests were migrated, adapted to the new .NET interfaces, and expanded before the implementation was written. This created a validation layer around the legacy behavior while the business rules were being translated from Haskell into an object-oriented design.

Where the existing suite leaves gaps, additional characterization scenarios should be created from the production evidence and domain knowledge available to the team. The important distinction is that these scenarios are established before the corresponding implementation is rewritten, rather than being added only after discrepancies appear.

The effect is that the question, "Does the new implementation preserve the required behavior?", stops being a matter of code review judgment and becomes a pass/fail check against the behavior contract captured in the migrated tests and additional characterization scenarios. Interfaces and mocks were used extensively in the insurance transformation to simulate complex legacy dependencies while the .NET implementation was being developed. They allowed the team to isolate the new components and validate them against defined interactions before the complete system was available. Because mocks validate an expected interaction rather than the full behavior of a live dependency, they should be complemented by broader system verification during the test, staging, and production rollout stages.

Skip this step, and here's what happens instead: Correctness is assessed by code review and manual QA against whatever the reviewer remembers about the old system's behavior. Instead of being identified during testing, gaps surface as production incidents after launch, — and by then they're customer-facing instead of internal.

The hardest boundary is usually the database

Code boundaries aren't enough on their own. Data access must also be included in the architectural mapping, because a supposedly independent service can remain tightly coupled to the monolith through shared tables, data models, or write paths.

In the PHP modernization, the target architecture included unified data models and a hybrid data-access layer alongside the new service boundaries. This reflects a broader rule: before extracting a domain, determine which data it owns, which data must remain shared during the transition, and whether the proposed boundary can support independent changes without coordinated database updates.

More complex transitions may also require explicit decisions about source-of-truth ownership, synchronization, historical data transfer, and eventual consistency. Those decisions depend on the system's data topology and should be treated as a separate migration workstream rather than assumed to follow automatically from the code decomposition.

Step 4: Run the migration in parallel with product development

In the PHP modernization, the dedicated modernization team worked alongside the client's main product team, which continued developing the existing platform. This made it possible to introduce new services and perform deep refactoring, without freezing the main branch or interrupting business operations.

That setup only works when the boundaries and integration points are explicit. Otherwise, ongoing product changes can reintroduce dependencies into the areas being separated or create incompatible assumptions between the core and the new services. Parallel delivery therefore must be designed into the migration process, not treated merely as a staffing arrangement.

Step 5: Roll out incrementally, not as a big-bang cutover

Both modernization projects described above moved through staging environments first; they then gradually moved into production, with stability monitored at each stage before proceeding further.

We applied the same sequencing principle in a different migration context. During an AEM website and product-catalog migration for a global automotive parts company, we first migrated and tested the individual sub-brand catalogs. Only after those migrations were completed did the team proceed to the main website and its catalog. This wasn't a microservice extraction, but it illustrates the same risk-control principle: validate the migration process on contained assets before applying it to the highest-impact part of the system.

How the two systems coexist: The coexistence mechanism should follow the architecture of the system rather than a pattern name. In the PHP modernization, the refactored core and the new Laravel services operated as a hybrid system. Where appropriate, the services communicated asynchronously through message queues and integrated with the core through standardized adapters and a dedicated compatibility layer. Distributed service APIs and common standards for configuration, error handling, and contracts kept the components consistent while the architecture evolved gradually.

Strangler Fig, branch by abstraction, routing proxies, and feature flags are possible ways to implement this kind of transition, but the essential requirement is the same: the old and new components need an explicit integration boundary that allows functionality to move without destabilizing the working system.

What "stable" actually has to mean:. "Stable" must be defined before the rollout advances. In the PHP modernization, automated testing, standardized integration processes, test and staging deployments, monitoring, and post-deployment stability checks were built into the release process. The platform used centralized logging and monitoring tools, including Sentry and a custom monitoring solution, to make production behavior more visible.

The exact release criteria depend on the extracted domain, but they should cover both technical behavior and the business operation being migrated. Error rates and latency matter, but so do failed workflows, inconsistent outputs, and signs that users or internal teams can't reliably complete the same tasks. Rollback conditions should be agreed before production deployment, even when the implementation doesn't involve percentage-based traffic shifting.

What This Buys You

It does not provide a "seamless" transformation — rather, it provides practical outcomes from modernizing monolithic applications this way: 

  • Predictable releases: Teams stop guessing whether a change broke something and start checking it against a test suite that already encodes correct behavior.
  • Lower regression risk: Because tests are established before implementation, required functional parity becomes testable against the defined behavior contract rather than merely assumed.
  • Parallel development without collisions: Once services are genuinely isolated, teams can ship product features and migrate the platform at the same time, rather than choosing between them.
  • Faster iteration after the fact: Once responsibilities are separated into bounded modules and independently evolving services, changes can usually be made within a smaller development and validation scope. In the PHP project, this enabled teams to work in parallel across domains and deliver functionality more predictably. In the insurance transformation, restructuring the system into independent modules enabled more frequent releases on a unified .NET stack. Cross-service workflows still require contract and end-to-end validation, but the entire platform no longer has to be treated as one undifferentiated change surface.
  • Traceable evidence of functional parity: Behavioral tests and staged system verification provide evidence that the new implementation preserves the required legacy behavior. Alongside that, CI/CD and static analysis tools like SonarQube enforce code-quality and security gates — a different, complementary kind of assurance, not a substitute for it.

When Migrating a Monolith to Microservices Doesn’t Make Sense

This behavior-preserving approach earns its return on investment when the system contains complex, business-critical logic that's difficult to reconstruct from documentation alone. That was the profile of the insurance workflow platform, where undocumented Haskell code and tightly coupled functional abstractions had to be translated without changing the existing rules and JSON-based workflows.

That doesn't mean the destination must be a fully distributed microservice system. The PHP project reached a hybrid architecture because retaining a modernized core while extracting selected services delivered the required flexibility without forcing decomposition for its own sake. For simpler systems, internal modularization, selective replatforming, or a technology upgrade may remove the actual constraint at lower operational cost.

How to Know When the Migration Is Actually Done

Shipping the new implementation isn't necessarily the end of the migration. The target state should be defined at the beginning: a fully replaced legacy platform, as in the insurance transformation, or a deliberate hybrid architecture, as in the PHP modernization.

In a full replacement, completion means that the new system owns the required functionality and the legacy implementation is no longer needed for normal operation. In a hybrid architecture, completion may instead mean that the retained core has explicit module boundaries, extracted services have stable integration contracts, and both sides can evolve without repeatedly reopening the same coupling problems.

Temporary migration components should still have an owner and an exit condition. But the correct finish line isn't always "the monolith has disappeared." It's the point at which the intended target architecture is operational, maintainable, and no longer dependent on undocumented transitional assumptions.

Is Your Monolith a Fit for This?

If your platform sits on a stack that's becoming a hiring or maintenance liability, and the logic inside it is too business-critical to risk a clean rewrite, the questions worth answering first are the same ones covered in Step 1: how coupled are your domains, where does data ownership currently sit, and how much of the behavior the business depends on is actually captured in tests? We're happy to walk through the specifics for migrating your monolith to microservices.

How useful was this article?

5
15 reviews
Recommended for you