Guice Providers and @Provides: Construction Logic Without the new Keyword


Series: Guice for JVM Engineers · I (Fundamentals) — Part 4 of 8

So far every dependency in this series has been buildable by Guice on autopilot: a class with an @Inject constructor, an interface linked to an implementation. But the real world is messier. Sometimes building an object needs logic — read a config value, call a factory, talk to a connection pool, decide between two implementations at runtime. There’s no @Inject constructor that captures “open a JDBC DataSource from these three properties.” You need somewhere to put a few lines of actual code that returns the thing.

Guice gives you two places for that code, and they’re really the same idea wearing two outfits: the Provider<T> interface and the @Provides method. This post is about when to reach for each, and the one genuinely surprising trick — injecting a Provider you never bind — that solves a class of problems people usually solve badly.

Injecting a Provider, and why you’d want to

Start with the interface. jakarta.inject.Provider<T> is almost insultingly simple. One method:

package jakarta.inject

fun interface Provider<T> {
    fun get(): T
}
package jakarta.inject;

public interface Provider<T> {
    T get();
}

Guice’s own com.google.inject.Provider<T> extends that, adding nothing but documentation, so you can type your code against either. get() hands you a T.

Here’s the part people miss: for any type Guice can inject, it can also inject a Provider<T> of that type — for free, with no extra binding. If GreetingService is bindable, then Provider<GreetingService> is injectable too. You don’t bind it; Guice synthesizes it. And asking for the provider instead of the instance buys you four things:

  1. Lazy creation — nothing is built until you call get(). Useful when the dependency is expensive and not always needed.
  2. Multiple instances — call get() three times, get three objects (for non-singleton bindings).
  3. Crossing a scope mismatch — a long-lived object can hold a Provider of a short-lived one and call get() fresh each time, instead of capturing one stale instance.
  4. Breaking the eagerness of construction — handy for circular dependencies, which we’ll meet properly in Part 8.
import jakarta.inject.Inject
import jakarta.inject.Provider

// A request-scoped thing; each request should get a fresh one.
class RequestContext

// A long-lived service. If it captured a RequestContext directly,
// it would freeze the *first* request's context forever.
class AuditLog @Inject constructor(
    private val contextProvider: Provider<RequestContext>,
) {
    fun record(event: String) {
        val context = contextProvider.get() // fresh per call
        // ... write event against this request's context
    }
}
import jakarta.inject.Inject;
import jakarta.inject.Provider;

// A request-scoped thing; each request should get a fresh one.
class RequestContext {}

// A long-lived service. If it captured a RequestContext directly,
// it would freeze the *first* request's context forever.
class AuditLog {
    private final Provider<RequestContext> contextProvider;

    @Inject AuditLog(Provider<RequestContext> contextProvider) {
        this.contextProvider = contextProvider;
    }

    void record(String event) {
        RequestContext context = contextProvider.get(); // fresh per call
        // ... write event against this request's context
    }
}

If AuditLog had taken a RequestContext directly, Guice would inject one instance at AuditLog construction and that’s the one it keeps: wrong, if contexts are supposed to be per-request. The Provider defers the decision to call time. This is the single most useful thing to know about providers, and it costs you nothing to set up.

@Provides methods: a binding that’s just a method

The other outfit. When the construction needs logic, not just “pick this implementation,” you write a method in a module, annotate it @Provides, and Guice treats it as the recipe for that type. The annotation is dead simple; here’s its entire definition from the source:

@Documented @Target(METHOD) @Retention(RUNTIME)
public @interface Provides {}

No members. All the meaning is in the method it sits on. The method’s return type is the bound type, and Guice injects the method’s parameters. Whenever something needs that return type, Guice calls your method.

import com.google.inject.AbstractModule
import com.google.inject.Provides
import java.util.Properties
import javax.sql.DataSource

class DatabaseModule(private val config: Properties) : AbstractModule() {
    // No configure() body needed for this binding — the method IS the binding.
    @Provides
    fun dataSource(): DataSource {
        val ds = org.h2.jdbcx.JdbcDataSource()
        ds.setURL(config.getProperty("db.url"))
        ds.user = config.getProperty("db.user")
        return ds
    }
}
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import java.util.Properties;
import javax.sql.DataSource;

class DatabaseModule extends AbstractModule {
    private final Properties config;
    DatabaseModule(Properties config) { this.config = config; }

    // No configure() body needed for this binding — the method IS the binding.
    @Provides
    DataSource dataSource() {
        org.h2.jdbcx.JdbcDataSource ds = new org.h2.jdbcx.JdbcDataSource();
        ds.setURL(config.getProperty("db.url"));
        ds.setUser(config.getProperty("db.user"));
        return ds;
    }
}

A @Provides method lives right inside the module alongside your bind(...) calls, so the wiring stays in one readable place, exactly the legibility Guice is built around. You don’t need a configure() override at all if methods are your only bindings; Guice scans the module for @Provides automatically. Note one important default: a provider method runs every time the binding is satisfied, just like a new. If you want one shared instance, you scope it, coming up next.

Parameters get injected; qualifiers and scope go on the method

Two things make @Provides methods pull their weight. First, the method’s parameters are themselves injected — so a provider method can declare dependencies and Guice satisfies them before calling you. Second, annotations you put on the method are honored: a qualifier annotation changes which key the binding answers to, and a scope annotation like @Singleton controls how often the method runs. (I confirmed this against Guice’s ProvidesMethodScanner / ProviderMethodsModule: it reads the method’s qualifier into the binding Key and its scope annotation into the binding’s scope.)

import com.google.inject.AbstractModule
import com.google.inject.Provides
import com.google.inject.Singleton
import com.google.inject.name.Named
import javax.sql.DataSource

class RepositoryModule : AbstractModule() {
    // Parameter `dataSource` is injected (from the @Provides above).
    // @Singleton => Guice calls this method at most once.
    // @Named tags the result so you can ask for *this* repository specifically.
    @Provides
    @Singleton
    @Named("readOnly")
    fun readOnlyRepository(dataSource: DataSource): GreetingRepository =
        SqlGreetingRepository(dataSource, readOnly = true)
}
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import javax.sql.DataSource;

class RepositoryModule extends AbstractModule {
    // Parameter `dataSource` is injected (from the @Provides above).
    // @Singleton => Guice calls this method at most once.
    // @Named tags the result so you can ask for *this* repository specifically.
    @Provides
    @Singleton
    @Named("readOnly")
    GreetingRepository readOnlyRepository(DataSource dataSource) {
        return new SqlGreetingRepository(dataSource, /* readOnly= */ true);
    }
}

That @Named("readOnly") is a qualifier — it’s how Guice tells apart two bindings of the same type. It’s the whole subject of Part 5, so I’ll leave it as a teaser; the point here is that you attach it to the method, not to a bind(...) line. Without @Singleton, this method would run on every injection and you’d get a new repository (and a new connection) each time, rarely what you want for something backed by a pool. Scope where it counts.

Going deeper: Provider class vs. @Provides vs. .toProvider

You now have three ways to put construction logic in front of Guice. They’re not redundant; each has a sweet spot.

  • A @Provides method — the default. Reach for it first. The logic is short, it lives with the rest of the module, and there’s no extra class to name. The overwhelming majority of “I need some construction logic” cases are a @Provides method and you should feel no guilt about it.

  • A standalone class implementing Provider<T> — when the construction logic is substantial, has its own dependencies it injects via its @Inject constructor, deserves its own unit tests, or is reused across modules. A @Provides method that’s grown to 40 lines is a Provider class wearing a disguise. You wire it with .toProvider(...):

import com.google.inject.AbstractModule
import jakarta.inject.Inject
import jakarta.inject.Provider
import javax.sql.DataSource

// The Provider has its own injected dependencies and can be tested alone.
class DataSourceProvider @Inject constructor(
    private val config: DbConfig,
    private val metrics: MetricsClient,
) : Provider<DataSource> {
    override fun get(): DataSource {
        metrics.increment("datasource.created")
        return buildPooledDataSource(config) // however much logic you like
    }
}

class DatabaseModule : AbstractModule() {
    override fun configure() {
        bind(DataSource::class.java).toProvider(DataSourceProvider::class.java)
    }
}
import com.google.inject.AbstractModule;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import javax.sql.DataSource;

// The Provider has its own injected dependencies and can be tested alone.
class DataSourceProvider implements Provider<DataSource> {
    private final DbConfig config;
    private final MetricsClient metrics;

    @Inject DataSourceProvider(DbConfig config, MetricsClient metrics) {
        this.config = config;
        this.metrics = metrics;
    }

    @Override public DataSource get() {
        metrics.increment("datasource.created");
        return buildPooledDataSource(config); // however much logic you like
    }
}

class DatabaseModule extends AbstractModule {
    @Override protected void configure() {
        bind(DataSource.class).toProvider(DataSourceProvider.class);
    }
}
  • .toProvider(someInstance) — when you already hold a provider instance (a lambda, a third-party factory adapted to the interface) and just want to register it. It’s the same .toProvider method, overloaded to take an instance, a Class, a TypeLiteral, or a Key. Note that a class given to .toProvider gets its own dependencies injected; an instance you pass in does not — Guice uses it as-is.

When a provider blows up: ProvisionException

Provision can fail at runtime: your @Provides method throws, a connection refuses, a get() can’t deliver. Guice wraps that in a ProvisionException (com.google.inject.ProvisionException, a RuntimeException). The thing to internalize: this is a runtime failure, distinct from the configuration failures (CreationException) you get at Guice.createInjector(...) time. A missing binding is caught when the injector is built; a DataSource that can’t reach the database is caught when something actually asks for it. ProvisionException.getMessage() formats all the underlying errors, and getErrorMessages() hands you the structured Message list if you want to inspect them. Don’t catch ProvisionException to paper over a broken dependency — let it surface; that’s the whole point of failing where the truth is.

Gotchas

  • @Provides methods run every time by default. No scope annotation means a fresh call — and a fresh object — per injection. If the result is expensive or meant to be shared (a pool, a client, a cache), add @Singleton or you’ll quietly create N of them.

  • Asking for Provider<T> doesn’t make T lazy everywhere — only at that injection point. Other classes that inject T directly still get it eagerly. Laziness is a property of how you ask, not of the binding.

  • A Provider class passed as an instance isn’t injected; passed as a class it is. bind(X.class).toProvider(new MyProvider()) uses your object verbatim — its fields stay null unless you filled them. bind(X.class).toProvider(MyProvider.class) lets Guice construct and inject it. Pick the class form unless you have a reason not to.

  • Two @Provides methods returning the same unqualified type collide. Guice can’t tell which one answers a plain DataSource request, and you’ll get a binding conflict at injector-creation time. The fix is a qualifier on each (Part 5) — same type, different keys.

  • A @Provides method is invisible to bind(...) overrides unless you think in keys. It binds the Key formed from its return type plus any qualifier. Overriding it in a test means binding that same key. Plain return type, plain key; @Named return, @Named key.

  • Don’t reach for Provider<T> to dodge a circular dependency you could just untangle. It works — the lazy get() breaks the construction cycle — but a cycle is usually a design smell. Use the provider when the lifecycle genuinely demands it, not as spackle. (More on cycles in Part 8.)

What’s next

Providers gave us a place to put construction logic. But twice now I’ve waved at a problem and deferred it: what happens when you have two bindings of the same type — a read-only and a read-write DataSource, a primary and a fallback client — and need Guice to tell them apart? That’s qualifiers, and they’re more than just @Named.

Next: Part 5 — Qualifiers and Binding Annotations, where one type gets to have many implementations without Guice losing the plot.


Target keyword(s): guice provider, guice @Provides.

Comments