Modern Android: Stop Designing Callback Interfaces, Use Suspend and Flow Instead
Keeping Listener/register/unregister patterns inside a coroutine app creates a second, hidden lifecycle model that bypasses structured concurrency. The result is leaked subscriptions and dangling callbacks when scopes cancel; adopting suspend and Flow as the default keeps cancellation and cleanup where the caller already manages it.
Kotlin coroutine stacks are the norm in Android, yet data-layer interfaces often still expose Listener/register/unregister patterns. A senior engineer with three years of Flow experience recently proposed a callback interface for a continuous data API, showing that using Flow doesn't guarantee thinking in Flow. The right default is to model one-shot operations as suspend functions and continuous data as Flow; callback is fine for low-level APIs but drags a second lifecycle model into a coroutine-native app.
Flow ties cancellation and cleanup to the calling scope, and callbackFlow adapts legacy listener APIs while keeping unregistration in awaitClose. Data semantics decide the type: Flow for event streams, StateFlow for current state (with conflation caveats), suspend for one-shot queries. A data layer can unify IM SDK, vehicle property, and network callbacks behind Flow contracts without forcing underlying APIs to change; the contract is what unifies, not the implementation.
The anecdote about a senior Flow user proposing a callback interface shows that tool experience does not guarantee design-model alignment; without explicit defaults, habits persist.
The article's rule is scoped to projects already fully on coroutines; a mixed codebase may need different trade-offs.
The core distinction is between the consumer contract and the implementation: Flow at the boundary, callbacks behind the adapter, gives flexibility without leaking lifecycle concerns.