Skip to content

nukeforum/magicbind

Repository files navigation

magicbind

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.

Before and after

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(/* ... */) : UserRepository

That annotation is the whole declaration. magicbind generates the module file during the KSP pass.

Setup

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 publishToMavenLocal

Then 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.

Usage

@BindAs — annotate the implementation

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,
) : UserRepository

Requirements: 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.

@BindWith — annotate the interface

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(/* ... */) : SessionManager

The implementation named in @BindWith must be a concrete class with an @Inject constructor and must implement the annotated interface.

Component override

Both annotations default to SingletonComponent. Override with the component parameter:

@BindAs(VmRepository::class, component = ViewModelComponent::class)
class VmRepositoryImpl @Inject constructor(/* ... */) : VmRepository

Any 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.

Qualifiers

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(/* ... */) : ApiClient

The generated method carries @Internal, so injection sites can use @Internal ApiClient to receive this specific binding.

Multibindings

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() : Plugin

Map multibinding:

@MapKey
annotation class PluginKey(val value: String)

@BindAs(Plugin::class)
@BindIntoMap
@PluginKey("auth")
class AuthPlugin @Inject constructor() : Plugin

The @MapKey-annotated annotation (@PluginKey) is collected as a qualifier-style annotation and forwarded to the generated method alongside @IntoMap.

Generic supertypes

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>.

What gets generated

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.

Comparison with Anvil

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 @Binds modules.
  • 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.

Errors

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@BindAs was 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 — the boundType argument 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 is kotlin.Any or 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@BindWith was placed on a concrete class.

  • magicbind: @BindWith.implementation could not be resolved — the implementation argument 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 @BindAs and a @BindWith annotation in the same compilation unit.

Limitations (v1)

  • Only @Binds. magicbind does not generate @Provides methods, 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 @BindAs twice — 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.

Building from source and running the sample

# 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:lintDebug

The generated KSP output for the sample lands in sample/build/generated/ksp/debug/kotlin/wizardry/magicbind/sample/.

Modules

  • annotations/ — public annotations: @BindAs, @BindWith, @BindIntoSet, @BindIntoMap.
  • compiler/ — KSP SymbolProcessor (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.

License

magicbind is released into the public domain under The Unlicense. Use it freely for any purpose, with or without attribution.

About

KSP plugin for automatically generating Hilt bindings

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages