Reading the Graph: How Helper Modules Become Load-Bearing Walls
utils.py starts as shared helper code, then quietly becomes one of the most imported files in the codebase. Learn how to spot the utility-module anti-pattern with dependency graph fan-in and refactor it safely.
Every codebase has one. It goes by different names in different languages and organizations:
utils.py. helpers.ts. common/. shared/. lib/core.go. StringUtils.java.
It starts with good intentions: a place for genuinely shared code that does not belong to any specific domain. A date formatting function. An email validation regex. A retry wrapper for HTTP calls. These things belong somewhere, and a shared utility module is a reasonable answer.
Then, over time, it becomes something else.
The utility module becomes one of the most imported files in the codebase. New functions are added there because it is convenient. Domain logic starts to appear. Nobody can explain what belongs in it anymore. Eventually, utils.py stops being a helpful shared module and becomes a load-bearing wall.
That is the utils.py anti-pattern. The structural fix — decomposing it into focused modules — is straightforward once you recognize the problem. But there is also a cultural fix, and without it the pattern comes back. We will cover both.
Is utils.py an Anti-Pattern?
Not automatically.
A small, focused utility module can be useful. There is nothing wrong with one that contains general-purpose date formatting, string normalization, or retry helpers — as long as it has a clear scope.
utils.py becomes an anti-pattern when it has no defined boundary. The problem is not that many modules import it. The problem is that many modules import it for unrelated reasons. One part of the codebase imports it for date formatting. Another imports it for authentication helpers. Another imports it for payment validation. Another imports it for feature flag checks.
At that point, the module is no longer a utility. It is an architectural junk drawer with high fan-in — and high fan-in combined with unclear scope creates unpredictable change risk.
The Lifecycle of a Utility Module
Phase 1: Legitimate Utility
The utility module is created to hold genuinely shared code. A few modules import it. It has a coherent scope — formatting functions, validation helpers, maybe some common data structures. It is small enough that anyone can read it in five minutes.
This is not a problem. A focused helper module is often better than duplicating the same small operation across the codebase.
Phase 2: The Convenience Magnet
More modules import it. More functions are added — not because they are genuinely shared, but because utils.py is the path of least resistance.
Where does this function go? Well, utils.py is already imported everywhere.
The module grows. Its scope blurs slightly. But it still feels manageable because nothing has obviously broken yet. This is where the pattern starts.
Phase 3: The Load-Bearing Wall
At some point — and it is rarely obvious when — the utility module becomes load-bearing.
So many modules import it that changing it requires extensive testing. New code is added there because it always works. The module has become the dependency that holds the codebase together.
This is the inflection point where utils.py stops being a convenience and starts becoming architectural risk. It is also the moment when the dependency graph first gives you a clear warning: the module's fan-in is climbing well past anything justified by its stated purpose.
Phase 4: The Untouchable Module
Nobody wants to touch it anymore.
It is too widely imported. Any change has an unpredictable blast radius. It is poorly tested because testing it requires understanding everything that depends on it. New developers are told "be careful with utils.py" as part of onboarding — which is code for "we do not really understand it anymore."
The Creeping Utility has become a load-bearing wall.
What Makes This Pattern Distinct
The Creeping Utility is related to the Star pattern, but it has a specific character that deserves its own treatment.
Stars can form in any module. A Creeping Utility follows a specific trajectory: it starts as a deliberate design choice, grows through accumulation rather than design, and ends up in a state opposite to its original purpose.
The original intent of a utility module is to hold genuinely general code — code with no specific owner, no specific domain, and no specific user.
The end state of a Creeping Utility is a module that has:
- An owner: nobody
- A domain: everything
- Users: everyone
It has become the opposite of what a utility module should be.
The pattern is also distinct in how it fails. Some architectural problems fail suddenly — a change breaks many things at once. Creeping Utilities usually fail slowly. They accumulate technical debt, discourage refactoring, and erode the codebase's structure over years rather than through one dramatic incident. That makes them easy to ignore until they are expensive to fix.
Quick Diagnosis: Is Your Utility Module Becoming Load-Bearing?
A utility module deserves closer review if several of these are true:
- It is one of the most imported modules in the codebase.
- Developers add code there when they do not know where else it belongs.
- It contains functions from unrelated domains — formatting, authentication, billing, feature flags — with no coherent thread connecting them.
- Changes to one helper cause test failures in unrelated parts of the system.
- Domain-specific business logic has started appearing inside it.
- The module has no clear owner, and code review rarely questions new additions.
- Refactoring it feels risky because too many things depend on it.
The key signal is not fan-in alone. Some utility modules are legitimately imported in many places. Logging, metrics, retry logic, and date formatting can all have high fan-in for valid reasons.
The warning sign is high fan-in combined with unclear scope. A module can be widely used and still healthy. It becomes dangerous when it is widely used for unrelated reasons.
The Warning Signs in Detail
Everything Ends Up There
The first warning sign is cultural rather than structural: the team has internalized utils.py as the default destination for anything without an obvious home.
When developers ask "where should this go?" and the reflexive answer is utils.py, the module has become a dumping ground. This cultural pattern often appears before the structural problem becomes severe — which is why preventing recurrence requires addressing it directly, not just reorganizing files.
It Is One of the Most Imported Files in the Codebase
Check your dependency graph sorted by fan-in.
If your utility module is at or near the top of the list — more imported than your core domain models, foundational infrastructure, or main application entry points — it deserves scrutiny. High fan-in makes a module expensive to change. Unclear scope makes it hard to predict what a change might affect. Together, they create unpredictable blast radius.

Both modules have high fan-in. The difference is who is importing them. Every importer of date_formatting.py needs the same category of thing. The importers of utils.py come from billing, auth, CLI, reporting, and background jobs — unrelated concerns sharing a single dependency container.
It Contains Unrelated Things
Open the utility module and list what it does.
If the list includes database connection utilities, string formatting functions, email validation, HTTP client wrappers, date arithmetic, feature flag checks, and authentication helpers, you have a Creeping Utility. Those things have no coherent relationship to each other. They are not a module. They are a pile.
It Hides Domain Logic
Some functions in a utility module are not really utilities at all.
A function called format_currency might be a utility.
A function called calculate_pro_rated_subscription_charge is not.
That is business logic. It belongs to a billing, subscription, or payments domain — not a generic helper module. Domain logic hiding in utils.py is especially dangerous because it makes the codebase harder to reason about. The dependency graph shows a shared helper module, but the actual responsibility is business behavior.
It Causes Surprising Test Failures
When a change to a utility function breaks tests in modules you did not expect, you are experiencing the blast radius of a Creeping Utility. The coupling is structural, but it feels random.
A payment test fails because of a helper used by an authentication flow. A reporting test fails because of a shared date function modified for a scheduling feature. A CLI test fails because utils.py imports something that now has a runtime side effect.
The issue is not just that the module is imported widely. The issue is that unrelated parts of the system are coupled through a shared container with no meaningful boundary.

The developer who modified format_date() expected to affect scheduling. The actual blast radius reached payments, auth, and reporting — modules that had nothing to do with the change, but happened to share the same dependency container.
A Simple Before-and-After Example
A Creeping Utility often looks like this:
utils.py
format_date()
parse_timestamp()
validate_email()
retry_request()
get_current_user_permissions()
calculate_subscription_proration()
normalize_phone_number()
load_feature_flags()
send_password_reset_email()
The problem is not that these functions are all bad. Some may be perfectly reasonable. The problem is that they do not belong to the same responsibility.
A decomposition might look like this:
date_formatting.py
format_date()
parse_timestamp()
validation/email.py
validate_email()
http/retry.py
retry_request()
auth/permissions.py
get_current_user_permissions()
billing/proration.py
calculate_subscription_proration()
validation/phone.py
normalize_phone_number()
features/flags.py
load_feature_flags()
auth/password_reset.py
send_password_reset_email()
This is not just file shuffling. It changes the structure of the codebase.
The old structure said: "These things are shared."
The new structure says: "These things belong to specific responsibilities."
A focused module gives future developers a boundary. It tells them what belongs there and what does not. A generic utility module gives them permission to keep adding unrelated code.
The Decomposition Strategy
Decomposing a Creeping Utility is one of the more tractable refactoring tasks. It is painstaking, but it is not usually technically complex.
Step 1: Inventory Everything
List every function, class, and constant in the utility module. Be specific about what each one does — do not accept "utility function" as a description. Name the actual operation.
For example:
- Formats a date for display
- Parses an ISO timestamp
- Validates an email address
- Calculates subscription proration
- Builds an authenticated HTTP client
- Loads feature flag configuration
- Converts cents to dollars
- Checks whether a user has admin permissions
This step often reveals the problem immediately. A module that felt vaguely messy becomes obviously incoherent when its responsibilities are listed one by one.
Step 2: Cluster by Responsibility
Group the inventory by subject matter. Common clusters include:
- Date and time operations
- String manipulation
- HTTP utilities
- Authentication helpers
- Validation logic
- Formatting logic
- Financial calculations
- Configuration helpers
- Test helpers
You are looking for clusters of related things that could plausibly live in a module with a clear, single-purpose name. Expect to find four to eight clusters in a moderately large Creeping Utility. Some items will resist categorization — that is useful information. Those functions may be genuinely general, or they may be domain logic hiding under a generic name.
Step 3: Identify Domain Belongers
Some functions in your utility module are not utilities at all. They are domain logic that ended up in the wrong place.
# Does not belong in utils.py
calculate_pro_rated_subscription_charge() # → billing/proration.py
canUserApproveRefund() # → auth/permissions.py
Finding these is often the most valuable output of the exercise. Domain logic hiding in a utility module is not just organization debt — it is architectural misinformation. The dependency graph says many modules depend on a helper. The reality is that many modules depend on business behavior without a clearly named domain boundary.
Step 4: Create Focused Replacement Modules
For each cluster, create a focused module with a clear name:
date_formatting.py
email_validation.py
http_retry.py
auth_tokens.py
billing/proration.py
feature_flags.py
Move the relevant functions. Add or update docstrings. Write tests for any functions that lack them.
The names matter. A module called utils.py has no defined scope. A module called date_formatting.py does. The name is a contract: this module contains date formatting functions. Nothing else should go here.
Step 5: Update Importers Incrementally
This is the most labor-intensive step — and the place where the refactor is most likely to go sideways if rushed.
Work through importers in batches: one cluster at a time, one directory at a time, one PR at a time. After each batch, run tests. Do not attempt this in a single PR for a large utility module — the review surface is too large and merge conflict risk is too high.
The goal is to make the change boring enough to review safely. A series of small, mechanical PRs is far better than one heroic restructuring that nobody can confidently approve.
Step 6: Delete or Quarantine the Original
Once all importers have been updated, the original utility module should be empty or near-empty. If a few genuinely general functions remain, they can stay in a much smaller, better-scoped module — but this should be intentional.
A useful rule: the remaining utility module should need a scope statement.
# utils.py
# Contains small, dependency-free helpers used across the application.
# Not for business logic, infrastructure clients, framework integration,
# or domain-specific behavior.
If that statement feels impossible to write, the module is still too vague.
Preventing Recurrence
The structural fix is necessary but not sufficient.
Creeping Utilities grow from cultural patterns — especially the reflexive answer to "where does this go?" If that pattern does not change, a new Creeping Utility will form to replace the old one. This is worth flagging early, because the cultural fix is easy to skip when the structural work feels like enough.
Add a fan-in review threshold. If a utility module is imported by more than 15 modules, new additions require explicit architectural review. The number is not magic — the point is to create a forcing function. When fan-in is low, adding another helper is usually cheap. When fan-in is high, every new responsibility makes the module more central and harder to unwind. A threshold turns "this feels messy" into a reviewable structural signal.
Require a scope statement for shared modules. Every shared module should be able to explain what belongs inside it — and what does not.
# payments_utils.py
# Contains formatting and validation utilities for payment-related data.
# Not for payment business logic, processor integration, or authorization rules.
A module with a scope statement is much harder to misuse than one without. Without it, "this seems useful" becomes enough justification to keep growing the module indefinitely.
What a Healthy Utility Module Looks Like
A healthy utility module:
- Does one category of thing
- Has a name that describes its scope
- Has fan-in proportionate to how widely that category is genuinely needed
- Contains no business logic
- Avoids importing heavy domain or infrastructure dependencies
- Has tests that verify its behavior without requiring knowledge of its importers
- Has a clear answer to "what does not belong here?"
That last point is important. A module is not well-scoped just because you can describe what it contains. It is well-scoped when you can also describe what it should reject. That is the difference between a healthy shared module and the beginning of another Creeping Utility.
How the Dependency Graph Helps
You can find a Creeping Utility before it becomes untouchable by looking at the dependency graph.
The most useful signal is fan-in: how many modules import a given module. A utility module with fan-in climbing past 20, 30, or 40 deserves attention. That does not automatically mean it is wrong, but it does mean the module has become structurally important.
The second signal is importer diversity. If the same utility module is imported by billing, authentication, reporting, CLI tools, background jobs, and UI code, it is probably serving multiple unrelated responsibilities.
The third signal is change risk. A high-fan-in module with unrelated responsibilities has a wider and less predictable blast radius than a focused module with the same fan-in.
By the time everyone is afraid to touch utils.py, the graph has usually been warning you for a while. That is the moment to act — not because the situation is urgent yet, but because the intervention is still cheap.
Conclusion
The Creeping Utility is one of the slowest-moving architectural problems in a codebase.
It takes years to develop. It does not usually produce a dramatic failure event. It just makes everything gradually harder: harder to understand, harder to test, harder to change, and harder to onboard into.
The fix is not to ban utility modules. The fix is to keep them scoped — structurally and culturally. A fan-in threshold and a scope statement are both cheap. A multi-month decomposition of an untouchable utils.py is not.
The dependency graph catches the pattern early. Act when the refactor is still boring.
Find the highest fan-in modules in your codebase with PViz →
Published in the Reading the Graph Series
The Star: When One Module Knows Too Much
High fan-in, high fan-out, and the module your entire codebase orbits around
The Mesh: When Everything Depends on Everything
What to do when your dependency graph stops having structure
The Chain: Deep Dependency Paths and the Teams That Can't Ship Fast
How long linear dependency chains create invisible coupling between unrelated parts of your system
The Island: Isolated Clusters and the Ownership Vacuum They Create
Why self-contained subsystems are more dangerous than they appear
The Upward Arrow: When Infrastructure Knows About Business Logic
Abstraction inversion and how to fix it
The Hourglass: Your Most Dangerous Module Is Probably One You've Never Thought About
High fan-in above, high fan-out below — and a single module holding it all together
The Hidden Seam: Using Your Dependency Graph to Find Microservice Boundaries
Graph structure reveals where your monolith already wants to split
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.