Dagger's Multibinding Mechanisms: How @IntoSet and @IntoMap Work Under the Hood
Dagger's Multibinding Mechanisms: How @IntoSet and @IntoMap Work Under the Hood
Dependency injection frameworks excel at wiring individual dependencies, but what happens when you need to collect multiple implementations of the same type into a set or map? This is where multibinding comes in, a mechanism that allows distributed contributions across modules to be aggregated into a single collection. While the API appears simple, the internal machinery reveals sophisticated compile-time aggregation, careful separation between declarations and contributions, and runtime factories optimized for both memory efficiency and performance.
In this article, you'll dive deep into how @IntoSet and @IntoMap work under the hood, exploring how the annotation processor distinguishes contributions from declarations, how the binding graph aggregates distributed bindings, how runtime factories materialize collections on demand, how map keys are processed and validated, and how Hilt leverages multibinding internally for its ViewModel infrastructure. This isn't a guide on using multibindings, it's an exploration of the compiler and runtime machinery that makes distributed collection building possible.
The fundamental problem: Distributed collection building
Consider a plugin architecture where multiple modules contribute plugins:
// Module A
@Module
class ModuleA {
@Provides @IntoSet
static Plugin providePluginA() {
return new PluginA();
}
}
// Module B
@Module
class ModuleB {
@Provides @IntoSet
static Plugin providePluginB() {
return new PluginB();
}
}
// Application
@Component(modules = {ModuleA.class, ModuleB.class})
interface AppComponent {
Set<Plugin> plugins(); // Returns {PluginA, PluginB}
}
The challenge: how does Dagger know to collect these separate @Provides methods into a single Set<Plugin> binding? The modules are independent, ModuleA doesn't know about ModuleB. Yet the component must somehow aggregate all contributions.
The naive approach would require manual collection:
@Module
class PluginCollectionModule {
@Provides
static Set<Plugin> providePlugins(
PluginA a, PluginB b, PluginC c, ...) {
Set<Plugin> plugins = new HashSet<>();
plugins.add(a);
plugins.add(b);
plugins.add(c);
return plugins;
}
}
This is brittle, every time you add a plugin, you must modify this central module. Multibinding solves this by allowing modules to independently contribute to collections without knowing about each other.
The annotation taxonomy: Contributions vs declarations
Multibinding introduces four annotations with distinct roles:
@IntoSet: Contributing individual elements
The @IntoSet annotation marks a method that contributes a single element to a set:
@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface IntoSet {}
The method's return type becomes the element type. For a method returning Plugin, it contributes to Set<Plugin>:
@Provides @IntoSet
static Plugin providePlugin() {
return new PluginImpl();
}
The RUNTIME retention is important, while Dagger processes these annotations at compile time, the retention allows runtime inspection for debugging and tooling. However, the actual multibinding logic is entirely compile-time generated.
@IntoMap: Contributing key-value pairs
The @IntoMap annotation marks a method that contributes an entry to a map:
@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface IntoMap {}
Unlike @IntoSet, @IntoMap requires a companion @MapKey annotation to specify the key:
@Provides @IntoMap
@StringKey("key1")
static Plugin providePlugin() {
return new PluginImpl();
}
This contributes to Map<String, Provider<Plugin>>. Notice the map value is Provider<Plugin>, not Plugin, maps use lazy evaluation by default.
@ElementsIntoSet: Contributing collections
The @ElementsIntoSet annotation contributes multiple elements at once:
@Provides @ElementsIntoSet
static Set<Plugin> provideDefaultPlugins() {
return ImmutableSet.of(new PluginA(), new PluginB());
}
This is useful for providing default contributions or bulk additions. The method returns a Set<T>, and all elements are added to the multibinding.
@Multibinds: Declaring empty multibindings
The @Multibinds annotation declares that a multibinding exists, even if empty:
@Module
abstract class MyModule {
@Multibinds
abstract Set<Plugin> plugins();
@Multibinds
abstract Map<String, Plugin> pluginMap();
}
This is necessary only for potentially empty multibindings. If at least one contribution exists, the declaration is implicit. However, if a component requests Set<Plugin> and there are zero contributions without a @Multibinds declaration, compilation fails.
The critical insight: Dagger never implements or calls @Multibinds methods. They're purely metadata, a compile-time signal that the multibinding should exist.
The ContributionType enum: Classification at compile time
During annotation processing, Dagger classifies each binding by contribution type:
public enum ContributionType {
UNIQUE, // Regular non-multibinding
SET, // @IntoSet contribution
SET_VALUES, // @ElementsIntoSet contribution
MAP, // @IntoMap contribution
}
public static ContributionType fromBindingElement(XElement element) {
if (element.hasAnnotation(XTypeNames.INTO_MAP)) {
return ContributionType.MAP;
} else if (element.hasAnnotation(XTypeNames.INTO_SET)) {
return ContributionType.SET;
} else if (element.hasAnnotation(XTypeNames.ELEMENTS_INTO_SET)) {
return ContributionType.SET_VALUES;
}
return ContributionType.UNIQUE;
}
This classification drives the entire multibinding machinery. When the processor encounters a @Provides method, it checks for these annotations and creates the appropriate binding type.
Binding representation: From contributions to aggregations
Multibinding uses a two-level binding structure: individual contributions and aggregated bindings.
Individual contributions: ProvisionBinding
Each @Provides @IntoSet or @Provides @IntoMap method creates a ProvisionBinding:
public abstract class ProvisionBinding extends ContributionBinding {
@Override
public BindingKind kind() {
return BindingKind.PROVISION;
}
@Memoized
@Override
public ContributionType contributionType() {
return ContributionType.fromBindingElement(bindingElement().get());
}
}
The key is stored based on the contribution type:
- For
@IntoSet: key isSet<T>where T is the return type - For
@IntoMap: key isMap<K, Provider<V>>where K is the map key type and V is the return type - For
@ElementsIntoSet: key isSet<T>where T is the element type of the returnedSet<T>
Multiple ProvisionBinding objects can share the same key—that's how Dagger knows they're contributions to the same multibinding.
Aggregated bindings: MultiboundSetBinding and MultiboundMapBinding
When Dagger resolves a request for Set<T> or Map<K, V>, it creates a synthetic aggregated binding:
public abstract class MultiboundSetBinding extends ContributionBinding {
@Override
public BindingKind kind() {
return BindingKind.MULTIBOUND_SET;
}
// Note: UNIQUE, not SET!
// This is the aggregation, not a contribution
@Override
public ContributionType contributionType() {
return ContributionType.UNIQUE;
}
}
This binding's contributionType() returns UNIQUE because the aggregated binding itself is not a multibinding contribution—it's a regular binding that aggregates contributions.
The dependencies of the aggregated binding point to all individual contributions:
public MultiboundSetBinding multiboundSet(
Key key, Iterable<ContributionBinding> multibindingContributions) {
return MultiboundSetBinding.builder()
.key(key)
.dependencies(
dependencyRequestFactory.forMultibindingContributions(key, multibindingContributions))
.build();
}
Let's trace an example:
@Module
class MyModule {
@Provides @IntoSet static String provideFoo() { return "foo"; }
@Provides @IntoSet static String provideBar() { return "bar"; }
}
@Component(modules = MyModule.class)
interface MyComponent {
Set<String> strings();
}
Processing flow:
-
Processor encounters
provideFoo():- Creates
ProvisionBindingwith keySet<String> contributionType()returnsSET
- Creates
-
Processor encounters
provideBar():- Creates
ProvisionBindingwith keySet<String>(same key!) contributionType()returnsSET
- Creates
-
Component requests
Set<String>:- Binding graph searches for bindings with key
Set<String> - Finds two
ProvisionBindingobjects both withcontributionType() == SET - Creates
MultiboundSetBindingwith:- Key:
Set<String> - Dependencies:
[provideFoo(), provideBar()] contributionType()returnsUNIQUE
- Key:
- Binding graph searches for bindings with key
-
Code generation uses
MultiboundSetBinding.dependencies()to generate the factory.
This two-level structure is crucial—it separates contribution metadata (individual bindings) from aggregation logic (synthetic binding).
Runtime factories: SetFactory and MapFactory
The generated component code uses runtime factory classes to materialize collections on demand.
SetFactory: Building sets from providers
The SetFactory class is the runtime workhorse for set multibindings:
public final class SetFactory<T> implements Factory<Set<T>> {
private final List<Provider<T>> individualProviders;
private final List<Provider<Collection<T>>> collectionProviders;
@Override
public Set<T> get() {
int size = individualProviders.size();
// First, get all collections and calculate total size
List<Collection<T>> providedCollections = new ArrayList<>(collectionProviders.size());
for (int i = 0, c = collectionProviders.size(); i < c; i++) {
Collection<T> providedCollection = collectionProviders.get(i).get();
size += providedCollection.size();
providedCollections.add(providedCollection);
}
// Pre-size the set for efficiency
Set<T> providedValues = newHashSetWithExpectedSize(size);
// Add individual contributions
for (int i = 0, c = individualProviders.size(); i < c; i++) {
providedValues.add(checkNotNull(individualProviders.get(i).get()));
}
// Add collection contributions
for (int i = 0, c = providedCollections.size(); i < c; i++) {
for (T element : providedCollections.get(i)) {
providedValues.add(checkNotNull(element));
}
}
return unmodifiableSet(providedValues);
}
}
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor