You inherit an application that has been running for 20 years. It processes millions of transactions, has hundreds of tables, and is used by thousands of users. The business wants faster releases. The development teams want independent deployments. Someone proposes: “Let’s convert it into microservices.”

If you have survived enterprise software for as long as I have, you know that this is exactly where modernization projects go wrong.

A complete rewrite is rarely the answer. In this article, I will explain how I would approach the modernization of a large, business-critical Java monolith.

(Note: The scenario discussed here is a composite of real-world architectural challenges I’ve encountered, simplified for discussion.)


First Principle: Don’t Start by Rewriting

The first mistake I would avoid is attempting a “big bang” rewrite.

When looking at legacy code, engineers often see technical debt. But the existing system is not just code—it is accumulated business knowledge. Those massive CustomerService.java files and deeply nested if/else statements contain 20 years of bug fixes, regulatory compliance rules, edge cases, and hard-learned lessons.

If you rewrite the system from scratch without understanding it, you will simply rediscover all those bugs the hard way in production. A rewrite is only justified if the business model is fundamentally changing or the current technology stack is entirely unsupported (e.g., a dead language). In most Java enterprise scenarios, the stack can be modernized incrementally.

Step 1: Understand the Existing Architecture

Before touching the code, I would map out what the system actually does today.

This involves two types of discovery: Application Discovery and Data Discovery.

For Application Discovery, I look beyond just the REST APIs. I need to find the scheduled batch jobs running at 2 AM, the internal shared libraries that every module depends on, the authentication mechanisms, and external integrations (like legacy SOAP services or FTP file drops).

For Data Discovery, I categorize the data. Which tables are highly transactional? Which are reference data? Which are used for audit or reporting?

Everything is usually connected in a legacy monolith.

Current Legacy Monolith Architecture

The architecture may look clean on a whiteboard, but at the database level, cross-module joins and foreign keys bypass all application-layer boundaries.

Step 2: Identify Business Boundaries

Once I understand the existing state, I apply Domain-Driven Design (DDD) to find business capabilities.

However, I would not use generic definitions of DDD. A package called com.enterprise.customer does not automatically mean I have found a “Customer” microservice. Instead, I look for ownership and transaction boundaries.

If the Customer module needs to instantly update the Order module within a single database transaction, they are tightly coupled. Extracting them into two separate services without fundamentally changing how the business accepts eventual consistency will result in a distributed monolith.

Step 3: Build an Architecture Dependency Map

With business capabilities identified, I would map their dependencies to determine where to start.

I prioritize based on:

  1. High Business Value: Areas where the business needs to innovate faster.
  2. High Scalability Requirements: Components that need to scale independently (e.g., holiday sales traffic).
  3. Coupling and Operational Risk: How deeply entangled the module is.

I would plot these on a matrix. A low-coupled, high-business-value module is the perfect candidate to extract first.

Step 4: Choose the First Service Carefully

The first extracted service should not necessarily be the most important business domain. Extracting the core OrderTransaction module first is a recipe for disaster.

Instead, I look for a “walking skeleton”—a service with a reasonable boundary, clear ownership, and limited dependencies. It might be something like the Notification or PDF Generation capability. This allows the team to build out the new deployment pipelines, establish observability, and prove the architecture with acceptable risk.

The Strangler Fig Pattern

To modernize safely, I would use the Strangler Fig pattern. We don’t turn off the old system; we gradually route traffic to new services while the monolith shrinks.

Strangler Fig Pattern

By placing an API Gateway or Routing Layer in front of the application, clients do not need to know whether their request was handled by the 20-year-old monolith or the 2-week-old microservice. The final architecture is achieved through a sequence of controlled transitions.

Database Is the Hard Part

Extracting application code is usually the easy part. The database is where the real complexity appears.

Simply extracting a “Payment Service” but having it read and write to the same shared relational database does not create true service independence. You still suffer from schema-coupling, shared connection pools, and database-level locks. True microservices require independent data ownership.

Database Migration Strategy

To separate the data, I follow a controlled process. Suppose we are extracting the Customer functionality:

  1. Identify source-of-truth tables in the legacy DB.
  2. Define the target schema for the new service.
  3. Perform a bulk migration to copy historical data.
  4. Establish Change Data Capture (CDC) to keep the new database synchronized with ongoing changes in the legacy system.

Data Migration Strategy

Using CDC (like Debezium + Kafka), any update in the legacy database is streamed to the new database. This ensures the new service has up-to-date data without requiring the legacy application code to be modified.

Shadow Traffic

Before routing actual users to the new service, I run shadow traffic.

The API Gateway routes the live request to the legacy monolith, but asynchronously sends a copy of that same request to the new Customer service. We then compare the responses. If the new service returns a different result, we have found a bug before it impacted a real user.

System-of-Record Transition

This is the most critical phase.

At first, the Legacy DB is the System of Record. The new DB is merely a read-replica populated by CDC. Once we validate the new service through shadow traffic, we must execute a System of Record Transition.

System of Record Transition

At a precise cut-off timestamp, the API Gateway begins routing live write-traffic to the new service. The new database becomes the System of Record. We often reverse the CDC pipeline to stream updates back to the legacy database just in case older modules still rely on that data.

We always keep a rollback path.

API and Contract Migration

When extracting APIs, I do not simply expose the new internal database models. I use Consumer-Driven Contracts to ensure backward compatibility. If a legacy client expects a specific JSON structure, the new API Gateway must transform the modern response to match the legacy contract until all consumers can be upgraded.

Event-Driven Architecture

In a monolith, components call each other synchronously. In a distributed architecture, this creates cascading failures.

I introduce Event-Driven Architecture (e.g., using Kafka) for state changes. When an order is created, the Order Service publishes an OrderCreated event. The Payment and Notification services listen to this event and react asynchronously.

However, I would not introduce Kafka everywhere. Synchronous REST/gRPC calls remain appropriate when a client needs an immediate read response.

Observability

Observability becomes vastly more important. A stack trace in a monolith tells you exactly what went wrong. In a distributed system, a request might span five services.

I would establish distributed tracing (using OpenTelemetry), centralized logging, and strict correlation IDs passed in HTTP headers. If an error occurs in the Payment service, I need to trace it all the way back to the API Gateway.

Testing Strategy

My testing strategy would rely heavily on Characterization Tests.

Before changing legacy behavior, I write tests that capture what the existing system actually does, not what the outdated documentation claims it does. Once the new service is built, contract tests and migration validation tests ensure we haven’t broken the business logic.

Deployment Strategy

I utilize CI/CD with extensive use of feature flags.

Feature flags separate “deployment” from “release.” I can deploy the new Customer service to production on a Tuesday, but keep it hidden behind a flag. On Thursday, I can turn the flag on for 1% of internal users (a canary deployment) and monitor error rates before rolling it out globally.

Security

As we break the monolith apart, network boundaries change. I would implement strict service-to-service authentication (e.g., mTLS), robust secrets management, and ensure each service operates with the principle of least privilege regarding data access.

Organizational Architecture

Conway’s Law dictates that systems mirror the communication structures of the organizations that build them.

Microservices are not only a technical transformation; they are an organizational one. If three different teams still need to coordinate and wait for each other to deploy their respective microservices, we have failed. We must align team ownership with service boundaries.

What I Would Not Do

❌ Big-bang rewrite: Stop the world and rewrite everything. It almost always fails. ❌ Extracting services based only on Java packages: Packages do not equal business domains. ❌ Creating hundreds of microservices immediately: Start with coarse-grained macro-services. ❌ Keeping a shared database forever: It defeats the purpose of independent deployment. ❌ Introducing Kafka everywhere: Not everything needs to be asynchronous. ❌ Migrating data without reconciliation: Always prove the data matches. ❌ Switching traffic without rollback: Never make a one-way architectural jump.

A Realistic Migration Roadmap

A practical migration roadmap looks like this:

  • Phase 0 — Discovery: Architecture, dependency, and data analysis.
  • Phase 1 — Stabilize: Add observability, testing, and deployment automation to the monolith.
  • Phase 2 — First extraction: Extract a low-risk, high-value capability (the walking skeleton).
  • Phase 3 — Data separation: Establish CDC and dedicated data ownership.
  • Phase 4 — Scale extraction: Extract additional business capabilities gradually.
  • Phase 5 — Retire monolith components: Delete the dead code in the legacy repository.
  • Phase 6 — Decommission: Retire the remaining legacy infrastructure once empty.

The Target Architecture and Trade-Offs

Eventually, you reach a modern, distributed architecture.

Target Microservices Architecture

But I am always honest about the trade-offs. Microservices give us independent deployment, scaling, and team autonomy. However, they introduce network failures, data consistency challenges, and immense operational overhead. Microservices are not a direct upgrade from a monolith; they are an entirely different set of trade-offs.

My Approach in One Sentence

I would not try to replace a 20-year-old monolith; I would gradually make it less important.

Modernization Checklist

  • Understand the existing system
  • Map business capabilities
  • Map dependencies
  • Identify service boundaries
  • Establish observability
  • Add characterization tests
  • Choose first extraction
  • Define data ownership
  • Plan migration
  • Introduce CDC where appropriate
  • Validate with shadow traffic
  • Transition system of record
  • Gradually route production traffic
  • Monitor
  • Retire legacy functionality

If you are navigating an enterprise modernization journey, feel free to review my About page to learn more about my background in Cloud and Distributed Systems architecture.