Skip to content

dev

Rekindling the spark: moving Raccoon to Metro

"For now" was doing a lot of heavy lifting

The last article ended with the words "you can consider this the end, for now, of my concerns about DI."

To be honest, when I wrote that sentence, I was far from happy with the state of things. That was precisely why I cautiously tacked on that parenthetical "for now".

The cracks in KCP

Between mid-July and early August, I had, in chronological order:

  • Migrated Raccoon from Kodein back to Koin out of genuine concern for the future of KOSI and the libraries they maintain;
  • Written a convention plugin to factor out common DI configuration across subprojects (adapting 50+ modules in the previous step made me want to make future work easier on myself);
  • Evolved from the "classic DSL" style to the Koin Compiler Plugin (KCP) with annotations, aiming to reduce manual wiring to a minimum and gain compile-time validation.

This was a substantial amount of work — with not a single visible benefit for end-users — and it forced me to freeze all new feature development and enter "maintenance mode," sticking strictly to bug fixes, dependency updates, and code cleanup.

What made it worse was that I wasn't even 100% satisfied with the end result. These were the issues that nagged at me most:

  • Kotlin 2.4.10 incompatibility: KCP wasn't fully compatible with the most recent version of Kotlin, emitting warnings for every module on every build;
  • iOS false positives: compile-time validation triggered false positives on iOS that broke iOS builds entirely, forcing me to disable it (and configuring that conditionally based on build target inside a Gradle convention plugin was anything but straightforward);
  • False negatives and runtime crashes: I realized validation was also yielding false negatives, meaning I could have a perfectly compiling project that still crashed at runtime due to migration oversights, e.g. marking a parameter with @InjectedParam (in either a factory function or a constructor) and then forgetting to pass it at the call site.

Rekindling the spark: Why Metro?

In 2026, there had to be something better in the KMP ecosystem. That's when a tiny voice in my head whispered: "Have you considered switching to Metro?"

I've been doing Android development for longer than I'm comfortable admitting (though my silver hair and beard betray me). I'm quite well-versed in Dagger/Hilt on native Android, and I'd been following the Metro project with keen interest—mixed with equal parts hope and apprehension.

I had spent months reading Zac Sweers' documentation, articles, and posts on the kotlinlang.org Slack, listening to all his talks. I'd been dreaming that one day I could convert Raccoon, while simultaneously worrying about how long and painful such a migration might be.

Last weekend, I started a playground project to test out the framework, evaluate its setup complexity and reproduce the specific edge cases I knew Raccoon presented. Then the work week hit, and it was a tough one. I had to churn through a series of difficult and remarkably unrewarding tasks.

After a couple of days of grinding, I decided I needed to compensate by building something that rekindled that "little spark": the feeling every developer gets when they finish a piece of work and are genuinely satisfied with the outcome.

So, one evening, I took the plunge and started the Metro migration.

Rolling up my sleeves: the migration plan

The following section isn't meant to be a general tutorial on how to use Metro: the project already has excellent documentation, especially if you're already familiar with Dagger/Hilt, kotlin-inject, or Anvil.

Instead, this is a chronicle of my personal approach to migrating Raccoon's specific codebase.

Given its multi-module architecture, I mapped out a six-step plan:

  1. Adapt the build logic and plugin setup;
  2. Define the root graph and entry points;
  3. Migrate core infrastructure;
  4. Migrate the business logic;
  5. Migrate all feature modules;
  6. Final cleanup.

Step 1: Build logic and plugin setup

This step involved creating a Gradle convention plugin to factor out the common DI configuration across all subprojects. The convention plugin applies the dev.zacsweers.metro plugin (version 1.4.3) and provides an extension option to also add the dev.zacsweers.metro:metrox-viewmodel-compose dependency when needed.1

Step 2: Defining the root graph and entry points

Next came defining the RootGraph interface in the :shared subproject, exposing essential accessors like UiDeps (crucial for UI-related dependencies) and AuthManager (required for the OAuth2 login flow):

interface RootGraph : ViewModelGraph {
    val authManager: AuthManager
    val uiDeps: UiDeps
}

Note that this interface is not annotated with @DependencyGraph, because the actual concrete graphs are platform-specific:

// in :androidApp
@DependencyGraph(scope = AppScope::class)
interface AndroidRootGraph :
    RootGraph,
    MetroAppComponentProviders,
    // exposes deps to the PushService subclass
    PushNotificationComponent,
    // exposes deps for the pull notification Worker
    PullNotificationComponent {

    @Provides
    fun provideContext(): Context = MainApplication.instance
}

// in :desktopApp
@DependencyGraph(scope = AppScope::class)
interface DesktopRootGraph : RootGraph

// in the iosMain source set in :shared
@DependencyGraph(scope = AppScope::class)
interface IosRootGraph : RootGraph

The main @Composable entry point of the application was adapted to accept RootGraph as a parameter.

On the platform sides:

  • Android: MainApplication was updated to invoke the createGraph factory inside onCreate(), while MainActivity was updated to retrieve the root graph reference from the Application instance, use it for the OAuth2 login flow, and pass it down into the Composable tree.
  • JVM (desktop): The main() function was updated to call the createGraph factory and pass the resulting graph down into Composition.
  • iOS: MainViewController was updated to lazily call the createGraph factory (passing it into Composition), and iOSApp was adjusted to access AuthManager via MainViewControllerKt.iosRootGraph for OAuth2 authentication.

Step 3: Migrating core infrastructure

In this phase, all subprojects under the :core group were migrated, focusing primarily on remote API services, local persistence, and native utilities. While I won't detail every single class, a couple of key highlights illustrate the flavor of the migration.

Remote API services relied heavily on assisted injection. The centralized factory, which previously relied on Koin's dynamic runtime parameter resolution based on class types, was refactored to use explicit pattern matching over types.

Before:

@Factory
internal class DefaultAppService(@InjectedParam args: ServiceCreationArgs) : AppService {
    /* ... */
}

@Single
internal class DefaultServiceFactory : ServiceFactory, KoinComponent {
    override fun <T : Any> create(clazz: KClass<T>, args: ServiceCreationArgs): T =
        getKoin().get(clazz = clazz, qualifier = null, parameters = { parametersOf(args) })
}

After:

@AssistedInject
class DefaultAppService(@Assisted args: ServiceCreationArgs) : AppService {
    /* ... */
}

@AssistedFactory
fun interface AppServiceFactory {
    fun create(@Assisted args: ServiceCreationArgs): DefaultAppService
}

@ContributesBinding(AppScope::class)
@Inject
class DefaultServiceFactory(
    private val appServiceFactory: AppServiceFactory,
    /* ... other factories... */
) : ServiceFactory {

    override fun <T : Any> create(clazz: KClass<T>, args: ServiceCreationArgs): T {
        val service = when (clazz) {
            AppService::class -> appServiceFactory.create(args)
            /* ... remaining `when` branches with other service types... */
            else -> throw IllegalArgumentException("Unknown service class: ${clazz.simpleName}")
        }
        return clazz.cast(service)
    }
}

I intentionally refrained from migrating @Single to @SingleIn(AppScope::class) here because doing so would be redundant: ServiceFactory is injected only once in the entire project inside ServiceProvider (which itself is a singleton). Furthermore, even if it were injected elsewhere, leaving it unscoped offers distinct advantages:

  • It is a lightweight, stateless object, so instantiation overhead is negligible;
  • It holds no internal state, eliminating concurrency or interference risks;
  • Unscoped instances can be garbage collected as soon as they go out of scope, rather than lingering permanently in memory.

Tip

As a general rule during this migration, leaving dependencies unscoped was always the preferred default whenever possible.

Here is the exact decision heuristic I used to decide whether to scope a dependency or keep it unscoped:

Is the class a ViewModel?
  ├── YES ──> ✅ Use @Inject + @ContributesIntoMap(AppScope::class) with @ViewModelKey
  └── NO ──> Does it hold in-memory state or flows shared across components?
              ├── YES ──> ✅ USE @Inject + @SingleIn(AppScope::class)
              └── NO ──> Is it heavy or expensive to construct?
                          ├── YES ──> ✅ USE @Inject + @SingleIn(AppScope::class)
                          └── NO ──> ❌ DON'T scope ✅ USE unscoped (just @Inject)

Step 4: Migrating the business logic

This step involved migrating all subprojects in the :domain group, which went smoothly without major surprises. The key detail was preserving the singleton scope (@SingleIn(AppScope::class)) for swipe navigation state while keeping newly provided pagination instances unscoped.

In Raccoon, the domain layer also handles push and pull background notifications on Android. As mentioned in Step 2, the dependencies needed inside our background Worker were previously retrieved via Koin and are now exposed through PullNotificationComponent.

Before:

@KoinWorker
internal class CheckNotificationWorker(
    context: Context,
    parameters: WorkerParameters,
    // injected by the framework
    private val inboxManager: InboxManager,
    private val strings: Strings,
) : CoroutineWorker(context, parameters) {
    /* ... */
}

After:

internal class CheckNotificationWorker(
    context: Context,
    parameters: WorkerParameters
) : CoroutineWorker(context, parameters) {

    private val component: PullNotificationComponent
        get() =
            (applicationContext as? MetroApplication)?.appComponentProviders as? PullNotificationComponent
                ?: error("PullNotificationComponent not found")

    private val inboxManager: InboxManager by lazy { component.inboxManager }

    private val strings: Strings by lazy { component.strings }

    /* ... */
}

Step 5: Migrating all feature modules

This group of modules houses UI and presentation logic. For the UI layer, the UiDeps accessor (providing access to UI helpers like NavigationCoordinator, MainRouter, DrawerCoordinator, ClipboardHelper, etc.) introduced in Step 2 finally came into action.

In the old setup, many of these dependencies were fetched directly inside navigation containers using koinInject(). That pattern is typical of service locators, but in anticipation of the Metro migration, I had already minimized those calls. Now, after retrieving UiDeps from RootGraph, it is passed down through child composables via a CompositionLocal.2

As for presentation logic, ViewModels are the star of the show, and the metrox-viewmodel-compose artifact made migrating them surprisingly straightforward.

Many ViewModels take assisted constructor parameters (e.g., navigation arguments). We already saw how @AssistedInject works in Step 3; the only new requirement here was subclassing ViewModelAssistedFactory and annotating it with @ViewModelAssistedFactoryKey:

@AssistedInject
class LoginViewModel(
    @Assisted args: LoginViewModelArgs,
    /* ... all non-assisted injected params... */
) : ViewModel() {
    /* ... */

    companion object {
        val KEY_ARGS = CreationExtras.Key<LoginViewModelArgs>()
    }
}

@Serializable
data class LoginViewModelArgs(val type: LoginType)

@AssistedFactory
@ViewModelAssistedFactoryKey(LoginViewModel::class)
@ContributesIntoMap(AppScope::class)
interface LoginViewModelFactory : ViewModelAssistedFactory {
    override fun create(extras: CreationExtras): LoginViewModel =
        create(extras[KEY_ARGS] ?: error("ViewModel args not found"))

    fun create(@Assisted args: LoginViewModelArgs): LoginViewModel
}

At the call site, assistedMetroViewModel() serves as a near drop-in replacement for koinViewModel(), with arguments passed through CreationExtras:

val model = assistedMetroViewModel<LoginViewModel>(
    extras = CreationExtras {
        this[KEY_ARGS] = LoginViewModelArgs(loginType.toLoginType())
    },
)

The non-assisted case, on the other hand, is even simpler:3

@ContributesIntoMap(AppScope::class)
@ViewModelKey
@Inject
@OptIn(FlowPreview::class)
class TimelineViewModel(
    /* ... injected params... */
) : ViewModel() {
    /* ... */
}

Where at the composable call site, it boils down to:

val model = metroViewModel<TimelineViewModel>()

Step 6: Final cleanup

This was by far the most satisfying part: stripping out all Koin references, cleaning up the version catalog, and deleting the DI setup helper object where all Koin @Modules were previously registered by hand.

The battle scars: Top technical blockers

Unsurprisingly, when migrating a medium-sized project with 50+ modules and 150+ injected dependencies, not everything worked perfectly on the first attempt. Here is a breakdown of the mistakes I made along the way—along with their symptoms, root causes, and fixes.

1. Multi-module Gradle dependency scope

  • Symptom: Compile-time error ([Metro/MissingBinding]) or, worse, runtime IllegalArgumentException when building :androidApp.
  • Cause: :shared included feature modules using implementation(projects.feature.timeline). In Gradle, implementation isolates transitive dependencies. Metro's @DependencyGraph processor running in :androidApp couldn't discover @ContributesBinding/@ContributesIntoMap in feature modules because they were missing from :androidApp's compilation classpath.
  • Solution: Replace implementation with api in shared/build.gradle.kts for all feature and domain modules.

2. Visibility modifiers and cross-module bindings

  • Symptom: Compile-time error ([Metro/MissingBinding]).
  • Cause: Classes annotated with @ContributesBinding(AppScope::class) were declared as internal. Metro generates internal bindings that the root graph in platform-specific modules ( :androidApp or :desktopApp) cannot access across Gradle module boundaries.
  • Solution: Remove the internal visibility modifier from @ContributesBinding-annotated classes.

3. ViewModel multi-bindings with multiple supertypes

  • Symptom: Compile error: @ContributesIntoMap-annotated class doesn't declare an explicit binding type but has multiple supertypes.
  • Cause: ViewModels that extended ViewModel() and also implemented MVI contract interfaces had multiple supertypes, creating ambiguity for Metro's implicit supertype resolution.
  • Solution: Explicitly pass the binding parameter and use @ViewModelKey inside the generic type argument e.g. binding<@ViewModelKey ViewModel>() (leaving the interred class key).

4. Swift / KMP Framework export boundaries

Last but not least, a KotlinNativeTarget configuration issue!

  • Symptom: Swift compilation error: cannot find 'X' in scope or type 'MainViewControllerKt' has no member X.
  • Cause: Swift only exposes types from packages explicitly listed in the framework's export declaration. Top-level functions in non-exported files or Kotlin objects outside exported packages are omitted from the generated Swift header.
  • Solution: Ensure the KMP framework is configured with the correct export(...) directives and that :shared exposes its transitive dependencies via api ( see Blocker 1).

  1. The dev.zacsweers.metro:metrox-android dependency was added manually to the :androidApp module. ↩

  2. This was also a great opportunity to refactor our CompositionLocal providers and streamline the "startup ceremony" required for UI components (making UI tests and Compose previews much cleaner). ↩

  3. In my case, it was slightly more involved because in my MVI setup all ViewModels also implement another interface, and the UI refers to them through that interface type. See Blocker 3 for a more detailed explanation. ↩

Koin is back, b*tches!

If you remember, last summer I wrote a technical article about DI and why our Raccoon apps, which had always used Koin from the beginning in 2023, were both migrated to Kodein at the end of 2024.

TL;DR

There was an issue with the 2.0.0-Beta version of Koin Annotation which broke reproducible builds, which blocked releases on F-Droid. I reported the issue but it remained unanswered and unnoticed for months.

An unwanted Christmas present

A year and a half later, I still do not have good memories of that migration. I completed it around the last week of December, and it was a bitter Christmas present. It just felt "wrong" in many ways.

In the first place, Kodein was not on the same level of completeness, and I had to manually write some missing connectors, e.g. for lifecycle-viewmodel integration. Secondly, I had to switch from a powerful annotation set to define bindings to manual wiring, just as with Koin's classic DSL.

So I was left with all the advantages and disadvantages of a classic DSL-based service locator (which apply both to Kodein and to Koin with the classic DSL):

Feature
Multiplatform support ✅
Flexibility (definition and call site) ✅
Conciseness ❌
Compile-time validation ❌
Performance ❌

Yes, it is true that this works seamlessly across all supported platforms (even if, as usual, the iOS build was more complicated). It is also easy to integrate and flexible (perhaps excessively so, considering it's possible to retrieve a dependency at whatever level of the architecture). On the downside, manual wiring via DSL implies boilerplate, there is no compile time safety so missing bindings result in runtime crashes and, last but not least, there's some overhead since resolution only happens at runtime.

I could accept this as a temporary tradeoff in order to have reproducible builds back, but I was keeping an eye on the Koin library evolution, waiting for the right moment to come back. And, as of 2026, that moment seems to have finally arrived.

"it is with great pleasure to inform you" frog meme

But, this time, it's done the "right way".

Why a change was needed

Technical considerations

As anticipated, there were some compromises. I had to accept maintaining a fair amount of boilerplate (scattered along 50+ module definitions per project), because no matter how well-thought DSLs are, you still have to manually wire components, i.e.:

  • Scoping: define the lifetime of each component and scope it correctly;
  • Binding: provide implementors using the qualifiers provided by the framework (typically a combination of type and names/tags);
  • Assisted injection: deal with the cases when some constructor parameters are dynamically passed at runtime, while some others are provided by the framework itself.

I also had to give up on safety, i.e. not get compile time errors if the DI setup is incorrect, e.g.:

  • Missing bindings: some dependency is used somewhere, but it is defined nowhere;
  • Conflicting bindings: there are multiple dependency definitions with the same qualifiers, leading to resolution ambiguity;
  • Cycles: the DI graph needs to be acyclic, it is not possible to have direct or indirect recursion, i.e. it is not possible to require in order to instantiate a component anything requiring the component itself (potentially across multiple indirection levels)

Finally, there were some performance tradeoffs I had to accept: using the service locator pattern implies that dependencies can only be resolved at runtime, with some inevitable overhead.

While these issues affect both Kodein and Koin, the latter has been maturing year after year and is going towards a different direction, whereas the former has been stalling. Which leads towards a second set of reasons why I was considering a change.

Political considerations

There were increasingly worrying signals about how the KOSI project is maintained, detailed in the public Manifesto.

  • Development Bottleneck: core maintainers operate as a commercial agency and explicitly shifted focus, reducing their open-source bandwidth;
  • Ecosystem Stagnation: Secondary projects have been frozen rather than community-delegated.1
  • Strict Governance: the organization acts as a closed ecosystem to protect the corporate brand. Community pull requests are accepted only in isolation, and the project does not onboard independent co-maintainers (applications are denied in spite of workforce being needed to implement feature requests).
  • Architectural Stalling: The project maintains an intentional distance from modern compilation features, such as compile-time verification.

Salomon Brys, one of the two core maintainers, publicly stated:

we believe that compile-time verification is way to [sic] restrictive and leads to a lot of complications when we want to provide flexibility

This sets them apart from the current industry trend towards increased compile-time safety.

The migration steps

In our Code of Conduct, the last bit says "Never give up". Even when this implies taking difficult decisions. Every technical challenge is an opportunity for improvement, experimenting and having fun.

Sometimes development, like life, moves in spirals rather than in a straight line and in order to advance further a phase when it seems like you are going backwards may be required.

Tip

In order to better understand the following, remember that DI is like a two-side coin (koin?):

  • Definition Site: where each component is scoped and bound;
  • Call Site: where a component is required and accessed in client code.

The first step was migrating back from Kodein to the initial "classic DSL" Koin, where each use case had almost a one-to-one equivalent2 at least at the definition site.

Secondly, I worked on cleaning up all the cruft, i.e. removing the "glue code" I had to write to make up for what Kodein was lacking (Compose integration, ViewModel integration etc.), which mostly was on the call site.

In the third place I migrated from the "classic DSL" to the new Compiler Plugin (KCP) DSL at the definition site, which already added compile-time validation and performance: all dependencies were determined and validated at compile time.

As a fourth step, again at the definition site, I migrated to annotations, in order to remove all boilerplate and leverage component scanning and automatic wiring (e.g. when a class implements only an interface the binding is automatically done).

Final outcome

The result has the best of both worlds: the flexibility of a service locator, the safety of a full-fledged DI framework.

Feature
Multiplatform support ✅
Flexibility ✅
Conciseness ✅
Compile-time validation ✅
Performance ✅

Moreover, Koin has established itself as the leading framework for DI on KMP. It has extensive documentation and tooling, and it is backed by an active group of users and maintainers and has grown over time, showing maturity and openness towards community feedback.

Lessons learned

I tend to be open-minded towards tools and I chose to use libraries like Koin and Kodein no matter how much they were frowned upon by my colleagues as professional Android developers (accustomed to Dagger/Hilt safety and power). However, I remember having thought my coworkers were probably right when the reproducibility problem hit.

I remember how angry I was when one single library was acting as a road blocker and preventing updates from being distributed on app stores, and I swore I would never go back to Koin in my life.

But eventually I appreciated the commitment of Arnaud and his team; I listened to him in person at KotlinConf some year ago, which was really impressive. I was really grateful for their listening to community feedback, abandoning the old KSP-based approach in favor of the new KCP.

Changing one's mind is a sign of intelligence and maturity, and I was genuinely happy to change my mind with this respect.

You can consider this the end, for now, of my concerns about DI in Raccoon.3


  1. For example, the official Kodein-DB repository has been explicitly labeled as a "Project paused" with the caveat that it would not be maintained in its current form. ↩

  2. Except that Kodein has three ways of defining bindings: singleton (single instance), provider (new instance each time, no arguments) and factory (new instance each time with assisted arguments); whereas Koin has only two: single and factory (with or without arguments). ↩

  3. As told in the previous article, Metro looks great, but I am still evaluating it, considering both technical features and the way is maintained. ↩

Multi-module architecture and shared build logic

Raccoon apps are Gradle projects, meaning that all tasks (compilation, verification, packaging) are orchestrated by a tool called Gradle. In this article we will explore the relationships between build system and project structure, discuss why some architectural choices are better and how to configure builds so that the tool works for you (and not vice versa).

What is Gradle?

Gradle is a general-purpose tool for projects written in Java, C++, Scala, JavaScript, Groovy and Kotlin or any combination thereof. It is an extremely powerful tool, suited for enterprise-scale projects (backend or full stack applications, Android apps, KMP apps and so on). It is designed for extensibility, with a wide range or third party plugins which make it very flexible and adaptable, at the cost of a quite steep learning curve. Setting it up correctly is just the first step, being able to analyze build traces, identify errors and fix them indeed takes some years of seniority.

Nonetheless, Gradle is still the de facto standard for Android and KMP projects as of 2026, even if there are alternatives. For example the Kotlin toolchain, backed by JetBrains itself, is very promising and, personally, I foresee that in the future the community will gravitate towards leaner and more specialized tools like that.

Info

Gradle is deeply integrated into the Android ecosystem (e.g. with AGP), so it will likely remain the requirement for Android-specific packaging in the foreseeable future.

Build Nightmares

If Gradle is not configured properly, there are several downsides. The most obvious one is a build failure, which can give some headaches but at least you realize immediately that something is wrong.

But there are more subtle ones: for example its performance may degrate significantly, and as the project grows you end up wasting more time waiting for builds to finish rather than actually working on the project.

Or, which is worse to me, your build config becomes unmaintainable. As technology evolves or plugins are added, you end up with lots of interconnected yet sparse build scripts and config files scattered in your source tree. Each new change may be incompatible with other ones and break everything at the least expected moment in an endless dependency nightmare.

Now, let's move into the solution space. To control build times, the most important thing is to avoid running unnecessary steps and caching results from already performed steps to reuse them in subsequent builds. This is a wide topic and would require several chapters to properly discuss it.

However, the key idea is that building a project is a sequence of interconnected tasks, where the output of each one may be the input for another one. This concept in CS is commonly referred to as a "pipeline", and all built tools from the early days work in this way (GNU Make, Apache ant, Maven, etc.).

Since tasks are deterministic, if all the inputs of a task are unchanged, its output will be unchanged too so it may be skipped. So, for example, if the sources of a specific compilation unit are not changed from a previous run, the corresponding binary does not need to be recreated. But sources are not the only input of the build step of a given unit, it may also depend on the APIs of other units and if those are changed the current one is affected too.

The key to avoid unnecessary passes is to isolate the project in discrete components with well-defined rules about which units depend on which other ones in order to minimize coupling and, therefore, the amounts of rebuilds whenever changes occur.

Modularization

In Gradle projects, this boils down modularization strategies. Every Gradle project consists of a root project, i.e. the container project which results in the main artifact when the build is completed (this may be a .ear, .war, .jar, .apk or whatever package depending on the applied plugins) and one or more subprojects whose build outputs are assembled into the main artifact. So, the assemble task of the root projects depends on the output of the assemble task of each subproject.

"Depending" in this sense has two negative consequences affecting build times:

  • the dependant task needs to wait until all its dependees have completed;
  • every time a dependency changes, the dependant is invalidated and needs to rerun.

Building each subproject can happen in parallel, so having more than one is usually beneficial in modern multicore architectures. However, if there are dependencies between subprojects, the two aforementioned consequences for each dependant apply.

If subprojects are seen as nodes and a dependency is a directed edge pointing from the dependant to the dependee, Gradle projects have the form of a DAG, implying that all builds end sooner or later (no cycles).

But some configurations are worse than others for performances. Let's consider the following configuration for example:

stateDiagram-v2
    [*] --> C
    [*] --> D
    C --> B
    B --> A
Diagram illustrating the "chain" configuration.

where we see that for the build to complete C and D are needed, but C needs B to complete, and B needs A to complete in turn. This means that A, B and C can not be run in parallel and that each change in A affects B, C and the overall result.

Let's also consider the following scenario:

stateDiagram-v2
    [*] --> B
    [*] --> C
    [*] --> D
    B --> A
    C --> A
    D --> A
Diagram illustrating the "fan-out" configuration.

Here we successfully decoupled B, C and D so that they can run in parallel, which is good. But they all depend on A so that each change in A determine that every other module is affected and must be recompiled.

Modularizing in the correct way allows to minimize the occurrences of situations like these. There is a consensus among mobile developers that modularization by feature is the best one to avoid unwanted entanglements between different parts of the app, rather than isolate modules by layer.

However, each project has its needs and each developer has their opinion, so a balance needs to be found. In Raccoon, I decided to divide projects in three kinds:

  • core modules: reusable pieces of software that provide the foundational layer for all other ones. Each of them should not depend on any other module ideally, but if it really has to, it can only be on some special core modules with the least amount of outgoing edges (e.g. :core:di, :core:l10n or :core:preferences). They are divided logically by the functionality they provide (except :core:utils).
  • domain modules: contain the model and business logic and are roughly divided by area, e.g. the classes related to identity, the ones related to remote contents, and so on. Here the criterion is more layer-centered than feature-centered (e.g. content-related data models are in a single module) but for practical reasons, because application logic and domain models are not changing so frequently in this kind of project. Domain modules can depend only on core modules.
  • feature modules: contain the presentation logic and they are strictly divided by feature. As a matter of fact, there is almost a 1:1 relationship between each subproject and each app screen. Feature modules can depend on core and domain modules, but not on any other feature.1

Convention plugins

Splitting a project into multiple subprojects comes with some configuration overhead. Each subproject needs its build script with its applied plugins, its dependencies, its configuration. Some parts of the build scripts are common to all modules, some are "more equal" than others (e.g. all modules having a UI need the Compose compiler plugin applied and include Compose dependencies), etc.

For external library versions, there is an out-of-the-box solution already offered by Gradle: Version Catalogs which allow to centralize in a single source of truth all the dependency versions.

The solution I decided to adopt for common configuration is known as Convention Plugins, i.e. isolating the repeated bits of build configuration – e.g. applying some Gradle plugins or including some dependencies which always go together or configure a plugin in some way e.g. for running tests.

This way it is simpler to make changes, because you have to only change the definition of the plugin to automatically adapt dozens of subproject in a row; which was extremely beneficial in situations like the AGP 9.x migration.

The main custom plugins I wrote are:

  • com.livefast.eattrash.kotlinMultiplatform to be applied in all modules to apply and configure the org.jetbrains.kotlin.multiplatform and com.android.kotlin.multiplatform.library plugins;
  • com.livefast.eattrash.composeMultiplatform to be applied in all UI related modules to apply and configure the org.jetbrains.compose and org.jetbrains.kotlin.plugin.compose plugins;
  • com.livefast.eattrash.test and com.livefast.eattrash.uiTest to configure host tests and device tests.

Wrap-up

I consider that the ability to spot and avoid pitfalls is crucial in order to maintain a high quality of developer experience, not only user experience; and working on Raccoon one has been a great training ground which allowed me to become a better developer for my work IRL.

Identify common parts (even in configuration, not only in logic) and factor them out to reusable pieces of code is an application of the DRY principle; it saves a lot of time which can be dedicated to work on more valuable activities.

Plus, it taught me not to "fear" Gradle and better understand the tools I use in my everyday life, even at work and not only in side projects.

Question

What is your experience with Gradle? Did you come to similar solutions or do you have futher recommendations? Let me know!


  1. This requires some careful design for navigation, because navigation between screens must be totally decoupled. In Raccoon I used a :core:navigation module and abstracted away the underlying navigation system (I actually migrated from Voyager to Compose Navigation and I am willing to migrate to Navigation 3 soon). ↩

Dealing with KMP’s Shifting Sands: Another Day, Another Project Structure

Hurray! A few days ago, JetBrains's blog published a new post highlighting the new default structure for KMP projects, which goes hand in hand with the migration to AGP 9.x.

This piece of news hit me last Friday evening. I had just left the office after a long, tiring work week. I got into my car to drive home and opened YouTube, hoping to listen to a relaxing talk during my commutetime… and voilà! There was Márton Braun uttering the frightening words: «We are introducing a new default structure for KMP projects…»

And so there I was — suffering from framework-induced PTSD, nervously blinking and hysterically chuckling alone in my car. I remembered how it took me several days to get my local builds for all platforms, CI workflows, and integrations (Codecov and Weblate, just to mention a couple) up and running correctly.

How on earth is the recommended structure changing again?!

Backstory: breaking changes in AGP 9.0

To fully understand my despair, a little bit of background is needed.

Earlier this year, I migrated from Gradle 8.14.x to Gradle 9.1, and from AGP 8.11 to AGP 9.0.0.

For KMP projects like mine, this meant it was no longer possible to apply the org.jetbrains.kotlin.multiplatform Gradle plugin alongside the com.android.library or com.android.application plugin in the same module.1

Instead, the newcom.android.kotlin.multiplatform.library plugin was introduced. Now, all shared modules must use the new KMP library plugin, while the main Android entry point has to use com.android.application alone.

The previous approach — where all entry points (except iOS which was in a separate folder in order to be opened as an Xcode project) coexisted in a single composeApp module — was no longer viable.

Therefore, I had to create a shared library module, making the Android application a separate module entirely. Ironically enough, this feels a lot like the standard structure we had back in 2023.

I felt relatively lucky because only my Raccoon for Friendica app used the structure they recommended after 2023. My Raccoon for Lemmy app still had a shared module and a separate androidApp module.

But the story doesn't end there.

Ripple effect: broken tests and displaced resources

AGP 9.x also fundamentally changes the source set structure for common unit tests (running on the host system) and instrumented tests (running on a device/simulator).

In my case, I used convention plugins, meaning I apply configurations via the Gradle Kotlin DSL and various plugin DSLs rather than doing it manually.

Trying to figure this out with incredibly scarce and scattered documentation on how to do it properly was a nightmare (and I had to fix it (e.g. here and here).

Not to mention that all Compose resources such as string translations had to be moved from the composeApp mobile module to the shared module, which completely broke my crowdsourced translations on Weblate.

A love-hate relationship

I am truly caught in a love-hate relationship with KMP right now. More than ever, it feels like a brittle foundation to build a long-term project upon.

«Why are you complaining?» you might ask. «After all, early-adoption has always been like this.» And you're right. In the early years, this chaos was expected. Every new technology needs time to settle down until the community reaches a consensus on best practices and shared conventions.

But KMP has been around for quite some time now, and it is supposed to be stable! Yet here we are, and it feels like they introduce breaking changes every six months.

Looking ahead

So here I am once again trying to follow their new standard as closely as possible. I'm doing this in the desperate hope of minimizing the chances of everything melting down with the next release of the Kotlin compiler, KSP, Gradle, AGP, Compose, or whatever else updates next.

At the very least, I’m hoping that by strictly adhering to these recommendations now, the next migration will be less painful. It feels like offering sacrifices to a vengeful deity, praying that my compliance today will earn me a little more mercy when the next storm hits.

As a conclusion, I hope you allow me this little rant. Also, please wish me luck—because as of right now, I have no idea if my iOS build is actually working, and I won't know if any of my release workflows are truly safe until my next beta deployment.


  1. A note on terminology: in the rest of this article, the word "module" is used as a synonym of subproject for brevity, even though in the Gradle lingo the most correct term is "subproject". ↩

Why I decided to migrate away from the Ktorfit library

Why maintaining an open source app is hard…

Maintaining open source projects is not for the faint of heart. Beyond technical competence it requires commitment and dedication, being able to organize and plan time carefully not only to deal with actual implementations but also for triaging user requests/reports, managing public relationships, reviewing external contributions, organizing internal workforce, etc.

It can be daunting, especially for projects maintained by few people (or just one person) in their spare time, considering we all have our regular jobs, families, hobbies, etc. Working on an application other people use in their day by day can be a great responsibility because final users depend on what you do or won't do.

… and a KMP lib is harder

Maintaining an open source library is even harder, because you are providing the building blocks on which third party apps rely, so the exposure to end-users scales up exponentially, responsibilities are much greater and so is the amount of stress maintainers are subjected to.

Kotlin Multiplatform is highly dynamic and ever-evolving ecosystem, where maintaining a library is almost a nightmare: not only the underlying platforms behind each target evolve continuously and independently ‒ JVM, native (macOS / iOS / WatchOS / TvOS / Linux / Android / MinGW) Wasm, JavaScript ‒ but also each new release of the Kotlin compiler can break things, considering JetBrains hasn't stabilized yet the APIs for compiler plugins, so each new version is potentially breaking compatibility.

Beyond the K2 compiler, the same is valid for KSP (Kotlin symbol processor) which has a release cycle close (but not 100% aligned) with the one of the compiler, with every new version potentially requiring adjustments.

Development previews and Release Candidates for both the compiler and the symbol processors are made available for the community before a new stable release becomes public, in order to give both app developers and library maintainers the time to test and adapt; nonetheless it should not be taken for granted that an open source project (especially if maintained by a small group of volunteers) do so, considering there may be other internal priorities when the new version of an external build tool is released.

Our experience

Initial dilemma and a promised solution

With this in mind, let us consider what happened during the last weeks in our apps. Both the Raccoon apps are using Ktor for networking, due to its excellent flexibility and multiplatform support.

But I was used to Retrofit to write endpoint contracts and abstract away how network calls are performed. Using annotations to define service specifications and rely on code generation to create an implementation which internally calls an HTTP client is convenient and can save writing a considerable amount of boilerplate.

Unfortunately, Retrofit is not available on KMP, so there are two alternatives:

  1. use just the Ktor client, which has a nice DSL to configure the base HTTP client as well as each individual request, plus it offers support for authentication, content negotiation (with several serialization options), logging, etc.
  2. use a library like Ktorfit, which is very similar to Retrofit and offers a familiar set of annotations plus all the advantages of code generation.

I wanted to experiment, so in 2023 chose solution 2, and for the Lemmy app there were no issues using Ktorfit in the beginning.

End of the honeymoon

When in 2024 I started working on the Friendica/Mastodon app, some headaches arrived because in some Mastodon APIs for pagination you need to access both the body and the headers of responses. Doing so requires method signatures to change in service definition (and you have to add an additional converter). Having to deal with Response<T> instead of T as a return type started to feel like the boilerplate I wanted to avoid, but I could use it just where it was needed, so overall it was fine.

When KSP 2 reached the stable stage and became the default in April 2025, both apps broke because implicit inference of dependencies between Gradle tasks (and both the Android and iOS compile tasks depended on kspCommonMainKotlinMetadata) changed. But, again, I adapted my setup after spending some time figuring out how to deal with this issue (not documented), and I still thought the benefits from using Ktorfit outnumbered the disadvantages.

Final breakup

The breakup arrived at the end of June 2025, when Kotlin 2.2.0 and KSP 2.2.0-2.0.2 were released. The library broke again, it was not possible to update the Kotlin Multiplatform plugin, which made it risky to upgrade the Gradle distribution and the Android Gradle Plugin (see here for more compatibility details). As if it wasn't enough, I had recently adopted ViewModels from AndroidX lifecycle library, and an incompatibility between the version of the library available for KMP and the AGP version I was stuck on made the Android lint crash ( see here), so my CI pipelines were broken and I had to skip a beta release.

Someone had already filed an issue to Ktorfit maintainers, but the days passed and the fix, even if a contributor had submitted a solution already, was not being merged. People started asking for updates (which is understandable considering the amount of issues I found myself in too) and I took part in the discussion wondering whether this KSP release was the gravestone for the library and switching to plain Ktor (i.e. solution 1) would allow me to escape the impasse. If there were no plans to release a new version in the short term, which is understandable too because the team could have other priorities, it could be a viable option.

And here the hell started. A well known personality in the Compose and Kotlin world, who was not even a direct contributor of the project, replied to me

Quote

Quit complaining, and constantly asking for updates. If you want to write all of the service definitions yourself, then go ahead!

and then, when someone intervened to try and make the tones settle down a little bit, replied angrily

Quote

Are you paying for support? Are you paying for maintenance? I very much doubt it, therefore you have zero right to expect anything.

And yes, at that point enough was enough. I started working on this and this to completely remove Ktorfit from all Procyon projects. The result was quite neat:

  • with dependency injection, you still decouple the service clients and the service implementations;
  • Ktor has a nice DSL to configure request (both centrally and individually).

Considering the unwelcoming community around it, I don't think I'll revert the decision, use Ktorfit again in other projects, submit new report or collaborate with them in any way in the future.

Lessons learned

What lessons did I learn from all this? First of all, as already discussed multiple times here, that choosing wisely your libraries can really make the difference. And by "wisely" I do not only mean that they implement the features they are made for, but also that they are well-maintained, updated regularly and with a positive and supportive community around them. Codes of conduct for open source projects are there for a reason, we can do better than proprietary alternatives, we believe that community in itself is a value and our words / actions reflect this principle.

Secondly, I had to review my opinion on "very important people" in software development. One can have a high degree, a great career, be an active and renowned member of the open source movement and still behave rudely to others, scare away contributors, dismiss arrogantly bug reports, etc.

I still think highly of the person who argued with me: from a technical point of view he is on a level I will probably never reach. But we are humans, we have feelings, we already live in a harsh world and we should try to be kind to each other, especially with those who are not our enemies.

I think replying in that way to someone, without knowing anything of what they are going through IRL and without trying to understand the reasons of their behaviour, shows you have little empathy and — quoting what I've been told when I said I would switch away — «puts you in a bad light».

Hold my state: why shared ViewModels are a life-changer

This week's technical post is going to deal with state holders and the major overhaul which Raccoon apps have undergone here and here concerning how state is retained and managed.

Before deep-diving in the technical questions and project history, let's start by making it clear what domain we are in with some basic terminology, what is the issue we are trying to solve and why this matters for app development in general and specifically for KMP.

What is this all about?

We are dealing with the relationships between the UI — i.e. that part of the software which has the responsibility of rendering graphical elements on the screen to convey some information to users and collect user input — (generally called «View»), the business logic objects which represent the entities being handled (called «Model») and the presentational component which stands in between the View and the Model and coordinates the interaction between them.

This intermediate component is called in different way depending on the architectural pattern followed by developers, it can be called «Controller» in the Model View Controller (MVC) architecture, «Presenter» in the Model View Presenter (MVP) one, «ViewModel» in Model View ViewModel (MVVM), and so on. What is common to all of them is that this "middleman" is responsible for:

  • interacting with the View to present information;
  • collecting user input from the View in order to perform changes in the Model.

How much and how each of these tasks is achieved and the relationships with the other two elements of the triangle are different in MVC, MVP or MVVM, but this is outside the scope of this article.

Concerning mobile development, on iOS the MVC pattern has been the standard for many years, it was recommended one by Apple and it was an integral part of UIKit (the framework used to create UI on iOS apps before the advent of SwiftUI). On Android, on the other side, the most widely accepted pattern in modern apps is MVVM (coming built-in with AndroidX libraries and recommended by Google).

What about Kotlin Multiplatform?

KMP apps can have either of these "share strategies":

  1. The business logic (all or parts of it) are shared across platforms, which corresponds to having a shared Model but native Views;
  2. All is shared (like we do in Raccoon apps) so Model, View and ViewModel are all shared (and the only native parts are the components dealing at the lowest level with hardware features such as the camera, Bluetooth, gallery, sharing data with other processes, playing videos, etc.).

While the first option leaves developers with the choice to share or not the ViewModel and, if they decide to do so, handle the interaction on iOS with the native SwitfUI View (see, about this, the KMP-ObservableViewModel project which has a brilliant solution for this issue), the second one requires to have a way to create components to hold the screen state, observe it in the Compose UI and properly manage their lifecycle (e.g. cancelling pending asynchronous operations when the portion of UI they are tied to goes off screen, e.g. due to back navigation).

Early stages: 3rd party solutions

Initially, when working with Compose Multiplatform, there was no official solution for ViewModels and the only available solution were third-party libraries like Voyager. The latter, besides offering a comprehensive navigation library, allows you to define not only your Screens but also the ScreenModels, bind them so that their lifecycle is tied together, automatically managing their creation and disposal.

Going this way has a lot of positive aspects, such as:

  • easy setup, especially if compared to other popular libraries in the KMP environment such as Decompose;
  • pragmatic approach and thorough documentation (synthetic but extensive);
  • it is well integrated with popular frameworks for dependency injection both native (Hilt) and multiplatform (Koin, Kodein) and reactive programming;
  • the library works well and it does what it promises and many more things (animated transitions, etc.).

But, on the other hand, it has also several downsides:

  • obtrusiveness: it forces you to follow their design choices (some of which are not Compose-idiomatic, for example screens are class instances and not functions);
  • state restoration does not work with non-primitive constructor parameters (and even if screens are classes, you should resist at any cost the temptation of adding any instance variables, otherwise expect runtime crashes whenever the lifecycle leaves the STARTED state);
  • no support for predictive back gesture on Android and weird way to intercept back navigation (using the onBackPressed callback of the root Navigator which acts globally and not on a per-screen basis, unlike the regular BackHandler Composable);
  • deep link support: if sort of works even though with some workaround (which is crucial in Raccoon apps to support integration with Mastodon Redirect) but it feels fragile;
  • the transition artifact works but does not play well in all scenarios (I've had multiple problems, e.g. with the Kodein + Compose combo);
  • once you start using the library, you find yourself more and more tied to it because everything must be done in "their way", even at the cost of reduplicating existing components (such as using BottomSheetNavigator where Compose has ModalBottomSheet, etc.) which is needless and makes your project diverge more and more from mainstream.

AndroidX ViewModels to the rescue

This situation changed drastically once for all in May 2024 when JetBrains's port of the popular org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose library hit the stable distribution. When you add this dependency, suddenly you have AndroidX's ViewModels available with all the same constructs every native Android developer is familiar with. For example, you can configure their instantiation them with a ViewModelProvider.Factory and, if you add the org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate library, you can also rely on SavedStateHandle to be able to retain values even across death and recreation.

Here is a simple example of how you can setup a ViewModelProvider.Factory to retrieve instances with Koin's DI and pass custom parameters with assisted injection

val VM_ARG_KEY = object : CreationExtras.Key<ViewModelCreationArgs> {}

class CustomViewModelFactory(private val injector: DI) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: KClass<T>, extras: CreationExtras): T {
        val argument = extras[VM_ARG_KEY]
        if (argument != null) {
            val model by injector.instance<ViewModelCreationArgs, ViewModel>(
                tag = modelClass.simpleName,
                arg = argument,
            )
            return modelClass.cast(model)
        }

        val model by injector.instance<ViewModel>(tag = modelClass.simpleName)
        return modelClass.cast(model)
    }
}

@Composable
inline fun <reified T : ViewModel> getViewModel(arg: ViewModelCreationArgs? = null): T {
    val factory by localDI().instance<ViewModelProvider.Factory>()
    return viewModel(
        factory = factory,
        extras = MutableCreationExtras().apply {
            if (arg != null) {
                set(VM_ARG_KEY, arg)
            }
        },
    )
}

Our experience

Raccoon for Lemmy was born in 2023 with Voyager, because back then it was the best solution in terms of tradeoff between functionality, flexibility and ease of use. I had tried other alternatives in the past such as Precompose or Decompose but in all other solutions the disadvantages outnumbered the pros.

When I started working on Raccoon for Friendica in 2024, even if AndroidX ViewModels were already stable, I chose to continue with Voyager because I was already familiar with it and my idea was to create a proof-of-concept of a Friendica client as soon as possible so there wasn't much room for experimentation during project setup.

The situation changed in 2025, when I had grown more and more dissatisfied with Voyager and wanted to give a try to a more standard solution, considering common ViewModel is the recommended solution by JetBrains for KMP apps.

I was stunningly surprised as how easy it was to migrate away from Voyager's ScreenModels and even get rid of the DI integration (which give me more flexibility if I decide to change in the future my DI framework).

But there is more, actually: this was the first step to completely remove the Voyager library (see here and here) and migrate towards more standard solutions such as AndroidX navigation which again matches more closely JetBrain's recommendations.

One more time, being able to embrace change and being open minded towards technology evolution has made me a better software architect and resulted in a more solid app for end users (since making these changes was a great opportunity for refactoring and cleanup).

Question

What do you think of navigation libraries on Compose or Compose Multiplatform? Have you ever tried Voyager, Decompose or Precompose and want to share your experience? Let us know!

Koin VS Kodein: a developer's journey through multiplatform DI hell

Dependency injection (or DI) has been the backbone of Android development for years, bringing flexibility, easier refactoring, and proper lifecycle management through decoupling and abstraction. But when you venture into Kotlin Multiplatform (KMP) territory, the comfortable world of Dagger and Hilt suddenly becomes unavailable.

This is the story of my two-year journey building Raccoon for Lemmy and Raccoon for Friendica, and how I learned the hard way that not all DI solutions are created equal.

The multiplatform DI dilemma

Why Dagger & Hilt don't work

The gold standards of Android DI face fundamental barriers in KMP:

  • Java dependency: both rely heavily on generated Java code, making them incompatible with native platforms like iOS;
  • KAPT legacy: until late 2023 (versions < 2.49), Dagger was tightly coupled with KAPT (Kotlin Annotation Processing Tool), while KMP uses KSP (Kotlin Symbol Processor).

Enter the alternatives

With traditional solutions off the table, the KMP community turned to alternatives. Koin emerged as a popular choice, with its lead developer Arnaud Giuliani making bold claims about multiplatform compatibility since 2021.

But here's the thing: Koin isn't actually a proper DI framework — it's a service locator. This distinction matters more than you might think. As a matter of fact, a lot of experienced Android developers do not consider Koin worth to be used large-scaled industry-level projects (as a rule of thumb, the greater the seniority the more "toy tools" are frowned upon). But why trusting blindly other people's prejudices, when you can try things directly?

Round 1: Koin (no annotations)

The setup

When I started Raccoon for Lemmy in 2023, Koin seemed like the logical choice:

  • Strong multiplatform support claims;
  • Integration with Voyager navigation library;
  • Simplified ViewModel injection and lifecycle management.

I went with the manual module definition approach, fully aware of the risks that NoBeanDefFoundException was just around the corner if I misconfigured the DI or forgot to define a binding.

A reality check

The good:

  • Project completed successfully;
  • Apps published on Google Play and F-Droid;
  • Complex interface-to-implementation binding in platform-specific source sets worked.

Concerning the last point, I was also able to handle complex scenarios where you have an interface in the commonMain source set and you want to bind it to different implementations in platform-specific source sets.

The idea was to simply have the native module as an expect val and several actual modules with native bindings (see example below).

The bad:

  • No compile-time validation: NoBeanDefFoundException lurking around every corner;
  • A lot of boilerplate code to define modules manually.

The lack of compile-time safety became increasingly problematic as the project grew. Unlike Dagger, where DI errors prevent compilation, Koin happily lets you ship broken apps if you forget a binding or including some module (it happened, for example here).

Round 2: Koin-Annotations

The promise

When starting Raccoon for Friendica, I decided to challenge myself with Koin-Annotations, lured by promises of:

  • Compile-time validation;
  • Reduced boilerplate;
  • Industry-ready reliability.

An implementation nightmare

KSP configuration hell

Setting up KSP in a multi-module KMP project was not so easy:

  • Documentation was sparse (mid-2024)
  • Trial and error for days
  • Multiple modules made everything trickier, but I eventually created a Gradle convention plugin to properly configure all subprojects.
Platform-specific bindings

The elegant solution for platform-specific implementations became a little more convoluted:

Before (manual):

// in commonMain source set
interface SomeInterface {
    fun someFunction()
}

expect val nativeModule: Module

// in platform-specific source sets
class SomeImplementation : SomeInterface {
    override fun someFunction() = Unit
}

actual val nativeModule = module {
    single<SomeInterface> {
        SomeImplementation()
    }
}

After (with annotations):

// in commonMain source set
interface SomeInterface {
    fun someFunction()
}

@Single
expect class SomeImplementation : SomeInterface {
    override fun someFunction()
}

@Module
expect class SomeModule

// in platform-specific source sets
@Single
actual class SomeImplementation : SomeInterface {
    override fun someFunction() = Unit
}

@Module
@ComponentScan
actual class SomeModule

This required the experimental -Xexpected-actual-classes compiler argument and significantly more boilerplate (notice the repeated @Single scopes, the empty method body in the expect class, etc.).

Nonetheless, I rolled up my sleeves and embarked in the journey:

  • ~200 DI bindings per app
  • Multiple Gradle subprojects
  • Several weeks of intensive work (in my spare time)

The F-Droid catastrophe

Just when I thought the migration was successful, disaster struck. The Lemmy app, which had been successfully building on F-Droid, suddenly failed their reproducible build requirements.

The root cause

Koin-Annotations generates metadata classes with time-based hashes that change on every compilation. This breaks reproducible builds — a critical requirement for F-Droid's security policies.

The maintainer response

I opened an issue explaining the problem. The response was disappointing:

  • No acknowledgment of the issue;
  • Similar issues remain unaddressed after 6+ months;
  • Arrogant dismissal (to the other developer reporting the same issue I encountered): lead maintainer doesn't understand why this is an issue (see here).

For an open-source project where F-Droid represented my main user base, this was unacceptable.

Round 3: Kodein to the rescue

Why Kodein?

Frustrated with Koin's maintainership and technical issues, I looked for alternatives. Kodein caught my attention:

  • "Painless dependency injection" tagline (painkillers were exactly what I needed);
  • Integration with Voyager navigation;
  • No pretentious marketing claims: it was honest about what it is and isn't.

The migration experience

What I expected

Another painful multi-week migration process.

What I got
  • Clear, comprehensive documentation
  • Practical examples
  • Straightforward integration
  • Excellent Compose multiplatform support
Technical considerations

The only "tricky" part was Android Context binding (Koin had dedicated constructs for this):

// in commonMain source set
fun initDi(additionalBuilder: DI.Builder.() -> Unit = {}) {
    RootDI.di =
        DI {
            additionalBuilder()
            // rest of module imports and/or definitions
        }
}

// in androidMain source set
class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        initDi {
            bind<Context> { provider { applicationContext } }
        }
    }
}

whereas in the iOSApp.swift:

@main
struct iOSApp: App {
    init() {
        DiHelperKt.doInitDi { _ in }
    }
}

The results

  • Seamless cross-platform portability
  • No build reproducibility issues
  • Cleaner architecture (thanks to accumulated experience)
  • Reliable, predictable behavior

Key lessons learned

Tool selection matters

For cross-functional infrastructure (DI, navigation, logging), choose tools based on:

  • Maintenance quality: how are issues handled?
  • Community engagement: are contributions properly managed?
  • Documentation: is it clear and comprehensive?
  • Stability vs. innovation balance: new features shouldn't compromise reliability

Design for flexibility

  • Keep DI structure modular and replaceable
  • Abstract away framework-specific details
  • Design interfaces that don't leak implementation concerns
  • Changes are inevitable — embrace them as improvement opportunities

Stay open to innovation

The KMP DI landscape continues evolving. Keep an eye on emerging solutions like:

  • Metro: a promising new KMP DI tool
  • Future Dagger/Hilt KMP support
  • Other community-driven alternatives

The final veredict: a comparison

Aspect Koin (Manual) Koin-Annotations Kodein
Compile-time Safety ❌ ✅ ❌
Setup Complexity ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐
Documentation ⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐
Maintenance ⭐⭐ ⭐ ⭐⭐⭐⭐
F-Droid Compatible ✅ ❌ ✅
Learning Curve ⭐⭐ ⭐⭐⭐⭐ ⭐⭐

Final thoughts

Sometimes the best solution isn't the most popular or heavily marketed one. Kodein's honest, straightforward approach to dependency injection proved more valuable than Koin's flashy promises and problematic execution.

The journey taught me that in software development, as in life, reliability trumps hype every time. When building production applications that need to work across multiple platforms and distribution channels, choose tools that do what they say they will do — nothing more, nothing less.

For your next KMP project, consider giving Kodein a try. Your future self (and your F-Droid users) will thank you.

Question

Have you had similar experiences with DI libraries in KMP? Reach out to your thoughts and war stories!

Building modern Fediverse apps in KMP & Compose: the perfect match

The Fediverse represents the future of social media — decentralized, open, and user-controlled. As platforms like Mastodon, Friendica, and Lemmy gain momentum, developers face a crucial question: how do you build modern, cross-platform applications that can keep pace with this rapidly evolving ecosystem?

The answer lies in Kotlin Multiplatform (KMP) combined with Compose Multiplatform (CMP) — a technology stack that is uniquely suited to solve what I refer to as «Fediverse development challenge».

The Fediverse development challenge

Platform fragmentation

Modern users expect native experiences across mobile, desktop, and web platforms. Traditional approaches usually force developers to choose between:

  • building separate native apps (which is expensive and slow to iterate)
  • creating web-only solutions (with the disadvantage of limited platform integration )
  • using older cross-platform frameworks for app development (which may imply performance tradeoffs or increase the side of the app package).

Protocol complexity

Fediverse protocols like ActivityPub are intricate, requiring sophisticated networking, JSON parsing, proper state management and a well-designed and future-proof implementation.

Duplicating this logic across multiple platforms introduces maintenance overhead for sure, plus it may potentially lead to bugs.

Rapid evolution

The Fediverse moves fast: new features, protocol extensions, and federation behaviors emerge regularly. The development stack needs to support quick iterations and seamless updates across all platforms, in order to adapt to changes rapidly, release quickly and reach the widest possible audience.

Why Kotlin Multiplatform?

Share what you want, when you want

KMP is flexible and allows you to choose what to share: for example, you can share the complex business logic — ActivityPub implementation, federation handling, data models, and networking — while keeping platform-specific UI native. Otherwise, you can also share some UI parts or, if you are brave enough, all of it with Compose Multiplatform (CMP) as we do in Procyon.

The key concept is, it is not an "all or nothing" choice where you either opt in or opt out completely at once, you can gradually choose what to share and when to do it, which gives you as a developer great power (even to roll back some of your decisions if you want to).

The expect/actual mechanism, moreover, makes it possible to access underlying operating system features (such as filesystem, gallery, camera, video playback, push notifications, inter-process communication e.g. sharing data to other apps or receive data from other apps through deep links) when you need them, defining your own abstractions to call from common code.

Type-safety

Kotlin's strong type system and sealed classes make it excellent for modeling ActivityPub entities and activities. You can create robust, compile-time-safe representations of posts, actors, and interactions that work identically across all platforms.

There are even libraries, like LemmyBackwardsCompatibleAPI which offer abstractions for Lemmy data types and a unified adapter to endpoints which make it transparent for client-apps developers which version of the backend is used by the current instance.

Networking & serialization

Libraries like Ktor, Ktorfit and kotlinx.serialization provide first-class support for HTTP clients, API modeling and JSON parsing — essential for Fediverse applications that constantly communicate with various servers and handle complex data.

Using these three libraries in conjunction (with KSP, the Kotlin Symbol Processor) makes it extremely simple to define endpoint calls, handling asynchronous result and errors in an idiomatic way. Dealing with KSP in a multi-module and multi-platform project may pose some difficulties, nonetheless, stay tuned for updates on this in future posts.

Structured concurrency

Kotlin coroutines excel at handling the asynchronous, real-time nature of social media apps. Parent-child relationships between tasks, when background operations are tied with the lifecycle of a screen, allow to gracefully cancel tasks when their result is not needed, saving battery power and limiting data transmission over the network.

Compose Multiplatform

Declarative UI

Social media interfaces are inherently complex — timelines, media galleries, thread visualizations for discussions, and real-time updates. The declarative approach of Compose Multiplatform makes these interfaces easier to build and maintain than traditional imperative UI frameworks.

Consistent design systems

Fediverse apps benefit from consistent branding and behavior across platforms. Compose Multiplatform lets you implement your design once and deploy it everywhere, ensuring users get the same experience (no matter whether they're on Android, iOS, desktop, or web).

Material Design 3 is a modern and robust design system which ensures consistency, accessibility and customization options.

Performance where it counts

Unlike web-base cross-platform solutions, Compose Multiplatform compiles to native code, providing the smooth scrolling and responsive interactions that social media users expect, which are especially important to make the Fediverse as engaging as possible for users and promote its adoption.

Real-world advantages

Faster federation support

When new ActivityPub extensions or features emerge, you can implement them once in shared KMP code and immediately have support everywhere. This is crucial to keep up with the fast-moving Fediverse ecosystem.

For example, Raccoon is going to provide Lemmy 1.x support to all its target platform (Android, iOS) at once, see this issue to monitor the state of integration.

Simplified testing

Not only the implementation code is shared, with KMP you can also share tests. For business logic this is pretty straightforward (more on this in future posts, especially for mocking libraries) — whereas UI tests need a device to run (iOS simulator, Android emulator, etc.) but it is nonetheless possible to write tests once and run them on multiple devices (more on this here).

Community contributions

A single, well-structured Kotlin codebase is more approachable for open-source contributors than maintaining separate applications. This matters for Fediverse projects that often rely on community development, when the workforce is often limited to few volunteers.

Leverage a rich ecosystem

The Kotlin and Compose Multiplatform ecosystem has matured significantly over the past few years and at Procyon I've seen it grow from 2023 on.

Key libraries for Fediverse development include:

Conclusion

Building a modern Fediverse app requires balancing rapid development, protocol complexity, and user experience across multiple platforms.

Kotlin Multiplatform and Compose Multiplatform provide the perfect foundation — letting you focus on what makes your Fediverse app unique while sharing the complex domain code that makes your app work.

The Fediverse represents a return to user agency and open protocols. Your development stack should embody those same principles: open, flexible, and designed for the long term. Kotlin Multiplatform and Compose Multiplatform deliver exactly that.

Tip

Ready to build the next great Fediverse app? The tools are here, the protocols are maturing, and the community is waiting.