Scopes: How Long Should an Object Hang Around?


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

So far we’ve been answering which object Guice should build when you ask for a type. This post answers a different question: how many of them should exist, and for how long? That’s scope — the lifetime policy attached to a binding. Get it wrong and you either churn through objects you meant to share, or share an object that was never meant to outlive a single request. Guice’s default is deliberately the safe-but-wasteful one, and the whole job of this post is teaching you when — and how — to override it.

The default: a fresh instance every time

Unless you say otherwise, a Guice binding is unscoped. Every time the injector satisfies a dependency on that type, it constructs a brand-new instance. Ask for the same thing twice, get two different objects:

import com.google.inject.AbstractModule
import com.google.inject.Guice

class Stamp {
    val id = System.nanoTime()
}

class AppModule : AbstractModule() {
    override fun configure() {
        // No scope declared — unscoped is the default.
        bind(Stamp::class.java)
    }
}

fun main() {
    val injector = Guice.createInjector(AppModule())
    val a = injector.getInstance(Stamp::class.java)
    val b = injector.getInstance(Stamp::class.java)
    println(a === b) // false — two distinct objects
}
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Injector;

class Stamp {
    final long id = System.nanoTime();
}

class AppModule extends AbstractModule {
    @Override protected void configure() {
        // No scope declared — unscoped is the default.
        bind(Stamp.class);
    }
}

// ...
Injector injector = Guice.createInjector(new AppModule());
Stamp a = injector.getInstance(Stamp.class);
Stamp b = injector.getInstance(Stamp.class);
System.out.println(a == b); // false — two distinct objects

This is the right default. A new instance per injection is stateless-friendly and impossible to misuse: no shared mutable state, no surprises about who else holds a reference. The cost is allocation, and for cheap, throwaway objects that cost is nothing. You only reach for a scope when the default’s cost actually bites — which, for most of your graph, it eventually does.

@Singleton: one instance per injector

The scope you’ll use ninety-nine percent of the time is the singleton: exactly one instance, created once and reused for every injection — per injector. That last clause matters and people forget it. Guice’s “singleton” is not a JVM-wide singleton; it’s scoped to the Injector that created it. Two injectors, two instances. (For most apps you build one injector at startup, so the distinction is invisible — until you spin up a second injector in a test and wonder why your “singleton” reset.)

There are two ways to declare it, and they are genuinely equivalent for the common case. The first is to annotate the class with jakarta.inject.Singleton:

import jakarta.inject.Inject
import jakarta.inject.Singleton

@Singleton
class ConnectionPool @Inject constructor() {
    // Expensive to build, safe to share, holds shared state.
}
import jakarta.inject.Inject;
import jakarta.inject.Singleton;

@Singleton
class ConnectionPool {
    @Inject ConnectionPool() {
        // Expensive to build, safe to share, holds shared state.
    }
}

The annotation travels with the class. Anyone who binds ConnectionPool, in any module, gets singleton behavior for free — the lifetime is a property of the type, declared once. Guice honors both jakarta.inject.Singleton and its own com.google.inject.Singleton; they’re treated identically, so reach for the Jakarta one and never think about it again.

The second way is to declare the scope in the binding instead of on the class, using .in(...):

import com.google.inject.AbstractModule
import jakarta.inject.Singleton

class AppModule : AbstractModule() {
    override fun configure() {
        bind(PaymentGateway::class.java)
            .to(StripeGateway::class.java)
            .`in`(Singleton::class.java)
    }
}
import com.google.inject.AbstractModule;
import jakarta.inject.Singleton;

class AppModule extends AbstractModule {
    @Override protected void configure() {
        bind(PaymentGateway.class)
            .to(StripeGateway.class)
            .in(Singleton.class);
    }
}

(Note the backticks around Kotlin’s `in`in is a Kotlin keyword, so you have to escape it to call the method.)

Which should you use? Annotate the class when the type is inherently a singleton — a connection pool, a metrics registry, a cache. Declare it in the binding when the singleton-ness is a deployment decision about this particular wiring, or when you can’t annotate the class because you don’t own it (a third-party type bound to Singleton.class in your module is the canonical case). If both are present, they agree, and nothing breaks. The annotation is the more common, more readable choice — keep the lifetime next to the type that has it.

There’s also Scopes.SINGLETON, a Scope instance you can pass to .in(Scopes.SINGLETON). It’s identical in effect to .in(Singleton.class); the annotation-class form just reads better. Its sibling Scopes.NO_SCOPE exists for one narrow reason: to override a @Singleton annotation back to unscoped in a specific binding. You will rarely need it, but it’s there.

Eager vs. lazy, and what Stage decides

By default, singletons are lazy: Guice doesn’t build the instance until something first asks for it. That keeps startup fast and means an unused singleton costs nothing. Usually fine. Sometimes wrong — if a singleton’s construction is where you validate config or warm a cache, lazy means that work (and any failure) is deferred until the first request, which is exactly when you don’t want surprises.

asEagerSingleton() forces construction at injector-creation time instead:

import com.google.inject.AbstractModule

class AppModule : AbstractModule() {
    override fun configure() {
        // Built when the injector is created, not on first use.
        bind(SchemaMigrator::class.java).asEagerSingleton()
    }
}
import com.google.inject.AbstractModule;

class AppModule extends AbstractModule {
    @Override protected void configure() {
        // Built when the injector is created, not on first use.
        bind(SchemaMigrator.class).asEagerSingleton();
    }
}

asEagerSingleton() is eager in every Stage. But there’s a second, sneakier knob: the Stage you pass to Guice.createInjector. The default is Stage.DEVELOPMENT, where ordinary singletons stay lazy. In Stage.PRODUCTION, Guice flips the policy and instantiates all singletons eagerly at startup — both annotated (@Singleton) and instance-bound ones:

import com.google.inject.Guice
import com.google.inject.Stage

// In PRODUCTION, every singleton is built up front.
val injector = Guice.createInjector(Stage.PRODUCTION, AppModule())
import com.google.inject.Guice;
import com.google.inject.Stage;

// In PRODUCTION, every singleton is built up front.
Injector injector = Guice.createInjector(Stage.PRODUCTION, new AppModule());

The payoff is honest startup: if any singleton’s constructor is going to blow up, it blows up at boot — loudly, with your whole graph being assembled — instead of an hour later on the first unlucky request. That’s the trade PRODUCTION makes: slower, more thorough startup in exchange for catching errors early. Run your services in PRODUCTION stage. (There’s also a third stage, Stage.TOOL, for IDE plugins that need binding metadata without a working injector — you’ll likely never touch it.)

Scoping a @Provides method

Part 4 introduced @Provides methods for when construction needs logic. Those bindings are unscoped by default too — the method runs every time the type is injected. To make a @Provides result a singleton, just stack @Singleton on the method:

import com.google.inject.AbstractModule
import com.google.inject.Provides
import jakarta.inject.Singleton

class AppModule : AbstractModule() {
    override fun configure() {}

    @Provides
    @Singleton
    fun objectMapper(): ObjectMapper =
        ObjectMapper().registerModule(KotlinModule.Builder().build())
}
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import jakarta.inject.Singleton;

class AppModule extends AbstractModule {
    @Override protected void configure() {}

    @Provides
    @Singleton
    ObjectMapper objectMapper() {
        return new ObjectMapper().registerModule(new JavaTimeModule());
    }
}

Now the method body runs once and the result is cached. There’s no .in(...) to chain on a @Provides method — you don’t have a binding builder there — so the annotation is the only way to scope it. Forgetting @Singleton on a @Provides that builds something expensive (an ObjectMapper, an HTTP client) is one of the most common quiet performance bugs in a Guice codebase: it works, it’s just rebuilding a heavyweight object on every injection.

Going deeper: don’t depend on a narrower scope from a singleton

Here’s the one scoping rule that will actually bite you, and it’s worth internalizing now even though the scopes it references don’t arrive until Part II.

Scopes form a hierarchy by lifetime. A singleton lives for the life of the injector. A (hypothetical) request-scoped object lives for one HTTP request. A singleton is built once and keeps whatever it was given at construction forever. So if a singleton injects a request-scoped dependency directly, it captures the first request’s instance and hands that stale object to every subsequent request — a bug that’s invisible under single-threaded testing and catastrophic under concurrent load. This is scope-widening injection: a long-lived object pinning a short-lived one.

The rule: a wider scope must never directly inject a narrower scope. A singleton may depend on other singletons and unscoped objects freely. When a singleton genuinely needs something request-scoped, it injects a Provider<T> of it (remember Provider from Part 4) and calls .get() inside the request, so it fetches the right instance each time instead of capturing one at construction. We’ll build real request scopes in Part II’s servlet post; for now, just bank the principle — Guice doesn’t stop you from widening a scope, so the discipline is yours to keep.

Gotchas

  • “Singleton” means per-injector, not per-JVM. Two injectors produce two instances. If a test creates a fresh injector and your “singleton” state resets, this is why — it’s working as designed.
  • Unscoped is the default, and silence is a choice. A @Provides method or plain binding with no scope runs/constructs every injection. For an expensive object that’s a performance bug hiding in plain sight. Annotate it @Singleton the moment construction stops being trivial.
  • asEagerSingleton() is always eager; plain @Singleton is eager only in PRODUCTION. If you want guaranteed-at-startup construction regardless of stage, use asEagerSingleton() explicitly rather than relying on the stage flag.
  • Don’t widen scopes by direct injection. A singleton holding a reference to a request- or session-scoped object captures the first one it sees, forever. Inject a Provider<T> instead and resolve inside the narrow scope.
  • @Singleton on a class with mutable state must be thread-safe. The instant you share one instance across the graph, every field is touched concurrently. Singleton scope is a lifetime decision; thread safety is still your problem.
  • You can scope a binding twice and they’d better agree. A @Singleton-annotated class bound with .in(Singleton.class) is fine (redundant). But annotating a class @Singleton and binding it .in(Scopes.NO_SCOPE) is a deliberate override, not a contradiction — know which you’re doing.

What’s next

Scope is the last of the per-binding decisions: you now know which implementation (linked bindings), how it’s built (@Provides, Provider), which one when there are several (qualifiers), and how long it lives (this post). That’s a complete vocabulary for a single binding. The next problem is organizational — once you have dozens of bindings across a real app, where do they live and how do they fit together without one giant module?

Next: Part 7 — Composing Modules, where we split, install, and organize modules so the graph scales past a single file.


Target keyword(s): guice scopes, guice @Singleton.

Comments