Skip to content
Published on

Kotlin Complete Guide 2025: From Spring Boot Backend to Android, Multiplatform & Coroutines

Authors

Table of Contents

1. Kotlin in 2025

Kotlin is one of the fastest-growing languages in the JVM ecosystem in 2025. Since Google adopted it as the official language for Android development, it has expanded into server-side, multiplatform, and even data science.

Kotlin Adoption Status

DomainStatusPrimary Use
AndroidOfficial primary language95%+ new projects in Kotlin
Spring BootOfficial support (1st class)Coroutines + WebFlux integration
KtorKotlin-native serverLightweight microservices
KMPStable (1.9.20+)Shared iOS/Android business logic
Compose MultiplatformBeta to StableShared Android/Desktop/Web UI
Data ScienceGrowingKotlin DataFrame, KotlinDL

Why Kotlin

  • Conciseness: 40% less code compared to Java
  • Safety: Null safety at the type system level
  • Interoperability: 100% compatible with existing Java code
  • Coroutines: Simplified async processing with structured concurrency
  • Multiplatform: Support multiple platforms from a single codebase

2. Kotlin vs Java: Comparison and Migration

Syntax Comparison

// Java
public class User {
    private final String name;
    private final int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }

    @Override
    public boolean equals(Object o) { /* ... */ }
    @Override
    public int hashCode() { /* ... */ }
    @Override
    public String toString() { /* ... */ }
}

// Kotlin — same functionality in one line
data class User(val name: String, val age: Int)

Key Differences

FeatureJavaKotlin
Null SafetyOptional, annotationsBuilt into type system (?/!!)
Data ClassesRecord (Java 16+)data class (since 1.0)
Extension FunctionsNot possibleNative support
CoroutinesProject Loom (Virtual Threads)suspend/launch/async
Sealed Typessealed (Java 17+)sealed class/interface
Smart Castsinstanceof + castingAutomatic casting
Default ParametersOverloading requiredDefault values supported
String TemplatesJava 21+ STRBuilt-in support
Scope FunctionsNonelet, run, apply, also, with

Migrating from Java to Kotlin

// Step 1: Add Kotlin to build.gradle.kts
plugins {
    kotlin("jvm") version "2.0.0"
    kotlin("plugin.spring") version "2.0.0"
}

// Step 2: Convert Java files to Kotlin (IntelliJ: Ctrl+Alt+Shift+K)
// Step 3: Apply Kotlin idioms
// Step 4: Introduce coroutines

Migration strategy:

  1. Write new files in Kotlin
  2. Convert tests to Kotlin first
  3. Convert data classes as priority
  4. Gradually convert the service layer
  5. Introduce coroutines as the final step

3. Core Kotlin Language Features

Null Safety

// Nullable type declaration
var name: String? = null

// Safe call operator
val length = name?.length  // returns null if name is null

// Elvis operator
val length = name?.length ?: 0  // default value if null

// Smart cast
if (name != null) {
    println(name.length)  // automatically cast to String
}

// let scope function
name?.let { nonNullName ->
    println(nonNullName.length)
}

Data Class

data class User(
    val id: Long,
    val name: String,
    val email: String,
    val role: Role = Role.USER  // default value
) {
    // Modify immutable object with copy
    fun promote() = copy(role = Role.ADMIN)
}

// Destructuring declaration
val (id, name, email) = user

Sealed Class / Sealed Interface

sealed interface Result<out T> {
    data class Success<T>(val data: T) : Result<T>
    data class Error(val exception: Throwable) : Result<Nothing>
    data object Loading : Result<Nothing>
}

// Exhaustive when expression
fun handleResult(result: Result<User>) = when (result) {
    is Result.Success -> println("User: ${result.data}")
    is Result.Error -> println("Error: ${result.exception.message}")
    is Result.Loading -> println("Loading...")
    // no else needed — all cases handled
}

Extension Functions

// Add new method to String
fun String.toSlug(): String =
    lowercase()
        .replace(Regex("[^a-z0-9\\s-]"), "")
        .replace(Regex("\\s+"), "-")
        .trim('-')

// Usage
val slug = "Hello World 2025!".toSlug()  // "hello-world-2025"

// Collection extension
fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null

// Generic extension
inline fun <reified T> Any.castOrNull(): T? = this as? T

Delegation

// Class delegation
interface Repository {
    fun findAll(): List<User>
}

class CachedRepository(
    private val delegate: Repository
) : Repository by delegate {
    private val cache = mutableMapOf<String, List<User>>()

    override fun findAll(): List<User> =
        cache.getOrPut("all") { delegate.findAll() }
}

// Property delegation
class UserPreferences {
    var theme: String by Delegates.observable("light") { _, old, new ->
        println("Theme changed: $old -> $new")
    }

    val expensiveValue: String by lazy {
        computeExpensiveValue()
    }
}

DSL (Domain Specific Language)

// HTML DSL example
fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}

class HTML {
    fun body(init: Body.() -> Unit) { /* ... */ }
}

class Body {
    fun p(text: String) { /* ... */ }
    fun div(init: Div.() -> Unit) { /* ... */ }
}

// Usage
val page = html {
    body {
        p("Hello, Kotlin DSL!")
        div {
            p("Nested content")
        }
    }
}

// Real-world: Gradle Kotlin DSL
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webflux")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
    testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
}

4. Coroutines Deep Dive

Core Concepts

Coroutines are an asynchronous processing mechanism that behaves like lightweight threads. They can suspend and resume without blocking actual OS threads.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // launch: coroutine that doesn't return a result
    val job = launch {
        delay(1000L)
        println("World!")
    }

    // async: coroutine that returns a result
    val deferred = async {
        delay(1000L)
        42
    }

    println("Hello,")
    job.join()
    println("Answer: ${deferred.await()}")
}

suspend Functions

// suspend functions can only be called from coroutines
suspend fun fetchUser(id: Long): User {
    return withContext(Dispatchers.IO) {
        // Network/DB operations
        userRepository.findById(id)
    }
}

suspend fun fetchUserWithPosts(id: Long): UserWithPosts {
    return coroutineScope {
        // Parallel execution
        val userDeferred = async { fetchUser(id) }
        val postsDeferred = async { fetchPosts(id) }

        UserWithPosts(
            user = userDeferred.await(),
            posts = postsDeferred.await()
        )
    }
}

Structured Concurrency

suspend fun processOrder(orderId: Long) = coroutineScope {
    // Parent returns only when all children complete
    val inventory = async { checkInventory(orderId) }
    val payment = async { processPayment(orderId) }

    // If one fails, the other is cancelled too
    try {
        val inventoryResult = inventory.await()
        val paymentResult = payment.await()
        completeOrder(orderId, inventoryResult, paymentResult)
    } catch (e: Exception) {
        cancelOrder(orderId)
        throw e
    }
}

Flow (Reactive Streams)

import kotlinx.coroutines.flow.*

// Cold Flow — executes on subscription
fun numberFlow(): Flow<Int> = flow {
    for (i in 1..10) {
        delay(100)
        emit(i)
    }
}

// Operator chaining
suspend fun processNumbers() {
    numberFlow()
        .filter { it % 2 == 0 }
        .map { it * it }
        .catch { e -> println("Error: $e") }
        .collect { value -> println(value) }
}

// StateFlow (UI state management)
class UserViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    fun loadUser(id: Long) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val user = repository.fetchUser(id)
                _uiState.value = UiState.Success(user)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

// SharedFlow (event broadcast)
class EventBus {
    private val _events = MutableSharedFlow<Event>()
    val events: SharedFlow<Event> = _events.asSharedFlow()

    suspend fun emit(event: Event) {
        _events.emit(event)
    }
}

Cancellation and Timeouts

// Coroutine cancellation
val job = launch {
    repeat(1000) { i ->
        println("Processing $i")
        delay(500)  // cancellation point
    }
}

delay(2500)
job.cancelAndJoin()  // cancel and wait for completion

// Timeout
val result = withTimeoutOrNull(3000L) {
    fetchDataFromNetwork()
} ?: defaultValue

// Non-cancellable work
suspend fun saveToDb(data: Data) {
    withContext(NonCancellable) {
        // Must execute even when cancelled
        database.save(data)
    }
}

5. Spring Boot + Kotlin

Project Setup

// build.gradle.kts
plugins {
    id("org.springframework.boot") version "3.3.0"
    id("io.spring.dependency-management") version "1.1.5"
    kotlin("jvm") version "2.0.0"
    kotlin("plugin.spring") version "2.0.0"
    kotlin("plugin.jpa") version "2.0.0"
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webflux")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
    implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
    testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.3")
    testImplementation("io.mockk:mockk:1.13.10")
}

WebFlux + Coroutines

@RestController
@RequestMapping("/api/users")
class UserController(private val userService: UserService) {

    // suspend function for async endpoint
    @GetMapping("/{id}")
    suspend fun getUser(@PathVariable id: Long): ResponseEntity<UserResponse> {
        val user = userService.findById(id)
            ?: return ResponseEntity.notFound().build()
        return ResponseEntity.ok(user.toResponse())
    }

    // Flow for streaming response
    @GetMapping(produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
    fun getAllUsers(): Flow<UserResponse> =
        userService.findAll().map { it.toResponse() }

    @PostMapping
    suspend fun createUser(
        @Valid @RequestBody request: CreateUserRequest
    ): ResponseEntity<UserResponse> {
        val user = userService.create(request)
        return ResponseEntity
            .created(URI.create("/api/users/${user.id}"))
            .body(user.toResponse())
    }
}

@Service
class UserService(private val userRepository: UserRepository) {

    suspend fun findById(id: Long): User? =
        userRepository.findById(id)

    fun findAll(): Flow<User> =
        userRepository.findAll().asFlow()

    @Transactional
    suspend fun create(request: CreateUserRequest): User {
        val user = User(
            name = request.name,
            email = request.email
        )
        return userRepository.save(user)
    }
}

Spring Data R2DBC

interface UserRepository : CoroutineCrudRepository<User, Long> {

    suspend fun findByEmail(email: String): User?

    @Query("SELECT * FROM users WHERE name LIKE :pattern")
    fun searchByName(pattern: String): Flow<User>

    @Query("SELECT COUNT(*) FROM users WHERE active = true")
    suspend fun countActive(): Long
}

@Table("users")
data class User(
    @Id val id: Long? = null,
    val name: String,
    val email: String,
    val active: Boolean = true,
    val createdAt: LocalDateTime = LocalDateTime.now()
)

Testing: MockK + Kotest

class UserServiceTest : FunSpec({

    val userRepository = mockk<UserRepository>()
    val userService = UserService(userRepository)

    test("findById should return user when exists") {
        // Given
        val user = User(id = 1, name = "Kim", email = "kim@test.com")
        coEvery { userRepository.findById(1L) } returns user

        // When
        val result = userService.findById(1L)

        // Then
        result shouldNotBe null
        result!!.name shouldBe "Kim"
        coVerify(exactly = 1) { userRepository.findById(1L) }
    }

    test("findById should return null when not exists") {
        coEvery { userRepository.findById(any()) } returns null

        val result = userService.findById(999L)

        result shouldBe null
    }

    test("create should save and return user") {
        val request = CreateUserRequest("Park", "park@test.com")
        val savedUser = User(id = 1, name = "Park", email = "park@test.com")
        coEvery { userRepository.save(any()) } returns savedUser

        val result = userService.create(request)

        result.id shouldBe 1
        result.name shouldBe "Park"
    }
})

6. Ktor: Kotlin-Native Server

Project Configuration

// build.gradle.kts
plugins {
    kotlin("jvm") version "2.0.0"
    kotlin("plugin.serialization") version "2.0.0"
    id("io.ktor.plugin") version "2.3.11"
}

dependencies {
    implementation("io.ktor:ktor-server-core-jvm")
    implementation("io.ktor:ktor-server-netty-jvm")
    implementation("io.ktor:ktor-server-content-negotiation-jvm")
    implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
    implementation("io.ktor:ktor-server-auth-jvm")
    implementation("io.ktor:ktor-server-auth-jwt-jvm")
    implementation("io.ktor:ktor-server-websockets-jvm")
    implementation("io.ktor:ktor-server-status-pages-jvm")
}

Routing and Plugins

fun main() {
    embeddedServer(Netty, port = 8080) {
        configureSerialization()
        configureRouting()
        configureAuth()
    }.start(wait = true)
}

fun Application.configureSerialization() {
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = true
            isLenient = true
            ignoreUnknownKeys = true
        })
    }
}

fun Application.configureRouting() {
    routing {
        route("/api") {
            userRoutes()
            postRoutes()
        }
    }
}

fun Route.userRoutes() {
    val userService = UserService()

    route("/users") {
        get {
            val users = userService.findAll()
            call.respond(users)
        }

        get("/{id}") {
            val id = call.parameters["id"]?.toLongOrNull()
                ?: throw BadRequestException("Invalid ID")
            val user = userService.findById(id)
                ?: throw NotFoundException("User not found")
            call.respond(user)
        }

        post {
            val request = call.receive<CreateUserRequest>()
            val user = userService.create(request)
            call.respond(HttpStatusCode.Created, user)
        }
    }
}

WebSocket

fun Application.configureWebSocket() {
    install(WebSockets) {
        pingPeriod = Duration.ofSeconds(15)
        timeout = Duration.ofSeconds(60)
        maxFrameSize = Long.MAX_VALUE
    }

    routing {
        val connections = Collections.synchronizedSet<Connection>(mutableSetOf())

        webSocket("/chat") {
            val connection = Connection(this)
            connections += connection
            try {
                for (frame in incoming) {
                    frame as? Frame.Text ?: continue
                    val text = frame.readText()
                    connections.forEach {
                        it.session.send("${connection.name}: $text")
                    }
                }
            } finally {
                connections -= connection
            }
        }
    }
}

7. Android + Jetpack Compose

Compose Basics

@Composable
fun UserListScreen(
    viewModel: UserListViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    Scaffold(
        topBar = {
            TopAppBar(title = { Text("Users") })
        }
    ) { padding ->
        when (val state = uiState) {
            is UiState.Loading -> LoadingIndicator(Modifier.padding(padding))
            is UiState.Success -> UserList(
                users = state.data,
                modifier = Modifier.padding(padding)
            )
            is UiState.Error -> ErrorMessage(
                message = state.message,
                onRetry = viewModel::retry,
                modifier = Modifier.padding(padding)
            )
        }
    }
}

@Composable
fun UserList(users: List<User>, modifier: Modifier = Modifier) {
    LazyColumn(modifier = modifier) {
        items(users, key = { it.id }) { user ->
            UserCard(user = user)
        }
    }
}

@Composable
fun UserCard(user: User) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
        elevation = CardDefaults.cardElevation(4.dp)
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = user.name,
                style = MaterialTheme.typography.titleMedium
            )
            Text(
                text = user.email,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
        }
    }
}

State Management and Navigation

// ViewModel
@HiltViewModel
class UserListViewModel @Inject constructor(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState<List<User>>>(UiState.Loading)
    val uiState: StateFlow<UiState<List<User>>> = _uiState.asStateFlow()

    init { loadUsers() }

    private fun loadUsers() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            userRepository.getUsers()
                .onSuccess { users ->
                    _uiState.value = UiState.Success(users)
                }
                .onFailure { error ->
                    _uiState.value = UiState.Error(error.message ?: "Unknown error")
                }
        }
    }

    fun retry() = loadUsers()
}

// Navigation (Type-safe)
@Serializable
sealed class Screen {
    @Serializable
    data object UserList : Screen()

    @Serializable
    data class UserDetail(val userId: Long) : Screen()
}

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = Screen.UserList) {
        composable<Screen.UserList> {
            UserListScreen(
                onUserClick = { userId ->
                    navController.navigate(Screen.UserDetail(userId))
                }
            )
        }
        composable<Screen.UserDetail> { backStackEntry ->
            val screen: Screen.UserDetail = backStackEntry.toRoute()
            UserDetailScreen(userId = screen.userId)
        }
    }
}

8. Kotlin Multiplatform (KMP)

Project Structure

shared/
  src/
    commonMain/     <- Shared code
    androidMain/    <- Android implementation
    iosMain/        <- iOS implementation
androidApp/         <- Android app
iosApp/             <- iOS app (Swift)

expect / actual Pattern

// commonMain — define common interface
expect class PlatformContext

expect fun getPlatformName(): String

expect class DatabaseDriverFactory {
    fun createDriver(): SqlDriver
}

// androidMain — Android implementation
actual typealias PlatformContext = Context

actual fun getPlatformName(): String = "Android ${Build.VERSION.SDK_INT}"

actual class DatabaseDriverFactory(private val context: Context) {
    actual fun createDriver(): SqlDriver =
        AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}

// iosMain — iOS implementation
actual class PlatformContext

actual fun getPlatformName(): String = UIDevice.currentDevice.systemName()

actual class DatabaseDriverFactory {
    actual fun createDriver(): SqlDriver =
        NativeSqliteDriver(AppDatabase.Schema, "app.db")
}

Shared Business Logic

// commonMain
class UserRepository(
    private val api: UserApi,
    private val db: UserDatabase,
    private val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun getUsers(forceRefresh: Boolean = false): List<User> =
        withContext(dispatcher) {
            if (!forceRefresh) {
                val cached = db.getUsers()
                if (cached.isNotEmpty()) return@withContext cached
            }
            val users = api.fetchUsers()
            db.saveUsers(users)
            users
        }

    fun observeUsers(): Flow<List<User>> =
        db.observeUsers()
}

// Ktor-based shared HTTP client
class UserApi(private val client: HttpClient) {
    suspend fun fetchUsers(): List<User> =
        client.get("https://api.example.com/users").body()
}

// Shared HttpClient configuration
expect fun httpClient(): HttpClient

// androidMain
actual fun httpClient(): HttpClient = HttpClient(OkHttp) {
    install(ContentNegotiation) { json() }
}

// iosMain
actual fun httpClient(): HttpClient = HttpClient(Darwin) {
    install(ContentNegotiation) { json() }
}

Compose Multiplatform

// commonMain — shared UI
@Composable
fun App() {
    MaterialTheme {
        var users by remember { mutableStateOf<List<User>>(emptyList()) }

        LaunchedEffect(Unit) {
            users = repository.getUsers()
        }

        UserListScreen(users)
    }
}

// Android
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { App() }
    }
}

// iOS (SwiftUI)
// ContentView.swift
// struct ContentView: View {
//     var body: some View {
//         ComposeView().ignoresSafeArea()
//     }
// }

9. Advanced Features

Inline / Reified

// inline function: eliminates lambda call overhead
inline fun <T> measureTime(block: () -> T): Pair<T, Long> {
    val start = System.nanoTime()
    val result = block()
    val duration = System.nanoTime() - start
    return result to duration
}

// reified: preserves type information at runtime
inline fun <reified T> String.parseJson(): T =
    Json.decodeFromString<T>(this)

// Usage
val user = """{"name":"Kim","age":30}""".parseJson<User>()

// crossinline: prohibits non-local returns
inline fun runSafe(crossinline block: () -> Unit) {
    try { block() } catch (e: Exception) { /* handle */ }
}

Contracts

import kotlin.contracts.*

// Contracts tell the compiler about function behavior
fun String?.isNotNullOrEmpty(): Boolean {
    contract {
        returns(true) implies (this@isNotNullOrEmpty != null)
    }
    return this != null && this.isNotEmpty()
}

// Usage — enables smart cast
fun greet(name: String?) {
    if (name.isNotNullOrEmpty()) {
        println("Hello, $name!")  // name smart-cast to String
    }
}

Context Receivers (Experimental)

context(LoggerContext, TransactionContext)
fun transferMoney(from: Account, to: Account, amount: BigDecimal) {
    log("Transfer $amount from ${from.id} to ${to.id}")
    transaction {
        from.withdraw(amount)
        to.deposit(amount)
    }
}

Value Classes

@JvmInline
value class UserId(val value: Long)

@JvmInline
value class Email(val value: String) {
    init {
        require(value.contains("@")) { "Invalid email" }
    }
}

// Inlined to Long/String at compile time — zero overhead
fun findUser(id: UserId): User? = repository.findById(id.value)

// Type safety guaranteed
val userId = UserId(42L)
val email = Email("user@example.com")
// findUser(email)  // Compile error!

10. Testing

Kotest

// BehaviorSpec (BDD style)
class UserServiceBehaviorSpec : BehaviorSpec({
    val repository = mockk<UserRepository>()
    val service = UserService(repository)

    Given("a user exists in the database") {
        val user = User(1, "Kim", "kim@test.com")
        coEvery { repository.findById(1L) } returns user

        When("findById is called with the user's ID") {
            val result = service.findById(1L)

            Then("it should return the user") {
                result shouldBe user
            }
        }
    }

    Given("no user exists") {
        coEvery { repository.findById(any()) } returns null

        When("findById is called") {
            val result = service.findById(999L)

            Then("it should return null") {
                result shouldBe null
            }
        }
    }
})

// Property-based testing
class StringExtensionTest : StringSpec({
    "toSlug should produce valid slugs" {
        checkAll(Arb.string(1..100)) { input ->
            val slug = input.toSlug()
            slug shouldNotContain " "
            slug shouldMatch Regex("[a-z0-9-]*")
        }
    }
})

MockK

// Coroutine mocking
coEvery { repository.findById(any()) } returns mockUser
coVerify { repository.save(match { it.name == "Kim" }) }

// Extension function mocking
mockkStatic("com.example.ExtensionsKt")
every { "test".toSlug() } returns "test-slug"

// Object mocking
mockkObject(DateUtils)
every { DateUtils.now() } returns fixedDate

Turbine (Flow Testing)

class UserViewModelTest : FunSpec({
    test("loadUsers should emit Loading then Success") {
        val viewModel = UserViewModel(mockRepository)

        viewModel.uiState.test {
            viewModel.loadUsers()

            awaitItem() shouldBe UiState.Loading
            val success = awaitItem()
            success.shouldBeInstanceOf<UiState.Success<List<User>>>()
            success.data.size shouldBe 3

            cancelAndIgnoreRemainingEvents()
        }
    }
})

Coroutine Testing

class CoroutineTest : FunSpec({
    test("structured concurrency cancellation") {
        runTest {
            var completed = false

            val job = launch {
                try {
                    delay(10_000)
                    completed = true
                } catch (e: CancellationException) {
                    // cleanup
                    throw e
                }
            }

            advanceTimeBy(5_000)
            job.cancel()
            job.join()

            completed shouldBe false
        }
    }
})

11. Interview Questions and Quizzes

Top 15 Interview Questions

Q1: How does Kotlin's null safety work?

Kotlin distinguishes between nullable and non-nullable types at the type system level. String cannot be null, while String? can be null. It provides tools like safe call operator (?.), Elvis operator (?:), and non-null assertion (!!). The compiler enforces null checks to prevent runtime NullPointerExceptions.

Q2: What's the difference between a data class and a regular class?

A data class automatically generates equals(), hashCode(), toString(), copy(), and componentN() functions. It must have at least one parameter in the primary constructor and cannot be abstract, open, sealed, or inner. It's ideal for DTOs and value objects.

Q3: Explain use cases for sealed classes.

Sealed classes define a restricted class hierarchy. When all subtypes are handled in a when expression, no else branch is needed, ensuring compile-time safety. They're ideal for API response states (Success/Error/Loading), navigation events, and UI state representation.

Q4: What's the difference between coroutines and threads?

Coroutines are much lighter than threads. Thousands of coroutines can run on a single thread. Coroutines use cooperative multitasking (yielding at suspension points), while threads use preemptive multitasking. Structured concurrency makes lifecycle management easier.

Q5: What's the difference between launch and async?

launch returns a Job and is fire-and-forget with no result. async returns a Deferred<T> and its result can be obtained with await(). Use launch when no result is needed, and async when you need results from parallel operations.

Q6: What's the difference between Flow and Channel?

Flow is a cold stream that only executes when subscribed. Channel is a hot stream that transmits data regardless of subscribers. Flow is declarative with easy transformations, while Channel is imperative and used for inter-coroutine communication.

Q7: What should you watch out for when using Kotlin with Spring Boot?

The kotlin-spring plugin is needed to make classes open (for Spring AOP proxies). The kotlin-jpa plugin adds no-arg constructors. Jackson's kotlin-module supports data class serialization. For coroutines, WebFlux + kotlinx-coroutines-reactor is required.

Q8: How do you choose between Ktor and Spring Boot?

Ktor excels at being lightweight, Kotlin-native, and DSL-based configuration. It's suitable for microservices and lightweight environments like Lambda. Spring Boot has a vast ecosystem, enterprise features, and auto-configuration. It's better for large applications or existing Spring infrastructure.

Q9: How do you optimize recomposition in Jetpack Compose?

Use remember for value caching, derivedStateOf for derived state management, Stable/Immutable annotations for stability marking, key for list item identification, lambda stabilization (wrapping with remember), and minimizing state read scope.

Q10: How do you manage platform-specific dependencies in Kotlin Multiplatform?

Use the expect/actual mechanism. Declare expect in commonMain and provide actual implementations in each platform (androidMain/iosMain). Dependencies are managed in the dependencies block of each source set.

Q11: What's the relationship between inline functions and reified type parameters?

Due to JVM type erasure, generic type information isn't available at runtime in regular functions. Inline functions have their code inlined at the call site, so the reified keyword can preserve type information. This enables is T checks and T::class access.

Q12: Explain Kotlin delegation.

Class delegation (by keyword) delegates interface implementation to another object, implementing the decorator pattern without boilerplate. Property delegation (lazy, observable, map) reuses getter/setter logic.

Q13: Why is structured concurrency important?

When a parent coroutine is cancelled, all child coroutines are automatically cancelled. If one child fails, sibling coroutines are also cancelled. Coroutine lifecycle is bound to scope, preventing memory leaks and resource leaks.

Q14: What are the use cases and limitations of value classes?

Value classes provide type safety with zero runtime overhead. They're ideal for wrapper types like UserId and Email. Limitations: only one property allowed, can implement interfaces but not inherit, no var properties, no === comparison.

Q15: How do Kotlin DSLs work?

They're based on lambdas with receivers. The T.() -> Unit form allows calling T's members directly within the scope. This enables building domain-specific languages like Gradle build scripts, Ktor routing, and HTML builders.

5 Quizzes

Q1: What's the output of this code?
fun main() = runBlocking {
    val result = coroutineScope {
        val a = async { delay(100); 1 }
        val b = async { delay(200); 2 }
        a.await() + b.await()
    }
    println(result)
}

Answer: 3

Both async coroutines run in parallel. a returns 1 after 100ms, b returns 2 after 200ms. Total time is about 200ms, and the result is 1 + 2 = 3. coroutineScope waits until all children complete.

Q2: What's the difference between sealed interface and sealed class?

Answer:

A sealed class can have state (properties) but only supports single inheritance. A sealed interface cannot have state but supports multiple implementation. With sealed interfaces, subclasses can inherit from other classes while belonging to the sealed hierarchy, offering more flexibility. Sealed interfaces were introduced in Kotlin 1.5.

Q3: Find the problem in this code.
class MyViewModel : ViewModel() {
    fun loadData() {
        GlobalScope.launch {
            val data = repository.fetchData()
            _state.value = data
        }
    }
}

Answer:

Using GlobalScope means the coroutine continues running even after the ViewModel is destroyed. This causes memory leaks and unnecessary work. Use viewModelScope.launch instead, which automatically cancels when the ViewModel is destroyed. This code violates the structured concurrency principle.

Q4: What are the differences between let, run, apply, also, and with?

Answer:

FunctionObject ReferenceReturn ValueUse Case
letitLambda resultNull check + transform
runthisLambda resultObject setup + compute result
applythisReceiver objectObject initialization
alsoitReceiver objectSide effects (logging, etc.)
withthisLambda resultCalling methods on existing object
Q5: What's the difference between StateFlow and SharedFlow?

Answer:

StateFlow holds a current state value and immediately delivers the latest value to new subscribers. It always requires an initial value and ignores duplicate values. SharedFlow has no state and is used for event broadcasting. Buffering is controlled via the replay parameter. StateFlow is suitable for UI state, while SharedFlow is ideal for one-time events (navigation, toasts).


References

  1. Kotlin Official Documentation - Language reference and tutorials
  2. Kotlin Coroutines Guide - Official coroutines documentation
  3. Spring Boot Kotlin Support - Spring Kotlin guide
  4. Ktor Official Documentation - Ktor framework guide
  5. Jetpack Compose - Android Compose official docs
  6. Kotlin Multiplatform - KMP official guide
  7. Kotest Framework - Kotlin test framework
  8. MockK - Kotlin mocking library
  9. Turbine - Flow testing library
  10. Kotlin Coroutines Design Document - Coroutine design philosophy
  11. Android Developers - Kotlin - Android Kotlin guide
  12. Compose Multiplatform - JetBrains Compose Multiplatform
  13. Kotlin KEEP - Kotlin Evolution and Enhancement Process
  14. kotlinx.serialization - Serialization library
  15. Arrow - Kotlin functional programming library
  16. SQLDelight - KMP SQL library