Blog › ICP guides
Kotlin developer on retainer: coroutines, Android Jetpack Compose, Spring Boot Kotlin, and Kotlin Multiplatform on monthly retainer
August 28, 2026 · ~22 min read
A consumer Android app had severe UI jank on the home screen. Users reported lag when opening the app and scrolling the main feed — testing on a mid-range device confirmed 20 or more dropped frames per second. The RecyclerView stalled every time it loaded because the underlying Room database query was executing on the main thread: homeDao.getItems() returned List<Item> and was called directly inside the Fragment’s onViewCreated. A Kotlin developer on monthly retainer identified the violation in the first session using StrictMode.setThreadPolicy with detectDiskReads() — Logcat lit up with StrictMode policy violation: android.os.StrictMode$StrictModeDiskReadViolation on every home screen load. The fix involved three coordinated changes: migrating the Room DAO to return Flow<List<Item>> instead of a blocking list, collecting the Flow inside viewModelScope.launch { dao.getItems().collect { _state.value = it } } so that emission stayed on Dispatchers.IO and only the state assignment crossed to the main thread, and replacing the RecyclerView XML layout and ItemAdapter with a LazyColumn in Jetpack Compose using collectAsStateWithLifecycle(). Dropped frames fell from 20-plus to zero on the same device.
No new feature shipped. The home screen displayed the same items in the same order. The observable change was purely performance: the app stopped janking on load. What changed structurally was the threading model — Room’s reactive Flow integration replaced a blocking synchronous query, the ViewModel gained a proper StateFlow<UiState> that composables could collect with lifecycle awareness, and the UI layer gained the automatic recomposition and scroll performance that LazyColumn provides over a manually managed RecyclerView with DiffUtil. The Kotlin developer’s retainer invoice logged 14 hours across three sessions: StrictMode investigation and DAO migration in session one, ViewModel StateFlow wiring in session two, Compose LazyColumn adoption and lifecycle-aware collection in session three.
That 14-hour engagement is representative of what makes Kotlin retainers difficult to communicate to clients who expect a feature list per invoice. Coroutine threading migrations produce no feature. Compose state architecture refactors produce no feature. Flow operator chain redesigns that replace race-prone nested launch blocks with flatMapLatest pipelines produce no feature. Kotlin Multiplatform expect/actual wiring that eliminates duplicated business logic between Android and iOS produces no feature visible to an end user. All of these are the highest-value Kotlin work a retainer developer delivers, and none of it produces an artifact proportional to the hours behind it. The sections below describe each domain in technical depth, explain why the hours are invisible, and show how to structure a Kotlin retainer so that the work remains legible to a client who did not write the code.
Kotlin language features
Idiomatic Kotlin is not Java with nullable annotations. The language ships a set of features — null safety operators, scope functions, extension functions, sealed classes, inline reified generics, delegation, and DSL builders — that interact with each other in ways that require a Kotlin developer to internalize the idiom before they can read, let alone write, production Kotlin at speed. A retainer engagement often begins with a codebase where Kotlin is being written but Java habits are producing verbose, unsafe, and incorrect code. Understanding the full feature surface is prerequisite work.
Null safety: operators and scope functions
Kotlin’s null safety type system distinguishes nullable types (String?) from non-nullable types (String) at compile time. The four null-handling operators — safe call (?.), non-null assertion (!!), Elvis (?:), and the safe-cast (as?) — compose with the five scope functions (let, run, also, apply, with) to cover every null-handling pattern without null pointer exceptions in production.
// Safe call: returns null if user is null, no NPE
val email: String? = user?.profile?.email
// Elvis: supply a default when the left side is null
val displayName: String = user?.name ?: "Anonymous"
// Non-null assertion: throws KotlinNullPointerException if null
// Use only when a preceding invariant guarantees non-null
val token: String = response.headers["Authorization"]!!
// Safe cast: returns null instead of throwing ClassCastException
val admin: Admin? = user as? Admin
// let: execute a block only when the receiver is non-null
user?.let { u ->
// u is non-nullable inside this block
sendWelcomeEmail(u.email)
}
// run: transform the receiver and return the result
val summary: String = user?.run {
"$name <${email ?: "no email"}>"
} ?: "No user"
// also: side-effect on the receiver, then return it unchanged
val savedUser: User = repository.save(user).also { u ->
logger.info("Saved user ${u.id}")
}
// apply: configure a mutable object, return it
val request = HttpRequest().apply {
method = "POST"
url = "https://api.example.com/users"
body = Json.encodeToString(payload)
}
// with: operate on a non-null receiver, return a result
val html: String = with(template) {
replace("{{name}}", user.name)
.replace("{{email}}", user.email)
}
The selection rule: let for null-guarded execution; run for null-guarded transformation; also for side effects that must not alter the chain; apply for mutable object configuration; with for operations on a non-null receiver where this-reference is cleaner than a parameter. Mixing them arbitrarily — which a developer writing Java habits in Kotlin typically does — produces code that compiles but is unreadable. A Kotlin retainer engagement commonly includes a scope function audit that touches dozens of files.
Extension functions and properties
Extension functions add methods to existing types without inheritance or decoration. They resolve statically at the call site, which means they cannot override virtual methods but they can be defined in any package and imported selectively. Extension properties follow the same rules but cannot store state — they must delegate to the receiver or compute a value.
// Extension function on String
fun String.toSlug(): String =
lowercase()
.replace(Regex("[^a-z0-9\\s-]"), "")
.trim()
.replace(Regex("\\s+"), "-")
// Extension property on String
val String.wordCount: Int
get() = if (isBlank()) 0 else trim().split(Regex("\\s+")).size
// Extension function on a sealed class
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
data object Loading : ApiResult<Nothing>()
}
fun <T> ApiResult<T>.getOrDefault(default: T): T = when (this) {
is ApiResult.Success -> data
is ApiResult.Error -> default
is ApiResult.Loading -> default
}
// Extension on nullable type
fun String?.orEmpty(): String = this ?: ""
// Usage
val slug = "Hello World! Welcome.".toSlug() // "hello-world-welcome"
val count = "the quick brown fox".wordCount // 4
val result: ApiResult<User> = fetchUser(id)
val user = result.getOrDefault(User.ANONYMOUS)
Data classes, sealed classes, and destructuring
Data classes generate equals, hashCode, toString, copy, and componentN functions from the primary constructor. Sealed classes and sealed interfaces restrict the class hierarchy to a known set of subtypes, enabling exhaustive when expressions that the compiler enforces at compile time — the critical correctness property for UI state machines.
// Data class with copy and destructuring
data class User(
val id: String,
val name: String,
val email: String,
val role: Role = Role.VIEWER
)
val alice = User("1", "Alice", "alice@example.com", Role.ADMIN)
val bob = alice.copy(id = "2", name = "Bob", email = "bob@example.com", role = Role.VIEWER)
// Destructuring via componentN
val (id, name, email) = alice
// Sealed interface for UI state (preferred over sealed class in Kotlin 1.5+)
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val message: String, val cause: Throwable? = null) : UiState<Nothing>
}
// Exhaustive when — compiler error if a branch is missing
fun <T> renderState(state: UiState<T>): String = when (state) {
is UiState.Loading -> "Loading..."
is UiState.Success -> "Loaded: ${state.data}"
is UiState.Error -> "Error: ${state.message}"
}
// Sealed class for navigation events (one-shot)
sealed class NavEvent {
data class Navigate(val route: String) : NavEvent()
data object Back : NavEvent()
data class ShowDialog(val title: String, val message: String) : NavEvent()
}
Inline functions and reified generics
Kotlin’s inline modifier copies the function body to the call site at compile time, eliminating the lambda object allocation and enabling reified type parameters that retain their generic type at runtime. This is the mechanism behind idioms like startActivity<DetailActivity>() that avoid passing Class<T> tokens explicitly.
// reified: access T::class without a Class<T> parameter
inline fun <reified T : Any> fromJson(json: String): T =
Json.decodeFromString<T>(json)
// Usage — no Class<T> token needed
val user: User = fromJson("""{"id":"1","name":"Alice","email":"alice@example.com"}""")
// crossinline: lambda cannot use non-local return (passed to another execution context)
inline fun runOnMain(crossinline block: () -> Unit) {
Handler(Looper.getMainLooper()).post { block() }
}
// noinline: one lambda among several should NOT be inlined
inline fun measureTime(
noinline setup: () -> Unit,
block: () -> Unit
): Long {
setup() // not inlined — can be stored as an object reference
val start = System.currentTimeMillis()
block() // inlined at call site
return System.currentTimeMillis() - start
}
// Inline function with reified for Activity navigation
inline fun <reified T : Activity> Context.startActivity(
block: Intent.() -> Unit = {}
) {
val intent = Intent(this, T::class.java).apply(block)
startActivity(intent)
}
// Usage
context.startActivity<DetailActivity> {
putExtra("itemId", item.id)
}
Delegation: lazy, observable, vetoable, and interface delegation
Kotlin’s by keyword supports four delegation patterns: lazy initialization, observable property changes, vetoing property assignments, and delegating interface implementation to a wrapped object. All four are implemented in the standard library as ReadOnlyProperty or ReadWriteProperty implementations that the compiler wires at the property accessor level.
import kotlin.properties.Delegates
// by lazy: thread-safe initialization on first access
class ExpensiveService {
private val connection by lazy {
// Only executed once, on first access
DatabasePool.createConnection(config)
}
}
// Delegates.observable: callback on every assignment
class UserPreferences {
var theme: String by Delegates.observable("light") { prop, old, new ->
logger.info("${prop.name} changed: $old -> $new")
analyticsService.track("theme_changed", mapOf("from" to old, "to" to new))
}
}
// Delegates.vetoable: reject assignments that fail validation
class TemperatureController {
var setpoint: Double by Delegates.vetoable(20.0) { _, _, new ->
new in 10.0..30.0 // returns false to reject
}
}
// Interface delegation: wrap an implementation, override selectively
interface Logger {
fun log(message: String)
fun warn(message: String)
fun error(message: String, cause: Throwable?)
}
class TimestampLogger(private val delegate: Logger) : Logger by delegate {
// Only log() is overridden; warn() and error() delegate to the wrapped instance
override fun log(message: String) {
delegate.log("[${System.currentTimeMillis()}] $message")
}
}
Kotlin DSL builders
Kotlin type-safe builders combine extension functions with receiver lambdas (fun T.() -> Unit) and the @DslMarker annotation to create fluent configuration APIs that the compiler verifies for nesting correctness. This is the mechanism behind Gradle Kotlin DSL, Ktor routing, Jetpack Compose’s LazyColumn, and every internal DSL in the Kotlin ecosystem.
@DslMarker
annotation class HtmlDsl
@HtmlDsl
class HtmlBuilder {
private val children = mutableListOf<String>()
fun h1(text: String) { children += "<h1>$text</h1>" }
fun p(text: String) { children += "<p>$text</p>" }
fun a(href: String, text: String) {
children += """<a href="$href">$text</a>"""
}
fun div(block: HtmlBuilder.() -> Unit) {
val inner = HtmlBuilder().apply(block)
children += "<div>\n${inner.build()}\n</div>"
}
fun build(): String = children.joinToString("\n")
}
fun html(block: HtmlBuilder.() -> Unit): String =
HtmlBuilder().apply(block).build()
// Usage — @DslMarker prevents calling outer h1 inside the inner div
val page = html {
h1("Kotlin DSL Builder")
div {
p("Type-safe and IDE-complete")
a("https://hourtab.com", "Track your retainer hours")
}
}
// Retainer config DSL example
@DslMarker
annotation class RetainerDsl
@RetainerDsl
class RetainerConfig {
var clientName: String = ""
var monthlyHours: Int = 0
var hourlyRate: Double = 0.0
private val services = mutableListOf<String>()
fun service(name: String) { services += name }
fun build() = Retainer(clientName, monthlyHours, hourlyRate, services.toList())
}
fun retainer(block: RetainerConfig.() -> Unit): Retainer =
RetainerConfig().apply(block).build()
val engagement = retainer {
clientName = "Acme Corp"
monthlyHours = 30
hourlyRate = 195.0
service("Coroutine architecture advisory")
service("Compose state refactor")
service("Spring Boot Kotlin WebFlux integration")
}
Coroutines
Kotlin coroutines replace threads, callbacks, and RxJava chains with sequential-looking asynchronous code that the compiler transforms into state machines. The coroutine library provides structured concurrency guarantees that prevent coroutine leaks, cancellation propagation that eliminates the manual lifecycle cleanup that plagued callback-based Android code, and the Flow primitive that replaces LiveData and Observable for reactive streams. Most Kotlin retainer work that touches concurrency touches coroutines.
Fundamentals: suspend, CoroutineScope, dispatchers
A suspend function is a function that can pause execution without blocking a thread. The compiler transforms it into a state machine that resumes on a CoroutineContext when the suspended operation completes. CoroutineScope defines the lifetime of coroutines launched within it; Dispatchers specify which thread pool executes the coroutine’s body.
import kotlinx.coroutines.*
// suspend function: can call other suspend functions
suspend fun fetchUser(id: String): User {
return withContext(Dispatchers.IO) {
// Dispatchers.IO: thread pool sized for blocking I/O (up to 64 threads)
userRepository.findById(id) ?: throw UserNotFoundException(id)
}
}
suspend fun computeScore(user: User): Int {
return withContext(Dispatchers.Default) {
// Dispatchers.Default: thread pool sized to CPU count, for computation
scoreEngine.calculate(user.history)
}
}
// launch: fire-and-forget within a CoroutineScope
class UserService(private val scope: CoroutineScope) {
fun refreshUser(id: String) {
scope.launch {
val user = fetchUser(id) // suspend, no blocking
val score = computeScore(user) // suspend, no blocking
cache.put(id, user.copy(score = score))
}
}
}
// async/await: parallel execution with a result
suspend fun loadDashboard(userId: String): Dashboard {
return coroutineScope {
val userDeferred = async { fetchUser(userId) }
val postsDeferred = async(Dispatchers.IO) { postRepository.findByUser(userId) }
val statsDeferred = async(Dispatchers.Default) { statsEngine.compute(userId) }
// All three execute concurrently; await() suspends until each finishes
Dashboard(
user = userDeferred.await(),
posts = postsDeferred.await(),
stats = statsDeferred.await()
)
}
}
// runBlocking: ONLY for tests and main() entry points — never in production Android or Spring
fun main() = runBlocking {
val user = fetchUser("1")
println(user)
}
Structured concurrency: coroutineScope, supervisorScope, cancellation
Structured concurrency guarantees that a parent coroutine waits for all its children before completing, and that cancellation propagates from parent to children. coroutineScope { } enforces this strictly: a failure in any child cancels the scope and all siblings. supervisorScope { } relaxes it: a failure in one child does not cancel siblings, which is the correct model for a UI screen that loads multiple independent data sources.
// coroutineScope: any child failure cancels all siblings and rethrows
suspend fun loadMandatoryData(): AllData {
return coroutineScope {
val users = async { userRepo.fetchAll() }
val config = async { configRepo.fetch() }
// If either fails, both are cancelled and the exception propagates
AllData(users.await(), config.await())
}
}
// supervisorScope: child failures are independent
suspend fun loadOptionalWidgets(): WidgetSet {
return supervisorScope {
val primary = async { primaryWidget.load() }
val ads = async {
try { adService.fetch() }
catch (e: Exception) { emptyList() } // graceful degradation
}
val recommendations = async {
try { recommendationEngine.fetch() }
catch (e: Exception) { emptyList() }
}
WidgetSet(primary.await(), ads.await(), recommendations.await())
}
}
// Cancellation: cooperative — suspend functions check for cancellation
suspend fun longRunningExport(items: List<Item>): File {
val file = createTempFile("export", ".csv")
for (item in items) {
ensureActive() // throws CancellationException if the scope is cancelled
file.appendText(item.toCsvRow())
}
return file
}
// Job lifecycle and cancellation
class ExportManager(private val scope: CoroutineScope) {
private var exportJob: Job? = null
fun startExport(items: List<Item>) {
exportJob?.cancel() // cancel previous export if running
exportJob = scope.launch {
try {
val file = longRunningExport(items)
onExportComplete(file)
} catch (e: CancellationException) {
// Expected — do not log as error
throw e // always rethrow CancellationException
} catch (e: Exception) {
onExportError(e)
}
}
}
fun cancelExport() = exportJob?.cancel()
}
// CoroutineExceptionHandler: catches uncaught exceptions from launch (not async)
val handler = CoroutineExceptionHandler { context, throwable ->
logger.error("Unhandled coroutine exception in $context", throwable)
crashReporter.report(throwable)
}
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + handler)
Flow: streams, operators, StateFlow, SharedFlow
Flow<T> is Kotlin’s cold asynchronous stream: it emits values lazily when a collector starts, and emissions happen on the collector’s coroutine by default. StateFlow<T> is a hot flow that always holds a current value — the direct replacement for LiveData<T> in ViewModels. SharedFlow<T> is a hot flow that replishes to multiple collectors without caching — the correct mechanism for one-shot navigation events.
import kotlinx.coroutines.flow.*
// Cold flow: producer lambda runs on each collect call
fun searchResults(query: String): Flow<List<Result>> = flow {
emit(cache.get(query) ?: emptyList()) // emit cached immediately
val fresh = withContext(Dispatchers.IO) { searchApi.search(query) }
cache.put(query, fresh)
emit(fresh) // emit fresh results
}
// Operators: map, filter, flatMapLatest for search-as-you-type
val searchFlow: StateFlow<String> = _searchQuery.asStateFlow()
val resultsFlow: StateFlow<List<Result>> = searchFlow
.debounce(300L) // wait 300ms after last keystroke
.filter { it.length >= 2 } // skip short queries
.flatMapLatest { query -> // cancel previous search on new input
searchResults(query)
}
.flowOn(Dispatchers.IO) // run upstream on IO dispatcher
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
initialValue = emptyList()
)
// combine: merge two StateFlows into one UI state
val uiState: StateFlow<HomeUiState> = combine(
userFlow, // StateFlow<User?>
postsFlow, // StateFlow<List<Post>>
statsFlow // StateFlow<Stats?>
) { user, posts, stats ->
when {
user == null -> HomeUiState.Loading
stats == null -> HomeUiState.Partial(user, posts)
else -> HomeUiState.Full(user, posts, stats)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, HomeUiState.Loading)
// zip: pair emissions one-to-one
val pairedFlow: Flow<Pair<A, B>> = flowA.zip(flowB) { a, b -> Pair(a, b) }
// flatMapConcat: sequential; flatMapMerge: concurrent; flatMapLatest: cancels previous
val concurrentFlow: Flow<Result> = idFlow.flatMapMerge(concurrency = 4) { id ->
flow { emit(repository.fetch(id)) }
}
// SharedFlow for one-shot events (navigation, snackbars)
private val _navEvents = MutableSharedFlow<NavEvent>(extraBufferCapacity = 1)
val navEvents: SharedFlow<NavEvent> = _navEvents.asSharedFlow()
fun navigate(event: NavEvent) {
_navEvents.tryEmit(event) // non-suspending emission with buffer
}
// conflate: skip intermediate values when collector is slow
val sensorFlow: Flow<SensorReading> = rawSensorFlow
.conflate() // drop readings if the collector hasn't processed the previous one
.map { reading -> reading.filtered() }
Channel: fan-out, fan-in, produce builder
Channel<T> is the hot, concurrent communication primitive — the coroutines equivalent of a BlockingQueue. Channels coordinate work between producer and consumer coroutines with explicit buffering strategies. The produce { } coroutine builder creates a ReceiveChannel<T> whose lifetime is tied to the producing coroutine.
import kotlinx.coroutines.channels.*
// produce: creates a ReceiveChannel tied to a coroutine
fun CoroutineScope.generateIds(count: Int): ReceiveChannel<Int> = produce {
repeat(count) { send(it) }
}
// Fan-out: multiple consumers share work from one channel
fun CoroutineScope.processor(
id: Int,
input: ReceiveChannel<Int>
) = launch(Dispatchers.Default) {
for (item in input) {
println("Processor $id handling item $item")
processItem(item)
}
}
// Fan-in: multiple producers send to one channel
suspend fun fanIn(scope: CoroutineScope): ReceiveChannel<String> {
val channel = Channel<String>()
scope.launch { highPrioritySource().collect { channel.send("HIGH: $it") } }
scope.launch { lowPrioritySource().collect { channel.send("LOW: $it") } }
return channel
}
// Buffered channel: producer doesn't suspend until buffer is full
val buffered = Channel<WorkItem>(capacity = 64)
// Pipeline with produce
fun CoroutineScope.buildPipeline(): ReceiveChannel<ProcessedItem> {
val raw: ReceiveChannel<RawItem> = produce { fetchRawItems().forEach { send(it) } }
val validated: ReceiveChannel<ValidItem> = produce {
for (item in raw) {
if (item.isValid()) send(item.validate())
}
}
return produce {
for (item in validated) send(item.process())
}
}
// consumeEach: iterate a channel and close it when done
val output = scope.buildPipeline()
output.consumeEach { item ->
database.insert(item)
}
Error handling in coroutines
Coroutine error handling differs between launch (exceptions propagate to the CoroutineExceptionHandler or crash the app if unhandled) and async/await (exceptions are deferred until await() is called). SupervisorJob isolates child failures. Structured exception propagation means that catching CancellationException and not rethrowing it breaks cancellation — one of the most common coroutine bugs in production Android code.
// try/catch in suspend functions: works exactly like synchronous code
suspend fun safeNetworkCall(): Result<Data> = try {
val data = api.fetchData()
Result.success(data)
} catch (e: IOException) {
Result.failure(e)
} catch (e: HttpException) {
Result.failure(e)
}
// Note: do NOT catch CancellationException here — it would break structured concurrency
// async exception is deferred to await() — catch around await()
suspend fun loadWithFallback(): Data {
return coroutineScope {
val primary = async { primarySource.load() }
val fallback = async { fallbackSource.load() }
try {
primary.await()
} catch (e: Exception) {
fallback.cancel() // cancel unused fallback
fallback.await() // or use a default
}
}
}
// SupervisorJob: independent child failure handling
class AppCoroutineScope {
// SupervisorJob means one child failure doesn't cancel siblings
private val supervisorJob = SupervisorJob()
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
logger.error("Coroutine failed", throwable)
}
val scope = CoroutineScope(
supervisorJob + Dispatchers.Main.immediate + exceptionHandler
)
fun cancel() = supervisorJob.cancel()
}
// Correct CancellationException handling
suspend fun safeOperation() {
try {
doSuspendWork()
} catch (e: CancellationException) {
// Rethrow — never swallow CancellationException
throw e
} catch (e: Exception) {
// Handle actual errors
handleError(e)
}
}
Android and Jetpack Compose
Jetpack Compose replaced the View/XML system as Android’s primary UI toolkit. A Kotlin developer on retainer spends a large share of their time on Compose adoption: migrating screens from XML layouts to composable functions, restructuring state from LiveData to StateFlow, wiring Hilt dependency injection across ViewModel and navigation boundaries, connecting Room DAO Flows to composable screens via collectAsStateWithLifecycle, and scheduling background work with WorkManager. Each of these migrations touches multiple layers of the application simultaneously and produces no new feature.
@Composable functions and state management
A composable function describes a UI component as a function of its inputs. Compose re-runs (“recomposes”) composables when their inputs change. State that survives recomposition lives in remember { }; state that survives the Activity being recreated lives in rememberSaveable { }; derived state that avoids unnecessary recomposition uses derivedStateOf { }.
import androidx.compose.runtime.*
import androidx.compose.material3.*
import androidx.compose.foundation.lazy.*
// Stateless composable: receives data, emits events
@Composable
fun ItemRow(
item: Item,
onFavorite: (Item) -> Unit,
modifier: Modifier = Modifier
) {
Row(modifier = modifier.padding(16.dp)) {
Text(text = item.title, modifier = Modifier.weight(1f))
IconButton(onClick = { onFavorite(item) }) {
Icon(
imageVector = if (item.isFavorite) Icons.Filled.Favorite
else Icons.Outlined.FavoriteBorder,
contentDescription = "Toggle favorite"
)
}
}
}
// Stateful screen composable with hoisted state
@Composable
fun HomeScreen(
uiState: HomeUiState,
onFavorite: (Item) -> Unit,
onSearch: (String) -> Unit
) {
// remember: survives recomposition, not recreation
val listState = rememberLazyListState()
// rememberSaveable: survives recomposition AND recreation
var searchQuery by rememberSaveable { mutableStateOf("") }
// derivedStateOf: only recomposes when the derived value changes
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
Scaffold(
topBar = {
SearchBar(
query = searchQuery,
onQueryChange = { query ->
searchQuery = query
onSearch(query)
}
)
},
floatingActionButton = {
if (showScrollToTop) {
FloatingActionButton(onClick = { /* scroll to top */ }) {
Icon(Icons.Filled.ArrowUpward, "Scroll to top")
}
}
}
) { padding ->
when (uiState) {
is HomeUiState.Loading -> CircularProgressIndicator()
is HomeUiState.Error -> ErrorMessage(uiState.message)
is HomeUiState.Success -> LazyColumn(
state = listState,
contentPadding = padding
) {
items(uiState.items, key = { it.id }) { item ->
ItemRow(item = item, onFavorite = onFavorite)
}
}
}
}
}
// Side effects: LaunchedEffect, DisposableEffect, SideEffect
@Composable
fun TrackingScreen(itemId: String, viewModel: TrackingViewModel = hiltViewModel()) {
// LaunchedEffect: launched once per key change, cancelled on recomposition
LaunchedEffect(itemId) {
viewModel.loadItem(itemId)
}
// DisposableEffect: cleanup when the effect leaves composition
DisposableEffect(itemId) {
val listener = analytics.startTracking(itemId)
onDispose { listener.stop() }
}
// SideEffect: runs after every successful recomposition (use sparingly)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
SideEffect {
systemUiController.setStatusBarColor(uiState.primaryColor)
}
}
ViewModel, StateFlow, and SavedStateHandle
ViewModel survives configuration changes and provides viewModelScope — a CoroutineScope tied to the ViewModel’s lifecycle that is automatically cancelled when the ViewModel is cleared. StateFlow<UiState> replaces LiveData<UiState> as the mechanism for exposing state to composable screens. SavedStateHandle provides access to navigation arguments and process-death restoration within the ViewModel.
import androidx.lifecycle.*
import kotlinx.coroutines.flow.*
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
private val itemRepository: ItemRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
// Read navigation arg from SavedStateHandle (survives process death)
private val categoryId: String = savedStateHandle["categoryId"] ?: "all"
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private val _navEvents = MutableSharedFlow<NavEvent>(extraBufferCapacity = 1)
val navEvents: SharedFlow<NavEvent> = _navEvents.asSharedFlow()
// Derive UI state from repository Flow + search query
val uiState: StateFlow<HomeUiState> = searchQuery
.debounce(300L)
.flatMapLatest { query ->
itemRepository.getItemsFlow(categoryId, query)
.map<List<Item>, HomeUiState> { items ->
HomeUiState.Success(items)
}
.catch { e -> emit(HomeUiState.Error(e.message ?: "Unknown error")) }
.onStart { emit(HomeUiState.Loading) }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = HomeUiState.Loading
)
fun onSearch(query: String) {
_searchQuery.value = query
}
fun onFavorite(item: Item) {
viewModelScope.launch {
itemRepository.toggleFavorite(item.id)
}
}
fun onItemClick(item: Item) {
_navEvents.tryEmit(NavEvent.Navigate("detail/${item.id}"))
}
}
// In Composable: collect with lifecycle awareness
@Composable
fun HomeRoute(viewModel: HomeViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Collect one-shot navigation events
val navController = LocalNavController.current
LaunchedEffect(Unit) {
viewModel.navEvents.collect { event ->
when (event) {
is NavEvent.Navigate -> navController.navigate(event.route)
is NavEvent.Back -> navController.popBackStack()
else -> { }
}
}
}
HomeScreen(
uiState = uiState,
onSearch = viewModel::onSearch,
onFavorite = viewModel::onFavorite
)
}
Navigation Compose and type-safe routes
Navigation Compose replaced the Fragment back stack for Compose-based apps. The Kotlin 2.x type-safe navigation API uses @Serializable route objects instead of string paths, eliminating the class of runtime crash caused by mismatched argument names between navController.navigate("route/{arg}") call sites and composable("route/{arg}") declarations.
import androidx.navigation.compose.*
import kotlinx.serialization.Serializable
// Type-safe route objects (Kotlin 2.x + Navigation 2.8+)
@Serializable
data object HomeRoute
@Serializable
data object SettingsRoute
@Serializable
data class DetailRoute(val itemId: String, val categoryId: String)
@Serializable
data class EditRoute(val itemId: String, val readOnly: Boolean = false)
// NavHost with type-safe composable destinations
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController = navController, startDestination = HomeRoute) {
composable<HomeRoute> {
HomeRoute(
onNavigateToDetail = { item ->
navController.navigate(
DetailRoute(itemId = item.id, categoryId = item.category)
)
}
)
}
composable<DetailRoute> { backStackEntry ->
// Type-safe argument extraction — no string key lookup
val route: DetailRoute = backStackEntry.toRoute()
DetailScreen(
itemId = route.itemId,
onEdit = { navController.navigate(EditRoute(route.itemId)) },
onBack = { navController.popBackStack() }
)
}
composable<EditRoute> { backStackEntry ->
val route: EditRoute = backStackEntry.toRoute()
EditScreen(
itemId = route.itemId,
readOnly = route.readOnly,
onSave = { navController.popBackStack() }
)
}
composable<SettingsRoute> {
SettingsScreen()
}
}
}
Hilt dependency injection
Hilt is Android’s recommended DI framework. It generates Dagger components at build time, wires them to Android lifecycle components (Application, Activity, Fragment, ViewModel), and eliminates the boilerplate of manual Dagger component setup. A retainer engagement often involves migrating a Koin or manual DI setup to Hilt, or restructuring an existing Hilt module graph that has accumulated Singleton-scoped dependencies that should be ViewModelScoped.
import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.components.SingletonComponent
import dagger.Module
import dagger.Provides
import dagger.Binds
import dagger.hilt.InstallIn
import javax.inject.Inject
import javax.inject.Singleton
// Application class
@HiltAndroidApp
class MyApp : Application()
// Activity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { AppNavHost(rememberNavController()) }
}
}
// Network module
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.addInterceptor(AuthInterceptor())
.build()
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
fun provideItemApi(retrofit: Retrofit): ItemApi = retrofit.create(ItemApi::class.java)
}
// Repository binding (interface to implementation)
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
}
// Database module
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
@Provides
fun provideItemDao(db: AppDatabase): ItemDao = db.itemDao()
}
// Constructor-injected repository
class ItemRepositoryImpl @Inject constructor(
private val itemDao: ItemDao,
private val itemApi: ItemApi,
private val ioDispatcher: CoroutineDispatcher
) : ItemRepository {
override fun getItemsFlow(category: String, query: String): Flow<List<Item>> =
itemDao.searchItemsFlow(category, "%$query%")
override suspend fun toggleFavorite(itemId: String) {
withContext(ioDispatcher) {
itemDao.toggleFavorite(itemId)
}
}
}
Room with Flow
Room’s reactive integration returns Flow<List<T>> from DAO queries. Room automatically re-emits on every table write, so a composable collecting a Room Flow reflects database changes without any manual invalidation. The migration from LiveData<List<T>> or synchronous List<T> DAO returns is one of the most common Kotlin retainer tasks.
import androidx.room.*
import kotlinx.coroutines.flow.Flow
// Entity
@Entity(tableName = "items", indices = [Index("category"), Index("created_at")])
data class Item(
@PrimaryKey val id: String,
val title: String,
val category: String,
val isFavorite: Boolean = false,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)
// DAO with Flow return types
@Dao
interface ItemDao {
// Room re-emits whenever the items table changes
@Query("""
SELECT * FROM items
WHERE category = :category
AND title LIKE :query
ORDER BY created_at DESC
""")
fun searchItemsFlow(category: String, query: String): Flow<List<Item>>
@Query("SELECT * FROM items WHERE id = :id")
fun getItemFlow(id: String): Flow<Item?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItem(item: Item)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertItems(items: List<Item>)
@Query("UPDATE items SET isFavorite = NOT isFavorite WHERE id = :itemId")
suspend fun toggleFavorite(itemId: String)
@Delete
suspend fun deleteItem(item: Item)
@Query("DELETE FROM items WHERE category = :category")
suspend fun deleteByCategory(category: String)
}
// Database
@Database(
entities = [Item::class, Category::class],
version = 3,
exportSchema = true
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun itemDao(): ItemDao
abstract fun categoryDao(): CategoryDao
}
// Migration
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE items ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0")
}
}
WorkManager with CoroutineWorker
WorkManager schedules guaranteed background work that survives process death and device reboots. CoroutineWorker provides a suspend doWork() function that runs on Dispatchers.Default by default. Constraints, periodic requests, and chained work sequences are the three WorkManager patterns most commonly configured in a Kotlin retainer engagement.
import androidx.work.*
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
// CoroutineWorker: suspend doWork() on Dispatchers.Default
class SyncWorker @Inject constructor(
@ApplicationContext context: Context,
workerParams: WorkerParameters,
private val itemRepository: ItemRepository
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
return try {
val lastSync = inputData.getLong("last_sync_timestamp", 0L)
val items = itemRepository.fetchRemoteItemsSince(lastSync)
itemRepository.saveItems(items)
Result.success(
workDataOf("synced_count" to items.size)
)
} catch (e: IOException) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
} catch (e: Exception) {
Result.failure(workDataOf("error" to e.message))
}
}
companion object {
const val WORK_NAME = "item_sync"
}
}
// Scheduling: one-time with constraints
fun scheduleSync(context: Context, lastSyncTimestamp: Long) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setInputData(workDataOf("last_sync_timestamp" to lastSyncTimestamp))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag(SyncWorker.WORK_NAME)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(SyncWorker.WORK_NAME, ExistingWorkPolicy.KEEP, request)
}
// Periodic sync
fun schedulePeriodicSync(context: Context) {
val request = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 6,
repeatIntervalTimeUnit = TimeUnit.HOURS,
flexTimeInterval = 30,
flexTimeIntervalUnit = TimeUnit.MINUTES
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
"periodic_sync",
ExistingPeriodicWorkPolicy.UPDATE,
request
)
}
Spring Boot with Kotlin
Spring Boot supports idiomatic Kotlin through first-class null safety annotations, data class support for request and response bodies, coroutine integration in Spring WebFlux, and the coRouter DSL for functional routing. A Kotlin developer on retainer working on a Spring Boot backend typically migrates Java-style imperative controllers to suspend functions, replaces Mono<T>/Flux<T> WebFlux chains with suspend and Flow<T>, and configures Kotlin serialization as a replacement for Jackson.
Kotlin-idiomatic controllers and WebFlux coroutines
Spring WebFlux supports suspend controller methods directly when the Kotlin coroutines reactor bridge dependency is on the classpath. The controller dispatches on the WebFlux event loop; withContext(Dispatchers.IO) inside the suspend function handles blocking I/O without blocking a reactive thread.
import org.springframework.web.bind.annotation.*
import org.springframework.http.ResponseEntity
import kotlinx.coroutines.flow.Flow
// Idiomatic Kotlin data classes for request/response
data class CreateItemRequest(
@field:NotBlank val title: String,
@field:Size(min = 1, max = 50) val category: String,
val tags: List<String> = emptyList()
)
data class ItemResponse(
val id: String,
val title: String,
val category: String,
val tags: List<String>,
val createdAt: Long
)
// suspend controller methods — no Mono/Flux boilerplate
@RestController
@RequestMapping("/api/items")
class ItemController(private val itemService: ItemService) {
@GetMapping
fun getItems(
@RequestParam(defaultValue = "all") category: String,
@RequestParam(defaultValue = "") query: String
): Flow<ItemResponse> =
itemService.searchItems(category, query).map { it.toResponse() }
@GetMapping("/{id}")
suspend fun getItem(@PathVariable id: String): ResponseEntity<ItemResponse> {
val item = itemService.getItem(id)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(item.toResponse())
}
@PostMapping
suspend fun createItem(
@RequestBody @Validated request: CreateItemRequest
): ResponseEntity<ItemResponse> {
val item = itemService.createItem(request)
return ResponseEntity.status(HttpStatus.CREATED).body(item.toResponse())
}
@PatchMapping("/{id}/favorite")
suspend fun toggleFavorite(@PathVariable id: String): ResponseEntity<Unit> {
itemService.toggleFavorite(id)
return ResponseEntity.noContent().build()
}
}
// coRouter DSL for functional routing
@Configuration
class RouterConfig(private val itemHandler: ItemHandler) {
@Bean
fun itemRouter() = coRouter {
"/api/v2/items".nest {
GET("", itemHandler::listItems)
POST("", itemHandler::createItem)
GET("/{id}", itemHandler::getItem)
DELETE("/{id}", itemHandler::deleteItem)
}
}
}
@Component
class ItemHandler(private val itemService: ItemService) {
suspend fun listItems(request: ServerRequest): ServerResponse {
val category = request.queryParamOrNull("category") ?: "all"
val items = itemService.searchItems(category, "")
return ServerResponse.ok().bodyAndAwait(items.map { it.toResponse() })
}
suspend fun createItem(request: ServerRequest): ServerResponse {
val body = request.awaitBody<CreateItemRequest>()
val item = itemService.createItem(body)
return ServerResponse.status(HttpStatus.CREATED).bodyValueAndAwait(item.toResponse())
}
suspend fun getItem(request: ServerRequest): ServerResponse {
val id = request.pathVariable("id")
val item = itemService.getItem(id) ?: return ServerResponse.notFound().buildAndAwait()
return ServerResponse.ok().bodyValueAndAwait(item.toResponse())
}
suspend fun deleteItem(request: ServerRequest): ServerResponse {
val id = request.pathVariable("id")
itemService.deleteItem(id)
return ServerResponse.noContent().buildAndAwait()
}
}
Spring Data with Kotlin and coroutine repositories
Spring Data supports coroutine repositories via CoroutineCrudRepository<T, ID> and CoroutineRepository<T, ID>. Methods return Flow<T> for collection queries and suspend for single-item operations, replacing the Flux<T>/Mono<T> reactive types.
import org.springframework.data.repository.kotlin.CoroutineCrudRepository
import org.springframework.data.r2dbc.repository.Query
import kotlinx.coroutines.flow.Flow
// Coroutine repository — all methods are suspend or return Flow
interface ItemRepository : CoroutineCrudRepository<ItemEntity, String> {
fun findByCategory(category: String): Flow<ItemEntity>
suspend fun findByIdAndCategory(id: String, category: String): ItemEntity?
@Query("""
SELECT * FROM items
WHERE category = :category
AND (title ILIKE '%' || :query || '%' OR tags @> ARRAY[:query])
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
""")
fun searchItems(
category: String,
query: String,
limit: Int = 20,
offset: Int = 0
): Flow<ItemEntity>
@Query("UPDATE items SET is_favorite = NOT is_favorite WHERE id = :id")
suspend fun toggleFavorite(id: String)
suspend fun countByCategory(category: String): Long
}
// Service layer using the coroutine repository
@Service
class ItemService(
private val itemRepository: ItemRepository,
private val categoryRepository: CategoryRepository
) {
fun searchItems(category: String, query: String): Flow<Item> =
itemRepository.searchItems(category, query).map { it.toDomain() }
suspend fun getItem(id: String): Item? =
itemRepository.findById(id)?.toDomain()
suspend fun createItem(request: CreateItemRequest): Item {
val entity = ItemEntity(
id = UUID.randomUUID().toString(),
title = request.title,
category = request.category,
tags = request.tags,
createdAt = Instant.now().toEpochMilli()
)
return itemRepository.save(entity).toDomain()
}
suspend fun toggleFavorite(id: String) = itemRepository.toggleFavorite(id)
}
Kotlin serialization and configuration properties
kotlinx.serialization provides compile-time JSON serialization without reflection. Spring Boot integrates it via KotlinSerializationJsonDecoder and KotlinSerializationJsonEncoder. @ConfigurationProperties with Kotlin data classes provides type-safe configuration binding that validates at startup.
import kotlinx.serialization.*
import kotlinx.serialization.json.*
// Kotlin serializable models
@Serializable
data class WebhookPayload(
val event: String,
val itemId: String,
@SerialName("created_at") val createdAt: Long,
val metadata: Map<String, JsonElement> = emptyMap()
)
// Polymorphic serialization
@Serializable
sealed class NotificationPayload {
@Serializable @SerialName("item_created")
data class ItemCreated(val itemId: String, val title: String) : NotificationPayload()
@Serializable @SerialName("item_deleted")
data class ItemDeleted(val itemId: String) : NotificationPayload()
}
// JSON instance with custom configuration
val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = false
prettyPrint = false
coerceInputValues = true
}
// Serialization/deserialization
val payload: String = json.encodeToString(WebhookPayload("item.created", "abc", 1000L))
val decoded: WebhookPayload = json.decodeFromString(payload)
// Spring Boot WebFlux codec configuration
@Configuration
class WebFluxConfig : WebFluxConfigurer {
override fun configureHttpMessageCodecs(registry: ServerCodecConfigurer) {
val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
registry.defaultCodecs().apply {
kotlinSerializationJsonDecoder(KotlinSerializationJsonDecoder(json))
kotlinSerializationJsonEncoder(KotlinSerializationJsonEncoder(json))
}
}
}
// Type-safe configuration properties
@ConfigurationProperties(prefix = "app")
data class AppProperties(
val apiKey: String,
val apiUrl: String,
val retryAttempts: Int = 3,
val timeoutMs: Long = 5000L,
val features: FeaturesConfig = FeaturesConfig()
)
@ConfigurationProperties(prefix = "app.features")
data class FeaturesConfig(
val enableSync: Boolean = true,
val enableWebhooks: Boolean = false,
val maxItemsPerPage: Int = 50
)
// application.yml
// app:
// api-key: ${API_KEY}
// api-url: https://api.example.com
// retry-attempts: 3
// timeout-ms: 5000
// features:
// enable-sync: true
// enable-webhooks: false
@SpringBootApplication
@ConfigurationPropertiesScan
class Application
// Bean validation with Kotlin @field: annotation (required for data class fields)
data class CreateItemRequest(
@field:NotBlank(message = "Title cannot be blank")
val title: String,
@field:Size(min = 1, max = 50, message = "Category must be 1–50 chars")
val category: String,
@field:Size(max = 10, message = "Maximum 10 tags")
val tags: List<@field:NotBlank String> = emptyList()
)
Kotlin Multiplatform
Kotlin Multiplatform (KMP) compiles shared Kotlin code to JVM bytecode for Android, native binaries for iOS via Kotlin/Native, JavaScript for web targets, and JVM for backend targets. A KMP project shares business logic, data models, network clients, and database access across platforms, with platform-specific implementations injected via the expect/actual mechanism. Structuring a KMP module from scratch or migrating an existing Android codebase to KMP is among the highest-effort Kotlin retainer tasks — and among the least visible to end users.
Project structure: expect/actual pattern
KMP projects declare platform-agnostic contracts in commonMain using expect declarations, and supply platform-specific implementations in each target’s source set using actual. The expect/actual mechanism covers classes, functions, objects, and type aliases.
// commonMain: contract declaration
expect class Platform() {
val name: String
val version: String
}
expect fun currentTimeMillis(): Long
expect fun generateUUID(): String
// androidMain: Android implementation
actual class Platform actual constructor() {
actual val name: String = "Android"
actual val version: String = android.os.Build.VERSION.RELEASE
}
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
actual fun generateUUID(): String = java.util.UUID.randomUUID().toString()
// iosMain: iOS (Kotlin/Native) implementation
actual class Platform actual constructor() {
actual val name: String = "iOS"
actual val version: String = UIDevice.currentDevice.systemVersion
}
actual fun currentTimeMillis(): Long =
(NSDate.date().timeIntervalSince1970 * 1000).toLong()
actual fun generateUUID(): String = NSUUID.UUID().UUIDString
// jvmMain: JVM (backend) implementation
actual class Platform actual constructor() {
actual val name: String = "JVM"
actual val version: String = System.getProperty("java.version") ?: "unknown"
}
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
actual fun generateUUID(): String = java.util.UUID.randomUUID().toString()
// Gradle KMP configuration (build.gradle.kts)
kotlin {
androidTarget {
compilations.all {
kotlinOptions.jvmTarget = "17"
}
}
iosX64()
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.sqldelight.runtime)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.sqldelight.android.driver)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
implementation(libs.sqldelight.native.driver)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
implementation(libs.sqldelight.sqlite.driver)
}
}
}
Ktor client for KMP networking
Ktor’s HttpClient is the standard networking client for KMP commonMain. The client engine is platform-specific (OkHttp on Android, Darwin on iOS, CIO on JVM), selected via actual factory functions or Gradle source set dependencies. The ContentNegotiation plugin with kotlinx.serialization JSON handles request and response serialization without reflection.
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
// commonMain: platform-agnostic client factory
expect fun httpEngineFactory(): HttpClientEngineFactory<*>
fun createHttpClient(): HttpClient = HttpClient(httpEngineFactory()) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
coerceInputValues = true
})
}
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO
}
install(HttpTimeout) {
requestTimeoutMillis = 15_000L
connectTimeoutMillis = 5_000L
}
}
// androidMain
actual fun httpEngineFactory(): HttpClientEngineFactory<*> = OkHttp
// iosMain
actual fun httpEngineFactory(): HttpClientEngineFactory<*> = Darwin
// commonMain: repository using the shared client
class ItemApiClient(private val client: HttpClient, private val baseUrl: String) {
suspend fun fetchItems(category: String): List<ItemDto> =
client.get("$baseUrl/items") {
parameter("category", category)
}.body()
suspend fun createItem(request: CreateItemRequest): ItemDto =
client.post("$baseUrl/items") {
contentType(ContentType.Application.Json)
setBody(request)
}.body()
suspend fun toggleFavorite(itemId: String): Unit =
client.patch("$baseUrl/items/$itemId/favorite").body()
}
// Serializable DTOs in commonMain
@Serializable
data class ItemDto(
val id: String,
val title: String,
val category: String,
val isFavorite: Boolean,
@SerialName("created_at") val createdAt: Long
)
@Serializable
data class CreateItemRequest(
val title: String,
val category: String,
val tags: List<String> = emptyList()
)
SQLDelight for cross-platform database access
SQLDelight generates type-safe Kotlin APIs from SQL schema files in commonMain. The generated queries are available in all source sets; the database driver is platform-specific (AndroidSqliteDriver on Android, NativeSqliteDriver on iOS, JdbcSqliteDriver on JVM). SQLDelight queries return Flow via the coroutines extension, enabling Room-style reactive updates across all platforms.
-- commonMain/sqldelight/com/example/db/Item.sq
CREATE TABLE Item (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
category TEXT NOT NULL,
is_favorite INTEGER AS Boolean NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
-- Named queries generate type-safe Kotlin functions
selectAll:
SELECT * FROM Item ORDER BY created_at DESC;
selectByCategory:
SELECT * FROM Item WHERE category = :category ORDER BY created_at DESC;
searchItems:
SELECT * FROM Item
WHERE category = :category AND title LIKE :query
ORDER BY created_at DESC;
insertItem:
INSERT OR REPLACE INTO Item (id, title, category, is_favorite, created_at)
VALUES (?, ?, ?, ?, ?);
toggleFavorite:
UPDATE Item SET is_favorite = NOT is_favorite WHERE id = :id;
deleteItem:
DELETE FROM Item WHERE id = :id;
// expect driver factory in commonMain
expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
// androidMain
actual class DatabaseDriverFactory actual constructor(private val context: Context) {
actual fun createDriver(): SqlDriver =
AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}
// iosMain
actual class DatabaseDriverFactory actual constructor() {
actual fun createDriver(): SqlDriver =
NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
// commonMain: shared database repository
class ItemLocalRepository(driverFactory: DatabaseDriverFactory) {
private val database = AppDatabase(driverFactory.createDriver())
private val queries = database.itemQueries
// Flow via coroutines extension — re-emits on every table write
fun searchItemsFlow(category: String, query: String): Flow<List<Item>> =
queries.searchItems(category, "%$query%")
.asFlow()
.mapToList(Dispatchers.Default)
.map { list -> list.map { it.toDomain() } }
suspend fun insertItem(item: Item) = withContext(Dispatchers.Default) {
queries.insertItem(
id = item.id,
title = item.title,
category = item.category,
is_favorite = item.isFavorite,
created_at = item.createdAt
)
}
suspend fun toggleFavorite(itemId: String) = withContext(Dispatchers.Default) {
queries.toggleFavorite(itemId)
}
fun getAll(): Flow<List<Item>> =
queries.selectAll().asFlow().mapToList(Dispatchers.Default).map { rows ->
rows.map { it.toDomain() }
}
}
Kotlin/JS
Kotlin compiles to JavaScript for browser and Node.js targets. The external keyword declares JavaScript type interop without implementation; the dynamic type escapes the type system for untyped JavaScript values. The kotlin-wrappers library provides typed React bindings for building React UIs in Kotlin.
// External declarations: typed interop with JavaScript APIs
external fun parseInt(string: String, radix: Int = definedExternally): Int
external class Date {
constructor()
constructor(milliseconds: Long)
fun getTime(): Double
fun toISOString(): String
}
external object console {
fun log(message: Any?)
fun error(message: Any?)
}
// dynamic type: escapes the type system for untyped JS
fun processUnknownJs(value: dynamic): String {
return when {
value.type == "text" -> value.content as String
value.type == "image" -> value.url as String
else -> "unknown"
}
}
// @JsExport: expose Kotlin code to JavaScript consumers
@JsExport
data class SharedConfig(
val apiUrl: String,
val version: String
)
@JsExport
fun createApiUrl(base: String, path: String): String = "$base/$path"
// React component with kotlin-wrappers
import react.*
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.h1
import react.dom.html.ReactHTML.button
external interface ItemListProps : Props {
var items: Array<Item>
var onFavorite: (String) -> Unit
}
val ItemList = FC<ItemListProps> { props ->
div {
h1 { +"Items" }
props.items.forEach { item ->
div {
key = item.id
+item.title
button {
onClick = { props.onFavorite(item.id) }
+"Favorite"
}
}
}
}
}
Structuring a Kotlin retainer
Kotlin retainer work fails to communicate its value when the engagement is structured like feature development. The client sees an invoice for 18 hours, checks the app, and finds that the same screens exist, the same data loads, and the same buttons do the same things. The RecyclerView doesn’t jank anymore, but the client didn’t know it was janking in the first place. The Spring Boot controller handles 200 concurrent requests without thread exhaustion, but the client doesn’t monitor connection pool metrics. The KMP module eliminated 2,400 lines of duplicated Swift code, but the client’s iOS engineer notices the deletion, not the hours that produced it.
The solution is an hour log that connects the invisible work to a business metric the client already cares about. A Kotlin retainer developer should log at the session level, not the task level — because a single session often spans a ViewModel refactor, a Flow pipeline redesign, and a Hilt module reorganization that are logically one piece of work. Each log entry should state the advisory category, the specific file or module, the problem identified (with a concrete metric: “20+ dropped frames per second,” not “performance issue”), the work performed (with the specific Kotlin constructs changed, not generic descriptions), the result (with the same metric measured after the change), and the hours. A client who receives this log does not need to understand coroutines to understand that 14 hours eliminated the app’s most commonly reported UX complaint.
HourTab is built for exactly this pattern. A Kotlin developer on retainer uploads their hours CSV and shares a public dashboard URL with the client — no client account required, no portal login, no email thread asking how many hours remain. The client bookmarks the URL. When they want to know the current burn rate before requesting another Compose migration or coroutine architecture session, they check the dashboard directly. The dashboard shows hours consumed, hours remaining, a session-level work log, and the retainer renewal date. The Kotlin developer doesn’t get chased for status updates; the client doesn’t feel like they’re working blind. The relationship stays on the work itself.
A Kotlin retainer agreement should distinguish between four service categories with separate hour allocations: feature development (new screens, new API endpoints, new database tables — the work that produces user-visible artifacts); architecture advisory (coroutine hierarchy design, Flow operator selection, Compose state architecture, KMP module structure — the work that produces no artifact but determines whether the codebase can scale); performance advisory (recomposition profiling with Layout Inspector, ANR analysis with Android Vitals, coroutine leak detection with LeakCanary, Spring Boot throughput profiling — the work that produces a metric improvement); and upgrade advisory (Kotlin version migration, Compose version migration, Gradle KMP configuration changes, KSP migration from KAPT — the work that produces no new behavior but keeps the toolchain current). Clients who understand which category they’re spending hours on are less likely to be surprised when a session produces no new feature.
Internal links to HourTab’s blog document the broader pattern: retainer billing for technical work that produces no deliverable proportional to the hours is a structural challenge across every software discipline, not a Kotlin-specific problem. The retainer dashboard URL is the practical solution — it makes the hours continuously visible without requiring the client to ask and the developer to respond.
Retainer rates for Kotlin developers
Kotlin developer rates reflect the language’s position as the primary language for Android development, an increasingly common backend language for Spring Boot applications, and a growing choice for cross-platform mobile development via Kotlin Multiplatform. Rates vary significantly by specialization: an Android Kotlin developer focused on Jetpack Compose and coroutines commands different rates than a KMP architect who ships shared codebases across Android, iOS, and backend, or a Spring Boot Kotlin developer building reactive WebFlux APIs.
Entry level: 1–3 years of Kotlin experience
Entry-level Kotlin developers with 1 to 3 years of experience, familiarity with Kotlin syntax (null safety, data classes, extension functions), basic coroutine usage with launch and async, and Jetpack component familiarity (ViewModel, LiveData, Room) typically bill at $80–$140 per hour. Monthly retainers at this level run 15 to 25 hours per month for feature development, unit test writing, and code review. Kotlin developers at the lower end of this range are typically coming from Java Android development and are still learning coroutine idioms; those at the upper end have written production Compose screens and understand StateFlow vs LiveData.
Mid level: 3–8 years of Kotlin experience
Mid-level Kotlin engineers with 3 to 8 years of experience — expertise in structured concurrency (SupervisorJob, CoroutineExceptionHandler, custom CoroutineScope lifecycle management), Flow operator chains (flatMapLatest, combine, stateIn, shareIn), Jetpack Compose state architecture (sealed UiState classes, hoisted state, derivedStateOf, LaunchedEffect lifecycle contracts), Hilt dependency injection (module design, scope selection, TestComponent wiring), Room database migration, and Spring Boot Kotlin WebFlux — typically bill at $135–$240 per hour. Monthly retainers at this level run 20 to 35 hours per month and typically cover a mix of architecture advisory, feature development, and performance investigation.
Senior level: 8–15 years of experience
Senior Kotlin architects and engineers with 8 to 15 years of experience — deep Kotlin compiler internals (inline functions, reified generics, value classes, K2 compiler migration), Kotlin Multiplatform architecture (expect/actual design, XCFramework packaging, Kotlin/Native memory model, Gradle multiplatform configuration), custom Kotlin DSL builder design with @DslMarker, Kotlin Symbol Processing (KSP) for annotation processing, Compose compiler metrics and recomposition optimization, and Kotlin coroutines internal implementation (ContinuationInterceptor, CoroutineContext element composition, structured exception propagation through Job hierarchies) — typically bill at $195–$370 per hour. Monthly retainers at this level run 20 to 40 hours per month, often covering architecture advisory across multiple teams, KMP module design, Kotlin version migration strategy, and Compose performance investigation.
Firm and agency rates
Kotlin consulting firms and agencies with multi-developer teams, formal project management, and pooled expertise across Android, Spring Boot Kotlin, and KMP typically bill at $165–$295 per hour. Firm retainers usually include a dedicated technical lead, structured sprint planning, and monthly architecture review sessions alongside the development work.
Monthly retainer amounts
Monthly retainer amounts for Kotlin advisory engagements reflect the hour volume and rate band of the work. Advisory-only retainers covering architecture review, code review, and technical direction without hands-on feature development typically run $4,500–$9,000 per month (15 to 30 hours per month at mid-to-senior rates). This tier works for companies with an internal Android or backend team who want a senior Kotlin specialist to review critical architectural decisions, audit coroutine usage, approve Compose state designs, and advise on KMP adoption strategy without being on the critical path for feature delivery.
Full consulting retainers covering hands-on feature development, architecture design, coroutine migration, Compose adoption, KMP shared module development, Spring Boot Kotlin backend integration, and ongoing code review typically run $13,000–$26,000 per month (40 to 80 hours per month at senior rates, or 60 to 120 hours per month at mid rates). This tier works for companies building a new Kotlin-based product, migrating a Java Android app to Kotlin and Compose, or building a KMP shared codebase from scratch. At this engagement depth, the Kotlin developer functions as a fractional lead engineer — setting architecture standards, writing the highest-risk code, and reviewing all code that touches the coroutine or Compose layers.
Kotlin Multiplatform retainers at the upper end of the full consulting range often include iOS integration work (XCFramework packaging, CocoaPods/SPM configuration, Kotlin/Native interop debugging) that requires coordinating with the iOS team, which adds hours not always visible in the Kotlin codebase alone. Clients hiring a KMP specialist on retainer should budget for iOS integration sessions explicitly rather than treating them as overhead within a fixed monthly allocation.
HourTab gives Kotlin consultants a public, no-login retainer dashboard URL their clients can bookmark. No client account. No portal. Upload your hours CSV, share the link. Start free →