Android Architecture's Seven Iron Rules: The Architect's Real Deliverable Is Order
A while ago, I was discussing Android architecture with a young developer.
He talked about MVVM.
He talked about MVI.
He talked about Clean Architecture.
And about various design patterns.
He said a lot.
I listened for a long time.
Then asked one question.
"Why shouldn't a Repository casually start coroutines inside itself?"
He fell silent.
Many people study architecture.
They learn tools.
They learn frameworks.
They learn layers.
They learn various diagrams.
But few actually think:
What problems are these rules meant to solve?
A project crashes.
Not because a diagram is missing.
But because responsibility is unclear.
Tasks spiral out of control.
Dependencies get tangled.
State loses its single source of truth.
What an architect actually does.
Is never draw diagrams.
But before chaos appears.
Set rules.
So every action has a boundary.
And every responsibility has an owner.
First Iron Rule: Repository must be Main Safe
A mature system.
Should not push its own problems onto others.
If calling a Repository requires thinking about
withContext(Dispatchers.IO)
Where to put it.
Then the problem already exists.
The caller should only care about business logic.
Not how the Repository internally executes.
For example:
suspend fun getUser(): User
No matter if internally it uses:
- Room
- Retrofit
- File I/O
- JNI
- other blocking interfaces
A Repository should guarantee this interface is safe to call from the main thread.
For example:
class UserRepository(
private val api: UserApi,
private val ioDispatcher: CoroutineDispatcher
) {
suspend fun getUser(): User =
withContext(ioDispatcher) {
api.getUser()
}
}
Of course, not every operation should mechanically switch to IO.
CPU-intensive tasks should consider Default.
Suspend APIs that are already main-safe do not need repeated thread switching.
What matters is:
A Repository is responsible for its own execution strategy, not for pushing the burden of thread scheduling onto the caller.
Second Iron Rule: Repository must not start tasks that detach from the caller
Many production issues
Come from one line of code.
scope.launch {
// execute business task
}
The task starts.
But nobody knows:
- Who ends it?
- Who handles exceptions?
- Who waits for results?
- Who handles duplicate tasks?
Seems convenient.
Actually already out of control.
For example:
class UserRepository {
fun refresh() {
CoroutineScope(Dispatchers.IO).launch {
// refresh user data
}
}
}
The caller doesn't even know when the task completes.
Nor when it ends.
So the rule is simple:
Whoever initiates the task owns the task.
The Repository provides capability.
The caller decides the task lifecycle.
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
fun refresh() {
viewModelScope.launch {
repository.refresh()
}
}
}
Here, the task belongs to the ViewModel's lifecycle.
When the ViewModel is destroyed,
The task is canceled as well.
Of course, app-level synchronization tasks can be managed with app-level scopes.
But the owner must be explicit.
Don't let a task become an unmanaged orphan.
Third Iron Rule: State must have an explicit Single Source of Truth
Chaos often starts
From multiple truths.
Activity modifies state.
Fragment modifies state.
Service modifies state.
Repository also maintains a copy of state.
In the end,
Everyone claims to be right.
But the system doesn't know who to trust.
This is why we need:
Single Source Of Truth (SSOT).
For example, user info:
UI
|
v
ViewModel
|
v
Repository
|
v
Room
Room is the single source of truth for local user data.
After a network request succeeds,
Repository updates Room.
UI observes Room through Flow.
When data changes,
UI updates automatically.
Note here:
SSOT does not mean the whole app can have only one state object.
It means for the same business data, there must be a clear, trustworthy source of truth.
One fact should not have multiple competing masters.
Fourth Iron Rule: One-shot operations use suspend, continuous data uses Flow
A single action
And a continuous stream
Are inherently different things.
For example login:
suspend fun login(
account: String,
password: String
): User
It's a one-shot operation.
Call once.
Wait for a result.
Observing user info:
fun observeUser(): Flow<User>
It's a continuous stream.
Data may change multiple times.
Subscribers keep receiving updates.
The semantics of the two are completely different.
If all async operations use Callback,
Call relations easily become complex.
If all operations are wrapped as Flow,
One-shot operations lose their simple semantics.
So:
- One-shot operations use suspend.
- Continuous data changes use Flow.
- Current UI state usually uses StateFlow.
- One-time UI effects choose an appropriate event delivery mechanism based on the scenario.
Don't sacrifice semantics for API uniformity.
Good interface design is not about unified form, but accurate expression.
Fifth Iron Rule: Never use a global event bus to hide business dependencies
The most dangerous dependencies
Are often invisible ones.
The sender doesn't know who receives.
The receiver doesn't know who sends.
There's no explicit relationship between the two.
Convenient at first.
Later more and more chaotic.
For example:
FlowBus.post(UserLoginEvent())
Another place:
FlowBus.subscribe<UserLoginEvent> {
// update user info
}
The code looks simple.
But problems arise:
- Who sent this event?
- Who subscribed to this event?
- When does the subscriber start working?
- When does the subscriber end?
- Is there an order requirement among multiple subscribers?
When something goes wrong,
Everyone asks:
Who sent this event?
Event mechanisms themselves aren't wrong.
Broadcast, system notifications, and cross-module events all have legitimate uses.
The real problem is:
Using a global event bus to hide business dependencies that should have been explicit.
If a dependency can be expressed through a clear interface,
Don't casually refactor it into a global event.
Invisible relationships
Are hardest to maintain.
Sixth Iron Rule: Exceptions must have explicit handling responsibility
Many systems don't die from exceptions.
They die from exceptions disappearing.
This code is common:
try {
repository.getUser()
} catch (e: Exception) {
}
Nothing happens.
But everything has happened.
The caller doesn't know about failure.
No log record.
The system keeps running.
Then slowly rots.
There's a subtler issue.
In Kotlin coroutines:
try {
repository.getUser()
} catch (e: Exception) {
// handle exception
}
This might catch CancellationException along with others.
That breaks coroutine cancellation semantics.
Correct handling:
try {
repository.getUser()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// handle business exception
}
Different layers should take different responsibilities:
- Data layer: handle low-level exceptions, convert to business errors when necessary.
- Domain layer: define failure semantics of business operations.
- UI layer: decide how to present errors to the user.
Exceptions can be converted.
Can be handled.
But they must not be arbitrarily hidden.
Errors are not scary; losing visibility of errors is scary.
Seventh Iron Rule: For async waiting, prefer suspending over blocking threads
Many people already use coroutines,
But their thinking is still stuck in the thread era.
They still like:
Future.get();
CountDownLatch.await();
BlockingQueue.take();
These APIs can put threads into a blocking wait.
But coroutines offer another option.
suspend fun getUser(): User {
return suspendCancellableCoroutine { continuation ->
api.getUser { user ->
continuation.resume(user)
}
}
}
While waiting for the result,
The coroutine can suspend.
The executing thread doesn't have to be occupied.
When the result arrives,
The coroutine resumes.
Here we must distinguish two concepts:
| Method | What happens while waiting |
|---|---|
| Thread blocking | The thread is occupied |
| Coroutine suspension | The coroutine pauses, the thread can do other tasks |
Of course.
Coroutines don't automatically turn every blocking API into a non-blocking API.
For example:
suspend fun getUser(): User =
withContext(Dispatchers.IO) {
blockingApi.getUser()
}
Here blocking still exists.
It's just isolated to an IO Dispatcher.
The real value of coroutines,
Is not simply switching threads.
But:
When waiting for a result, you don't need to occupy an executing thread needlessly.
Waiting for a result is a business requirement.
Blocking a thread is just one implementation.
Epilogue: What does an architect actually design?
Later that young developer came back.
I asked him the same question again.
"What is architecture, exactly?"
This time he didn't mention MVVM.
Didn't mention MVI.
Didn't mention any framework.
He said:
"Architecture is, when a project gets more and more complex,
still letting everyone know
where data comes from,
who manages state,
who is responsible for tasks,
who handles exceptions,
how long objects live,
how modules should depend."
I nodded.
Because at that moment,
He finally understood.
Clean Architecture helps us establish code boundaries.
Architecture rules in engineering practice help us establish behavioral boundaries.
The former resolves dependency relationships.
The latter resolves responsibility ownership.
Together they determine whether a system can remain stable in the long run.
Architecture is not a technical term.
Not a framework.
Nor a pretty layered diagram.
Architecture is a set of rules.
Rules create boundaries.
Boundaries define responsibility.
Responsibility creates order.
And order is the reason large systems can survive over the long term.
What architects design is not code.
But the order behind the code.
Top 2 of 4 from juejin.cn, machine-translated. The original thread is authoritative.
Architecture left to AI
AI needs you to guide it
Let AI write templates to define standards
Very practical