跪拜 Guibai
← Back to the summary

Prevention Over Cure: Enforcing Android Architecture Through Contracts, Not Code Review

1. Why "Governance-Oriented Architecture" Is Becoming Increasingly Strained

When many teams talk about architecture, they eventually gravitate toward "governance":

But the reality is often: the larger the project, the higher the cost of governance; the more governance, the more fragile the system. Governance is essentially "firefighting," not "fire prevention." As the ancients said, "In all matters, preparedness ensures success; lack of preparedness leads to failure." Top-tier architectural design never pins its hopes on later governance but strangles risks in the cradle of initial prevention.


2. Why Does Governance Always Fail?

Let's look at a few very typical runaway scenarios in Android projects that you've likely seen before.

1. Dispatcher Runaway

Early in the project:

viewModelScope.launch(Dispatchers.IO) { ... }

Later, as business logic becomes more complex:

withContext(Dispatchers.IO) { ... }

Even later, newcomers are unsure if predecessors switched threads:

launch(Dispatchers.Default) { ... }

Eventually, it evolves into: Everyone is switching threads, but no one knows who is responsible for the thread.

2. Repository Is Not Main-Safe

Because the Repository has no baseline guarantee, code like this starts appearing in the UI layer:

// UI Layer (Fragment/ViewModel)
withContext(Dispatchers.IO) {
    repository.loadUser()
}

Over time, the complexity of the IO thread pool leaks to all callers. Years later, you'll find: The system is riddled with dense withContext(IO) calls, and no one dares to delete any of them, because no one knows if removing one will trigger a NetworkOnMainThreadException.

3. Fragmented Exception Handling

Every layer is doing try-catch: the Repository is afraid of crashing, so it tries; the UseCase is afraid of crashing, so it tries; the ViewModel is afraid of crashing, so it tries again. In the end, it becomes: Exceptions have no ownership. Everyone handles them, but no one handles them completely, making it difficult for the UI to produce accurate error prompts.

4. Chaotic Flow Lifecycles

In a project, launch { }, launchIn(scope), stateIn(scope), and shareIn(scope) all coexist. The ultimate problem becomes: No one can explain "who exactly is managing the lifecycle of this flow," and memory leaks and cold flow re-activations happen silently.


3. The Essence of Governance: It Can Only "Stop Losses," Not "Prevent Errors"

Tools like Code Review, Lint, and Sonar are essentially:

  1. Discovering problems
  2. Limiting the spread of problems
  3. Reducing the probability of errors

But they cannot do one thing: Prevent errors from being written in the first place.

Core Architectural Thinking Excellent architecture should do the opposite: Make errors extremely difficult to occur at the design level. The essence of architecture is not to dictate "how to write code," but to dictate "where complexity must not leak." It's not about controlling the writing style, but about controlling the boundaries.


4. Core Practices of Preventive Architecture

To achieve "prevention," we need to establish ironclad contracts at each layer.

1. Repository: Shield Thread Complexity (Main-Safe)

//  Governance / Wrong Approach: Throwing thread scheduling to the caller, causing IO leakage
class UserRepository {
    // Implicitly requires the caller to remember to switch threads. The API here is a blocking usage; if the outside doesn't use IO, it will crash.
    fun loadUser(): User = api.getUser().toModel() 
}

//  Preventive / Correct Approach: Repository internally contains a self-contained main-safe contract
class UserRepository(
) {
    // The caller calls this method directly on the main thread. The api here is Retrofit's suspend function.
    suspend fun loadUser(): User =  {
        api.getUser().toModel()
    }
}

2. Repository: Unified Exception Model

If every layer does try-catch, exceptions will inevitably become fragmented. A more reasonable approach is to converge exceptions into a domain model/state model at the Data Layer.

//  Governance / Wrong Approach: Layers throw unknown Exceptions bare, triggering a try-catch war
// The UI layer is forced to write:
try { repository.loadUser() } catch(e: HttpException) { ... }

//  Preventive / Correct Approach: Use Kotlin's Result or a custom Sealed Class to carry the result
suspend fun loadUser(): Result<User> =  {
    try {
        Result.success(
            api.getUser().toModel()
        )
    } catch (e: CancellationException) {
        // Coroutine cancellation exceptions must be re-thrown
        throw e
    } catch (e: Exception) {
        // Other exceptions are uniformly converted
        Result.failure(e)
    }
}

At this point, the UI layer or ViewModel only needs a very clean:

repository.loadUser()
    .onSuccess { user -> updateUI(user) }
    .onFailure { error -> showErrorMessage(error) }

3. UseCase: Only Do Composition, Not Implementation

The value of a UseCase (Domain Layer) is not "to add a layer just for the sake of it." Its core value is: Extract business composition from the UI to prevent UI bloat.

//  Governance / Wrong Approach: Temporarily assembling multi-source data in the ViewModel, causing business logic leakage
class UserViewModel : ViewModel() {
    fun loadProfile() {
        viewModelScope.launch {
            val user = userRepo.loadUser()
            val badge = badgeRepo.getBadge(user.id) // Stitching logic in the UI layer
            _uiState.value = combineData(user, badge)
        }
    }
}

//  Preventive / Correct Approach: Use a UseCase to lock down the business boundary
class GetUserProfileUseCase(
    private val userRepo: UserRepository,
    private val badgeRepo: BadgeRepository
) {
    // Stitching logic is converged here. The UI layer (ViewModel) knows nothing about the underlying logic.
    suspend operator fun invoke(): Result<UserProfile> { ... }
}

4. Flow/ViewModel: Clarify Cold/Hot Boundaries and Responsibilities

// ViewModel remains absolutely pure: no Dispatchers, only state mapping
class UserViewModel(
    getUserProfileUseCase: GetUserProfileUseCase
) : ViewModel() {

    // Convert the underlying cold flow into a Hot Flow (StateFlow) safely tied to the lifecycle at the UI top level
    val uiState: StateFlow<UserUiState> = flow { emit(getUserProfileUseCase()) }
        .map { result -> 
            result.fold(
                onSuccess = { UserUiState.Success(it) },
                onFailure = { UserUiState.Error(it) }
            )
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000), // Prevent resource waste from background recreation
            initialValue = UserUiState.Loading
        )
}

5. The True Goal of Architecture: Complexity Convergence

All design principles ultimately point to the same thing: Converge complexity into the correct layer, rather than diffusing it into all layers.

Core Problem Misplaced Spread (Governance Type) Correct Convergence Position (Preventive Type)
Thread Switching Leaks to ViewModel, Fragment, even Adapter Inside the Data Layer (Repository)
Exception Interception Every layer does try-catch, exception information is fragmented Unified interception at the Data Layer, converted to Result
Business Composition Force-stitching data in ViewModel or even View Domain Layer (UseCase)
UI State Scattered across various variables, or arbitrary stateIn at various layers ViewModel aggregates into a single UI State

6. The Cost of Preventive Architecture

There is no free lunch in the world, and preventive architecture also has its costs.

Implementing a strict preventive architecture means that in the early stages of a project or when writing simple business logic, you need to write seemingly redundant wrappers (e.g., writing a UseCase for every simple business operation, writing Result wrappers, injecting Dispatchers). Some people who pursue "speed" might feel this adds to the mental burden.

But this is exactly the boundary between a technical expert and an ordinary programmer: The architect's duty is to use upfront "design/constraint costs" to hedge against later "disaster governance costs." This is a long-term technical investment that is a sure win.


7. Conclusion

Many people understand the value of architecture to be: excellent refactoring capability, superb evolution capability, powerful governance capability.

But more fundamentally: Excellent architecture simply makes technical bugs very hard to appear.

Main-safe, a unified exception model, clear responsibility boundaries, stable data flows... these are not some exquisite "advanced tricks," but the most basic preventive mechanisms of a system.

Prevention far outweighs governance. End.

It is recommended that team technical leads use this as a reference for the underlying principles of internal architecture specifications and Code Review.

Main-safe: The True Watershed of Modern Android Architecture

"Structured" essentially means—turning chaotic things into organized, rule-based, bounded things

From "Caller's Treading on Thin Ice" to "Natural Semantics of the Interface": Insights from Room/DataStore/Retrofit

Why Modern Android Officially Recommends Repository Expose suspend fun Instead of launch Internally

# Android Architecture Guide: Stop Exposing start/stop in the Data Layer: Use Flow to Manage Lifecycles

Stop launch(IO): 3 Hidden Anti-Patterns of Coroutine Thread Switching

Understanding Android Clean Architecture from a Food Delivery Perspective: Why the Boss Doesn't Need to Know What Car the Delivery Guy Drives

A Small Demo to Get You Started with Android Clean Architecture

Modern Android Architecture Doesn't Need an Event Bus

Why I Don't Handle Exceptions Directly in the Android ViewModel?