The Ad Detail Page is one of the most-visited screens in the app — opened by around 17 million people every month. This is how we rebuilt it from a Java, Fragment-hosting screen that mixed MVP and MVVM into a single Compose screen where the UI is a list of self-building components, the ViewModel is almost empty, every action runs off the main thread, and one broken component can never take the page down — all while keeping the crash-free rate above 99.9%.
When a screen is opened 17 million times a month, two things become true at once. It is one of the most valuable surfaces in the product, and one of the most dangerous to touch. A regression here is not a bug in a corner of the app — it is a bad experience served millions of times before anyone can react.
For years our Ad Detail Page View (Ad Detail Page) — the screen a buyer lands on after tapping any listing, and the screen a seller uses to preview their own ad — had grown into exactly the kind of code you are afraid to change. In this article, I will walk through how we rebuilt it, and more importantly, the architectural ideas that made the rebuild safe at this scale. We will go deep on four properties that matter when a screen is this critical:
- Nothing blocks the main thread. Every action is handled as a cold Flow collected inside a coroutine.
- A failing component cannot break the page. If one section throws, or its data is null, that section quietly disappears and everything else renders.
- The ViewModel is almost empty. Logic lives in small, single-responsibility action handlers — you are never lost in a 2,000-line file.
- Testing is trivial. Each handler is a pure function from input to a list of effects, so most tests need no Android, no rules, no mocks-of-mocks.
Let me start with what we were replacing.
The screen we were afraid of
The old entry point was OldAdDetailPage, a Java BaseFragmentActivity. Its real job was to choose one of four fragments and host it, forked by two booleans — “is this my own ad?” and “is this a V2 category?”:
- MyOldDetailPageFragment / MyOldDetailPageFragmentV2 for a seller viewing their ad,
- OtherOldDetailPageFragment / OtherOldDetailPageFragmentV2 for a buyer viewing someone else’s.
None of these were small. OldDetailPageFragmentV2 was roughly 1,900 lines, OldDetailPageFragment about 1,400, with the buyer and seller fragments each over a thousand. A single behavioural change had to be made — and re-tested — in up to four near-identical places, because “V2” was a feature-flagged fork that never fully retired “V1”.
Underneath, the adDetails package (94 files) had collected every architecture the app had ever tried, all at the same time:
- MVP — OldDetailPageContract / OldDetailPagePresenter, MyOldDetailPageContract / MyOldDetailPagePresenter.
- MVVM with a god ViewModel — OldDetailPageViewModel at roughly 800 lines and 73 methods, beside OldDetailPageViewModelV2, MyOldDetailPageViewModel and OldDetailPageBuyerViewModel.
- Manual everything — the Activity carried 14+ mutable fields and reconciled them by hand across onCreate, onSaveInstanceState, onActivityResult and onBackPressed. The UI was around thirty hand-written XML View classes, most duplicated across the V1/V2 split, with layout order hard-coded inside each fragment.
When we finally retired all of this, a single cleanup commit deleted 136 files and 21,166 lines. But the deletion is not the achievement. The achievement is the shape of what replaced it.
Before (Old UI)
After (New UI)
The new shape: one screen, driven by state
The replacement, AdDetailPage, is a Jetpack Compose host with no fragments at all. Its onCreate does three things:
- Dispatch an Init action
- Start collecting one-time events
- Set a single composable tree.
Whether the viewer is a buyer, a seller, or in a special category is no longer four classes — it is one screen reading different state.
The ViewModel is declared as an interface with a nested implementation, so it fakes trivially in tests and previews:
interface AdDetailPageViewModel {
val state: StateFlow<PageState> // persistent UI state
val events: Flow<PageEvent> // one-time effects (navigate, toast…)
fun onAction(action: PageAction) // the single entry point
@HiltViewModel
class AdDetailPageViewModelImpl @Inject constructor(
private val actionHandlers: Set<@JvmSuppressWildcards PageActionHandler>,
// …a few collaborators
) : AdDetailPageViewModel, ViewModel()
}
Three contracts replace the old tangle of LiveData, presenters and mutable fields:
- State is a StateFlow<PageState>, where PageState is a sealed interface of Loading, Error, and Success. The UI is a single when over it.
- Events are one-time effects delivered through a Channel exposed as a Flow, collected once inside repeatOnLifecycle(STARTED).
- Actions are a single onAction(PageAction) funnel for every interaction.
One small detail pays off enormously: PageAction is a @Parcelize sealed interface. Because actions are Parcelable, a login-gated action can be parked in SavedStateHandle, the user sent to log in, and the action replayed when they return. The whole category of fragile onSaveInstanceState field-juggling from the old activity simply disappears.
Property 1 — Nothing blocks the main thread
At 17 million sessions a month, a few milliseconds of main-thread work in the wrong place becomes jank that millions of people feel. So the architecture treats the main thread as sacred: the only thing that happens on it is dispatching an action and emitting a UI state.
Every action handler is a cold Flow. The ViewModel collects it inside viewModelScope, and each handler does its real work on a background dispatcher, emitting lightweight effects back. The dispatch loop is essentially this:
override fun onAction(action: PageAction) {
viewModelScope.launch { // structured concurrency
actionHandlerMap[action::class]?.forEach { handler ->
handler(action, _state.value) // returns a Flow<Effect>
.collect { effect -> handleEffect(effect) }
}
}
}
A handler that does I/O switches dispatchers explicitly, so the network or database call never rides the main thread:
class InitActionHandler @Inject constructor(
private val loadComponents: LoadComponentsUseCase,
private val dispatchers: DispatcherProvider,
) : PageActionHandler {
override val actions = listOf(PageAction.Init::class, PageAction.Reload::class)
override fun invoke(action: PageAction, state: PageState): Flow<PageEffect> = flow {
emit(PageEffect.SetLoadingState)
val result = loadComponents() // suspends; data fetched + mapped off-main
emit(result.toEffect())
}.flowOn(dispatchers.io()) // <- the whole upstream runs on IO
}
Three things make this robust:
- Structured concurrency. Because work launches in viewModelScope, it is automatically cancelled when the screen goes away. No leaked jobs, no callbacks firing into a dead Activity.
- Injected dispatchers, never hard-coded. Handlers depend on a DispatcherProvider, so the same code that runs on IO in production runs on a test scheduler in a unit test — no Dispatchers.setMain gymnastics scattered around.
- Tracking is fire-and-forget. Analytics never sits in the user’s path; it is launched on its own IO coroutine so a slow tracking call can’t delay a tap.
The result is that the UI thread’s entire job is to render the latest PageState. Loading, mapping, ordering, building components — all of it happens off-main, and the screen stays smooth even on low-end devices.
Property 2 — A failing component can never break the page
This is the property we are proudest of, and the one most directly responsible for a crash-free rate above 99.9% on a screen this complex.
The whole page is modelled as data: PageState.Success carries an ordered list of typed UI components. The component type is a sealed interface in the domain layer:
sealed interface ComponentUi {
sealed interface EagerComponentUi : ComponentUi // data ready now
data class LazyComponentUi(
val component: suspend () -> EagerComponentUi? // data fetched later
) : ComponentUi
// examples of eager components
data class GalleryUi(/* … */) : EagerComponentUi
data class OverviewCardUi(/* … */) : EagerComponentUi
data class DescriptionUi(/* … */) : EagerComponentUi
data class MapLocationUi(/* … */) : EagerComponentUi
// …~20 component types in total
}
The page is built component by component. Each component type has its own builder, and the builders are independent. Two layers of isolation make any single failure harmless:
1. A builder that throws is logged and skipped. The repository walks the ordered list of component types and asks each builder to produce its UI model. That loop is fault-tolerant
val components = orderedTypes.mapNotNull { type ->
try {
builders[type]?.build(adData) // build one section
} catch (e: Exception) {
if (e is CancellationException) throw e
logger.warn("Skipping component $type", e) // one section drops out…
null // …the rest of the page is fine
}
}
If the seller-profile builder hits a malformed field, the seller block is omitted — the gallery, price, description and map still render. The user gets a slightly shorter page instead of an error screen. One bad section can never cascade.
2. A builder that returns null is simply not shown. Builders are encouraged to return null when their data is missing — no images, no description text, no location. mapNotNull filters those out. There is no “empty box” state to design around and no null-pointer risk downstream, because a null section never enters the list.
The same discipline carries into the UI. The render side is a single exhaustive when, and because ComponentUi is sealed, the compiler guarantees every type has a branch — you cannot ship a component the screen doesn’t know how to draw:
@Composable
fun ComponentUi(component: ComponentUi.EagerComponentUi, onAction: (PageAction) -> Unit) {
when (component) {
is GalleryUi -> Gallery(component, onAction)
is OverviewCardUi -> OverviewCard(component, onAction)
is DescriptionUi -> Description(component, onAction)
is MapLocationUi -> MapLocation(component, onAction)
// …compiler enforces the rest
}
}
And because the order of the page is itself computed (a dedicated ordering step decides which sections appear and in what sequence, and differs for a seller versus a buyer), the composition of the page is data too — which means it can be varied by configuration or experiment without shipping a new layout.
There is also a performance dividend here. The expensive sections — recommendations, sponsored ads, anything needing a second network call — are modelled as LazyComponentUi: a suspend lambda that is not run during the first build. The core ad paints immediately; the heavy parts resolve afterward, concurrently, and slot themselves in (or drop out if they return null). First paint stays fast for everyone.
Property 3 — The ViewModel is almost empty
The old OldDetailPageViewModel was 800 lines and 73 methods — a place where every feature dumped a little more logic until no one could hold it in their head.
The new ViewModel does almost nothing. It builds a lookup map from action class to handlers once, then dispatches. All the logic lives in 46 small action handlers, each implementing one interface and owning exactly one concern:
interface PageActionHandler {
val actions: List<KClass<out PageAction>> // what this handler claims
operator fun invoke(action: PageAction, state: PageState): Flow<PageEffect>
}
Handlers are contributed as a Set through Hilt multibinding, so adding behaviour means adding a new file, never editing an existing one:
@Module
@InstallIn(ViewModelComponent::class)
abstract class ActionHandlerModule {
@Binds @IntoSet abstract fun init(h: InitActionHandler): PageActionHandler
@Binds @IntoSet abstract fun back(h: BackActionHandler): PageActionHandler
// …44 more, each one file
}
This is the part that makes the screen pleasant to work in again. When you pick up a ticket — “change what happens when the user reveals the phone number” — you open exactly one small handler. You are never scrolling through a 2,000-line class hunting for the seven places that touch the thing you care about. The blast radius of a change is one file, and the diff in code review is one file.
A nice safety net rides along with this: a single meta-test asserts that every action type has at least one registered handler. Forget to wire a new action up, and CI fails with a clear message — instead of a silent no-op shipping to seventeen million people.
Property 4 — Testing is genuinely easy
Because each handler is a pure function from (action, state) to a Flow<Effect>, most tests need no Android framework, no JUnit rules, no InstantTaskExecutorRule, no Robolectric. You feed inputs, collect the flow into a list, and assert on it:
class InitActionHandlerTest {
private val loadComponents = mock<LoadComponentsUseCase>()
private val handler = InitActionHandler(loadComponents, TestDispatcherProvider())
@Test
fun `emits loading then success`() = runTest {
whenever(loadComponents()).thenReturn(LoadResult.Success(state))
val effects = handler(PageAction.Init, PageState.Loading).toList()
assertEquals(
listOf(PageEffect.SetLoadingState, PageEffect.SetSuccessState(state)),
effects,
)
}
}
That is the whole test. No setup ceremony. And the same shape covers the unhappy paths — a null id, a thrown exception, an expired ad — because they are just different inputs producing different effect lists. Compare that to unit-testing a 1,900-line fragment: in the old world, most of that logic was effectively untestable, which is part of why it was so scary to change. Here, the small surface of each handler is exactly what makes it cheap to cover, and the high coverage is part of why the crash-free rate stays above 99.9% even as features keep landing.
The UI is testable in isolation too. Each component composable takes only its own data model and an onAction callback, so a Compose test can render one section, perform a click, and assert the dispatched action — without standing up the whole screen.
A quick tour of the data layer
Ad Detail Page’s own contract is a single repository method that returns the domain model, the broader ad entity, and the built component list. Its implementation orchestrates the network call (the detail endpoint returns the full ad plus metadata — users, category panels, a location tree), maps the DTO into a clean domain AdDetails model, computes the component order, and runs the builder map to produce the component list.
Crucially, ad details are not cached in a database. On a marketplace, price, status and availability change constantly and ads sell quickly, so every open is a fresh fetch — stale detail data would be worse than a slightly slower load. The local database (Room) is reserved for two genuinely local concerns: a small recently-viewed table that stores the latest few dozen ads and trims itself, and a favourites table that holds only minimal wishlist metadata synced from the network. Between screens, an in-memory holder simply hands the current ad to the chat or offer flow to avoid an immediate re-fetch; it is not persisted. The result is a data layer that is honest by default and caches only what is safe to cache.
Old versus new, at a glance
| Concern | Old (OldAdDetailPage) | New (AdDetailPage) |
|---|---|---|
| Language | Java | Kotlin |
| UI | 4 fragments + ~30 XML views | Compose, ~20 component packages |
| Pattern | MVP and MVVM together | MVI (state / action / event) |
| Screen variants | forked by my-ad × V1/V2 | one screen; variant is state |
| Page composition | hard-coded in each fragment | computed order + pluggable builders |
| Business logic | ~800-line god ViewModel | 46 single-responsibility handlers |
| Threading | ad hoc | every action is a Flow, off-main by default |
| Failure isolation | a bad view could crash the screen | a bad section is logged and skipped |
| Testability | mostly untestable | pure handlers, no framework needed |
| Process death | manual onSaveInstanceState | @Parcelize actions + SavedStateHandle |
| Footprint | 94 files | ~130 small, testable files |
The total line count is similar — but the old sixteen thousand lines were a handful of 1,000-to-1,900-line files you could not test, and the new sixteen thousand are ~130 small files where the largest unit of logic is a short, Flow-returning handler with its own test.
Shipping it without a big bang
A screen used 17 million times a month cannot be swapped overnight. We treated the migration as a strangler fig: the new screen and its first components landed behind a feature flag while the old screen stayed live; logic and tests followed, still gated; both ran in parallel as traffic shifted by flag and the new path was validated against the old. Only after the flag was fully on and stable — about eight months later — did the deprecated flags and all 136 old files come out in a single deletion commit. Eight months of coexistence, then one satisfying git rm.
What We Achieve
Rebuilding the Ad Detail Page was never about deleting old code for its own sake. The real test was whether a screen opened seventeen million times a month got measurably safer, smoother and easier to work on — and it did:
- 17 million monthly visitors, served reliably on a screen that used to be one of the most fragile parts of the app.
- Crash-free rate above 99.9%, even on a screen this complex, because a failing component is isolated and skipped instead of taking the whole page down.
- Zero jerk, a smooth 60 fps on decent devices — every action is dispatched off the main thread, so scrolling and rendering stay fluid even under load.
- ViewModel shrunk from an 800-line god class to 46 single-responsibility handlers, each owning exactly one concern and living in its own file.
- Testing became trivial — pure handler functions mean most tests need no Android framework, no mocks-of-mocks, and no rules.
- 136 files and 21,166 lines retired, replaced by ~130 small, testable files delivering the same functionality with far less risk.
- Zero big-bang risk — the new screen ran behind a feature flag alongside the old one for eight months before the old code was deleted.
Some ideas you can take to your own screens
If you maintain a high-traffic screen, you do not need to copy this verbatim — but a few of these ideas travel well:
- Model the screen as a list of typed components, not a layout. The moment the page is data, you get server-driven ordering, A/B-testable composition, and per-section failure isolation almost for free.
- Make failure isolation a property of the architecture, not a discipline. Building each section in its own try/catch and letting null mean “don’t show me” turns “one bad field crashed the page” into “the page is one card shorter.” That single decision is worth a lot of crash-free percentage points.
- Keep the ViewModel a dispatcher, push logic into one-concern handlers. A Set-multibound handler per action means new behaviour is a new file, code-review diffs stay small, and nobody fights a 2,000-line class.
- Add a coverage meta-test for your dispatch map. “Every action has a handler” as a unit test catches the most embarrassing class of bug — the silent no-op — before release.
- Treat the main thread as sacred. Cold flows + injected dispatchers + structured concurrency give you off-main work that cancels itself and tests itself.
- Defer the expensive parts. An eager/lazy split lets the core content paint instantly while recommendations and ads stream in.
- Cache only what’s safe to cache. For volatile data, network-first beats a clever cache that serves a sold listing at the wrong price.
- Migrate behind a flag, delete with confidence. Run old and new in parallel, shift traffic gradually, and only remove the old code once the new one has earned it in production.
The headline metric — 17 million monthly visitors, crash-free above 99.9% — is not the result of one trick. It is the result of an architecture where adding the next feature does not require understanding all the previous ones, where a single broken section degrades gracefully instead of failing loudly, and where every piece is small enough to test. If your most important screen has become the one you are afraid to touch, that is the property worth chasing.
