Loading content...
Of all the problems Service Locator creates, hidden dependencies is the most insidious. It's not just one problem among many—it's the root cause from which most other problems flow. Hidden dependencies corrupt your ability to reason about code, test it reliably, refactor it safely, and onboard new developers effectively.
This concept is so important that it deserves deep examination. Understanding hidden dependencies will sharpen your thinking not just about Service Locator, but about API design in general. Any pattern that hides requirements creates the same class of problems, and you'll encounter this anti-pattern in many disguises throughout your career.
By the end of this page, you will deeply understand what hidden dependencies are, why they're harmful, how they manifest in real codebases, and how to recognize them in patterns beyond Service Locator. You'll develop the instinct to make dependencies explicit as a general design principle.
A hidden dependency is any requirement of a class that is not visible in its public interface—typically its constructor signature. The class needs something to work, but this need is concealed from consumers.
The key insight: There's a difference between what a class appears to need and what it actually needs. Hidden dependencies create a gap between these two.
With explicit dependencies (Dependency Injection), the constructor is a complete specification:
class OrderProcessor {
constructor(
private orderRepo: IOrderRepository, // Visible requirement
private paymentGateway: IPaymentGateway, // Visible requirement
private notificationService: INotificationService // Visible requirement
) {}
}
Looking at this class, you know exactly what it needs. The constructor is a contract.
With hidden dependencies (Service Locator), the constructor lies:
class OrderProcessor {
constructor() {} // Appears to need nothing!
process(orderId: string) {
const locator = ServiceLocator.getInstance();
const orderRepo = locator.resolve(ServiceTokens.OrderRepo); // Hidden
const paymentGateway = locator.resolve(ServiceTokens.Payment); // Hidden
const notificationService = locator.resolve(ServiceTokens.Notification); // Hidden
}
}
The class appears to need nothing, but actually needs three services plus a configured global locator.
Every time there's a gap between apparent requirements and actual requirements, there's an opportunity for misuse, misconfiguration, and failure. Hidden dependencies maximize this gap by making actual requirements completely invisible.
How do you discover what a class actually needs when its dependencies are hidden? There are only unpleasant options:
locator.resolve() calls. Hope you don't miss conditional branches. Hope nobody adds new dependencies later.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Scenario: You need to use AnalyticsProcessor in a new service class AnalyticsProcessor { constructor() {} // Hmm, looks simple enough processEvent(event: AnalyticsEvent): void { // What's hidden inside? }} // Attempt 1: Just create itconst processor = new AnalyticsProcessor(); // Compiles fine!processor.processEvent(event);// Runtime Error: "Service not found: EventStore" // Attempt 2: Register EventStorelocator.register(ServiceTokens.EventStore, new EventStore());processor.processEvent(event);// Runtime Error: "Service not found: MetricsClient" // Attempt 3: Register MetricsClientlocator.register(ServiceTokens.MetricsClient, new MetricsClient());processor.processEvent(event);// Runtime Error: "Service not found: Logger" // Attempt 4: Register Loggerlocator.register(ServiceTokens.Logger, new ConsoleLogger());processor.processEvent(event);// Works... for now // But wait - there's a conditional branch:if (event.type === 'conversion') { // This branch resolves AdTracker const adTracker = locator.resolve(ServiceTokens.AdTracker); adTracker.recordConversion(event);} // You won't discover this until a 'conversion' event happens// Maybe that's in production, months from now // --- // Compare with explicit dependencies:class AnalyticsProcessor { constructor( private eventStore: IEventStore, private metricsClient: IMetricsClient, private logger: ILogger, private adTracker: IAdTracker ) {}} // The compiler tells you EXACTLY what's needed// No trial and error. No runtime surprises.In a small team where everyone wrote the code, discovering hidden dependencies is inconvenient but manageable—you probably remember what you wrote. In a large organization with hundreds of services and high turnover, discovery costs become prohibitive. New engineers spend weeks learning implicit conventions that could have been explicit contracts.
One of software engineering's fundamental principles is managing coupling—the degree to which components depend on each other. Both Service Locator and Dependency Injection create coupling (your class needs certain services). The crucial difference is visibility.
With visible coupling (DI), you can:
With invisible coupling (Service Locator), the coupling exists but you can't see it. This is arguably worse than tight coupling with direct instantiation—at least then you could grep for new ConcreteService() and know what's coupled to what.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// Scenario: You need to refactor IPaymentGateway - add a new method // Question: What classes use IPaymentGateway and might need updating? // === WITH DEPENDENCY INJECTION ===// Simple: Search for constructors that accept IPaymentGateway class CheckoutService { constructor(private gateway: IPaymentGateway) {} // Found!} class RefundProcessor { constructor( private gateway: IPaymentGateway, // Found! private orderRepo: IOrderRepository ) {}} class SubscriptionManager { constructor( private gateway: IPaymentGateway, // Found! private scheduler: IScheduler ) {}} // IDE can find all 3 consumers instantly via "Find Usages"// You know exactly what to review and update // === WITH SERVICE LOCATOR ===// Not so simple: Must search for resolve calls with that token class CheckoutService { constructor() {} // No mention of IPaymentGateway checkout(cart: Cart): Order { const gateway = locator.resolve(ServiceTokens.Payment); // Hidden deep in a method }} class RefundProcessor { processRefund(orderId: string): void { // Maybe uses gateway here? Must read to find out if (this.needsRefund(orderId)) { const gateway = locator.resolve(ServiceTokens.Payment); } }} // To find consumers, you must:// 1. Search for "ServiceTokens.Payment" - finds resolve calls// 2. But wait, some code might use a different token name// 3. Or store the token in a variable first// 4. Or resolve via string: locator.resolve("payment")// 5. You can never be sure you found everything // The coupling EXISTS (same services are needed)// But you CAN'T SEE IT reliablyWe touched on testing friction earlier, but hidden dependencies create particularly nasty testing scenarios that deserve deeper examination.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// Your class uses ServiceA, which internally uses ServiceB and ServiceC// With hidden dependencies, this transitivity is invisible class ReportGenerator { constructor() {} generateReport(): Report { const locator = ServiceLocator.getInstance(); const dataFetcher = locator.resolve<IDataFetcher>(ServiceTokens.DataFetcher); // DataFetcher internally resolves ICache, IMetrics, ILogger const data = dataFetcher.fetchData(); // ... process data }} // Testing ReportGenerator:// You think you just need to mock IDataFetcher... test("generates report correctly", () => { locator.clear(); locator.register(ServiceTokens.DataFetcher, mockDataFetcher); const generator = new ReportGenerator(); const report = generator.generateReport(); expect(report.sections).toHaveLength(3);}); // But mockDataFetcher is a stub that doesn't resolve anything.// The REAL DataFetcher would have resolved ICache, IMetrics, ILogger.// If those aren't registered, the real code fails. // Integration test scenario:test("integration: generates report end-to-end", () => { locator.clear(); locator.register(ServiceTokens.DataFetcher, new RealDataFetcher()); // BOOM: RealDataFetcher tries to resolve ICache // Error: "Service not found: ICache" // You must register EVERYTHING in the transitive closure: locator.register(ServiceTokens.Cache, mockCache); locator.register(ServiceTokens.Metrics, mockMetrics); locator.register(ServiceTokens.Logger, mockLogger); // ... and anything THOSE might need}); // With DI, transitive dependencies are explicit:class DataFetcher { constructor( private cache: ICache, // Visible! private metrics: IMetrics, // Visible! private logger: ILogger // Visible! ) {}} // To test with real DataFetcher, you MUST provide its deps// The signature forces you to think about the whole graph1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// A particularly insidious scenario: tests that pass for wrong reasons class PaymentProcessor { constructor() {} async process(amount: number): Promise<PaymentResult> { const locator = ServiceLocator.getInstance(); const gateway = locator.resolve<IPaymentGateway>(ServiceTokens.Gateway); const audit = locator.resolve<IAuditService>(ServiceTokens.Audit); const result = await gateway.charge(amount); if (result.success) { await audit.log(`Payment successful: ${amount}`); } return result; }} // Test file A: Tests payment successdescribe("PaymentProcessor - success scenarios", () => { beforeEach(() => { locator.clear(); locator.register(ServiceTokens.Gateway, new MockSuccessGateway()); locator.register(ServiceTokens.Audit, new MockAuditService()); }); test("processes payment", async () => { const processor = new PaymentProcessor(); const result = await processor.process(100); expect(result.success).toBe(true); });}); // Test file B: Tests payment failure (written by different dev)describe("PaymentProcessor - failure scenarios", () => { beforeEach(() => { locator.clear(); locator.register(ServiceTokens.Gateway, new MockFailGateway()); // Developer forgot to register Audit service // But tests still pass because the 'if (result.success)' branch // is never entered when gateway fails! }); test("handles declined payment", async () => { const processor = new PaymentProcessor(); const result = await processor.process(100); expect(result.success).toBe(false); // Passes! });}); // Later, someone adds audit logging for failed payments:if (result.success) { await audit.log(`Payment successful: ${amount}`);} else { await audit.log(`Payment failed: ${amount}`); // NEW!} // Now Test file B explodes in CI// "Service not found: Audit"// But the original test author is long gone... // With DI, the constructor would have required IAuditService// The test would have been FORCED to provide it from the startHidden dependencies mean tests can pass because they never exercise the path that resolves a missing service—not because the code is correct. This is worse than a failing test; it's a passing test that lies about coverage.
Hidden dependencies create a knowledge asymmetry between those who wrote the code and those who must use or maintain it later. The original authors hold implicit knowledge that isn't captured in the code itself.
| Scenario | With Explicit Dependencies | With Hidden Dependencies |
|---|---|---|
| New developer uses a class | Reads constructor, knows exactly what to provide | Creates instance, hits runtime error, searches code, asks colleague, repeats |
| Developer adds new dependency | Adds constructor parameter; all call sites become compile errors | Adds resolve() call; silently broken until someone runs that code path |
| Code review for new service | Reviewer sees all dependencies in class signature | Reviewer must read entire implementation to find all resolve() calls |
| Understanding system architecture | Generate dependency graph from constructor analysis | Manually trace code or run with logging enabled |
| Refactoring shared service | Compiler shows all affected components | Search and hope; deploy and pray |
The 'tribal knowledge' problem:
In teams using Service Locator heavily, there often develops a set of unwritten conventions:
This tribal knowledge is fragile. It lives in people's heads, not in the code. When people leave, knowledge leaves with them. New team members must absorb these conventions through observation and errors, slowing them down for weeks or months.
Well-designed code teaches you how to use it. Constructor signatures, type annotations, and compile errors guide you toward correct usage. Hidden dependencies create code that can only be understood by reading all of it—or by trial and error. Good design eliminates the need for tribal knowledge.
A healthy architecture has a composition root—a single location where all object composition happens. This is where you wire up dependencies, typically at application startup. With Dependency Injection, the composition root is clear and contained.
With Service Locator, the composition root becomes diffuse. Registration happens in the composition root, but resolution (and thus the actual dependency wiring) happens everywhere. Every resolve() call is a mini composition root, scattered throughout the codebase.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// === DEPENDENCY INJECTION: Concentrated Composition === // All composition happens HERE, in one placefunction createApplicationRoot(): Application { // Infrastructure const logger = new ConsoleLogger(); const config = new EnvironmentConfig(); const database = new PostgresConnection(config.dbUrl); // Repositories const userRepo = new PostgresUserRepository(database); const orderRepo = new PostgresOrderRepository(database); // Services const userService = new UserService(userRepo, logger); const orderService = new OrderService(orderRepo, userService, logger); const paymentService = new PaymentService( new StripeGateway(config.stripeKey), logger ); // Controllers const userController = new UserController(userService); const orderController = new OrderController(orderService, paymentService); return new Application([userController, orderController]);} // The ENTIRE object graph is visible in this one function// No hidden wiring. No surprises. Full transparency. // === SERVICE LOCATOR: Diffuse Composition === // Registration happens here...function bootstrap(): void { const locator = ServiceLocator.getInstance(); locator.register(ServiceTokens.Logger, new ConsoleLogger()); locator.register(ServiceTokens.Config, new EnvironmentConfig()); // ... etc} // But RESOLUTION (the actual wiring) happens everywhere: class UserController { constructor() { // Composition happens HERE this.userService = ServiceLocator.getInstance().resolve(/*...*/); }} class OrderService { processOrder() { // Composition happens HERE const logger = ServiceLocator.getInstance().resolve(/*...*/); const payment = ServiceLocator.getInstance().resolve(/*...*/); }} class RefundHandler { handleRefund() { // Composition happens HERE const orderService = ServiceLocator.getInstance().resolve(/*...*/); }} // Composition is SCATTERED across the entire codebase// No single place shows you the object graph// Every class is a mini composition rootWhy concentrated composition matters:
When composition is concentrated, you can:
When composition is diffuse, you have none of these benefits. The system's shape is invisible until you run it.
The hidden dependencies problem isn't unique to Service Locator. Understanding this anti-pattern helps you recognize it in other forms throughout software design. Anywhere requirements are implicit rather than explicit, you have hidden dependencies.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// === AMBIENT STATIC STATE ===class ReportGenerator { generate(): Report { // Hidden dependency on global configuration const format = GlobalConfig.reportFormat; const locale = GlobalConfig.locale; // ... }} // === ENVIRONMENT VARIABLES ===class DatabaseConnection { connect(): Connection { // Hidden dependency on environment const host = process.env.DB_HOST; // Must be set! const port = process.env.DB_PORT; // Must be set! // Constructor doesn't reveal these requirements }} // === THREAD/CONTEXT LOCALS ===class AuditLogger { log(action: string): void { // Hidden dependency on context const user = RequestContext.currentUser; // Must be set! const tenant = RequestContext.currentTenant; // Must be set! // ... }} // === FILE SYSTEM ===class ConfigLoader { load(): AppConfig { // Hidden dependency on file existing return JSON.parse( fs.readFileSync('/etc/app/config.json') // Must exist! ); }} // === IMPLICIT INITIALIZATION ORDER ===class PaymentGateway { constructor() { // Hidden dependency on initialization if (!SecurityModule.isInitialized) { // Must be called first! throw new Error("SecurityModule not initialized"); } }} // === ALL OF THESE share the hidden dependencies problem:// - Requirements not visible in signature// - Discovered only at runtime// - Hard to test in isolation// - Easy to break by changing context// - Onboarding requires tribal knowledgeFor all these cases, the solution is the same: make the dependency explicit. Accept configuration as a constructor parameter. Accept a clock interface instead of calling Date.now(). Accept a file reader instead of reading directly. Explicit dependencies create honest, testable, maintainable code.
Hidden dependencies don't just add problems—they multiply them. Each hidden dependency makes every other problem worse. As systems grow, the compound effect becomes devastating.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// Layer 1: ServiceA hides its dependency on ServiceBclass ServiceA { constructor() {} doWork(): void { const b = locator.resolve<IServiceB>(ServiceTokens.B); b.help(); }} // Layer 2: ServiceB hides its dependency on ServiceC and ServiceDclass ServiceB { constructor() {} help(): void { const c = locator.resolve<IServiceC>(ServiceTokens.C); const d = locator.resolve<IServiceD>(ServiceTokens.D); c.assist(); d.support(); }} // Layer 3: ServiceC hides its dependency on ServiceEclass ServiceC { constructor() {} assist(): void { const e = locator.resolve<IServiceE>(ServiceTokens.E); e.execute(); }} // To use ServiceA, you actually need: B, C, D, E, plus whatever E needs...// But ServiceA's interface tells you: NOTHING // The hidden dependency graph://// A (hidden: B)// └── B (hidden: C, D)// ├── C (hidden: E)// │ └── E (hidden: ?)// └── D (hidden: ?)//// Every layer multiplies the problem:// - Discovery effort: multiplied// - Test setup complexity: multiplied // - Chance of missing something: multiplied// - Impact of changes: multiplied // With explicit dependencies, the same structure is VISIBLE: class ServiceA { constructor(private b: IServiceB) {} // ^^^^^^^^^^^^^^^^^^^^^^ // But wait, to create ServiceA, you need an IServiceB // Looking at ServiceB's constructor tells you what IT needs // The TYPES guide you through the entire graph| Problem | Single Hidden Dependency | Many Hidden Dependencies |
|---|---|---|
| Discovery | Read one class to find it | Read entire transitive closure |
| Testing | Mock one unexpected service | Build complex mock graphs |
| Debugging | One source of mystery behavior | Countless potential sources |
| Onboarding | Learn one convention | Absorb extensive tribal knowledge |
| Refactoring | Unknown impact in one area | Unknown impact everywhere |
| Confidence | Slight uncertainty | Pervasive fear of change |
The ultimate compound effect is fear. When hidden dependencies proliferate, developers become afraid to change anything because they can't predict what will break. Velocity decreases. Technical debt accumulates. The codebase becomes a minefield. This is the endgame of hidden dependencies.
Hidden dependencies are the root cause of most problems with Service Locator and similar anti-patterns. They corrupt fundamental software engineering activities: understanding code, testing it, changing it, and teaching it to others.
What's next:
Despite all these problems, there are scenarios where Service Locator remains acceptable or even preferable. The next page examines these edge cases, helping you recognize when the anti-pattern's costs might be worth accepting—and when Dependency Injection's benefits might not fully apply.
You now deeply understand the hidden dependencies problem—the fundamental issue that makes Service Locator and similar patterns problematic. This understanding extends beyond Service Locator to inform your thinking about API design in general: always prefer explicit requirements over implicit ones.