magicbind is a KSP annotation processor and thin Gradle plugin for Hilt projects. It generates
@Module @InstallIn(...) files containing @Binds methods, so you don't have to write them by
hand. Each annotation you place on an implementation class or interface produces one file; Hilt's
own aggregator picks it up automatically. If you use Hilt and spend time writing boilerplate
@Binds modules, magicbind removes that work.
Without magicbind you write this for every interface-to-implementation binding:
// hand-written boilerplate
@Module
@InstallIn(SingletonComponent::class)
abstract class UserRepositoryModule {
@Binds
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}With magicbind:
@BindAs(UserRepository::class)
class UserRepositoryImpl @Inject constructor(/* ... */) : UserRepositoryThat annotation is the whole declaration. magicbind generates the module file during the KSP pass.
Add mavenLocal() to your settings.gradle.kts repository blocks (magicbind is not yet on
Maven Central):
pluginManagement {
repositories {
mavenLocal()
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
mavenLocal()
google()
mavenCentral()
}
}Publish to your local Maven cache first:
./gradlew publishToMavenLocalThen apply the plugin in each Android module that uses magicbind annotations. The plugin wires the KSP processor and annotation dependency in for you — you do not need to declare them separately:
plugins {
id("com.google.devtools.ksp")
id("dagger.hilt.android.plugin")
id("wizardry.magicbind") version "0.1.0-SNAPSHOT"
}KSP and Hilt plugins must already be applied; magicbind adds on top of them.
Place @BindAs on the implementation class when the interface lives in a different Gradle module
(so the implementation module can reference the interface type but not vice-versa).
@BindAs(UserRepository::class)
class UserRepositoryImpl @Inject constructor(
private val db: AppDatabase,
) : UserRepositoryRequirements: the annotated class must be concrete (not abstract, not an interface, not an
object), must have an @Inject constructor, and must actually implement the named bound type.
Place @BindWith on the interface when the interface module owns the canonical implementation —
for example, a :core module that declares both the interface and a default implementation.
@BindWith(SessionManagerImpl::class)
interface SessionManager
class SessionManagerImpl @Inject constructor(/* ... */) : SessionManagerThe implementation named in @BindWith must be a concrete class with an @Inject constructor
and must implement the annotated interface.
Both annotations default to SingletonComponent. Override with the component parameter:
@BindAs(VmRepository::class, component = ViewModelComponent::class)
class VmRepositoryImpl @Inject constructor(/* ... */) : VmRepositoryAny Hilt component class is valid. magicbind does not infer the component from scope annotations
on the impl — you must name it explicitly if you want something other than SingletonComponent.
Any annotation meta-annotated with @javax.inject.Qualifier that is present on the
implementation class is forwarded to the generated @Binds method:
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Internal
@BindAs(ApiClient::class)
@Internal
class InternalApiClient @Inject constructor(/* ... */) : ApiClientThe generated method carries @Internal, so injection sites can use @Internal ApiClient to
receive this specific binding.
Use @BindIntoSet and @BindIntoMap (from magicbind) instead of Dagger's @IntoSet /
@IntoMap. Dagger's annotations are declared @Target(METHOD), and placing them directly on a
class causes Hilt's aggregating processor to crash at compile time. magicbind's annotations are
@Target(CLASS) and are translated to the correct Dagger annotations on the generated method.
Set multibinding:
@BindAs(Plugin::class)
@BindIntoSet
class LoggingPlugin @Inject constructor() : Plugin
@BindAs(Plugin::class)
@BindIntoSet
class MetricsPlugin @Inject constructor() : PluginMap multibinding:
@MapKey
annotation class PluginKey(val value: String)
@BindAs(Plugin::class)
@BindIntoMap
@PluginKey("auth")
class AuthPlugin @Inject constructor() : PluginThe @MapKey-annotated annotation (@PluginKey) is collected as a qualifier-style annotation
and forwarded to the generated method alongside @IntoMap.
Bound types may be generic. Name the raw type in @BindAs; the generated method's return type
preserves the concrete parameterization from the implementation class:
interface Repository<T> { fun load(): T }
@BindAs(Repository::class)
class AccountRepository @Inject constructor() : Repository<Account>Generated: fun bind(instance: AccountRepository): Repository<Account>.
For each annotation site, magicbind emits one file in the implementation's package. A plain
@BindAs binding produces:
@Module
@InstallIn(SingletonComponent::class)
internal abstract class Magicbind_wizardry_magicbind_sample_UserRepositoryImpl_to_UserRepository {
@Binds
internal abstract fun bind(instance: UserRepositoryImpl): UserRepository
}A @BindIntoSet binding:
@Module
@InstallIn(SingletonComponent::class)
internal abstract class Magicbind_wizardry_magicbind_sample_LoggingPlugin_to_Plugin {
@Binds
@IntoSet
internal abstract fun bind(instance: LoggingPlugin): Plugin
}A qualifier binding (@Internal ApiClient):
@Module
@InstallIn(SingletonComponent::class)
internal abstract class Magicbind_wizardry_magicbind_sample_InternalApiClient_to_ApiClient {
@Binds
@Internal
internal abstract fun bind(instance: InternalApiClient): ApiClient
}The class name is derived from the fully-qualified implementation and bound-type names to avoid
collisions across modules. The class is internal and abstract; Hilt's aggregator discovers it
via the @InstallIn annotation and merges it into the component graph at the @HiltAndroidApp
site without any explicit wiring from you.
Anvil is the most common alternative for eliminating @Binds boilerplate. The two tools solve
overlapping problems for different DI frameworks.
| Dimension | magicbind | Anvil |
|---|---|---|
| Target DI framework | Hilt | Plain Dagger 2 |
| Aggregation | Delegated to Hilt's aggregator | Anvil's own Kotlin compiler plugin |
@Binds elimination |
Yes | Yes (@ContributesBinding) |
@Provides generation |
No | No |
| Subcomponent contribution | No | Yes (@ContributesSubcomponent) |
| Assisted-inject factory generation | No | Yes |
| Scope inference | No | Yes |
| Cross-module merging cost | Per-module KSP run; Hilt pays merge cost | Paid at @MergeComponent site |
| Maintenance status | Active (0.1.0-SNAPSHOT) | Square's original is maintenance-only as of 2024 |
Key structural difference. Anvil does its own cross-module aggregation: its Kotlin compiler
plugin reads compiled bytecode from all transitive dependencies and merges @ContributesBinding
declarations into the component at the @MergeComponent site. magicbind skips that entirely
because Hilt already does equivalent aggregation — each module's KSP output is an
@InstallIn-annotated module, and Hilt collects all of them when it processes @HiltAndroidApp.
magicbind is therefore not a replacement for Anvil's aggregation machinery; it is a thin
code-generation layer that targets the aggregation machinery Hilt already provides.
Active Anvil forks. Square's original square/anvil repository is in maintenance mode.
Active development has split: amzn/kotlin-inject-anvil targets kotlin-inject (not Dagger),
and ZacSweers/anvil continues Dagger support. For plain Dagger projects without Hilt, one of
those forks is the right tool.
When to choose magicbind:
- You are using Hilt and want to eliminate handwritten
@Bindsmodules. - You do not need subcomponent contribution, factory generation, or scope inference.
When to choose Anvil (or a fork):
- You are using plain Dagger 2 without Hilt.
- You need subcomponent contribution (
@ContributesSubcomponent). - You need scope inference or assisted-inject factory generation.
- You are on a kotlin-inject project — use kotlin-inject-anvil.
magicbind reports all validation errors through KSP at the annotation site. Compilation fails with one of the following messages:
-
magicbind: @BindAs target must be a concrete class—@BindAswas placed on an interface, abstract class, or object. -
magicbind: @BindAs target must have an @Inject constructor— the annotated class has no constructor annotated with@Inject. -
magicbind: @BindAs.boundType could not be resolved— theboundTypeargument could not be resolved during KSP symbol processing. This is an unusual case typically triggered by classpath misconfiguration. -
magicbind: cannot bind to <Type>— the named bound type iskotlin.Anyor a final concrete class. Bindings must target interfaces, abstract classes, or open classes. -
magicbind: <ImplFqName> does not implement <BoundTypeFqName>— the implementation class does not extend or implement the named bound type. -
magicbind: @BindWith target must be an interface or abstract class—@BindWithwas placed on a concrete class. -
magicbind: @BindWith.implementation could not be resolved— theimplementationargument could not be resolved during KSP symbol processing. -
magicbind: @BindWith implementation must be a concrete class— the named implementation is an interface, abstract class, or not a class declaration at all. -
magicbind: @BindWith implementation must have an @Inject constructor— the named implementation class has no constructor annotated with@Inject. -
magicbind: cannot determine qualified name for binding; this is likely a local or anonymous class, which is not supported— the impl or bound type resolved to a local or anonymous class whose fully-qualified name cannot be determined. -
magicbind: duplicate binding declared by <X> and <Y>— the same (implementation, boundType) pair was declared by both a@BindAsand a@BindWithannotation in the same compilation unit.
- Only
@Binds. magicbind does not generate@Providesmethods, factory classes, or any other Dagger construct. - Bound type must be named explicitly. There is no inference from supertypes. If your class implements multiple interfaces you must choose one per annotation site.
- One binding per annotation site. To bind the same implementation to two different
interfaces, apply
@BindAstwice — that is not currently supported. Use a hand-written module for multi-supertype bindings. - Component defaults to
SingletonComponent; no inference. Hilt scope annotations on the impl (e.g.@ActivityScoped) do not influence the generated@InstallIn. Override explicitly. - Local and anonymous classes are not supported. The generated class name is derived from the fully-qualified name of the implementation.
- Not on Maven Central. Currently published to local Maven only.
# Publish annotations, compiler, and plugin to ~/.m2
./gradlew publishToMavenLocal
# Run the compiler unit tests
./gradlew :compiler:test
# Assemble the sample Android library (end-to-end smoke test)
./gradlew :sample:assembleDebug
# Run Android lint on the sample
./gradlew :sample:lintDebugThe generated KSP output for the sample lands in
sample/build/generated/ksp/debug/kotlin/wizardry/magicbind/sample/.
annotations/— public annotations:@BindAs,@BindWith,@BindIntoSet,@BindIntoMap.compiler/— KSPSymbolProcessor(MagicbindProcessor) and code generator.gradle-plugin/— thin Gradle plugin (id("wizardry.magicbind")) that adds the KSP processor and annotation artifact as dependencies of the consuming module.sample/— Android library that exercises every annotation and serves as an end-to-end test.
magicbind is released into the public domain under The Unlicense. Use it freely for any purpose, with or without attribution.