Assisted Injection: When Half the Object Comes From Guice and Half From You


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

Some objects can’t be fully built at startup, and they can’t be fully built by the caller either. A Payment needs a Clock and a Gateway — collaborators Guice already knows how to supply. But it also needs an amount, and nobody knows the amount until a user clicks “Pay” three hours after the injector booted. So who builds it?

The injector can’t: it has no idea what the amount is. The caller can’t either, or at least shouldn’t have to. Making the call site new RealPayment(clock, gateway, amount) drags the Clock and Gateway back into a place that has no business knowing they exist, and we’re right back to the hand-wiring this whole series exists to delete. The object is half injected, half runtime, and Guice has a purpose-built tool for exactly that seam: assisted injection.

The constructor that wants both

You mark the runtime parameters — the ones the caller supplies — with @Assisted, from com.google.inject.assistedinject. Everything unmarked is a normal dependency Guice resolves the usual way. The annotation does nothing magic on its own; it’s a label that says this one isn’t mine, it comes from the factory.

import com.google.inject.assistedinject.Assisted
import jakarta.inject.Inject
import java.time.Clock

class RealPayment @Inject constructor(
    private val clock: Clock,            // injected by Guice
    private val gateway: Gateway,        // injected by Guice
    @Assisted private val amount: Money, // supplied by the caller at runtime
) : Payment {
    override fun apply() {
        gateway.charge(amount, at = clock.instant())
    }
}
import com.google.inject.assistedinject.Assisted;
import jakarta.inject.Inject;
import java.time.Clock;

class RealPayment implements Payment {
    private final Clock clock;       // injected by Guice
    private final Gateway gateway;   // injected by Guice
    private final Money amount;      // supplied by the caller at runtime

    @Inject
    RealPayment(Clock clock, Gateway gateway, @Assisted Money amount) {
        this.clock = clock;
        this.gateway = gateway;
        this.amount = amount;
    }

    @Override public void apply() {
        gateway.charge(amount, clock.instant());
    }
}

Note that there’s exactly one @Inject constructor, and it mixes both kinds of parameter freely. The position doesn’t matter and the marked ones don’t have to be last — I just put amount last because it reads well. What matters is that every parameter is accounted for: injectable or @Assisted, no third category.

The factory interface (which you never implement)

Now the trick. You declare an interface whose method takes the runtime values and returns the object. You write the interface; you do not write a class that implements it. Guice generates the implementation for you at injector-creation time.

interface PaymentFactory {
    fun create(amount: Money): Payment
}
public interface PaymentFactory {
    Payment create(Money amount);
}

The contract is mechanical: the method’s parameters must line up with the constructor’s @Assisted parameters (by type), and the return type is the thing being built. That’s Payment, the interface, even though the concrete class is RealPayment. The method name is yours to choose; create, createPayment, newPayment all work, Guice doesn’t care. There is no PaymentFactoryImpl to write, and that’s the entire point — the boilerplate factory you’d otherwise hand-roll, the one that just forwards amount and pulls clock/gateway from somewhere, is the boilerplate Guice generates.

Wiring it with FactoryModuleBuilder

You don’t bind the factory the usual way. You install a module that FactoryModuleBuilder hands you. The .implement(...) call says “when the factory returns a Payment, build a RealPayment,” and .build(...) names the factory interface to generate:

import com.google.inject.AbstractModule
import com.google.inject.Guice
import com.google.inject.assistedinject.FactoryModuleBuilder

class PaymentModule : AbstractModule() {
    override fun configure() {
        install(
            FactoryModuleBuilder()
                .implement(Payment::class.java, RealPayment::class.java)
                .build(PaymentFactory::class.java),
        )
    }
}

// Inject the factory like any other dependency, then call it with runtime values.
class Checkout @Inject constructor(private val payments: PaymentFactory) {
    fun charge(amount: Money) {
        val payment = payments.create(amount) // clock + gateway injected, amount yours
        payment.apply()
    }
}

fun main() {
    val injector = Guice.createInjector(PaymentModule())
    injector.getInstance(Checkout::class.java).charge(Money.usd(42))
}
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Injector;
import com.google.inject.assistedinject.FactoryModuleBuilder;
import jakarta.inject.Inject;

class PaymentModule extends AbstractModule {
    @Override protected void configure() {
        install(new FactoryModuleBuilder()
            .implement(Payment.class, RealPayment.class)
            .build(PaymentFactory.class));
    }
}

// Inject the factory like any other dependency, then call it with runtime values.
class Checkout {
    private final PaymentFactory payments;
    @Inject Checkout(PaymentFactory payments) { this.payments = payments; }

    void charge(Money amount) {
        Payment payment = payments.create(amount); // clock + gateway injected, amount yours
        payment.apply();
    }
}

// ...
Injector injector = Guice.createInjector(new PaymentModule());
injector.getInstance(Checkout.class).charge(Money.usd(42));

After that one install, PaymentFactory is a real binding. Inject it anywhere — constructor, of course — and call create(amount). Each call produces a fresh RealPayment with clock and gateway pulled live from the injector and amount threaded straight through from the argument. The Checkout class never sees a Clock, never sees a Gateway, never says new. It knows one thing: it has a factory, and the factory makes payments.

One nicety worth knowing: if the factory returns a concrete class with an @Assisted constructor — not an interface — you can skip .implement(...) entirely and just .build(TheFactory::class.java). Guice infers the target. The .implement call only earns its keep when the return type is an interface or abstract type that needs mapping to a concrete one.

Going deeper: naming, and what you’re not writing

Disambiguating same-typed parameters. The factory matches @Assisted parameters by type, which falls apart the instant two of them share a type. A create(startDate: Instant, dueDate: Instant) is ambiguous — two Instants, no way to tell which is which. The fix is a named @Assisted: give each one a string, and the names must match on both the factory method and the constructor.

interface PaymentFactory {
    fun create(
        @Assisted("startDate") startDate: Instant,
        @Assisted("dueDate") dueDate: Instant,
        amount: Money,
    ): Payment
}

class RealPayment @Inject constructor(
    private val gateway: Gateway,
    @Assisted("startDate") private val startDate: Instant,
    @Assisted("dueDate") private val dueDate: Instant,
    @Assisted private val amount: Money,
) : Payment { /* ... */ }
public interface PaymentFactory {
    Payment create(
        @Assisted("startDate") Instant startDate,
        @Assisted("dueDate") Instant dueDate,
        Money amount);
}

class RealPayment implements Payment {
    @Inject
    RealPayment(
        Gateway gateway,
        @Assisted("startDate") Instant startDate,
        @Assisted("dueDate") Instant dueDate,
        @Assisted Money amount) {
        /* ... */
    }
}

The amount stays plain @Assisted because Money is already unique among the assisted params. You only need names where the types collide, and the names are matched literally — a typo on one side is a wiring error, not a silent fallback.

Versus the factory you’d write by hand. You could absolutely skip all of this and write PaymentFactoryImpl yourself: inject Clock and Gateway into it, expose create(amount), and new RealPayment(clock, gateway, amount) inside. It works. It’s also pure ceremony: a class whose entire body forwards constructor arguments, and which you have to edit every single time RealPayment gains or loses a dependency. Assisted injection deletes that class and tracks the constructor automatically: add a @Inject FraudChecker to RealPayment and the factory keeps compiling, unchanged. The hand-written factory is the thing you reach for only when the construction needs logic — branching, validation, choosing between implementations — that an @Assisted constructor can’t express.

Gotchas

  • The factory can’t be used during injector creation. Guice generates and initializes the factory implementation as part of building the injector, so it isn’t ready until createInjector returns. Calling create(...) from inside a @Provides method or another constructor that runs at startup will fail. Inject the factory and call it later, when you actually have the runtime value.
  • Every constructor parameter must be one or the other. Injectable or @Assisted — there’s no “Guice will figure it out” middle ground. A parameter that’s neither bound nor assisted is a creation-time error. Conversely, an @Assisted parameter that doesn’t correspond to a factory-method parameter is also an error.
  • Same type without names is ambiguous — and it fails loudly. Two @Assisted Instant params with no value() won’t compile a working factory; Guice rejects the configuration. Reach for @Assisted("name") the moment a type repeats. (And remember: assisted params are matched by type, not by parameter order or name in source — only the @Assisted value counts.)
  • One @Inject constructor, or switch to @AssistedInject. The single-constructor case uses @Inject. If you need multiple constructors for the same type — say one factory method takes a date and another doesn’t — you annotate each constructor with @AssistedInject instead of @Inject, and Guice matches each factory method to the constructor whose assisted params line up. Don’t mix @Inject and @AssistedInject on the same class.
  • The created object is a full Guice citizen. The factory builds instances through a child injector, so they get the usual treatment — member injection runs, and they’re eligible for AOP method interception (the very next post). An assisted object is not a second-class, hand-new-ed thing.
  • You need the extension on the classpath. Assisted injection lives in a separate artifact: com.google.inject.extensions:guice-assistedinject. It’s not in guice-core. Match its version to your Guice version (7.x with Guice 7), or the injector won’t find FactoryModuleBuilder.

What’s next

Assisted injection is the clean answer to “this object needs both a dependency and a runtime value” — you mark the runtime parameters with @Assisted, declare a factory interface, and let FactoryModuleBuilder generate the implementation you’d otherwise write by hand and resent. The instant a factory’s body is doing nothing but forwarding arguments, it shouldn’t be a body at all.

I mentioned the generated objects are eligible for method interception — Guice can wrap your methods with cross-cutting behavior like timing, retries, or transactions, without you touching the method body. That’s next: Part 4 — AOP and Method Interception.


Target keyword(s): guice assisted injection, guice @Assisted.

Comments