Application Modernization · November 2024

Modernizing Java Applications Without a Full Rewrite

Full rewrites of Java enterprise applications fail more often than they succeed. There are better approaches — and they start with understanding why rewrites fail.

Java Spring Boot Microservices Strangler Fig

Why rewrites fail

The appeal of a full rewrite is understandable. You look at a ten-year-old Java application with no test coverage, tightly coupled modules, a data model that reflects a business that no longer exists, and a deployment process that requires a four-hour maintenance window, and the idea of starting fresh with modern architecture sounds compelling.

The problem is what happens next. The team starts building the new system in parallel with the old one. After six months, the new system is partially functional. Meanwhile, the business continues to add features to the old system — because the old system is in production and the new one isn't. By month twelve, the gap between the two systems has grown. The new system has made architectural decisions based on requirements that have changed. The migration date keeps moving. Eventually the project is cancelled, the engineers who built the new system leave out of frustration, and the old system continues unchanged.

This pattern is common enough that it has a name: the "second system effect" or, more colloquially, the "big bang rewrite." The problem isn't that rewrites are inherently impossible — it's that they require a stable period where the existing system is frozen and the new one can catch up, and that period never arrives in a functioning business.

The strangler fig pattern

Martin Fowler documented the strangler fig pattern in 2004, and it remains the most reliable approach for migrating away from a legacy monolith without a full rewrite. The name comes from the strangler fig plant, which grows around a host tree until it eventually replaces it.

The core idea is simple: instead of building a replacement system in parallel, you intercept calls to the existing system and gradually redirect them to new services as those services are built. The existing system continues to function throughout. New services are introduced incrementally, one capability at a time. When a capability is fully migrated, the corresponding code in the monolith is decommissioned.

In practice, this requires an entry point — an API gateway or reverse proxy that sits in front of the monolith and can route traffic to either the monolith or the new services based on the request type. Kong, Nginx, AWS API Gateway, and Azure API Management are all viable options. The routing rules start entirely pointing at the monolith and gradually shift as new services come online.

Identifying where to start

Not all parts of a monolith are equal candidates for early extraction. A useful starting heuristic is to look for bounded contexts that have the combination of: high rate of change (frequently modified, causing friction), relatively clear domain boundaries (limited coupling to the rest of the monolith), and high business value (a reason the business would benefit from independent deployment).

Notification services are often good early candidates — they're frequently modified, they have a clear boundary (events go in, messages go out), and they tend to be lightly coupled to core domain logic. Authentication and identity services are similarly good candidates, and extracting them early provides security benefits beyond just architectural ones.

Avoid extracting parts of the system with heavy shared data model dependencies early in the programme. If two capabilities share twenty tables in the database, extracting one of them requires significant data migration work before the extraction can complete. Save those for later phases after you've established the migration pattern and tooling.

Managing data

Data is the hardest part of monolith decomposition. A monolith's shared database schema is the strongest source of coupling — services that share tables cannot be deployed independently, because a database change for one service potentially breaks another.

The target state is that each new service owns its own data store. But getting there requires a migration path that maintains data consistency throughout. The approaches we use most frequently are:

  • Dual-write: During migration, writes go to both the old schema and the new service's database. Once the new service is validated, the old schema entries are treated as read-only and eventually decommissioned.
  • Database view layer: Create database views that present the new service's data model from the existing tables without migrating the data immediately. The service code talks to the view layer rather than the raw tables, allowing the data model to evolve gradually.
  • Synchronisation service: A dedicated synchronisation service keeps both databases consistent during the transition period, with reconciliation checks to surface inconsistencies early.

None of these are elegant. Data migration is inherently messy in a running production system. The goal is to manage the mess in a controlled way rather than hoping for a clean cutover.

The anti-corruption layer

When new services need to interact with the monolith during the transition period (which is almost always), an anti-corruption layer (ACL) is essential. The ACL translates between the new service's domain model and the monolith's domain model, preventing the new service from inheriting the monolith's design decisions.

Without an ACL, the new service's API contracts and internal domain model tend to mirror the monolith's — because the simplest integration is to expose the monolith's data structures directly. This means the new service inherits the technical debt it was supposed to escape. The anti-corruption layer adds a translation step that keeps the two domain models separate.

In Spring Boot, this typically means a service layer that translates between the internal domain model and the integration contracts, with explicit mapping rather than shared DTOs.

Testing strategy during migration

Monolith migration presents a specific testing challenge: you're making changes to a production system with low test coverage. The new services you're building should have comprehensive test coverage — but you also need a safety net for the integration between old and new.

Contract testing with Pact has been valuable in several migration programmes we've run. Consumer-driven contract tests define the API contract from the consumer's perspective (what the new service expects from the monolith, or what a consuming system expects from the new service) and verify it automatically. This provides confidence that the API surface is stable during the period when both systems are running in parallel.

Setting realistic expectations

Monolith decomposition is a multi-year programme for any non-trivial system. A twelve-year-old core banking platform isn't going to be decomposed in six months, and any scope that suggests it will is setting the programme up for a reset.

The realistic expectation is that meaningful improvements become visible within the first year — at least one or two domains running independently, deployment frequency improving for those domains — but the full migration takes considerably longer. The benefit of the incremental approach is that those early wins are real and in production, not hypothetical.

The mistake to avoid is treating the programme as a success only when the monolith is fully decommissioned. Each domain that's extracted, each maintenance window that's eliminated, each team that can deploy independently is a concrete improvement. Track and communicate those intermediate outcomes.

Related resources

Our banking platform modernization case study documents a six-phase strangler fig migration across 26 months. Our application modernization service page covers how we approach these programmes in more detail.

AlgoDomain Solutions

Engineering insights from our delivery teams.