How does the structured concurrency principle shape the design of suspend functions that do internal concurrency? Why shouldn't such a function expose its own background coroutines?
answer
- Return point = all spawned work settled
- Wrap fan-out in coroutineScope { }
- Don't take a scope to detach work
- Lifecycle producers: CoroutineScope.foo() extension, not suspend
- Encapsulate concurrency; never GlobalScope in libs
basics
~10 sA suspend function should finish all the concurrency it starts before it returns, using coroutineScope. That way callers never inherit hidden background work they didn't ask for.
solid answer
~50 sStructured concurrency turns 'a suspend function returns' into a strong contract: when it returns normally, all work it spawned is finished; if it throws, that work is cancelled. To honor that, a suspend function that fans out internally should wrap its concurrency in coroutineScope { } (or supervisorScope { }) so the block awaits every child before returning. It should NOT take a CoroutineScope receiver just to launch detached work, nor use GlobalScope, because that leaks coroutines past the call and makes the function's completion meaningless. The exception is APIs that intentionally start lifecycle-bound background work — those use the CoroutineScope extension-function convention (fun CoroutineScope.startX()) so the caller's scope explicitly owns the work, keeping it structured. The default, though, is encapsulation: callers see a plain suspend function whose return point is also the point at which all its concurrency is settled.
go deeper
Understands a suspend function should finish its work before returning and use coroutineScope.
Can write the coroutineScope wrapper and identify GlobalScope as the wrong choice.
Articulates the return-point contract, the scope-extension exception for lifecycle producers, and why encapsulation aids composability.
Sets library conventions, weighs encapsulation vs caller-owned producers across module boundaries, and reasons about cancellation/timeout composition guarantees.
## The contract structured concurrency creates For a `suspend fun`, structured concurrency makes the return point meaningful: - **Returns normally** ⇒ every coroutine it started has completed. - **Throws** ⇒ every coroutine it started has been cancelled. This lets a caller reason locally: after `val x = compute()`, there is no leftover background work from `compute()`. ## How to honor it: wrap concurrency in coroutineScope ```kotlin suspend fun aggregate(): Report = coroutineScope { val a = async { sourceA() } val b = async { sourceB() } Report(a.await(), b.await()) // both settled before return } ``` The `coroutineScope` block guarantees both `async` children finish (or all are cancelled on failure) before `aggregate` returns. The concurrency is **encapsulated** — callers don't even know it exists. ## The anti-pattern: leaking your own coroutines ```kotlin // BAD: takes a scope just to detach work fun CoroutineScope.aggregateLeaky() { launch { sourceA() } // outlives the logical call launch { sourceB() } } ``` or worse, `GlobalScope.launch` inside a suspend function. Now the function 'returns' while work is still running. The return point lies — it no longer means the work is done. That breaks the local-reasoning property and risks leaks. ## The legitimate exception: lifecycle-bound producers Some APIs *intentionally* start background work tied to a caller's scope — e.g. a function that launches a long-running listener. The idiom is to make it an **extension on CoroutineScope** and NOT mark it `suspend`: ```kotlin fun CoroutineScope.launchHeartbeat(): Job = launch { while (isActive) { sendPing(); delay(5_000) } } ``` Here the signature *advertises* that the caller's scope will own a new coroutine — it's explicit, so it's still structured: the caller's scope is responsible for it. The rule: *suspend functions await their concurrency; scope-extension functions hand ownership to the caller — explicitly.* ## Design heuristics - Default to `suspend fun` + internal `coroutineScope { }`; keep concurrency invisible to callers. - Never accept a `CoroutineScope` just to spray detached `launch`es from an otherwise-suspend API. - If you must start lifecycle work, use the `CoroutineScope.foo()` extension convention so ownership is in the signature. - Never use `GlobalScope` inside library/suspend code. ## Why it pays off Encapsulated concurrency composes: callers can wrap your function in `withTimeout`, cancel it, or run it inside their own `coroutineScope`, and cancellation/leak guarantees still hold end-to-end.
- When is it correct for a function to take a CoroutineScope receiver?When it intentionally hands a long-running, lifecycle-bound coroutine to the caller's scope — the CoroutineScope.foo() extension convention, which makes ownership explicit in the signature.
- Why does internal coroutineScope make the function more composable?Because cancellation and timeouts applied by the caller propagate cleanly into the encapsulated children, with no orphaned work escaping.
saying these in an interview costs you the question
- Having a suspend function spawn detached launches that outlive it
- Passing CoroutineScope around just to fire-and-forget
- Using GlobalScope inside library code
- Believing a suspend function may legitimately leave background work running after returning
- Not distinguishing the suspend-encapsulation pattern from the scope-extension producer pattern