Estimated time: about 30 minutes
TL;DR
async { }+await()— call two APIs in parallel, faster than serial- Using
suspend funon@GetMappingenables coroutine controllers in Spring MVCwithTimeout(ms) { }— set a timeout; on exceed throwsTimeoutCancellationException- Structured concurrency — when the parent coroutine is cancelled, child coroutines are also cancelled automatically
Target audience: Developers with Kotlin basics and Spring Boot experience Prerequisites: Spring Boot Integration, basic coroutine concepts
Apply coroutines to real-world scenarios. Call two external APIs in parallel, use suspend fun inside Spring MVC controllers, and handle timeouts and cancellation propagation.
Step 1 — Dependencies#
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-webflux") // For WebClient
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.8.1") // Spring integration
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
}kotlinx-coroutines-reactor bridges Reactor (Project Reactor) and coroutines, allowing suspend fun to be used in Spring MVC.
Step 2 — Calling Two External APIs in Parallel#
The most common real-world scenario: fetch user info and order history from different services.
// src/main/kotlin/com/example/coroutine/service/UserDashboardService.kt
package com.example.coroutine.service
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withTimeout
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody
data class UserInfo(val id: Long, val name: String, val email: String)
data class OrderHistory(val userId: Long, val orders: List<String>, val totalAmount: Long)
data class UserDashboard(val user: UserInfo, val orders: OrderHistory, val loadTimeMs: Long)
@Service
class UserDashboardService(
private val webClient: WebClient
) {
private val log = LoggerFactory.getLogger(this::class.java)
suspend fun getDashboard(userId: Long): UserDashboard {
val startTime = System.currentTimeMillis()
log.info("Dashboard query started: userId=$userId")
// coroutineScope — wait until both tasks complete
// If one fails, the other is cancelled (structured concurrency)
return coroutineScope {
// async — call two APIs concurrently
val userDeferred = async {
withTimeout(3_000L) { // 3-second timeout
fetchUserInfo(userId)
}
}
val ordersDeferred = async {
withTimeout(5_000L) { // 5-second timeout
fetchOrderHistory(userId)
}
}
// Wait for both results — parallel execution
val user = userDeferred.await()
val orders = ordersDeferred.await()
val elapsed = System.currentTimeMillis() - startTime
log.info("Dashboard query complete: ${elapsed}ms")
UserDashboard(user, orders, elapsed)
}
}
private suspend fun fetchUserInfo(userId: Long): UserInfo {
log.info("Fetching user info: $userId")
// In real code, you'd call an external API
// return webClient.get()
// .uri("http://user-service/users/$userId")
// .retrieve()
// .awaitBody<UserInfo>()
// Simulation
kotlinx.coroutines.delay(500L)
return UserInfo(userId, "Alice", "alice@example.com")
}
private suspend fun fetchOrderHistory(userId: Long): OrderHistory {
log.info("Fetching order history: $userId")
// Simulation
kotlinx.coroutines.delay(800L)
return OrderHistory(userId, listOf("ORDER-001", "ORDER-002"), 150_000L)
}
}Instead of serial processing (500ms + 800ms = 1300ms), parallel processing (max(500ms, 800ms) = 800ms) is about 40% faster.
Step 3 — suspend Controller#
You can use suspend fun as a controller method in Spring MVC. When the coroutine-Reactor adapter (kotlinx-coroutines-reactor) is on the classpath, Spring automatically recognizes coroutines.
// src/main/kotlin/com/example/coroutine/controller/DashboardController.kt
package com.example.coroutine.controller
import com.example.coroutine.service.UserDashboard
import com.example.coroutine.service.UserDashboardService
import kotlinx.coroutines.TimeoutCancellationException
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api")
class DashboardController(
private val dashboardService: UserDashboardService
) {
private val log = LoggerFactory.getLogger(this::class.java)
// suspend fun — Spring runs this as a coroutine
@GetMapping("/users/{userId}/dashboard")
suspend fun getDashboard(@PathVariable userId: Long): UserDashboard {
return dashboardService.getDashboard(userId)
}
// Exception handling — including timeouts
@GetMapping("/users/{userId}/dashboard-safe")
suspend fun getDashboardSafe(@PathVariable userId: Long): Any {
return try {
dashboardService.getDashboard(userId)
} catch (ex: TimeoutCancellationException) {
log.warn("Dashboard query timed out: userId=$userId")
mapOf("error" to "Request timed out", "code" to "TIMEOUT")
} catch (ex: Exception) {
log.error("Dashboard query failed: userId=$userId", ex)
mapOf("error" to "An internal service error occurred", "code" to "INTERNAL_ERROR")
}
}
}Step 4 — Using withTimeout#
// src/main/kotlin/com/example/coroutine/service/ResilientService.kt
package com.example.coroutine.service
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class ResilientService {
private val log = LoggerFactory.getLogger(this::class.java)
// Timeout + fallback
suspend fun fetchWithFallback(id: Long): String {
return try {
withTimeout(2_000L) {
slowExternalCall(id)
}
} catch (ex: TimeoutCancellationException) {
log.warn("Timeout — returning fallback: id=$id")
"fallback"
}
}
// Retry logic
suspend fun fetchWithRetry(id: Long, maxRetries: Int = 3): String {
repeat(maxRetries) { attempt ->
try {
return withTimeout(2_000L) {
slowExternalCall(id)
}
} catch (ex: TimeoutCancellationException) {
log.warn("Attempt ${attempt + 1}/$maxRetries failed")
if (attempt < maxRetries - 1) {
delay(500L * (attempt + 1)) // Exponential backoff
}
}
}
throw RuntimeException("Failed after $maxRetries retries: id=$id")
}
private suspend fun slowExternalCall(id: Long): String {
delay(1_500L) // Simulate 1.5-second delay
return "result-$id"
}
}Step 5 — Cancellation Propagation via Structured Concurrency#
With coroutine structured concurrency, when the parent is cancelled, all child coroutines are automatically cancelled.
// src/main/kotlin/com/example/coroutine/service/BatchService.kt
package com.example.coroutine.service
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class BatchService {
private val log = LoggerFactory.getLogger(this::class.java)
// Process multiple items in parallel — if one fails, all are cancelled
suspend fun processBatch(ids: List<Long>): List<String> = coroutineScope {
ids.map { id ->
async {
log.info("Processing started: $id")
processItem(id)
}
}.map { deferred ->
deferred.await() // Collect all results
}
}
// Independent parallel processing — individual failures don't affect others
suspend fun processBatchIndependent(ids: List<Long>): Map<Long, Result<String>> =
coroutineScope {
ids.map { id ->
id to async {
runCatching { processItem(id) } // Handle exceptions individually
}
}.associate { (id, deferred) ->
id to deferred.await()
}
}
// Cancellable work — handle CancellationException
suspend fun cancellableWork(id: Long): String {
return try {
repeat(10) { i ->
ensureActive() // Check cancellation status
delay(100L)
log.info("In progress $i/10: id=$id")
}
"Done: $id"
} catch (ex: CancellationException) {
log.info("Work cancelled: id=$id")
throw ex // CancellationException MUST be re-thrown
}
}
private suspend fun processItem(id: Long): String {
delay(200L)
if (id == 99L) throw RuntimeException("Processing failed: $id")
return "result-$id"
}
}graph TD
A["Parent coroutineScope"] --> B["async: id=1"]
A --> C["async: id=2"]
A --> D["async: id=3"]
B --> E["Completed"]
C --> F["Failed!"]
F --> G["Parent cancellation signal"]
G --> D
D --> H["Auto-cancelled"]Figure: Exception propagation in structured concurrency — when one of three async tasks fails, the parent coroutineScope receives a cancellation signal and the remaining child coroutines are automatically cancelled.
Step 6 — Exception Handling Patterns#
// src/main/kotlin/com/example/coroutine/service/ErrorHandlingService.kt
package com.example.coroutine.service
import kotlinx.coroutines.*
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class ErrorHandlingService {
private val log = LoggerFactory.getLogger(this::class.java)
// CoroutineExceptionHandler — handle uncaught exceptions in launch
private val exceptionHandler = CoroutineExceptionHandler { _, ex ->
log.error("Unhandled coroutine exception", ex)
}
// SupervisorJob — sibling coroutines continue even if one child fails
suspend fun independentTasks() = supervisorScope {
val task1 = async {
delay(100L)
"task1 done"
}
val task2 = async {
delay(200L)
throw RuntimeException("task2 failed")
}
val task3 = async {
delay(300L)
"task3 done"
}
// task1 and task3 still complete even if task2 fails
listOf(
runCatching { task1.await() }.getOrDefault("task1 failed"),
runCatching { task2.await() }.getOrDefault("task2 failed"),
runCatching { task3.await() }.getOrDefault("task3 failed")
)
}
}Step 7 — Running and Testing#
./gradlew bootRun# Parallel API call test
curl http://localhost:8080/api/users/1/dashboard
# Example response
# {
# "user": {"id":1,"name":"Alice","email":"alice@example.com"},
# "orders": {"userId":1,"orders":["ORDER-001","ORDER-002"],"totalAmount":150000},
# "loadTimeMs": 823
# }
# Timeout handling test
curl http://localhost:8080/api/users/1/dashboard-safeSimple test:
// src/test/kotlin/com/example/coroutine/service/UserDashboardServiceTest.kt
package com.example.coroutine.service
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
class UserDashboardServiceTest(
private val dashboardService: UserDashboardService
) {
@Test
fun `dashboard query runs in parallel`() = runTest {
val start = System.currentTimeMillis()
val dashboard = dashboardService.getDashboard(1L)
val elapsed = System.currentTimeMillis() - start
assert(dashboard.user.id == 1L)
// Completes faster than serial (1300ms)
assert(elapsed < 1100L) { "Parallel execution is slower than expected: ${elapsed}ms" }
}
}Key takeaways
coroutineScope { }+async { }.await()— parallel execution with structured concurrencysuspend fun+ Spring MVC — coroutine controllers without WebFluxwithTimeout(ms) { }— set a timeout, handleTimeoutCancellationExceptionCancellationExceptionMUST be re-thrown for cancellation to propagate correctlysupervisorScope— use when a child’s failure shouldn’t affect siblings
Writing Tests#
For coroutine testing, use kotlinx-coroutines-test. For mocking suspend functions, use MockK. For Flow assertions, use Turbine. Add the following to build.gradle.kts.
// build.gradle.kts — dependencies block
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
testImplementation("io.mockk:mockk:1.13.10")
testImplementation("app.cash.turbine:turbine:1.1.0")runTest basic pattern + StandardTestDispatcher
runTest replaces delay() with a virtual clock so tests complete immediately. With StandardTestDispatcher you can advance virtual time explicitly using advanceTimeBy().
// src/test/kotlin/com/example/coroutine/service/ResilientServiceTest.kt
package com.example.coroutine.service
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ResilientServiceTest {
private val service = ResilientService()
@Test
fun `delay is handled instantly with virtual time inside runTest`() = runTest {
// The internal delay(1_500L) does not actually wait — the test completes immediately
val result = service.fetchWithFallback(1L)
assertThat(result).isEqualTo("result-1")
}
@Test
fun `StandardTestDispatcher verifies timeout boundary explicitly`() = runTest(StandardTestDispatcher()) {
var result: String? = null
val job = launch { result = service.fetchWithFallback(1L) }
// Advance 1499ms — internal delay(1500L) not yet complete, still within withTimeout(2000L)
advanceTimeBy(1_499L)
assertThat(result).isNull()
// Advance the rest — delay completes
advanceTimeBy(2L)
job.join()
assertThat(result).isEqualTo("result-1")
}
}Mocking suspend functions with MockK coEvery
MockK mocks suspend functions using coEvery { ... } returns .... Use coVerify to verify calls.
// src/test/kotlin/com/example/coroutine/service/UserDashboardMockTest.kt
package com.example.coroutine.service
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class UserDashboardMockTest {
private val service: UserDashboardService = mockk()
@Test
fun `dashboard query success returns user and orders`() = runTest {
val expectedUser = UserInfo(1L, "Alice", "alice@example.com")
val expectedOrders = OrderHistory(1L, listOf("ORDER-001"), 50_000L)
val expected = UserDashboard(expectedUser, expectedOrders, 100L)
// coEvery — mock suspend function
coEvery { service.getDashboard(1L) } returns expected
val result = service.getDashboard(1L)
assertThat(result.user.name).isEqualTo("Alice")
assertThat(result.orders.totalAmount).isEqualTo(50_000L)
// coVerify — verify the suspend function was called
coVerify(exactly = 1) { service.getDashboard(1L) }
}
@Test
fun `exception from getDashboard propagates to the caller`() = runTest {
coEvery { service.getDashboard(99L) } throws RuntimeException("Service error")
val exception = runCatching { service.getDashboard(99L) }.exceptionOrNull()
assertThat(exception).isInstanceOf(RuntimeException::class.java)
assertThat(exception?.message).isEqualTo("Service error")
}
}Verifying Flows with Turbine
Turbine uses a flow.test { } block to verify emitted Flow values in order via awaitItem(), awaitComplete(), and so on.
// src/test/kotlin/com/example/coroutine/service/ProgressFlowTest.kt
package com.example.coroutine.service
import app.cash.turbine.test
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class ProgressFlowTest {
// Example function emitting progress updates as a Flow
private fun progressFlow(ids: List<Long>) = flow {
for (id in ids) {
emit("Processing: $id")
}
emit("Done")
}
@Test
fun `progressFlow emits each ID in order and completes`() = runTest {
progressFlow(listOf(1L, 2L, 3L)).test {
assertThat(awaitItem()).isEqualTo("Processing: 1")
assertThat(awaitItem()).isEqualTo("Processing: 2")
assertThat(awaitItem()).isEqualTo("Processing: 3")
assertThat(awaitItem()).isEqualTo("Done")
awaitComplete() // Verify the Flow terminates normally
}
}
@Test
fun `flow exception is verified with awaitError`() = runTest {
val errorFlow = flow<String> {
emit("Start")
throw RuntimeException("Processing failed")
}
errorFlow.test {
assertThat(awaitItem()).isEqualTo("Start")
val error = awaitError()
assertThat(error).isInstanceOf(RuntimeException::class.java)
assertThat(error.message).isEqualTo("Processing failed")
}
}
}When to use which
runTest— the standard entry point for all coroutine tests. Handlesdelay()with virtual time.StandardTestDispatcher— use when you want to control the virtual clock manually.UnconfinedTestDispatcher— use when coroutines should run immediately (eager execution).coEvery/coVerify— mock suspend functions and verify their calls.Turbine— verify emissions fromFlow,StateFlow, andSharedFlowin order.
Run the tests.
./gradlew testNext Steps#
- Multiplatform Intro — how coroutines work in KMP
- Coroutines Basics — coroutine builders and dispatcher mechanics
Tip: Further reading: As parallel calls grow, analyzing the response-time distribution becomes important. See Golden Signals: Latency for p95/p99 measurement criteria and operational perspectives.