Skip to content

Coming from Raw Coroutines

If you're using coroutineScope { async { } } for parallel execution, this guide shows how KAP simplifies your code while keeping all the structured concurrency guarantees you rely on.

KAP is built ON coroutines

KAP doesn't replace kotlinx.coroutines — it uses it internally. .evalGraph() creates a coroutineScope. .with uses async. All structured concurrency rules still apply:

  • Parent cancels → all children cancel
  • One child fails → siblings cancel (unless .settled())
  • CancellationException is never caught
  • CoroutineContext propagates to all branches

Simple parallel: async/awaitkap + .with

val result = coroutineScope {
    val dUser = async { fetchUser() }
    val dCart = async { fetchCart() }
    val dPromos = async { fetchPromos() }
    Dashboard(dUser.await(), dCart.await(), dPromos.await())
}
@KapTypeSafe
data class Dashboard(val user: String, val cart: String, val promos: String)

val result = kap(::Dashboard)
    .with { user from fetchUser() }
    .with { cart from fetchCart() }
    .with { promos from fetchPromos() }
    .evalGraph()

6 lines → 4 lines. No shuttle variables. Swap two .with lines? Compile error.

Phased execution: nested coroutineScope.then

// Where does phase 1 end? Read every line to find out.
val result = coroutineScope {
    val dA = async { fetchA() }
    val dB = async { fetchB() }
    val a = dA.await()
    val b = dB.await()
    val validated = validate(a, b)   // invisible barrier
    val dC = async { fetchC() }
    val dD = async { fetchD() }
    Result(a, b, validated, dC.await(), dD.await())
}
@KapTypeSafe
data class Result(val a: A, val b: B, val validated: Validated, val c: C, val d: D)

val result = kap(::Result)
    .with { a from fetchA() }             // ┐ phase 1
    .with { b from fetchB() }             // ┘
    .then { validated from validate() }   // ── explicit barrier
    .with { c from fetchC() }             // ┐ phase 2
    .with { d from fetchD() }             // ┘
    .evalGraph()

Bounded concurrency: Semaphoretraverse(concurrency)

val semaphore = Semaphore(10)
val results = coroutineScope {
    userIds.map { id ->
        async {
            semaphore.withPermit { fetchUser(id) }
        }
    }.awaitAll()
}
val results = userIds.traverse(concurrency = 10) { id ->
    Kap { fetchUser(id) }
}.evalGraph()

Timeout with fallback: withTimeoutOrNull.timeout

val result = withTimeoutOrNull(500) { fetchSlowService() } ?: "fallback"
val result = Kap { fetchSlowService() }
    .timeout(500.milliseconds) { "fallback" }
    .evalGraph()

Parallel fallback: sequential → timeoutRace

// Sequential: waste 100ms before starting fallback
val result = try {
    withTimeout(100) { fetchFromPrimary() }
} catch (e: TimeoutCancellationException) {
    fetchFromFallback()  // starts AFTER timeout
}
// Parallel: both start at t=0
val result = Kap { fetchFromPrimary() }
    .timeoutRace(100.milliseconds, Kap { fetchFromFallback() })
    .evalGraph()
// 2.6x faster — fallback already running when primary times out

Error recovery: try/catch.recover

val result = try {
    fetchUser()
} catch (e: Exception) {
    if (e is CancellationException) throw e
    "anonymous"
}
val result = Kap { fetchUser() }
    .recover { "anonymous" }
    .evalGraph()
// CancellationException automatically re-thrown — no manual check needed

Retry: manual loop → Schedule

var result: String? = null
var lastException: Exception? = null
repeat(3) { attempt ->
    try {
        result = fetchUser()
        return@repeat
    } catch (e: Exception) {
        if (e is CancellationException) throw e
        lastException = e
        delay(100L * (attempt + 1))  // linear backoff, hardcoded
    }
}
result ?: throw lastException!!
val result = Kap { fetchUser() }.retry(
    Schedule.times<Throwable>(3) and Schedule.exponential(100.milliseconds).jittered()
).evalGraph()

Resource cleanup: try/finallybracket

val conn = openConnection()
try {
    conn.query("SELECT 1")
} finally {
    conn.close()  // not NonCancellable — cancellation can skip this!
}
val result = bracket(
    acquire = { openConnection() },
    use = { conn -> Kap { conn.query("SELECT 1") } },
    release = { conn -> conn.close() },  // NonCancellable — guaranteed
).evalGraph()

Partial failure: supervisorScope.settled()

val result = supervisorScope {
    val dUser = async { fetchUserMayFail() }
    val dCart = async { fetchCart() }
    val user = try { dUser.await() } catch (e: Exception) { "anonymous" }
    val cart = dCart.await()
    Dashboard(user, cart)
}
// Builder function: handles the Result wrapper explicitly
fun buildDashboard(user: Result<String>, cart: String): Dashboard =
    Dashboard(user.getOrDefault("anonymous"), cart)

val result = kap(::buildDashboard)
    .with(BuildDashboardKap.user from settled { fetchUserMayFail() })  // .settled() → Result<String>
    .with { cart from fetchCart() }                                    // normal String
    .evalGraph()
// fetchUserMayFail() fails → Result.failure → buildDashboard uses "anonymous"
// fetchCart() is NOT cancelled

Cheat sheet

Raw Coroutines KAP
coroutineScope { async { } } kap(::T).with { }.evalGraph()
async { }.await() .with { }
suspend call between phases .then { }
nested coroutineScope .andThen { ctx -> }
Semaphore + async traverse(concurrency)
withTimeoutOrNull .timeout(d) { default }
try/catch .recover { }
try/finally bracket(acquire, use, release)
supervisorScope .settled()
select { } race() / raceN()
manual retry loop retry(Schedule)