Guice: Dependency Injection You Can Actually Read


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

Every non-trivial program has the same boring problem: something has to build your objects, in the right order, and hand each one its collaborators. Do it by hand and the new keyword metastasizes — every caller has to know how to construct the entire graph beneath the thing it actually wants. Guice, Google’s dependency injection framework for the JVM, makes that wiring someone else’s job, while keeping it as explicit, readable code rather than runtime magic. This series builds Guice up from first principles; this post is the “why.”

Every code sample here has a Kotlin and a Java tab — pick whichever you write, once, and the rest of the series follows your choice.

The problem: who builds your objects?

Say a GreetingService needs a GreetingRepository. The repository needs a data source. Without help, the caller assembles the whole chain by hand:

class GreetingService(private val repository: GreetingRepository) {
    fun greet(name: String) = repository.greetingFor(name)
}

fun main() {
    // The caller must know how to build everything underneath GreetingService.
    val repository = SqlGreetingRepository(dataSource())
    val service = GreetingService(repository)
    println(service.greet("world"))
}
class GreetingService {
    private final GreetingRepository repository;
    GreetingService(GreetingRepository repository) {
        this.repository = repository;
    }
    String greet(String name) {
        return repository.greetingFor(name);
    }
}

public static void main(String[] args) {
    // The caller must know how to build everything underneath GreetingService.
    GreetingRepository repository = new SqlGreetingRepository(dataSource());
    GreetingService service = new GreetingService(repository);
    System.out.println(service.greet("world"));
}

That’s tolerable for three objects. At three hundred — services depending on services depending on clients and config — every main, every test, every entry point becomes a hand-assembled construction manual. Change a constructor and the edits ripple outward to everyone who ever built that object. The wiring logic, which is incidental to what your code does, ends up smeared across the whole codebase.

Dependency injection, minus the buzzword

The fix has an intimidating name and a simple idea: a class should declare what it needs and let something else supply it, instead of constructing or looking up its own dependencies. “Injection” just means the collaborators arrive from outside, usually through the constructor.

You’re already doing the first half: GreetingService takes its repository as a constructor parameter rather than new-ing one. That’s good design on its own. What’s missing is the other half — something that knows how to satisfy those declarations and assemble the graph for you. That something is a DI container, and Guice is one.

Enter Guice: a module and an injector

Guice has two core concepts. A module declares how to satisfy dependencies — which implementation to use for an interface. An injector reads your modules and builds objects on demand, wiring in everything they need. You mark injectable constructors with @Inject:

import com.google.inject.AbstractModule
import com.google.inject.Guice
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)
}

class AppModule : AbstractModule() {
    override fun configure() {
        bind(GreetingRepository::class.java).to(SqlGreetingRepository::class.java)
    }
}

fun main() {
    val injector = Guice.createInjector(AppModule())
    val service = injector.getInstance(GreetingService::class.java)
    println(service.greet("world"))
}
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Injector;
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); }
}

class AppModule extends AbstractModule {
    @Override protected void configure() {
        bind(GreetingRepository.class).to(SqlGreetingRepository.class);
    }
}

// ...
Injector injector = Guice.createInjector(new AppModule());
GreetingService service = injector.getInstance(GreetingService.class);
System.out.println(service.greet("world"));

Notice how little the module says. We bound one thing — the GreetingRepository interface to its implementation — and asked for a GreetingService. We never told Guice how to build GreetingService or SqlGreetingRepository: both have an @Inject constructor, so Guice figures them out automatically (a “just-in-time” binding, which we’ll meet properly in Part 3). The main no longer assembles a graph; it asks for the thing it wants and gets it fully wired. Add a dependency to GreetingService’s constructor and nothing at the call site changes — Guice supplies the new collaborator.

Bindings, not scanning

If you’ve used Spring, this looks familiar but it is pointedly different in one way, and that difference is the whole reason to like Guice. Spring discovers beans by scanning the classpath for annotations and resolving them through proxies and reflection at runtime — convenient until two candidates collide or a cycle surfaces as a stack trace, and you’re left asking what did the container decide, and where?

Guice doesn’t scan. A dependency exists in the graph because a module explicitly bound it, in code you can read. When you wonder where a GreetingRepository comes from, the answer is a bind(...) line in a module, and your IDE jumps straight to it. The wiring is a little more verbose to write than an annotation, but it’s honest: nothing is decided behind your back. For a codebase many people maintain, that legibility is worth more than the keystrokes. Hence the title: Guice is dependency injection you can actually read.

This is also why Guice quietly underpins serious frameworks (Cash App’s Misk among them): when the container’s behavior is explicit, building a whole service platform on top of it stays predictable.

A note on the two tabs

A few things to set expectations for the rest of the series:

  • jakarta.inject, not javax.inject. Guice 7 moved to the Jakarta namespace; @Inject comes from jakarta.inject. (If you’re pinned to an older Guice, it’s javax.inject — we’ll cover the migration later.)
  • Kotlin uses SomeType::class.java in raw Guice, because Guice’s API takes java.lang.Class. It’s a touch noisier than Java’s SomeType.class. There are small reified helpers that turn bind(Foo::class.java).to(Bar::class.java) into bind<Foo>().to<Bar>() — Misk ships one — and we’ll build and discuss them in the Kotlin-idioms post. Until then the Kotlin tab shows plain Guice so the concepts stay framework-free.
  • Constructor injection only. Both tabs inject through the constructor. Guice supports field and method injection too, but constructor injection keeps dependencies explicit and objects immutable, and it’s what you should reach for.

What this series covers

Guice I (Fundamentals) takes you from here to confidently wiring a real application:

  • the ways to bind (linked, instance, @Provides, just-in-time)
  • Provider<T> and @Provides methods for construction logic
  • qualifiers and binding annotations, for when one type has many implementations
  • scopes — @Singleton and friends
  • composing and organizing modules as the graph grows
  • and what to do when it breaks: CreationException, circular dependencies, and reading the object graph

Guice II then goes advanced: multibindings, generics with TypeLiteral, assisted injection, AOP/method interception, private modules, custom scopes, the servlet extension, and testing.

Final thoughts

Dependency injection isn’t a framework feature you adopt; it’s a design discipline — declare what you need, don’t construct it — that you’re probably half-following already. Guice is just the machine that completes the other half, and its one strong opinion is the right one: the wiring should be explicit code you can open and read, not magic you reverse-engineer from a stack trace. Get comfortable with the module-and-injector loop from this post, because everything else in Guice is a richer way to answer the same question — how should this dependency be satisfied?

Next: Part 2 — Your First Injector, where we slow down on @Inject, AbstractModule, and the Injector itself, and turn this sketch into a project you can run.


Target keywords: guice, guice dependency injection, guice tutorial.

Comments