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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

The Business Side of Software Engineering

Software isn’t just built — it’s funded, prioritized, and traded off. Behind every technical decision, there’s a business decision hiding.

Read more

How Oslo and Copenhagen Startups Cut Backend Costs Without Cutting Quality

You just ran payroll and noticed that your two backend engineers cost more than your entire sales team combined. In Oslo or Copenhagen, that's not unusual — it's just math that gets harder to justify every quarter.

Read more

OAuth2 and JWT in Spring Boot — Resource Server Configuration, Token Validation, and Claims Extraction

A Spring Boot service that protects resources with OAuth2 JWT tokens is a resource server. Configuring one correctly requires understanding token validation, claims extraction, scope-based authorization, and how to test without a live authorization server.

Read more

How the JVM Manages Memory — Heap Regions, GC Algorithms, and What to Tune

JVM garbage collection is not magic — it follows predictable patterns that determine latency, throughput, and memory footprint. Understanding the model lets you tune effectively instead of guessing at flags.

Read more