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":
- Technical debt governance
- Module splitting and refactoring
- Large-scale refactors
- Patching rules via Code Review
- Architecture upgrade plans
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:
- Discovering problems
- Limiting the spread of problems
- 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)
- Iron Law: Main-safe is a contract, not a suggestion. All
suspendmethods exposed by a Repository must guarantee they can be called directly on the main thread.
// 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
- Cold Flow: Data source (Data/Domain), only responsible for producing data on demand.
- Hot Flow (StateFlow): ViewModel is responsible for convergence. The ViewModel is only responsible for state transformation, not scheduling.
// 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
Why Modern Android Officially Recommends Repository Expose suspend fun Instead of launch Internally
Stop launch(IO): 3 Hidden Anti-Patterns of Coroutine Thread Switching
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?