跪拜 Guibai
← Back to the summary

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:

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:

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:


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:

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:

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.

Comments

Top 2 of 4 from juejin.cn, machine-translated. The original thread is authoritative.

hello先生 1 likes

Architecture left to AI

潜龙勿用之化骨龙

AI needs you to guide it

hello先生  → 潜龙勿用之化骨龙

Let AI write templates to define standards

新手开发

Very practical