跪拜 Guibai
← Back to the summary

Structured Concurrency Isn't Just for Coroutines: Why Android's Data Layer Must Stop Launching, Starting, and Broadcasting

The Essence of "Structured" — Turning Chaos into Organized, Rule-Bound, Bounded Systems

Foreword

This article stems from repeated observations:

These issues seem unrelated — one is about coroutine usage, another about resource management, and the third about communication frameworks. But digging deeper reveals they all point to the same underlying problem:

Which layer should decide the ownership of tasks, resources, and events?

The answer to this question is "structured."


Chapter 1: What Is Structured

Before diving into coroutines, let's step away from technology and look at a real-world example.

Moving house: everything piled on the floor:

Clothes / Socks / Computer / Charger / Toothbrush / Books / Cables

Finding anything is pure luck — this is unstructured.

If you sort and organize:

Wardrobe
 ├─ Shirts
 ├─ Pants
 └─ Socks

Bookshelf
 ├─ Technical books
 └─ Novels

Toolbox
 ├─ Cables
 ├─ Charger
 └─ Mouse

Now everything has a place, every place has a boundary, and every boundary has rules. This is structured:

Structured = organizational relationships + lifecycle relationships + boundary relationships

The entire history of software engineering has been doing the same thing — turning "chaos" into "structure":

Phase Chaos Solved
GOTO → if/for/while Uncontrolled flow jumps
Object-oriented programming Data scattered everywhere, unclear responsibilities
MVC / MVVM / Clean Architecture Code piled together, anyone can modify or depend on anything
Structured concurrency (coroutines) Concurrent tasks detached from lifecycle, no one knows who created, manages, or cancels them

The problem with the traditional thread model is precisely "unstructured concurrency":

new Thread(() -> {
    while (true) { doWork(); }
}).start();

The parent thread ends, the Activity is destroyed, the page exits — but the child thread keeps running, and no one knows when it should stop.

The real innovation of coroutines is not "easier thread switching," but Structured Concurrency: making every concurrent task know "who creates me, who manages me, who cancels me, who awaits me, and who handles my exceptions."


Chapter 2: The Two Layers of Coroutine Structure

Structured concurrency manifests at two levels.

First Layer: Lifecycle Structure

A coroutine must belong to a Scope; it cannot exist in a vacuum, just as a file must belong to a folder:

class UserViewModel : ViewModel() {
    fun load() {
        viewModelScope.launch {
            repository.loadUser()
        }
    }
}

When the ViewModel is destroyed, viewModelScope is automatically cancelled, and the entire coroutine tree is reclaimed together.

Second Layer: Cancellation Structure

coroutineScope {
    launch { loadUser() }
    launch { loadOrder() }
}

This forms a parent-child relationship: cancelling the Parent automatically cancels the child tasks. This propagation is automatic and requires no manual stop().

Why GlobalScope Is Officially Discouraged

GlobalScope.launch { }

The problem isn't that the API is hard to use, but that it breaks away from structure: no parent node, no lifecycle ownership, no cancellation relationship, no exception ownership. It effectively binds to the process-level lifecycle, which is usually not the boundary the developer truly wants.

The Essential Difference Between withContext and launch

This is a point many developers confuse, but it's key to understanding "structured."

// withContext: only switches execution context, still belongs to the same task
suspend fun loadUser(): User = withContext(Dispatchers.IO) {
    api.getUser()
}

withContext only switches the thread; lifecycle, cancellation relationship, and exception propagation remain unchanged. The entire call chain stays structured.

// launch: creates a new, independent coroutine
fun loadUserBad() {
    repositoryScope.launch {
        api.getUser()
    }
}

launch creates a new Job, meaning the lifecycle, cancellation relationship, and exception propagation all change — cancelling the caller does not necessarily cancel this new task.

withContext changes the execution thread; launch changes the task's ownership.

Many tech blogs conflate the two, which is a major reason for anti-patterns like "abusing launch in Repository."


Chapter 3: Three Anti-Patterns in the Wild

With the theory understood, let's look at how "structure" is broken in real code. The following code can be seen in many projects:

class UserViewModel : ViewModel() {
    fun load() {
        viewModelScope.launch(Dispatchers.IO) {
            try {
                val user = repository.loadUser()
                _uiState.value = UiState.Success(user)
            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    _uiState.value = UiState.Error(e.message)
                }
            }
        }
    }
}

The code works, but from an architectural perspective, problems have already emerged.

Anti-Pattern 1: ViewModel Decides the Thread Model

viewModelScope.launch(Dispatchers.IO)

The core issue isn't "whether IO is used correctly," but that the ViewModel shouldn't be making this decision. The ViewModel's responsibility is to orchestrate business processes and expose UI state, not to decide which thread to run on. Once the ViewModel specifies Dispatchers.IO, the UI layer starts to be aware of the thread model, and thread semantics leak from the data layer to the presentation layer — a classic case of inverted responsibilities.

Anti-Pattern 2: Manually Switching Back to Main in the UI Layer Signals Structural Imbalance

withContext(Dispatchers.Main) {
    _uiState.value = UiState.Error(e.message)
}

If you need to "manually switch back to Main" in the ViewModel, it means the coroutine's launch context is already unreasonable. The result is an execution path like Main → IO → Main. The problem isn't the extra switch, but that the ViewModel is taking on the responsibility of thread orchestration.

Anti-Pattern 3: Exception Handling Logic Leaks into the UI Layer

try {
    val user = repository.loadUser()
} catch (e: Exception) {
    _uiState.value = UiState.Error(e.message)
}

This code appears to be "playing it safe," but it actually exposes a deeper problem: the UI layer is directly aware of underlying exceptions. The ViewModel needs to know what exceptions the Repository might throw, coupling the UI state to exception types. Network errors, business errors, and data errors are all mixed together in the UI layer. Exceptions are implementation details; the UI layer should only care about result semantics.

Correct Layering Principles

The Repository's responsibilities should converge to three points: determine the thread model, catch and transform exceptions, and return stable, consumable results.

The ViewModel's responsibility is just one thing: map the Repository's results to UI state.

// Repository layer: thread switching and exception transformation happen here
class UserRepository {
    suspend fun loadUser(): User = withContext(Dispatchers.IO) {
        try {
            api.getUser()
        } catch (t: Throwable) {
            throw mapToAppException(t)
        }
    }
}

// ViewModel layer: only consumes results, no Dispatchers, no try-catch
class UserViewModel : ViewModel() {
    fun load() {
        viewModelScope.launch {
            repository.loadUser()
                .onSuccess { _uiState.value = UiState.Success(it) }
                .onFailure { _uiState.value = UiState.Error(it.message) }
        }
    }
}

When requirements change (adding cache, retry, fallback, logging, replacing data sources), all changes happen only in the Repository layer; the UI and ViewModel code remain untouched.

Symptom Root Problem
ViewModel switches to IO Thread responsibility leak
UI manually switches to Main Coroutine structure imbalance
UI try-catch Exception model not converged

Coroutine problems are often not about using the wrong API, but about blurred boundaries.


Chapter 4: One-Shot Tasks — Should the Repository launch?

The previous chapter showed what happens when structure is broken. This chapter addresses a more specific engineering judgment: For one-shot data operations, should the Repository internally launch?

Many articles present the following as a "best practice":

class UserRepository {
    private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    fun load(onResult: (User) -> Unit) {
        repositoryScope.launch {
            val user = api.getUser()
            onResult(user)
        }
    }
}

The usual justification is "the Repository handles its own async" and "the caller doesn't need to launch, making it simpler." But what this really changes isn't "whether it's async" — it's the lifecycle ownership of the task.

Many mistakenly believe "time-consuming operation = launch," so whenever they access a database or make a network request, they habitually sneak a new coroutine inside the Repository. But the modern Android recommended design is: for one-shot data operations, the Repository should expose a suspend fun, and let the caller decide which CoroutineScope to launch the coroutine in.

suspend expresses capability; launch expresses task ownership.

The Unified Voice Behind Official Recommendations

The Data/Domain layer exposes suspend functions and Flows; the caller controls coroutine creation, cancellation, and lifecycle.

The Essential Difference Between the Two Approaches

Approach A (Recommended):

// Repository
suspend fun load(): User = withContext(Dispatchers.IO) {
    api.getUser()
}

// ViewModel
fun refresh() {
    viewModelScope.launch {
        val user = repository.load()
        _uiState.value = UiState.Success(user)
    }
}

The task is managed by viewModelScope; when the page is destroyed, the task is automatically cancelled, and exceptions propagate naturally along the call chain.

Approach B (Not Recommended):

// Repository
fun load(onResult: (User) -> Unit) {
    repositoryScope.launch {
        onResult(api.getUser())
    }
}

The ViewModel just "sends a command"; the actual coroutine lifecycle is no longer managed by the ViewModel. The caller doesn't know when the task ends or how exceptions are handled. After the page is destroyed, this task might still be running.

The difference isn't "who writes launch more conveniently" — it's who owns the control of this asynchronous task.

Why suspend fun Better Fits Modern Architecture

1. The caller determines the lifecycle. The same Repository might be used by many callers (Fragment, ViewModel, WorkManager), each with completely different lifecycles. If the Repository secretly launchs, it assumes "I decide the lifecycle of this task," which is precisely the problem.

2. suspend naturally supports structured concurrency composition.

suspend fun loadAll() = coroutineScope {
    val user = async { repository.loadUser() }
    val notice = async { repository.loadNotice() }
    UiData(user.await(), notice.await())
}

Because it's a suspend fun, you can freely use coroutineScope for concurrent composition, supervisorScope for failure isolation, and withTimeout for timeout control. If the Repository internally launchs, these composition capabilities are significantly weakened because you've lost the task's completion timing.

3. launch makes completion timing ambiguous. With the suspend version, the caller knows clearly "when this line completes, the result is ready"; with the internal launch version, the caller can only rely on callbacks, event notifications, or additional state flows to perceive completion — internal launch in the Repository doesn't reduce complexity; it just hides the temporal complexity.

4. Exception propagation is interrupted. Exceptions from a suspend fun propagate naturally up the call chain: Repository throws → ViewModel catches → UI decides how to display. But if the Repository launchs itself, exception handling becomes a dangling problem — swallow it? Log it? Send an event? More critically, the upper layer's try-catch simply cannot catch exceptions thrown by an internal launch.

5. Cancellation capability is weakened. If the call chain remains structured, when viewModelScope is cancelled, repository.load() will be cancelled along with it. But if the Repository uses its own repositoryScope, cancelling the caller doesn't necessarily cancel the task inside the Repository — leading to common issues like "page is gone, but the network request is still running" or "the result comes back, but the page is already destroyed."

Responsibility Overreach

Intuitively, it might seem like just "encapsulating async," but in essence, the Repository has taken on extra responsibilities that don't belong to it: deciding which Scope the coroutine runs in, whether the task is cancellable, who handles exceptions, whether the task can be re-entered concurrently, and how the caller perceives completion.

A normal Repository should be a data access abstraction, a local/remote data combiner, and a wrapper for query and write semantics — not a background task scheduler or lifecycle manager.

Where Should launch Be Placed?

launch belongs at the boundary layer; suspend stays in the composable business capability layer.

Can a Repository Never Create a Coroutine?

Not exactly; we need to distinguish two scenarios.

Scenario 1: One-shot operations, exposing suspend externally, internally using coroutineScope, supervisorScope, async to organize subtasks within the current call chain — this is "organizing subtasks within the current call chain," not secretly creating a top-level task detached from the caller. The difference is significant.

Scenario 2: The task must outlive the caller. For example, a user clicks "favorite" and wants the write to complete even if they leave the page. A more accurate design here is to use an externally injected long-lived Scope, rather than the Repository casually newing one. The key point: this is an exception, not the default mode; why the task needs a longer lifecycle must be explicitly designed.

Actionable Decision Rules

When debating in code review whether a Repository should launch itself, ask these four questions:

  1. Is this a one-shot operation or a continuous data source? One-shot → suspend fun; Continuous → Flow.
  2. Who is best suited to decide its lifecycle? Page-related → ViewModel; Business action entry → UseCase; Detached from page → WorkManager. If the answer isn't the Repository, it shouldn't default to launch.
  3. Does the caller need to know when it completes and whether it fails? If the answer is "yes," internal launch is usually a bad idea.
  4. Must this task outlive the caller? No → suspend fun; Yes → consider an externally injected long-lived Scope.

Team Convention Recommendations:

  1. Repository defaults to exposing suspend fun for one-shot operations
  2. Repository defaults to exposing Flow for continuous data
  3. Coroutine launch is typically decided by ViewModel / UseCase / WorkManager
  4. Repository can use coroutineScope / withContext internally to organize implementation, but should not default to secretly launching top-level launch
  5. Only when a task explicitly needs to outlive the caller's lifecycle should an externally injected long-lived Scope be considered

Modern Android recommends Repository expose suspend fun, not because launch is unusable, but because the lifecycle of one-shot data operations should, by default, be controlled by the caller, not the Repository.


Chapter 5: Continuous Resources — start/stop Should Retire

The previous chapter discussed one-shot tasks. This chapter addresses another, more insidious form of structural breakage: lifecycle management of continuous resources (system broadcasts, listeners).

In many projects, the Data layer uses a pattern like this to listen for system state changes (e.g., system language changes):

class LocaleRepository(context: Context) {
    private val appContext = context.applicationContext
    private var receiver: BroadcastReceiver? = null
    private var isRegistered = false

    fun start(onChange: (String) -> Unit) {
        if (isRegistered) return
        receiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context, intent: Intent) {
                onChange(Locale.getDefault().toLanguageTag())
            }
        }
        appContext.registerReceiver(receiver, IntentFilter(Intent.ACTION_LOCALE_CHANGED))
        isRegistered = true
    }

    fun stop() {
        if (!isRegistered) return
        appContext.unregisterReceiver(receiver)
        isRegistered = false
    }
}

start() registers the broadcast, stop() unregisters it — intuitive on the surface, but in real engineering, it gradually leads to long-term issues like unmanageable lifecycle synchronization, resource leaks from missed stop() calls, and concurrency state errors.

The Real Problem: It Breaks Structured Lifecycle

Many first encountering this problem think "just remember to call stop()", but the real issue isn't just "easy to forget" — it's:

start/stop detaches the resource lifecycle from structured scope management.

The core idea of structured concurrency is: the lifecycle must be managed by the parent scope. Who creates the resource, who holds it, who cancels it, who releases it — all should be traceable and convergeable. viewModelScope.launch { } doesn't need a manual stop because when the ViewModel is destroyed, the child coroutine is automatically cancelled — the lifecycle follows the scope and converges automatically. In contrast, start/stop makes the lifecycle dependent on human convention, call order, and external memory — this is essentially unstructured resource management.

Why the Data Layer Shouldn't Expose stop()

1) Impure responsibilities: The Data layer shouldn't expose resource control details. The Data layer should answer "what is the data," not "when should you start/stop me." When a Repository exposes start/stop, the upper layer must understand the internal resource model, know when to register and release, and know if stop must be called in pairs — internal lifecycle details have leaked outward.

2) Fragile calling contract: System stability relies on "memory." The start/stop model essentially depends on the caller guaranteeing strictly paired calls. But in real projects, page navigation, lifecycle interruptions, coroutine cancellation, early returns due to exceptions, and multi-page sharing can all lead to missed stop() calls, resulting in Receiver leaks, duplicate registrations, state mismatches, and Receiver not registered crashes.

3) High concurrency complexity: Shared state easily spirals out of control. Many start/stop implementations also maintain an isRegistered flag. But in concurrent scenarios, coroutine A calls start(), coroutine B calls stop() almost simultaneously, and the order of flag updates can easily misalign with system call order — isRegistered becomes an extra "source of truth" for the lifecycle, and when multiple threads modify it simultaneously, complexity skyrockets.

Recommended Solution: callbackFlow + awaitClose

class NetworkRepository(context: Context) {

    private val appContext = context.applicationContext
    private val connectivityManager =
        appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

    fun observeNetworkConnected(): Flow<Boolean> = callbackFlow {

        val callback = object : ConnectivityManager.NetworkCallback() {

            override fun onAvailable(network: Network) {
                trySend(true)
            }

            override fun onLost(network: Network) {
                trySend(false)
            }

            override fun onUnavailable() {
                trySend(false)
            }
        }

        val request = NetworkRequest.Builder()
            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
            .build()

        connectivityManager.registerNetworkCallback(request, callback)

        // Initial value (to avoid cold flow having no initial state)
        val activeNetwork = connectivityManager.activeNetwork
        val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
        trySend(capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true)

        awaitClose {
            connectivityManager.unregisterNetworkCallback(callback)
        }
    }
}

Core idea: resources are registered when collection starts and automatically released when collection ends. The lifecycle is driven by the subscription relationship, not by an external manual stop().

This is essentially like try/finallytry corresponds to "establishing resources when collection starts," and finally corresponds to the release logic in awaitClose. The difference is that this mechanism is entirely bound to the coroutine scope chain, requiring no external stop, no shared state, and no additional lifecycle synchronization.

Upper-Layer Usage Recommendations

class SettingsViewModel(
    repository: NetworkRepository
) : ViewModel() {
    val isNetworkConnected: StateFlow<Boolean> =
        repository.observeNetworkConnected()
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = false
            )
}

Two benefits here:

This Isn't Just "More Elegant Code"

Many first encountering callbackFlow think "it's just moving register/unregister into awaitClose." But what's truly important isn't the change in syntax — it's:

The resource lifecycle shifts from "human management" to "subscription-driven" — i.e., structured management.

Engineering Benefits:

  1. Stability gains: Collection start automatically registers, collection end automatically releases, no longer relying on the caller to remember stop. A large number of Receiver leaks, Context leaks, duplicate unregister errors, and crashes are eliminated.
  2. Architectural gains: The Data layer only "outputs data streams" and no longer exposes resource control behavior. In the future, switching from BroadcastReceiver to a callback API or ContentObserver requires almost no changes to the upper layer.
  3. Concurrency gains: The subscription relationship itself is the true lifecycle state; no more shared boolean states like isRegistered are needed. No extra "source of truth" for the lifecycle is maintained, naturally reducing concurrent state contention.
  4. Collaboration gains: New team members seeing Flow<T> will naturally know "just collect it"; seeing start/stop requires tracing the lifecycle, call chain, boundary states, and whether duplicate calls are allowed. A unified Flow style makes APIs more consistent, code reviews more focused, and collaboration more efficient.

Code Review Checklist

Migration Steps

  1. Add a new Flow interface, e.g., observeSystemLocaleTag()
  2. Gradually remove start() / stop() dependencies
  3. Upper layer switches to lifecycle-aware collect (e.g., with repeatOnLifecycle)
  4. Mark old interfaces with @Deprecated, then fully remove after a stable release

The traditional start()/stop() model essentially means "the caller is responsible for the resource lifecycle"; the essence of callbackFlow + awaitClose is "the subscription relationship drives the resource lifecycle." The biggest difference between these two designs isn't the amount of code, but whether the lifecycle is structured and whether resources can converge automatically.


Chapter 6: Event Buses Should Retire — Modern Alternatives to EventBus and LocalBroadcastManager

The previous five chapters focused on "structure within coroutines." This chapter expands the perspective to inter-component communication — the corner where structured thinking is most easily overlooked. Tools like EventBus and LocalBroadcastManager are essentially textbook examples of completely scattering "ownership relationships."

The Problems with EventBus and LocalBroadcastManager

Event bus frameworks like EventBus and Otto solved inter-component communication challenges in their time, but their drawbacks are clear:

Many projects then switched to LocalBroadcastManager for in-app communication, which is equally wrong: it was officially deprecated in AndroidX 1.1.0; Intents pass data via string keys, so changing a field name compiles without error but crashes at runtime with an NPE; broadcasts are designed for cross-process/cross-app communication, using them in-app is a responsibility mismatch; and they cannot naturally integrate with ViewModel and lifecycle.

The common root of all these problems is exactly what the previous chapters have repeatedly emphasized: communication relationships have broken away from structured ownership management — who publishes, who subscribes, whose lifecycle it belongs to — all implicitly scattered across the codebase, untraceable.

Alternative Solutions, One by One

1. UI State Updates: StateFlow + ViewModel

class UserViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()
}

No manual register/unregister needed; the state is a single source of truth, and the UI is always in sync with the state.

2. Fragment-to-Fragment Communication: Shared ViewModel

class SharedViewModel : ViewModel() {
    val selectedItem = MutableStateFlow<Item?>(null)
}

// FragmentA and FragmentB
private val sharedViewModel: SharedViewModel by activityViewModels()

Two Fragments communicate through the same SharedViewModel instance; the dependency is explicit, testable, and side-effect-free.

3. One-Shot Events (Toast, Navigation): SharedFlow / Channel

StateFlow sends the latest value to new subscribers, which is suitable for continuous state but not for one-shot events (showing a Toast, navigating) — after a page rotation, the event would be re-triggered. Use:

private val _navEvent = MutableSharedFlow<NavEvent>()
val navEvent: SharedFlow<NavEvent> = _navEvent.asSharedFlow()

4. Global State and Cross-Module Events: Repository + Flow

This is the most commonly abused scenario and the hardest hit by EventBus and LocalBroadcastManager. Many projects abstract cross-module capabilities into interfaces, but design the methods as regular functions returning instantaneous values:

// ❌ Gets an instantaneous value; the caller can only poll repeatedly
interface UserRepo {
    fun getCurrentUser(): User?
}

This makes the same mistake as EventBus passing state — losing the advantage of reactivity. The interface should be designed according to correct semantics:

// ✅ Truly reactive
interface UserRepo {
    val currentUser: Flow<User?>
    suspend fun login(account: String, password: String): Result<Unit>
}
// Global state synchronization: Repository as single source of truth
class UserRepository {
    private val _currentUser = MutableStateFlow<User?>(null)
    val currentUser: StateFlow<User?> = _currentUser.asStateFlow()

    suspend fun login(account: String, password: String): Result<Unit> = runCatching {
        val user = api.login(account, password)
        _currentUser.value = user
    }
}

After a successful login, all pages respond automatically; the same for logout. Zero broadcasts, zero event buses.

For cross-module command-type action signals (not continuous state), use SharedFlow; for Service-to-UI communication, no broadcast is needed either — just @Inject Repository + StateFlow.

Complete Alternative Solution Comparison Table

Scenario ❌ Old Solution ✅ Modern Alternative
UI state updates EventBus.post(StateEvent) StateFlow + collect
Fragment-to-Fragment communication EventBus.post / @Subscribe Shared ViewModel (activityViewModels())
One-shot events (Toast/Navigation) EventBus.post(NavEvent) SharedFlow or Channel
Global login/user state LocalBroadcastManager.sendBroadcast Repository + StateFlow
Cross-module command-type events LocalBroadcastManager.sendBroadcast Repository + SharedFlow
Service notifying UI LocalBroadcastManager.sendBroadcast @Inject Repository + StateFlow
Cross-module capability invocation Direct dependency on implementation class Interface (Flow + suspend) + Hilt injection
Cross-process/cross-app System BroadcastReceiver (the correct use of broadcasts)

Migration Recommendations

Gradual Migration:

  1. Ban new code from introducing EventBus and LocalBroadcastManager (enforce with lint rules)
  2. Find all existing usage points (grep -r "EventBus.getDefault()")
  3. Replace them one by one per scenario: global/cross-module state → Repository + StateFlow; one-shot events → SharedFlow/Channel; Service communication → @Inject Repository
  4. After full replacement, remove the EventBus dependency and delete all @Subscribe annotation code

Architecture Trap Warning: Don't use Flow/LiveData to forcibly "hand-craft a bus." If you find yourself writing GlobalEventBus.post(MyEvent()), even if the underlying implementation uses Kotlin Flow, you're still creating implicit coupling and untraceable code — you've changed the tool, but haven't solved the structural problem.

In a componentized scenario, the three basic approaches remain unchanged, with just one additional convention: interfaces, Repositories, and public Models are all defined in module_base; business modules are not allowed to depend on each other.


Conclusion: All Problems Are the Same Problem

Looking back at the six chapters, you'll find an interesting fact: the structured nature of coroutines, the Repository suspend/launch debate, the broadcast start/stop debate, and the EventBus survival debate — they are all essentially discussing the same thing

Which layer should decide the ownership of tasks, resources, and events?

These four things seem scattered across different technical points, but they all answer the same architectural question: who creates, who manages, who cancels, who handles exceptions, and who decides the lifecycle boundary.

Dispatchers.IO written in the ViewModel, try-catch exposed in the UI layer, launch hidden in the Repository, start/stop scattered in the Data layer, events published to a global bus — these look like coding issues, but they're all symptoms of the same thing not being done right:

The ownership of tasks has been placed in the wrong layer.

Coroutines give us the ability for structured concurrency at the language level, but whether the code is actually "structured" depends on whether each layer holds its own boundary. That's the core message of this article.

References:

Why Modern Android Officially Recommends Repository Expose suspend fun Instead of launch Internally

Android Architecture Guide: Data Layer Should Stop Exposing start/stop: Use Flow to Manage Lifecycle

Stop launch(IO): 3 Hidden Anti-Patterns of Coroutine Thread Switching

Understanding Android Clean Architecture Through Food Delivery: Why the Boss Doesn't Need to Know What Vehicle the Delivery Person Drives

Getting Started with Android Clean Architecture with a Small Demo

Modern Android Architecture Doesn't Need an Event Bus

Why I Don't Handle Exceptions Directly in Android ViewModel