How AssistedInject work with Hilt under the hood

skydovesJaewoong Eum (skydoves)||21 min read

How AssistedInject work with Hilt under the hood

Dependency injection excels at wiring together static dependency graphs, but what happens when you need runtime parameters? This is the challenge assisted injection solves, bridging the gap between compile-time dependency management and runtime parameter passing. While the concept appears simple on the surface, the internal machinery that makes it work reveals sophisticated compile-time code generation, careful separation of concerns, and elegant integration with Hilt's component system.

In this article, you'll dive deep into how assisted injection works under the hood, exploring how the annotation processor distinguishes assisted parameters from injected dependencies, how factories are generated and wired together, how Hilt integrates assisted injection with ViewModels through multibinding maps, and the runtime mechanisms that tie everything together. This isn't a guide on using @AssistedInject, it's an exploration of the compiler machinery that makes it possible.

The fundamental problem: Runtime parameters in a compile-time framework

At its core, dependency injection frameworks operate at compile time. When you write:

class MyService {
  @Inject
  MyService(Database db, NetworkClient client) {
    // ...
  }
}

The framework generates code that wires Database and NetworkClient from the dependency graph. But what if you need to pass a runtime parameter, a user ID, a configuration object, or data from user input? You can't put these in the dependency graph because they don't exist until runtime.

The naive approach is to inject a factory and manually construct the object:

class MyService {
  private final Database db;
  private final NetworkClient client;
  private final String userId;

  MyService(Database db, NetworkClient client, String userId) {
    this.db = db;
    this.client = client;
    this.userId = userId;
  }
}

// Manually created factory
interface MyServiceFactory {
  MyService create(String userId);
}

// Manual implementation
class MyServiceFactoryImpl implements MyServiceFactory {
  private final Database db;
  private final NetworkClient client;

  @Inject
  MyServiceFactoryImpl(Database db, NetworkClient client) {
    this.db = db;
    this.client = client;
  }

  @Override
  public MyService create(String userId) {
    return new MyService(db, client, userId);
  }
}

This works, but it's tedious and error-prone. Every time you add or remove a dependency, you must update both the constructor and the factory implementation. Assisted injection automates this entire pattern.

The annotation taxonomy: Distinguishing assisted from injected

Assisted injection introduces three annotations that work together to automate factory generation:

@AssistedInject: Marking the constructor

The @AssistedInject annotation marks a constructor that mixes injected dependencies with assisted parameters:

@Retention(RUNTIME)
@Target(CONSTRUCTOR)
public @interface AssistedInject {}

The RUNTIME retention is important, unlike Hilt's component-scoped annotations (which use CLASS retention), assisted injection needs runtime information for validation and debugging. However, the actual injection logic is entirely compile-time generated.

A critical constraint: types with @AssistedInject constructors cannot be scoped. This makes sense, if you're passing runtime parameters, each call to the factory creates a new instance. Scoping would require caching based on assisted parameters, which would be complex and rarely useful.

@Assisted: Marking runtime parameters

The @Assisted annotation distinguishes runtime parameters from injected dependencies:

@Retention(RUNTIME)
@Target(PARAMETER)
public @interface Assisted {
  String value() default "";
}

The value() parameter serves as a discriminator when you have multiple assisted parameters of the same type. Consider:

class DataService {
  @AssistedInject
  DataService(
      Database db,
      @Assisted String name,
      @Assisted("id") String id,
      @Assisted("repo") String repo) {
    // ...
  }
}

Here, three String parameters are assisted. Without identifiers, the framework couldn't distinguish them. The identifier creates a unique key: (type, identifier). So you have:

  • (String, "") for name
  • (String, "id") for id
  • (String, "repo") for repo

During processing, these parameters are wrapped in an AssistedParameter class that implements equality based on both type and identifier. This ensures uniqueness validation at compile time.

@AssistedFactory: Defining the user-facing API

The @AssistedFactory annotation marks the interface that users will interact with:

@Retention(RUNTIME)
@Target(TYPE)
public @interface AssistedFactory {}

The annotated type must obey strict constraints:

  • Must be abstract (interface or abstract class)
  • Must contain exactly one abstract, non-default method
  • That method must return the @AssistedInject-annotated type
  • That method's parameters must exactly match the assisted parameters (type + identifier, in the same order)

These constraints are validated by the AssistedValidator at compile time, ensuring that the generated factory implementation will type-check.

The two-phase code generation: Implementation then adapter

Assisted injection generates code in two phases, creating two separate classes with distinct responsibilities.

Phase 1: The implementation factory

For every @AssistedInject constructor, the processor generates a *_Factory implementation. Let's examine a concrete example:

class Foo {
  private final Dep1 dep1;
  private final Dep2 dep2;
  private final AssistedDep1 assistedDep1;
  private final AssistedDep2 assistedDep2;
  private final int assistedInt;

  @AssistedInject
  Foo(
      Dep1 dep1,
      @Assisted AssistedDep1 assistedDep1,
      Dep2 dep2,
      @Assisted AssistedDep2 assistedDep2,
      @Assisted int assistedInt) {
    this.dep1 = dep1;
    this.dep2 = dep2;
    this.assistedDep1 = assistedDep1;
    this.assistedDep2 = assistedDep2;
    this.assistedInt = assistedInt;
  }
}

The processor generates Foo_Factory:

public final class Foo_Factory {
  private final Provider<Dep1> dep1Provider;
  private final Provider<Dep2> dep2Provider;

  public Foo_Factory(Provider<Dep1> dep1Provider, Provider<Dep2> dep2Provider) {
    this.dep1Provider = dep1Provider;
    this.dep2Provider = dep2Provider;
  }

  public Foo get(AssistedDep1 assistedDep1, AssistedDep2 assistedDep2, int assistedInt) {
    return newInstance(
        dep1Provider.get(),
        assistedDep1,
        dep2Provider.get(),
        assistedDep2,
        assistedInt);
  }

  public static Foo newInstance(
      Dep1 dep1,
      AssistedDep1 assistedDep1,
      Dep2 dep2,
      AssistedDep2 assistedDep2,
      int assistedInt) {
    return new Foo(dep1, assistedDep1, dep2, assistedDep2, assistedInt);
  }

  public static Foo_Factory create(Provider<Dep1> dep1Provider, Provider<Dep2> dep2Provider) {
    return new Foo_Factory(dep1Provider, dep2Provider);
  }
}

Notice the structure:

  1. Provider fields for injected dependencies: dep1Provider and dep2Provider are stored as fields. These come from the dependency graph.

  2. get() method with assisted parameters: The get() method accepts the assisted parameters as method arguments. This is the key difference from a standard Factory<T>, standard factories have a no-arg get() method, but assisted factories have a get(...) method with parameters.

  3. newInstance() static method: This is a convenience method that directly constructs the object. It's useful for testing and for cases where the factory itself doesn't need to be injectable.

  4. create() static factory method: This creates the factory instance. Dagger components will call this method during component initialization.

The parameter ordering in newInstance() exactly matches the constructor signature. This might seem obvious, but it's important, the processor must carefully maintain parameter order when interleaving injected and assisted parameters.

Phase 2: The adapter factory

For every @AssistedFactory interface, the processor generates a *_Impl adapter that implements the user-defined interface and delegates to the implementation factory:

@AssistedFactory
interface FooFactory {
  Foo createFoo(AssistedDep1 dep1, AssistedDep2 dep2, int value);
}

Generated adapter:

public final class FooFactory_Impl implements FooFactory {
  private final Foo_Factory delegateFactory;

  FooFactory_Impl(Foo_Factory delegateFactory) {
    this.delegateFactory = delegateFactory;
  }

  @Override
  public Foo createFoo(AssistedDep1 dep1, AssistedDep2 dep2, int value) {
    return delegateFactory.get(dep1, dep2, value);
  }

  public static Provider<FooFactory> createFactoryProvider(Foo_Factory delegateFactory) {
    return InstanceFactory.create(new FooFactory_Impl(delegateFactory));
  }
}

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