Skip to content

worker-kmp Single-API Guide (v4.0.0+)

End-to-end consumer-facing guide for the worker-kmp single-API in commonMain. Consumers write 100% commonMain code for the worker-kmp scheduling/observation/init/Koin domain — per-platform glue is auto-generated by cmp-worker-app-plugin at build time.

Quick start

// commonMain — entire worker-kmp setup
@WorkerKmpApp(
    title = "My App",
    iosBundleId = "com.example.myapp",
)
public fun appKoinModules(): List<Module> = listOf(
    DataModule,
    SyncObserverKoinModule,  // from cmp-worker-sync — binds UniqueWorkObserver
)

@WorkerKmpWorkers(workers = [DataSyncWorker::class, NotificationWorker::class])
public fun workerDeclarations() = Unit

public class DataSyncWorker(
    context: WorkerContext,
    private val repo: CurrencyRepository,   // public
) : CoroutineWorker(context) { /* ... */ }
// commonMain — your app's shared init (the function EVERY platform entry point calls).
// One line wires workers on Android + iOS + Desktop + Web. Do NOT put it in a single
// platform's app class — the others would silently get no workers.
fun initApp(config: KoinAppDeclaration? = null) {
    startKoin {
        config?.invoke(this)          // Android binds androidContext(this@App) here
        modules(appKoinModules())
    }
    WorkerKmpAuto.install()           // single line — codegen handles the rest
}
// androidMain — the app class only supplies the Android Koin context; NO worker code:
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        initApp { androidContext(this@MyApp) }
    }
}
// desktopMain: fun main() { initApp(); ... }
// wasmJsMain / jsMain: fun main() { initApp(); ... }
// iosMain: ViewController { initApp(); ... }

That's it. iosMain / desktopMain / wasmJsMain source sets have ZERO consumer-written files for the worker-kmp domain — and every platform is wired from the one commonMain install().

Two integration shapes

Shape 1 — full app codegen (no existing Application class)

Use both @WorkerKmpApp + @WorkerKmpWorkers. The plugin generates per-platform launchers (Android Application + Activity, iOS MainViewController, Desktop main(), Web main()) PLUS the worker-init files. Consumer's commonMain code is sufficient — no per-platform shell classes needed.

Shape 2 — bring-your-own-Application (existing app shell)

Use only @WorkerKmpWorkers. The plugin skips launcher generation; you keep your own app shells and call WorkerKmpAuto.install() once from your commonMain shared init (exactly as the Quick start above shows), after Koin is started.

⚠️ Placement: commonMain, not a single platform's app class. install() is a no-arg commonMain call, so it goes in the one shared init every platform funnels through. Putting it in Application.onCreate only leaves Desktop / iOS / Web silently un-wired — they compile and run but schedule no workers, and an Android-only smoke test won't catch it. If your app has no shared commonMain init, add install() to each platform entry point.

samples/kmp-project-template uses this shape: @WorkerKmpWorkers in cmp-shared/WorkerDeclarations.kt, and WorkerKmpAuto.install() in the commonMain cmp-shared/utils/KoinExt.kt#initKoin — so all five of its platforms are wired from one line.

Annotation reference

@WorkerKmpWorkers

  • Goes on a top-level commonMain function (typically public fun workerDeclarations() = Unit)
  • workers: Array<KClass<*>> — each must extend CoroutineWorker with WorkerContext as first primary-constructor param + be public
  • OPTIONAL — omit if no workers needed (e.g. CMP app shell only)
  • Within-module aggregation — multiple sites in the SAME module are aggregated; cross- module aggregation is NOT supported (place annotation in the module that depends on every worker-owning module)

@WorkerForPlatforms([Platform.Web, Platform.Android, ...])

  • Co-annotation on worker classes — opts the worker into a subset of generated init files
  • Default (annotation absent): worker registered on all 4 platforms

@WorkerKmpApp(title, iosBundleId, webCanvasId?, androidApplicationId?, androidPermissions?)

  • Goes on public fun appKoinModules(): List<Module> — REMOVED the v3.1.x factory: WorkManagerFactory parameter (codegen handles factory selection now)

Visibility constraint

All worker classes AND their primary-constructor dep types MUST be public. KSP processor errors with clear suggested-fix message if you use internal class or private class — codegen-emitted code lives in your cmp-shared/build/generated/... and can only reference public symbols across module boundaries.

Default-valued constructor params

public class IntervalWorker(
    ctx: WorkerContext,
    val intervalMs: Long = 5000L,  // SKIPPED from Koin autowiring — default used at runtime
) : CoroutineWorker(ctx) { ... }

KSP detects KSValueParameter.hasDefault == true and emits register<IntervalWorker> { ctx -> IntervalWorker(context = ctx) } (no getKoin().get<Long>()).

Generic Koin dep types — require @Named qualifier

// ❌ Compile error — Koin runtime erases generic type args
public class GenericWorker(
    ctx: WorkerContext,
    val store: Store<String, ExchangeRates>,
) : CoroutineWorker(ctx)

// ✅ Use @Named qualifier
public class GenericWorker(
    ctx: WorkerContext,
    @Named("exchange-rates") val store: Store<String, ExchangeRates>,
) : CoroutineWorker(ctx)

// + matching Koin binding
single<Store<String, ExchangeRates>>(named("exchange-rates")) { ... }

Calling order

startKoin { … } MUST complete BEFORE WorkerKmpAuto.install() — placing install() at the end of your shared init (after the startKoin block) satisfies this on every platform. On Android the actual additionally reads Context from the androidContext() binding, so bind it inside your startKoin config. Wrong order → the shim throws IllegalStateException with a clear fix message.

WorkerKmpHost.initialize is NON-SUSPEND

Pure setup state — safe to call from your shared init without runBlocking (no ANR risk). No first-sync is enqueued — that's the consumer's responsibility after WorkerKmpAuto.install() returns (e.g. get<WorkManager>().enqueueUniqueWork(...) from your bootstrap code).

Configuration

// Customize via Koin binding (D22 — annotations can't carry data-class instances)
val MyAppModule = module {
    single { WorkerKmpHostConfig(logTag = "my-app.worker") }
}

Defaults: koinScopeQualifier = null (global scope), logTag = "worker-kmp.host".

Test path

Koin module overrides — allowOverride = true is REQUIRED:

@Before
fun setUp() {
    startKoin { modules(appKoinModules()) }
    WorkerKmpAuto.install()

    // Replace the codegen-bound WorkManager with a fake
    loadKoinModules(
        module {
            single<WorkManager>(allowOverride = true) { FakeWorkManager() }
        },
    )
}

For pure-commonMain unit tests of workers (no factory needed), instantiate the worker class directly with a fake WorkerContext (same pattern existing worker-kmp samples already use).

Source-set discipline GUARANTEE

Your consumer's per-platform source sets contain ZERO files for the worker-kmp scheduling/observation/init/Koin domain. Verify:

find your-app/src/{androidMain,iosMain,desktopMain,wasmJsMain} \
    \( -name 'SyncManager*.kt' \
     -o -name 'WorkScheduler*.kt' \
     -o -name 'WorkerKmpAuto.kt' \
     -o -name 'Sync*Initializer.kt' \) \
    -not -path '*/build/*'

Expected output: EMPTY.

Koin Compiler Plugin — compileSafety + @Provided (#61)

If your app uses the Koin Compiler Plugin (io.insert-koin.compiler.plugin) with koinCompiler { compileSafety = true } and you inject WorkManager into an annotation-defined component, the compile-safety checker reports:

[Koin][KOIN-D001] Missing dependency: io.github.mobilebytelabs.worker.WorkManager
  required by: SyncViewModel (parameter 'workManager')

This is expected, and correctWorkManager is bound at runtime by WorkerKmpAuto.install() (which calls loadKoinModules(...)), so it is deliberately not part of the compile-time annotation graph. It is an externally-provided dependency, exactly like Android's Context/SavedStateHandle.

Fix — mark the injection @Provided (Koin's designed escape for runtime/externally-supplied types). No worker-kmp change is needed:

import org.koin.core.annotation.Provided
import org.koin.core.annotation.KoinViewModel
import io.github.mobilebytelabs.worker.WorkManager

@KoinViewModel
class SyncViewModel(
    @Provided private val workManager: WorkManager,   // supplied at runtime by WorkerKmpAuto.install()
) : ViewModel()

@Provided tells the checker "this type is supplied externally at runtime" and suppresses KOIN-D001 — while the real binding is still owned by WorkerKmpAuto.install(). Do this at every site that injects a worker-kmp runtime-provided type (WorkManager, and any other type bound only via install()).

Do not set compileSafety = false to work around this — that disables the whole safety net. @Provided is the targeted, idiomatic fix.