Misk Guice Dependency Injection: KAbstractModule and the Module Way


Series: Building Production Services with Misk — Part 3 of 24

Every Misk service is a list of modules. We’ve waved at that twice now; this is the post where we open it up. Misk’s dependency injection is Google Guice, wrapped in a thin Kotlin layer called misk-inject. If your DI instincts come from Spring, the mental model is different in one decisive way — and that difference is most of why people like Misk. Let’s make it concrete.

Why Guice, not Spring DI

Here’s the stance, stated plainly: Misk uses Guice because wiring should be code you read, not magic you debug.

Spring’s container discovers your beans by scanning the classpath for annotations, then resolves @Autowired dependencies through proxies and reflection at runtime. It’s convenient until it isn’t — until two beans of the same type collide, or a circular dependency surfaces as a runtime exception, or you genuinely cannot tell where a particular bean got configured without a debugger.

Guice flips that. There’s no scanning. A dependency exists in the graph because a module explicitly bound it, in Kotlin you can read. When you wonder “where does this PaymentGateway come from?”, the answer is a bind<PaymentGateway>().to<RealPaymentGateway>() line in some module, and your IDE will jump straight to it. The wiring is denser to write than Spring’s annotations, but it’s honest — nothing is decided behind your back. For a fleet of services maintained by many people, that legibility is worth more than the keystrokes it costs.

The functional comparison to Spring: a Guice Module is your @Configuration; bind().to() is an explicit @Bean; @Inject on a constructor is @Autowired (but constructor-only, which is healthier); and Misk’s install(OtherModule()) is how modules compose, replacing component scan with deliberate assembly.

KAbstractModule: bindings in Kotlin

Raw Guice is a Java API, so misk-inject provides KAbstractModule — a Guice AbstractModule subclass with reified Kotlin helpers. It turns Java’s bind(Foo::class.java).to(RealFoo::class.java) into:

class BillingModule : KAbstractModule() {
  override fun configure() {
    bind<PaymentGateway>().to<StripePaymentGateway>()
  }
}

bind<T>() and .to<T>() are inline reified functions, so you work in Kotlin’s type system instead of passing ::class.java everywhere. This is the base class for essentially every module you’ll write. Everything in this post happens inside configure().

You bind an interface to an implementation (bind<X>().to<Y>()), or a type to a specific instance (bind<X>().toInstance(value)). And for concrete classes with an injectable constructor, you often bind nothing at all — Guice’s just-in-time bindings will construct them on demand. You only need an explicit binding when you’re choosing between implementations or configuring how something is built.

Constructor injection

Misk classes declare their dependencies as constructor parameters annotated with @Inject. Note the namespace — Misk uses jakarta.inject, not the old javax.inject:

import jakarta.inject.Inject
import jakarta.inject.Singleton

@Singleton
class CheckoutService @Inject constructor(
  private val gateway: PaymentGateway,
  private val clock: Clock,
) {
  fun charge(amountCents: Long) { /* ... */ }
}

Constructor injection is the only kind you should reach for — dependencies are explicit, the object is fully formed once constructed, and the class is trivially testable by just calling the constructor with fakes. @Singleton tells Guice to create one shared instance for the injector’s lifetime; without it you get a fresh instance per injection point. Most services and clients are singletons; most request-scoped or value-like objects are not.

Qualifiers: same type, many bindings

Sooner or later you need two bindings of the same type — two DataSources, a primary and replica HttpClient, two Service implementations. Guice disambiguates with qualifier annotations. The exemplar shows the built-in @Named qualifier in action:

class ExemplarGuiceBindingsModule : KAbstractModule() {
  override fun configure() {
    bind<Service>().to<ServiceImpl>()
    bind<Service>().annotatedWith(Names.named("AnotherService")).to<AnotherServiceImpl>()
    bind<String>().annotatedWith(Names.named("ConstantBindingExample")).toInstance("MyString")
  }
}

The unqualified Service resolves to ServiceImpl; the one qualified with @Named("AnotherService") resolves to AnotherServiceImpl. Consumers ask for exactly the one they want:

class Consumer @Inject constructor(
  private val service: Service,                              // ServiceImpl
  @Named("AnotherService") private val other: Service,       // AnotherServiceImpl
)

@Named is fine for one-offs, but for anything load-bearing prefer a custom typed qualifier — a real annotation — so a typo becomes a compile error instead of a mystifying missing-binding at startup:

@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Replica

// bind<DataSource>().annotatedWith<Replica>().to<ReplicaDataSource>()
// class Reader @Inject constructor(@Replica val ds: DataSource)

@Provides for constructed dependencies

When a dependency needs construction logic — reading from config, calling a builder — bind it with an @Provides method instead of bind().to(). The exemplar uses one:

@Provides @Named("YetAnotherService")
fun provideYetAnotherService(): Service = AnotherServiceImpl()

@Provides methods can themselves take injected parameters, which Guice supplies from the graph — so a provider can depend on config, a clock, anything else that’s bound:

@Provides @Singleton
fun provideStripeGateway(config: BillingConfig): PaymentGateway =
  StripePaymentGateway(apiKey = config.stripeKey.value)

This is the workhorse for wiring third-party clients that aren’t @Inject-annotated and need values from your config.

Multibindings: the plugin pattern

Here’s the Guice feature that powers Misk’s whole “features are modules” design. A multibinding lets many modules each contribute to a shared Set or Map, without any of them knowing about the others. KAbstractModule exposes multibind and newMultibinder:

class HealthChecksModule : KAbstractModule() {
  override fun configure() {
    multibind<HealthCheck>().to<DatabaseHealthCheck>()
    multibind<HealthCheck>().to<RedisHealthCheck>()
  }
}

Anything that injects Set<HealthCheck> receives every contributed check, gathered across all installed modules. This is exactly how Misk registers web actions, interceptors, services, and health checks: each feature module quietly adds itself to a set that the container consumes. When you later write install(WebActionModule.create<HelloWebAction>()), under the hood that’s a multibinding contribution. There’s a newMapBinder<K, V>() for the keyed variant when contributions need names.

Understanding multibindings is the key that unlocks Misk’s extensibility. Once you see that “install a module to add a feature” is really “a module contributing to a multibound set,” the framework stops feeling magical and starts feeling like plain Guice arranged well.

How modules compose a service

Modules assemble through install. A module can install other modules, so you build a tree: a top-level app module installs feature modules, which install their own dependencies.

class MyServiceModule : KAbstractModule() {
  override fun configure() {
    install(BillingModule())
    install(HealthChecksModule())
    install(WebActionModule.create<CheckoutAction>())
  }
}

And recall from Part 2 that long-running work is a Guava Service, bound with a ServiceModule that can declare ordering:

install(ServiceModule<MovieService>().dependsOn<DatabaseService>())

That dependsOn is honored by the ServiceManager at startup — MovieService won’t start until DatabaseService is healthy. We’ll spend all of the next post on that lifecycle. For now, the takeaway is that your entire service graph — every dependency, every feature, every ordering constraint — is described in explicit KAbstractModule code. No scan, no surprises.

Production notes & gotchas

  • jakarta.inject, always. The single most common paste-error. javax.inject.Inject won’t resolve in modern Misk.
  • Prefer typed qualifiers over @Named. A misspelled @Named("primay") fails at runtime with an opaque missing-binding error; a typed @Primary annotation fails at compile time. Use strings only for throwaway bindings.
  • Missing bindings surface at injector creation, not compile time. Guice resolves the graph when the injector is built (at startup, or in your first test). A forgotten bind is a CreationException on boot — loud and early, but not caught by the compiler. Misk’s fast tests (Part 23) are your real safety net here.
  • Don’t field-inject. Constructor injection keeps dependencies explicit and objects immutable. Field injection (@Inject lateinit var) exists and Misk’s test classes use it deliberately, but in production code it hides dependencies and invites half-constructed objects.
  • Multibindings are additive and silent. Contributing to a set never errors on duplicates the way a plain bind does — convenient, but it means an accidental double-install just adds the thing twice. Know which of your modules are multibinding.

What’s next

You can now wire a service graph. But binding services is only half the story — they have to start, in the right order, and stop cleanly. In Part 4: Service Lifecycle, Readiness & Graceful Shutdown we’ll follow a Misk service from startAsync to SIGTERM: the ServiceManager, dependency ordering, readiness, warmup, and how Misk drains in-flight work before it dies.


Target keywords: misk guice, misk dependency injection, KAbstractModule.

Comments