Your First Guice Injector: Wiring That Builds Itself
Series: Guice for JVM Engineers · I (Fundamentals) — Part 2 of 8
Part 1 sold you on the idea — declare what you need, let a container supply it — and showed a module and an injector in the same breath. That post was a sketch. This one is a project: a tiny app you can paste into a fresh Gradle build and run, with every moving part slowed down enough to actually see. By the end you’ll know what @Inject does, what AbstractModule.configure() is for, and what Guice.createInjector(...) hands you back.
As before, every sample has a Kotlin tab and a Java tab. Pick your language once and ignore the other.
The problem: a graph with nobody to build it
You have a GreetingService that needs a GreetingRepository. The repository, in a real app, would need a data source, which needs config, which needs an environment lookup. Three classes in, the new keyword has turned your main into an assembly line — and every test that touches GreetingService has to reproduce that same line by hand. We want to write the classes, declare one fact (“use this implementation for that interface”), and have something assemble the rest. That something is the injector. Let’s give it the pieces it needs.
The dependency
Guice ships as a single artifact. Add it to your build — this series targets Guice 7.0.0, which is the first major version on the Jakarta namespace (jakarta.inject, not javax.inject):
// build.gradle.kts
dependencies {
implementation("com.google.inject:guice:7.0.0")
}// build.gradle
dependencies {
implementation 'com.google.inject:guice:7.0.0'
}That one line pulls in jakarta.inject transitively, so @Inject is on your classpath without a second declaration. Nothing else to configure — no XML, no annotation processor, no codegen step. Guice does its work at runtime when you build the injector.
@Inject: marking the constructor Guice should call
When the injector needs a GreetingService, it has to know which constructor to invoke and what to pass. You tell it by annotating the constructor with @Inject. Guice then reads the parameter types, builds each one (recursively), and calls the constructor for you.
import jakarta.inject.Inject
interface GreetingRepository {
fun greetingFor(name: String): String
}
class SqlGreetingRepository @Inject constructor() : GreetingRepository {
override fun greetingFor(name: String) = "Hello, $name"
}
class GreetingService @Inject constructor(
private val repository: GreetingRepository,
) {
fun greet(name: String) = repository.greetingFor(name)
}import jakarta.inject.Inject;
interface GreetingRepository {
String greetingFor(String name);
}
class SqlGreetingRepository implements GreetingRepository {
@Inject
SqlGreetingRepository() {}
public String greetingFor(String name) {
return "Hello, " + name;
}
}
class GreetingService {
private final GreetingRepository repository;
@Inject
GreetingService(GreetingRepository repository) {
this.repository = repository;
}
String greet(String name) {
return repository.greetingFor(name);
}
}In Kotlin the annotation sits on the primary constructor with the slightly awkward @Inject constructor(...) form — that’s the price of the constructor keyword being optional everywhere else. In Java it’s a plain annotation on a normal constructor.
A few rules worth internalizing now. A class can have at most one @Inject constructor — Guice won’t guess between two. If a class has a single no-arg constructor, you can skip @Inject entirely and Guice will use it; that’s why people sometimes drop it from leaf classes. And the constructor doesn’t need to be public — package-private (or, in Kotlin, the default public primary constructor) is fine, which keeps your injectable surface honest.
Use constructor injection. Guice also supports field injection (annotate a mutable field and Guice sets it after construction) and method injection (annotate a setter and Guice calls it). Both exist; both are worse. Field injection produces objects that are invalid until the container finishes with them and impossible to construct in a unit test without reflection, and it forces mutable, non-final/non-val fields. Constructor injection gives you immutable objects that are fully valid the moment they exist and trivially testable with a plain new/constructor call. Reach for the constructor. We won’t show the alternatives again.
The module: declaring how to satisfy a dependency
Guice can build GreetingService and SqlGreetingRepository on its own — both have @Inject constructors. What it can’t guess is which GreetingRepository implementation to use when something asks for the interface. That decision is the one fact you state explicitly, and you state it in a module by extending AbstractModule and overriding configure():
import com.google.inject.AbstractModule
class AppModule : AbstractModule() {
override fun configure() {
bind(GreetingRepository::class.java).to(SqlGreetingRepository::class.java)
}
}import com.google.inject.AbstractModule;
class AppModule extends AbstractModule {
@Override
protected void configure() {
bind(GreetingRepository.class).to(SqlGreetingRepository.class);
}
}configure() is where every binding in this module lives. Inside it, bind(...).to(...) reads as a sentence: bind this interface to that implementation. Note the Kotlin/Java seam from Part 1 — Guice’s API takes a java.lang.Class, so Kotlin writes GreetingRepository::class.java where Java writes GreetingRepository.class. It’s a little noisier; we’ll build reified helpers to hide it in the Kotlin-idioms post.
Everything else in this app is a just-in-time binding — Guice constructs it from its @Inject constructor without you binding it at all. The module only needs to speak up where Guice would otherwise have to guess. That’s the whole discipline: bind the ambiguous, let Guice infer the obvious.
The injector: createInjector and getInstance
A module is inert. To turn it into running wiring you hand it to Guice.createInjector(...), which returns an Injector — the live object graph. You then ask the injector for a fully-wired instance with getInstance(...):
import com.google.inject.Guice
fun main() {
val injector = Guice.createInjector(AppModule())
val service = injector.getInstance(GreetingService::class.java)
println(service.greet("world")) // Hello, world
}import com.google.inject.Guice;
import com.google.inject.Injector;
public class Main {
public static void main(String[] args) {
Injector injector = Guice.createInjector(new AppModule());
GreetingService service = injector.getInstance(GreetingService.class);
System.out.println(service.greet("world")); // Hello, world
}
}That’s a complete, runnable Guice application. createInjector takes a vararg of modules (there are also Iterable overloads — handy once you have a list of modules to compose). When you call getInstance(GreetingService.class), Guice sees the @Inject constructor, notices it needs a GreetingRepository, follows your binding to SqlGreetingRepository, builds that via its own @Inject constructor, and hands back a GreetingService with everything plugged in. You asked for the top of the graph; you got the whole thing.
Two things never to do with the injector in real code: don’t pass it around so classes can getInstance their own dependencies (that’s the service locator anti-pattern Guice exists to kill), and don’t call getInstance anywhere but the entry point. The injector is plumbing you touch once, at main, to pull the root object out. After that, dependencies arrive through constructors.
Going deeper: Stage and a Provider teaser
createInjector has overloads that take a Stage as the first argument, and the default — when you don’t pass one — is Stage.DEVELOPMENT. Stage is Guice telling you when to do its work:
Stage.DEVELOPMENToptimizes for fast startup and fast test cycles. It skips some up-front error checking and, notably, does not eagerly create singletons — they’re built lazily on first request.Stage.PRODUCTIONtakes the performance hit at boot to catch errors as early as possible. It eagerly constructs every singleton when you callcreateInjector, so a misconfiguration blows up at startup rather than at 3 a.m. on the first request that touches it.
(There’s a third, Stage.TOOL, for IDE plugins that want binding metadata without a working injector — you’ll never type it.)
The practical advice: run PRODUCTION in production. Fail at deploy, not under load.
import com.google.inject.Stage
val injector = Guice.createInjector(Stage.PRODUCTION, AppModule())import com.google.inject.Stage;
Injector injector = Guice.createInjector(Stage.PRODUCTION, new AppModule());One more thing the injector offers, as a teaser for later: alongside getInstance(T) there’s getProvider(T), which returns a Provider<T> — a deferred handle you call .get() on to produce an instance later, repeatedly. getInstance(type) is literally getProvider(type).get(). That indirection is the key to lazy construction, multiple instances, and scoping, and it’s the spine of Part 3 onward. For now, getInstance at the entry point is all you need.
Gotchas
jakarta.inject.Inject, notjavax.inject.Inject. Guice 7 moved namespaces. If you accidentally importjavax, Guice 7 won’t recognize the annotation and you’ll get a confusing “no injectable constructor” error. Check the import first.- Two
@Injectconstructors is a build-time-ish error. Guice refuses to choose; it throws aCreationExceptionwhen you build the injector. One injectable constructor per class, full stop. - A no-arg constructor needs no
@Inject. Guice will use a lone no-arg constructor automatically, so for leaf classes the annotation is optional — but being consistent and always marking the intended constructor saves confusion when you later add a parameter. getInstanceon an interface with no binding fails. If you callinjector.getInstance(GreetingRepository::class.java)but never bound it, Guice can’t construct an interface and throws.getInstanceworks without a binding only for concrete classes that have an injectable (or no-arg) constructor.- Errors surface from
createInjector, not where you’d expect. A missing binding deep in the graph throws aCreationExceptionat injector-creation time (or, for lazily-built things in DEVELOPMENT, at thegetInstancethat triggers them). The message lists every problem at once — read the whole thing, not just the first line. configure()isprotected/overridden, not called by you. You never invokeconfigure()yourself; the injector calls it while reading your module. Putting logic that should run at startup directly inconfigure()(rather than in a binding) runs it during configuration, which is rarely what you want.
What’s next
You now have a Guice app that builds its own object graph from one bind(...).to(...) and a sprinkle of @Inject. But binding an interface to a class is only the simplest of several ways to answer Guice’s central question — how should this dependency be satisfied? Sometimes you have an instance already, sometimes construction needs logic, sometimes Guice can figure it out with no binding at all.
Next: Part 3 — Ways to Bind, where we work through linked bindings, instance bindings, @Provides methods, and just-in-time bindings — the full vocabulary of the module.
Target keyword(s): guice injector, guice tutorial.
Comments