Back to Blog

How We Migrated a 10-Year-Old Java Monolith to a Modern Stack — Full Story

Published: June 24, 2026
How We Migrated a 10-Year-Old Java Monolith to a Modern Stack — Full Story

Java 8, 200,000 lines of code, zero automated tests, one Oracle database that nobody wanted to touch, and a single deployment that took 45 minutes and required three people on a call hoping nothing broke.

That was the state of the client's application when we first saw it. A 10-year-old Java monolith running an e-commerce platform that processed millions of dollars in transactions every month. It worked. It generated revenue. And it was becoming impossible to change.

The client wanted a modern stack — NestJS for the backend, Next.js for the frontend, PostgreSQL for the database. But they also wanted the system to keep running while we rebuilt it. No downtime. No big-bang switchover. No "we'll turn off the old system on Friday and hope the new one works."

This is the full story of that Java monolith migration to a modern stack. We used the Strangler Fig pattern, extracted modules one at a time, migrated the database from Oracle to PostgreSQL, and delivered a working new system 14 months later — about six months later than we planned, but with no production incidents and a client who trusted us enough to let us do it again.

Steel framework cabinets housing servers networking devices in data center representing Java monolith migration infrastructure

The Assessment: What We Found Before Touching Code

Before writing a single line of new code, we spent three weeks mapping the existing system. This was not optional — you cannot migrate what you do not understand.

The monolith was a standard Spring MVC application with a thick service layer, JSP templates on the frontend, and Hibernate ORM connected to Oracle. The codebase had grown organically over a decade. Multiple developers had come and gone. The only documentation was the code itself, and the code did not always tell the truth.

We identified four things we needed before starting:

Module boundaries. The application had seven logical domains — product catalog, inventory, pricing, orders, customers, payments, and shipping. The boundaries were blurry (every service seemed to import every other service), but they existed. We documented every cross-module dependency and ranked modules by how tightly coupled they were.

Data dependencies. The Oracle schema had 140+ tables with foreign keys spanning all seven domains. We mapped which tables belonged to which module and identified the shared tables that multiple domains depended on. The shared tables became the hardest problem — they could not be cleanly assigned to any single new service.

Business rules. We ran the existing application test suite (there were eight integration tests that the team had been keeping alive) and shadowed the support team for a week to learn the edge cases. The support tickets told us more about the undocumented behavior than the code did.

Deployment process. The 45-minute deployment involved a manual database migration script, a WAR file deploy to Tomcat, and a smoke test that the team had memorized but never written down. We needed the new system to deploy in under five minutes with zero manual steps.

The assessment told us one thing clearly: a big-bang rewrite was not an option. The monolith was too large, too untested, and too interwoven to replace in one shot.

The Decision: Strangler Fig Pattern

We chose the Strangler Fig pattern — build the new system incrementally alongside the old one, routing traffic module by module until the monolith is empty and can be shut down.

The pattern, described by Martin Fowler in his original Strangler Fig Application article and documented as the Azure Strangler Fig pattern, has three phases:

  1. Wrap. Add a routing layer (API gateway or reverse proxy) in front of the monolith. All traffic flows through this layer.
  2. Replace. Build new services for each module. Route traffic for that module to the new service while the rest of the traffic continues to hit the monolith.
  3. Remove. Once all modules are migrated, decommission the monolith.

The key decision was which module to extract first. We chose the product catalog — it was the most self-contained domain, had the fewest cross-module dependencies, and was the module the client wanted to improve most urgently. Starting with a low-risk module let us validate the migration approach before tackling the harder domains.

Phase 1: Adding a REST API Layer

The monolith served JSP pages directly. There was no API layer — the frontend and backend were compiled into the same WAR file. Before we could extract any module, we needed a clean API boundary.

We added a thin REST API layer to the monolith. Not a rewrite — just REST controllers that wrapped existing service methods and returned JSON:

JAVA
1@RestController
2@RequestMapping("/api/products")
3public class ProductController {
4    private final ProductService productService;
5
6    @GetMapping("/{id}")
7    public ResponseEntity<ProductDto> getProduct(@PathVariable Long id) {
8        Product product = productService.findById(id);
9        return ResponseEntity.ok(toDto(product));
10    }
11}

This gave us two things. First, the Next.js frontend could talk to the monolith through a clean API without touching JSP templates. Second, when we extracted the product module to a new NestJS service, the API contract stayed the same — we just changed the routing configuration in the API gateway to point product requests at the new service.

We built the Next.js frontend against this API layer while the monolith still handled all the backend logic. The client saw immediate value — a modern UI running against the same old backend — which built trust for the harder phase to come.

Phase 2: Extracting the Product Catalog

The first module extraction was the product catalog. We built a NestJS service with its own PostgreSQL database for product data, migrated the product tables out of Oracle, and pointed the API gateway at the new service for all /api/products/* routes.

The NestJS service followed our standard project structure with controllers, services, and repositories organized by domain:

TypeScript
1// product.module.ts
2@Module({
3  controllers: [ProductController],
4  providers: [ProductService, ProductRepository],
5})
6export class ProductModule {}

The NestJS monorepo setup, using the pattern described in our SaaS monorepo structure post, let us keep all extracted services in a single repository with shared TypeScript types.

The API gateway routing looked like this in Nginx:

NGINX
1location /api/products {
2    proxy_pass http://nestjs-product-service:3000;
3}
4
5location /api/ {
6    proxy_pass http://java-monolith:8080;
7}

Every request to /api/products/* went to the new NestJS service. Everything else went to the monolith. The frontend had no idea anything had changed — it called the same API endpoints and got the same JSON shapes.

Phase 3: Database Migration from Oracle to PostgreSQL

The database migration was the hardest part. Oracle and PostgreSQL speak different SQL dialects, handle transactions differently, and have incompatible data types.

We migrated the product catalog tables first, using these steps for each table group:

Schema translation. Oracle's NUMBER(10) became PostgreSQL's INTEGER. Oracle's VARCHAR2(255) became VARCHAR(255). Oracle's CLOB became TEXT. Oracle sequences became PostgreSQL SERIAL or IDENTITY columns.

Data migration in batches. We wrote batch scripts that read 10,000 rows at a time from Oracle, transformed the data to PostgreSQL format, and wrote it to the new database. Each batch ran inside a transaction so failures could be rolled back without corruption.

Change data capture. During the migration window, the Oracle database kept receiving writes from the monolith. We used a CDC process to sync changes from Oracle to PostgreSQL — any row inserted or updated in Oracle after its migration batch ran was applied to PostgreSQL within seconds.

Validation. After the initial migration and throughout the CDC sync, we ran validation queries that compared row counts, checksums, and recently updated records between the two databases. Any discrepancy triggered an alert and paused the cutover.

The process worked. The product catalog migration took three days of engineering time and about four hours of wall-clock time for the data transfer. The monitoring dashboard showed zero errors and zero data discrepancies.

Phase 4: Extracting the Remaining Modules

With the product catalog proven, the remaining modules followed the same pattern. Each extraction took two to four weeks:

  1. Build the NestJS service with its API endpoints
  2. Migrate the Oracle tables to PostgreSQL
  3. Update the API gateway routing
  4. Sync data changes during cutover
  5. Validate and decommission the old code

The order mattered. We extracted inventory next (it depended on products), then pricing (depended on products and inventory), then orders (depended on everything), and finally customers, payments, and shipping.

Each extraction revealed something unexpected about the monolith. The pricing module had a discount calculation that referenced a static configuration file nobody had updated in three years — the discount values were hardcoded in a properties file that the team had forgotten existed. The orders module had a stored procedure that did data cleanup as a side effect of the order creation flow — the cleanup was undocumented and we only discovered it when the new service's order metrics diverged from the old reports.

Colorful programming code on a screen representing Java monolith migration to modern NestJS stack

The Things Nobody Warns Us About

Three problems that cost us weeks each:

Shared database tables. Some tables were used by multiple domains. The users table was referenced by almost every service. We could not migrate it cleanly to a single new service because no single domain owned it. The solution was a shared user service — a lightweight NestJS service that owned the user data and exposed it via API to every other service. Not elegant, but better than keeping a shared Oracle table.

Undocumented state in the monolith. The Java application used an in-memory cache for reference data (country lists, tax rates, shipping zones) that was populated at startup and never persisted. When we routed traffic for a module to the new service, the cache was gone. We had to build a Redis cache for the new services and pre-populate it during the migration window. Our Redis caching strategy post covers the pattern we used.

The human factor. The client's Java developers had been maintaining this monolith for years. Watching their code get replaced by a new stack was hard, even when they agreed it was necessary. We paired them with our NestJS developers on every module extraction so they learned the new stack by building it, not by watching someone else build it. The team that ended the project was a NestJS team, not a Java team.

Timeline: Planned 8 Months, Took 14

The honest timeline:

  • Months 1-2: Assessment, REST API layer, Next.js frontend build
  • Months 3-4: Product catalog extraction (including Oracle to PostgreSQL migration tooling)
  • Months 5-7: Inventory, pricing, and order extraction
  • Months 8-11: Customer, payment, and shipping extraction
  • Months 12-13: Monolith decommissioning, performance optimization, and bug fixes
  • Month 14: Final validation, documentation, and handover

The overrun came entirely from the undocumented behavior we discovered during each module extraction. The pricing module discount calculation. The order stored procedure with side effects. The shared cache that nobody knew about. Each discovery added a week or two of investigation and rework.

We could have built a faster timeline by being less careful — skip the validation queries, batch larger data migrations, cut over without a rollback plan. The risk of a production incident would have been higher, and the client would not have trusted us to migrate the remaining modules.

The lesson: budget for discovery. The first 80% of the migration takes 80% of the time. The last 20% also takes 80% of the time, because that is where the hidden complexity lives.

What We Would Do Differently

Three things for the next Java monolith migration:

Start the data migration earlier. We waited until the service code was ready before migrating the database. The data migration was the bottleneck for every module. Next time we will migrate the data first — build the PostgreSQL schema, run the migration batches, and validate the data while the service code is still being written.

Add more validation checkpoints. We validated at the module level (product data matches, order counts match) but not at the integration level. When we migrated the orders module, we discovered that a pricing endpoint the orders service called had different response times in the new system. Add cross-module integration validation before each cutover.

Plan the decommissioning from day one. We left the old module code in the monolith for weeks after each extraction, telling ourselves we would remove it later. The monolith grew harder to read as modules were extracted but not cleaned up. Decommission the old code in the same sprint as the cutover, not later.

The Result

The new system runs on NestJS and Next.js with PostgreSQL, deployed in Docker containers on a Kubernetes cluster. Deployments take 90 seconds instead of 45 minutes. Adding a new feature takes days instead of weeks. The database is PostgreSQL — a database the team can actually query without Oracle licensing costs.

The client still talks about the migration as the project that was six months late and perfectly on time. Every module worked on the first cutover. Zero production incidents. Zero data loss. And a team that started the project maintaining a Java monolith ended it building features in a modern stack.

Large projects succeed less than 10% of the time. Our migration succeeded because we did not treat it as a large project. We treated every module extraction as a separate small project with its own timeline, its own rollback plan, and its own validation gate. Ten small projects, each with ~90% success odds, beats one big project with ~10% odds every time.

The Strangler Fig pattern is not fast. But it is reliable, and in production migrations, reliable beats fast.

Laptop screen with code and coffee mug representing Java monolith migration development work

Frequently Asked Questions

The Strangler Fig pattern is an incremental migration approach where you build new functionality alongside the legacy system and gradually route traffic from the old system to the new one. Named after the strangler fig vine that grows around a host tree until it replaces it, the pattern has three phases: wrap (add a routing facade in front of the monolith), replace (build and route to new services one module at a time), and remove (decommission the legacy system after all modules are migrated). It is the safest alternative to a high-risk big-bang rewrite.

Use Oracle's built-in migration tools or third-party solutions like AWS Schema Conversion Tool (SCT) to map data types and schemas. Key differences to handle: Oracle's NUMBER maps to PostgreSQL's NUMERIC or INTEGER depending on precision; Oracle sequences map to PostgreSQL SERIAL or identity columns; Oracle's VARCHAR2 to PostgreSQL's VARCHAR or TEXT; and stored procedures require manual translation from Oracle PL/SQL to PostgreSQL PL/pgSQL. Migrate data in batches rather than a single dump to reduce downtime, and use change data capture (CDC) to sync changes between the old and new databases during the cutover window.

The biggest risk is the undocumented behavior in the legacy system. After 10 years of production use, the codebase contains business rules, edge case handlers, and data cleanup routines that nobody remembers writing and no test covers. When you extract a module, you inevitably discover that it depends on a piece of shared state you did not identify. The mitigation is to add an anti-corruption layer between the new and old systems, use feature flags for every cutover, and budget at least 30% of your timeline for 'unknown unknowns' — the bugs you find only when real traffic hits the new code.

Always choose incremental migration (Strangler Fig pattern). Large projects succeed less than 10% of the time, while small incremental projects succeed about 90% of the time (Standish CHAOS data). A full rewrite of a 200,000-line Java monolith with no test suite is the definition of a large project. By extracting one module at a time, you turn a single doomed project into a series of small projects with much better odds. You also ship value continuously — the first extracted module is in production weeks after the project starts, not months.

Plan for 1.5x to 2x your initial estimate. Our project was scoped at 8 months and took 14. The overrun came from undocumented business logic in the legacy system that we only discovered when real production traffic hit the new modules. Each module extraction revealed edge cases that the old system handled by accident — a default value here, a silently swallowed error there. Budget time for discovery and expect the timeline to stretch. The Strangler Fig pattern makes the overrun manageable because you are shipping working modules the whole time, rather than waiting 14 months for a big bang that might never arrive.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio