DependenciesGENERALCONTESTEDLANGUAGE-SPECIFIC

Service Locator

A global registry objects pull dependencies out of. It makes dependencies implicit — which defeats local reasoning and moves whole classes of error from compile time to run time.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

Why is asking a registry for a dependency worse than being handed it, when the object ends up with the same collaborator either way?

The requirement

Threading a logger and a feature-flag client through six layers is genuinely tedious, and someone proposes a ServiceLocator so any code can call ServiceLocator.get("flags") wherever it needs one.

The obvious build

A static registry with register(name, instance) and get(name). It removes all the threading, any code anywhere can reach any service in one line, and adding a dependency to a deeply nested class requires editing exactly one file. That is a real reduction in friction, and the reason locators keep being reinvented is that the friction they remove is the friction people feel every day.

Why it breaks

A class's dependencies stop being readable from the class. To know what OrderService touches you must read every method body and every method they call, transitively — which is the definition of losing local reasoning (Local Reasoning).

How it breaks as requirements change
  • A class's dependencies stop being readable from the class. To know what OrderService touches you must read every method body and every method they call, transitively — which is the definition of losing local reasoning (Local Reasoning).
  • A missing or misnamed registration is not a compile error. It is a null or an exception at the moment that code path first runs, which for a rarely-taken branch means in production, weeks later (Validate at Startup, Fail Loudly).
  • A test that overrides "flags" leaks into the next test in the same process unless every test resets the registry, and the failure that produces is order-dependent and intermittent — the most expensive kind of test failure there is.
  • Nothing constrains who can reach what. Two years in, the pricing rules call ServiceLocator.get("http") because it was one line, and no boundary in the codebase means anything any more (Trust Boundaries).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The tedium is real; nobody proposing this is confused, and "just thread it through" is a worse answer than it sounds when the chain is six deep.
  • There is already one global-ish thing in the codebase — a static logger — and it has never caused a visible problem.
  • Tests run in parallel in the same process, which turns any shared mutable registry into a source of cross-test interference.
  • A plugin system is on the roadmap, where the set of implementations genuinely is not known at compile time.
Invariants
  • Two tests running concurrently must not be able to see each other's doubles.
  • Whatever the mechanism, a missing dependency must be detected before a customer request reaches the code path that needs it.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • A locator claims to own "finding the implementation of a named service" — which sounds narrow and is not, because it also silently owns lifetime, thread-safety, initialization order and test isolation.
  • The class using the locator owns knowing the *string key*, which is a dependency on a naming convention rather than on a type.
  • Nobody owns the registration being complete and correct, which is the actual defect source.
  • By contrast, under injection the composition root owns all four of those explicitly, in one file, checked by the compiler (Wiring and the Composition Root).
Boundaries
  • A locator erases boundaries by construction: every module is one call away from every service, so the import graph no longer reflects the dependency graph. That is the deepest problem with it and it is not fixable by discipline.
  • The one place a lookup is structurally honest is where the set of implementations genuinely is not known when the calling code is compiled — plugins, extensions, handlers discovered by scanning (Plugin Architecture).
  • The boundary between "registry" and "service locator" is who does the lookup. A registry the composition root reads to build the graph is fine; a registry that arbitrary business code reads mid-execution is the pattern being warned about.

The dependency is still there; it is just not written down

The locator does not remove a dependency. OrderService needs a payment gateway in both versions below and will fail without one in both. What changes is whether that fact appears anywhere a compiler, a reviewer, or a person reading the class can see it.

This is why the argument is about reasoning rather than about coupling. The coupling is identical. The difference is that one version tells you and the other makes you find out.

Two classes with the same dependencies
1// ── located ────────────────────────────────────────────────
2export class OrderService {
3 async place(order: Order) {
4 const gateway = ServiceLocator.get<PaymentGateway>('payments')
5 const flags = ServiceLocator.get<FeatureFlags>('flags')
6 if (flags.on('instant-capture')) await gateway.charge(order.total)
7 }
8}
9// Signature: `new OrderService()`. It touches nothing.
10// Reality: payments, flags, and whatever those touch.
11// Misspell 'payments' and you find out on the first order.
12
13// ── injected ───────────────────────────────────────────────
14export class OrderService {
15 constructor(
16 private readonly gateway: PaymentGateway,
17 private readonly flags: FeatureFlags,
18 ) {}
19 async place(order: Order) {
20 if (this.flags.on('instant-capture')) await this.gateway.charge(order.total)
21 }
22}
23// Signature: everything it touches. Misspell nothing:
24// there is no name to misspell.

The generic parameter on get<PaymentGateway>() is worth noticing, because it looks like type safety and is not: it is an unchecked assertion about what someone registered under a string. The compiler is not verifying anything there, and the code reads as if it were.

What the registry actually owns

It is worth putting the locator itself through a responsibility analysis, because the answer explains why the pattern behaves worse than it looks. A locator is usually justified as owning one small thing — "finding a service by name" — and it in fact owns four, three of which nobody decided to give it.

A unit with this many independent reasons to change, that every other module depends on, is the shape this domain spends most of its time warning about. That it is small and looks like infrastructure is what lets it through review.

responsibilitiesServiceLocator (static registry)
Knows
  • A string-to-instance map covering every service in the application
  • Which instances exist right now, and implicitly when they were created
  • Nothing about types: the cast at the call site is an assertion, not a check
Does
  • Resolves a name to an instance on demand, from anywhere in the process
  • Holds instances for the process lifetime, making itself the de facto lifetime manager
  • Silently decides thread-safety and initialization order by whatever the registration code happened to do
  • Provides the override mechanism tests use, and therefore couples test isolation to global state
Depends on
  • Every registered service type
  • The registration code having run first, which nothing enforces
  • A naming convention shared by every caller in the codebase
Changes when — 5 distinct reasons
  • A new service is added
  • A service's lifetime needs to change (per-request instead of singleton)
  • Thread-safety requirements change
  • Test isolation needs scoping
  • The naming convention changes

Five independent reasons to change in a unit that every module depends on and no module declares a dependency on. Whatever else is true of it, it is a coordination point disguised as a utility — and the fact that it usually fits in forty lines is precisely why nobody treats it as one (Divergent Change).

Where lookup is genuinely the right shape

LIFETIME-SPECIFICFor a tool with a six-week life and one author, a locator costs nothing: the reader who suffers is the same person who wrote it, and there is no later. The costs here are all paid by people who arrive after the decision, so the argument scales with how long the code lives and how many people read it — which is exactly why it is so often made badly by whoever is writing it first.

Being fair about this matters, because the case where lookup is correct is not a grudging exception — it is a real design situation with no better answer. If the set of implementations is not known when the calling code is compiled, there is nothing to inject, and any honest design has a lookup in it somewhere.

The distinction that survives is not lookup-versus-injection. It is whether the lookup is *scoped, typed and itself injected*, or global, stringly-typed and reachable from anywhere.

Someone wants a registry. Which one do they actually need?

Is the set of implementations known at compile time, and who needs to do the resolving?

Known set, resolved once at startup

when The ordinary case: one gateway, one repository, one mailer.

cost Inject them. The wiring cost is real and it is the price of the compiler checking your graph (Constructor Injection).

Unknown set, discovered at load time

when Plugins, extension points, format handlers registered by third parties.

cost A typed registry is correct here. Inject *the registry*, not a global — so a test can hand in a registry with two fake plugins (Plugin Architecture).

Optional capability that may be absent

when Metrics, tracing, an enterprise-only feature.

cost Usually better as a required dependency with a no-op implementation. Lookup-and-null-check spreads the absence across every call site (Optional Values and Absence).

Genuinely ambient per-request value

when Request id, trace context, current tenant.

cost Use the language's scoped-context mechanism, which at least defines a lifetime. It is still invisible in signatures, and multi-tenant systems have leaked data this way (Tenant Isolation).

Legacy code you cannot thread through

when A ten-year-old module with no seams and no tests.

cost A locator can be a legitimate temporary seam to get the code under test at all — that is Feathers' argument and it is sound. Write down that it is temporary and what would retire it (Seams, Revisit Triggers).

How to build it

Most important first.

  • Thread the dependency instead, and if that is genuinely painful, treat the pain as information about your layering before treating it as a reason for a registry (Package by Layer).
  • For cross-cutting values that really are ambient — the current request id, a trace context — use the language's scoped-context facility rather than a global registry, so at least the lifetime is defined (Request Context Propagation).
  • Where lookup is genuinely required, make it a typed, injected registry rather than a static one: constructor(private plugins: PluginRegistry) keeps the dependency visible and testable while still resolving late (Dependency Injection).
  • Resolve at construction time, not at use time. A locator called once in a factory to build the graph is a wiring detail; a locator called inside a business method is a hidden dependency (Wiring and the Composition Root).
  • If you keep a static locator, add a startup check that resolves every key the application will ever ask for, so the compile-time proof you gave up is bought back as a test.

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Adding a dependency deep in the tree: one line, and this is genuinely and permanently cheaper than injection. The locator is not irrational — this is what it buys and it buys it every time.
  • Understanding what a class touches, before changing it: read every method transitively. That cost is paid on every change by every person, and it is where the saving above is spent several times over.
  • Removing a service: nothing tells you who used it. Under injection the compiler enumerates the call sites; under a locator you grep for a string and hope nobody built the key by concatenation.
  • Migrating off it later: every use site is a hidden dependency that has to be discovered and lifted into a signature, and there is no compiler assistance for any of it. This is the change that makes the decision hard to reverse (Reversible and Irreversible Decisions).
What the recommended approach costs
  • Injection's alternative is not free, and pretending otherwise is why this argument is often lost. Threading a dependency through six layers touches six files that do not care, and does so every time.
  • Recommending injection everywhere means the composition root grows large and the wiring becomes its own thing to maintain.
  • The typed-injected-registry compromise keeps the visibility and gives up the one-line convenience entirely, so the people who wanted the locator do not actually get what they wanted.

What can go wrong

Failure modes
  • A key is misspelled and the failure surfaces on a rare code path in production, with a stack trace that points at the lookup rather than at the missing registration.
  • Registration order matters and is implicit, so a service resolved during another service's construction gets a partially initialized instance (Initialization Races covers the concurrent version of this).
  • Tests interfere. The mitigation — a reset hook in a global test fixture — fails the moment one test forgets it or one runs in a different runner.
  • The mitigation of typing the keys (a typed locator with an enum) fixes the misspelling and none of the other four problems, which makes it feel solved and leaves it unsolved.
Dependencies, and their direction
  • Every class using the locator depends on the locator, on the key string, and on the registration having happened — three dependencies, none of them typed, none visible in the signature.
  • The locator depends on everything, which makes it a cycle magnet: it is simultaneously below every module (they call it) and above every module (it holds them) (Dependency Cycles).
  • Tests depend on global registration state, which is what makes them order-dependent.
Misreads
  • "Service locator is an anti-pattern, full stop." It is a bad default and a legitimate mechanism. Plugin discovery, extension points and any case where implementations are genuinely unknown at compile time are lookups by nature, and calling that an anti-pattern leaves you without a name for the thing you have to do anyway (Plugin Architecture).
  • "It is the same as a DI container, so if containers are fine this is fine." The difference is who calls the resolver. A container resolves the graph at the composition root and hands finished objects to code that never mentions it; a locator is called by the business code itself, which is exactly where the visibility is lost (Wiring and the Composition Root).
  • "Typing the keys fixes it." It fixes misspelling. It does nothing about invisibility, test isolation, lifetime or the erasure of boundaries.
  • "A singleton logger proves it is fine." Logging is the special case that genuinely does not damage reasoning much, because it has no return value and no effect on control flow. Generalising from it to stateful, behaviour-changing dependencies is the step that goes wrong (Logging at Boundaries).
Smells this explains
  • hidden-global-state
  • utility-dumping-ground

Testing it, and how it ages

What to test, and at which boundary
  • The diagnostic test is whether a class can be unit tested without touching global state. If setting up a test means registering things in a singleton, the design has a hidden dependency (Testing as Design Feedback).
  • If a locator stays, test the registry itself: a test that resolves every key used anywhere, so a missing registration fails in CI rather than in production.
  • Run tests in parallel deliberately. Serial test runs hide global-state interference, and a locator plus parallel tests exposes it immediately, which is worth knowing early.
  • For the legitimate plugin case, contract-test every discovered implementation against the same suite, since the compiler cannot check what it has never seen (Contract Tests).
How this design ages
  • Locators spread. The first one is for logging, and because the mechanism is available and one line, the tenth is for the payment gateway. There is no natural stopping point built into the pattern, which is why it is worth deciding about rather than drifting into.
  • They tend to survive framework migrations, because so much code depends on them that removing one is a codebase-wide change nobody schedules.
  • The one direction it ages well: as an explicitly-scoped plugin registry with a typed interface and a documented lifecycle, which is a different artefact that happens to share a mechanism.

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat a dependency obtained from a global registry is not visible in the consuming code's signature is true in every language, and everything downstream of that — reasoning, testing, removal — follows from it.
  • CONTESTEDThe strongest defence, and it deserves to be stated properly: injection's cost is paid on every layer of every call chain forever, while the locator's cost is paid only by the person trying to understand an unfamiliar class — and in a codebase with strong conventions, good naming and a small team where everyone already knows what is in the registry, that reader cost is close to zero while the threading cost is not. Ambient context is also how several successful ecosystems work in practice: Rails, Django and many game engines locate rather than inject, at enormous scale, and their maintainers are not confused. The rebuttal is not that they are wrong but that they pay for it with framework-enforced conventions most codebases do not have.
  • LANGUAGE-SPECIFICIn a language with a real module system and compile-time linking, the alternative to a locator is cheap and well-supported. In a dynamic language where any module can be imported and patched from anywhere, the boundary a locator erases was weaker to begin with, so the marginal damage is smaller — which is part of why the pattern is more accepted in Ruby and Python communities than in Java or Go ones.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — order-dependent, intermittently failing tests are the most common way a service locator announces itself, and diagnosing that class of flake is theirs.