跪拜 Guibai
← All articles
Android

Main-safe Isn't a Threading Rule — It's the Contract That Makes Android Layers Real

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

A codebase without main-safe forces every ViewModel and UseCase to understand the threading model of every dependency. That knowledge spreads fast, makes refactors dangerous, and turns the UI layer into a fragile dispatch center. Main-safe draws a hard line that keeps the UI layer testable, swappable, and ignorant of infrastructure — the same property that makes Clean Architecture and hexagonal ports work.

Summary

Main-safe is widely misunderstood as just "Repository switches threads so the UI doesn't have to." The real shift is that complexity stops leaking upward. In a main-safe architecture, the UI layer expresses intent and displays state; it never sees Dispatchers.IO, never guesses whether a call is heavy, and never becomes a dispatch center for threading decisions. The Data layer absorbs all of that — IO, concurrency, scheduling, data-source switching — and presents a clean suspend-fun surface.

The contract rests on four "no leaks": no leakage of the threading model, no leakage of blocking risk, no leakage of concurrency semantics, and no leakage of call cost. When any of those leaks, the boundary between layers dissolves and the UI starts making execution decisions it has no business making.

Achieving main-safe fixes thread spillover but doesn't fix business-logic spillover. The next trap is a Repository that starts making business decisions — caching conditionally, transforming data based on rules — which turns the Data layer into a god object. The fix is pulling those decisions into UseCases, keeping the Repository a pure data-fetching surface. That progression — thread safety, then responsibility safety, then structural safety — is what separates a genuine layered architecture from one that just looks layered on a diagram.

Takeaways
Main-safe means any caller can invoke a function from the main thread without knowing whether IO, network, or heavy computation happens inside.
In a non-main-safe codebase, Dispatchers.IO and withContext appear across ViewModels, UseCases, and Repositories — the same threading concern gets solved repeatedly in different layers.
A main-safe Data layer absorbs all threading decisions; the UI layer only launches coroutines in viewModelScope and calls suspend functions.
The contract rests on four prohibitions: don't leak the threading model, don't leak blocking risk, don't leak concurrency semantics, and don't leak call cost to the caller.
Retrofit and Room already provide main-safe suspend functions, so manually wrapping them in withContext(Dispatchers.IO) is redundant and can hide the real contract.
Main-safe stops thread spillover but doesn't stop business-logic spillover — a Repository that makes caching or transformation decisions becomes a god object.
The correct split: Repository fetches raw data; UseCase makes business decisions and orchestrates multiple data sources.
Three maturity levels define the architecture: Level 1 is thread safety, Level 2 is responsibility safety, and Level 3 is structural safety where no layer crosses its boundary.
Conclusions

Most Android teams treat main-safe as a coroutine best practice, but it's actually a test of whether the architecture has a real boundary. If a ViewModel ever imports Dispatchers, the boundary has already failed.

The four 'no leaks' reframe main-safe from a positive rule ('use withContext in the Repository') to a negative constraint ('the UI must never see these concepts'). Negative constraints are easier to enforce in code review because violations are unambiguous.

Business-logic spillover is the natural next failure mode after thread spillover is fixed. A Repository that conditionally caches or transforms data is doing the UseCase's job, and the symptom is the same as thread leakage: the caller starts depending on implementation details.

The three-level maturity model exposes a common pattern: teams stop at Level 1 and declare victory, then wonder why their codebase still feels coupled. Thread safety is necessary but nowhere near sufficient.

Calling main-safe a 'watershed' is accurate because it's a one-way door. Once a codebase commits to main-safe, the UI layer's relationship to infrastructure changes permanently — you can't accidentally reintroduce Dispatchers.IO into a ViewModel without it sticking out as a violation.

Concepts & terms
Main-safe
An architectural contract where any suspend function exposed by the Data layer can be called from the main thread without the caller needing to know about IO, threading, or blocking. The Data layer owns all thread-switching internally.
Four 'no leaks'
The four dimensions of the main-safe contract: no leakage of the threading model (Dispatchers stay hidden), no leakage of blocking risk (callers never worry about ANRs), no leakage of concurrency semantics (mutexes and locks are internal), and no leakage of call cost (every suspend fun is safe to call directly).
Business-logic spillover
A failure mode where a Repository starts making business decisions — conditional caching, data transformation based on rules, orchestration logic — instead of acting as a pure data-fetching surface. It turns the Data layer into a god object and prevents reuse of business rules.
Three-level maturity model
A progression for layered Android architecture: Level 1 (thread safety — UI doesn't manage IO), Level 2 (responsibility safety — Data layer doesn't make business decisions), and Level 3 (structural safety — every layer does exactly one thing and never crosses its boundary).
Source: juejin.cn ↗ Google Translate ↗ Backup ↗