Brrrr
Senior Android Engineer · Tapforce · 2026
- Kotlin Multiplatform
- Compose Multiplatform
- Koin
- Ktor
- Firebase
- Plaid
Context
Brrrr is a personal-finance app: link your investment accounts through Plaid, and get AI-generated insights about your money delivered as swipeable stories, with a follow-up chat to dig into any of them. It existed first as a legacy single-platform Android app — Hilt, Retrofit, Gson, the 2023 stack — with no iOS presence at all.
Approach
Rather than build a second app, I rewrote the one that existed as Kotlin Multiplatform with Compose Multiplatform — an eleven-phase migration that ended with the legacy :app module deleted and Hilt, Retrofit, and Gson purged. All logic and all UI live in commonMain; the Android app is a thin shell, and the iOS app is an Xcode project generated by xcodegen that embeds the shared framework. The feature-sliced shape carried over intact from the other codebases — accounts, home, insights, investments, onboarding, settings over a shared core — with the same Konsist boundary tests enforcing it.
Firebase runs through the KMP-native GitLive SDK, networking through Ktor with per-platform engines, and DI through Koin.
Technical decisions
The rule that shaped the migration: platform differences go behind interfaces, not expect/actual. The entire codebase contains exactly three expect declarations — and one of them is the single seam the DI graph needs. Everything platform-shaped (Plaid's launcher, notification permissions, share sheets, phone-number verification) is a plain interface in commonMain, bound to a platform implementation in one place:
// commonMain — the only expect the DI graph needs
expect val platformModule: Module
// androidMain — platform capabilities bound behind common interfaces
actual val platformModule: Module = module {
single<HttpClientEngine> { OkHttp.create() }
singleOf(::PhoneNumberVerifierImpl) { bind<PhoneNumberVerifier>() }
singleOf(::PlaidLinkLauncherImpl) { bind<PlaidLinkLauncher>() }
singleOf(::NotificationPermissionControllerImpl) { bind<NotificationPermissionController>() }
singleOf(::ShareTextImpl) { bind<ShareText>() }
singleOf(::SystemSettingsImpl) { bind<SystemSettings>() }
singleOf(::UrlOpenerImpl) { bind<UrlOpener>() }
}Feature code never knows which platform it is on; it asks Koin for a capability and gets whichever implementation the platform bound. That kept the migration mechanical — and it keeps the next platform-specific requirement a one-file change instead of a fork in the codebase.
Outcome
The rewrite runs on both platforms — the same Compose UI on Android and on the iOS simulator, phone auth and push-driven sign-in included — with the shared logic covered by commonTest (Ktor MockEngine for the network layer, serialization round-trips for the models) executing on the Android host. It is honest about where it is: Plaid linking, Segment analytics, and push are still Android-only, documented as such, and it has not shipped to the stores yet. What it already proves is the thing I wanted to know — that the feature-sliced architecture, the Koin wiring, and the enforcement suite all survive the jump to multiplatform intact.