The hardest part of modernizing a long-lived Java application is rarely the Java. It is the web of other systems that quietly depend on how the application behaves today: partner feeds, scheduled jobs, database links, message queues, reports and users' habits. Break any of those and the modernization has failed, however clean the new code is.
Key takeaways
- Inventory every inbound and outbound integration before touching the code.
- Capture current behavior in automated tests so you can prove you have not changed it.
- Migrate incrementally behind a stable facade instead of rewriting everything at once.
- Upgrade the platform (Java, framework) as its own step, separate from restructuring.
1. Find the integrations that nobody documented
Start with discovery. Look in code, configuration and infrastructure for everything that talks to the application or that the application talks to: REST and SOAP endpoints, message queues and topics, file drops and scheduled imports, shared database tables or views, batch jobs, email and report consumers. Check production logs and network traffic as well, because actual usage often differs from the documentation. For each integration, note the owner, the contract (format, fields, timing), the volume and how critical it is.
2. Characterize behavior before changing it
Where specifications are missing or outdated, write characterization tests: tests that record what the system actually does for a representative set of inputs. They are not a statement that the behavior is correct. They are a safety net that tells you immediately when a change alters something. For integrations with other teams, add contract tests that verify the messages you send and receive match the agreed shape.
3. Put a stable facade in front
The strangler pattern replaces a system gradually. Route traffic through a facade (a gateway, a routing layer or an adapter) that initially sends everything to the legacy application. Then, one capability at a time, implement the function in the new service and switch that route over. External consumers keep calling the same interface, so they do not need to coordinate with you. Where the legacy data model leaks awkward concepts, place an anti-corruption layer between old and new so the legacy shape does not spread into new code.
4. Verify with parallel runs
For risky functions, run old and new side by side. Send real or recorded requests to both, return the legacy response and compare the new one offline. Investigate each difference: it is either a bug in the new code or an undocumented behavior you now understand. Cut over only when differences are explained, and keep a fast way to switch back.
5. Handle the database carefully
Shared databases are the most common hidden integration. Use an expand-and-contract approach: add new structures alongside old ones, write to both or migrate data in the background, move readers over, and remove the old structures only after nothing depends on them. Make each migration step reversible and rehearse it on production-like data.
6. Upgrade the platform as a separate step
Moving to a supported Java release and a current framework version is valuable, and it can be disruptive on its own. A well-known example is the move to Spring Boot 3, which also changes the Jakarta EE package namespace from javax to jakarta. Do platform upgrades in dedicated, well-tested steps, ideally before large refactors, so that when something breaks you know which change caused it. Keep dependency and vulnerability scanning in the pipeline throughout.
7. Build observability first
You cannot safely change what you cannot see. Add structured logging, metrics and tracing around the integration points early, so you can compare behavior before and after each step and diagnose problems quickly in production.
8. Plan rollback and communication
For every step, define how you will detect a problem, how you will roll back and who must be told. Tell integration owners ahead of time about anything that could affect them, even when you expect no change.
A sequence that works
- Inventory integrations and owners.
- Add characterization and contract tests.
- Add observability at the boundaries.
- Upgrade the runtime and framework in controlled steps.
- Introduce the facade, then migrate capability by capability with parallel runs.
- Retire legacy pieces only when nothing depends on them.
This article offers general engineering perspective. It does not describe a specific client engagement.