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:
- Some developers
launcha coroutine directly in a Repository, arguing it "makes things easier for the caller"; - During code reviews, I've seen Data layers manage broadcast registration with
start()/stop(), leading to endless debates over "who should call stop"; - Colleagues still use EventBus for inter-component communication, claiming it's "simple and easy."
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.
withContextchanges the execution thread;launchchanges 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.
suspendexpresses capability;launchexpresses 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.
- One-shot operations → expose
suspend fun - Continuous data streams → expose
Flow - Who launches the coroutine → typically the upper layer: ViewModel, UseCase entry points, WorkManager, or application-level coordinators
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?
launchbelongs at the boundary layer;suspendstays in the composable business capability layer.
- ViewModel: for tasks triggered by page lifecycle events like button clicks, initial loading, and pull-to-refresh
- UseCase entry points: for defining higher-level business execution processes
- WorkManager / application-level task coordinators: for tasks that naturally need to exist beyond the page lifecycle (log upload, offline sync, large file downloads), which should not be bound to
viewModelScope
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:
- Is this a one-shot operation or a continuous data source? One-shot →
suspend fun; Continuous →Flow. - 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. - Does the caller need to know when it completes and whether it fails? If the answer is "yes," internal
launchis usually a bad idea. - Must this task outlive the caller? No →
suspend fun; Yes → consider an externally injected long-lived Scope.
Team Convention Recommendations:
- Repository defaults to exposing
suspend funfor one-shot operations - Repository defaults to exposing
Flowfor continuous data - Coroutine launch is typically decided by ViewModel / UseCase / WorkManager
- Repository can use
coroutineScope/withContextinternally to organize implementation, but should not default to secretly launching top-levellaunch - Only when a task explicitly needs to outlive the caller's lifecycle should an externally injected long-lived
Scopebe considered
Modern Android recommends Repository expose
suspend fun, not becauselaunchis 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/stopdetaches 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/finally — try 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:
- Immediately restores the latest state after UI reconstruction:
StateFlowcaches the last value; page reconstruction doesn't need to wait for a broadcast, avoiding blank states. WhileSubscribed(5_000)reduces frequent register/unregister churn: avoids frequent broadcast registration/release due to brief backgrounding, configuration changes, or Compose recomposition — a very important engineering optimization.
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:
- 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. - 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.
- Concurrency gains: The subscription relationship itself is the true lifecycle state; no more shared boolean states like
isRegisteredare needed. No extra "source of truth" for the lifecycle is maintained, naturally reducing concurrent state contention. - Collaboration gains: New team members seeing
Flow<T>will naturally know "justcollectit"; seeingstart/stoprequires 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
- Does the Data layer still expose
start/stop? - Are internal lifecycle control details leaked?
- Is
callbackFlowused? Are resources released inawaitClose? - Is Application Context used to avoid Context leaks?
- Is there a risk of duplicate registration?
Migration Steps
- Add a new Flow interface, e.g.,
observeSystemLocaleTag() - Gradually remove
start()/stop()dependencies - Upper layer switches to lifecycle-aware
collect(e.g., withrepeatOnLifecycle) - 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 ofcallbackFlow + awaitCloseis "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:
- Implicit dependencies: Subscribe/publish relationships are scattered everywhere, making the call chain untraceable
- Memory leaks: Forgetting to
unregisteris a common occurrence - Lifecycle unawareness: Receiving events at the wrong time can cause UI anomalies at best, or crashes at worst
- No type safety: Early versions relied on reflection; the IDE couldn't statically check them, making refactoring a nightmare
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:
- Ban new code from introducing EventBus and LocalBroadcastManager (enforce with lint rules)
- Find all existing usage points (
grep -r "EventBus.getDefault()") - Replace them one by one per scenario: global/cross-module state → Repository + StateFlow; one-shot events → SharedFlow/Channel; Service communication →
@Inject Repository - After full replacement, remove the EventBus dependency and delete all
@Subscribeannotation 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?
- Coroutines tell us: concurrent tasks must have a parent scope to manage them
- Repository design tells us: the lifecycle of one-shot operations should be decided by the caller, not secretly managed by the data layer
callbackFlowtells us: the lifecycle of continuous resources should be driven by the subscription relationship and converge automatically, not rely on manual start/stop- EventBus's retirement tells us: inter-component communication should also have clear, traceable ownership, not be published into a global bus with unknown boundaries
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
Getting Started with Android Clean Architecture with a Small Demo