skip to content

Compare the Service Locator pattern (classes ask a global registry for their collaborators at runtime) with dependency injection. Why do many architects label Service Locator an anti-pattern, and when is it still justified?

level: seniorimportance: should knowfreq 45%

answer

  1. push vs pull: injected vs looked-up
  2. hidden deps: constructor says nothing
  3. fail at startup vs fail at 3 a.m.
  4. tests must seed and reset a global registry
  5. fine at plugin boundary, not in domain logic

basics

~20 s

With a service locator, a class calls a shared registry to fetch what it needs, so its dependencies are hidden inside method bodies. With dependency injection, they arrive through the constructor, so they are visible, required at construction, and easy to replace in tests.

solid answer

~50 s

Both patterns invert who *creates* collaborators, but they differ in who *knows* about them. Dependency injection makes dependencies part of the type's public contract: the constructor lists them, missing wiring fails at startup (or compile time), tests just pass fakes, and static analysis can enforce layering. A service locator turns every dependency into a runtime lookup against a global registry, so: the class's real dependencies are invisible from its signature; wiring mistakes surface as late runtime failures on rare code paths; every unit test must populate and then clean a global registry, creating shared-state coupling between tests; and the locator itself becomes a dependency of everything, including library code you wanted to keep portable. It is defensible when you genuinely cannot inject — plugin systems and late binding where the set of implementations is unknown until runtime, framework/legacy boundaries that construct objects for you, or as a temporary shim during a migration to DI. Even then, keep the lookup at the composition edge, not scattered through domain logic.

code

pseudocode · 12 lines
pseudocode
// Service Locator: dependency invisible, resolved late, test needs global setup
class Checkout {
  fun pay(order: Order) {
    val gateway = ServiceLocator.get(PaymentGateway)   // hidden, may fail here
    gateway.charge(order.total)
  }
}

// Constructor injection: dependency is part of the contract, fails fast at wiring
class Checkout(private val gateway: PaymentGateway) {
  fun pay(order: Order) = gateway.charge(order.total)
}

go deeper

for a junior

Contrast the two mechanically: 'given to me' versus 'I go fetch it', and note that injection makes testing with fakes easy.

for a middle

Add hidden dependencies, late runtime failures versus fail-fast wiring, and the global-registry setup/reset burden in tests.

for a senior

Discuss library versus application context, container-as-locator, lifetime opacity, plugin/late-binding exceptions, and a concrete incremental migration strategy.

for a principal

Frame as enforceable architecture: signature-level dependency visibility enables automated layering checks and blast-radius analysis; weigh where a locator is a deliberate seam (plugins, framework boundaries) versus accidental erosion, and set the governing rule.

## Setting the vocabulary - **Dependency (collaborator)** — another object your class needs to do its job (a repository, a clock, a payment gateway). - **Inversion of Control (IoC)** — the general idea that a class does not create its own collaborators; something outside decides. - **Dependency Injection (DI)** — an IoC style where collaborators are *handed in*, usually as constructor parameters (also setter or method injection). - **Service Locator** — an IoC style where the class *asks* a well-known registry: `val repo = ServiceLocator.get(UserRepository)`. - **Composition root** — the single place (usually `main` or a container configuration) where the object graph is assembled. Both remove `new ConcreteRepo()` from the class. The difference is *direction of knowledge*: injection pushes, location pulls. ## The case against Service Locator ### 1. Dependencies become invisible A constructor is a contract: `Checkout(pricing, tax, ledger)` tells any reader and any tool what this class touches. With a locator, `Checkout()` reveals nothing; the truth is buried inside method bodies, possibly on a rare branch. Consequences: reviewers can't see coupling, layering rules cannot be checked mechanically, and you cannot estimate the blast radius of changing an interface. ### 2. Failures move from startup to runtime A DI container that cannot satisfy a constructor fails fast at wiring time — before serving traffic. A locator lookup only fails when that line executes, which may be the rare refund path at 3 a.m. This trades a deterministic boot-time error for a probabilistic production error. ### 3. Test friction and test coupling To unit-test a locator-based class you must populate a global registry with fakes and then reset it, or tests contaminate each other. That reintroduces global mutable state (see the Singleton discussion), makes tests order-dependent, and hinders parallel execution. With DI, the test simply constructs the object with fakes — no global setup, perfect isolation. ### 4. The locator itself becomes a universal dependency Every class now depends on the locator type. For *application* code that may be tolerable; for a *library* it is a poison pill — consumers must adopt your locator. Mark Seemann's well-known argument is precisely this: Service Locator is acceptable-ish inside an app you control but is an anti-pattern in reusable libraries because it foists a hidden dependency on all consumers. ### 5. Type safety and lifetime opacity Many locators are keyed by type token or string and return a general type, requiring casts and enabling typos. And the lifetime of what you get back (fresh? shared? request-scoped?) is a property of registry configuration invisible at the call site. ## The honest case *for* it - **Late binding / plugins**: when the concrete set of implementations is only known at runtime (loaded modules, tenant-specific strategies), some registry lookup is unavoidable. Prefer to resolve once at the boundary and inject the result downward. - **Framework-constructed objects**: UI controls, serializers, legacy entities and some ORM/framework hooks are instantiated by the framework, so constructor injection is not available; a locator (or the framework's own resolver) fills the gap. - **Migration shim**: while converting a legacy codebase, having constructors default to `ServiceLocator.get(...)` lets you introduce injection incrementally without changing all call sites at once. - **Extremely wide optional dependencies**: rather than a 15-parameter constructor, some designs resolve rarely-used services lazily — though that is usually a signal to split the class, not to add a locator. ## Related distinctions people confuse - **A DI container is not a service locator.** A container *may* expose a `resolve()` method, but using it only in the composition root is fine. Calling `container.resolve()` from business code is what turns it into the anti-pattern ("container as service locator"). - **Ambient context / static gateways** (`CurrentUser.get()`, `Clock.now()` as statics) are the same shape with a single service; they carry the same visibility and testability costs. - **Property/setter injection** sits in between: visible-ish, but allows temporarily invalid objects because the dependency may be missing after construction. Constructor injection is preferred because it makes an incompletely wired object unrepresentable. ## Practical guidance 1. Default to constructor injection; make dependencies explicit and required. 2. Confine any `resolve()` call to the composition root or to a genuine plugin boundary. 3. If you must locate, wrap the lookup behind a small, injectable abstraction (e.g. a `PaymentProviderFactory`) so business code still declares what it needs. 4. If constructors are getting huge, treat it as evidence of a god object — split the class rather than hiding the dependencies behind a locator. 5. Enforce with an architecture test that no code outside the wiring package references the locator/container type.

  • Is using a DI container automatically a service locator?
    No. It depends where you call it. Resolving the object graph once in the composition root is normal DI. Injecting the container into business classes and calling resolve() there reproduces every downside of Service Locator — hidden dependencies, late failures, global test state.
  • How would you migrate a locator-heavy codebase to injection without a big-bang change?
    Add constructor parameters that default to the existing lookup, so old call sites still compile. Convert call sites gradually, forbid new locator calls with a lint or architecture test, then delete the defaults and let the compiler find the remainder.
  • Constructor injection is pushing a class to 12 parameters. What does that tell you?
    That the class has too many responsibilities. The long parameter list is honest feedback; hiding it behind a locator suppresses the signal. Split the class, or group cohesive dependencies into a higher-level collaborator.

Injection is a job posting listing required tools; the worker cannot start without them. A service locator is a worker who wanders to a shared supply cupboard mid-task — nobody knows what they'll need until they need it, and if the cupboard is missing an item, they discover it only at the worst moment.

saying these in an interview costs you the question

  • "A DI container is a service locator" — only if you call resolve() outside the composition root
  • Claiming Service Locator is equally fine in libraries and applications
  • Believing setter injection gives the same guarantees as constructor injection
  • Using a locator to hide a 12-parameter constructor instead of splitting the class
  • "Injection is just more boilerplate" — ignoring fail-fast wiring and test isolation

context