Skip to main content

Untangling Dependencies in Legacy Code

The worst legacy codebases are not the ones with ugly functions. They are the ones where you cannot change a function at all, because forty other things reach into it and no two of them agree on what it does.

This is the practical work of cutting those connections: mapping the real dependency graph, breaking cycles with inversion, layering a service so the arrows point one way, and running a monolith decomposition that does not stop the business.

This is one of three deep dives under Proven Techniques to Tackle Tech Debt. The companion pages are the framework refactoring catalog and characterization testing.

Map the Graph Before You Cut Anything

Every team has a mental model of how their system is wired, and in a legacy codebase that model is always wrong in the same direction: it is the architecture diagram from three years ago, not the import statements from this morning. Before you plan any decoupling work, generate the real graph from the code. It takes an afternoon and it usually reorders your entire plan, because the module everyone complains about is rarely the module everything actually depends on.

You are looking for three things: cycles (A imports B imports A, directly or through six hops), hubs (one module that dozens of others import, which means it cannot change without a coordinated release), and inverted arrows (infrastructure code that your domain logic imports rather than the other way round).

Tools that generate the graph from source

StackToolWhat it is good at
JavaScript, TypeScriptmadge, dependency-cruiserCircular import detection and rules that fail CI when a forbidden import appears
Javajdeps, ArchUnitPackage-level dependency listing, plus architecture rules written as ordinary unit tests
Pythonpydeps, import-linterVisual module graphs and declarative layer contracts enforced in CI
C#, .NETNDepend, NetArchTestAssembly and namespace coupling metrics, and assertions about allowed references
Any languageVersion control historyTemporal coupling: files that keep changing together are coupled even when nothing imports anything

Do not skip temporal coupling. The import graph shows the dependencies the compiler knows about. The commit history shows the ones your team actually pays for. Two files with no import relationship that change together in eighty percent of commits are coupled through a shared assumption - a database column, a message format, a magic string - and that is the kind of dependency that breaks production, because nothing checks it.

Breaking a Cycle With Dependency Inversion

A cycle is not a style problem. It means the two modules are one module wearing two filenames: you cannot test either alone, you cannot deploy either alone, and you cannot reason about either alone. The fix is almost always the same move - find the direction that is wrong, define an abstraction owned by the module that should not depend on the other, and let the other side implement it.

Where to put the seam when the cycle is bigger than two files

  • Cut at the smallest edge, not the biggest module. In a cycle of six modules there is usually one import used by a single function. Removing that one edge breaks the whole cycle for a fraction of the work.
  • Move the shared type, not the logic. Two modules that import each other only for a data shape need a third module holding that shape. This is the cheapest cycle break there is and it is very common.
  • Use an event when neither side should own the other. If orders must not know about billing and billing must not know about orders, publish an event and let a thin composition layer wire them. Do not reach for this first - an event bus turns a compile-time dependency into a runtime one, which is harder to trace.
  • Add an anti-corruption layer at the legacy boundary. When the tangled side is a system you cannot change, wrap it in a translation layer that speaks your domain's language. New code depends on your model, and exactly one adapter knows the legacy vocabulary.
  • Lock it with a test. The moment a cycle is gone, add a rule to the build that fails if it returns. Every tool in the table above supports this, and without it the cycle comes back within two sprints.

Worked Example: Layering a Node.js Service

Route handlers that own business rules and talk straight to the database are the most common dependency tangle in Node

This is dependency inversion applied at the scale of a whole service rather than a single cycle. The transformation below moves every arrow in one direction - HTTP depends on services, services depend on repositories, and nothing in the domain imports Express or the database driver.

Node.js Refactoring Guide Node.js Express/Fastify

Node.js backends often start as simple Express apps and grow into unstructured spaghetti. The fix: implement layered architecture with clear boundaries.

Pattern: Implement Layered Architecture

Separate Controllers (HTTP) from Services (Business Logic) from Repositories (Data Access).

Migration Playbooks

Step-by-step guides for major architectural transformations

Playbook: Monolith to Microservices Migration

Warning: Microservices are not a silver bullet. Only migrate if you have clear scaling, deployment, or team autonomy problems. A well-structured monolith is often better than poorly implemented microservices.

Phase 1: Assessment (1-2 months)

Goal: Understand what you have and identify natural service boundaries.

  • Map all modules, dependencies, and data flows
  • Identify bounded contexts using Domain-Driven Design
  • Analyze database tables - which belong together?
  • Document external integrations and APIs
  • Measure current deployment frequency and pain points
  • Survey team: What are the biggest pain points?

Deliverable: Service boundary diagram with proposed first extraction

Phase 2: Foundation (2-3 months)

Goal: Build the infrastructure needed for microservices before extracting any.

  • Deploy API Gateway (Kong, AWS API Gateway, Nginx)
  • Set up service discovery (Consul, Kubernetes DNS)
  • Implement centralized logging (ELK, Datadog)
  • Add distributed tracing (Jaeger, Zipkin)
  • Create CI/CD pipelines for service deployments
  • Establish inter-service communication patterns (REST, gRPC, events)

Deliverable: Working platform that can host both monolith and new services

Phase 3: Incremental Extraction (6-18 months)

Goal: Extract services one at a time using Strangler Fig pattern.

  • Start with lowest-risk, most isolated boundary
  • Build new service with identical API contract
  • Route new traffic to new service
  • Gradually migrate existing traffic (1% -> 10% -> 50% -> 100%)
  • Delete old code from monolith after 30+ days stable
  • Repeat for next service boundary

Order of extraction (typical): Authentication → User Management → Notifications → Search → Payments → Core Business Logic

Phase 4: Optimization (Ongoing)

Goal: Refine architecture based on real-world usage patterns.

  • Monitor service dependencies - look for chatty interactions
  • Consider merging services that are too fine-grained
  • Implement event sourcing where appropriate
  • Add caching layers for hot paths
  • Optimize database per service (polyglot persistence)

Key Principles (Do Not Ignore)

  • Never share databases between services - leads to tight coupling
  • Keep shipping features during migration - business cannot wait
  • Extract smallest possible piece first to build confidence
  • Plan for failure - every network call can fail
  • Own the data - each service owns its data completely

Playbook: Legacy System Modernization Strategies

Not every legacy system needs microservices. Choose the right modernization strategy based on your constraints and goals.

Rehosting (Lift and Shift)

Move application as-is to cloud infrastructure without code changes.

Pros:

  • Fastest path to cloud
  • Minimal risk
  • No code changes required

Cons:

  • Misses cloud-native benefits
  • May increase costs
  • Tech debt remains

Best for: Urgent datacenter exits, compliance deadlines

Replatforming

Make targeted optimizations during migration without full rewrite.

Pros:

  • Some cloud benefits (managed DB, etc.)
  • Moderate risk
  • Faster than full refactor

Cons:

  • Scope can creep
  • Requires some code changes
  • Testing complexity

Best for: Database migrations, containerization

Refactoring

Restructure and optimize code while migrating to improve architecture.

Pros:

  • Full cloud-native benefits
  • Improved maintainability
  • Performance gains

Cons:

  • Highest effort
  • Longer timeline
  • Higher risk

Best for: Strategic apps with long lifespan, scaling needs

Strangler Pattern

Gradually replace components while keeping the system running.

Pros:

  • Zero downtime migration
  • Incremental value delivery
  • Easy rollback

Cons:

  • Temporary complexity (2 systems)
  • Data sync challenges
  • Longer total timeline

Best for: Mission-critical systems that cannot have downtime

Case Study: Enterprise Payment Platform Rescue

An illustrative composite of a large payment platform rescue - a 15-year-old Java monolith on an end-of-life runtime, moved off it with a phased strangler fig migration.

The Crisis (Starting Point)

Business Impact:

  • Payment processing down 40% in reliability
  • $2M/month in failed transaction penalties
  • Customer churn increasing 15% QoQ
  • 3 major clients threatening to leave
  • Engineering team morale at all-time low

Technical Debt Symptoms:

  • Java 8 (Oracle ended free public updates for commercial use in January 2019 - unpatched vulnerabilities)
  • 15-year-old monolith, 800K lines of code
  • Zero test coverage on payment logic
  • Deployment took 8 hours, failed 60% of time
  • Average bug fix: 3 weeks (created 2 new bugs)

The Approach: 18-Month Strangler Fig Migration

Phase 1 Stabilize & Observe (Months 1-2)

Actions taken:

  • Deployed observability stack (DataDog + custom metrics)
  • Identified "hot paths" - 5 critical endpoints handling 95% of traffic
  • Implemented circuit breakers and rate limiting
  • Added feature flags to entire codebase (1,200+ flags)

Result: Reduced outages by 40% in first 30 days

Phase 2 Build Anti-Corruption Layer (Months 3-5)

Actions taken:

  • Deployed API Gateway (Kong) in front of monolith
  • Created routing rules: new API contracts → gateway → monolith
  • Established contract testing framework
  • Upgraded Java 8 → Java 25 LTS (via containerization, isolated)

Result: Deployment time: 8 hours → 20 minutes

Phase 3 Strangle First Service: Authorization (Months 6-9)

Why authorization first? Isolated, well-defined boundary, low data dependencies

  • Built new Authorization Service in Kotlin (team's choice, modern JVM)
  • Routed NEW users to new service (0% of existing traffic)
  • Ran parallel processing: both old and new services for 30 days, compared results
  • Gradually migrated existing users: 1% → 10% → 50% → 100%
  • Deleted 80,000 lines of Java from monolith

Result: Authorization latency: 800ms → 45ms (17x faster)

Phase 4 Critical Path: Payment Processing (Months 10-15)

The big one - 40% of codebase, 95% of revenue

  • Broke payment logic into 3 microservices: Validation, Processing, Reconciliation
  • Started with validation (lowest risk) - routed 5% of traffic
  • Implemented "shadow mode" - new service processes but doesn't commit, old service executes
  • Compared outputs for 10 million transactions before cutting over
  • Found and fixed 23 edge cases in new service that old service handled (tribal knowledge)
  • Full cutover took 4 months, zero failed transactions

Result: Payment success rate: 92% → 99.7%

Phase 5 Remaining Services & Decommission (Months 16-18)

  • Migrated reporting, notifications, and audit logging (parallel, lower risk)
  • Ran old monolith in "read-only" mode for 60 days (safety net)
  • Deleted 750,000 lines of legacy Java code in single commit (celebration day!)
  • Shut down 40 legacy servers, reducing infrastructure cost $180K/year

Result: Complete migration, zero downtime, $2.4M saved in year 1

Before/After Code Comparison: Payment Validation

BEFORE: Legacy Java 8 Monolith

Problems:

  • 15,000 lines in one class (should be 100-200 max)
  • No input validation or error handling
  • Hardcoded business logic (can't A/B test or change rules without deploy)
  • XML parsing with REGEX (!)
  • Silent failures, no observability
  • Zero test coverage (too complex to test)

AFTER: Modern Kotlin Microservice

Improvements:

  • Single Responsibility: Validation separate from processing
  • Clear error messages (not silent failures)
  • Type-safe (Kotlin sealed classes prevent invalid states)
  • Observable (structured logging with correlation IDs)
  • Testable (dependency injection, small functions)
  • Modern async (coroutines, not blocking threads)
  • Event-driven (downstream systems decoupled)
  • 100% test coverage achieved in 2 weeks

Final Outcomes (18 Months Post-Migration)

Technical Metrics:

  • Deployment frequency: 2x/week → 50x/week
  • Mean time to recovery: 4 hours → 8 minutes
  • Payment success rate: 92% → 99.7%
  • P99 latency: 2.8s → 180ms
  • Code volume: 800K lines → 95K lines
  • Test coverage: 0% → 87%
  • Infrastructure cost: -$180K/year

Business Impact:

  • Failed transaction penalties: $2M/month → $80K/month
  • Customer churn: 15% increase → 8% decrease
  • Revenue impact: +$12M ARR (retained clients + new sales)
  • Feature velocity: 2 features/quarter → 12 features/quarter
  • Developer satisfaction: 3.2/10 → 8.7/10
  • Client NPS: -12 → +34
  • Competitive advantage: Won 3 major RFPs citing platform reliability

What made it stick: the migration was run alongside the existing engineering team rather than around it. Architecture decisions, the new engineering practices, and vendor ownership were all handed to in-house owners before the work wound down. A rescue that ends the day the outside help leaves is not a rescue - it is the next round of debt.

Related Resources

Frequently Asked Questions

Generate the graph from source rather than reading imports by hand. Use madge or dependency-cruiser for JavaScript and TypeScript, jdeps or ArchUnit for Java, pydeps or import-linter for Python, and NDepend or NetArchTest for .NET. Run the scan on a clean checkout and export the cycle list before you plan any work. Then add the same check to continuous integration with the current cycles allowlisted, so the number can only go down. Without that ratchet, you will remove three cycles this quarter and inherit four new ones next quarter.

Clean up the internals first, always. Extracting a service from a module whose boundaries are not yet clear converts a compile-time mess into a distributed one, where every mistake becomes a network call, a deployment coordination problem, and a partial failure mode. If you can draw a clean module boundary inside the monolith and enforce it with an architecture test for a few months without pain, that boundary is a candidate for extraction. If you cannot, extracting it will not make it true - it will just make it expensive.

An anti-corruption layer is a translation boundary between your model and a model you do not control - a legacy database, a vendor API, or the part of the monolith nobody will fund fixing. New code depends on your vocabulary, and exactly one adapter knows the foreign vocabulary and converts between them. You need one whenever a foreign concept starts leaking into your domain code: fields named after someone else's database columns, status codes from a vendor, or null-handling rules that only make sense in the old system.

Use branch by abstraction. Introduce the interface first while both the old and new implementations satisfy it, route callers through the interface one at a time, and keep both paths shippable at every commit. A feature flag lets you flip a single caller and revert in seconds if the behavior differs. The rule is that main is always releasable, so the work can pause for a quarter if priorities change and nothing is left half-migrated in a long-lived branch that will never merge.

Temporal coupling is when two files must change together even though neither imports the other, usually because they share an assumption that lives outside the code - a database column, a message field name, a serialization format, or an ordering guarantee. Static analysis cannot see it because there is no reference to follow. You find it in version control by listing pairs of files that appear in the same commit far more often than chance would explain. Those pairs are your hidden dependencies, and they are the ones that cause production incidents after a "safe" change.

Stop when the change you actually want to make becomes cheap. Untangling is not a goal in itself, and a perfectly layered system that nobody is trying to change delivers nothing. Pick the next real piece of work - a feature, a platform upgrade, a compliance requirement - and untangle only what stands between you and shipping it. That keeps the work fundable, because every increment has a visible outcome attached, and it stops the project from becoming an architecture rewrite under a different name.

Start With the Decision, Not the Refactor

Untangling is expensive. Make sure it is the technique your situation actually calls for.

Back to Proven Techniques