Multibindings: How to Build a Plugin System Without a Registry


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

Welcome to Guice II. Fundamentals were about answering one question per type: how is a GreetingRepository satisfied? One interface, one binding, one winner. But real systems have a different shape: a question with many right answers. You have a Set of health checks, a Map of payment processors keyed by name, a pile of event handlers that fire on startup. The defining feature is that no single module knows the whole list: the auth module contributes its health check, the billing module contributes its own, and something downstream wants them all, wired together, without either module knowing the other exists.

The naive fix is a registry: a static List somewhere that every module mutates in a configure() block, racing each other and praying about ordering. Guice has a first-class answer instead. It’s called multibindings, and it’s the cleanest plugin/extension mechanism on the JVM.

The plugin problem

Say you’re building a notification system. Channels (email, SMS, Slack) should be pluggable: adding one is a matter of dropping in a module, not editing a central switch. You want to inject Set<NotificationChannel> and have it contain every channel any installed module contributed. With ordinary bindings you can’t, because the second bind(NotificationChannel::class.java).to(...) just clobbers the first. Guice rejects duplicate bindings for the same key — that’s the legibility guarantee from Part 1 working against you here.

Multibinder is the escape hatch. It lets each module say “add one more to the collection” instead of “be the binding,” then aggregates the contributions into a single injectable Set.

Multibinder → Set<T>

You get a Multibinder for your type, then call addBinding() to contribute each implementation. Crucially, every module gets its own Multibinder for the same type, and Guice merges them. Calling newSetBinder twice for the same type isn’t a conflict; it’s the whole point.

import com.google.inject.AbstractModule
import com.google.inject.multibindings.Multibinder
import jakarta.inject.Inject

interface NotificationChannel { fun send(msg: String) }

class EmailChannel @Inject constructor() : NotificationChannel {
    override fun send(msg: String) { /* ... */ }
}

class SmsChannel @Inject constructor() : NotificationChannel {
    override fun send(msg: String) { /* ... */ }
}

class EmailModule : AbstractModule() {
    override fun configure() {
        val channels = Multibinder.newSetBinder(binder(), NotificationChannel::class.java)
        channels.addBinding().to(EmailChannel::class.java)
    }
}

class SmsModule : AbstractModule() {
    override fun configure() {
        val channels = Multibinder.newSetBinder(binder(), NotificationChannel::class.java)
        channels.addBinding().to(SmsChannel::class.java)
    }
}

// Anything wanting the full list just asks for the Set:
class Notifier @Inject constructor(private val channels: Set<NotificationChannel>) {
    fun broadcast(msg: String) = channels.forEach { it.send(msg) }
}
import com.google.inject.AbstractModule;
import com.google.inject.multibindings.Multibinder;
import jakarta.inject.Inject;
import java.util.Set;

interface NotificationChannel { void send(String msg); }

class EmailChannel implements NotificationChannel {
    @Inject EmailChannel() {}
    public void send(String msg) { /* ... */ }
}

class SmsChannel implements NotificationChannel {
    @Inject SmsChannel() {}
    public void send(String msg) { /* ... */ }
}

class EmailModule extends AbstractModule {
    @Override protected void configure() {
        Multibinder<NotificationChannel> channels =
            Multibinder.newSetBinder(binder(), NotificationChannel.class);
        channels.addBinding().to(EmailChannel.class);
    }
}

class SmsModule extends AbstractModule {
    @Override protected void configure() {
        Multibinder<NotificationChannel> channels =
            Multibinder.newSetBinder(binder(), NotificationChannel.class);
        channels.addBinding().to(SmsChannel.class);
    }
}

// Anything wanting the full list just asks for the Set:
class Notifier {
    private final Set<NotificationChannel> channels;
    @Inject Notifier(Set<NotificationChannel> channels) { this.channels = channels; }
    void broadcast(String msg) { channels.forEach(c -> c.send(msg)); }
}

Install both modules and Set<NotificationChannel> has two elements. Install neither and it’s an empty set, not a missing-binding error, which matters: code that depends on the set degrades to “no plugins” rather than failing to start. The addBinding() call returns the same LinkedBindingBuilder you already know, so you can .to(impl), .toInstance(...), .toProvider(...), or scope each contribution independently. The elements are still fully injected: EmailChannel could take its own constructor dependencies and Guice supplies them.

MapBinder → Map<K, V>

A Set is great when contributions are interchangeable. Often they aren’t, and you want to look one up by name. “Run the processor for "stripe".” That’s a MapBinder: same contribution model, but each addBinding takes a key.

import com.google.inject.AbstractModule
import com.google.inject.multibindings.MapBinder
import jakarta.inject.Inject

interface PaymentProcessor { fun charge(cents: Long) }

class StripeProcessor @Inject constructor() : PaymentProcessor {
    override fun charge(cents: Long) { /* ... */ }
}

class PaypalProcessor @Inject constructor() : PaymentProcessor {
    override fun charge(cents: Long) { /* ... */ }
}

class PaymentModule : AbstractModule() {
    override fun configure() {
        val processors = MapBinder.newMapBinder(
            binder(), String::class.java, PaymentProcessor::class.java)
        processors.addBinding("stripe").to(StripeProcessor::class.java)
        processors.addBinding("paypal").to(PaypalProcessor::class.java)
    }
}

class Checkout @Inject constructor(
    private val processors: Map<String, PaymentProcessor>
) {
    fun pay(gateway: String, cents: Long) {
        val processor = processors[gateway]
            ?: error("No payment processor for '$gateway'")
        processor.charge(cents)
    }
}
import com.google.inject.AbstractModule;
import com.google.inject.multibindings.MapBinder;
import jakarta.inject.Inject;
import java.util.Map;

interface PaymentProcessor { void charge(long cents); }

class StripeProcessor implements PaymentProcessor {
    @Inject StripeProcessor() {}
    public void charge(long cents) { /* ... */ }
}

class PaypalProcessor implements PaymentProcessor {
    @Inject PaypalProcessor() {}
    public void charge(long cents) { /* ... */ }
}

class PaymentModule extends AbstractModule {
    @Override protected void configure() {
        MapBinder<String, PaymentProcessor> processors =
            MapBinder.newMapBinder(binder(), String.class, PaymentProcessor.class);
        processors.addBinding("stripe").to(StripeProcessor.class);
        processors.addBinding("paypal").to(PaypalProcessor.class);
    }
}

class Checkout {
    private final Map<String, PaymentProcessor> processors;
    @Inject Checkout(Map<String, PaymentProcessor> processors) {
        this.processors = processors;
    }
    void pay(String gateway, long cents) {
        PaymentProcessor processor = processors.get(gateway);
        if (processor == null) throw new IllegalArgumentException("No processor for " + gateway);
        processor.charge(cents);
    }
}

Inject Map<String, PaymentProcessor> and you get every keyed contribution across every module. There’s a bonus you get for free: MapBinder also makes Map<String, Provider<PaymentProcessor>> injectable, so if instantiating a processor is expensive, you can inject the provider-valued map and only build the one you actually route to.

OptionalBinder → Optional<T>

The third member of the family solves a subtler problem: a framework wants to define an injection point that users may or may not fill in, optionally with a default the user can override. This is the override pattern done honestly.

OptionalBinder.newOptionalBinder(binder(), Foo::class.java) makes Optional<Foo> injectable, absent by default. The framework calls .setDefault() to supply a fallback; the user calls .setBinding() to override it. (If you only ever want “present or absent,” skip setDefault entirely and let downstream user code do a plain bind(Foo::class.java).to(...) — the OptionalBinder picks it up.)

import com.google.inject.AbstractModule
import com.google.inject.multibindings.OptionalBinder
import jakarta.inject.Inject
import java.util.Optional

interface RateLimiter { fun allow(): Boolean }

class NoopRateLimiter @Inject constructor() : RateLimiter {
    override fun allow() = true
}

class RedisRateLimiter @Inject constructor() : RateLimiter {
    override fun allow(): Boolean = TODO()
}

// The framework declares the seam and a sane default:
class FrameworkModule : AbstractModule() {
    override fun configure() {
        OptionalBinder.newOptionalBinder(binder(), RateLimiter::class.java)
            .setDefault().to(NoopRateLimiter::class.java)
    }
}

// The application overrides it — only if it wants to:
class ProdModule : AbstractModule() {
    override fun configure() {
        OptionalBinder.newOptionalBinder(binder(), RateLimiter::class.java)
            .setBinding().to(RedisRateLimiter::class.java)
    }
}

class Gateway @Inject constructor(private val limiter: Optional<RateLimiter>) {
    fun handle() {
        if (limiter.map { it.allow() }.orElse(true)) { /* serve */ }
    }
}
import com.google.inject.AbstractModule;
import com.google.inject.multibindings.OptionalBinder;
import jakarta.inject.Inject;
import java.util.Optional;

interface RateLimiter { boolean allow(); }

class NoopRateLimiter implements RateLimiter {
    @Inject NoopRateLimiter() {}
    public boolean allow() { return true; }
}

class RedisRateLimiter implements RateLimiter {
    @Inject RedisRateLimiter() {}
    public boolean allow() { throw new UnsupportedOperationException(); }
}

// The framework declares the seam and a sane default:
class FrameworkModule extends AbstractModule {
    @Override protected void configure() {
        OptionalBinder.newOptionalBinder(binder(), RateLimiter.class)
            .setDefault().to(NoopRateLimiter.class);
    }
}

// The application overrides it — only if it wants to:
class ProdModule extends AbstractModule {
    @Override protected void configure() {
        OptionalBinder.newOptionalBinder(binder(), RateLimiter.class)
            .setBinding().to(RedisRateLimiter.class);
    }
}

class Gateway {
    private final Optional<RateLimiter> limiter;
    @Inject Gateway(Optional<RateLimiter> limiter) { this.limiter = limiter; }
    void handle() {
        if (limiter.map(RateLimiter::allow).orElse(true)) { /* serve */ }
    }
}

OptionalBinder always supplies both Optional<T> and Optional<Provider<T>>, and — for compatibility — both java.util.Optional and Guava’s com.google.common.base.Optional. One sharp edge worth internalizing now: even when setBinding overrides setDefault, the default binding still exists in the graph and, if it’s a singleton, gets instantiated in Stage.PRODUCTION. So don’t make a setDefault target do expensive work in its constructor expecting it to be dead code when overridden.

The method-style alternative: @ProvidesIntoSet / @ProvidesIntoMap

Just as @Provides methods are the ergonomic alternative to bind(...) when construction needs logic, there’s a method-style form of multibindings. Annotate a module method and its return value joins the collection. It’s the natural choice when a contribution needs to be built rather than merely linked to an @Inject constructor.

For maps, the key isn’t a method argument — it’s a separate annotation. Guice ships @StringMapKey and @ClassMapKey for the common cases (and @MapKey to define your own for enums or primitives).

import com.google.inject.AbstractModule
import com.google.inject.multibindings.ProvidesIntoSet
import com.google.inject.multibindings.ProvidesIntoMap
import com.google.inject.multibindings.StringMapKey

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

    @ProvidesIntoSet
    fun emailChannel(): NotificationChannel = EmailChannel()

    @ProvidesIntoMap
    @StringMapKey("stripe")
    fun stripe(config: AppConfig): PaymentProcessor =
        StripeProcessor(/* config.stripeKey */)
}
import com.google.inject.AbstractModule;
import com.google.inject.multibindings.ProvidesIntoSet;
import com.google.inject.multibindings.ProvidesIntoMap;
import com.google.inject.multibindings.StringMapKey;

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

    @ProvidesIntoSet
    NotificationChannel emailChannel() { return new EmailChannel(); }

    @ProvidesIntoMap
    @StringMapKey("stripe")
    PaymentProcessor stripe(AppConfig config) {
        return new StripeProcessor(/* config.getStripeKey() */);
    }
}

In Guice 7 the scanner that picks these up is installed by default — you don’t register anything. (In older versions you had to binder().scanModulesForAnnotatedMethods(MultibindingsScanner.scanner()); that method is now deprecated and a no-op.) There’s a matching @ProvidesIntoOptional(DEFAULT) / @ProvidesIntoOptional(ACTUAL) for the optional case, where DEFAULT mirrors setDefault and ACTUAL mirrors setBinding.

Gotchas

  • Duplicate keys are a runtime error, not a silent overwrite. Two modules calling MapBinder.addBinding("stripe") produces a CreationException when the injector is created. That’s a feature — clobbering would hide a bug — but it bites when two modules independently claim the same key. If you genuinely want collisions tolerated, call .permitDuplicates() on the Multibinder/MapBinder (which then also makes Set-valued maps available); otherwise treat a duplicate as the conflict it is.

  • Multibinder dedupes by binding equality, not instance. Two addBinding().to(EmailChannel::class.java) calls collapse to one entry, but two .toInstance(...) of equal-but-distinct objects can both land — and without permitDuplicates, equal instances throw. Don’t rely on the Set to dedupe your logic for you.

  • No ordering guarantees. The injected Set/Map has no defined iteration order across modules. If you need handlers to run in a sequence, attach an explicit priority to each contribution and sort downstream — never depend on installation order.

  • The collection itself can’t be rebound, only contributed to. You can’t bind(new TypeLiteral<Set<NotificationChannel>>(){}) to your own set once a Multibinder owns that type — Guice will report a conflict. Let the multibinder own it; if you need to replace the whole set in a test, use Modules.override.

  • Empty is legal and silent. A Multibinder with zero contributions yields an empty Set injection, not an error. Convenient, but it means a typo’d type parameter — contributing to Set<NotifcationChannel> — fails by giving you an empty set somewhere else, with no complaint. Watch your generics.

  • Annotated/qualified multibinders are distinct. newSetBinder(binder(), Foo::class.java, Names.named("a")) and the unannotated one are separate sets. Inject @Named("a") Set<Foo> to get the first. Mismatching the qualifier between contribution and injection is the most common “why is my set empty” cause.

What’s next

Multibindings lean hard on parameterized types — Set<NotificationChannel>, Map<String, PaymentProcessor> — and you may have noticed we quietly used new TypeLiteral<Set<Foo>>(){} above without explaining it. That TypeLiteral trick, and why Guice needs it to see past Java’s type erasure, is exactly where we go next.

Next: Part 2 — TypeLiteral and Generics, on injecting generic types Guice can’t otherwise name.


Target keyword(s): guice multibinder, guice multibindings.

Comments