When should you reach for ServiceLoader/SPI versus a dependency-injection framework or a hand-rolled plugin registry? What are the design tradeoffs?
answer
- SPI = open extension point, zero deps, JDK-native
- DI = internal assembly, injection + lifecycle + scope
- SPI limits: no injection, no scope, unspecified order, no-arg ctor only
- Compose: DI app discovers plugins via ServiceLoader; provider() can return a wired bean
- Choose by who owns the extension boundary
basics
~20 sUse ServiceLoader/SPI when you want zero-dependency, JDK-native plug-in discovery — third parties extend your library by dropping in a jar/module. Use a DI framework when you need rich lifecycle, scoping, and wiring of many beans inside one app. SPI is for open extension points; DI is for assembling your own application's objects.
solid answer
~60 sServiceLoader/SPI is the right tool when you are publishing an extension point that you want others — possibly unknown third parties — to plug into without you knowing them at build time, and you want no runtime dependencies beyond the JDK. It is how libraries and the JDK itself expose pluggability (JDBC, charsets, logging backends). Its limits: no dependency injection into providers, no scopes or lifecycle, unspecified ordering, and only a no-arg constructor or provider() factory to bootstrap. A DI framework (Spring, Guice, CDI) is better when you are assembling your own application: it injects collaborators, manages singletons/prototypes/request scopes, handles configuration and AOP, and gives explicit wiring. The two compose: a DI app can use ServiceLoader to discover plug-ins and then hand them to the container, or a provider() factory can fetch a fully-wired instance from a container. A hand-rolled registry makes sense only when you need behavior SPI lacks (priority, hot-reload, metadata) and don't want a framework. Choose by ownership: SPI for open extension boundaries, DI for internal assembly.
go deeper
Can say SPI is for plug-ins discovered by the JDK, while a DI framework wires up an app's objects.
Lists concrete SPI limits (no injection, no scope) and gives examples of when each tool fits.
Articulates the tradeoffs and knows SPI + DI compose, including provider() returning a container-managed instance.
Frames the choice by ownership of the extension boundary and richness of the object graph, designs SPI seams for a platform, and bridges discovery with wiring deliberately.
## Three approaches, defined - **ServiceLoader / SPI**: a **JDK-native** mechanism (no libraries) for discovering implementations of an interface at runtime via declared metadata (`META-INF/services` or `provides ... with`). Designed for **open extension points** — anyone can ship a provider jar/module. - **Dependency-injection (DI) framework** (Spring, Guice, CDI/Jakarta): a library/container that **constructs and wires** your application's objects, **injecting** their dependencies, and managing **lifecycle and scope** (singleton, prototype, request, session). Designed for **assembling one application** out of many collaborating objects. - **Hand-rolled plugin registry**: your own code that lets plugins **register** themselves (e.g. a static `Map<String, Handler>` populated at startup, or an annotation scan). Maximum control, maximum maintenance. ## What SPI is genuinely good at - **Zero dependencies.** Pure JDK — ideal for libraries that must not impose a framework on consumers. - **Open extensibility across a boundary you don't control.** A JDBC driver vendor extends `java.sql` without the JDK knowing the vendor exists. This is the canonical fit: a **library/platform publishing an SPI** for third parties. - **Encapsulation on the module path.** `provides ... with` lets a provider stay unexported yet instantiable. - **Lazy discovery.** Unused providers cost nothing. ## Where SPI runs out of road - **No injection.** A provider gets a **no-arg constructor** or a `provider()` factory — nothing is injected. If your provider needs collaborators, you must fetch them yourself (e.g. inside `provider()` pull from a container or a static locator). - **No lifecycle/scope.** No init/destroy callbacks, no per-request instances; ServiceLoader caches one instance per loader. - **Unspecified ordering / no priority.** You must encode priority yourself (annotation + `stream().sorted(...)`). - **No configuration binding, AOP, conditional wiring.** Those are DI-framework features. - **Stringly/file-based on the classpath**, with the shaded-jar merge gotcha. ## Where DI frameworks win When you are **assembling your own app**: many beans, each depending on others, with config, profiles/conditions, scopes, transactions, AOP, and testability via injected mocks. DI's whole job is internal wiring; doing that with raw SPI would be painful. ## They compose — not either/or The mature pattern is **both**: 1. **DI app discovers plugins via SPI.** At startup the app calls `ServiceLoader.load(Plugin.class)`, collects the providers, and registers them as beans so the rest of the app can inject the plugin list. (Spring Boot's `spring.factories`/auto-configuration is essentially a private SPI; Java's `ServiceLoader` is the standard one.) 2. **A `provider()` factory returns a container-managed instance.** Because ServiceLoader supports a static `provider()` method, that method can ask a DI container (or service locator) for a **fully wired** instance — bridging SPI discovery with DI wiring, so the provider *can* have injected dependencies after all. ## Decision rubric (by ownership of the extension point) - **You own a library/platform and want outsiders to extend it** → **SPI**. It's the standard, dependency-free contract; that's literally what the JDK does. - **You own the whole app and are wiring its internals** → **DI framework**. Richer lifecycle/scoping/config justify the dependency. - **You need SPI-style discovery plus injected dependencies** → **SPI for discovery + DI/`provider()` for wiring** (compose them). - **You need metadata/priority/hot-reload SPI lacks and refuse a framework** → **hand-rolled registry**, accepting the maintenance cost. (Or stay on SPI and add a priority annotation + `stream()` selection, which covers many of these needs without a registry.) ## A principal-level framing Think in terms of **who controls the implementation set** and **how rich the object graph is**. SPI optimizes for an *open, dependency-free, low-richness* boundary; DI optimizes for a *closed, framework-blessed, high-richness* internal graph. Most real systems use SPI at their public extension seams and DI inside, and bridge them with `provider()` factories.
- How can an SPI provider end up with injected dependencies despite SPI not supporting injection?Use a public static provider() factory method instead of a no-arg constructor. Inside provider(), obtain a fully wired instance from a DI container or service locator and return it. ServiceLoader prefers provider() over the constructor, so this bridges discovery (SPI) with wiring (DI).
- Why does the JDK use ServiceLoader rather than mandating a DI framework for JDBC drivers?Because the JDK must impose zero third-party dependencies and let unknown vendors plug in across a published boundary. SPI is dependency-free and standard; mandating Spring/Guice in the platform would be inappropriate coupling.
saying these in an interview costs you the question
- Claiming SPI can inject dependencies into providers (it can't natively — only no-arg ctor / provider())
- Treating SPI and DI as mutually exclusive when they commonly compose
- Using a heavy DI framework just to discover a couple of plug-ins a library should expose via SPI
- Assuming SPI gives ordering/priority guarantees
- Reaching for a hand-rolled registry before considering a priority annotation + stream() selection