Your "simple" refactor just broke 47 tests. Half of them don't even mention the code you changed.
Sound familiar? You thought you were moving one function. Maybe renaming a class. Five minutes of work, tops. Instead, you're three days deep in a rabbit hole of mysterious failures, wondering why changing UserService.getProfile() somehow broke the email delivery system.
Welcome to the world of hidden dependencies.
The Invisible Web
Here's what I thought was happening when I tried to extract our authentication logic into a separate service:
// Before - seemed straightforward
class UserController {
async login(email: string, password: string) {
const user = await this.userRepo.findByEmail(email);
if (!user || !this.validatePassword(password, user.hash)) {
throw new UnauthorizedError();
}
return this.generateToken(user);
}
}
// After - clean separation, right?
class AuthService {
async authenticate(email: string, password: string) {
const user = await this.userRepo.findByEmail(email);
if (!user || !this.validatePassword(password, user.hash)) {
throw new UnauthorizedError();
}
return this.generateToken(user);
}
}
What actually happened: 23 integration tests failed. The notification service stopped working. The audit log went silent.
Why? Because UserController.login() wasn't just authenticating users. It was also:
- Updating the
last_logintimestamp (buried inuserRepo.findByEmail()) - Triggering login webhooks (side effect in
generateToken()) - Publishing analytics events (hidden in the
UnauthorizedErrorconstructor)
None of this was documented. None of it was obvious from reading the code.
The Four Horsemen of Hidden Dependencies
1. Side Effects Masquerading as Pure Functions
The worst offender in our codebase was this innocent-looking method:
def get_user_preferences(user_id):
"""Returns user preferences."""
prefs = cache.get(f"prefs:{user_id}")
if not prefs:
prefs = db.query("SELECT * FROM preferences WHERE user_id = ?", user_id)
cache.set(f"prefs:{user_id}", prefs)
# This line right here killed three refactors
analytics.track("preferences_cache_miss", user_id=user_id)
return prefs
That analytics call? It was feeding our recommendation engine. When we "optimized" the caching layer, recommendations went stale. Customer complaints poured in. Nobody connected the dots for two weeks.
2. Global State That Isn't Global Enough
JavaScript developers, you know this pain:
// Looks harmless
function formatCurrency(amount) {
return new Intl.NumberFormat(getCurrentLocale(), {
style: 'currency',
currency: 'USD'
}).format(amount);
}
function getCurrentLocale() {
// Reads from a "global" that's actually request-scoped
return RequestContext.current?.locale || 'en-US';
}
Move formatCurrency to a utility library? Congratulations, you just hardcoded 'en-US' for every user. The dependency on RequestContext was invisible until it wasn't there.
3. Database Triggers and Stored Procedures
The absolute worst. You change a column name, update all the application code, run the tests — everything passes. Deploy to production and watch the world burn.
-- This trigger was added two years ago
-- The person who wrote it left the company
-- It's not in version control
-- Good luck
CREATE TRIGGER update_user_stats
AFTER UPDATE ON users
FOR EACH ROW
WHEN NEW.email != OLD.email
BEGIN
UPDATE user_analytics SET email_changed = 1 WHERE user_id = NEW.id;
INSERT INTO audit_log VALUES (NEW.id, 'email_change', datetime('now'));
END;
I've seen entire teams spend weeks tracking down "ghost" behavior that turned out to be undocumented database triggers. The application code was perfect. The database had other plans.
4. Event Listeners You Forgot About
Modern applications love events. They also love to forget about them:
@EventListener
public void onUserCreated(UserCreatedEvent event) {
// This runs when users are created
// But also when they're imported from CSV
// And when test fixtures run
// And when...
slackService.notifyNewUser(event.getUser());
}
Refactor the user creation flow? Hope you enjoy explaining to your CEO why Slack is getting spammed with 10,000 "new user" messages during data migration.
Detection Strategies That Actually Work
Static analysis tools miss most of this. Dependency graphs don't show runtime coupling. Here's what I do instead:
The Chaos Monkey Approach: Comment out your method and run the entire test suite. Not just unit tests — integration tests, end-to-end tests, everything. The failures will show you the real dependencies.
Git Archaeology: Before touching any code, run:
git log --follow --patch -- path/to/your/file.js | grep -A5 -B5 "your_method"
Look for commits that changed other files in the same PR. Those are your hidden dependencies.
The Mirror Test: If you can't easily write the exact inverse operation (undo your change), you've got hidden dependencies. Pure functions are reversible. Side effects aren't.
The Real Solution
Documentation won't save you (nobody reads it). Comments go stale. The only reliable approach is to make dependencies explicit:
// Instead of this hidden nightmare
class UserService {
async createUser(userData: UserData) {
const user = await this.db.save(userData);
// Hidden: sends email, updates analytics, triggers webhooks
return user;
}
}
// Make it explicit
class UserService {
constructor(
private db: Database,
private emailService: EmailService,
private analytics: AnalyticsService,
private webhookService: WebhookService
) {}
async createUser(userData: UserData, effects: {
sendWelcomeEmail?: boolean;
trackAnalytics?: boolean;
triggerWebhooks?: boolean;
} = {}) {
const user = await this.db.save(userData);
if (effects.sendWelcomeEmail) {
await this.emailService.sendWelcome(user);
}
if (effects.trackAnalytics) {
this.analytics.track('user_created', user.id);
}
if (effects.triggerWebhooks) {
await this.webhookService.notify('user.created', user);
}
return user;
}
}
Yes, it's more verbose. Yes, it requires more thinking upfront. But when you need to refactor six months later, you'll know exactly what you're dealing with.
The alternative? Three days of debugging why your authentication refactor broke the recommendation engine.
Your choice.