DEV Community

Riazul Karim Ivan
Riazul Karim Ivan

Posted on

Structured Concurrency with Kotlin Coroutines

Modern applications are highly concurrent. We fetch APIs, read databases, update UI, and process background tasks — often at the same time.

But concurrency without structure becomes chaos.

In this blog, we’ll explore:

  • What is Unstructured Concurrency
  • Why we need Structured Concurrency
  • The Fundamental Laws of Structured Coroutines
  • Real example with android viewModel implementation

What Is Unstructured Concurrency?

Unstructured concurrency is when asynchronous work is started without clear ownership, lifecycle, or control.

It’s the classic: “Start it and hope for the best.”

Example using traditional threads:

Thread {
    println("Doing background work")
}.start()
Enter fullscreen mode Exit fullscreen mode

What’s wrong here?

  • Who owns this thread?
  • What if the screen is destroyed?
  • How do we cancel it?
  • What if it throws an exception?
  • How do we wait for it?

You don’t have much control over:

  • Lifecycle
  • Cancellation
  • Exception propagation
  • Execution flow

Even error handling becomes messy:

Thread {
    try {
        // some work
    } catch (e: Exception) {
        // won't propagate to parent
    }
}.start()
Enter fullscreen mode Exit fullscreen mode

Exceptions don’t bubble up naturally. You lose structured error handling.

This is what we call Fire and Forget.

It may work in small programs — but in real systems (especially large Android apps), this becomes dangerous.

Why Traditional Threading Becomes Hard

Traditional threading:

  • Doesn’t enforce hierarchy
  • Doesn’t automatically propagate cancellation
  • Doesn’t wait for child work
  • Doesn’t provide predictable error handling

In complex systems:

  • Memory leaks happen
  • Background jobs continue after UI is gone
  • Errors disappear silently
  • Debugging becomes painful

As applications grow, this becomes unmanageable.


Why We Need Structured Concurrency

Structured concurrency solves these problems by introducing rules.

Instead of free-floating async work, we:

  • Group related tasks together
  • Track execution steps clearly
  • Manage lifecycle properly
  • Create a parent-child hierarchy
  • Make cancellation predictable
  • Make failure handling consistent

Think of it like this:

Unstructured concurrency = random background workers
Structured concurrency = organized team with a manager


The Core Idea: Parent-Child Hierarchy

Structured concurrency enforces a tree structure:

Parent
 ├── Child 1
 │     └── Grandchild
 └── Child 2
Enter fullscreen mode Exit fullscreen mode

Rules:

  • A parent controls its children
  • A child cannot outlive its parent
  • Errors propagate upward
  • Cancellation flows downward

No more orphan coroutines.


What Is Structured Concurrency in Kotlin?

Structured concurrency is built into Kotlin coroutines using:

  • CoroutineScope
  • coroutineScope
  • supervisorScope
  • launch
  • async

Every coroutine must run inside a scope.

That scope defines:

  • Lifecycle
  • Cancellation rules
  • Error propagation
  • Completion behavior

Fundamental Laws of Structured Coroutines

There are five fundamental laws.

1. Law of Scope Ownership

Every coroutine must run inside a scope.

Bad (unstructured):

GlobalScope.launch {
    delay(1000)
}
Enter fullscreen mode Exit fullscreen mode

Why bad?

  • No lifecycle control
  • Not tied to UI or business logic
  • Hard to test
  • Hard to cancel

Good:

coroutineScope {
    launch {
        delay(1000)
    }
}
Enter fullscreen mode Exit fullscreen mode

The coroutine now belongs to the scope.

2. Law of Parent Completion

A parent scope suspends until all children complete.

Example:

coroutineScope {
    launch {
        delay(1000)
        println("Child done")
    }
}

println("Parent done")
Enter fullscreen mode Exit fullscreen mode

Output:

Child done
Parent done
Enter fullscreen mode Exit fullscreen mode

The parent does not finish until the child finishes.

This guarantees predictable execution flow.

3. Law of Downward Cancellation

Cancellation flows from parent to children.

Example:

val job = launch {
    launch {
        delay(5000)
        println("Child finished")
    }
}

delay(1000)
job.cancel()
Enter fullscreen mode Exit fullscreen mode

When the parent is cancelled:

  • All children are cancelled automatically.

No orphan jobs.

4. Law of Upward Exception Propagation

Exceptions propagate from child to parent.

Example:

coroutineScope {
    launch {
        throw RuntimeException("Failure!")
    }

    launch {
        delay(5000)
    }
}
Enter fullscreen mode Exit fullscreen mode

If one child fails:

  • Parent is cancelled
  • Sibling coroutines are cancelled

This enforces failure transparency.

You cannot silently ignore critical failures.

5. Law of Structured Boundaries

Concurrency must be bounded within a scope.

All async work must exist inside a well-defined boundary.

Not this:

fun loadData() {
    GlobalScope.launch {
        // background work
    }
}
Enter fullscreen mode Exit fullscreen mode

But this:

suspend fun loadData() = coroutineScope {
    launch {
        // background work
    }
}
Enter fullscreen mode Exit fullscreen mode

Now:

  • The caller controls lifecycle
  • The caller controls cancellation
  • The caller can handle exceptions

What About Independent Failures?

Sometimes you don’t want one failure to cancel everything.

That’s where supervisorScope comes in:

supervisorScope {
    launch {
        throw RuntimeException("Fails")
    }

    launch {
        delay(1000)
        println("Still running")
    }
}
Enter fullscreen mode Exit fullscreen mode

Here:

  • One child fails
  • Other children continue

Useful for:

  • Parallel API calls
  • UI widgets
  • Independent features

Real-World Benefit (Especially for Large Apps)

In large systems:

  • Multi-module apps
  • Multiple API orchestration
  • Complex dashboards
  • Feature-based architecture

Structured concurrency gives:

  • Predictable lifecycle management
  • Safer cancellation
  • Cleaner error handling
  • Easier testing
  • No memory leaks
  • No zombie background jobs

It turns asynchronous chaos into a structured tree.


Structured vs Unstructured – Final Comparison

Feature Unstructured Structured
Lifecycle control
Cancellation propagation
Exception propagation
Hierarchy
Predictability
Maintainability

How to apply for android api call

Lets discuss three Scenarios to understand better:

  1. Sequential / Dependent API calls (Chain)
  2. Parallel Independent API calls (Zip – need both results)
  3. Parallel Calls — Independent — Partial Failure Allowed

All examples are structured concurrency safe and lifecycle-aware.

Base Assumption (Clean Architecture Style)

Assume:

class FirstApiUseCase
class SecondApiUseCase
Enter fullscreen mode Exit fullscreen mode

Each returns:

Flow<Result<T>>
Enter fullscreen mode Exit fullscreen mode

And everything runs inside:

viewModelScope
Enter fullscreen mode Exit fullscreen mode

Which means:

  • Automatically cancelled when ViewModel is cleared
  • No memory leaks
  • Structured lifecycle control

Scenario 1: Sequential API Calls (Second Depends on First)

Call API-1 → based on result → call API-2

Example:

  • Fetch user profile
  • Use userId → fetch user transactions

Let implement this with flatMapLatest chain

fun loadUserData(userId: String) {
    firstApiUseCase.execute(userId)
        .onStart { showLoading() }
        .flatMapLatest { userResult ->
            if (userResult is Result.Success) {
                secondApiUseCase.execute(userResult.data.id)
            } else {
                flowOf(Result.Error(Exception("User fetch failed")))
            }
        }
        .onEach { transactionResult ->
            handleTransactionResult(transactionResult)
        }
        .catch { showError(it) }
        .onCompletion { hideLoading() }
        .launchIn(viewModelScope)
}
Enter fullscreen mode Exit fullscreen mode

Code Flow Explanation

  • firstApiUseCase.execute() starts.
  • When first API emits success →
  • flatMapLatest triggers second API.
  • If first fails → second never runs.
  • Everything is inside viewModelScope.
  • If ViewModel clears → both calls cancel.

Structured Concurrency Law Applied:

  • Parent scope owns both API calls.
  • Cancellation flows downward.
  • Exceptions propagate upward.
  • Execution remains bounded inside scope.

Scenario 2: Parallel Independent Calls (Zip – Need Both Results)

Call API-A and API-B in parallel
Continue only when BOTH succeed

Example:

  • Fetch wallet balance
  • Fetch recent transactions

They are independent but UI needs both.

Lets implement this with zip

fun loadDashboard() {

    val balanceFlow = balanceUseCase.execute()
    val transactionFlow = transactionUseCase.execute()

    balanceFlow
        .zip(transactionFlow) { balanceResult, transactionResult ->
            Pair(balanceResult, transactionResult)
        }
        .onStart { showLoading() }
        .onEach { (balance, transactions) ->
            renderDashboard(balance, transactions)
        }
        .catch { showError(it) }
        .onCompletion { hideLoading() }
        .launchIn(viewModelScope)
}
Enter fullscreen mode Exit fullscreen mode

Code Flow Explanation

  • Both APIs start at the same time.
  • zip waits for BOTH to emit.
  • If either fails → whole flow fails.
  • Structured and lifecycle-safe.

Why This Is Structured

Both flows:

  • Are children of the same scope
  • Are cancelled if ViewModel clears
  • Fail together unless using supervisor

Scenario 3: Parallel Calls — Independent — Partial Failure Allowed

Example:

  • Load Profile
  • Load Notifications
  • Even if notifications fail → still show profile

Lets implement this with Supervisor Scope

Assuming use case returns Flow:

fun loadHomeScreen() {
    viewModelScope.launch {

        supervisorScope {

            val profileFlow = async {
                profileUseCase.execute().catch { emit(null) }.firstOrNull()
            }

            val notificationFlow = async {
                notificationUseCase.execute().catch { emit(null) }.firstOrNull()
            }

            val profile = profileFlow.await()
            val notifications = notificationFlow.await()

            profile?.let { renderProfile(it) }
            notifications?.let { renderNotifications(it) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

What Happens Here (Code Flow)

  1. viewModelScope.launch → lifecycle owned by ViewModel
  2. supervisorScope creates structured boundary
  3. Two async blocks start in parallel
  4. If one throws → it does NOT cancel the other
  5. Both results are awaited safely
  6. Each result handled independently

This is still 100% structured concurrency.

Why Not Use coroutineScope?

If you use:

coroutineScope {
    async { ... }
    async { ... }
}
Enter fullscreen mode Exit fullscreen mode

If one throws →
Parent gets cancelled →
Sibling gets cancelled automatically ❌

That’s Law #4: Exceptions propagate upward.

But in this case we WANT:

  • Failure isolation
  • Independent execution

So we use supervisorScope.

Structured Hierarchy Now Looks Like

viewModelScope
    └── supervisorScope
            ├── async Profile
            └── async Notifications
Enter fullscreen mode Exit fullscreen mode

Rules here:

  • Parent waits for both
  • Failure does NOT cancel sibling
  • Still lifecycle aware
  • Still bounded
  • Still structured

No GlobalScope.
No fire-and-forget.


Final Takeaway

Structured Concurrency is not just a feature. It’s a philosophy.

It says:

  • Async work must have ownership.
  • Work must be grouped logically.
  • Lifecycle must be controlled.
  • Errors must be visible.
  • Cancellation must be predictable.

If concurrency is a tree —
Structured concurrency makes sure every branch belongs to something.

No floating jobs | No silent failures | No chaos


Top comments (0)