Modern Android: Stop Designing Callback Interfaces, Use Suspend and Flow Instead
Modern Android Architecture Guide: Stop Designing Callback Interfaces by Default
The project has fully adopted coroutines, and we've been using Flow for three years.
One day, while discussing an interface that continuously receives data, a colleague with ten years of experience and three years of Flow usage suddenly proposed:
"Let's design a callback interface?"
I froze for three seconds.
I began to wonder whether the content I'd shared over the past three years merely echoed in the meeting room.
That moment made me realize: technical sharing doesn't equal changed design habits. Using a technology for a long time doesn't mean you truly understand the design philosophy behind it; writing lots of Flow doesn't mean you know when to use it.
In modern Android development, when a project has fully adopted Kotlin coroutines and Flow, why do we still habitually redesign Callback interfaces?
My view is clear:
For internal business interfaces and data-access interfaces, don't treat Callback as the default design. Prefer suspend for one-shot operations and Flow for continuous data.
This isn't about eliminating Callback, but about changing our first reaction when designing interfaces.
1. First, Distinguish One-Shot Operations from Continuous Data Streams
Different asynchronous operations suit different interfaces.
If an operation eventually returns a single result, such as loading a message, submitting an order, or querying vehicle info, a suspend function usually expresses it in Kotlin:
interface MessageRepository {
suspend fun getMessage(id: String): Message
}
If the caller needs to continuously receive data changes, such as incoming messages, gear changes, or network state, consider Flow:
interface MessageRepository {
fun observeMessages(): Flow<Message>
}
In short:
suspendfunctions express a single asynchronous operation and its result.Flow<T>expresses an asynchronous data stream that can be collected.
Android's official coroutine best practices also suggest that the data layer and business layer can use suspend functions for one-shot operations and Flow for data changes.
It's worth clarifying: Flow isn't only for infinite data streams; finite sequences can also be expressed with it.
What we need isn't to turn every interface into Flow, but to choose interfaces based on the actual semantics of the operation.
2. Why Modern Android Development Should Not Habitually Design Callback?
In Android projects, we often design interfaces like this:
interface MessageListener {
fun onMessageReceived(message: Message)
}
interface MessageRepository {
fun registerListener(listener: MessageListener)
fun unregisterListener(listener: MessageListener)
}
When a message arrives, the Repository calls onMessageReceived() to notify the upper layer.
This design isn't wrong. Callback is a mature design pattern. Android system APIs, third-party SDKs, and many low-level frameworks use it.
The problem is: low-level APIs using Callback doesn't mean your app's internal code must keep using Callback.
Assume our project has fully adopted Kotlin coroutines and the Repository already provides data upward via Flow.
If, every time we face continuous data, we still subconsciously design:
- A Listener interface;
- A register method;
- An unregister method;
- A set of callback-thread handling logic;
- A set of listener lifecycle management logic.
Then we are actually re-designing an asynchronous programming model inside the app.
And these efforts aren't necessarily what the business caller cares about.
For a continuously changing data source, the caller cares about:
What data do I consume, and what does it represent?
So instead of designing:
interface MessageRepository {
fun registerListener(listener: MessageListener)
fun unregisterListener(listener: MessageListener)
}
Prefer:
interface MessageRepository {
fun observeMessages(): Flow<Message>
}
The caller receives messages by collecting the Flow, without directly depending on the Listener type and registration method.
That's why I don't recommend habitually designing Callback inside modern Android apps.
Not because Callback is bad, but because we already have interface choices that fit Kotlin's asynchronous programming model better.
3. What's the Real Difference Between Callback and Flow?
A key difference, in my view, is that Flow naturally integrates with Kotlin coroutines' structured concurrency model.
Look at a traditional Callback interface:
interface MessageListener {
fun onMessageReceived(message: Message)
}
interface MessageRepository {
fun registerListener(listener: MessageListener)
fun unregisterListener(listener: MessageListener)
}
The caller registers a listener, receives messages, and unregisters at an appropriate time.
The issue is that the listener's lifecycle has no natural relation to the caller's coroutine lifecycle.
For example, when the page exits, the business logic collecting data no longer needs to run, but the underlying Listener may still be registered. Without explicit unregistering, it may keep receiving messages, even causing resource leaks.
Of course, Callback can solve this. We can manually manage registration and unregistration, or tie the listener to the lifecycle component.
But that means we need to build an extra resource management mechanism.
Now look at Flow:
interface MessageRepository {
fun observeMessages(): Flow<Message>
}
The caller can consume data like this:
viewModelScope.launch {
repository.observeMessages().collect { message ->
handleMessage(message)
}
}
When viewModelScope is cancelled, the coroutine collecting the Flow is also cancelled. For a Flow that follows the coroutine cancellation protocol, upstream data processing also gets cancelled.
If the underlying layer uses Callback adapted via callbackFlow, you can unregister the listener inside awaitClose.
fun observeMessages(): Flow<Message> = callbackFlow {
val listener = object : MessageListener {
override fun onMessageReceived(message: Message) {
trySend(message)
}
}
messageSdk.registerListener(listener)
awaitClose {
messageSdk.unregisterListener(listener)
}
}
This way, the underlying layer still uses Callback, but listener cleanup is tied to the cancellation of Flow collection.
That's one of Flow's key values in modern Kotlin Android development: it expresses continuous data and lets data consumption, cancellation, and resource cleanup integrate with coroutine lifecycle management.
What truly needs to change is our design habit.
When a project has fully adopted Kotlin coroutines, for continuous data we should prefer Flow rather than habitually designing another Listener, register, and unregister method set.
Flow's value isn't just making interfaces look more modern; it's letting continuous data consumption combine naturally with coroutines' structured concurrency model.
4. Example: Vehicle Gear Listening
Take AAOS vehicle gear as an example. The low-level CarPropertyManager (CPM) can notify property changes via CarPropertyEventCallback.
If acquiring vehicle properties belongs to the data-access responsibility, the data layer can wrap CPM and provide an interface that suits business needs.
For example:
interface CarRepository {
fun observeGear(): Flow<Int>
}
The caller consumes gear data through the interface without directly depending on CPM's callback types.
The underlying layer continues using Callback; the app's internal layer uses Flow.
That's the boundary we want to establish.
5. Converting Callback to Flow in the Data Layer
The Repository can use callbackFlow to adapt CPM's callback into a Flow:
class CarRepositoryImpl(
private val carPropertyManager: CarPropertyManager
) : CarRepository {
override fun observeGear(): Flow<Int> = callbackFlow {
val callback =
object : CarPropertyManager.CarPropertyEventCallback {
override fun onChangeEvent(
value: CarPropertyValue<*>
) {
if (
value.propertyId ==
VehiclePropertyIds.GEAR_SELECTION
) {
val gear = value.value as? Int
?: return
trySend(gear)
}
}
override fun onErrorEvent(
propertyId: Int,
areaId: Int
) {
// Design error propagation, logging, or flow termination based on interface design
}
}
// Register callback as required by the CPM API.
awaitClose {
// Unregister the same callback you registered above.
}
}
}
This code omits specific CPM registration parameters, initialization checks, thread constraints, and error handling. A real implementation must fill those in according to the Android API and project requirements.
callbackFlow suits converting callback APIs that fire multiple times into Flow.
It's a cold flow: each time collection starts, the building code runs; when collection is cancelled, the cleanup logic in awaitClose executes.
6. Choose Flow Types Based on Data Semantics
Flow, StateFlow, and SharedFlow can all carry data, but they have different semantics.
1. Continuous Events
If each value represents an independent event, like incoming IM messages, use Flow<Message>, or consider SharedFlow when you need to broadcast events to multiple collectors.
Decide ahead: if there are no consumers, may events be lost? Do you need caching or replay?
2. Current State
If the caller needs to know "what's the current value," such as the current gear or network connection state, StateFlow is often a better fit.
It always holds a current value, and new subscribers can read the latest state.
However, StateFlow conflates equal consecutive values. If every change must be handled as an independent event, you can't drop it in as a replacement for an event stream without analysis.
3. One-Shot Operations
If the caller performs one operation and waits for a result, such as loading a message by ID, use a suspend function rather than wrapping the result in a flow just to unify interfaces.
When choosing types, first decide what behavior you want to express, then pick the API.
Don't treat them as interchangeable types just because they all carry data.
7. Unify the App's Interfaces
An app may have multiple data sources:
- IM messages come from a third-party SDK's Listener;
- Vehicle gear comes from AAOS CPM Callback;
- Network state comes from system callbacks;
- Database queries come from Room Flow.
The data layer can convert these sources into interfaces that suit the app:
interface MessageRepository {
fun observeMessages(): Flow<Message>
}
interface CarRepository {
fun observeGear(): Flow<Int>
}
interface NetworkRepository {
fun observeNetworkState(): Flow<NetworkState>
}
The underlying IM SDK and CPM can still use Callback; Room can continue to provide Flow directly without an extra callbackFlow wrapper just for uniformity.
What the app unifies is the contract the caller uses to consume data, not the underlying implementation technology.
In other words, we don't need to force every low-level API to use Flow.
What we need is to convert data from various sources into a form suitable for internal consumption at reasonable architecture boundaries.
This preserves the flexibility of underlying implementations and reduces business modules' dependence on specific asynchronous mechanisms.
8. The Core Is Changing the First Reaction in Interface Design
In a modern Kotlin Android app, I lean toward this default:
Prefer
suspendfor one-shot asynchronous operations; prefer Flow for continuous data changes; convert underlying Callback as needed at the adapter layer.
This choice is just a starting point.
We still need to decide interface details and resource management based on whether data is state or events, how consumers share data, and when collection starts and ends.
For a simple callback serving only a local scenario, don't force a refactor just for form.
For data that changes continuously and is consumed by multiple upper layers, prefer providing a Flow interface at the data layer.
What I truly oppose is, in a project that has fully adopted Kotlin coroutines, instinctively designing a Callback interface every time an async need appears.
Because interfaces don't just decide how callers call; they decide what kind of asynchronous programming model the team has to maintain.
If the project already has a unified coroutine model, try to keep internal interfaces aligned with it, rather than constantly introducing new abstractions and adding unnecessary complexity.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
The author's technical architecture skills are profound and their literary expression is exceptional—truly both skilled in engineering and articulate in prose.