DEV Community

Cover image for I applied Haskell's Applicative Functors to Kotlin Coroutines. Here's what happened.
Damián Rafael Lattenero
Damián Rafael Lattenero

Posted on

I applied Haskell's Applicative Functors to Kotlin Coroutines. Here's what happened.

How currying, functors, applicative functors and monads from Haskell led me to build a parallel orchestration library for Kotlin coroutines with zero overhead.
tags: kotlin, functionalprogramming, haskell, coroutines

If you know Haskell, you already know this library

In Haskell, combining independent IO actions looks like this:

mkDashboard <$> fetchUser <*> fetchCart <*> fetchPromos
Enter fullscreen mode Exit fullscreen mode

Three independent effects. The runtime can execute them however it wants — including in parallel. The structure tells you: these don't depend on each other.

When one result depends on another, you switch to monadic bind:

do
  ctx <- mkContext <$> fetchProfile <*> fetchPrefs <*> fetchTier
  mkDashboard <$> fetchRecs ctx <*> fetchPromos ctx <*> fetchTrending ctx
Enter fullscreen mode Exit fullscreen mode

Phase 1 is applicative (parallel). Phase 2 is monadic (depends on ctx). The code shape is the dependency graph.

I looked at Kotlin coroutines and saw the same problem: you need to orchestrate parallel calls with sequential barriers, but async/await gives you no way to express this distinction. So I built KAP — Kotlin Applicative Parallelism. The Haskell pattern, natively expressed in Kotlin coroutines.

Here's the same thing in KAP:

val dashboard = Async {
    lift3(::UserContext)
        .ap { fetchProfile(userId) }       // ┐ phase 1: applicative (parallel)
        .ap { fetchPrefs(userId) }         // │
        .ap { fetchTier(userId) }          // ┘
    .flatMap { ctx ->                      // >>= monadic bind (barrier)
        lift3(::Dashboard)
            .ap { fetchRecs(ctx) }         // ┐ phase 2: applicative (parallel)
            .ap { fetchPromos(ctx) }       // │
            .ap { fetchTrending(ctx) }     // ┘
    }
}
Enter fullscreen mode Exit fullscreen mode

If you know Haskell, you can read this immediately: lift3(f).ap.ap.ap is f <$> a <*> b <*> c. .flatMap is >>=. That's the whole mapping.

Haskell                          KAP (Kotlin)
─────────                        ────────────
f <$> a <*> b <*> c          →  lift3(f).ap{a}.ap{b}.ap{c}
ma >>= \x -> mb x            →  computation.flatMap { x -> ... }
pure x                        →  Computation.pure(x)
fmap f ma                     →  computation.map { f(it) }
Enter fullscreen mode Exit fullscreen mode

If you're a Kotlin developer who's never touched Haskell — don't worry. The rest of this post explains everything with raw coroutines comparisons. You don't need to know Haskell to use KAP. But if you do, you'll feel right at home.


The problem: 11 microservice calls, 5 phases

You have a checkout flow. 11 services. Some can run in parallel, others depend on earlier results. Here's what Kotlin gives you today:

Raw coroutines:

val checkout = coroutineScope {
    val dUser      = async { fetchUser(userId) }
    val dCart       = async { fetchCart(userId) }
    val dPromos    = async { fetchPromos(userId) }
    val dInventory = async { fetchInventory(userId) }
    val user       = dUser.await()
    val cart        = dCart.await()
    val promos     = dPromos.await()
    val inventory  = dInventory.await()

    val stock = validateStock(inventory)    // barrier — but you can't SEE it

    val dShipping  = async { calcShipping(cart) }
    val dTax       = async { calcTax(cart) }
    val dDiscounts = async { calcDiscounts(promos) }
    val shipping   = dShipping.await()
    val tax        = dTax.await()
    val discounts  = dDiscounts.await()

    val payment = reservePayment(user, cart)  // barrier — also invisible

    val dConfirmation = async { generateConfirmation(payment) }
    val dEmail        = async { sendReceiptEmail(user) }

    CheckoutResult(user, cart, promos, inventory, stock,
                   shipping, tax, discounts, payment,
                   dConfirmation.await(), dEmail.await())
}
Enter fullscreen mode Exit fullscreen mode

30+ lines. 8 shuttle variables. Phase boundaries invisible without comments. Move one await() above its async and you silently serialize — the compiler won't warn.

KAP:

val checkout = Async {
    lift11(::CheckoutResult)
        .ap { fetchUser(userId) }          // ┐
        .ap { fetchCart(userId) }           // ├─ phase 1: parallel
        .ap { fetchPromos(userId) }        // │
        .ap { fetchInventory(userId) }     // ┘
        .followedBy { validateStock() }    // ── phase 2: barrier
        .ap { calcShipping() }            // ┐
        .ap { calcTax() }                 // ├─ phase 3: parallel
        .ap { calcDiscounts() }           // ┘
        .followedBy { reservePayment() }  // ── phase 4: barrier
        .ap { generateConfirmation() }    // ┐ phase 5: parallel
        .ap { sendReceiptEmail() }        // ┘
}
Enter fullscreen mode Exit fullscreen mode

12 lines. All val. No nulls. Phases visible. Swap any two .ap lines of different types — compiler error. Same wall-clock time: 130ms virtual time (vs 460ms sequential), verified with runTest.


How it works: currying + three primitives

Currying is what makes lift + ap possible. In Haskell, all functions are curried by default. In Kotlin, lift11(::CheckoutResult) curries an 11-argument constructor into a chain where each .ap fills one slot. This is why swapping two .ap lines is a compiler error — each slot expects a specific type at compile time.

The entire library is built on three primitives:

Primitive Haskell equivalent What it does
.ap { } <*> Launch in parallel with everything above
.followedBy { } *> (with barrier) Wait for everything above, then continue
.flatMap { ctx -> } >>= Wait, pass the result, then continue

And lift works with any function — not just constructors:

// A data class constructor IS a function:
//   ::Greeting  has type  (String, String) -> Greeting
val g = Async { lift2(::Greeting).ap { fetchName() }.ap { "hello" } }

// A regular function:
fun buildSummary(name: String, items: Int) = "$name has $items items"
val s = Async { lift2(::buildSummary).ap { fetchName() }.ap { 5 } }

// A lambda:
val greet: (String, Int) -> String = { name, age -> "Hi $name, you're $age" }
val r = Async { lift2(greet).ap { fetchName() }.ap { fetchAge() } }
Enter fullscreen mode Exit fullscreen mode

When Phase 2 depends on Phase 1: flatMap

Raw coroutines — three separate coroutineScope blocks, manual variable threading:

val ctx = coroutineScope {
    val dProfile = async { fetchProfile(userId) }
    val dPrefs   = async { fetchPreferences(userId) }
    val dTier    = async { fetchLoyaltyTier(userId) }
    UserContext(dProfile.await(), dPrefs.await(), dTier.await())
}
val enriched = coroutineScope {
    val dRecs     = async { fetchRecommendations(ctx.profile) }
    val dPromos   = async { fetchPromotions(ctx.tier) }
    val dTrending = async { fetchTrending(ctx.prefs) }
    val dHistory  = async { fetchHistory(ctx.profile) }
    EnrichedContent(dRecs.await(), dPromos.await(), dTrending.await(), dHistory.await())
}
val dashboard = coroutineScope {
    val dLayout = async { renderLayout(ctx, enriched) }
    val dTrack  = async { trackAnalytics(ctx, enriched) }
    FinalDashboard(dLayout.await(), dTrack.await())
}
Enter fullscreen mode Exit fullscreen mode

Three scopes. Manual ctx and enriched threading. The dependency between phases is invisible in the code structure.

KAP — single expression, dependencies are the structure:

val dashboard: FinalDashboard = Async {
    lift3(::UserContext)
        .ap { fetchProfile(userId) }         // ┐
        .ap { fetchPreferences(userId) }     // ├─ phase 1 (parallel)
        .ap { fetchLoyaltyTier(userId) }     // ┘
    .flatMap { ctx ->                        // ── barrier: phase 2 NEEDS ctx
        lift4(::EnrichedContent)
            .ap { fetchRecommendations(ctx.profile) }  // ┐
            .ap { fetchPromotions(ctx.tier) }           // ├─ phase 2 (parallel)
            .ap { fetchTrending(ctx.prefs) }            // │
            .ap { fetchHistory(ctx.profile) }           // ┘
        .flatMap { enriched ->                          // ── barrier
            lift2(::FinalDashboard)
                .ap { renderLayout(ctx, enriched) }     // ┐ phase 3
                .ap { trackAnalytics(ctx, enriched) }   // ┘
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
t=0ms   ─── fetchProfile ──────┐
t=0ms   ─── fetchPreferences ──├─ phase 1 (parallel, all 3)
t=0ms   ─── fetchLoyaltyTier ──┘
t=50ms  ─── flatMap { ctx -> }  ── barrier, ctx available
t=50ms  ─── fetchRecommendations ──┐
t=50ms  ─── fetchPromotions ───────├─ phase 2 (parallel, all 4)
t=50ms  ─── fetchTrending ─────────┤
t=50ms  ─── fetchHistory ──────────┘
t=90ms  ─── flatMap { enriched -> } ── barrier
t=90ms  ─── renderLayout ──┐
t=90ms  ─── trackAnalytics ┘─ phase 3 (parallel)
t=115ms ─── FinalDashboard ready
Enter fullscreen mode Exit fullscreen mode

All val, no null, no !!

Raw coroutines force you into mutable nullable variables:

data class CheckoutView(
    val user: UserProfile,
    val cart: ShoppingCart,
    val promos: PromotionBundle,
    val shipping: ShippingQuote,
    val tax: TaxBreakdown,
)

var user: UserProfile? = null
var cart: ShoppingCart? = null
var promos: PromotionBundle? = null
var shipping: ShippingQuote? = null
var tax: TaxBreakdown? = null
coroutineScope {
    launch { user = fetchUser() }
    launch { cart = fetchCart() }
    launch { promos = fetchPromos() }
    launch { shipping = calcShipping() }
    launch { tax = calcTax() }
}
val view = CheckoutView(user!!, cart!!, promos!!, shipping!!, tax!!)
// 5 vars. 5 nulls. 5 bang-bangs. One forgotten launch → NPE at runtime.
Enter fullscreen mode Exit fullscreen mode

KAP — the constructor receives everything at once. Every field is val. Nothing is ever null:

val view: CheckoutView = Async {
    lift5(::CheckoutView)
        .ap { fetchUser() }
        .ap { fetchCart() }
        .ap { fetchPromos() }
        .ap { calcShipping() }
        .ap { calcTax() }
}
// Constructor called once with all 5 values. Nothing was ever null.
Enter fullscreen mode Exit fullscreen mode

At 11 fields it's 11 var, 11 ?, 11 !!. KAP stays flat.


Computation is a description, not an execution

Like Haskell's IO, Computation is a value that describes work — it doesn't perform it. Nothing runs until Async {}:

// This builds a plan — nothing runs yet
val plan: Computation<Dashboard> = lift3(::Dashboard)
    .ap { fetchUser() }     // NOT executed
    .ap { fetchCart() }     // NOT executed
    .ap { fetchPromos() }   // NOT executed

// NOW it runs — all three in parallel
val result: Dashboard = Async { plan }
println(result) // Dashboard(user=Alice, cart=3 items, promos=SAVE20)
Enter fullscreen mode Exit fullscreen mode

You can store computation graphs, pass them around, compose them — all without triggering side effects. This is fundamentally different from async {}, which starts immediately.


What only KAP can do

These features don't have a clean equivalent in raw coroutines or Arrow.

Partial failure tolerance

Raw coroutines: coroutineScope cancels ALL siblings when one fails. You can't collect partial results.

KAP: .settled() wraps individual branches in Result so one failure doesn't cancel siblings:

val dashboard = Async {
    lift3 { user: Result<String>, cart: String, config: String ->
        Dashboard(user.getOrDefault("anonymous"), cart, config)
    }
        .ap(Computation { fetchUserMayFail() }.settled())
        .ap { fetchCart() }     // keeps running even if user fails
        .ap { fetchConfig() }   // keeps running even if user fails
}
Enter fullscreen mode Exit fullscreen mode

Timeout with parallel fallback

Raw coroutines: wait for the timeout, then start the fallback (sequential).

KAP: start both at t=0, return whichever wins (parallel):

val result = Async {
    Computation { fetchFromPrimary() }
        .timeoutRace(100.milliseconds, Computation { fetchFromFallback() })
}
// JMH: KAP 30.34ms vs raw coroutines 180.55ms — 6x faster
Enter fullscreen mode Exit fullscreen mode

Quorum consensus

Raw coroutines: no primitive. You'd build it yourself with select and manual cancellation.

KAP: return the first 2 successes out of 3 replicas, cancel the rest:

val quorum: List<String> = Async {
    raceQuorum(
        required = 2,
        Computation { fetchReplicaA() },
        Computation { fetchReplicaB() },
        Computation { fetchReplicaC() },
    )
}
Enter fullscreen mode Exit fullscreen mode

Per-branch resilience

Raw coroutines: 30+ lines of nested try/catch with manual backoff math per branch.

KAP: each .ap branch carries its own timeout, retry, circuit breaker, or fallback:

val breaker = CircuitBreaker(maxFailures = 5, resetTimeout = 30.seconds)
val retryPolicy = Schedule.recurs<Throwable>(3) and Schedule.exponential(100.milliseconds)

val result = Async {
    lift3(::Dashboard)
        .ap(Computation { fetchUser() }
            .withCircuitBreaker(breaker)
            .retry(retryPolicy)
            .recover { "cached-user" })
        .ap(Computation { fetchFromSlowApi() }
            .timeoutRace(100.milliseconds, Computation { fetchFromCache() }))
        .ap { fetchPromos() }
}
Enter fullscreen mode Exit fullscreen mode

Parallel validation — collect every error

Raw coroutines: impossible. Structured concurrency cancels all siblings when one throws.

Arrow: zipOrAccumulate handles it, but maxes out at 9 arguments.

KAP: scales to 22, all validators run in parallel, all errors accumulated:

val registration: Either<NonEmptyList<RegError>, User> = Async {
    liftV4<RegError, ValidName, ValidEmail, ValidAge, ValidUsername, User>(::User)
        .apV { validateName("Alice") }       // ┐ all 4 in parallel
        .apV { validateEmail("alice@ex.com") } // │ errors accumulated
        .apV { validateAge(25) }              // │ (not short-circuited)
        .apV { checkUsername("alice") }       // ┘
}
// 3 fail? → Left(NonEmptyList(NameTooShort, InvalidEmail, AgeTooLow))
// All pass? → Right(User(...))
Enter fullscreen mode Exit fullscreen mode

Chain phases with accumulate — validate identity first, then check business rules:

val result: Either<NonEmptyList<RegError>, Registration> = Async {
    accumulate {
        val identity = zipV(
            { validateName("Alice") },
            { validateEmail("alice@example.com") },
            { validateAge(25) },
        ) { name, email, age -> Identity(name, email, age) }
            .bindV()

        val cleared = zipV(
            { checkNotBlacklisted(identity) },
            { checkUsernameAvailable(identity.email.value) },
        ) { a, b -> Clearance(a, b) }
            .bindV()

        Registration(identity, cleared)
    }
}
Enter fullscreen mode Exit fullscreen mode

Benchmarks: 119 JMH tests

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
Validation (4 x 40ms) N/A 40.32ms 40.28ms
Retry (3 attempts w/ backoff) 120.70ms 30.21ms
timeoutRace (primary wins) 180.55ms 30.34ms
Max validation arity 9 22
Compile-time arg safety No No Yes
Quorum race (N-of-M) Manual No Yes

KAP matches raw coroutines in latency and overhead. Where it pulls ahead: timeoutRace (parallel fallback — 6x faster), retry with Schedule (declarative vs manual loops — 4x), and race (auto-cancel loser).

Live benchmark dashboard — tracked on every push to master.


Getting started

Three modules, pick what you need:

dependencies {
    // Core — the only required module (zero deps beyond coroutines)
    implementation("io.github.damian-rafael-lattenero:kap-core:2.1.0")

    // Optional: resilience (Schedule, CircuitBreaker, Resource, bracket)
    implementation("io.github.damian-rafael-lattenero:kap-resilience:2.1.0")

    // Optional: Arrow integration (validated DSL, Either/Nel, raceEither)
    implementation("io.github.damian-rafael-lattenero:kap-arrow:2.1.0")
}
Enter fullscreen mode Exit fullscreen mode

906 tests across 61 suites. 119 JMH benchmarks. Kotlin Multiplatform (JVM / JS / Native). 7 runnable examples. Algebraic laws (Functor, Applicative, Monad) property-tested with Kotest.

All code examples in this post are compilable — they live in examples/readme-examples/ and run on every CI push.

GitHub: github.com/damian-rafael-lattenero/coroutines-applicatives


Top comments (0)