The Glue Code Standard for Kotlin Multiplatform
Safe, leak-free bridge between shared code and platform-specific APIs
KRelay is a lightweight bridge that connects your shared Kotlin code to platform-specific implementations (Android/iOS) without memory leaks or lifecycle complexity. It offers a simple, type-safe API for one-way, fire-and-forget UI commands.
v2.0 introduces a powerful instance-based API, perfect for dependency injection and large-scale "Super Apps," while remaining fully backward-compatible with the original singleton.
Use Cases:
- Singleton: Simple, zero-config for small to medium apps.
- Instances: DI-friendly, isolated for large modular apps.
// β
Singleton (Existing projects)
class LoginViewModel {
fun onLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome!") }
}
}
// β
Instance-based (DI / Super Apps)
class RideViewModel(private val krelay: KRelayInstance) {
fun onBookingConfirmed() {
krelay.dispatch<ToastFeature> { it.show("Ride booked!") }
}
}KRelay v2.0 introduces a powerful instance-based API, designed for scalability, dependency injection, and large-scale applications ("Super Apps"), while preserving 100% backward compatibility with the simple singleton API.
- β
Create Isolated Instances:
KRelay.create("MyModuleScope") - β Solves Super App Problem: No more feature name conflicts between independent modules.
- β
DI-Friendly: Inject
KRelayInstanceinto your ViewModels, UseCases, and repositories. - β Full Isolation: Each instance has its own registry, queue, and configuration.
// Before (v1.x): Global singleton could cause conflicts
// β οΈ Ride module and Food module might conflict on `ToastFeature`
KRelay.register<ToastFeature>(RideToastImpl())
KRelay.register<ToastFeature>(FoodToastImpl()) // Overwrites the first one!
// After (v2.0): Fully isolated instances
val rideKRelay = KRelay.create("Rides")
val foodKRelay = KRelay.create("Food")
rideKRelay.register<ToastFeature>(RideToastImpl()) // No conflict
foodKRelay.register<ToastFeature>(FoodToastImpl()) // No conflict- β
Builder Pattern:
KRelay.builder("MyScope").maxQueueSize(50).build() - β Per-Instance Settings: Customize queue size, action expiry, and debug mode for each module.
- β
No Breaking Changes: All existing code using
KRelay.dispatchworks exactly as before. - β Easy Migration: Adopt the new instance API incrementally, where it makes sense.
- β
The global
KRelayobject now transparently uses a default instance.
Recommendation: All new projects, especially those using DI (Koin/Hilt) or with a multi-module architecture, should use the new instance-based API. Existing projects can upgrade without any changes.
KRelay queues lambdas that may capture variables. Follow these rules to avoid leaks:
β DO: Capture primitives and data
// Singleton
val message = viewModel.successMessage
KRelay.dispatch<ToastFeature> { it.show(message) }
// Instance
val krelay: KRelayInstance = get() // from DI
krelay.dispatch<ToastFeature> { it.show(message) }β DON'T: Capture ViewModels or Contexts
// BAD: Captures entire viewModel
KRelay.dispatch<ToastFeature> { it.show(viewModel.data) }π§ CLEANUP: Use clearQueue() in onCleared()
// Singleton Usage
class MyViewModel : ViewModel() {
override fun onCleared() {
super.onCleared()
KRelay.clearQueue<ToastFeature>()
}
}
// Instance Usage (with DI)
class MyViewModel(private val krelay: KRelayInstance) : ViewModel() {
override fun onCleared() {
super.onCleared()
krelay.clearQueue<ToastFeature>()
}
}Each KRelay instance includes three passive safety mechanisms:
- actionExpiryMs (default: 5 min): Old actions auto-expire.
- maxQueueSize (default: 100): Oldest actions are dropped when the queue is full.
- WeakReference: Platform implementations are weakly referenced and auto-released.
For 99% of use cases (Toast, Navigation, Permissions), these are sufficient. These settings can be configured per-instance using the KRelay.builder().
Without KRelay:
// β DIY approach - Memory leak!
object MyBridge {
var activity: Activity? = null // Forgot to clear β LEAK
}With KRelay:
// β
Automatic WeakReference - Zero leaks
override fun onCreate(savedInstanceState: Bundle?) {
KRelay.register<ToastFeature>(AndroidToast(this))
// Auto-cleanup when Activity destroyed
}Without KRelay:
// β Command missed if Activity not ready
viewModelScope.launch {
val data = load()
nativeBridge.showToast("Done") // Activity not created yet - event lost!
}With KRelay:
// β
Sticky Queue - Commands preserved
viewModelScope.launch {
val data = load()
KRelay.dispatch<ToastFeature> { it.show("Done") }
// Queued if Activity not ready β Auto-replays when ready
}Without KRelay:
// β ViewModel coupled to a specific Navigator
class LoginViewModel(private val navigator: Navigator) {
fun onLoginSuccess() {
navigator.push(HomeScreen())
}
}
// - Hard to test (requires a Navigator mock)
// - Can't switch navigation libraries easilyWith KRelay (v2.0):
// β
ViewModel is pure, depends only on the KRelay contract
class LoginViewModel(private val krelay: KRelayInstance) {
fun onLoginSuccess() {
krelay.dispatch<NavigationFeature> { it.goToHome() }
}
}
// - Easy testing: pass in a mock instance
// - DI-friendly: inject the correct instance
// - Switch Voyager β Decompose without touching the ViewModel// In your shared module's build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.0.0")
}Step 1: Define Feature Contract (commonMain) This is the shared contract between your business logic and platform UI.
interface ToastFeature : RelayFeature {
fun show(message: String)
}Perfect for single-module apps or maintaining backward compatibility.
Step 2A: Use from Shared Code
// ViewModel uses the global KRelay object
class LoginViewModel {
fun onLoginSuccess() {
// The @SuperAppWarning reminds you that this is a global singleton
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
}
}Step 3A: Implement and Register on Platform
// Android (in Activity)
class AndroidToast(private val context: Context) : ToastFeature { /*...*/ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
KRelay.register<ToastFeature>(AndroidToast(applicationContext))
}
// iOS (in UIViewController)
class IOSToast: ToastFeature { /*...*/ }
override func viewDidLoad() {
super.viewDidLoad()
KRelay.shared.register(impl: IOSToast(viewController: self))
}The recommended approach for new, multi-module, or DI-based projects.
Step 2B: Create & Inject Instance Create a shared instance for your module or screen. Here, we use Koin as an example.
// In a Koin module (e.g., RideModule.kt)
val rideModule = module {
single { KRelay.create("Rides") } // Create a scoped instance
viewModel { RideViewModel(krelay = get()) }
}
// ViewModel receives the instance via constructor
class RideViewModel(private val krelay: KRelayInstance) : ViewModel() {
fun onBookingConfirmed() {
krelay.dispatch<ToastFeature> { it.show("Ride booked!") }
}
}Step 3B: Implement and Register on Platform The implementation is the same, but you register it with the specific instance.
// Android (in Activity)
val rideKRelay: KRelayInstance by inject() // from Koin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
rideKRelay.register<ToastFeature>(AndroidToast(applicationContext))
}
// iOS (in UIViewController)
let rideKRelay: KRelayInstance = koin.get() // from Koin
override func viewDidLoad() {
super.viewDidLoad()
rideKRelay.register(impl: IOSToast(viewController: self))
}
β οΈ Important Warnings:
@ProcessDeathUnsafe: The queue is in-memory and lost on process death. This is safe for UI feedback (Toasts, Navigation), but not for critical data (payments).@SuperAppWarning: This reminds you that the globalKRelayobject is a singleton. For modular apps, use the instance-based API (Option B) to prevent conflicts.See Managing Warnings to suppress at the module level.
- Super App Ready: Create isolated
KRelayInstances for each module, preventing conflicts. - DI Friendly: Inject instances into ViewModels and services.
- Configurable: Each instance can have its own queue size, expiry, and debug settings.
- Automatic WeakReference prevents Activity/ViewController leaks.
- No manual cleanup needed for 99% of use cases.
- Commands are never lost during configuration changes (e.g., screen rotation).
- Auto-replays queued commands when a platform implementation becomes available.
- All commands execute on the Main/UI thread automatically.
- Reentrant locks on both platforms (Android & iOS) ensure safe concurrent access.
- Stress-tested with 100k+ concurrent operations.
- Decouples ViewModels from navigation libraries like Voyager, Decompose, and Compose Navigation.
- Integrates cleanly with permission handlers (Moko Permissions), image pickers (Peekaboo), and more.
- Singleton:
KRelay.reset()provides a clean state for each test. - Instances: Pass a mock
KRelayInstancedirectly to your ViewModel for even easier and more explicit testing. - No complex mocking libraries needed.
- Zero overhead when dispatching from the main thread.
- Efficient queue management and minimal memory footprint.
dump(): A visual printout of the current state (registered features, queue depth).getDebugInfo(): Programmatic access to all diagnostic data.- Real-time monitoring of registered features and queue depth.
The Core API is consistent across the singleton and instances.
For quick setup or existing projects. All calls are delegated to a default instance.
// Register a feature on the default instance
KRelay.register<ToastFeature>(AndroidToast(context))
// Dispatch an action on the default instance
KRelay.dispatch<ToastFeature> { it.show("Hello from singleton!") }For dependency injection, multi-module apps, and testability.
// Create a new, isolated instance
val rideKRelay = KRelay.create("Rides")
// Or, create a configured instance
val foodKRelay = KRelay.builder("Food")
.maxQueueSize(20)
.build()
// Register a feature on a specific instance
rideKRelay.register<ToastFeature>(RideToastImpl())
// Dispatch an action on that instance
rideKRelay.dispatch<ToastFeature> { it.show("Your ride is here!") }These functions are available on both the KRelay singleton and any KRelayInstance.
Utility Functions:
// On singleton
KRelay.isRegistered<ToastFeature>()
KRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
KRelay.reset() // Resets the default instance
// On instance
val myRelay: KRelayInstance = get()
myRelay.isRegistered<ToastFeature>()
myRelay.getPendingCount<ToastFeature>()
myRelay.clearQueue<ToastFeature>()
myRelay.reset() // Resets only this instanceDiagnostic Functions:
// On singleton
KRelay.dump()
KRelay.getDebugInfo()
// On instance
val myRelay: KRelayInstance = get()
myRelay.dump()
myRelay.getDebugInfo()- Navigation:
KRelay.dispatch<NavFeature> { it.goToHome() } - Toast/Snackbar: Show user feedback
- Permissions: Request camera/location
- Haptics/Sound: Trigger vibration/audio
- Analytics: Fire-and-forget events
- Notifications: In-app banners
- Return Values: Use
expect/actualinstead - State Management: Use
StateFlow - Heavy Processing: Use
Dispatchers.IO - Database Ops: Use Room/SQLite directly
- Critical Transactions: Use WorkManager
- Network Requests: Use Repository pattern
Golden Rule: KRelay is for one-way, fire-and-forget UI commands. If you need a return value or guaranteed execution after process death, use different tools.
Lambda functions cannot survive process death (OS kills app).
Impact:
- β Safe: Toast, Navigation, Haptics (UI feedback - acceptable to lose)
- β Dangerous: Payments, Uploads, Critical Analytics (use WorkManager)
Why? Lambdas can't be serialized. When OS kills your app, the queue is cleared.
See @ProcessDeathUnsafe and Anti-Patterns Guide for details.
KRelay provides two APIs, and choosing the right one is important.
Singleton API (KRelay.dispatch)
- Pros: Zero setup, easy to use, great for simple apps.
- Cons: Can cause feature conflicts in large, multi-module "Super Apps" if two modules use the same feature interface.
- Use When: Your app is a single module, or you are certain feature names will not conflict.
Instance API (KRelay.create(...))
- Pros: Full isolation between modules, DI-friendly, configurable per-instance. This is the solution for Super Apps.
- Cons: Requires a small amount of setup (creating and providing the instance).
- Use When: Building a multi-module app, using dependency injection, or needing different configurations for different parts of your app.
See the @SuperAppWarning annotation and the "Quick Start" guide for examples of each.
- Integration Guides - Voyager, Moko, Peekaboo, Decompose
- Anti-Patterns - What NOT to do (Super App examples)
- Testing Guide - How to test KRelay-based code
- Managing Warnings - Suppress
@OptInat module level
- Architecture - Deep dive into internals
- API Reference - Complete API documentation
- ADR: Singleton Trade-offs - Design decisions
- Positioning - Why KRelay exists (The Glue Code Standard)
- Roadmap - Future development plans (Desktop, Web, v2.0)
A: We understand the PTSD! π But KRelay is fundamentally different:
| Aspect | Old EventBus | KRelay |
|---|---|---|
| Scope | Global pub/sub across all components | Strictly Shared ViewModel β Platform (one direction) |
| Memory Safety | Manual lifecycle management β leaks everywhere | Automatic WeakReference - leak-free by design |
| Direction | Any-to-Any (spaghetti) | Unidirectional (ViewModel β View only) |
| Discovery | Events hidden in random places | Type-safe interfaces - clear contracts |
| Use Case | General messaging (wrong tool) | KMP "Last Mile" problem (right tool) |
Key difference: EventBus was used for component-to-component communication (wrong pattern). KRelay is for ViewModel-to-Platform bridge only (the missing piece in KMP).
A: KRelay v2.0 is designed to integrate seamlessly with Dependency Injection frameworks. The new instance-based API allows you to register KRelayInstances as providers in your DI graph and inject them where needed.
KRelay complements DI by solving the specific problem of bridging to lifecycle-aware, Activity/UIViewController-scoped UI actions (like navigation, dialogs, permissions) without leaking platform contexts into your ViewModels.
Modern DI Approach (with KRelay v2.0):
// 1. Provide a KRelay instance in your Koin/Hilt module
val appModule = module {
single { KRelay.create("AppScope") } // Create an instance
viewModel { LoginViewModel(krelay = get()) }
}
// 2. Inject the instance into your ViewModel
class LoginViewModel(private val krelay: KRelayInstance) : ViewModel() {
fun onLoginSuccess() {
// ViewModel is pure and easily testable
krelay.dispatch<NavigationFeature> { it.goToHome() }
}
}
// 3. Register the implementation at the UI layer
class MyActivity : AppCompatActivity() {
private val krelay: KRelayInstance by inject() // Inject the same instance
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
krelay.register<NavigationFeature>(AndroidNavigation(this))
}
}When to use what:
- DI (Koin/Hilt): For managing the lifecycle of your dependencies, including repositories, use cases, and
KRelayInstances. - KRelay: As the clean, lifecycle-safe bridge for dispatching commands from your DI-managed components to the UI layer.
A: Absolutely! LaunchedEffect is lifecycle-aware and doesn't leak. KRelay solves two different problems:
1. Boilerplate Reduction
Without KRelay:
// ViewModel
class LoginViewModel {
private val _navEvents = MutableSharedFlow<NavEvent>()
val navEvents = _navEvents.asSharedFlow()
fun onSuccess() {
viewModelScope.launch {
_navEvents.emit(NavEvent.GoHome)
}
}
}
// Every screen needs this collector
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val navigator = LocalNavigator.current
LaunchedEffect(Unit) {
viewModel.navEvents.collect { event ->
when (event) {
is NavEvent.GoHome -> navigator.push(HomeScreen())
// ... handle all events
}
}
}
}With KRelay:
// ViewModel
class LoginViewModel {
fun onSuccess() {
KRelay.dispatch<NavFeature> { it.goToHome() }
}
}
// One-time registration in MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
KRelay.register<NavFeature>(VoyagerNav(navigator))
}2. Missed Events During Rotation
If you dispatch an event during rotation (between old Activity destroy β new Activity create), LaunchedEffect isn't running yet β event lost.
KRelay's Sticky Queue catches these events and replays them when the new Activity is ready.
Trade-off: If you only have 1-2 features and prefer explicit Flow collectors, stick with LaunchedEffect. If you have many platform actions (Toast, Nav, Permissions, Haptics), KRelay reduces boilerplate significantly.
KRelay is designed for testability. The v2.0 instance API makes testing even cleaner.
If you use the KRelay singleton, you can use KRelay.reset() to ensure a clean state between tests.
class LoginViewModelTest {
@BeforeTest
fun setup() {
KRelay.reset() // Clears the default instance's registry and queue
}
@Test
fun `when login success, dispatches toast and nav commands`() {
// Arrange: Register mock implementations on the global object
val mockToast = MockToast()
val mockNav = MockNav()
KRelay.register<ToastFeature>(mockToast)
KRelay.register<NavigationFeature>(mockNav)
val viewModel = LoginViewModel() // Assumes ViewModel uses KRelay singleton
// Act
viewModel.onLoginSuccess()
// Assert
assertEquals("Welcome back!", mockToast.lastMessage)
assertTrue(mockNav.navigatedToHome)
}
}This is the modern, recommended approach. It avoids global state and makes dependencies explicit.
class RideViewModelTest {
private lateinit var mockRelay: KRelayInstance
private lateinit var viewModel: RideViewModel
@BeforeTest
fun setup() {
// Create a fresh instance for each test
mockRelay = KRelay.create("TestScope")
viewModel = RideViewModel(krelay = mockRelay)
}
@Test
fun `when booking confirmed, dispatches confirmation toast`() {
// Arrange: Register a mock feature on the instance
val mockToast = MockToast()
mockRelay.register<ToastFeature>(mockToast)
// Act
viewModel.onBookingConfirmed()
// Assert
assertEquals("Ride booked!", mockToast.lastMessage)
}
}Shared Mock Implementations:
// A simple mock used in the tests above
class MockToast : ToastFeature {
var lastMessage: String? = null
override fun show(message: String) {
lastMessage = message
}
}
class MockNav : NavigationFeature {
var navigatedToHome: Boolean = false
override fun goToHome() {
navigatedToHome = true
}
}Run tests:
./gradlew :krelay:testDebugUnitTest # Android
./gradlew :krelay:iosSimulatorArm64Test # iOS SimulatorThe project includes a demo app showcasing real integrations:
Android:
./gradlew :composeApp:installDebugFeatures:
- Basic Demo: Core KRelay features
- Voyager Integration: Real navigation library integration
See composeApp/src/commonMain/kotlin/dev/brewkits/krelay/ for complete examples.
KRelay follows Unix philosophy - it has one responsibility:
Guarantee safe, leak-free dispatch of UI commands from shared code to platform.
What KRelay Is:
- β A messenger for one-way UI commands
- β Fire-and-forget pattern
- β Lifecycle-aware bridge
What KRelay Is NOT:
- β RPC framework (no request-response)
- β State management (use StateFlow)
- β Background worker (use WorkManager)
- β DI framework (use Koin/Hilt)
By staying focused, KRelay remains simple, reliable, and maintainable.
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Copyright 2026 Brewkits
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
If KRelay saves you time, please give us a star!
It helps other developers discover this project.
Made with β€οΈ by Nguyα» n TuαΊ₯n Viα»t at Brewkits
Support: datacenter111@gmail.com β’ Community: GitHub Issues
