Technical Debt Archaeology: What Your Dependency Graph Reveals
How dependency graph analysis, circular dependency detection, and co-change analysis help you prioritize technical debt based on measurable impact instead of opinion.
Every mature codebase has a backlog of "we should really fix this someday" issues.
The slow test suite.
The god object.
The third-party library you're three major versions behind on.
The circular dependencies.
The cryptic module that nobody dares touch.
The challenge isn't identifying technical debt — it's deciding which debt actually matters.
Which problems truly slow your team down?
Which refactorings will have measurable impact?
Which legacy code is genuinely risky versus just ugly?
Most teams prioritize technical debt based on pain: whatever caused the most visible disruption this sprint gets attention. This approach is reactive and biased toward recent memory. The database query that caused an outage gets fixed; the architectural coupling that silently slows down every feature remains untouched.
There's a better way.
Your dependency graph — combined with change history — reveals which technical debt costs you the most. Instead of relying on opinion, you can use dependency graph analysis to prioritize work based on structural impact and behavioral patterns.
Let's dig into the archaeological record.
The Hidden Cost of Outdated Dependencies
Outdated dependencies are the most obvious form of technical debt. But the real issue isn't version age — it's architectural spread.
The "Django 1.11" Pattern
Consider a typical hypothetical scenario: a Django app that feels increasingly brittle, but nothing obvious shows up in logs. A dependency graph analysis often surfaces something like this:
Dependencies on Django 1.11:
Direct imports: 127 modules
Transitive dependencies: 341 modules
Modules blocked from upgrading: 89
Potential zombie imports:
django.utils.decorators: 34 imports, 0 actual uses
django.core.urlresolvers: 18 imports (deprecated since Django 2.0)
django.contrib.formtools: 12 imports (unmaintained)
The problem isn't Django itself — it's how deeply that version is embedded throughout the codebase.
"Modules blocked from upgrading" refers to modules that directly import deprecated APIs or rely on behavior that changed in later versions. They can't be upgraded in isolation; each one requires investigation and case-by-case fixes before the framework version can move.
Upgrading now means touching 127 modules simultaneously.
That's architectural debt.
Change Amplification: The Real Cost
Dependency age matters less than dependency spread.
An old library used in one place is a quick fix. An old library imported by half your codebase creates change amplification.
When teams finally attempt a major upgrade, the effort almost always exceeds estimates. Every affected module must be tested. Small API changes require careful, case-by-case fixes. New type or runtime errors surface in unexpected places.
A dependency graph gives you the blast radius before you start. That's what enables realistic planning.
Technical debt analysis isn't about shame — it's about scope visibility.
Zombie Dependencies: The Easiest Wins
Not all imports are equal. Many codebases accumulate imports that are barely — or never — used.
Zombie imports are modules pulled in across many files but rarely or never actually invoked. They're the most common byproduct of copy-paste development and incremental refactoring that never got cleaned up.
# Imported in 45 files
from company.legacy.utils import get_user
Actual usage often looks like this: 3 files use it heavily, 7 use it occasionally, and 35 never call it at all — pure copy-paste artifacts.
A dependency analysis can be used to identify this pattern:
Zombie Import Detected:
Module: company.legacy.utils
Imported by: 45 modules
Actually used by: 10 modules
Unnecessary imports: 78%
Risk: Very low (no behavior change)
These are high-impact, low-effort refactors. Removing zombie imports reduces import overhead, speeds up test startup, clarifies dependency boundaries, and lowers upgrade friction for anything downstream.
Unlike major framework upgrades, zombie import cleanup is usually measured in hours — not weeks. It's also low-risk: no behavior changes, just removed dead weight. For a team building momentum on a debt reduction initiative, this is where you start.
The Dependency Age Map
Mapping dependency freshness across modules often reveals a predictable pattern:
Module Age Analysis (years since last major dependency update):
Core Business Logic: ⬛⬛⬛⬛⬛⬛⬛⬛ (oldest)
Payment Processing: ⬛⬛⬜⬜⬜⬜⬜⬜ (mixed)
API Layer: ⬜⬜⬜⬜⬜⬜⬜⬜ (recent)
Admin Tools: ⬛⬛⬛⬛⬛⬛⬛⬛ (oldest)
⬜ = Recently updated ⬛ = Outdated
The pattern is nearly universal: frequently touched layers stay modern, stable core logic calcifies, and admin or internal tooling quietly becomes legacy. Nobody is forcing upgrades because nothing is visibly broken.
By the time an upgrade is forced — by an EOL notice, a security advisory, or a new hire who refuses to work with the old version — the blast radius is enormous.
Dependency graph analysis makes that visible early, while the team still has the option to plan.
Circular Dependencies: The Hidden Tax
Circular dependencies don't always break your code.
They break your ability to change it.
What a Circular Dependency Actually Looks Like
# user.py
from project import get_user_projects
class User:
def get_projects(self):
return get_user_projects(self.id)
# project.py
from user import User
def get_user_projects(user_id):
user = User.objects.get(id=user_id)
return user.projects.all()
This works. Until you try to move User to another package, extract Project into a microservice, mock User in tests, or just explain to a new developer how the module structure fits together.
Cycles increase the cost of change, silently and persistently.
The Measurable Cost of Cycles
Circular dependencies frequently affect test suite performance (large import chains get pulled in unnecessarily), mocking boundaries, refactoring velocity, and onboarding time. None of these show up cleanly in a bug tracker. They accumulate as friction.
Not all cycles are equal. Severity depends on module count, lines of code involved, and — most importantly — how often those modules change.
Cycle 1 (HIGH):
user → auth → permissions → user
Modules: 3
LOC affected: 2,347
Change frequency: 4.2 changes/month
Cycle 2 (MEDIUM):
product → inventory → warehouse → product
LOC affected: 891
Change frequency: 0.3/month
Change frequency transforms a list of structural issues into a prioritized backlog.
A cycle in a high-churn authentication module matters far more than a cycle in a stable utility library that nobody touches. Both show up in static analysis. Only one is costing you time right now.
Breaking a Cycle (Strategically)
The pattern for resolving circular dependencies is usually the same regardless of language: identify the natural direction of dependency, extract an interface or protocol, and inject the dependency instead of importing it directly.
This is dependency inversion applied surgically, not architecturally.
The goal isn't purity — it's restoring independence between modules. Once broken, each module can be tested in isolation, moved, or replaced without dragging the other along with it.
Not every cycle needs to be broken immediately. Prioritize by change frequency and LOC, fix the high-churn ones first, and let the stable low-risk cycles wait.
Code Coupling: The Silent Velocity Killer
Coupling is harder to spot than circular dependencies but often more damaging to day-to-day development speed.
PViz measures two core metrics:
Fan-in — how many modules depend on this module.
Fan-out — how many modules does this module depend on.
The danger zone is high fan-in combined with high fan-out.
High fan-in means risky changes — touch this file, and 73 modules might behave differently. High fan-out means fragile code — this file is one broken import away from a cascade failure. Together, they create architectural bottlenecks that slow the entire team, not just the engineer working in that corner.
What High Coupling Actually Looks Like in Practice
The helpers module with fan-in of 73 is a classic accumulation pattern. It started as a place to put shared utilities. Over time, anything that didn't have an obvious home landed there. Now it's a module that everyone depends on but nobody fully understands.
Making a change to helpers.py requires checking 73 call sites. Adding a new parameter risks breaking callers that don't expect it. Removing a function requires auditing the entire codebase first.
The coupling score isn't academic. It's a measure of how expensive a change will be.
The right response isn't to delete the module — it's to decompose it. Identify which functions cluster together by topic and extract them into focused modules with clear ownership. Each extraction reduces fan-in on the original and creates something with a coherent interface.
In practice, decomposing a utility module like this often looks like a clustering exercise. You audit what helpers.py actually contains — say, 40 functions — and group them by what they operate on:
# Before: everything in helpers.py (fan-in: 73)
from utils.helpers import format_currency, parse_date, get_user_display_name, build_api_response, validate_email
# After: decomposed into focused modules
from utils.formatting import format_currency, parse_date # fan-in: 12
from utils.users import get_user_display_name # fan-in: 8
from utils.api import build_api_response # fan-in: 31
from utils.validation import validate_email # fan-in: 19
The total number of imports across the codebase doesn't change much. But now each module has a clear purpose, a bounded surface area, and a predictable set of callers. utils.validation can be changed without worrying about the 43 modules that only use format_currency. utils.api can be mocked in tests without pulling in date formatting logic.
This is the core payoff of coupling reduction: not fewer lines of code, but smaller blast radii per change.
Co-Change Analysis: Finding Hidden Dependencies
Structural dependency graphs reveal static coupling — what explicitly imports what.
Git history reveals behavioral coupling — what actually changes together in practice.
These two views are often misaligned in ways that matter.
The Problem with "No Imports"
Consider this scenario:
# user_service.py
def create_user(email, name):
...
# marketing_service.py
def track_signup(email, name):
...
No imports between them. No structural coupling. But rename name to full_name in your user creation flow without updating the marketing service, and production breaks because both services write to the same data contract — an API payload, a database column, an event schema.
This is coupling through shared assumptions, not explicit imports. Static analysis misses it entirely.
How Co-Change Analysis Works
By analyzing which files change together across hundreds of commits, you can surface these invisible dependencies:
High Co-Change Pairs:
payment/service.py ↔ order/service.py (73% co-change rate)
Structural coupling: minimal
Recommendation: investigate shared data contract or implicit protocol
user/models.py ↔ auth/serializers.py (61% co-change rate)
Structural coupling: moderate
Recommendation: review whether serializer logic belongs in model layer
A 73% co-change rate means these two files changed together in 73% of commits that touched either one. That's a strong signal of implicit coupling — even if import statements don't reflect it.
What to Do with Co-Change Data
High co-change with low structural coupling usually means one of three things: a shared data contract that should be made explicit, business logic that's been split across modules for the wrong reasons, or a missing abstraction layer.
High co-change with high structural coupling is less surprising — that's normal feature development — but it tells you where your most active blast radius is. Changes there ripple. And when both metrics are high on the same pair, it's worth asking whether the coupling is intentional or accidental.
Consider a pair like order/service.py and payment/service.py with 68% co-change and an explicit import relationship. That coupling might be entirely justified — payments are a direct consequence of orders, and the two legitimately belong together. Or it might indicate that payment logic has leaked into the order service over time, making both harder to test independently and impossible to deploy separately. The co-change data doesn't tell you which. But it tells you where to look, and that's the conversation worth having before you're trying to extract payments into its own service under deadline pressure.
Static analysis shows structure. Co-change shows evolution. You need both to prioritize correctly, because the most expensive debt is often the coupling that doesn't show up in a dependency graph at all.
A Framework for Prioritizing Technical Debt
Once you've run a technical debt analysis, how do you choose what to fix?
The instinct is to start with the worst-looking issue — the most egregious god object, the oldest library. That instinct is usually wrong. What you want is impact per unit of effort, not severity in isolation.
Think in terms of impact × effort, weighted by how often that code actually changes.
High impact, low effort — start here to build momentum and demonstrate value: zombie imports, small cycles in high-churn areas, excessive fan-out in utility modules.
High impact, high effort — treat as a project with dedicated planning: major framework upgrades, breaking deeply embedded circular dependencies, refactoring high-churn god objects.
Low impact items — even if intellectually satisfying — should be questioned. Cleaning up a beautifully structured but rarely-touched module is not debt reduction. It's aesthetics.
A Simple Scoring Model
One practical formula for ranking work:
Score = (Change Frequency × Coupling Breadth) ÷ Estimated Effort
Where Change Frequency is commits per month for the affected modules, Coupling Breadth is the number of modules directly affected by a change (fan-in + cycle members + co-change partners), and Estimated Effort is a rough team estimate in days.
You can refine this however you want. The specific numbers matter less than the discipline of scoring consistently. The goal isn't perfect math — it's moving from "this feels urgent" to "this ranks highest by the criteria we agreed on."
Run the score across your top ten candidates and sort. You'll almost always find that the intuitive priorities and the scored priorities disagree in at least two or three places. Those disagreements are worth a conversation.
What a Systematic Debt Reduction Effort Looks Like
Consider a 250k LOC Python monolith. Discovery surfaces: 20+ circular dependencies, several high fan-in hotspots, hundreds of zombie imports, and a framework version approaching end-of-life.
After scoring, the team selects four targets: break two high-churn cycles, remove zombie imports across the affected surface area, upgrade the expiring framework, and refactor the most overloaded serializer module.
Execution typically follows a predictable pattern:
Zombie import cleanup moves fast and builds team confidence. It's verifiably safe, produces immediate metrics (test startup time, import graph complexity), and demonstrates that the process works.
Cycle removal is slower but produces visible architectural improvements. Tests that previously required 400ms of setup time to pull in transitive imports can now run in isolation. Engineers notice.
Framework upgrade takes longer than estimated. Major framework upgrades almost always do. But the dependency graph gave the team an accurate blast radius upfront, so the overrun is expected and planned for rather than a crisis.
Serializer refactor reduces day-to-day coordination cost. When the most-coupled module gets decomposed, the number of engineers who need to review changes to it drops, and PR review cycles shorten.
After a quarter, teams that run this process consistently report faster test startup, shorter PR review cycles, fewer "what does this even do?" questions, and — most importantly — increased confidence making changes. That last one is the real ROI. Code velocity isn't just about speed; it's about willingness to move.
Your Action Plan
Week 1: Generate a dependency graph. Run co-change analysis against your git history. Ask the team what slows them down most and note whether the structural data confirms or contradicts their intuitions. The gaps are informative either way.
Week 2: Score and prioritize. Align on the top five issues using the impact × effort framework. Assign owners. Agree on what "done" looks like for each item — including what metric you'll measure before and after.
Weeks 3–4: Fix quick wins. Zombie imports are the canonical starting point. Track measurable improvements. Test startup time, import graph depth, and co-change rate are all good baseline metrics. Share the results with the broader team.
Month 2–3: Treat one major issue as a project — with dedicated engineering time, before-and-after metrics, and a postmortem when it's done. This is where you validate whether the scoring model is calibrated correctly.
Month 4+: Make it continuous. Run dependency analysis in CI. Track coupling drift over time. Set thresholds for fan-in on critical modules and fail builds that exceed them. Protect architectural baselines the same way you protect test coverage.
Technical debt management isn't a one-time cleanup. It's architectural hygiene — and like any hygiene practice, the value comes from consistency, not from a single heroic effort.
Conclusion: Debt Is Data, Not Opinion
Technical debt discussions often sound philosophical.
"Our architecture is messy."
"This code is hard to work with."
"We should modernize."
Dependency graph analysis turns those into data:
- This circular dependency affects 47 modules with a combined change frequency of 4.2 commits per month.
- This module has fan-in from 73 files and a coupling score of 91.
- These 35 imports are unused and carry zero behavior risk to remove.
- This framework version blocks 89 modules from upgrading.
When debt is measurable, it becomes prioritizable. When it's prioritizable, it becomes plannable. When it's planned, it actually gets done — instead of languishing in a backlog as a permanent "someday."
Your codebase is an archaeological site. The layers tell a story of growth, tradeoffs, and shortcuts taken under deadline pressure. Dependency analysis lets you excavate those layers deliberately — and decide what's worth preserving, what needs refactoring, and what's just dead weight.
The difference between teams that manage technical debt well and teams that drown in it usually isn't discipline or engineering culture. It's visibility. Teams that can see their architecture clearly make better decisions. Teams that rely on intuition and memory get surprised.
If you want to run this analysis on your own codebase, you can generate a full dependency graph to use in assessing technical debt at pvizgenerator.com.
Start digging.
Try PViz on your own codebase
Get dependency graphs, coupling signals, and a compressed bundle ready for your LLM — for any GitHub repository, in minutes.