A Kotlin KSP library for multimodule Android projects that automates the wiring of feature module implementations into Dagger multibinding sets.
In a multimodule Android app, the core app often needs to delegate work to feature modules without knowing which features exist at compile time. For example, when a push notification arrives, any number of feature modules might want to handle it. Manually maintaining a list of handlers in the app module creates tight coupling and boilerplate.
Delegator solves this with KSP code generation and Dagger multibindings — feature teams register themselves; the app just uses a typed delegator.
In a shared :app-features module, apply KSP and annotate an interface with @Delegatable:
// :app-features/build.gradle.kts
plugins {
id("com.google.devtools.ksp")
}
dependencies {
api("io.github.saadfarooq:delegator-annotations:x.y.z")
ksp("io.github.saadfarooq:delegator-processor:x.y.z")
}// AppFeatures.kt
@Delegatable
interface AppFeatures {
// Extending DelegatableCleanup enforces cleanup on all implementations
interface PushNotificationHandler : DelegatableCleanup {
fun canHandle(message: PushMessage): Boolean
fun handle(message: PushMessage)
}
interface PillarCell {
fun render(data: PillarCellData): String
}
}KSP generates:
@AppFeaturesDelegatable— typed registration annotation for feature teamsAppFeaturesPushNotificationHandlerDelegator— injectable class withgetHandlers()andrunCleanup()AppFeaturesPillarCellDelegator— same forPillarCell- Empty-set Dagger base modules so the graph is valid with zero implementations
In their feature module, they apply KSP and use the generated @AppFeaturesDelegatable annotation once. IDE autocompletes the parameter names; the compiler rejects wrong types. Parameters not provided default to "not participating":
// :feature-inbox/build.gradle.kts
plugins {
id("com.google.devtools.ksp")
}
dependencies {
implementation(project(":app-features"))
ksp("io.github.saadfarooq:delegator-processor:x.y.z")
}// InboxFeatures.kt
@AppFeaturesDelegatable(
pushNotificationHandler = InboxPushNotificationHandler::class,
pillarCell = InboxPillarCell::class,
guard = InboxGuard::class,
)
interface InboxFeaturesThe guard references the team's own feature flag system — the library is agnostic:
object InboxGuard : DelegatableGuard {
override val isEnabled get() = Statsig.checkGate("inbox_enabled")
}Implementations are plain classes — no library annotations required:
class InboxPushNotificationHandler @Inject constructor(
private val notificationManager: NotificationManager
) : AppFeatures.PushNotificationHandler {
override fun canHandle(message: PushMessage) = message.type == "inbox"
override fun handle(message: PushMessage) { TODO() }
// Required — PushNotificationHandler extends DelegatableCleanup
override fun cleanup() {
notificationManager.deleteNotificationChannel("INBOX_NOTIFICATIONS")
}
}
class InboxPillarCell @Inject constructor() : AppFeatures.PillarCell {
override fun render(data: PillarCellData): String = TODO()
}KSP generates InboxFeaturesDelegatableModule — a Dagger @Module with @Provides @IntoSet for each implementation, wrapped in Guarded<T> alongside the feature's guard.
The generated delegator is injectable. getHandlers() returns only implementations whose guard is enabled; runCleanup() calls cleanup() on implementations whose guard is disabled:
class AppPushService @Inject constructor(
private val delegator: AppFeaturesPushNotificationHandlerDelegator
) {
fun onAppStart() = delegator.runCleanup()
fun onPush(message: PushMessage) {
delegator.getHandlers()
.firstOrNull { it.canHandle(message) }
?.handle(message)
}
}:app-features module
@Delegatable interface AppFeatures ← you write this
└── KSP generates ──────────────────→ @AppFeaturesDelegatable annotation
AppFeaturesPushNotificationHandlerDelegator
AppFeaturesPushNotificationHandlerBaseModule
:feature-inbox module
@AppFeaturesDelegatable(...) ← you write this
interface InboxFeatures
└── KSP generates ──────────────────→ InboxFeaturesDelegatableModule
@Provides @IntoSet Guarded<PushNotificationHandler>
@Provides @IntoSet Guarded<PillarCell>
Dagger assembles the Set<Guarded<T>> across all feature modules. The delegator filters by guard at runtime.
Interface authors can require cleanup by extending DelegatableCleanup. Feature teams get a compile error if cleanup() is missing:
interface PushNotificationHandler : DelegatableCleanup { ... } // cleanup required
interface PillarCell { ... } // cleanup optional| Module | Description |
|---|---|
delegator-annotations |
@Delegatable, @DelegatableRegistration, DelegatableGuard, DelegatableCleanup, Guarded<T> |
delegator-processor |
KSP processor — generates registration annotations, delegators, and Dagger modules |
sample-app-features |
Example: platform team defines AppFeatures namespace |
sample-feature-inbox |
Example: Inbox team registers via @AppFeaturesDelegatable |
-
@Delegatable→ generates@{Name}Delegatableannotation with bounded KClass params -
@Delegatable→ generates{Name}{Interface}Delegatorwith guard-filteredgetHandlers()andrunCleanup() -
@Delegatable→ generates empty-set base Dagger module -
@{Name}Delegatable→ generates{Feature}DelegatableModulewith@Provides @IntoSet Guarded<T> - Hilt
@InstallInsupport - Compile-time validation (type mismatch, missing interface implementation)
- Gradle convenience plugin
- Publishing to Maven Central