Two of the Same Thing: Telling Identical Types Apart in Guice


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

Everything so far has rested on a quiet assumption: a type maps to exactly one binding. Ask for a GreetingRepository and Guice knows which one you mean, because there’s only one. But real systems break that assumption constantly: a read replica and a primary DataSource, an internal HttpClient and a third-party one, a config value for the API host and one for the admin host. Same type, different intent. Guice keys every binding by type alone, so two bindings of DataSource collide: the injector can’t read your mind about which you want where.

The fix is to give Guice a second piece of the key. Alongside the type, you attach an annotation, and the pair (type, annotation) becomes the real binding key. That annotation is a qualifier (the spec’s term) or a binding annotation (Guice’s older term for the same idea). This post is about the two flavors of them, and a stance about which to reach for.

The quick fix: @Named

Guice ships a ready-made qualifier for the lazy case: @Named, which carries a string. You bind .annotatedWith(Names.named("primary")) and inject @Named("primary"). The string is the discriminator.

Note the import: use Guice’s own com.google.inject.name.Named together with com.google.inject.name.Names, since the binding side needs Names.named(...) to build a matching annotation instance. (There’s a jakarta.inject.Named too; Guice understands both, but pick one namespace and don’t mix them within a binding.)

import com.google.inject.AbstractModule
import com.google.inject.name.Named
import com.google.inject.name.Names
import javax.sql.DataSource
import jakarta.inject.Inject

class AppModule : AbstractModule() {
    override fun configure() {
        bind(DataSource::class.java)
            .annotatedWith(Names.named("primary"))
            .toInstance(primaryDataSource())
        bind(DataSource::class.java)
            .annotatedWith(Names.named("replica"))
            .toInstance(replicaDataSource())
    }
}

class ReportRepository @Inject constructor(
    @Named("replica") private val readOnly: DataSource,
) {
    // Reads go to the replica.
}
import com.google.inject.AbstractModule;
import com.google.inject.name.Named;
import com.google.inject.name.Names;
import javax.sql.DataSource;
import jakarta.inject.Inject;

class AppModule extends AbstractModule {
    @Override protected void configure() {
        bind(DataSource.class)
            .annotatedWith(Names.named("primary"))
            .toInstance(primaryDataSource());
        bind(DataSource.class)
            .annotatedWith(Names.named("replica"))
            .toInstance(replicaDataSource());
    }
}

class ReportRepository {
    private final DataSource readOnly;
    @Inject ReportRepository(@Named("replica") DataSource readOnly) {
        this.readOnly = readOnly; // Reads go to the replica.
    }
}

@Named("replica") DataSource is now a distinct key from @Named("primary") DataSource and from a plain unannotated DataSource. All three can coexist in the same injector. The collision is gone.

This works, and for a throwaway or a binding nobody else touches, it’s fine. But notice what you’ve signed up for: a magic string repeated at every injection site, validated by nobody, with the compiler cheerfully waving through @Named("replcia"). Hold that thought.

The grown-up fix: a custom qualifier

Instead of a string, define your own annotation and let the type system be the discriminator. You declare an annotation, mark it as a qualifier, and use the annotation itself — @Primary, @Replica — both where you bind and where you inject.

To make Guice treat an annotation as a qualifier, mark it one of two ways:

  • @jakarta.inject.Qualifier — the standard, portable choice. (Verified: Guice 7 recognizes it natively.)
  • @com.google.inject.BindingAnnotation — Guice’s own marker, identical in effect but Guice-specific.

Either way you must also give the annotation @Retention(RUNTIME) (the qualifier has to survive into bytecode, because Guice reads it reflectively at injector-creation time) and a @Target listing where it may appear. Guice’s own @Named uses {FIELD, PARAMETER, METHOD}, and that’s the set to copy: parameter (constructor injection), field (field injection), method (@Provides methods and setter injection). I’ll use @jakarta.inject.Qualifier here.

Declaring an annotation is one of the few places the two languages genuinely diverge, so read both tabs. In Java it’s an @interface. In Kotlin it’s an annotation class, and the meta-annotations come from kotlin.annotation.*@Retention(AnnotationRetention.RUNTIME) and @Target(...) with AnnotationTarget constants, not Java’s ElementType.

import jakarta.inject.Qualifier

@Qualifier
@Retention(AnnotationRetention.RUNTIME)
@Target(
    AnnotationTarget.VALUE_PARAMETER, // constructor/method parameters
    AnnotationTarget.FIELD,
    AnnotationTarget.FUNCTION,         // @Provides methods
)
annotation class Replica

@Qualifier
@Retention(AnnotationRetention.RUNTIME)
@Target(
    AnnotationTarget.VALUE_PARAMETER,
    AnnotationTarget.FIELD,
    AnnotationTarget.FUNCTION,
)
annotation class Primary
import jakarta.inject.Qualifier;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Qualifier
@Retention(RUNTIME)
@Target({FIELD, PARAMETER, METHOD})
@interface Replica {}

@Qualifier
@Retention(RUNTIME)
@Target({FIELD, PARAMETER, METHOD})
@interface Primary {}

Now bind and inject with the annotation itself. On the binding side, annotatedWith has an overload that takes the annotation typeClass<? extends Annotation> — which is exactly right for marker annotations with no members:

class AppModule : AbstractModule() {
    override fun configure() {
        bind(DataSource::class.java)
            .annotatedWith(Primary::class.java)
            .toInstance(primaryDataSource())
        bind(DataSource::class.java)
            .annotatedWith(Replica::class.java)
            .toInstance(replicaDataSource())
    }
}

class ReportRepository @Inject constructor(
    @Replica private val readOnly: DataSource,
    @Primary private val writeThrough: DataSource,
)
class AppModule extends AbstractModule {
    @Override protected void configure() {
        bind(DataSource.class)
            .annotatedWith(Primary.class)
            .toInstance(primaryDataSource());
        bind(DataSource.class)
            .annotatedWith(Replica.class)
            .toInstance(replicaDataSource());
    }
}

class ReportRepository {
    private final DataSource readOnly;
    private final DataSource writeThrough;
    @Inject ReportRepository(@Replica DataSource readOnly,
                             @Primary DataSource writeThrough) {
        this.readOnly = readOnly;
        this.writeThrough = writeThrough;
    }
}

@Replica DataSource and @Primary DataSource are two distinct keys, just like the @Named version, except now the discriminator is a name your IDE autocompletes, your compiler checks, and “find usages” can trace. Misspell @Replcia and the build fails on the spot, not at injector startup six modules away.

Qualifiers on @Provides

Part 4 introduced @Provides methods for construction logic. Qualifiers compose with them cleanly: annotate the method, and its return value is bound under (type, annotation). This is the natural home for “two of a kind that each need a little assembly.”

import com.google.inject.Provides

class DataSourceModule : AbstractModule() {
    @Provides @Primary
    fun primaryDataSource(config: DbConfig): DataSource =
        HikariDataSource(config.primaryUrl)

    @Provides @Replica
    fun replicaDataSource(config: DbConfig): DataSource =
        HikariDataSource(config.replicaUrl)
}
import com.google.inject.Provides;

class DataSourceModule extends AbstractModule {
    @Provides @Primary
    DataSource primaryDataSource(DbConfig config) {
        return new HikariDataSource(config.primaryUrl());
    }

    @Provides @Replica
    DataSource replicaDataSource(DbConfig config) {
        return new HikariDataSource(config.replicaUrl());
    }
}

Two @Provides methods returning the same type no longer collide, because the qualifier is part of each key. Anywhere downstream, @Primary DataSource and @Replica DataSource resolve to the matching method. Note the Kotlin tab needs AnnotationTarget.FUNCTION in the qualifier’s @Target for this to compile — that’s the entry you’d forget and then spend ten minutes confused about.

Going deeper: why typed beats @Named

Both approaches produce the same runtime behavior. The difference is entirely in what the tools can do for you, and it’s lopsided enough to be a rule.

A custom qualifier is a real type. The compiler knows it exists, so a typo is a compile error rather than a silent miss. Your IDE autocompletes it, renames it across the codebase in one shot, and answers “where is @Replica injected?” with a real reference list. The annotation’s name documents intent — @Replica says what it is — and you can confine its @Target so it can’t be slapped on the wrong kind of element. A @Named string gets none of that: it’s an opaque literal the compiler treats as decoration, duplicated at every site, where "replica" and "replicas" are different bindings and nothing warns you. The classic failure is binding @Named("primary") and injecting @Named("Primary") — capital P — then staring at a CreationException about a missing binding while the typo sits in plain sight.

So the stance, and it’s not a close call: prefer typed custom qualifiers; treat @Named as a shortcut for the truly trivial. @Named earns its keep in exactly two spots — quick prototypes, and binding lots of primitive config values where minting an annotation per string is heavier than the problem (and where Names.bindProperties can wire a whole Properties map of @Named constants at once). Everywhere a qualifier expresses a real architectural distinction — primary vs. replica, internal vs. external — spend the dozen lines on a named type. You write the annotation once; you read it forever.

Gotchas

  • No @Retention(RUNTIME), no qualifier. Annotations default to CLASS retention, which is invisible at runtime. Forget the RUNTIME retention and Guice never sees the annotation — your @Replica DataSource silently resolves to the unqualified binding (or fails as a missing one). The annotation that does nothing is the worst kind, because there’s no error to read.

  • The annotation isn’t a binding. @Replica declares a key; it doesn’t create the DataSource. You still need a bind(...).annotatedWith(Replica.class) or a @Provides @Replica somewhere. Define the qualifier, inject it, and forget to bind it, and you get a CreationException for the missing key at injector creation — not a runtime null, at least.

  • Qualified and unqualified are different keys. bind(DataSource.class).annotatedWith(Primary.class) does not satisfy a plain DataSource injection point. The moment you add a qualifier, every consumer that wants that instance must name it; an unannotated DataSource parameter looks for a separate, unqualified binding. This trips people migrating an existing single binding into a qualified pair — the old call sites suddenly have nothing to inject.

  • One binding annotation per injection point. Guice rejects two binding annotations on the same parameter — @Primary @Replica DataSource is an error, not an intersection. The rule lives in BindingAnnotation’s own contract: only one may apply to a single injection point.

  • Annotation type vs. annotation instance. @Named carries a value, so it’s bound with an instanceNames.named("replica"), the annotatedWith(Annotation) overload. A marker like @Replica has no members, so it’s bound with its typeReplica.class / Replica::class.java, the annotatedWith(Class) overload. Reach for the instance overload only when your qualifier has members.

  • Kotlin field injection and @field:. This series sticks to constructor injection, so it rarely bites here — but if you ever put a qualifier on a Kotlin property being field-injected, you may need the @field:Replica use-site target so the annotation lands on the backing field rather than the property or constructor parameter. Another reason constructor injection, where the parameter target is unambiguous, stays the default.

What’s next

Qualifiers solved which of several same-typed bindings you get. The next question is how many times Guice builds each one, and how long it keeps it around. By default every injection gets a fresh instance — fine for cheap objects, wasteful for a connection pool you meant to share exactly once.

Next: Part 6 — Scopes, where we meet @Singleton, eager vs. lazy construction, and why scope is a property of the binding, not the class.


Target keyword(s): guice qualifiers, guice @Named.

Comments