Service Locator vs Dependency Injection in Java — Understanding the Tradeoffs
by Eric Hanson, Backend Developer at Clean Systems Consulting
The core difference
Both patterns solve the same problem: a class needs a collaborator it doesn't create itself. They differ in who initiates the lookup.
With dependency injection, the class declares what it needs through its constructor. Something external — a framework, a composition root, a test — provides the dependencies. The class is passive; it receives what it needs.
With the service locator, the class actively calls a registry to retrieve its dependencies. The class controls the lookup:
// Dependency injection — passive, declared
public class OrderService {
private final PaymentGateway gateway;
public OrderService(PaymentGateway gateway) {
this.gateway = gateway;
}
}
// Service locator — active, hidden
public class OrderService {
public void processOrder(Order order) {
PaymentGateway gateway = ServiceLocator.get(PaymentGateway.class);
gateway.charge(order.getTotal(), order.getPaymentMethod());
}
}
The difference looks small. The consequences are not.
What service locator hides
With the service locator version, nothing in OrderService's public API reveals that it depends on PaymentGateway. The dependency is invisible from the outside. To understand what OrderService needs, you must read its implementation — every method, in case any of them call ServiceLocator.get().
This has three practical consequences:
Hidden dependencies fail late. A ServiceLocator.get(PaymentGateway.class) call fails at runtime when PaymentGateway isn't registered. A constructor parameter fails at compile time — or at worst, at application startup when the DI container wires the graph. The service locator version of OrderService compiles and starts successfully whether or not PaymentGateway is registered; the constructor version does not.
Tests require registry setup. To test the service locator version of OrderService, you must populate the service locator registry with the required dependencies before calling any method. The registry is global mutable state — test isolation requires careful setup and teardown:
// Service locator test — registry setup required
@BeforeEach
void setUp() {
ServiceLocator.register(PaymentGateway.class, mockGateway);
}
@AfterEach
void tearDown() {
ServiceLocator.clear(); // or tests bleed into each other
}
// DI test — no registry, no global state
@BeforeEach
void setUp() {
service = new OrderService(mockGateway); // that's it
}
Dependency changes are invisible to callers. When OrderService acquires a new dependency via the service locator, nothing in its external contract changes — no constructor parameter is added, no compile error fires for callers that haven't provided the dependency. The new dependency is hidden until the code path that needs it runs.
Where service locator is still used
Despite these tradeoffs, service locator isn't extinct. It's the right tool in specific contexts:
Plugin architectures. When plugins are loaded at runtime by a class loader, their dependencies can't be injected at compile time — the plugin didn't exist when the host application was compiled. A registry that plugins query at load time is the practical mechanism. OSGi's service registry is a large-scale example of this pattern.
Framework integration points. ServletContext in Jakarta EE acts as a service locator — servlets retrieve services from it at request time. JNDI is a service locator for Java EE resources. These are framework-imposed constraints, not design choices.
Legacy codebase integration. When adding DI to a codebase that wasn't built for it, a service locator can be an intermediate step — centralizing dependency lookup while the codebase is gradually refactored toward constructor injection.
Hot-reload and dynamic configuration. When the implementation of a service needs to be swapped at runtime without restarting the application — A/B testing, feature flags that control implementation, configuration-driven behavior — a registry that can update its registrations at runtime is more flexible than constructor injection.
// Hot-reload use case — registry is updated at runtime
public class FeatureFlaggedPaymentGateway implements PaymentGateway {
public void charge(Money amount, PaymentMethod method) {
// Always retrieves current implementation — can be swapped without restart
PaymentGateway impl = ServiceLocator.get(
featureFlags.isEnabled("new-payment-processor")
? NewPaymentGateway.class
: LegacyPaymentGateway.class
);
impl.charge(amount, method);
}
}
This pattern requires that the service locator's registry is thread-safe and that swaps are atomic — both achievable but requiring explicit implementation.
Implementing a type-safe service locator
If a service locator is the right choice, implement it with type safety — avoid the stringly-typed get("paymentGateway") that returns Object:
public class ServiceLocator {
private static final Map<Class<?>, Object> registry = new ConcurrentHashMap<>();
public static <T> void register(Class<T> type, T implementation) {
registry.put(type, implementation);
}
public static <T> T get(Class<T> type) {
Object service = registry.get(type);
if (service == null) {
throw new ServiceNotFoundException(
"No service registered for: " + type.getName()
);
}
return type.cast(service);
}
public static <T> Optional<T> find(Class<T> type) {
return Optional.ofNullable(registry.get(type)).map(type::cast);
}
}
Class<T> as the key provides compile-time type safety — ServiceLocator.get(PaymentGateway.class) returns PaymentGateway, not Object. type.cast() is a safe cast — it throws ClassCastException with a useful message if the registered implementation doesn't match the type.
find() for optional services — ones that may not be registered — prevents the caller from writing null checks or try-catch around get().
The abstraction each pattern depends on
Both patterns benefit from interface abstractions — but for different reasons.
With DI, the interface lets you substitute a test double without changing the class under test. The substitution happens at construction time.
With service locator, the interface lets the registry hold multiple implementations that callers retrieve by type. Without the interface, the registry is a grab-bag of concrete classes that must be explicitly typed at every call site.
Neither pattern makes interfaces mandatory. A service locator can register concrete classes; a DI container can inject concrete classes. The interface is a design choice on top of the dependency resolution mechanism, not a requirement of either.
Combining both patterns
The patterns can coexist. Spring's ApplicationContext is a service locator that also manages DI — you can retrieve beans from it directly (context.getBean(OrderService.class)) or inject them via @Autowired. The ApplicationContext is itself injectable, allowing code to use the service locator when dynamic lookup is needed while keeping static dependencies as constructor parameters.
The combination principle: use constructor injection for static dependencies known at class creation time. Use the service locator for dynamic lookups that depend on runtime state — which implementation to use, whether a service is available, resolving services from loaded plugins.
A class that uses both clearly signals the distinction:
public class PaymentRouter {
private final FraudDetector fraudDetector; // static — always needed
public PaymentRouter(FraudDetector fraudDetector) {
this.fraudDetector = fraudDetector;
}
public void route(Payment payment) {
// Dynamic — which gateway to use depends on runtime configuration
PaymentGateway gateway = ServiceLocator.get(
config.getGatewayClass(payment.getMethod())
);
if (fraudDetector.isSuspicious(payment)) {
gateway.flagAndHold(payment);
} else {
gateway.process(payment);
}
}
}
fraudDetector is always needed and always the same implementation — constructor injection. The gateway varies by payment method at runtime — service locator. The code makes both choices visible and intentional.
The decision
For static dependencies known at class creation time, constructor injection is almost always the right choice — compile-time verification, visible dependencies, easy testing.
For dynamic lookups at runtime, plugin architectures, or legacy integration, service locator is the appropriate tool.
The key question: does this class need different implementations of its dependency at different times, or just one implementation throughout its lifetime? If one: inject it. If different: look it up.