KAP
Type-safe coroutine orchestration for Kotlin Multiplatform.
The code reads like a diagram. The compiler won't let you wire it wrong.
You know this code¶
You've written it. Maybe last week. A backend endpoint that calls a few services, combines the results, and returns a response. It starts simple:
coroutineScope {
val dUser = async { fetchUser() }
val dCart = async { fetchCart() }
val dPromos = async { fetchPromos() }
CheckoutResult(dUser.await(), dCart.await(), dPromos.await())
}
Three calls, three awaits. Not bad. But then the requirements come in. Stock validation needs retry because the inventory service is flaky. Payment needs a circuit breaker. Promos have a timeout. And suddenly your clean coroutine code looks like this:
coroutineScope {
val dUser = async { fetchUser() }
val dCart = async { fetchCart() }
val dPromos = async { withTimeout(3.seconds) { fetchPromos() } }
val user = dUser.await()
val cart = dCart.await()
val promos = dPromos.await()
// retry loop — breaks the async/await rhythm
var stock = false
var attempt = 0
var delay = 100.milliseconds
while (true) {
try { stock = validateStock(); break }
catch (e: CancellationException) { throw e }
catch (e: Exception) {
if (++attempt >= 3) throw e
delay(delay); delay *= 2
}
}
val dShipping = async { calcShipping() }
val dTax = async { calcTax() }
// circuit breaker — interleaved with business logic
val payment = if (!breaker.shouldAttempt()) {
throw CircuitBreakerOpenException()
} else {
try {
val p = withTimeout(5.seconds) { reservePayment() }
breaker.recordSuccess(); p
} catch (e: CancellationException) { throw e }
catch (e: Exception) { breaker.recordFailure(); throw e }
}
CheckoutResult(user, cart, promos, stock, dShipping.await(), dTax.await(), payment)
}
Where are the phases? Which calls run in parallel? Where does the retry end and the business logic begin? You have to read every line to answer these questions. And this is a simple example — just 7 services.
Now look at this¶
@KapTypeSafe
data class CheckoutResult(
val user: String, val cart: String, val promos: String,
val stock: Boolean,
val shipping: Double, val tax: Double,
val payment: String,
)
val retryPolicy = Schedule.exponential<Throwable>(100.milliseconds) and Schedule.times(3)
val breaker = CircuitBreaker(maxFailures = 5, resetTimeout = 30.seconds)
kap(::CheckoutResult)
.with { user from fetchUser() } // ┐
.with { cart from fetchCart() } // ├─ phase 1: parallel
.with(CheckoutResultKap.promos from Kap { fetchPromos() }.timeout(3.seconds)) // ┘
.then(CheckoutResultKap.stock from Kap { validateStock() }.retry(retryPolicy)) // ── phase 2: barrier + retry
.with { shipping from calcShipping() } // ┐ phase 3: parallel
.with { tax from calcTax() } // ┘
.then(CheckoutResultKap.payment from Kap { reservePayment() } // ── phase 4: barrier
.withCircuitBreaker(breaker) // + circuit breaker
.timeout(5.seconds)) // + timeout
.evalGraph()
Same 7 calls. Same retry, circuit breaker, timeout. But now the phases are visible. .with means parallel. .then means wait. The retry is on the call, not around it. The circuit breaker is on the call, not interleaved with it. You can read the execution plan top to bottom.
@KapTypeSafe generates a scoped wrapper with per-slot tags — inside .with { … }, only the field expected at the current curry position is in scope. You can't swap, skip, or forget a field; the swap is a compile error citing the expected tag.
That's KAP. Let's start from the beginning.
Three things to learn¶
That's it. Three.
| You write | What happens | Think of it as |
|---|---|---|
.with { field from value } |
Runs in parallel with everything else in the same phase | "and at the same time..." |
.then { field from value } |
Waits for all above, then continues | "once that's done..." |
.andThen { result -> } |
Waits, passes the result, builds the next graph | "using what we got..." |
Everything else in KAP — retry, circuit breaker, racing, validation — is built on top of these three. Learn them once, and the rest follows.
Your first KAP graph¶
A dashboard that loads user, feed, and notification count in parallel:
@KapTypeSafe
data class Dashboard(val user: String, val feed: String, val notifications: Int)
kap(::Dashboard)
.with { user from fetchUser() } // ┐
.with { feed from fetchFeed() } // ├─ all three run in parallel
.with { notifications from countUnread() } // ┘
.evalGraph()
t=0ms ─── fetchUser ──────────┐
t=0ms ─── fetchFeed ──────────├─ parallel
t=0ms ─── countUnread ────────┘
t=50ms ─── Dashboard ready
Three services, one result, 50ms instead of 120ms sequential. Your suspend functions go in, your data class comes out. No framework, no wrapper types, no runtime magic.
Adding phases¶
Real APIs have dependencies. You can't calculate shipping until you know the cart. You can't generate a confirmation until payment is reserved. With raw coroutines, you'd nest coroutineScope blocks. With KAP, you change one character — .with becomes .then:
kap(::CheckoutResult)
.with { user from fetchUser() } // ┐ phase 1: parallel
.with { cart from fetchCart() } // ┘
.then { stock from validateStock() } // ── phase 2: waits for phase 1
.with { shipping from calcShipping() } // ┐ phase 3: parallel
.with { tax from calcTax() } // ┘
.evalGraph()
t=0ms ─── fetchUser ──────┐
t=0ms ─── fetchCart ───────┘─ phase 1
t=50ms ─── validateStock ───── phase 2 (barrier)
t=70ms ─── calcShipping ───┐
t=70ms ─── calcTax ────────┘─ phase 3
t=100ms ─── done
The phases are explicit. You see them in the code. No nesting, no shuttle variables, no mental reconstruction required.
Value-dependent phases¶
Sometimes phase 2 needs the result of phase 1 — not just to wait for it, but to use the data. That's .andThen:
@KapTypeSafe
data class UserContext(val profile: String, val prefs: String, val tier: String)
@KapTypeSafe
data class PersonalizedDashboard(val recs: String, val promos: String, val trending: String)
kap(::UserContext)
.with { profile from fetchProfile(userId) } // ┐
.with { prefs from fetchPreferences(userId) } // ├─ phase 1: parallel
.with { tier from fetchLoyaltyTier(userId) } // ┘
.andThen { ctx -> // ── barrier: ctx available
kap(::PersonalizedDashboard)
.with { recs from fetchRecommendations(ctx.profile) } // ┐
.with { promos from fetchPromotions(ctx.tier) } // ├─ phase 2: parallel
.with { trending from fetchTrending(ctx.prefs) } // ┘
.asKap // unwrap so andThen returns Kap<…>
}
.evalGraph()
Phase 1 fetches the user context in parallel. Phase 2 uses that context to personalize — also in parallel. The dependency is explicit, type-safe, and readable.
It scales¶
Here's a real checkout: 11 services, 5 phases, 8 Strings, 2 Booleans, 3 Doubles. The compiler catches every swap:
@KapTypeSafe
data class CheckoutResult(
val user: String, val cart: String,
val promos: String, val inventory: Boolean,
val stock: Boolean,
val shipping: Double, val tax: Double, val discounts: Double,
val payment: String,
val confirmation: String, val email: String,
)
kap(::CheckoutResult)
.with { user from fetchUser() } // ┐
.with { cart from fetchCart() } // ├─ phase 1: parallel
.with { promos from fetchPromos() } // │
.with { inventory from fetchInventory() } // ┘
.then { stock from validateStock() } // ── phase 2: barrier
.with { shipping from calcShipping() } // ┐
.with { tax from calcTax() } // ├─ phase 3: parallel
.with { discounts from calcDiscounts() } // ┘
.then { payment from reservePayment() } // ── phase 4: barrier
.with { confirmation from generateConfirmation() } // ┐ phase 5
.with { email from sendEmail() } // ┘
.evalGraph()
t=0ms ─── fetchUser ────────┐
t=0ms ─── fetchCart ────────┤
t=0ms ─── fetchPromos ─────├─ phase 1
t=0ms ─── fetchInventory ──┘
t=50ms ─── validateStock ───── phase 2
t=70ms ─── calcShipping ────┐
t=70ms ─── calcTax ─────────├─ phase 3
t=70ms ─── calcDiscounts ───┘
t=100ms ─── reservePayment ──── phase 4
t=140ms ─── generateConfirm ─┐
t=140ms ─── sendEmail ───────┘─ phase 5
t=170ms ─── done
170ms total (vs 460ms sequential). Verified with deterministic virtual-time tests.
"What if one call fails?"¶
Good question. By default, if any .with branch fails, the whole graph is cancelled — that's structured concurrency, and it's usually what you want. But sometimes a call is optional. The feed can fail, but you still want the profile.
settled { } wraps the result in Result<A> so a failure doesn't kill the rest:
@KapTypeSafe
data class HomePage(val profile: String, val feed: Result<String>, val ads: Result<String>)
kap(::HomePage)
.with { profile from fetchProfile() } // critical — failure cancels everything
.with(HomePageKap.feed from settled { fetchFeed() }) // optional — failure returns Result.failure
.with(HomePageKap.ads from settled { fetchAds() }) // optional — failure returns Result.failure
.evalGraph()
// Feed throws? Profile and ads still complete. You get Result.failure for feed.
Need ALL results even if some fail? traverseSettled runs every item and collects outcomes:
val results = listOf("svc-a", "svc-b", "svc-c").traverseSettled { svc ->
Kap { callService(svc) }
}.evalGraph()
// → [Success("ok"), Failure(TimeoutException), Success("ok")]
No supervisorScope. No runCatching per item. One method call.
Adding resilience¶
This is where it gets interesting. Every team needs retry, circuit breaker, timeout. Every team reimplements them. And they never compose well with each other.
In KAP, resilience is per-call, inline, and composable:
val breaker = CircuitBreaker(maxFailures = 5, resetTimeout = 30.seconds)
val retryPolicy = Schedule.exponential<Throwable>(100.milliseconds)
.jittered() // ±50% random spread (no thundering herd)
.and(Schedule.times(3)) // max 3 attempts
.withMaxDuration(10.seconds) // total budget
kap(::Dashboard)
.with(DashboardKap.user from Kap { fetchUser() }
.withCircuitBreaker(breaker)
.retry(retryPolicy))
.with(DashboardKap.slowData from Kap { fetchFromSlowApi() }
.timeoutRace(100.milliseconds, Kap { fetchFromCache() }))
.with { promos from fetchPromos() }
.evalGraph()
Schedule policies are reusable objects — define once, apply anywhere. timeoutRace starts both the primary and fallback at t=0, so the fallback is already warm when the timeout fires. bracket guarantees cleanup even on cancellation:
bracket(
acquire = { openConnection() },
use = { conn ->
kap(::QueryResult)
.with { data from conn.query("SELECT ...") }
.with { meta from conn.metadata() }
.asKap // bracket's `use` returns Kap<…>
},
release = { conn -> conn.close() } // runs in NonCancellable context
).evalGraph()
Collecting every error at once¶
You know the frustration: a user submits a form, gets "invalid email", fixes it, resubmits, gets "age too young". Why not show all errors the first time?
With kap-arrow, validations run in parallel and accumulate every error:
val result: Either<NonEmptyList<RegError>, User> = zipV(
{ validateName("A") }, // ← too short
{ validateEmail("bad") }, // ← invalid
{ validateAge(10) }, // ← too young
{ checkUsername("al") }, // ← too short
) { name, email, age, username -> User(name, email, age, username) }
.evalGraph()
// → Left(NonEmptyList(NameTooShort, InvalidEmail, AgeTooLow, UsernameTaken))
// ALL 4 errors in ONE response. No round trips.
Scales to 22 validators (Arrow's zipOrAccumulate maxes at 9). And since they run in parallel, if each validator hits the database, all queries run concurrently.
KAP and Arrow
KAP doesn't replace Arrow — it builds on it. Arrow gives you the types (Either, NonEmptyList). KAP gives you the orchestration (parallel execution, phase barriers, resilience). Use both.
Everything together¶
Here's a real order placement that uses everything you've seen: parallel validation with error accumulation, racing pricing providers, retry with backoff, circuit breaker on payment, partial failure on notifications, and transactional safety with guaranteed cleanup.
@KapTypeSafe
data class OrderResult(
val finalPrice: Double,
val reservationId: String,
val paymentId: String,
val notifications: List<Result<Unit>>,
)
val retryPolicy = Schedule.exponential<Throwable>(100.milliseconds).jittered() and Schedule.times(3)
val paymentBreaker = CircuitBreaker(maxFailures = 5, resetTimeout = 30.seconds)
suspend fun placeOrder(input: OrderInput): Either<Nel<OrderError>, OrderResult> {
// ── Phase 1: validate (parallel, accumulate ALL errors) ──────────
val validated = kapV<OrderError, ValidAddress, ValidCard, ValidItems, ValidOrder>(::ValidOrder)
.withV { validateAddress(input.address) } // ┐ all three run in parallel
.withV { validatePaymentInfo(input.card) } // ├─ errors accumulate
.withV { validateItems(input.items) } // ┘
.evalGraph()
val order = validated.getOrElse { return Either.Left(it) }
// ── Phases 2–5: process inside DB transaction ────────────────────
return bracketCase(
acquire = { db.beginTransaction() },
use = { tx ->
kap(::OrderResult)
.with(OrderResultKap.finalPrice from raceN( // phase 2: race 3 providers
Kap { pricingServiceA(order) }, // fastest wins
Kap { pricingServiceB(order) },
Kap { pricingServiceC(order) },
))
.then(OrderResultKap.reservationId from // phase 3: barrier + retry
Kap { reserveInventory(tx, order) }
.retry(retryPolicy)
)
.then(OrderResultKap.paymentId from // phase 4: circuit breaker
Kap { chargePayment(tx, order) }
.withCircuitBreaker(paymentBreaker)
.timeout(5.seconds)
)
.with(OrderResultKap.notifications from listOf( // phase 5: partial failure OK
Kap { sendEmail(order) },
Kap { sendPush(order) },
Kap { updateAnalytics(order) },
).sequenceSettled())
.asKap // drop wrapper to chain .map
.map { Either.Right(it) }
},
release = { tx, exit -> when (exit) {
is ExitCase.Completed -> tx.commit()
else -> tx.rollback()
}}
).evalGraph()
}
One function. Five phases. Validation, racing, retry, circuit breaker, partial failure, transactional safety. Each concern is one composable call. The business logic reads top to bottom.
More tools in the box¶
Every one of these is a method call — no boilerplate, no manual state:
| Pattern | KAP | What it does |
|---|---|---|
| Race | raceN(a, b, c) |
Fastest wins, losers cancelled |
| Bounded concurrency | ids.traverse(concurrency = 5) { Kap { fetch(it) } } |
Process N items, max M at a time |
| Timeout with fallback | kap.timeoutRace(2.seconds, fallback) |
Both start at t=0, fastest wins |
| Composable retry | kap.retry(Schedule.exponential().jittered().and(times(3))) |
Define once, reuse everywhere |
| Timed | timed { fetchSlowService() } |
Returns TimedResult(value, duration) |
| Memoize | Kap { loadConfig() }.memoizeOnSuccess() |
Compute once, cache thread-safely |
| Quorum | raceQuorum(required = 2, a, b, c) |
N-of-M consensus |
| Resource safety | bracket(acquire, use, release) |
Guaranteed cleanup, even on cancellation |
Extra type safety with per-slot tags¶
@KapTypeSafe generates per-slot tag interfaces — each field gets a distinct tag type, so the lambda receiver inside .with { … } only resolves the field expected at the current curry position. If firstName and lastName are both String, the compiler still rejects a swap: typing lastName from … where firstName is expected produces "Unresolved reference: lastName".
@KapTypeSafe
data class User(val firstName: String, val lastName: String, val age: Int)
kap(::User)
.with { firstName from fetchFirstName() } // Only `firstName` in scope here
.with { lastName from fetchLastName() } // Only `lastName` in scope here — swap? COMPILE ERROR
.with { age from fetchAge() } // Only `age` in scope here
.evalGraph()
The IDE autocompletes the expected field name when the body is empty — you always know which slot comes next. For Kap-decorated values built outside the lambda (e.g. Kap { … }.timeout(…)), use the parens form with the companion-qualified tag:
kap(::User)
.with(UserKap.firstName from Kap { fetchFirstName() }.timeout(500.milliseconds))
.with(UserKap.lastName from Kap { fetchLastName() })
.with(UserKap.age from Kap { fetchAge() })
.evalGraph()
Zero overhead¶
All claims backed by 119 JMH benchmarks and deterministic virtual-time proofs.
| Dimension | Raw Coroutines | Arrow | KAP |
|---|---|---|---|
| Framework overhead (arity 3) | <0.01ms | 0.02ms | <0.01ms |
| Framework overhead (arity 9) | <0.01ms | 0.03ms | <0.01ms |
| Simple parallel (5 x 50ms) | 50.27ms | 50.33ms | 50.31ms |
| Multi-phase (9 calls, 4 phases) | 180.85ms | 181.06ms | 180.98ms |
| Race (50ms vs 100ms) | 100.34ms | 50.51ms | 50.40ms |
| timeoutRace (primary wins) | 180.55ms | -- | 30.34ms |
| Max validation arity | -- | 9 | 22 |
KAP adds zero measurable overhead. The abstraction compiles away. What you're left with is pure coroutines running in a structured scope.
Pick what you need¶
KAP is modular. Start with core, add as you grow:
| Module | What you get | Depends on |
|---|---|---|
kap-core |
with, then, andThen, race, traverse, memoize, settled, timed |
kotlinx-coroutines-core |
kap-resilience |
Schedule, CircuitBreaker, Resource, bracket, timeoutRace, raceQuorum |
kap-core |
kap-arrow |
zipV, withV, kapV, accumulate {}, attempt(), raceEither |
kap-core + Arrow |
kap-ksp |
@KapTypeSafe, @KapBridge — compile-time named builders |
KSP |
kap-ktor |
Ktor plugin, circuit breaker registry, tracers, respondAsync |
kap-core + Ktor |
kap-kotest |
shouldSucceedWith, shouldFailWith, timing & lifecycle matchers |
kap-core (test) |
Get started¶
plugins {
id("com.google.devtools.ksp") // Required for @KapTypeSafe
}
dependencies {
implementation("io.github.damian-rafael-lattenero:kap-core:3.0.0")
// KSP — named builder generation (@KapTypeSafe)
implementation("io.github.damian-rafael-lattenero:kap-ksp-annotations:3.0.0")
ksp("io.github.damian-rafael-lattenero:kap-ksp:3.0.0")
// Optional
implementation("io.github.damian-rafael-lattenero:kap-resilience:3.0.0")
implementation("io.github.damian-rafael-lattenero:kap-arrow:3.0.0")
implementation("io.github.damian-rafael-lattenero:kap-ktor:3.0.0")
testImplementation("io.github.damian-rafael-lattenero:kap-kotest:3.0.0")
}
Or clone the starter project and run ./gradlew run in 30 seconds.