跪拜 Guibai
← All articles
Android

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

By 潜龙勿用之化骨龙 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Android projects that rely on code review to catch architectural violations inevitably accumulate invisible coupling: callers can't know if a function is main-safe, exceptions have no clear owner, and Flow lifecycles become a shared mystery. Enforcing these boundaries through contracts rather than vigilance stops complexity from leaking into layers where it becomes expensive to remove.

Summary

Most Android teams rely on governance—lint rules, refactors, and code review—to manage complexity, but this approach only limits damage after the fact. The real failure happens when thread dispatchers leak into ViewModels, repositories force callers to manage IO, and exception handling fragments across every layer. These patterns create systems where no one dares to remove a `withContext(IO)` call because the consequences are unknowable.

A preventive architecture instead enforces contracts at the design level. Repositories guarantee main-safety internally, exceptions are converged into a sealed `Result` type at the data layer, and UseCases lock down business composition so ViewModels only handle state mapping. The goal is complexity convergence: thread management stays in the data layer, business logic stays in the domain layer, and UI state aggregates in a single ViewModel `StateFlow`.

The trade-off is upfront cost—writing UseCases and Result wrappers even for simple features feels like over-engineering. But that cost hedges against the far larger expense of untangling a codebase where thread responsibility, error ownership, and Flow lifecycles have become collective unknowns.

Takeaways
Main-safe is a non-negotiable contract: every suspend function a repository exposes must be callable from the main thread without additional dispatchers.
Unchecked thread switching in ViewModels and Fragments creates a system where no one knows which call sites actually need IO, making every `withContext` call impossible to remove safely.
Exception handling fragments when every layer adds its own try-catch; the data layer should catch and convert exceptions into a sealed Result type so the UI only handles success and failure states.
UseCases exist to extract multi-source data composition from ViewModels, preventing business logic from leaking into the UI layer.
ViewModels should contain zero Dispatchers and only perform state mapping, converting cold data flows into a single lifecycle-aware StateFlow.
Complexity must converge to the correct layer: thread management in the data layer, business composition in the domain layer, and UI state aggregation in the ViewModel.
The upfront cost of writing wrappers for simple features is a deliberate investment that prevents exponentially larger refactoring costs later.
Conclusions

The argument reframes architecture not as a set of coding rules but as a system of enforceable contracts that make incorrect usage structurally difficult—a shift from policing developers to designing APIs that resist misuse.

Describing governance as 'firefighting' captures why lint rules and code review scale poorly: they detect symptoms after the fact but never prevent the initial misplacement of responsibility.

The observation that no one dares delete a `withContext(IO)` call in a mature codebase is a concrete litmus test for whether thread responsibility has already leaked beyond the data layer.

Requiring UseCases even for simple features is controversial in teams that prioritize velocity, but the piece frames this as a calculated trade-off where the real cost is deferred complexity that compounds silently.

Concepts & terms
Main-safe
A contract where a repository's suspend functions guarantee they can be called from the main thread without blocking it, typically by handling thread switching internally rather than exposing that responsibility to callers.
Complexity convergence
The architectural principle that each type of complexity—threading, exception handling, business logic, UI state—should be handled in exactly one designated layer rather than spread across the codebase.
Cold Flow vs Hot Flow (StateFlow)
A cold flow produces data on demand and restarts for each collector; a hot flow like StateFlow holds a current value and shares it with multiple collectors. ViewModels should convert cold data-layer flows into hot StateFlows tied to the viewModelScope lifecycle.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗