Private Modules: What Happens in the Module Stays in the Module


Series: Guice for JVM Engineers · II (Advanced & Real-World) — Part 5 of 8

A Guice injector has exactly one binding for any given key. Bind Config once and every object in the graph that asks for a Config gets that one — there is a single global namespace, and everybody shares it. Usually that’s the point. Occasionally it’s a disaster.

Consider two problems that look unrelated but are the same problem. First, leakage: your BillingModule needs an internal RetryPolicy, a Clock, a half-configured HttpClient — none of which anyone outside billing should ever see, let alone inject by accident. Bind them and they’re global, sitting in the graph as ambient temptation. Second, duplication: you need two of something — two DataSources, primary and replica, each wanting its own Config, its own pool size, its own URL. But Config is one key. Bind it twice and Guice throws CreationException for the duplicate; qualify every internal binding and you’ve turned a clean subsystem into a forest of @Primary/@Replica annotations smeared across types that have no business knowing they’re being instantiated twice.

PrivateModule solves both. It gives a chunk of your graph its own private namespace — bindings that are invisible from the outside by default — and lets you expose only the few keys you actually want to publish. What happens in the module stays in the module.

A PrivateModule and expose

PrivateModule is an abstract class, like AbstractModule, and you override configure() the same way. The difference is the default visibility: every binding you make is private to this module. To make one available to the parent injector — the global graph everyone else sees — you call expose(...).

import com.google.inject.PrivateModule

class BillingModule : PrivateModule() {
    override fun configure() {
        // All private: nobody outside this module can inject these.
        bind(RetryPolicy::class.java).toInstance(RetryPolicy(maxAttempts = 3))
        bind(BillingHttpClient::class.java).to(RealBillingHttpClient::class.java)

        // BillingService depends on the two private bindings above.
        bind(BillingService::class.java).to(RealBillingService::class.java)

        // Publish ONLY the service to the parent injector.
        expose(BillingService::class.java)
    }
}
import com.google.inject.PrivateModule;

class BillingModule extends PrivateModule {
    @Override protected void configure() {
        // All private: nobody outside this module can inject these.
        bind(RetryPolicy.class).toInstance(new RetryPolicy(3));
        bind(BillingHttpClient.class).to(RealBillingHttpClient.class);

        // BillingService depends on the two private bindings above.
        bind(BillingService.class).to(RealBillingService.class);

        // Publish ONLY the service to the parent injector.
        expose(BillingService.class);
    }
}

getInstance(BillingService.class) works — it’s exposed. getInstance(RetryPolicy.class) throws, because as far as the parent injector is concerned that binding doesn’t exist. The retry policy is real, it’s injected into BillingService just fine, but it lives behind a wall. You’ve published an interface and hidden the implementation details: encapsulation, applied to the object graph instead of to a class.

Mechanically, a private module is backed by a child injector (Guice’s docs describe it as a tree of environments rooted at the real injector). The child inherits scopes, type converters, and interceptors from the parent and can read the parent’s bindings, but the parent can only see what the child chooses to expose. The arrows point one way.

expose has three overloads, mirroring bind: a Class, a TypeLiteral, and a Key. The Class and TypeLiteral versions return an AnnotatedElementBuilder, so you can attach a qualifier with annotatedWith(...), which is exactly how we’ll publish two of something in a moment.

Exposing a @Provides method with @Exposed

expose(...) is for EDSL bindings — the bind(...).to(...) lines. But a lot of construction lives in @Provides methods, and you can’t expose those by key without writing the key out longhand. So Guice gives @Provides methods their own opt-in: annotate one with @Exposed and its binding is published, while every other @Provides method in the module stays private.

@Exposed lives in com.google.inject.Exposed, sits right next to @Provides, and that’s the whole API — it’s a marker with no members.

import com.google.inject.Exposed
import com.google.inject.PrivateModule
import com.google.inject.Provides
import com.google.inject.Singleton

class ReportingModule : PrivateModule() {
    override fun configure() {
        // configure() can stay empty; the @Provides methods do the work.
    }

    // Private: an implementation detail of how reports get rendered.
    @Provides
    fun renderer(): TemplateRenderer = MustacheRenderer()

    // Exposed: this is the one binding the rest of the app may inject.
    @Provides @Exposed @Singleton
    fun reportService(renderer: TemplateRenderer): ReportService =
        RealReportService(renderer)
}
import com.google.inject.Exposed;
import com.google.inject.PrivateModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;

class ReportingModule extends PrivateModule {
    @Override protected void configure() {
        // configure() can stay empty; the @Provides methods do the work.
    }

    // Private: an implementation detail of how reports get rendered.
    @Provides
    TemplateRenderer renderer() {
        return new MustacheRenderer();
    }

    // Exposed: this is the one binding the rest of the app may inject.
    @Provides @Exposed @Singleton
    ReportService reportService(TemplateRenderer renderer) {
        return new RealReportService(renderer);
    }
}

TemplateRenderer is fed to reportService as a method parameter — ordinary @Provides injection — but it never escapes the module. Only ReportService is exposed. The annotation reads like a deliberate “this one’s public,” which is far easier to scan than hunting through a separate configure() for matching expose calls.

The killer use case: two isolated copies of one subsystem

Here’s where private modules stop being a tidiness feature and start being load-bearing. You want a primary DataSource and a replica DataSource. Each needs its own Config — different URL, different pool size. With one global graph that’s impossible: Config is a single key.

So give each one its own private module. Inside the module, DbConfig and the connection pool are private and unqualified — they don’t know they have a twin. Only the finished DataSource is exposed, and it’s exposed under a qualifier so the two copies land at distinct keys in the parent graph.

The mechanism that makes this clean is expose(Key). You build a qualified Key from the module’s constructor argument, bind that exact key inside configure(), and expose it. The qualifier is chosen at runtime — once per module instance — which is precisely what a fixed @Provides @Exposed annotation can’t give you, so the explicit-Key form is the pattern to reach for here:

import com.google.inject.Guice
import com.google.inject.Key
import com.google.inject.PrivateModule
import com.google.inject.name.Names

// A reusable module: same code, different config, exposed under a name.
class DataSourceModule(
    private val name: String,
    private val config: DbConfig,
) : PrivateModule() {
    override fun configure() {
        val key = Key.get(DataSource::class.java, Names.named(name))
        bind(DbConfig::class.java).toInstance(config)    // private, unqualified
        bind(key).to(RealDataSource::class.java)         // qualified, internal
        expose(key)                                      // ...now publish it
    }
}

fun main() {
    val injector = Guice.createInjector(
        DataSourceModule("primary", DbConfig(url = "primary-url", poolSize = 20)),
        DataSourceModule("replica", DbConfig(url = "replica-url", poolSize = 5)),
    )
    val primary = injector.getInstance(Key.get(DataSource::class.java, Names.named("primary")))
    val replica = injector.getInstance(Key.get(DataSource::class.java, Names.named("replica")))
    // Two DataSources, each with its own private DbConfig. No collision.
}
import com.google.inject.Guice;
import com.google.inject.Injector;
import com.google.inject.Key;
import com.google.inject.PrivateModule;
import com.google.inject.name.Names;

// A reusable module: same code, different config, exposed under a name.
class DataSourceModule extends PrivateModule {
    private final String name;
    private final DbConfig config;

    DataSourceModule(String name, DbConfig config) {
        this.name = name;
        this.config = config;
    }

    @Override protected void configure() {
        Key<DataSource> key = Key.get(DataSource.class, Names.named(name));
        bind(DbConfig.class).toInstance(config);   // private, unqualified
        bind(key).to(RealDataSource.class);        // qualified, internal
        expose(key);                               // ...now publish it
    }
}

// ...
Injector injector = Guice.createInjector(
    new DataSourceModule("primary", new DbConfig("primary-url", 20)),
    new DataSourceModule("replica", new DbConfig("replica-url", 5)));

DataSource primary =
    injector.getInstance(Key.get(DataSource.class, Names.named("primary")));
DataSource replica =
    injector.getInstance(Key.get(DataSource.class, Names.named("replica")));
// Two DataSources, each with its own private DbConfig. No collision.

A note on why this works and @Provides @Exposed doesn’t, here: a @Provides method’s qualifier is baked into its annotation at compile time, so you can’t pick it per instance. The explicit-Key form chooses the qualifier from name at runtime, so the same module class, installed twice, publishes to two different keys.

RealDataSource injects a plain, unqualified DbConfig — it has no idea two of it exist. Each private module owns one DbConfig instance, links its qualified DataSource key to the implementation, and exposes that key. Install the module twice with different names and config, and the parent graph ends up with @Named("primary") DataSource and @Named("replica") DataSource, each wired to its own isolated innards. That is the move you cannot make cleanly any other way.

Going deeper: when to reach for this

Private modules earn their keep in two situations, and you should be able to name which one you’re in:

  • You’re shipping a reusable subsystem — a library or a platform module — whose internal bindings would pollute or collide with the host application’s graph. Hide the plumbing, expose the API. (This is why frameworks like Misk lean on them.)
  • You genuinely need N copies of the same subsystem with different internal configuration, distinguished by qualifier. The DataSource example is the canonical one; sharded clients and per-tenant pools are others.

When you’re not in one of those, don’t reach for it. If you just have one binding you’d rather not expose, the cheaper move is often to not make it public in your code and not bind it at all — let Guice’s just-in-time binding construct it implicitly. Private modules add a child-injector boundary and a layer of indirection; pay for it when encapsulation or multiplicity is the actual goal, not as a reflex.

Gotchas

  • You can only expose what you bind. expose(Foo.class) for a Foo that isn’t bound (or just-in-time creatable) inside the module is a configuration error. To expose under a qualifier, bind that exact qualified Key inside the module first — exposing reaches for a key, it doesn’t create one.
  • @Exposed is opt-in, per @Provides method. A bare @Provides in a PrivateModule is private. Forgetting @Exposed on the method you meant to publish gives you a “no binding” error at the call site, not in the module — look there when a published binding mysteriously isn’t.
  • Scopes are per-environment. A @Singleton bound inside a private module is a singleton within that module’s child injector. Install the module twice and you get two singletons — which is the whole point for the DataSource case, but a trap if you assumed “singleton” meant “one per JVM.”
  • The child sees the parent, not vice versa. Bindings in the private module can inject anything the parent exposes globally; the parent can only inject what the child explicitly exposes. If a private binding needs something, bind or expose it from the right side.
  • Injecting Injector gives you the wrong one if you’re not careful. Per Guice’s docs, a shared (just-in-time, root-environment) binding that injects Injector gets the root injector and can’t see the child’s private bindings; an explicit binding inside the module gets the child injector. If you inject Injector and can’t find a private binding, this is usually why.
  • Don’t over-isolate. Every private module is a child injector with its own environment. Nesting them deeply, or wrapping single bindings, turns a readable graph into a maze. Encapsulate subsystems, not individual classes.

What’s next

Private modules gave us spatial control over the graph — which bindings see which. Next we take temporal control: how long an instance lives and when Guice throws it away.

Next: Part 6 — Custom Scopes, where we go past @Singleton, implement the Scope interface, and build a request-or-session-style scope by hand.


Target keyword(s): guice private modules, guice expose.

Comments