DEV Community

Cover image for Stop wiring async calls manually
Damián Rafael Lattenero
Damián Rafael Lattenero

Posted on

Stop wiring async calls manually

If you’ve written non-trivial coroutine code, you’ve probably built something like this:

  • multiple async calls
  • some run in parallel
  • some depend on earlier results
  • everything gets glued together manually

And at some point… it starts to feel messy.


The real problems (not the obvious ones)

Raw coroutines are great, but orchestration has hidden costs:

  • Dependencies are implicit
  • Phases are invisible
  • You manually wire values between steps
  • Swapping values can still compile if types match

Example:

val user = async { fetchUser() }
val cart = async { fetchCart() }
val promos = async { fetchPromos() }

val result = combine(
    user.await(),
    promos.await(), // swapped accidentally
    cart.await()
)
Enter fullscreen mode Exit fullscreen mode

👉 This compiles.
👉 And now you have a bug.


What I wanted instead

I wanted something where:

  • parallel work is obvious
  • dependencies are explicit
  • phases are visible
  • and if it compiles, the wiring is correct

Enter KAP

val checkout: CheckoutResult = Async {
    kap(::CheckoutResult)
        .with { fetchUser() }
        .with { fetchCart() }
        .with { fetchPromos() }
        .with { fetchInventory() }

        .then { validateStock() }

        .with { calcShipping() }
        .with { calcTax() }
        .with { calcDiscounts() }

        .andThen { partial ->
            kap(::FinalCheckout)
                .with { reservePayment(partial) }
                .with { applyLoyaltyPoints(partial) }
        }

        .with { generateConfirmation() }
        .with { sendEmail() }
}
Enter fullscreen mode Exit fullscreen mode

👉 13 calls
👉 6 phases
👉 no manual wiring

The dependency graph is the code.


The mental model (it’s simpler than it looks)

You only need three things:

You write What it means
.with { } runs in parallel with others
.then { } waits for all previous
.andThen { ctx -> } next step using previous result

That’s it.


Why this matters

1. No more invisible phases

With coroutines:

  • phases exist
  • but you don’t see them

With KAP:

  • phases are structural

2. No more manual wiring

You don’t pass values around manually anymore.

No more:

val ctx = ...
val enriched = ...
Enter fullscreen mode Exit fullscreen mode

3. Compile-time safety

This is the key:

If it compiles, your async wiring is correct.

You literally can’t swap values accidentally.


4. Code reads like execution

Instead of:

“how is this executed?”

You read:

“this runs together → then this → then this depends on that”


Performance?

Same as raw coroutines.

  • no reflection
  • no runtime code generation
  • zero overhead

Benchmarks: ~identical execution time.


When this is useful

This shines when you have:

  • multi-service orchestration
  • dashboards
  • checkout flows
  • aggregation pipelines
  • anything with parallel + dependent steps

When you don’t need it

If you have:

  • 1–2 coroutine calls
  • no dependency structure

👉 stick with plain coroutines


Final thought

Coroutines solve concurrency.

KAP solves orchestration.


Links


If this resonates, I’d love feedback 🙌

Top comments (0)