Guice Bindings: Five Ways to Answer One Question
Series: Guice for JVM Engineers · I (Fundamentals) — Part 3 of 8
A module exists to answer one question, asked over and over: when something needs a T, where does the T come from? That’s it. Every line in a configure() method is a different way of answering it. Part 1 showed you the simplest answer — bind(this interface).to(that class) — and waved at the rest. This post is the rest. There are a handful of distinct binding kinds, each with a shape and a moment it’s right for, and once you can name them, reading someone else’s module stops being archaeology. Let’s catalogue them.
Linked bindings: “use this implementation for that type”
The one you already know, and the one you’ll write most. A linked binding says: when someone asks for type A, give them an instance of subtype B. It’s how an interface points at its implementation.
class BillingModule : AbstractModule() {
override fun configure() {
bind(PaymentService::class.java).to(StripePaymentService::class.java)
}
}class BillingModule extends AbstractModule {
@Override protected void configure() {
bind(PaymentService.class).to(StripePaymentService.class);
}
}Note what Guice does not do here: it doesn’t construct anything yet. It records a rule — PaymentService resolves to StripePaymentService — and goes back to sleep. The StripePaymentService itself still gets built the normal way, via its @Inject constructor, whenever someone actually needs one. The link is just a forwarding pointer.
Links chain, too. bind(A).to(B) and bind(B).to(C) both resolve down to C — useful when a concrete type is itself an alias. And because to() takes a Class, TypeLiteral, or Key, you can point one binding at another binding rather than a raw class, which is how you express “treat A exactly like the thing already bound for B.” Most days, though, you’ll bind an interface to a class and move on. This is the default answer, and 80% of a real module is this line repeated.
Instance bindings: “here, I already built it”
Sometimes you don’t want Guice to construct the object — you’ve got one in hand and just need it injectable. A config object parsed at startup, a pre-configured client, a constant. toInstance hands Guice a finished value.
class ConfigModule(private val config: AppConfig) : AbstractModule() {
override fun configure() {
bind(AppConfig::class.java).toInstance(config)
bind(String::class.java)
.annotatedWith(Names.named("region"))
.toInstance("us-east-1")
}
}class ConfigModule extends AbstractModule {
private final AppConfig config;
ConfigModule(AppConfig config) { this.config = config; }
@Override protected void configure() {
bind(AppConfig.class).toInstance(config);
bind(String.class)
.annotatedWith(Names.named("region"))
.toInstance("us-east-1");
}
}Two things to internalize about toInstance. First, you own the object’s construction now, not Guice — it never calls a constructor, so any @Inject constructor on the type is simply ignored. (Guice will still perform field and method injection on the instance you handed it, if it has any. Constructor injection is off the table by definition — the thing already exists.) Second, instance bindings are eagerly created: the value is in hand before the injector finishes booting, so it can’t be lazy. That’s usually fine for config and constants — exactly the things you tend to bind this way — but it’s the reason you don’t toInstance something expensive that you might never use. For that, you want a provider (next post).
A natural pattern, shown above: the module takes the value through its own constructor and re-exposes it as a binding. Modules are just objects; passing them runtime values is allowed and common.
Untargeted and constructor bindings: concrete types, on your terms
What about a concrete class with no interface — you just want it in the graph, maybe scoped? You can bind it to nothing in particular. An untargeted binding names the type with no .to(...):
class AppModule : AbstractModule() {
override fun configure() {
// Tell Guice "this type participates", e.g. so you can scope it.
bind(MetricsRegistry::class.java).asEagerSingleton()
// For a class you can't annotate, point at its constructor explicitly.
val ctor = LegacyClient::class.java.getConstructor(HttpConfig::class.java)
bind(LegacyClient::class.java).toConstructor(ctor)
}
}class AppModule extends AbstractModule {
@Override protected void configure() throws NoSuchMethodException {
// Tell Guice "this type participates", e.g. so you can scope it.
bind(MetricsRegistry.class).asEagerSingleton();
// For a class you can't annotate, point at its constructor explicitly.
Constructor<LegacyClient> ctor =
LegacyClient.class.getConstructor(HttpConfig.class);
bind(LegacyClient.class).toConstructor(ctor);
}
}The plain bind(MetricsRegistry::class.java) form is untargeted: there’s no .to(), so Guice constructs the type itself (via its @Inject constructor), and the binding exists mainly so you can attach a scope or eager-loading to a concrete class you’d otherwise never have to mention.
toConstructor is the escape hatch for code you can’t touch. Some third-party class has a usable constructor but no @Inject annotation — you can’t add one. So you reflect the constructor and hand it to Guice, which treats it as if it were annotated. Guice’s own docs call this “a bit simpler than using a Provider” for that case, and they’re right: when the only obstacle is a missing annotation, don’t write a whole provider class to route around it. Note the checked NoSuchMethodException in the Java tab — getConstructor throws it, and configure() is allowed to declare throws.
Just-in-time bindings: the ones you never write
Here’s the binding you’ll use most and write never. If a class is concrete and has an injectable constructor (an @Inject constructor, or a public no-arg one), Guice can build it without any binding at all. It synthesizes one on demand the first time something needs the type. This is the just-in-time (JIT), or implicit, binding.
class EmailSender @Inject constructor(
private val smtp: SmtpClient,
)
class SmtpClient @Inject constructor()
class AppModule : AbstractModule() {
override fun configure() {
// Deliberately empty. Both classes above are concrete with
// @Inject constructors, so Guice wires them just-in-time.
}
}
fun main() {
val injector = Guice.createInjector(AppModule())
val sender = injector.getInstance(EmailSender::class.java) // works
}class EmailSender {
private final SmtpClient smtp;
@Inject EmailSender(SmtpClient smtp) { this.smtp = smtp; }
}
class SmtpClient {
@Inject SmtpClient() {}
}
class AppModule extends AbstractModule {
@Override protected void configure() {
// Deliberately empty. Both classes above are concrete with
// @Inject constructors, so Guice wires them just-in-time.
}
}
// ...
Injector injector = Guice.createInjector(new AppModule());
EmailSender sender = injector.getInstance(EmailSender.class); // worksThis is why the Part 1 module bound one line and still produced a fully wired GreetingService: the service and its repository implementation were concrete with @Inject constructors, so Guice filled the gaps itself. The takeaway: you only bind what Guice can’t figure out. Interfaces need a linked binding because Guice can’t guess the implementation. Concrete classes with an injectable constructor need nothing. New engineers over-bind out of nervousness, listing every class in configure(); resist it. An empty-ish module that wires a large graph isn’t a bug, it’s the point.
The catch: JIT bindings can’t be scoped or annotated through configuration — they’re plain, unscoped, default bindings. The moment you need @Singleton on a concrete class or a qualifier, you’ve crossed into needing an explicit binding (untargeted + .in(...), or a @Provides method). JIT is the convenience that evaporates the instant you need to say anything about the binding beyond “exists.”
Binding to a provider: a one-line teaser
The last shape answers “where does it come from?” with “ask this object.” When construction needs real logic — a builder, a factory call, a value read at runtime — you bind the type to a Provider whose get() returns the instance:
bind(Connection::class.java).toProvider(ConnectionProvider::class.java)bind(Connection.class).toProvider(ConnectionProvider.class);ConnectionProvider is itself just an injectable class implementing jakarta.inject.Provider<Connection>, so it can take dependencies in its constructor and build the connection however it likes. That’s the whole idea, and it’s big enough to own the next post — including the much nicer @Provides method form that lets you skip the provider class entirely. For now, just file it: when construction is logic, not a lookup, you bind to a provider.
Going deeper: which binding, when
Pinned to a wall, the decision is almost mechanical:
- Interface → implementation? Linked binding,
bind(I).to(Impl). The default. - Already have the object (config, constant, pre-built client)? Instance binding,
toInstance. Remember it’s eager. - Concrete class, nothing special needed? Nothing — let JIT handle it.
- Concrete class that needs a scope or qualifier? Untargeted
bind(C)plus.in(...), or a@Providesmethod. - Third-party class with no
@Inject?toConstructorwith the reflected constructor. - Construction needs logic?
toProvider(or@Provides) — Part 4.
The unifying idea is minimal specification. Every binding kind tells Guice the least it needs to know and no more. A linked binding withholds construction (Guice still builds the impl); an instance binding withholds nothing because there’s nothing left to build; a JIT binding withholds the binding itself. Reach for the kind that says the least while still being correct, and your modules stay short and honest — which, per Part 1, is the entire reason to use Guice instead of Spring’s classpath séance.
Gotchas
toInstanceis eager, full stop. The value must exist before the injector finishes building. Bind a heavyweight object this way and you pay for it at startup whether or not anything uses it. Want laziness? Use a provider.toInstanceignores the type’s@Injectconstructor. You built the object, so Guice won’t. It will still do field and method injection on it — which is a surprise if you assumed the instance was untouched. Constructor injection, though, is simply not in play.- Over-binding concrete classes is noise, not safety. Listing
bind(SmtpClient.class)whenSmtpClientalready has an@Injectconstructor and needs no scope adds zero behavior and one more line to read. Let JIT do its job; bind only what carries extra meaning. - JIT bindings are unscoped and unqualified. You cannot make a just-in-time binding a singleton or attach a
@Namedto it. The instant you need either, write an explicit binding. Assuming a concrete class is “probably a singleton” because it’s used everywhere is a classic, silent bug — JIT gives you a fresh instance each time. toConstructorneeds the exact parameter types.getConstructor(HttpConfig.class)matches one specific constructor by signature and throwsNoSuchMethodExceptionif you get the types wrong — including subtleties like primitiveintversusInteger. It’s reflection; it’s literal.- Linked bindings don’t create the target binding.
bind(A).to(B)makesAresolve toB, butBitself is only bound if it’s JIT-eligible or separately bound. Linking an interface to an abstract class, or to a class with no injectable constructor, fails at injector creation — not at the call site.
What’s next
You now know every shape a binding can take except the most powerful one, which we kept deliberately vague: binding to something that runs construction logic. That’s Provider<T> and the wonderfully terse @Provides method — how you wire types whose creation is a recipe, not a pointer, and how you inject dependencies into the construction itself.
Next: Part 4 — Providers and @Provides.
Target keyword(s): guice bindings, guice bind to.
Comments