Custom Scopes: Teaching Guice a New Lifetime


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

Part I’s scopes post gave you the two lifetimes everyone uses: unscoped (a fresh instance every injection) and @Singleton (one per injector). Those cover the overwhelming majority of bindings, and you should reach for them by reflex. But “the life of the injector” and “no life at all” are the only two lifetimes Guice ships with out of the box — and sometimes neither is the one you want.

Picture a job-processing worker that pulls tasks off a queue. Each task should share one database transaction, one tracing span, one tenant context — for the duration of that task, and not a microsecond longer. That’s a lifetime: born when the task starts, dead when it finishes, with everything in between sharing a single instance. Guice has no @TaskScoped annotation, because Guice doesn’t know your task exists. So you teach it one. That’s a custom scope, and writing one is far less exotic than it sounds — it’s a single interface with a single method.

The Scope interface

Here’s the entire contract, straight from com.google.inject.Scope:

package com.google.inject

interface Scope {
    fun <T> scope(key: Key<T>, unscoped: Provider<T>): Provider<T>
}
package com.google.inject;

public interface Scope {
    <T> Provider<T> scope(Key<T> key, Provider<T> unscoped);
}

That’s it. (There’s also a toString() the docs ask you to make useful, but no behavior hangs on it.) Read the signature carefully, because it tells you exactly what a scope is: a function that takes a binding Key and the unscoped provider (the one that would build a brand-new instance every time) and returns a new provider that wraps it with caching policy.

The returned provider is where all the cleverness lives. When something asks the scoped provider for an instance, it decides: do I already have one for this key in the current “scope context”? If so, hand it back. If not, call unscoped.get() to build one, stash it, and return that. The Scope doesn’t create objects — unscoped does. The Scope only decides whether to reuse or build, and where the cache lives. @Singleton caches in a field that lives as long as the injector. Your custom scope caches somewhere that lives as long as your unit of work. Same machinery, different cache lifetime: that is the whole idea.

A ThreadLocal-backed scope

The canonical custom scope, the one in Guice’s own wiki, is SimpleScope: a scope whose “context” is a Map<Key, Object> parked in a ThreadLocal. You call enter() to open the scope on the current thread, do your work (during which scoped bindings resolve into that map), and exit() to tear it down. It models any thread-bound unit of work: a request, a task, a transaction.

import com.google.inject.Key
import com.google.inject.OutOfScopeException
import com.google.inject.Provider
import com.google.inject.Scope

/** A scope whose lifetime is one `enter()`/`exit()` block on the current thread. */
class SimpleScope : Scope {

    // One independent cache per thread.
    private val values = ThreadLocal<MutableMap<Key<*>, Any?>>()

    fun enter() {
        check(values.get() == null) { "A scope is already in progress on this thread." }
        values.set(HashMap())
    }

    fun exit() {
        checkNotNull(values.get()) { "No scope in progress on this thread." }
        values.remove()
    }

    /** Seed a value that scoped bindings can then inject (see below). */
    fun <T> seed(key: Key<T>, value: T) {
        val map = currentScopeMap()
        check(!map.containsKey(key)) { "$key already seeded in this scope." }
        map[key] = value
    }

    override fun <T> scope(key: Key<T>, unscoped: Provider<T>): Provider<T> =
        Provider {
            val map = currentScopeMap()
            @Suppress("UNCHECKED_CAST")
            var current = map[key] as T?
            if (current == null && !map.containsKey(key)) {
                current = unscoped.get()
                map[key] = current
            }
            current as T
        }

    private fun currentScopeMap(): MutableMap<Key<*>, Any?> =
        values.get() ?: throw OutOfScopeException("Cannot access scoped object outside a scope.")
}
import com.google.inject.Key;
import com.google.inject.OutOfScopeException;
import com.google.inject.Provider;
import com.google.inject.Scope;
import java.util.HashMap;
import java.util.Map;

/** A scope whose lifetime is one enter()/exit() block on the current thread. */
public class SimpleScope implements Scope {

    // One independent cache per thread.
    private final ThreadLocal<Map<Key<?>, Object>> values = new ThreadLocal<>();

    public void enter() {
        if (values.get() != null) {
            throw new IllegalStateException("A scope is already in progress on this thread.");
        }
        values.set(new HashMap<>());
    }

    public void exit() {
        if (values.get() == null) {
            throw new IllegalStateException("No scope in progress on this thread.");
        }
        values.remove();
    }

    /** Seed a value that scoped bindings can then inject (see below). */
    public <T> void seed(Key<T> key, T value) {
        Map<Key<?>, Object> map = currentScopeMap();
        if (map.containsKey(key)) {
            throw new IllegalStateException(key + " already seeded in this scope.");
        }
        map.put(key, value);
    }

    @Override
    public <T> Provider<T> scope(Key<T> key, Provider<T> unscoped) {
        return () -> {
            Map<Key<?>, Object> map = currentScopeMap();
            @SuppressWarnings("unchecked")
            T current = (T) map.get(key);
            if (current == null && !map.containsKey(key)) {
                current = unscoped.get();
                map.put(key, current);
            }
            return current;
        };
    }

    private Map<Key<?>, Object> currentScopeMap() {
        Map<Key<?>, Object> map = values.get();
        if (map == null) {
            throw new OutOfScopeException("Cannot access scoped object outside a scope.");
        }
        return map;
    }
}

Walk the scope method, because it’s the heart of everything. It returns a provider (a lambda — Provider<T> is a functional interface) that, every time it’s called, looks up the thread’s current map. Found a cached instance for this key? Return it. Otherwise build one via unscoped, cache it, return it. The !map.containsKey(key) check is there so a legitimately-null provided value isn’t mistaken for “not built yet”: the same null-sentinel concern Guice’s own ServletScopes handles with a NullObject enum. If there’s no map at all, we’re being asked for a scoped object outside any enter()/exit() block, which is a programming error, so we throw OutOfScopeException — Guice’s own exception type for exactly this.

This is not a toy. Strip the HTTP plumbing out of Guice’s real ServletScopes and you find the same skeleton: a ThreadLocal holding a per-request Map<Key<?>, Object>, an open()/close() pair instead of enter()/exit(), and a provider that reads-or-builds against that map. We’re studying the production pattern, just without a servlet container bolted on.

The annotation and bindScope

A Scope instance is the behavior. To attach that behavior to bindings the readable way, you want an annotation — your own @TaskScoped, sitting next to @Singleton in the developer’s vocabulary. Declaring a scope annotation has three non-negotiable requirements: it must be annotated @ScopeAnnotation (Guice’s com.google.inject.ScopeAnnotation, or equivalently jakarta’s @Scope), it must have @Retention(RUNTIME) so Guice can read it reflectively, and it should target types and methods.

import com.google.inject.ScopeAnnotation

@ScopeAnnotation
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class TaskScoped
import com.google.inject.ScopeAnnotation;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@ScopeAnnotation
@Retention(RUNTIME)
@Target({ TYPE, METHOD })
public @interface TaskScoped {}

Now wire the annotation to the instance with bindScope — a Binder method, available straight off AbstractModule. Its signature is bindScope(Class<? extends Annotation>, Scope): you hand it the annotation type and the live scope object. Because the scope object is created in the module, you’ll usually want a reference to it for enter()/exit() later, so bind it as well:

import com.google.inject.AbstractModule

class TaskModule : AbstractModule() {
    override fun configure() {
        val taskScope = SimpleScope()

        // Tell Guice: @TaskScoped bindings use this scope object.
        bindScope(TaskScoped::class.java, taskScope)

        // Expose the scope so the worker loop can enter()/exit() it.
        bind(SimpleScope::class.java).toInstance(taskScope)
    }
}

// A binding that lives for one task:
@TaskScoped
class TransactionContext

// Driving the scope around a unit of work:
fun runTask(scope: SimpleScope, injector: com.google.inject.Injector, task: Task) {
    scope.enter()
    try {
        val ctx = injector.getInstance(TransactionContext::class.java)
        // ...everything resolved here shares the same ctx...
    } finally {
        scope.exit()
    }
}
import com.google.inject.AbstractModule;
import com.google.inject.Injector;

public class TaskModule extends AbstractModule {
    @Override protected void configure() {
        SimpleScope taskScope = new SimpleScope();

        // Tell Guice: @TaskScoped bindings use this scope object.
        bindScope(TaskScoped.class, taskScope);

        // Expose the scope so the worker loop can enter()/exit() it.
        bind(SimpleScope.class).toInstance(taskScope);
    }
}

// A binding that lives for one task:
@TaskScoped
class TransactionContext {}

// Driving the scope around a unit of work:
void runTask(SimpleScope scope, Injector injector, Task task) {
    scope.enter();
    try {
        TransactionContext ctx = injector.getInstance(TransactionContext.class);
        // ...everything resolved here shares the same ctx...
    } finally {
        scope.exit();
    }
}

Annotate a class @TaskScoped (or chain .in(TaskScoped::class.java) on a binding — same as @Singleton had two forms), and Guice routes every injection of that type through taskScope.scope(...). Inside an enter()/exit() block, every request for TransactionContext returns the same instance; the block exits and that instance is gone. The try/finally is not optional — if work throws, you still must exit(), or the next task on this pooled thread inherits a stale, half-populated map.

Going deeper

Seeding. Often the scope’s defining value isn’t built by Guice; it’s the thing that started the unit of work: the dequeued Task, the inbound request, the tenant id. You don’t want a binding that constructs it; you want to inject the one you already have. That’s seeding: stuff a value into the scope map before the work runs, and bind it to a provider that refuses to run outside the scope. Bind it like this, then call seed(...) right after enter():

import com.google.inject.Key
import com.google.inject.Provider

// In the module: a @TaskScoped binding with no real provider — it MUST be seeded.
bind(Task::class.java)
    .toProvider(Provider<Task> { throw IllegalStateException("Task must be seeded per task.") })
    .`in`(TaskScoped::class.java)

// In the worker loop:
scope.enter()
try {
    scope.seed(Key.get(Task::class.java), dequeuedTask)
    // Anything @TaskScoped that injects Task now gets `dequeuedTask`.
} finally {
    scope.exit()
}
import com.google.inject.Key;

// In the module: a @TaskScoped binding with no real provider — it MUST be seeded.
bind(Task.class)
    .toProvider(() -> { throw new IllegalStateException("Task must be seeded per task."); })
    .in(TaskScoped.class);

// In the worker loop:
scope.enter();
try {
    scope.seed(Key.get(Task.class), dequeuedTask);
    // Anything @TaskScoped that injects Task now gets `dequeuedTask`.
} finally {
    scope.exit();
}

The throwing provider is a feature, not a hack: it guarantees that a Task injected outside a seeded scope fails loudly instead of silently building a wrong one. Guice’s own servlet and request scopes seed exactly this way — scopeRequest(callable, seedMap) takes a Map<Key<?>, Object> of seed values for precisely this reason.

Thread-safety. A ThreadLocal-backed scope is thread-confined: each thread gets its own map, so the map itself never needs locking — which is why SimpleScope’s map is a plain HashMap. The flip side is the hard rule: the scope only exists on the thread that called enter(). Hand work off to another thread mid-scope and that thread sees no scope and throws OutOfScopeException. Guice’s ServletScopes solves this with transferRequest/continueRequest, which snapshot the scope and re-open it on the worker thread under a lock. If your tasks fan out across threads, you need that machinery; for a single-threaded-per-task worker, plain ThreadLocal is correct and cheap.

When NOT to. Most of the time, don’t. If you need request or session lifetimes for a servlet app, the servlet extension (next post) already ships them — battle-tested, with thread-transfer and null-handling done right. If you only need “one per injector,” that’s @Singleton. A custom scope earns its keep when you have a genuinely bespoke unit of work Guice can’t know about (a queue task, an RPC, a batch job, a game tick) and you want its participants to share state implicitly through injection rather than threading a context object through every method signature. That last clause is the real payoff: a custom scope is how you make “this all belongs to one task” a property of the graph instead of a parameter you pass everywhere.

Gotchas

  • scope() must not build anything itself. It returns a provider; the actual unscoped.get() call happens lazily, on first request inside the scope. If you call unscoped.get() eagerly inside scope(), you’ve defeated the laziness and built objects before any scope is even open.
  • Forgetting exit() poisons pooled threads. ThreadLocal state outlives the method on a reused thread. Always pair enter() with a finally { exit() }, or the next task inherits the previous task’s cache — a brutal, intermittent bug.
  • Scoped does not mean thread-safe. A @TaskScoped object shared within one task is fine if that task runs on one thread. The moment you parallelize inside a task, the shared instance is touched concurrently and its thread-safety is your problem — scope is a lifetime decision, not a concurrency one. (Part I made the same point about singletons; it’s the same point.)
  • Don’t widen scopes by direct injection. A @Singleton that directly injects a @TaskScoped object captures the first task’s instance forever — the scope-widening bug from Part I. From a wider scope, inject Provider<TaskScoped> and call .get() inside the task.
  • The annotation needs @ScopeAnnotation and @Retention(RUNTIME). Miss either and Guice won’t recognize it as a scope — you’ll get a confusing error about the annotation not being a scoping annotation, or it’ll be silently ignored. Both are mandatory.
  • One scope annotation per class. Guice rejects a binding carrying two scope annotations (say @Singleton and @TaskScoped) — the lifetimes contradict each other and there’s no sensible merge. Pick one.

What’s next

A custom scope is the rawest form of lifetime control Guice gives you: implement one method, decide where the cache lives, and you’ve invented a lifetime the framework never shipped. The pattern is always the same — a context that you enter() and exit(), a ThreadLocal (or equivalent) holding the cache, and a provider that reads-or-builds against it. Now that you’ve seen the machinery from the inside, the obvious next question is: hasn’t someone already done this for the most common case, the HTTP request? They have.

Next: Part 7 — The Servlet Extension, where Guice’s own request and session scopes — built on exactly the Scope interface you just implemented — come ready-made, along with the filter and binding DSL that drive them.


Target keyword(s): guice custom scope, guice Scope.

Comments