Complete edition · 2026.1

The Staff Android Interview: depth, trade-offs, and scale

A book-length preparation guide for engineers interviewing at Staff, Senior Staff, Principal, and Mobile Architect level — organised around the thing that actually separates those loops from a Senior loop: not harder recall, but defensible judgment under scale, failure, and organisational constraint.

35Parts
200+Graded questions
117Code comparisons
60Build tasks
120+Recall terms
18System designs
12Mock loops

How to use this book

This volume is written for an engineer with 8–12+ years of experience who already ships production Android. It assumes you know what a ViewModel is. It does not assume you can defend, in front of a skeptical hiring committee, why a 200-screen app should or should not have one per screen.

Three habits make the material work:

  • Answer at a level, deliberately. Major questions are graded L1–L5. Write your own answer before reading the model one; the gap is your study plan.
  • Chase the second and third follow-up. Staff loops rarely fail on the opening question. They fail three questions deep, when the candidate runs out of mechanism and starts repeating vocabulary.
  • Run the thirteen lenses. Correctness, reliability, scalability, performance, maintainability, testability, security, observability, operability, cost, developer experience, business impact, organisational impact.

The answer quality rubric

Every graded question uses the same ordinal, cumulative scale. An L4 answer contains everything in L3 and adds a dimension.

LevelCharacterWhat moves an answer up to this level
L1 WeakDefinitional recallRepeats documentation. Correct vocabulary, no mechanism. Stops at "it's lifecycle-aware."
L2 AcceptableCorrect but boundedAccurate happy-path mechanism plus a concrete usage. Still framed as "the right way", no alternatives.
L3 SeniorPractically groundedProduction experience: what went wrong, how it was debugged, what the fix cost. Names an alternative and why it lost.
L4 StaffSystemicBehaviour under concurrency, failure and 10× load; explicit decision criteria; testability and observability; cost to 100 engineers; the condition that inverts the choice.
L5 PrincipalStrategicReversibility and option value, organisational and hiring consequences, migration and deprecation path, measurable success criteria, cost in money and headcount, how it is governed over years.

Worked calibration — "Why would you expose Flow from a repository?"

L1
"It's reactive and lifecycle-aware, and works well with coroutines."
L2
Adds: Flow is cold and re-executes per collector; a suspend function is the better fit for a one-shot read.
L3
Adds the real case: a Room-backed observable query so a sync worker's writes reach the UI without manual invalidation; stateIn with WhileSubscribed(5_000) to survive rotation without leaking a collector.
L4
Adds: who owns the subscription and therefore cancellation; conflation and backpressure for a high-rate location stream; whether Flow<Result<T>> leaks transport concerns into the domain; what five collectors of one upstream do and how shareIn changes the failure model; Turbine testing cost; and the written rule so 100 engineers don't each decide differently.
L5
Adds: this is really a question about where the app's reactive boundary sits; making it reactive everywhere is a one-way door; the guidance shipped as a lint rule plus an ADR; the metric that proves it worked (screen-level state bugs, not stream counts); and the exit path if the team later adopts a sync-engine model.

Complete contents

Part IThe Staff Engineer MindsetWhat the level is, why strong Senior answers get down-levelled, the thirteen judgment lenses, influence without authority. Part IIKotlin MasteryType system, variance, inline and value classes, sealed hierarchies and binary compatibility, delegation, sequences, Java interop, DSL discipline. Part IIICoroutines, Flow & ConcurrencyStructured concurrency, dispatchers, cancellation, supervision, mutexes and actors, cold vs hot streams, backpressure, sharing, deduplication and single-flight. Part IVThe Android PlatformBinder and the process model, Looper and Choreographer, tasks and launch modes, process death, background restrictions, permissions, notifications and links. Part VJetpack in DepthViewModel and state ownership, lifecycle collection, Room, DataStore, WorkManager, Paging, Navigation, dependency injection. Part VIJetpack ComposeSlot table and snapshots, effects, stability and strong skipping, hoisting, the three phases, lazy layouts, recomposition debugging, semantics. Part VIIAndroid ArchitectureBoundaries, Clean Architecture honestly assessed, MVVM vs MVI, state machines, the repository under pressure, a 200-screen design, multi-surface. Part VIIIModularization at ScaleModule taxonomy, breaking cycles, build-graph performance, convention plugins, a 100-engineer worked exercise. Part IXNetworkingTransport realities, REST vs GraphQL vs gRPC, OkHttp internals, single-flight token refresh, retries and idempotency, caching and connectivity. Part XStorage & CachingSQLite mechanics, schema and migrations, cache architecture and invalidation, media and large binaries. Part XIPerformance EngineeringA methodology, startup, rendering and jank, ANRs, battery, app size, and running a performance programme. Part XIIMemory ManagementART heap behaviour, leak topology, heap dump diagnosis, five leaks to find. Part XIIITesting StrategyWhat not to test, fakes over mocks, coroutine and Flow testing, UI and screenshot tests, contract tests, org-scale strategy. Part XIVBuild Systems, CI/CD, ReleaseGradle mechanics, variants, cutting build time org-wide, pipelines and static analysis, release trains and rollback. Part XVSecurityThreat modelling a hostile client, Keystore, OAuth2 and tokens, transport hardening, integrity and realistic expectations. Part XVIMobile System DesignA repeatable framework, how it is scored, API contracts, and eighteen complete designs from chat to payments. Part XVIISync & Distributed ConceptsLocal-first, delta sync, conflict resolution, idempotency, real-time transport, multi-device convergence. Part XVIIILegacy Code & MigrationThe case against rewrites, eight canonical Android migrations, debt as a portfolio, evaluating KMP. Part XIXProduction IncidentsMobile incident response, twelve investigated incidents, and observability that makes them survivable. Part XXThe Coding InterviewHow Staff coding rounds are scored, plus concurrency, systems and reactive problems with full Kotlin solutions. Part XXICode Review InterviewsWhat a Staff review looks for, fifteen defective snippets with expert reviews, review as leadership. Part XXIIArchitecture ReviewTen broken architectures, a critique method, and migration plans that keep production running. Part XXIIIBehavioral & LeadershipHow Staff behavioural rounds are scored, graded answers to the questions that decide offers, story portfolio construction. Part XXIVOrganization-Scale ArchitectureGovernance without bottlenecks, funding change with no product value, measuring architecture, drift. Part XXVTraps & Trick QuestionsQuestions where the confident conventional answer is wrong, and a reusable shape for answering with context. Part XXVIComparison ReferenceHead-to-head comparisons on fixed dimensions, and how to speak a table in ninety seconds. Part XXVIIRapid FireQuestions banded by the answer length they deserve — ten seconds to three minutes. Part XXVIIIMock Interview LoopsComplete loops with interviewer scripts, follow-ups, model answers and filled-in scorecards. Part XXIXFollow-Up TreesThe three-level rule, a fully expanded offline-first tree, and fifty design follow-ups to expect. Part XXXCheat Sheets & ChecklistsOne-page compressions, 100 things to know, the lists you must answer cold, and the last 24 hours. Part XXXIAndroid Best Practices & Anti-PatternsA side-by-side catalogue of Android-specific good and bad practice: lifecycle, background work, permissions, storage, networking, Compose, security, resources. Part XXXIIEnd-to-End: a Paginated, Offline-First FeatureOne vertical slice built completely — Hilt, Room, Paging 3, coroutines, StateFlow and SharedFlow, WorkManager, Compose — plus the interview tasks built from it. Part XXXIIIGreenfield Build TasksSixty build-from-nothing exercises graded Starter to Staff+, each with the signal it produces and the trap that catches strong candidates. Part XXXIVRapid RecallEvery term you must define in one breath — coroutines, structured concurrency, supervision, cancellation, Flow, Compose, platform — with the shortest example that makes it true. Part XXXVBest Practices by AreaFifty-odd pairs organised by what you are working on: Kotlin, coroutines, Flow, Compose, Fragments, ViewModels, DI, Room, networking, background, testing, build.

Front matter

Staff Engineer competency matrix

Loops are calibrated against a ladder, not a topic list. Read the columns horizontally: the same question, asked of three candidates, produces three materially different conversations. Most rejected Staff candidates give excellent Senior answers.

DimensionSenior L3Staff L4Principal L5
Scope of impactA feature or surface, delivered independently and correctly.A system or domain spanning teams. Owns the seams.The platform and multi-year direction, including what the org will not build.
Problem framingSolves the problem as stated.Interrogates it, surfaces the unstated constraint, sometimes redefines it.Chooses which problems deserve the org's attention this year.
Technical depthKnows the API and its correct usage.Knows the mechanism, failure modes and cost model underneath.Knows where the abstraction breaks at 10× and has the migration sketched.
Trade-offsNames pros and cons.Names decision criteria, weights them for this context, states what would change the answer.Reasons about reversibility and option value: which doors are one-way.
Failure thinkingHandles the errors the API surfaces.Designs for partial failure, retries, idempotency, degraded modes by default.Designs blast radius: what fails, who notices, how fast, how the org recovers.
Scale reasoningOptimises the hot path in front of them.Reasons about 10× users, data, engineers and screens.Reasons about cost, headcount and coordination, not just throughput.
ArchitectureApplies the team's architecture consistently.Defines it, its boundaries, and the mechanism that keeps it true.Establishes governance that survives their departure without becoming a bottleneck.
Code & reviewClean, tested code; reviews for correctness.Reviews for boundary integrity, failure behaviour, and precedent.Shapes what the org considers reviewable: standards, linters, defaults.
Testing postureWrites good unit and UI tests.Designs a strategy with an explicit cost/confidence budget and flake policy.Ties test investment to release velocity and incident rate as measured outcomes.
ObservabilityAdds logging, reads crash reports.Defines mobile SLIs and instruments before shipping.Runs the app-health programme: budgets, alerting, telemetry cost, accountability.
InfluencePersuades their team through good work.Builds cross-team consensus with written proposals, prototypes and data.Changes org behaviour via strategy, councils and hiring.
AmbiguityAsks for clarification, then executes.Operates without an owner, produces the missing definition, gets it ratified.Comfortable where there is no correct answer, only a defensible one — and commits.
MentorshipMentors one or two engineers.Raises a team's level through review culture, docs and delegated ownership.Builds mechanisms that grow Staff engineers without their involvement.
Business framingUnderstands the feature's goal.Connects decisions to retention, conversion, cost and time-to-market.Negotiates roadmap and quality with executives in money and risk.
Incident behaviourFixes their team's bug quickly.Runs the response and drives a blameless postmortem to closure.Removes the class of incident and changes the systems that allowed it.
Calibration signal

The most reliable Staff tell is unprompted constraint discovery: naming a constraint the interviewer did not mention, explaining why it dominates, and designing to it. The second is stating what would change your mind. Neither needs a whiteboard.

Front matter

Study roadmap

Three tracks by available runway. All assume 8–12 hours a week and existing working knowledge of Android — this is preparation for a level, not a bootcamp.

Track A — twelve weeks, full depth

WhenFocusCoverage
Weeks 1–2Foundations and framingPart I and the rubric. Self-assess against the matrix. Draft six STAR stories before touching technical material — most candidates over-prepare recall and under-prepare framing.
Weeks 3–4Language and concurrencyParts II and III, plus the concurrency problems in Part XX. The densest block; concurrency is where Staff candidates are most often caught because the failures only appear under load.
Weeks 5–6Platform, Jetpack, ComposeParts IV, V, VI. Internals rather than usage: aim to explain the mechanism behind every recommendation you make.
Weeks 7–8Architecture and scaleParts VII, VIII, XVIII, XXII, XXIV. Write two ADRs from the Part XXII prompts and have a peer critique them.
Weeks 9–10System design intensiveParts XVI, XVII, IX, X. Work eight designs on paper, timed at 45 minutes, before reading any model answer.
Week 11OperationsParts XI, XII, XIII, XIV, XV, XIX. The half that distinguishes engineers who ship from engineers who operate.
Week 12SimulationParts XXVIII, XXVII, XXIII, XXV, XXX. Four full loops under time pressure with a peer as interviewer, then reference only.

Track B — four weeks, targeted

Week 1: Parts I, III, VII. Week 2: Parts XVI and XVII, six designs. Week 3: Parts VI, XI, XIX, XXIV. Week 4: Parts XXVIII, XXIII, XXV, XXVII. Skip Parts II, V and XX unless self-assessment puts you below L3 there.

Track C — one week, pre-onsite

Day 1: rubric and Part I. Day 2: the Part XVI framework and three designs. Day 3: Parts III and VI, mechanism only. Day 4: Parts XXIV and XXII. Day 5: Part XXIII, eight stories rehearsed aloud. Day 6: two mock loops. Day 7: Part XXX and the 24-hour checklist. Nothing new after day 6.

Dependencies

The parts are not strictly sequential. This is what genuinely depends on what, so you can reorder around your weak spots without reading into a prerequisite you have not covered.

flowchart LR
  I["I · Mindset"] --> VII["VII · Architecture"]
  I --> XXIII["XXIII · Behavioral"]
  I --> XXIV["XXIV · Org scale"]
  II["II · Kotlin"] --> III["III · Coroutines"]
  III --> VI["VI · Compose"]
  III --> IX["IX · Networking"]
  IV["IV · Platform"] --> V["V · Jetpack"]
  V --> VI
  V --> VII
  VII --> VIII["VIII · Modules"]
  VII --> XVI["XVI · System design"]
  IX --> XVI
  X["X · Storage"] --> XVII["XVII · Sync"]
  XVI --> XVII
  VI --> XI["XI · Performance"]
  XI --> XII["XII · Memory"]
  XI --> XIX["XIX · Incidents"]
  VIII --> XIV["XIV · Build/CI"]
  XIII["XIII · Testing"] --> XIV
  XVI --> XXVIII["XXVIII · Mocks"]
  XXIII --> XXVIII
  XXIV --> XXVIII
  
Non-negotiable

Reading model answers is the least valuable activity in this book. Produce an answer first, out loud, timed, then read. The gap between what you recognise and what you can generate under pressure is the entire difficulty of a Staff loop.

Part I

The Staff Engineer Mindset

Before any technical content: what the level actually is, and why strong Senior answers get down-levelled. Everything after this part is read through it.

Chapter 1What a Staff Android Engineer actually does

Job descriptions describe Staff work as "technical leadership across teams," which is true and useless. The four archetypes below (following Will Larson's taxonomy, adapted for mobile organisations) are more actionable, because each one produces a different interview loop and a different set of stories you need ready.

ArchetypeWhat the job isWhat the loop emphasises
Tech LeadOwns delivery of a large mobile programme with 2–4 teams. Roadmap, sequencing, unblockers.Project decomposition, estimation under uncertainty, stakeholder management, conflict between teams.
ArchitectOwns the shape of the app: module graph, state model, platform APIs, the paved road.Architecture review, modularization, migration strategy, governance without bottlenecks.
SolverDropped onto the highest-risk problem: a startup regression, a sync engine that loses data, an ANR crisis.Debugging depth, performance investigation, incident narrative, comfort with incomplete information.
Right HandExtends a director's reach: strategy, org-wide standards, cross-org negotiation.Written communication, prioritisation across competing orgs, executive framing, saying no.

Two consequences for interview preparation. First, find out which archetype the role is before the loop — ask the recruiter what the last two quarters of this role looked like. An Architect loop will punish you for having only delivery stories; a Tech Lead loop will punish you for having only refactoring stories. Second, your story portfolio needs coverage of at least three archetypes, because most Staff roles drift between them.

Where the time actually goes

A realistic week for a Staff Android engineer at a mid-size company: 25–35% hands-on code (usually the risky or exemplary parts, not feature volume), 20% design review and code review, 20% written artefacts — proposals, ADRs, incident reports, 15% unblocking other engineers, 10% cross-team and product negotiation. If your answer to "what do you do day to day" is 80% coding, interviewers hear Senior.

Chapter 2Senior vs Staff vs Principal vs Architect

The single most common reason a strong candidate is down-levelled: they answered every question about the code, and the interviewer was asking about the system. The ladder is not "harder questions" — it is a change in the unit of work.

Unit of workSeniorStaffPrincipal
DeliversFeatures and componentsSystems and standardsStrategy and capability
Time horizonSprint to quarterQuarter to yearOne to three years
OptimisesTheir own correctness and throughputThe team's or domain's throughputThe organisation's optionality
Failure they preventA bugA class of bugAn architecture that cannot change
ArtefactA pull requestA design doc others implementA strategy others plan against

The four down-levelling triggers

  1. Solution-first answering. Jumping to "I'd use MVI with a sealed state class" before establishing constraints. Staff candidates spend the first 3–5 minutes of a design question on requirements and non-functionals, and interviewers explicitly score this.
  2. No inversion condition. Recommending an approach without ever saying what would make you choose differently. This reads as dogma, and dogma at Staff level is dangerous because juniors copy it.
  3. "We" with no "I". Behavioural answers that never isolate the candidate's own decision, or conversely claim solo credit for team outcomes. Both are calibration failures.
  4. No cost awareness. Proposing a migration without engineer-months, a cache without memory budget, or an abstraction without the onboarding cost it imposes on 40 engineers.

Interview question · opener in almost every Staff loop

"What's the difference between a Senior and a Staff engineer, in your view?"

Why interviewers ask this

It is a self-calibration probe. Your answer tells them what you think you are being hired for, and they will spend the rest of the loop testing whether your examples match your own definition. Candidates who define Staff as "more technical depth" then get asked increasingly deep API questions — and get down-levelled when the depth is not accompanied by scope.

Short answer

Senior is measured by the quality of what they build; Staff is measured by the quality of what the organisation builds because of them — through architecture, standards, unblocking, and the problems they choose.

Common mistakes

  • Framing it as seniority or tenure ("Staff is a Senior who's been here longer").
  • Framing it as management ("Staff attends more meetings") — this signals you see the level as a tax rather than leverage.
  • Framing it purely as depth, which is the Senior definition of Staff.

Follow-up questions

  1. "Give me an example where you operated at that level but the outcome was bad."
  2. "How do you know your architectural influence actually helped, rather than just being adopted because you're senior?"
  3. "What's something you used to believe strongly about Android architecture that you changed your mind on?"
  4. "When did you deliberately choose not to intervene in a bad technical decision?"
  5. "How much of your last year was hands-on code? Was that the right amount?"

Staff-level answer L4

"The unit changes. As a Senior I owned components and was judged on whether they were correct, tested and on time. As Staff I own seams — the places where two teams' decisions collide and neither owns the outcome. Concretely: last year our checkout and account teams both cached user entitlement, with different TTLs, which produced a class of bug where a user who upgraded saw the old tier for up to an hour. No single team owned it. I wrote the entitlement-ownership proposal, built the shared source of truth, and got both teams to migrate over a quarter. The measurable outcome was that tier-mismatch support tickets went to near zero and both teams deleted code. What makes that Staff rather than Senior isn't the caching design — a Senior could do that — it's that nobody asked me to, and the hard part was the two-team agreement, not the cache."

Principal perspective L5

Adds the systemic layer: "…and the reason that bug existed is that we had no owner for cross-cutting domain concepts, so I also proposed the domain-ownership registry so the next entitlement-shaped problem has an owner on day one. That's the difference between fixing an instance and removing a class."

What the interviewer records

Scope evidence (multi-team), agency (self-initiated), measurable outcome, and — critically — whether the candidate can distinguish their own contribution from the team's.

Chapter 3Scope, ownership, and the seams between teams

Staff work concentrates in three places, all of them uncomfortable:

  • Unowned surfaces. App startup, the DI graph, navigation, the design system, crash triage rotation, the release process. Everyone depends on them; no team's OKRs include them. A Staff engineer either owns one or ensures someone does.
  • Contested boundaries. Two teams that disagree about where a responsibility lives. The technical answer is usually easy; the hard part is that one team must absorb work they did not plan.
  • Decisions with no deadline. The migration everyone agrees is needed and nobody starts. Staff engineers convert these into sequenced, funded work with a first increment small enough to survive a sprint.
Interview technique

When asked "tell me about a project you owned," pick one where you created the ownership. A project handed to you demonstrates competence; a project you defined demonstrates level.

Chapter 4Decision-making under ambiguity

The most portable Staff framework is the reversibility test. Classify the decision before you agonise over it:

ClassExamples on AndroidHow to decide
One-way doorPublic SDK API shape, on-device data format, the reactive boundary of the app, KMP adoption, a sync protocol other clients implement.Slow down. Write it up, prototype the risky part, get explicit disagreement on the record, design an exit path before committing.
Expensive to reverseDI framework, module topology, navigation library, database engine.Decide with 70% information, but isolate the choice behind an internal API so reversal is mechanical rather than architectural.
Cheap to reverseScreen-level state pattern, a mapper's location, test library, code style.Decide fast, pick a default, and stop the team from litigating it. Consistency is worth more than optimality here.

The associated interview skill is saying, unprompted: "This is a one-way door because the data format ships to devices we can't force-update, so I'd spend a week on it. The state-management choice inside the feature is cheap to reverse, so I'd just pick one and move." That single sentence does more for your level assessment than a perfect diagram.

Writing the decision down

Staff engineers are expected to produce ADRs. Interviewers sometimes ask you to sketch one verbally. The minimum viable shape:

ADR skeleton — what interviewers expect you to name
Context      What is true today, what forces are in tension, what we measured.
Decision     One sentence, in the active voice. "We will …"
Alternatives Two or three real options, each with why it lost — not strawmen.
Consequences What becomes easy, what becomes hard, what we now owe.
Reversal     What signal would make us revisit, and what the exit costs.
Owner/date   Who is accountable, when it is re-evaluated.

The two sections weak ADRs omit are Alternatives with honest reasons and Reversal. Both are exactly what a Staff interviewer is listening for.

Chapter 5The thirteen judgment lenses

Applied to any design answer, these convert a competent technical description into a Staff one. You will not use all thirteen in a single answer; naming the three that dominate this problem, and saying why the others are secondary, is the actual skill.

LensThe question it forcesAndroid-specific form
CorrectnessDoes it produce the right result?Including after process death, rotation, and a mid-flight cancellation.
ReliabilityWhat happens when a dependency fails?Offline, flaky cellular, backend 503, expired token, killed background work.
ScalabilityWhat breaks at 10×?10× items in the list, 10× screens, 10× engineers touching this module.
PerformanceWhat is the latency and resource cost?Frame budget, cold-start contribution, memory footprint, bytes over cellular.
MaintainabilityCan a stranger change it safely?Can a new hire in week two add a field without breaking three layers?
TestabilityCan we verify it cheaply and deterministically?Injectable dispatchers, fakes at the boundary, no real time in tests.
SecurityWhat can an attacker do?Rooted device, hostile app on the same device, MITM proxy, tampered APK.
ObservabilityHow will we know it broke?Which metric moves, within how long, and who is paged.
OperabilityCan we intervene without a release?Kill switch, remote config, staged rollout halt criteria.
CostWhat does it cost to run?Telemetry volume, image CDN egress, push fan-out, build minutes.
Developer experienceDoes it make engineers faster?Build time, boilerplate per screen, time to first commit.
Business impactWhy does this matter commercially?Conversion, retention, support load, App Store rating, time to market.
Organisational impactWho else must change?How many teams must adopt it, and who pays for their migration.

Chapter 6Influence, mentorship, and driving architectural change

The recurring Staff interview theme is change without authority. Four mechanisms, roughly in order of effectiveness:

  1. A working prototype. The strongest argument in engineering is a branch that demonstrably builds 40% faster. It converts an opinion debate into a measurement debate.
  2. A written proposal with a named cost. "Three engineer-months, sequenced over two quarters, with the first increment shipping in three weeks" is arguable; "we should modularize" is not.
  3. A pilot team. Prove the approach with one willing team, publish their numbers, and let the second team adopt it because it works, not because you said so.
  4. Mechanisms, not memos. The change that survives you is the lint rule, the module template, the CI gate, the default in the project generator. Documentation decays; enforcement does not.

Interview question · leadership

"How do you get another team to adopt an architecture change that gives them no immediate product value?"

Why interviewers ask this

It tests whether you understand that engineering-quality work competes for the same funding as features, and whether you can make that case in a language product leadership accepts. Candidates who answer with "I'd explain why it's technically better" have not yet operated at this level.

Deep answer L4

"First I'd check whether it actually should happen — 'no immediate product value' sometimes means 'no value.' Assuming it holds, I'd do four things. Convert it to a business unit: our old networking layer cost roughly 4 support escalations a week and 1.5 engineer-days per feature in workarounds, which I can express in engineer-months a year. Shrink the first increment until it fits inside work they're already doing — new screens use the new client, nobody backfills. Absorb the cost myself for the pilot: I write the migration for their first two screens so their perceived cost is a review, not a project. And attach it to something they already want, which was a flaky-test problem the new client made testable. What I would not do is make it a mandate from an architecture council; mandates without absorbed cost produce compliance theatre — teams add the wrapper and keep the old path."

Follow-up questions

  1. "What if after all that they still refuse?" — Escalate the decision, not the disagreement: put the cost and the risk in writing, let the accountable director choose, and then support the choice publicly even if it goes against you.
  2. "How do you avoid becoming the person who migrates everyone's code?" — Absorb the cost for the pilot only; the second team gets a codemod and a template, the third gets documentation and a lint rule.
  3. "How do you know the migration is actually working?" — A leading indicator (percentage of screens on the new path) and a lagging one (the escalation count you used to justify it). If the lagging one does not move within two quarters, stop.
  4. "When would you abandon the migration?" — When the pilot's numbers do not reproduce, when the team that must own it afterwards will not, or when a platform shift makes the target obsolete.

Part I checklist

  • I can name which Staff archetype the role I'm interviewing for actually is.
  • I have three stories where I created the ownership rather than receiving it.
  • I classify decisions as one-way / expensive / cheap out loud, unprompted.
  • Every design answer I give names an inversion condition.
  • I can express an engineering-quality investment in engineer-months and a business metric.
  • I have one story where my influence attempt failed, and what I learned.

Part II

Kotlin Mastery

Kotlin at the level where you can explain what the compiler emits, and why that matters for APIs crossing module boundaries and surviving 40 engineers.

Chapter 7Type system, null safety, and the Java boundary

Kotlin's null safety is a compile-time guarantee that stops at the JVM boundary. Every production NPE in a Kotlin codebase comes from one of four places: platform types from Java, reflection-based deserialisation, lateinit, or a !! someone added under deadline pressure.

Platform types are the biggest hole

When Kotlin calls unannotated Java, the return type is a platform type (String!): the compiler declines to enforce either nullability. Assigning it to a non-null type inserts an implicit null check that throws at the assignment site — which is why the stack trace often points at code that looks obviously safe.

Avoid
// Java library, no @Nullable annotations
val name: String = javaUser.getName()   // platform type
val len = name.length

// Deserialised by a reflective parser
data class Config(val apiUrl: String)
// Parser can construct this with apiUrl == null,
// bypassing the constructor's null check entirely.

Both compile. The first throws at the assignment with a confusing trace; the second produces a String that is null at runtime, and the NPE surfaces somewhere unrelated hours later.

Prefer
// Be explicit about the boundary
val name: String = javaUser.name
    ?: error("User.name was null for id=${javaUser.id}")

// Model wire types as nullable, validate once, map inward
@Serializable
data class ConfigDto(val apiUrl: String? = null)

fun ConfigDto.toDomain(): Config =
    Config(apiUrl = requireNotNull(apiUrl) { "apiUrl missing" })

data class Config(val apiUrl: String)   // always valid

Null becomes a validated boundary concern, and the failure message identifies the record. The domain type carries a real guarantee.

Interview question · fundamentals with a Staff tail

"Kotlin is null-safe, so why do we still see NPEs in production Kotlin code?"

Why interviewers ask this

It separates people who learned Kotlin's rules from people who have debugged Kotlin at scale. The second group immediately names platform types and deserialisation.

Deep explanation

Null safety is enforced by the compiler, not the runtime. Four escape hatches: platform types from unannotated Java; reflection, which can write null into a non-null field (Gson is the classic offender; kotlinx.serialization and Moshi's Kotlin adapter respect nullability); lateinit, which throws UninitializedPropertyAccessException; and !!. Additionally, uninitialised non-null fields can be observed as null during construction if a superclass constructor calls an overridden method.

Follow-up questions

  1. "How would you prevent the deserialisation class across a 300-module codebase?" — Ban reflective JSON in the paved road, use kotlinx.serialization or Moshi codegen, add a Detekt rule failing on Gson imports, and validate DTO→domain mapping at the boundary.
  2. "When is !! acceptable?" — When the invariant is genuinely enforced elsewhere and the alternative is noise, e.g. immediately after a contains check on a local map. Even then prefer requireNotNull with a message, because the message is what you'll want at 2am.
  3. "lateinit vs nullable var vs by lazy?" — lazy when the value is derivable on first access; lateinit for framework-injected values with a clear initialisation point; nullable when absence is a legitimate state rather than a bug.
  4. "How does this change with KMP?" — No platform types on native targets, but Objective-C interop reintroduces them on iOS; the discipline transfers.

Chapter 8Generics, variance, and reified types

Variance answers one question: if Dog is a Animal, is Repo<Dog> a Repo<Animal>? By default no (invariant), because a producer-consumer type cannot safely be either.

  • out T — covariant, producer. T may appear in return position only. List<out E>: a List<Dog> is a List<Animal> because you can only read.
  • in T — contravariant, consumer. T in parameter position only. A Comparator<Animal> can compare dogs.
  • * — star projection, "some unknown type"; safe to read as the upper bound, unsafe to write.
Avoid — invariance forces casts
interface Mapper<T, R> { fun map(input: T): R }

// Caller has a Mapper<UserDto, User> but a
// pipeline typed Mapper<Any, Any> — no relation.
fun runAll(ms: List<Mapper<Any, Any>>) { /* ... */ }

// Forced to do this at every call site:
runAll(mappers as List<Mapper<Any, Any>>)  // unchecked

Unchecked casts spread through the codebase and defeat the type system for everyone downstream.

Prefer — declare variance once
fun interface Mapper<in T, out R> {
    fun map(input: T): R
}

// Now a Mapper<UserDto, User> IS a Mapper<UserDto, Any>
// and a Mapper<Any, User> IS a Mapper<UserDto, User>.
fun <T> runAll(input: T, ms: List<Mapper<T, *>>): List<Any?> =
    ms.map { it.map(input) }

Variance declared at the declaration site fixes it for every consumer, forever. This is a public-API decision: adding variance later is source-compatible, removing it is not.

Reified: what it buys and what it costs

JVM generics are erased. reified works by forcing the function inline, so the concrete type is substituted at each call site. The cost is real: every call site duplicates the body.

Avoid
// 400-line inline reified function, called from
// 90 places. R8 sees 90 copies of 400 lines.
inline fun <reified T> parseAndValidateAndCache(
    json: String
): T {
    /* 400 lines */
}

Method-count and APK-size explosion, worse instruction-cache behaviour, and stack traces that no longer point at one place.

Prefer — thin reified shim
inline fun <reified T> parse(json: String): T =
    parse(json, T::class.java)     // tiny, inlined

// The real work is a normal function, compiled once.
fun <T> parse(json: String, type: Class<T>): T {
    /* 400 lines, one copy */
}

Keep the inlined surface to the type-capturing line. This is the pattern the Kotlin stdlib itself uses.

Chapter 9Inline, value classes, and what the compiler emits

inline exists primarily so higher-order functions do not allocate a lambda object per call, and so non-local returns work. It is not a general speed switch — inlining a function with no lambda parameters usually earns a compiler warning because the benefit is negligible.

ModifierEffectUse when
inlineBody and lambda bodies copied to the call site; enables non-local return and reified.Small higher-order functions called in hot paths or needing reified types.
noinlineThat specific lambda stays a real object.You need to store the lambda, pass it on, or make it nullable.
crossinlineLambda is inlined but non-local return is forbidden.The lambda is invoked from another context, e.g. inside a Runnable.
value classWrapper erased to the underlying type where possible.Type-safe IDs and units without allocation.
Avoid — primitive obsession
fun transfer(
    from: String,
    to: String,
    amount: Long
)

// Compiles fine, ships a bug:
transfer(to = accountA, from = accountB, amount = cents)
transfer(userId, accountId, amount)   // wrong arg order

Every ID is a String, so the compiler cannot help. These bugs are found in production, usually by finance.

Prefer — value classes
@JvmInline value class AccountId(val raw: String)
@JvmInline value class Cents(val raw: Long) {
    init { require(raw >= 0) { "negative amount" } }
}

fun transfer(from: AccountId, to: AccountId, amount: Cents)

transfer(userId, accountId, amount)  // ✗ won't compile

Zero allocation in the common case, and the entire argument-order class of bug becomes a compile error.

The value-class trap interviewers probe

The wrapper is only erased sometimes. Boxing occurs when the value class is used as a generic type argument (List<Cents>), as a nullable (Cents?), or where an interface type is expected. So List<Cents> allocates exactly like List<Long> boxed. Knowing when the optimisation disappears is the L4 signal; knowing it exists is L2.

Chapter 10Sealed hierarchies, data classes, and binary compatibility

Sealed types are how you make illegal states unrepresentable. They also create a binary-compatibility hazard that only appears in modular codebases — which is exactly why it is a Staff question.

The modelling win
// Illegal states are impossible: no "loading with an error and data".
sealed interface CheckoutState {
    data object Idle : CheckoutState
    data object Submitting : CheckoutState
    data class Failed(val reason: FailureReason, val retryable: Boolean) : CheckoutState
    data class Succeeded(val orderId: OrderId) : CheckoutState
}
Avoid — boolean soup
data class CheckoutState(
    val isLoading: Boolean = false,
    val error: String? = null,
    val orderId: String? = null,
    val isRetryable: Boolean = false,
)
// 16 representable combinations,
// 4 of them meaningful. Every consumer
// re-derives which is which.

Each new flag doubles the state space. Bugs appear as impossible UI: a spinner over an error message.

Prefer — exhaustive when
when (state) {
    Idle       -> ShowForm()
    Submitting -> ShowSpinner()
    is Failed  -> ShowError(state.reason, state.retryable)
    is Succeeded -> ShowReceipt(state.orderId)
}   // compiler enforces completeness

Adding a state becomes a compile error at every consumer — which is the point, and also the hazard below.

Interview question · Staff-only, modular codebases

"You add a new subtype to a sealed interface in a core module. What breaks, and when?"

Short answer

Source compatibility breaks for every module with an exhaustive when — they fail to compile, which is good. Binary compatibility also breaks: modules compiled against the old hierarchy and not recompiled will throw NoWhenBranchMatchedException at runtime. In a monorepo where everything is rebuilt together this is a compile-time inconvenience; if you ship the module as a versioned artefact to teams that update on their own schedule, it is a runtime crash.

Follow-up questions

  1. "How do you evolve a sealed hierarchy that is part of a published SDK?" — Do not expose it as sealed. Expose an interface plus a known set of implementations and require consumers to handle an Unknown/else branch, or version the type.
  2. "When is an enum better than a sealed class?" — When there is no per-case data and you need values(), ordinal serialisation, or EnumMap-style efficiency. Enums also serialise more predictably.
  3. "What's the risk with data class copy() in a validated type?" — copy() bypasses your init validation logic in older Kotlin versions when the constructor is private, and it always bypasses factory-level invariants. Prefer a non-data class with explicit withX functions when invariants matter.
  4. "data object vs object in a state hierarchy?" — data object gives you a sane toString() and correct equals semantics for free, which matters when states appear in logs and test assertions.

Chapter 11Delegation, delegated properties, and scope functions

Class delegation (by) is composition with compiler-generated forwarding. It is the cleanest way to add behaviour without inheritance — and it has one trap that appears in interviews constantly.

The delegation trap
interface Repo {
    fun load(): List<Item>
    fun loadFirst(): Item? = load().firstOrNull()
}

class Base : Repo {
    override fun load() = fetchFromNetwork()
}

class Cached(private val b: Base) : Repo by b {
    override fun load() = cache ?: b.load()
}

// Cached().loadFirst() does NOT use the cache.

The generated forwarder calls b.loadFirst(), and Base's default implementation calls its own load(). Overriding a member of the delegate does not affect calls made inside the delegate. Same class of surprise as `this` escaping in Java.

Prefer — override the full surface
class Cached(private val b: Repo) : Repo {
    private var cache: List<Item>? = null
    override fun load(): List<Item> =
        cache ?: b.load().also { cache = it }
    override fun loadFirst(): Item? =
        load().firstOrNull()      // explicit
}

When the interface has default methods that call other members, delegate explicitly or keep the interface free of defaults.

A defensible scope-function house style

FunctionReceiver / returnUse for
letit / lambda resultNull-guarded transformation: value?.let { transform(it) }.
runthis / lambda resultComputing a value from several members of one object.
applythis / receiverConfiguring a mutable object being built (mostly framework/Java types).
alsoit / receiverSide effects in a chain — logging, caching — where the value passes through.
withthis / lambda resultGrouping several calls on a non-null receiver you already have.
Review rule worth stating in an interview

Nesting scope functions more than one level deep, or using apply where the object is already immutable, are the two cases worth blocking in review. Beyond that, consistency matters more than which one is "correct" — this is a cheap-to-reverse decision (Chapter 4) and should not consume team debate.

Chapter 12Collections, sequences, and allocation

Every collection operator on an Iterable allocates a new list. A chain of five operators over 10,000 items allocates five lists of 10,000. Sequence makes the pipeline lazy and element-at-a-time, allocating once — but it adds per-element overhead, so on small collections it is slower.

Avoid — eager chain on a large list
// 50k rows, called on every DB emission
val visible = rows
    .map { it.toUiModel() }      // 50k allocs
    .filter { it.isVisible }      // new list
    .sortedBy { it.rank }         // new list
    .take(20)                     // new list

Four intermediate lists and 50k mapper calls to display 20 rows. This shows up as GC churn in a trace and as jank when it runs on the main thread.

Prefer — filter first, or push down
// Best: let SQLite do it (index + LIMIT)
@Query("SELECT * FROM rows WHERE visible = 1 " +
       "ORDER BY rank LIMIT 20")
suspend fun visibleTop(): List<Row>

// If it must be in memory:
val visible = rows.asSequence()
    .filter { it.isVisible }      // cheap predicate first
    .sortedBy { it.rank }         // terminal-ish, unavoidable
    .take(20)
    .map { it.toUiModel() }       // only 20 mapper calls
    .toList()

Order matters more than laziness: mapping after filtering and taking removes 49,980 conversions. And the real Staff answer is that this belongs in the query.

Interview nuance

sorted is a stateful operation — it must materialise the whole sequence, so laziness buys nothing across it. Candidates who claim "sequences are always faster for big data" get caught here. The honest rule: sequences win when the chain is long, the source is large, and the terminal operation is short-circuiting (first, take, any).

Chapter 13Java interoperability

Most large Android codebases are mixed for years. The Staff-level skill is designing Kotlin APIs that are pleasant from Java without contorting the Kotlin.

Avoid — hostile from Java
object AnalyticsClient {
    fun track(
        name: String,
        props: Map<String, Any> = emptyMap(),
        immediate: Boolean = false,
    ) { }
}

// From Java:
// AnalyticsClient.INSTANCE.track("x", map, false);
// default args unusable, INSTANCE noise

Java callers must pass every argument and navigate INSTANCE. Nullability is also unspecified for Java's tooling if the types come from generics.

Prefer — annotate the boundary
object AnalyticsClient {
    @JvmStatic
    @JvmOverloads
    fun track(
        name: String,
        props: Map<String, Any> = emptyMap(),
        immediate: Boolean = false,
    ) { }
}

// From Java: AnalyticsClient.track("x");

@JvmStatic, @JvmOverloads, @JvmName, @Throws and explicit nullability annotations on the boundary make the API usable without changing the Kotlin.

Other interop facts worth having ready: Kotlin's checked-exception freedom means Java callers get no compiler warning unless you add @Throws; internal is public in the bytecode with a mangled name, so it is not a security boundary; and Kotlin's List is java.util.List at runtime, so a Java caller can mutate your "immutable" list.

Chapter 14DSLs, operators, and the overengineering line

Type-safe builders are Kotlin's most abused feature. A DSL is justified when the domain has genuine nesting or repetition and the DSL will be used in dozens of places by people who did not write it. It is not justified to make a three-field configuration read like English.

Avoid — a private language
screen {
    +header { "Title" }
    body {
        row { col(2) { text("a") }; col(4) { text("b") } }
    }
} bindTo viewModel via mapper
// unary plus, infix chains, custom operators:
// nobody can grep for this, IDE help is poor,
// and errors surface as "expected Unit"

Cleverness has a maintenance cost paid by everyone else. Operator overloading that is not arithmetic-shaped is the strongest smell.

Prefer — DSL with @DslMarker, plain names
@DslMarker annotation class NavDsl

@NavDsl class GraphBuilder {
    fun screen(route: String, block: ScreenBuilder.() -> Unit)
}

navGraph {
    screen("home") { deepLink("app://home") }
    screen("cart")  { requiresAuth() }
}

@DslMarker prevents accidentally calling an outer builder's method from an inner scope — the single most confusing DSL bug. Names are ordinary functions, so IDE completion and grep work.

Part II rapid recall

  • Platform types, reflection, lateinit and !! are the four NPE sources; validate at the DTO boundary.
  • out = produces = return position; in = consumes = parameter position. Declaration-site variance is an API decision.
  • reified forces inlining — keep the inlined body one line thick.
  • Value classes box under generics, nullability, and interface positions.
  • Adding a sealed subtype breaks binary compatibility for separately-compiled consumers.
  • Overriding a delegate's member does not affect calls made inside the delegate.
  • Sequences win on long chains over large sources with short-circuiting terminals; sorted defeats laziness.
  • internal is not a security boundary; Kotlin's List is mutable from Java.

Part III

Coroutines, Flow & Concurrency

The highest-yield technical part of the book, and where strong Senior candidates most reliably reveal a ceiling — because concurrency failures only appear under load, cancellation, and error, which is exactly what interviewers probe.

Chapter 15Structured concurrency from first principles

Structured concurrency means every coroutine has a parent, and no parent completes before its children. Three properties follow, and they are the entire reason the model exists:

  • No leaks. Cancelling a scope cancels everything beneath it, transitively.
  • Error propagation. A failing child cancels its siblings and notifies the parent — failures cannot be silently dropped.
  • Completion is meaningful. When coroutineScope { } returns, all work it started is genuinely finished.
flowchart TD
  VMS["viewModelScope (SupervisorJob + Main.immediate)"] --> A["launch: load profile"]
  VMS --> B["launch: load orders"]
  A --> A1["async: avatar"]
  A --> A2["async: badges"]
  B --> B1["async: page 1"]
  A1 -. "throws" .-> A
  A -. "cancels sibling" .-> A2
  A -. "SupervisorJob stops here" .-> VMS
  VMS --> B
  
Avoid — GlobalScope
class SyncRepository {
    fun sync() {
        GlobalScope.launch {
            val data = api.fetch()
            db.save(data)
        }
    }
}

No owner, so no cancellation: the work outlives the screen, the user, and sometimes the login session. Exceptions go to the default handler and crash the process, or vanish. Untestable — a test has no handle to await. And it is invisible in review because it looks like the surrounding code.

Prefer — the caller owns the scope
class SyncRepository(
    private val api: Api,
    private val db: Db,
) {
    suspend fun sync() {          // suspend, not launch
        val data = api.fetch()
        db.save(data)
    }
}

// Caller decides the lifetime:
viewModelScope.launch { repo.sync() }        // screen-scoped
appScope.launch { repo.sync() }              // app-scoped, deliberate

A repository should expose suspend functions and let the caller own the scope. If work genuinely must outlive the screen (a write that must complete), inject a deliberately-scoped CoroutineScope — an application-scoped singleton with a SupervisorJob — so it is explicit, injectable, and replaceable in tests.

Interview question · asked in nearly every Staff loop

"Why can GlobalScope create architectural problems?"

Short answer

It breaks the ownership chain. Work with no owner cannot be cancelled, cannot be awaited, cannot be tested deterministically, and its failures have no defined destination.

Deep explanation

Four concrete consequences. Leaks: a coroutine capturing a ViewModel or a Composable's lambda keeps it alive past its lifecycle. Unbounded concurrency: a screen that launches on every scroll event can produce hundreds of in-flight requests with nothing to cancel them. Undefined error handling: uncaught exceptions reach the thread's default handler, which on Android usually means a crash from a stack trace with no application frames. Test flakiness: runTest controls the test scope only, so GlobalScope work escapes virtual time and either races or is silently dropped.

Real-world example

A checkout screen fired analytics via GlobalScope.launch. On a slow network, users who abandoned checkout still had "purchase intent" events flushed minutes later, from a scope holding a reference to the abandoned cart. Two consequences: a memory retention path found by LeakCanary, and a data-quality bug that skewed the funnel by ~3%.

Follow-up questions

  1. "When is an application-scoped coroutine correct?" — Fire-and-forget writes that must survive navigation: marking a message read, flushing an analytics batch, completing a payment confirmation. Use an injected @ApplicationScope CoroutineScope, not GlobalScope, so it is visible in the DI graph and swappable in tests.
  2. "How would you prevent it across 300 modules?" — A Detekt/lint rule banning GlobalScope at error severity, plus an approved AppScope injection in the paved road so there is a sanctioned alternative. A ban without an alternative produces creative workarounds.
  3. "What about WorkManager?" — For work that must survive process death, a coroutine of any scope is the wrong tool; the OS can kill you at any moment. Durable work belongs in WorkManager with a unique-work policy.
  4. "How do you test the app-scoped case?" — Inject a TestScope as the app scope so tests can advance and assert on it.

Interviewer evaluation

L2 says "it leaks." L3 adds cancellation and testing. L4 adds the enforcement mechanism and the legitimate exception, and distinguishes app-scoped coroutines from durable work.

Chapter 16Context, dispatchers, and main-safety

CoroutineContext is an immutable, indexed set of elements — Job, CoroutineDispatcher, CoroutineName, CoroutineExceptionHandler — combined with +. A child inherits the parent's context, with its own Job substituted.

DispatcherBackingUse for / caution
MainAndroid main looperUI state updates. Main.immediate avoids a re-post when already on main — this is what viewModelScope uses.
DefaultCPU-count threadsSorting, parsing, image maths, diffing. Saturating it blocks all CPU work app-wide.
IOElastic, 64+ threads, shared pool with DefaultBlocking file/network/DB calls. limitedParallelism(n) to bound a subsystem.
UnconfinedCaller's thread until first suspendAlmost never in production. Legitimate in some test and operator-implementation cases.
Avoid — main-safety pushed onto callers
class UserRepository(private val db: UserDao) {
    // Blocking, but nothing says so.
    fun loadBlocking(): User = db.queryBlocking()
}

// Every call site must remember:
viewModelScope.launch {
    val u = withContext(Dispatchers.IO) {
        repo.loadBlocking()
    }
}
// One forgotten withContext = a StrictMode
// violation, an ANR under load, or both.

Main-safety as a call-site convention fails at scale — you cannot enforce it in review across 40 engineers.

Prefer — main-safe by contract
class UserRepository(
    private val db: UserDao,
    private val io: CoroutineDispatcher,   // injected
) {
    /** Safe to call from any dispatcher. */
    suspend fun load(): User = withContext(io) {
        db.queryBlocking()
    }
}

// Call site is trivial and cannot get it wrong:
viewModelScope.launch { state = repo.load() }

The function that knows it blocks is the function that switches. Injecting the dispatcher (rather than referencing Dispatchers.IO directly) is what makes it testable with UnconfinedTestDispatcher.

The nuance interviewers reward

Room and Retrofit's suspend functions are already main-safe — Room dispatches to its own executor, Retrofit to OkHttp's. Wrapping them in withContext(Dispatchers.IO) is a no-op that costs a context switch and signals the candidate has not read the libraries. Saying this unprompted is a strong L4 marker.

Chapter 17Cancellation semantics

Cancellation is cooperative. Cancelling a job sets its state to cancelling and, at the next suspension point, the suspending function throws CancellationException. Code that never suspends is never cancelled.

Avoid — uncancellable CPU loop, swallowed cancellation
viewModelScope.launch(Dispatchers.Default) {
    for (frame in frames) {          // 5k iterations
        process(frame)               // pure CPU, no suspend
    }
}

// elsewhere:
try {
    api.upload(file)
} catch (e: Exception) {             // catches CancellationException!
    log("upload failed", e)
    state = Error
}

The loop runs to completion after the screen is gone. And catching Exception swallows CancellationException, which breaks the structured-concurrency contract: the parent believes the child completed normally, and you get "error" UI on a screen the user already left.

Prefer — cooperate, and let cancellation through
viewModelScope.launch(Dispatchers.Default) {
    for (frame in frames) {
        ensureActive()               // cheap cancellation check
        process(frame)
    }
}

try {
    api.upload(file)
} catch (e: CancellationException) {
    throw e                          // never swallow
} catch (e: IOException) {
    state = Error(e.toMessage())
}

ensureActive() (or yield() if you also want to give up the thread) makes CPU work cancellable. Rethrowing CancellationException — or catching specific exception types — keeps cancellation working.

Cleanup that must run after cancellation

withContext(NonCancellable) — use sparingly and only in finally
suspend fun uploadWithCleanup(file: File) {
    val session = api.beginUpload(file.name)
    try {
        api.uploadChunks(session, file)      // cancellable
    } finally {
        // Suspending calls in a cancelled scope throw immediately
        // unless we opt out. Keep this block tiny and fast.
        withContext(NonCancellable) {
            api.abortIfIncomplete(session)
        }
    }
}

Interview question · scenario

"The user leaves a screen while a write is in flight. What should happen?"

Why interviewers ask this

Because the naive answer ("cancel it, that's what viewModelScope is for") is wrong for writes and right for reads, and the candidate's ability to distinguish tells you whether they have shipped a real app.

Staff answer L4

"It depends on whether the operation is owned by the screen or by the user's intent. A read — loading the profile — is screen-owned: cancel it, nothing is lost. A write — submitting an order, marking a message read — is intent-owned: the user asked for it and leaving the screen is not a retraction. Cancelling it mid-flight also creates the worst possible state, because the server may already have committed.

So I'd split them. Reads go in viewModelScope. Writes go to an application-scoped coroutine, and if the write must survive process death — a payment, a post — it goes into WorkManager with a unique-work policy and an idempotency key, because an app-scoped coroutine dies with the process too. The idempotency key matters because the retry after process death must not produce a second order.

The failure mode to name: if the write is app-scoped and the ViewModel exposes the result, you now have a coroutine holding a reference to a dead ViewModel. The result has to land in a repository or database that the next screen observes, not in the ViewModel that started it."

Follow-up questions

  1. "How does the user find out it succeeded?" — Persisted state observed by whatever screen is current, plus a notification if the operation is long. Never a toast fired from a dead scope.
  2. "How do you test the process-death path?" — Instrumented test with WorkManager's TestDriver, plus a unit test asserting the idempotency key is stable across restarts.
  3. "What if the user logs out mid-write?" — The queue must be user-scoped and purged on logout, otherwise you write to the wrong account. This is a real production bug class.

Chapter 18Exception propagation and supervision

The single most-asked coroutine question in Staff loops is a variant of: three concurrent requests, one fails — what happens to the other two? The answer depends entirely on the builder and the scope.

ConstructOn child failureWhere the exception surfaces
coroutineScope { }Cancels all siblings, then rethrows.At the coroutineScope call, to the caller.
supervisorScope { }Siblings continue.At each child's own await(), or the handler for launch.
launchPropagates up immediately.Nearest CoroutineExceptionHandler, else the thread default handler (crash).
asyncDeferred until awaited……but in a normal scope it still cancels the parent immediately. Only under a supervisor is it truly deferred.
Avoid — assuming try/catch around await() contains it
suspend fun loadDashboard() = coroutineScope {
    val profile = async { api.profile() }
    val orders  = async { api.orders() }
    val promos  = async { api.promos() }   // optional data

    Dashboard(
        profile = profile.await(),
        orders  = orders.await(),
        promos  = try { promos.await() }   // does NOT help
                  catch (e: Exception) { emptyList() }
    )
}

When promos fails, async in a regular coroutineScope cancels the parent at failure time, not at await time. The whole dashboard fails even though the promo data was optional, and the catch never runs meaningfully.

Prefer — supervise the optional work
suspend fun loadDashboard() = coroutineScope {
    // Required data: fail together, deliberately.
    val profile = async { api.profile() }
    val orders  = async { api.orders() }

    // Optional data: isolated failure.
    val promos = async {
        runCatching { api.promos() }.getOrDefault(emptyList())
    }

    Dashboard(profile.await(), orders.await(), promos.await())
}

Handle the failure inside the child so it never propagates, or wrap the optional children in supervisorScope. Deciding which data is required and which is optional is a product decision the candidate should surface explicitly.

Trap: SupervisorJob in the wrong place

launch(SupervisorJob()) does not supervise anything useful — passing a Job to a builder replaces the child's job but the new job's parent is the scope's job, so failures still propagate, and worse, you have detached cancellation. Supervision comes from the scope: CoroutineScope(SupervisorJob() + dispatcher) or supervisorScope { }. Candidates who write launch(SupervisorJob()) in a coding round are demonstrating a memorised token rather than a model.

Chapter 19Shared mutable state: Mutex, atomics, confinement

Three strategies, in the order you should reach for them:

  1. Confinement — do not share. A single coroutine owns the state; others send it messages. Zero locking, but adds indirection.
  2. Atomics — for a single independent value. AtomicInteger, MutableStateFlow.update { } (which uses a CAS loop).
  3. Mutex — coroutine-aware lock; suspends instead of blocking a thread. Use when several values must change together.
Avoid — read-modify-write race
private val _state = MutableStateFlow(UiState())

fun addItem(item: Item) {
    // Two concurrent calls: both read the same
    // old value, second write wins, first item lost.
    _state.value = _state.value.copy(
        items = _state.value.items + item
    )
}

// And never this in a coroutine:
private val lock = ReentrantLock()
suspend fun save() = lock.withLock { api.save() } // blocks a thread

The first is a classic lost update — invisible in testing, reproducible only under real concurrency. The second blocks an IO thread and can deadlock if the lambda suspends.

Prefer — atomic update, or a Mutex
fun addItem(item: Item) {
    _state.update { it.copy(items = it.items + item) }  // CAS loop
}

// When several pieces must change atomically:
private val mutex = Mutex()
private var session: Session? = null

suspend fun refresh(): Session = mutex.withLock {
    session?.takeIf { it.valid } ?: api.refresh().also { session = it }
}

update { } retries on conflict, so no update is lost. Mutex.withLock suspends rather than blocking, and is safe around suspending calls. Note it is not reentrant — a nested withLock on the same mutex deadlocks.

Chapter 20Flow fundamentals: cold streams

A Flow is a suspending producer with no state of its own. The builder block does not run until collect; each collector re-runs it independently; and the whole thing is cancelled by cancelling the collecting coroutine.

Two invariants define correct Flow code:

  • Context preservation. A flow must emit in the collector's context. Emitting from a different coroutine throws IllegalStateException; change context with flowOn, never with withContext around an emit.
  • Exception transparency. A flow may not catch exceptions thrown downstream. try/catch around emit violates this; use the catch operator, which only sees upstream failures.
Avoid — both invariants violated
fun items(): Flow<List<Item>> = flow {
    try {
        withContext(Dispatchers.IO) {
            emit(dao.loadBlocking())      // ✗ wrong context
        }
    } catch (e: Exception) {              // ✗ catches downstream too
        emit(emptyList())
    }
}

The emit throws IllegalStateException: Flow invariant is violated, and the catch would swallow a crash originating in the collector's own code — a bug that presents as "the UI silently shows an empty list."

Prefer — flowOn and catch
fun items(): Flow<List<Item>> = flow {
        emit(dao.loadBlocking())
    }
    .flowOn(Dispatchers.IO)       // upstream context only
    .catch { e ->                 // upstream failures only
        Log.w(TAG, "items failed", e)
        emit(emptyList())
    }

flowOn affects everything above it; catch sees only what is above it. Both compose predictably, which is why they exist as operators rather than language constructs.

Chapter 21Hot streams: StateFlow, SharedFlow, Channel

TypeSemanticsCorrect use / failure mode
StateFlowAlways has a value; conflated; distinct-until-changed by equals.UI state. Failure mode: it drops intermediate values, so it must never carry events.
SharedFlowConfigurable replay and buffer; no initial value.Events with multiple observers. Failure mode: replay > 0 re-delivers old events to a new subscriber (navigation fires twice after rotation).
ChannelPoint-to-point; each element consumed once.One-shot events with exactly one consumer. Failure mode: an event sent while nothing collects is buffered or suspends; consumed elements are gone on rotation.
Avoid — events in StateFlow
data class UiState(
    val items: List<Item> = emptyList(),
    val navigateTo: Route? = null,     // event in state
    val errorMessage: String? = null,  // event in state
)

// UI must remember to clear it:
LaunchedEffect(state.navigateTo) {
    state.navigateTo?.let {
        nav.navigate(it)
        vm.consumedNavigation()   // easy to forget
    }
}

Rotation re-emits the state and navigates again. Two errors in a row with the same message emit once because StateFlow is distinct-until-changed. Every consumer must implement a consume protocol.

Prefer — state and events as separate channels
// State: what the screen looks like.
val state: StateFlow<UiState> = ...

// Events: things that happen once.
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()

// UI:
LaunchedEffect(Unit) {
    lifecycle.repeatOnLifecycle(STARTED) {
        vm.events.collect { e ->
            when (e) {
                is Navigate -> nav.navigate(e.route)
                is ShowSnack -> snackbar.showSnackbar(e.text)
            }
        }
    }
}

A Channel with receiveAsFlow gives exactly-once delivery to a single collector and buffers while the UI is stopped. Repeated identical errors both arrive. Nothing replays on rotation.

Honest trade-off to state in the interview

Channel-based events are not free: if the UI is destroyed before collecting, buffered events are lost, and with multiple collectors the delivery is arbitrary. SharedFlow(replay = 0, extraBufferCapacity = 1) is the multi-observer alternative but drops events with no active subscriber. There is no perfect answer — the L4 move is naming which loss you are accepting and why.

Chapter 22Backpressure, sharing, and stateIn

Coroutine flows apply backpressure by default: a slow collector suspends the producer. Operators change that trade-off explicitly.

OperatorBehaviourReach for it when
buffer(n)Producer runs ahead into a buffer.Producer and collector are both slow but independent.
conflate()Keeps only the latest, drops intermediates.Only the current value matters: location, progress, sensor.
debounce(t)Emits after quiet period.Search-as-you-type. Adds latency by design.
sample(t)Emits latest every interval.Steady-rate UI updates from a firehose.
flatMapLatestCancels the previous inner flow.Query → results. The correct default for user-driven input.
flatMapMergeRuns inner flows concurrently.Independent fan-out; bound it with concurrency = n.
flatMapConcatSequential, ordered.Order matters and must be preserved.
The canonical search pipeline — every operator earns its place
private val query = MutableStateFlow("")

val results: StateFlow<SearchState> = query
    .debounce(300)                       // don't search every keystroke
    .map { it.trim() }
    .distinctUntilChanged()              // "ab " and "ab" are one query
    .filter { it.length >= 2 }
    .flatMapLatest { q ->                // cancel the previous request
        repo.search(q)                   // Flow<List<Result>>
            .map<List<Result>, SearchState> { SearchState.Results(it) }
            .onStart { emit(SearchState.Loading) }
            .catch { emit(SearchState.Failed(it.userMessage())) }
    }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = SearchState.Idle,
    )

Interview question · the WhileSubscribed(5000) question

"Why WhileSubscribed(5000) and not Eagerly or Lazily?"

Short answer

The 5-second grace period keeps the upstream alive across a configuration change, so rotation does not re-run the query, while still stopping it when the user actually leaves.

Deep explanation

Eagerly starts the upstream at construction and never stops it — a location or socket flow then runs while the app is backgrounded, burning battery. Lazily starts on first collection and never stops — same problem, delayed. WhileSubscribed(0) stops immediately on unsubscribe, which means rotation tears down and re-establishes the upstream: a wasted network call and a visible flicker. The 5-second value is chosen to exceed a configuration change but be far below any meaningful idle period.

Follow-up questions

  1. "What does replayExpirationMillis do?" — Controls how long the cached value is retained after the upstream stops. Setting it to zero forces a fresh load after the grace period expires, which you want for data that goes stale (a balance) and do not want for data that does not (a profile name).
  2. "Five screens collect the same repository flow. How many network calls?" — With stateIn/shareIn at the repository, one; the shared flow multicasts. Without sharing, five, because Flow is cold. This is the single most common source of duplicate requests in a Compose app.
  3. "What happens to an exception in a shared flow?" — It terminates the shared upstream for all collectors, and with WhileSubscribed it will restart on the next subscription. Errors must be caught and modelled as values inside the flow, not allowed to escape — otherwise one screen's failure silently kills another screen's data.
  4. "Where should stateIn live — repository or ViewModel?" — ViewModel by default (screen-scoped state). Repository only when the data is genuinely app-wide and shared, and then it needs an app-scoped CoroutineScope and a deliberate SharingStarted policy.

Chapter 23Concurrency design problems

Request deduplication — five screens, one call

Single-flight deduplicator with correct cancellation semantics
class SingleFlight<K : Any, V>(private val scope: CoroutineScope) {
    private val mutex = Mutex()
    private val inFlight = mutableMapOf<K, Deferred<V>>()

    suspend fun run(key: K, block: suspend () -> V): V {
        val deferred = mutex.withLock {
            inFlight[key] ?: scope.async(start = CoroutineStart.LAZY) {
                try { block() } finally { mutex.withLock { inFlight.remove(key) } }
            }.also { inFlight[key] = it }
        }
        // await() from the *caller's* coroutine: if this caller is
        // cancelled, the shared work continues for the others.
        return deferred.await()
    }
}

Why the shared scope matters: if the work were launched in the first caller's scope, that caller navigating away would cancel the request for the other four. Hosting it in a shared scope decouples the work's lifetime from any one consumer — the trade-off being that you must remove entries in a finally or the map grows forever.

Token refresh — one refresh, not five

OkHttp Authenticator with coordinated refresh
class AuthAuthenticator(
    private val tokens: TokenStore,
    private val refreshApi: RefreshApi,
) : Authenticator {
    private val mutex = Mutex()

    override fun authenticate(route: Route?, response: Response): Request? {
        if (responseCount(response) >= 2) return null   // stop retry loops
        val stale = response.request.header("Authorization")
            ?.removePrefix("Bearer ")

        val fresh = runBlocking {          // Authenticator is a blocking API
            mutex.withLock {
                val current = tokens.access()
                // Another thread already refreshed while we waited.
                if (current != null && current != stale) return@withLock current
                val new = runCatching { refreshApi.refresh(tokens.refresh()) }
                    .getOrElse { tokens.clear(); return@withLock null }
                tokens.save(new); new.access
            }
        } ?: return null                   // force logout upstream

        return response.request.newBuilder()
            .header("Authorization", "Bearer $fresh")
            .build()
    }
}

The critical line is the comparison against the stale token: five requests fail concurrently, all enter the mutex in turn, and only the first performs a refresh — the rest observe that the stored token has changed and reuse it. Without that check you get five refreshes, and with rotating refresh tokens, four of them fail and log the user out.

Part III rapid recall

  • Repositories expose suspend/Flow; callers own scopes. GlobalScope never; injected AppScope deliberately; WorkManager for durability.
  • Main-safety belongs to the function that blocks, with an injected dispatcher. Room and Retrofit suspend functions are already main-safe.
  • Cancellation is cooperative: ensureActive() in CPU loops, never catch CancellationException, cleanup in finally with NonCancellable.
  • async in a plain coroutineScope fails the parent at throw time, not at await time.
  • Supervision comes from the scope, not from passing a SupervisorJob to a builder.
  • StateFlow for state, Channel/SharedFlow for events — never events inside state.
  • WhileSubscribed(5000) survives rotation; unshared cold flows produce N calls for N collectors.
  • Deduplicate with a shared-scope Deferred map; coordinate refresh by comparing against the stale token.

Part IV

The Android Platform

Internals rather than usage. Every section answers three questions: how does this work below the API, what breaks in production, and what does it imply for design.

Chapter 24Process model, zygote, and Binder

Your app is a process forked from zygote, a warm VM with the framework classes and shared resources already loaded and pages shared copy-on-write. That is why an Android process starts in tens of milliseconds rather than seconds, and why anything you add to Application.onCreate is paid on every cold start with no sharing.

Binder is the IPC mechanism behind essentially every framework call that touches another process — PackageManager, ActivityManager, ContentResolver, starting activities, showing notifications. Three consequences worth naming in an interview:

  • Transactions are size-limited. The per-process Binder buffer is about 1 MB, shared across all in-flight transactions. Exceeding it throws TransactionTooLargeException — most often from a large Bundle in onSaveInstanceState or an oversized Intent extra.
  • Binder calls are synchronous and finite. The thread pool has ~16 threads; a system_server under load makes your "cheap" call slow. Binder contention is a common and under-diagnosed ANR cause.
  • Binder can block the main thread. ContentResolver.query on main is a blocking IPC, not a local call.
Avoid — state that will not fit
override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putParcelableArrayList(
        "feed", ArrayList(viewModel.allLoadedItems)  // 2,000 items
    )
}
// TransactionTooLargeException, usually only on
// devices where users scroll a lot — so it reaches production.

Saved state travels to system_server over Binder. It is for identifiers and scroll positions, not for data.

Prefer — save the key, restore the data
// In the ViewModel:
val listState = savedStateHandle.getStateFlow("firstVisible", 0)
val query = savedStateHandle.getStateFlow("query", "")

// Data comes back from the local database / Paging,
// which survives process death anyway.
val items = query.flatMapLatest { repo.page(it) }

Saved state carries the minimum needed to reconstruct — a query, an ID, a scroll index. The data itself is re-derived from disk, which is faster and unbounded.

Chapter 25The main thread: Looper, MessageQueue, Choreographer

The main thread is a loop: Looper.loop() pulls Message objects from a MessageQueue ordered by target timestamp and dispatches them to Handlers. Everything — lifecycle callbacks, input events, view invalidation, your post {} — is a message.

Choreographer registers for the display's VSYNC signal and, once per frame, runs callbacks in a fixed order: input → animation → insets → traversal (measure, layout, draw) → commit. At 120 Hz you have 8.3 ms; at 60 Hz, 16.7 ms. Anything that overruns delays the next frame, which the user sees as jank.

sequenceDiagram
  participant D as Display VSYNC
  participant C as Choreographer
  participant M as MessageQueue
  participant A as App code
  D->>C: VSYNC (every 8.3ms @120Hz)
  C->>M: post frame callback (async, front of queue)
  M->>A: INPUT callbacks
  M->>A: ANIMATION callbacks
  M->>A: TRAVERSAL — measure / layout / draw
  A-->>M: your 40ms JSON parse runs here
  Note over D,A: next VSYNC missed → dropped frame
  
The mechanism question interviewers use to separate levels

"Why does a 40 ms operation on the main thread drop three frames at 60 Hz rather than one?" Because the frame it lands in is missed, and the two subsequent VSYNC pulses arrive while the thread is still busy — the messages queue up and are processed late. This is also why the frame-timeline metric to watch is consecutive dropped frames, not the total: three isolated drops are invisible, three consecutive ones are a visible stutter.

Chapter 26Activities, tasks, launch modes, and the back stack

Launch modeBehaviourWhere it bites
standardNew instance every time, in the caller's task.Duplicate screens stacked from repeated notification taps.
singleTopReuses the instance if it is already at the top; delivers onNewIntent.Forgetting to handle onNewIntent means the new intent's data is silently ignored.
singleTaskOne instance per task; clears everything above it on re-launch.Wipes the user's back stack. Common cause of "the app lost my place" after a deep link.
singleInstanceAlone in its own task.Breaks back navigation between your own screens; almost never correct.

The Staff framing: launch modes are a legacy multi-activity concern. The modern paved road is a single activity plus a navigation graph, where the back stack is application state you control rather than an OS structure you negotiate with. When an interviewer asks about launch modes, the strongest answer covers the mechanism and says "in a single-activity app, this reduces to how onNewIntent feeds the nav controller, which is a much smaller surface to get right."

Avoid — deep link that destroys context
<activity android:name=".MainActivity"
    android:launchMode="singleTask">
    <intent-filter> ... </intent-filter>
</activity>

// User is 4 screens deep in checkout, taps a
// marketing push → task cleared, checkout lost.

Silent data loss, and it correlates with revenue. It reproduces only when the app is already open, so it survives QA.

Prefer — singleTop + explicit routing policy
<activity android:name=".MainActivity"
    android:launchMode="singleTop" />

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    setIntent(intent)
    val route = deepLinks.resolve(intent) ?: return
    when (route.policy) {
        // Interruptible content: push on top of the stack.
        Policy.PUSH -> navController.navigate(route.dest)
        // Session-critical flow in progress: defer or confirm.
        Policy.DEFER -> if (navController.inCheckout()) pending = route
                        else navController.navigate(route.dest)
    }
}

The routing policy is a product decision made explicit in code, not an emergent property of a manifest attribute.

Chapter 27Lifecycle, configuration change, and process death

There are three tiers of state, and confusing them causes most "it works on my phone" bugs.

flowchart TD
  E["Event"] --> R{"What happened?"}
  R -- "rotation / locale / dark mode" --> CC["Activity destroyed, process alive"]
  R -- "system reclaims memory" --> PD["Process killed, task record kept"]
  R -- "user swipes away / force-stop" --> FS["Task cleared"]
  CC --> K1["ViewModel: kept
SavedStateHandle: kept
disk: kept"] PD --> K2["ViewModel: GONE
SavedStateHandle: restored from Bundle
singletons: reconstructed EMPTY
disk: kept"] FS --> K3["everything in memory gone
saved state discarded
disk: kept"]

The middle branch is the one that reaches production: singletons come back empty while the UI assumes they are populated. It is also the branch nobody tests, because it needs the OS to kill a backgrounded process — constant on a low-end device, almost never on a developer's phone.

MechanismSurvivesDoes not survive
ViewModel (in memory)Configuration change, back-stack navigation within its scope.Process death, the user leaving its scope.
SavedStateHandle / rememberSaveableConfiguration change and process death.Force-stop by the user, "clear data", swipe-away on most OEMs.
Disk — Room, DataStoreEverything short of uninstall.Uninstall, clear data.

Interview question · the classic that separates L2 from L4

"Walk me through what survives process death, and how you'd test it."

Why interviewers ask this

Because virtually no team tests it, and virtually every app has bugs there. It reaches production because the reproduction requires the OS to kill a backgrounded process — which happens constantly on low-end devices and almost never on a developer's Pixel.

Deep explanation

When the system needs memory it kills your process but keeps the task record. When the user returns, Android recreates the activity and delivers the saved Bundle. Your ViewModel is gone; your SavedStateHandle is repopulated from that bundle; your singletons are freshly constructed with default state; any in-flight coroutine is gone without notice; static caches are empty.

The bug class this produces: state held in a singleton that was initialised during login (a session object, a feature-flag cache, an in-memory user), which after process death is empty while the UI assumes it is populated — presenting as a logout, a crash, or an empty screen on resume from background.

How to test it

Reproducing process death deterministically
# 1. Put the app in the background (Home, not Back).
# 2. Kill the process without clearing the task record:
adb shell am kill com.example.app
# 3. Return via Recents. Note: `am force-stop` is WRONG —
#    it clears the task and does not exercise restoration.

# In CI, exercise the saved-state path directly:
@Test fun surviving_process_death() {
    val handle = SavedStateHandle(mapOf("query" to "shoes"))
    val vm = SearchViewModel(handle, repo)
    assertEquals("shoes", vm.state.value.query)
}

# Instrumented: StateRestorationTester (Compose) or
# recreate() plus a simulated bundle round-trip.

Follow-up questions

  1. "Why is am force-stop the wrong command?" — It clears the task, so you get a fresh launch rather than a restoration, which is precisely the path you are trying to test.
  2. "Developer options has 'Don't keep activities' — is that equivalent?" — No. It destroys the activity but keeps the process, so ViewModels and singletons survive. It tests configuration-change-shaped restoration only. Both tests are needed and they cover different bugs.
  3. "How would you make this class of bug impossible rather than tested?" — Forbid mutable app state in singletons by convention and lint; make the session a persisted, observable source of truth so a fresh process reads it from disk rather than assuming it was populated.
  4. "What's the cost of putting everything in SavedStateHandle?" — It goes over Binder into system_server, so it is size-capped and slow; oversizing it produces TransactionTooLargeException. Save keys, not payloads.

Chapter 28Context, and leak topology

NeedCorrect contextWhy
Inflating a view, theming, dialogs, startActivity without a flagActivityRequires the themed, windowed context.
Singletons, DI graph, WorkManager, DataStore, databaseApplicationLifetime matches the object holding it.
Broadcast registration in a UIActivity, unregistered in the mirrored callbackApplication-context registration outlives the screen — a leak plus wasted work.
Avoid — the two-line leak
object ImageCache {
    private lateinit var context: Context
    fun init(c: Context) { context = c }   // Activity passed in
}

class MainActivity : Activity() {
    override fun onCreate(b: Bundle?) {
        ImageCache.init(this)   // leaks the Activity forever
    }
}

A static field holding an Activity retains its entire view hierarchy — often several megabytes — for the process lifetime, and every rotation adds another.

Prefer — application context, injected
@Singleton
class ImageCache @Inject constructor(
    @ApplicationContext private val context: Context,
)

DI makes the lifetime explicit and reviewable. context.applicationContext is the manual equivalent when DI is unavailable.

Chapter 29Intents, PendingIntents, and package visibility

A PendingIntent is a token granting another process permission to execute an intent as your app. That is why mutability flags are mandatory on modern Android and why getting them wrong is a real vulnerability.

Avoid — mutable, implicit
val pi = PendingIntent.getActivity(
    ctx, 0,
    Intent(ACTION_HANDLE),          // implicit
    PendingIntent.FLAG_MUTABLE      // fillable by the receiver
)

A mutable PendingIntent wrapping an implicit intent can be filled in by a malicious app and redirected to a component of your app that it should not reach — with your app's permissions. This is the intent-redirection class of vulnerability.

Prefer — immutable and explicit
val pi = PendingIntent.getActivity(
    ctx, requestCode,
    Intent(ctx, DetailActivity::class.java).apply {
        putExtra(EXTRA_ID, id)
    },
    PendingIntent.FLAG_IMMUTABLE or
        PendingIntent.FLAG_UPDATE_CURRENT
)

Use FLAG_MUTABLE only where the platform requires it (direct-reply notifications, some Bubbles APIs), and then always with an explicit component.

Chapter 30Background execution in the restriction era

Modern Android's default position is that a backgrounded app should not run. The design question is therefore never "how do I run in the background" but "what class of work is this, and what guarantee does it actually need?"

Work classMechanismGuarantee / cost
Deferrable, must eventually happenWorkManager with constraintsSurvives process death and reboot. Timing is not guaranteed — Doze can delay it hours.
User-visible, ongoing, nowForeground service with a declared typeRuns while visible; requires a justified foregroundServiceType and survives Play review scrutiny.
Short, tied to a visible screenCoroutine in viewModelScopeNo durability. Dies with the screen and the process.
Must run at an exact wall-clock timeAlarmManager exact alarmsRequires a special permission; only alarms and calendar-type use cases are policy-defensible.
Server-initiatedFCM high-priority messageWakes the app even in Doze; abuse degrades your delivery priority.
Avoid — enqueueing duplicate work
fun scheduleSync() {
    WorkManager.getInstance(ctx).enqueue(
        OneTimeWorkRequestBuilder<SyncWorker>().build()
    )
}
// Called from onStart(). After ten launches,
// ten sync workers race each other.

Duplicate concurrent syncs cause write conflicts, duplicate analytics, and battery drain. Extremely common in real codebases.

Prefer — unique work with an explicit policy
fun scheduleSync() {
    WorkManager.getInstance(ctx).enqueueUniqueWork(
        "sync",
        ExistingWorkPolicy.KEEP,        // or REPLACE — decide deliberately
        OneTimeWorkRequestBuilder<SyncWorker>()
            .setConstraints(
                Constraints(requiredNetworkType = NetworkType.CONNECTED)
            )
            .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, SECONDS)
            .build()
    )
}

KEEP means "a sync is already pending, that's enough"; REPLACE means "the new request supersedes it". Choosing consciously — and saying why in the interview — is the L4 signal.

Chapter 31Permissions and the modern privacy surface

  • Runtime permissions are revocable at any time, including while your app is backgrounded. Every permission-gated call needs a "not granted right now" path, not just a request-at-startup flow.
  • Notifications are permission-gated from Android 13. Requesting at first launch produces the worst grant rates; request in context, after the user has done something that makes the value obvious.
  • Scoped storage and the photo picker removed the legitimate reasons to request broad media permissions. Asking for them now is both a UX cost and a Play review risk.
  • Background location requires a separate, later grant and a policy justification. Designing a feature that needs it is a product decision with a compliance cost, and a Staff engineer should say so before the design is committed.
  • Package visibility means queryIntentActivities returns a filtered list unless you declare <queries>. A "share sheet is empty on Android 11+" bug is almost always this.

Chapter 32Notifications, deep links, and app links

Deep-link routing in a 200-screen app should be a first-class subsystem, not a manifest of intent filters. The Staff-level design has four properties:

  1. One resolver. A single component maps URI → typed route, so links are testable without launching an activity.
  2. Validated input. Any ID or URL in the link is untrusted. A link that opens an in-app WebView at an arbitrary URL is a phishing vector; allow-list the host.
  3. Auth-aware. Routes declare whether they require a session; the router handles the login-then-continue flow centrally rather than each screen re-implementing it.
  4. Verified App Links. With a hosted assetlinks.json, your app opens the URL without a disambiguation dialog — and no other app can claim it.
A testable deep-link resolver
sealed interface Route {
    val requiresAuth: Boolean
    data class Product(val id: ProductId) : Route { override val requiresAuth = false }
    data class Order(val id: OrderId)     : Route { override val requiresAuth = true }
}

class DeepLinkResolver(private val allowedHosts: Set<String>) {
    fun resolve(uri: Uri): Route? {
        if (uri.host !in allowedHosts) return null          // untrusted
        val seg = uri.pathSegments
        return when {
            seg.size == 2 && seg[0] == "p" -> ProductId.parse(seg[1])?.let(Route::Product)
            seg.size == 2 && seg[0] == "o" -> OrderId.parse(seg[1])?.let(Route::Order)
            else -> null
        }
    }
}

// Unit-testable in milliseconds, no emulator:
@Test fun rejects_foreign_host() =
    assertNull(resolver.resolve(Uri.parse("https://evil.com/p/123")))

Part IV rapid recall

  • Binder transactions share a ~1 MB per-process buffer; saved state travels over it.
  • Choreographer runs input → animation → traversal per VSYNC; a 40 ms block drops consecutive frames.
  • singleTask clears the stack above it — the cause of "the deep link lost my checkout."
  • ViewModel survives rotation; SavedStateHandle survives process death; only disk survives everything.
  • adb shell am kill tests process death; force-stop does not.
  • PendingIntent must be FLAG_IMMUTABLE with an explicit component unless the platform demands otherwise.
  • Durable work is WorkManager with unique-work policy and backoff — never a bare coroutine.
  • Deep links are untrusted input; validate host and IDs in one testable resolver.

Part V

Jetpack in Depth

Why each library exists, what it was created to fix, and — the part most candidates skip entirely — when not to use it.

Chapter 33ViewModel and state ownership

ViewModel exists for exactly one reason: to hold state across configuration changes without leaking the Activity. Everything else attributed to it — "it's the presentation layer", "it's where business logic goes" — is convention, not mechanism.

The mechanism: ViewModelStore is retained across recreation by the ComponentActivity's NonConfigurationInstance. onCleared() is called when the store's owner is finished, not when it is recreated. This is why holding a Context or a View in a ViewModel leaks: the ViewModel outlives them by design.

ScopeLives as long asUse for / hazard
Activity / FragmentThat componentDefault. Simple and predictable.
Nav graphThe graph is on the back stackMulti-screen flows: checkout, onboarding. Correct answer to "how do three screens share state?"
Activity-scoped sharedThe whole activityConvenient and dangerous: becomes a global mutable object that no screen owns. The most common cause of the 2,000-line ViewModel.

Interview question · architecture, asked constantly

"Your ViewModel has grown to 2,000 lines. What do you do?"

Why interviewers ask this

Everyone has seen this file. The question is whether you reach for a mechanical split (which usually makes it worse) or diagnose why it grew.

Deep answer L4

"First I'd find out why, because the fix differs. There are four common causes and only one of them is solved by splitting the class.

  1. It's a shared activity-scoped ViewModel doing six screens' work. Fix: give each screen its own, and move genuinely-shared state into a nav-graph-scoped ViewModel or a repository. This is the most common cause.
  2. Business logic that belongs in the domain layer. Pricing rules, eligibility checks, validation. Fix: extract to plain, testable classes with no Android dependency. This shrinks the file and makes the logic unit-testable in milliseconds.
  3. Orchestration complexity that's genuinely there. A screen that really does coordinate eight sources. Fix: model it as an explicit state machine, or split into independently-testable state producers that the ViewModel combines. Do not split arbitrarily by line count — you get two files that must be read together, which is worse.
  4. Boilerplate: mapping, event plumbing, one-off UI flags. Fix: attack the pattern, not the file — a shared mapper convention or a small state-machine helper removes it from 40 ViewModels at once.

I'd also check what the file's change rate and defect rate look like. A 2,000-line ViewModel nobody touches is a low priority; a 600-line one that three teams edit weekly and that causes merge conflicts is the real problem.

What I would not do is introduce a use-case class per method to move lines out. That produces 40 one-line classes, no reduction in complexity, and an onboarding cost for everyone."

Follow-up questions

  1. "How do you split it without a two-week freeze?" — Extract domain logic first (pure, no behaviour change, coverable by tests before and after), then split screens, then decompose orchestration. Each step ships independently.
  2. "How do you stop it recurring?" — A soft limit surfaced in review, but more usefully a lint rule on ViewModel dependency count, and a template that makes the right structure the path of least resistance.
  3. "When is a big ViewModel acceptable?" — When the screen is genuinely complex and the alternative is scattering coherent logic across files that must be read together. Cohesion beats line count.

Chapter 34Lifecycle-aware collection

sequenceDiagram
  participant UI as Composable / Fragment
  participant L as Lifecycle
  participant VM as ViewModel StateFlow
  participant R as Repository (upstream)
  UI->>L: repeatOnLifecycle(STARTED)
  L->>VM: collect starts
  VM->>R: subscriptionCount 0 → 1, upstream starts
  Note over UI,R: user backgrounds the app
  L-->>VM: STOP → block cancelled, collector gone
  VM-->>R: WhileSubscribed(5s) grace, then upstream stops
  Note over UI,R: rotation instead: STOP then START inside 5s
  L->>VM: collect restarts, cached value replayed, no refetch
  

The single most consequential Jetpack detail: a coroutine launched in lifecycleScope is cancelled only at onDestroy. A flow collected there keeps running while the app is backgrounded — updating a UI nobody sees, holding a socket open, draining battery.

Avoid — collection that runs in the background
lifecycleScope.launch {
    viewModel.state.collect { render(it) }
}

// Also wrong: launchWhenStarted, which *suspends*
// the coroutine but keeps the upstream flow active.
lifecycleScope.launchWhenStarted {
    viewModel.locations.collect { ... }
}

launchWhenStarted (deprecated) pauses delivery but does not cancel the producer — so a location or socket upstream keeps running with its results buffered.

Prefer — repeatOnLifecycle
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.state.collect { render(it) }
    }
}

// Compose equivalent:
val state by viewModel.state
    .collectAsStateWithLifecycle()

repeatOnLifecycle cancels the whole block on STOP and restarts it on START, so the upstream is genuinely torn down. Paired with WhileSubscribed(5_000) upstream, a rotation costs nothing but a background trip costs nothing either.

Chapter 35Room

Room is a compile-time-verified SQLite wrapper. The interview value is in the parts that are not obvious from the docs.

  • Observable queries are table-scoped. Flow<List<T>> re-runs on any write to any table it touches, not on a change to the returned rows. A frequently-written table therefore re-queries constantly; that is a common jank source on list screens.
  • Suspend DAO functions are already main-safe. Room dispatches to its own executor.
  • @Transaction matters for relations. A @Relation query issues multiple statements; without @Transaction you can observe a torn read.
  • Migrations are permanent public API. Every schema version that shipped must have a path forward, from any version, forever — users skip releases.
Avoid — untested destructive migration
Room.databaseBuilder(ctx, Db::class.java, "app.db")
    .fallbackToDestructiveMigration()   // silent data loss
    .build()

// And a query that re-runs on every unrelated write:
@Query("SELECT * FROM messages ORDER BY ts DESC")
fun all(): Flow<List<Message>>   // 5k rows, re-read constantly

Destructive migration in a shipped app deletes user data — including a pending offline write queue. And an unbounded observable query over a hot table can dominate your CPU profile.

Prefer — real migrations, bounded queries
Room.databaseBuilder(ctx, Db::class.java, "app.db")
    .addMigrations(MIGRATION_7_8, MIGRATION_8_9)
    .build()

val MIGRATION_8_9 = object : Migration(8, 9) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE messages ADD COLUMN read INTEGER NOT NULL DEFAULT 0")
        db.execSQL("CREATE INDEX idx_messages_ts ON messages(ts)")
    }
}

@Query("SELECT * FROM messages WHERE chat_id = :id ORDER BY ts DESC LIMIT 50")
fun page(id: String): Flow<List<Message>>

Export the schema JSON, commit it, and run MigrationTestHelper over every version pair in CI. This is one of the few places where a missing test is a data-loss incident rather than a bug.

Chapter 36DataStore vs SharedPreferences

SharedPreferences has two defects that DataStore exists to fix: commit() blocks the calling thread on disk I/O, and apply(), while async, is drained synchronously on the main thread during onPause/onStop via QueuedWork — a well-documented ANR source in apps with many preference writes. There is also no error channel: a failed write is silent.

Avoid
prefs.edit().putString(KEY_TOKEN, token).commit()  // blocks main

// Reading on the main thread the first time also
// blocks: the whole file is parsed on first access.
val theme = prefs.getString("theme", "dark")

The first read loads and parses the entire XML file synchronously. On a cold start with a large prefs file this is a measurable startup cost, and it is one of the top ANR signatures in Play Vitals for older apps.

Prefer — DataStore, observed
val theme: Flow<Theme> = dataStore.data
    .catch { e ->
        if (e is IOException) emit(emptyPreferences()) else throw e
    }
    .map { Theme.from(it[THEME_KEY]) }

suspend fun setTheme(t: Theme) {
    dataStore.edit { it[THEME_KEY] = t.name }
}

Fully asynchronous, transactional, with errors as a real signal. Note the mandatory catch for IOException — DataStore surfaces read failures rather than hiding them, and forgetting this crashes on corrupt files.

When NOT to use DataStore

It has no multi-process support (a documented limitation), so a :widget or :pushservice process cannot share it safely. If you have multiple processes, you need a ContentProvider-fronted store or a database. Naming this unprompted is a strong signal — it is the kind of constraint that appears three months into a project.

Chapter 37WorkManager, Paging, Navigation

WorkManager guarantees, stated precisely

WorkManager guarantees the work will eventually run subject to its constraints, surviving process death and reboot. It does not guarantee when. On aggressively-optimising OEM firmware, deferred work can be delayed by hours or suppressed until the app is opened. Any product requirement of the form "this must happen within N minutes" cannot be met by WorkManager alone — it needs a server-side push as the trigger.

Paging 3 with a RemoteMediator — the shape interviewers expect

Network + database paging, database as source of truth
@OptIn(ExperimentalPagingApi::class)
class FeedMediator(
    private val api: FeedApi,
    private val db: AppDatabase,
) : RemoteMediator<Int, FeedEntity>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, FeedEntity>,
    ): MediatorResult = try {
        val key = when (loadType) {
            LoadType.REFRESH -> null
            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            LoadType.APPEND  -> db.keys().nextKeyFor(state.lastItemOrNull()?.id)
                ?: return MediatorResult.Success(endOfPaginationReached = true)
        }
        val page = api.feed(cursor = key, limit = state.config.pageSize)
        db.withTransaction {
            if (loadType == LoadType.REFRESH) { db.feed().clear(); db.keys().clear() }
            db.feed().insertAll(page.items.map { it.toEntity() })
            db.keys().insert(RemoteKey(page.items.lastOrNull()?.id, page.nextCursor))
        }
        MediatorResult.Success(endOfPaginationReached = page.nextCursor == null)
    } catch (e: IOException) {
        MediatorResult.Error(e)          // Paging surfaces this as LoadState.Error
    }
}

Two details that separate levels: the write is inside withTransaction so a crash mid-insert cannot leave the list and the keys table disagreeing; and REFRESH clears before inserting so a server-side reorder does not produce duplicates. Cursor-based keys rather than page numbers are also the correct choice for a feed where items are inserted at the head — page-number pagination on a live feed shows duplicates and skips items, which is a classic follow-up.

Chapter 38Dependency injection

DimensionHilt / DaggerKoinManual
Graph validationCompile time — a missing binding fails the buildRuntime — fails when the screen opensCompile time (it is just constructors)
Build costSignificant: kapt/KSP across modulesNegligibleNone
Runtime costNear zero; generated codeReflection-free but map lookups; measurable at startup with a large graphZero
ScopingRich, enforced, tied to Android componentsConvention-basedWhatever you write
Best fitLarge multi-module apps with many teamsSmall-to-medium apps, KMP-shared codeSmall apps, libraries, and the composition root of any app

The honest Staff position: the compile-time-validation argument is decisive at scale — a runtime DI failure in a rarely-visited screen reaches production, and with 40 engineers that will happen. Below roughly ten engineers, Koin's build-time saving and simplicity often wins. And for a library you publish, no DI framework at all: expose constructors and let the consumer wire them, because forcing your DI choice on consumers is a hostile API decision.

Avoid — field injection everywhere, untestable
class OrderRepository {
    @Inject lateinit var api: Api
    @Inject lateinit var db: Dao
    @Inject lateinit var clock: Clock

    init { AppComponent.inject(this) }   // service locator
}
// Cannot construct in a unit test without the graph.
// Dependencies are invisible at the call site.

Hidden dependencies, uninitialised-field crashes, and tests that need the whole DI graph to run.

Prefer — constructor injection
class OrderRepository @Inject constructor(
    private val api: Api,
    private val db: Dao,
    private val clock: Clock,
    private val io: CoroutineDispatcher,
)

// Test needs no framework at all:
val repo = OrderRepository(FakeApi(), InMemoryDao(),
    Clock.fixed(...), UnconfinedTestDispatcher())

Dependencies are visible, immutable, and the class is constructible without any container. Field injection is only for framework-instantiated types (Activity, Fragment, Worker) that you do not construct.

Part V rapid recall

  • ViewModel exists for configuration change; it outlives the Activity, so never hold a Context or View.
  • repeatOnLifecycle cancels the producer; launchWhenStarted only pauses delivery.
  • Room observable queries invalidate per table, not per row; bound them with LIMIT and indexes.
  • Every shipped schema version needs a migration path forever, tested with MigrationTestHelper.
  • SharedPreferences apply() drains synchronously at onStop — an ANR source. DataStore has no multi-process support.
  • WorkManager guarantees eventually, not soon; "within N minutes" requires a push trigger.
  • Cursor pagination, not page numbers, for live feeds; write pages and keys in one transaction.
  • Constructor injection always; field injection only for framework-constructed types.

Part VI

Jetpack Compose

The runtime, not the widget catalogue. Staff Compose questions live in composition, snapshots, stability and the three phases — because that is where the performance and correctness bugs are.

Chapter 39The runtime: slot table and composition

A composable function is not a normal function call. The Compose compiler rewrites it to accept a Composer and to emit groups into the slot table — a gap buffer that records, positionally, what was composed and what values were remembered.

Three consequences that explain most Compose behaviour:

  • Identity is positional. remember is keyed by the call site's position in the tree, not by variable name. Move the call inside an if and it is a different slot.
  • Recomposition is scoped. The runtime invalidates the smallest enclosing restartable scope that read the changed state, not the whole tree. This is why reading state as late as possible is the core performance discipline.
  • Composition is not the frame. Composition produces a description; layout and draw happen afterwards. A screen can be slow with almost no recomposition, because the cost is in measurement or drawing.
Avoid — unkeyed list identity
Column {
    messages.forEach { msg ->
        // Position-based identity. Insert at the head and
        // every item's remembered state shifts down one.
        MessageRow(msg)
    }
}

LazyColumn {
    items(messages) { MessageRow(it) }   // no key
}

Prepending one item makes every subsequent slot mismatch: animation state, expanded/collapsed flags and scroll anchors attach to the wrong rows. In a LazyColumn it also defeats item reuse.

Prefer — stable keys
LazyColumn {
    items(
        items = messages,
        key = { it.id },                 // stable identity
        contentType = { it.type },       // better reuse
    ) { MessageRow(it) }
}

// Non-lazy loops:
messages.forEach { msg ->
    key(msg.id) { MessageRow(msg) }
}

Keys let the runtime move slots rather than recreate them, preserving remembered state and enabling correct item animations.

Chapter 40The snapshot system

Compose state is built on a multi-version concurrency control system. Each snapshot sees a consistent view of all state; writes are recorded per-snapshot and applied atomically. Reading a State object inside a composition registers the reading scope as an observer of that object.

Practical implications interviewers probe:

  • You can safely read Compose state from a background thread inside Snapshot.withMutableSnapshot; the changes apply atomically or conflict.
  • snapshotFlow { } converts state reads into a cold Flow, emitting when the values read inside it change — the correct bridge from Compose state to coroutine world.
  • A MutableState holding a MutableList does not notify on list.add(), because the state object itself never changed. This is the single most common "why didn't my UI update" bug.
Avoid — mutating inside an immutable holder
var items by remember { mutableStateOf(mutableListOf<Item>()) }

fun add(i: Item) {
    items.add(i)      // no recomposition: the State never changed
}

The reference is identical, so no invalidation is recorded. Worse, it works intermittently — an unrelated recomposition later shows the new items, so it looks like a race.

Prefer — observable list, or replace the value
// Option A: observable collection
val items = remember { mutableStateListOf<Item>() }
items.add(i)                              // notifies

// Option B: immutable value, replaced
var items by remember { mutableStateOf(persistentListOf<Item>()) }
items = items.add(i)                      // new reference

// Option C (preferred for screen state): it lives in the
// ViewModel as an immutable UiState, not in the composable.

Option C is the architectural answer: screen state belongs in a state holder, and the composable receives an immutable snapshot of it.

Chapter 41Effects — which one, and why

APIRunsUse for / the bug it prevents
LaunchedEffect(key)Coroutine on entering composition; cancelled and restarted when key changesSuspending work tied to composition. The bug: using Unit as the key when the work depends on a parameter.
DisposableEffect(key)On enter; onDispose on leave or key changeRegistering listeners, sensors, receivers. The bug: forgetting cleanup, which leaks.
SideEffectAfter every successful compositionPublishing state to a non-Compose object. The bug: doing it inline in the composable body, which runs on failed compositions too.
produceStateCoroutine producing a StateConverting a callback/suspend source into state.
rememberUpdatedStateCaptures the latest value without restartingA long-lived effect that must call the newest lambda. The bug it fixes: a stale callback in a timer.
derivedStateOfRecomputes only when its result changesCheap derivation from rapidly-changing state. Misused, it costs more than it saves.
Avoid — stale lambda in a long-lived effect
@Composable
fun AutoDismiss(onDismiss: () -> Unit) {
    LaunchedEffect(Unit) {
        delay(5_000)
        onDismiss()      // captured on first composition
    }
}
// If the parent recomposes with a new onDismiss
// (a different navigation target), the OLD one fires.

Keying on onDismiss instead would restart the 5-second timer on every recomposition — the other half of the trap.

Prefer — rememberUpdatedState
@Composable
fun AutoDismiss(onDismiss: () -> Unit) {
    val current by rememberUpdatedState(onDismiss)
    LaunchedEffect(Unit) {
        delay(5_000)
        current()        // always the latest
    }
}

The effect keeps running; the reference it calls is refreshed. This exact question appears in Compose interviews constantly.

Interview question · the derivedStateOf trap

"When can derivedStateOf make performance worse?"

Short answer

When the derivation's result changes as often as its inputs. You then pay the snapshot-observation overhead and the extra state object for no reduction in recomposition.

Deep explanation

derivedStateOf earns its cost when a high-frequency input maps to a low-frequency output — the canonical case being scrollState.firstVisibleItemIndex > 0, where the index changes on every frame of a scroll but the boolean changes twice per session. If instead you write derivedStateOf { items.filter { it.matches(query) } }, the list changes whenever the query changes, so the output frequency equals the input frequency: you have added an observation layer and an allocation with no benefit. Worse, people reach for it to avoid recomputing an expensive derivation, which it does not do — it still recomputes on every input change; it only suppresses downstream invalidation. For expensive work you want remember(key), or better, the work moved out of composition entirely.

Follow-up questions

  1. "So what's the rule?" — Use it when reads are frequent, the derivation is cheap, and the output is coarse relative to the input.
  2. "How would you verify it helped?" — Layout Inspector recomposition counts before and after, on the specific composable you expected to stop recomposing. If the count is unchanged, remove it.
  3. "What about filtering a big list per keystroke?" — Not a Compose problem. That belongs in the ViewModel behind debounce and flatMapLatest, off the main thread.

Chapter 42Stability, skipping, and strong skipping

The runtime skips recomposing a composable when all its parameters are equal and stable. Stable means: the type's equals is consistent, its public properties do not change without notifying composition, and all its public property types are themselves stable.

The compiler infers stability. It cannot infer it for types it cannot see — which historically made every class from a module without the Compose compiler unstable, including List, Set and Map (the interface could be backed by a mutable implementation).

Avoid — unstable parameters
data class UiState(
    val items: List<Item>,          // interface: unstable
    val onClick: (Item) -> Unit,     // new lambda each recomposition
    val config: ConfigFromOtherModule // not Compose-compiled: unstable
)

@Composable fun Screen(state: UiState) { ... }
// Never skips. Recomposes on every parent recomposition.

One unstable parameter makes the whole call unskippable, and the effect cascades down the subtree.

Prefer — stable by construction
@Immutable
data class UiState(
    val items: ImmutableList<Item>,  // kotlinx.collections.immutable
    val config: Config,
)

@Composable
fun Screen(
    state: UiState,
    onClick: (Item) -> Unit,         // hoisted, remembered by caller
)

// For third-party types you cannot annotate:
// stability configuration file (Compose compiler option)
// listing them as stable.

ImmutableList is a real type the compiler can reason about. @Immutable is a promise you make and the compiler trusts — breaking it produces stale UI that is very hard to debug.

Strong skipping — what changed

With strong skipping mode (default from Compose 1.3.x-era compiler releases onward and standard in current toolchains), composables with unstable parameters become skippable using instance equality for unstable types, and lambdas are automatically remembered. This removes most of the manual @Stable work. Two things it does not remove: unstable types still compare by reference, so a newly-allocated equal object still triggers recomposition; and it does not fix a List you mutate in place. The L4 answer notes that strong skipping reduces the tax but does not make stability irrelevant — you still model state as immutable values.

Chapter 43State hoisting and screen architecture

flowchart TD
  VM["CartViewModel"] -- "StateFlow<CartUiState>" --> R["CartRoute — stateful"]
  VM -- "Channel<CartEvent>" --> R
  R -- "state: CartUiState" --> S["CartScreen — stateless"]
  S -- "onIntent(CartIntent)" --> R
  R -- "viewModel::onIntent" --> VM
  S --> P["@Preview"]
  S --> T["Screenshot test"]
  

The split exists so the two boxes on the right are reachable: a stateless screen can be rendered from a hand-built state with no ViewModel, DI or navigation, which is the only affordable way to cover 200 screens' visual states.

The screen contract that scales across 200 screens
// 1. State: immutable, exhaustive, no events inside.
@Immutable
data class CartUiState(
    val lines: ImmutableList<CartLine> = persistentListOf(),
    val total: Money = Money.ZERO,
    val isSubmitting: Boolean = false,
    val error: CartError? = null,
)

// 2. Stateful entry point: knows about the ViewModel.
@Composable
fun CartRoute(
    onCheckout: (OrderId) -> Unit,
    viewModel: CartViewModel = hiltViewModel(),
) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    LaunchedEffect(Unit) {
        viewModel.events.collect { e ->
            when (e) { is CartEvent.Checkout -> onCheckout(e.orderId) }
        }
    }
    CartScreen(state = state, onIntent = viewModel::onIntent)
}

// 3. Stateless UI: previewable, screenshot-testable, no DI.
@Composable
fun CartScreen(
    state: CartUiState,
    onIntent: (CartIntent) -> Unit,
    modifier: Modifier = Modifier,
) { /* pure UI */ }

Why this shape rather than a single composable: the stateless layer can be rendered in a @Preview and a screenshot test with a hand-built state, which is the only affordable way to test 200 screens' visual states; and the route layer isolates every framework dependency, so the UI module does not depend on Hilt or navigation.

CompositionLocal — when it is right

Legitimate uses: theme, typography, density, a locale-aware formatter — things that are ambient, rarely change, and would pollute every signature. Illegitimate: passing a ViewModel, a repository, or screen data. The test: if a reader of the composable would be surprised that the value can change the output, it should be a parameter. Implicit data flow is the thing Compose's model exists to remove.

Chapter 44The three phases: composition, layout, drawing

Each frame runs up to three phases. State read in a later phase does not invalidate the earlier ones — which is the single highest-leverage Compose performance technique.

flowchart LR
  S["State change"] --> C["Composition
what to show"] C --> L["Layout
measure & place"] L --> D["Drawing
render commands"] D --> F["Frame"] S -. "read in Modifier.offset { }" .-> L S -. "read in graphicsLayer { }" .-> D

The dotted paths are the point: a state value read inside a lambda-taking modifier is read during layout or draw, so a change to it re-runs only that phase. The same value read in the composable body invalidates all three.

Avoid — reading scroll state in composition
val offset = scrollState.firstVisibleItemScrollOffset

Box(
    Modifier.offset(y = (offset / 2).dp)   // recomposes every frame
        .alpha(1f - offset / 500f)
)

The state is read during composition, so every scroll pixel recomposes this subtree — composition, layout and draw, sixty times a second.

Prefer — defer the read to layout/draw
Box(
    Modifier
        .offset { IntOffset(0, scrollState.offset / 2) }  // lambda: layout phase
        .graphicsLayer { alpha = 1f - scrollState.offset / 500f } // draw phase
)

The lambda-taking overloads read the state inside the layout or draw phase, so only that phase re-runs. Same visual result, a fraction of the cost — and it is the standard answer to "this parallax scroll is janky."

Chapter 45Lazy layouts and a 10,000-item feed

The question "how would you optimise a LazyColumn rendering 10,000 items?" has a wrong premise, and saying so is the first point scored: you never render 10,000 items — lazy layouts compose only what is visible. If it is slow, the problem is per-item cost, key stability, or something above the list.

  1. Do not page the whole dataset into memory. Use Paging 3 so the list holds a window, not the corpus.
  2. Provide key and contentType. Keys preserve state and enable animation; content types make reuse effective across heterogeneous rows.
  3. Make item state immutable and stable, so scrolling does not recompose visible items.
  4. Fix the item's own cost: no allocation in the item body, no date formatting per frame, image sizes constrained so decode does not resize on the main thread.
  5. Never nest a scrollable in the same direction — a LazyColumn inside a vertically-scrolling Column forces infinite-constraint measurement and composes every item.
  6. Baseline profile the scroll path, otherwise the first scroll runs interpreted and janks on exactly the impression that matters.
Avoid — expensive work in the item body
items(rows, key = { it.id }) { row ->
    val fmt = SimpleDateFormat("dd MMM", Locale.getDefault())
    Text(fmt.format(row.date))            // alloc + parse per item per frame
    AsyncImage(model = row.url)           // unbounded size
}

Formatter allocation and image decode dominate the frame. This is what "the list is janky but recomposition counts look fine" actually means.

Prefer — precomputed, bounded
// Formatting happens once, in the mapper, off the main thread:
data class RowUi(val id: String, val dateLabel: String, val url: String)

items(rows, key = { it.id }, contentType = { it.type }) { row ->
    Text(row.dateLabel)
    AsyncImage(
        model = ImageRequest.Builder(ctx).data(row.url)
            .size(width = 320, height = 180)   // decode to display size
            .crossfade(false)
            .build(),
        modifier = Modifier.size(160.dp, 90.dp),
    )
}

Move every derivation out of the item body into the state mapping. The item body should read fields and emit UI — nothing else.

Chapter 46Recomposition debugging

Compose compiler metrics — the first tool, not the last
// build.gradle.kts
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}

// Produces, per module:
//   *-composables.txt  → restartable / skippable per function
//   *-classes.txt      → stable / unstable per class, with the reason
//
// Read the classes file first: one unstable class usually
// explains a dozen unskippable composables.

Interview question · production debugging

"A Compose screen is slow, but Layout Inspector shows a reasonable recomposition count. What now?"

Why interviewers ask this

Because the reflexive Compose answer is "reduce recomposition," and a candidate who has only that answer is stuck. The question tests whether they understand the three phases and can profile rather than guess.

Deep answer L4

"Recomposition count only measures the first phase. I'd get a system trace — Perfetto or the Studio profiler — on a physical mid-tier device with a release build, and look at where the main thread's frame time actually goes. Five candidates in rough order of frequency:

  • Layout cost. Deeply nested or intrinsic-measuring layouts, or a SubcomposeLayout in a hot path. Intrinsics can force multiple measurement passes; nested weights compound it.
  • Draw cost. Overdraw, large shadows/elevation, blur, or a Canvas re-rendering complex paths every frame. graphicsLayer with a cached layer often fixes this.
  • Work off the composition path entirely. Main-thread image decode, a synchronous disk read in a mapper, a Flow collecting on Main and doing real work in its lambda.
  • Missing baseline profile. If it is slow only for the first few seconds or the first scroll, this is almost always it — JIT versus AOT.
  • Recomposition cost rather than count. Twenty recompositions is fine unless each one allocates a list of 5,000 items.

The verification discipline matters as much as the fix: I'd add a Macrobenchmark for the scroll journey and gate the improvement in CI, so the regression cannot silently return. That's the difference between fixing this instance and fixing the class."

Follow-up questions

  1. "How do you measure jank in the field, not the lab?" — JankStats, and Play Vitals' frame-timing metrics segmented by device tier. Lab numbers on a flagship hide the problem entirely.
  2. "What's your device policy for performance work?" — Measure on the 25th-percentile device in your install base, not the newest. Most performance bugs are invisible on a current Pixel.
  3. "Would you ever move UI back to Views for performance?" — Rarely, and only with a measurement. The legitimate cases are highly specialised custom rendering; the illegitimate case is a Compose screen nobody profiled.

Chapter 47Testing, semantics, and accessibility

Compose's semantics tree is simultaneously the accessibility tree and the test tree — a design decision with a useful consequence: a screen that is hard to test is usually inaccessible. Treating them as the same problem is a Staff framing worth stating.

Avoid — untestable and inaccessible
Box(
    Modifier
        .size(32.dp)                    // below 48dp touch target
        .clickable { onDelete() }       // no role, no label
) { Icon(Icons.Default.Delete, contentDescription = null) }

// Test must resort to:
composeRule.onNodeWithTag("del_3").performClick()

Screen readers announce nothing, the target fails WCAG's minimum size, and the test is coupled to an arbitrary tag that says nothing about intent.

Prefer — semantics carry meaning
IconButton(                             // 48dp target, Role.Button
    onClick = onDelete,
    modifier = Modifier.semantics {
        contentDescription = "Delete ${item.name}"
    }
) { Icon(Icons.Default.Delete, contentDescription = null) }

// Test reads like the user's intent:
composeRule.onNodeWithContentDescription("Delete Milk")
    .assertHasClickAction()
    .performClick()

The same annotation serves TalkBack and the test. Note contentDescription = null on the inner icon: the button already carries the label, and duplicating it makes TalkBack say it twice.

Part VI rapid recall

  • Slot-table identity is positional; key is what makes it semantic.
  • Mutating a collection inside mutableStateOf does not notify — use mutableStateListOf or replace the value.
  • rememberUpdatedState for a fresh lambda in a long-lived effect; keying on the lambda restarts it instead.
  • derivedStateOf only pays when a high-frequency input yields a low-frequency output.
  • Strong skipping reduces the stability tax but does not replace immutable state modelling.
  • Route (stateful) / Screen (stateless) split is what makes previews and screenshot tests affordable.
  • Defer state reads to layout (Modifier.offset {}) or draw (graphicsLayer {}) to skip phases.
  • Slow screen with low recomposition = layout, draw, main-thread work, or a missing baseline profile.
  • The semantics tree is the accessibility tree; test through it, not through tags.

Part VII

Android Architecture

Architectures compared honestly, including the many cases where the more sophisticated option is the wrong one. The recurring test: can you justify a boundary by what it prevents, not by what it is called?

Chapter 48Boundaries: what they cost and what they buy

Every layer boundary buys one thing — the ability to change one side without the other — and charges three: indirection, mapping code, and a decision every engineer must make correctly ("where does this go?"). A boundary is justified when the change it isolates is likely and the cost of the change without it is high.

BoundaryIsolatesWorth it when
UI ↔ state holderRendering from logicAlways. It is what makes the UI testable and previewable.
Domain ↔ dataBusiness rules from storage and transportThe rules are non-trivial, reused across screens, or outlive the current backend.
Domain models ≠ DTOsYour model from the server's schemaThe API is owned by another team, versioned, or shaped for the backend's convenience.
Feature ↔ featureTeams from each otherMore than ~3 teams. Below that, the coordination cost exceeds the benefit.
The anaemic-layer smell

If your UserDomainModel has exactly the same fields as UserDto and UserUiModel, and the mappers are field-for-field copies, that boundary is currently costing you three files per entity and buying nothing. The honest Staff position: keep the boundary where the schemas have diverged or are expected to; collapse it where they have not. "We always map" and "we never map" are both dogma.

Chapter 49Clean Architecture, honestly assessed

flowchart LR
  subgraph UI["UI layer"]
    C["Composable"] --> VM["ViewModel / state holder"]
  end
  subgraph D["Domain layer — pure Kotlin, no Android"]
    UC["UseCase"] --> RI["Repository interface"]
  end
  subgraph DA["Data layer"]
    RImpl["Repository impl"] --> R["Remote source"]
    RImpl --> L["Local source"]
  end
  VM --> UC
  RImpl -. "implements" .-> RI
  

The arrow that matters is the dotted one: the data layer depends on the domain's interface, not the reverse. That single inversion is 80% of the value — the domain becomes a pure-Kotlin module with no Android dependency, so its tests run in milliseconds on the JVM and it can be shared with KMP later.

ClaimReality at scale
"It's testable"True, and the biggest real win: a pure domain module has no Robolectric, no emulator, no Dispatchers.Main.
"You can swap the database"Almost never happens. Do not justify the architecture with this; interviewers have heard it and it signals repetition rather than experience.
"It scales to large teams"True — but the mechanism is module boundaries and ownership, not the layer names. You can get most of it with fewer layers.
"Use cases document the domain"True when they contain logic. False when they are one-line pass-throughs, which is what most codebases actually have.
Cost3–5 files per feature slice, a mapping tax, and a genuine onboarding cost. On a 6-screen app with 3 engineers, this is a net negative.
Avoid — ceremony with no content
class GetUserUseCase(private val repo: UserRepository) {
    suspend operator fun invoke(id: String) = repo.getUser(id)
}
class GetOrdersUseCase(private val repo: OrderRepository) {
    suspend operator fun invoke() = repo.getOrders()
}
// 40 of these. Each adds a file, a DI binding,
// a test that asserts delegation, and zero logic.

Pure indirection. It also actively hides the real dependency graph, because the ViewModel now depends on 8 use cases instead of 2 repositories.

Prefer — a use case that earns its existence
/**
 * Combines entitlement, local drafts and remote orders,
 * applies the refund-eligibility rules, and is used by
 * three screens.
 */
class GetRefundableOrdersUseCase(
    private val orders: OrderRepository,
    private val entitlements: EntitlementRepository,
    private val clock: Clock,
) {
    operator fun invoke(): Flow<List<RefundableOrder>> =
        combine(orders.all(), entitlements.current()) { os, ent ->
            os.filter { it.isRefundable(ent, clock.now()) }
              .map { it.toRefundable() }
        }
}
// Elsewhere, the ViewModel calls repo.getUser(id) directly.

The rule: introduce a use case when there is logic, multiple sources, or reuse. Otherwise let the state holder call the repository. Mixing both is not inconsistency — it is proportion.

Chapter 50MVVM vs MVI

DimensionMVVM (state + methods)MVI (state + intents + reducer)
StateOne or several observable propertiesExactly one immutable state object
InputPublic methods on the ViewModelA single onIntent(Intent) entry point
TraceabilityHarder — state can change from anywhere in the classExcellent — every transition passes through one reducer; trivially loggable and replayable
BoilerplateLowHigher: intent, reducer, effect types per screen
TestingCall method, assert stateFeed intents, assert state sequence; reducer testable as a pure function
Best forMost screens: forms, detail pages, settingsComplex flows with many interacting inputs — checkout, editors, multi-step wizards, anything with concurrency

The trap: "MVI is better" is not an answer. Both put immutable state in a holder and drive UI unidirectionally; the difference is whether input is funnelled through one channel. That funnel buys traceability and costs boilerplate. On a settings screen it is pure cost; on a checkout flow with payment, address validation and inventory reservation racing each other, the replayable transition log is worth the files.

A pragmatic middle ground used by many large apps
class CheckoutViewModel(...) : ViewModel() {

    private val _state = MutableStateFlow(CheckoutUiState())
    val state: StateFlow<CheckoutUiState> = _state.asStateFlow()

    private val _events = Channel<CheckoutEvent>(Channel.BUFFERED)
    val events = _events.receiveAsFlow()

    // Single input channel (MVI), but no ceremony reducer class:
    fun onIntent(intent: CheckoutIntent) {
        when (intent) {
            is SetAddress  -> _state.update { it.copy(address = intent.a, error = null) }
            is Submit      -> submit()
            is Retry       -> submit()
        }
    }

    private fun submit() = viewModelScope.launch {
        _state.update { it.copy(isSubmitting = true) }
        submitOrder(_state.value.toRequest())
            .onSuccess { _events.send(CheckoutEvent.Done(it.orderId)) }
            .onFailure { e -> _state.update {
                it.copy(isSubmitting = false, error = e.toCheckoutError()) } }
    }
}

Chapter 51UI state modelling and state machines

Where do loading and error live? The question is really "is this state or an event?" — and the answer follows a rule: if it should still be true after rotation, it is state; if it should happen exactly once, it is an event.

  • A full-screen loading spinner: state.
  • A validation error under a field: state.
  • A one-off snackbar or navigation: event.
  • A retryable network failure that replaces the content: state (with a retry intent), because it must survive rotation.
Avoid — global loading flag over a partial screen
data class ProfileUi(
    val isLoading: Boolean,
    val user: User?,
    val orders: List<Order>?,
    val error: String?,
)
// User loads in 80ms, orders in 2s.
// The whole screen spins for 2s, and the
// "user is null but not loading" case is unrepresentable-but-reachable.

One flag for several independent sources produces either an over-eager spinner or a lying UI.

Prefer — per-section state
@Immutable
data class ProfileUi(
    val header: Section<User> = Section.Loading,
    val orders: Section<ImmutableList<Order>> = Section.Loading,
)

sealed interface Section<out T> {
    data object Loading : Section<Nothing>
    data class Ready<T>(val value: T) : Section<T>
    data class Failed(val retryable: Boolean) : Section<Nothing>
}

Each region renders independently, partial failure degrades gracefully, and every combination in the type is legal. This is what "progressive rendering" means concretely.

Chapter 52The repository under pressure

Interview question · the most-asked architecture question

"Should repositories expose Flow or suspend functions?"

Why interviewers ask this

It looks like a style question and is actually a question about where your app's reactive boundary sits — a decision that is expensive to reverse.

Short answer

Expose Flow when the caller must observe change over time; suspend when it is a request/response. Most repositories legitimately have both.

Deep explanation L4

"The decision criterion is the source of truth. If the data lives in a local store that other actors write to — a sync worker, a push handler, another screen — then a one-shot read is a bug waiting to happen: the screen shows a value that is already stale and has no way to learn. That is a Flow, backed by an observable query.

If the operation is a command — submit an order, refresh, upload — it is a suspend function returning a result. Modelling a command as a Flow means the caller must remember to terminate it, and errors become emissions instead of exceptions, which loses structured concurrency's error propagation.

Three failure modes I'd call out. First, cold flows without sharing: five screens collecting repo.user() means five network calls, because Flow is cold — you need shareIn/stateIn at the right level or a single-source-of-truth database. Second, Flow<Result<T>> everywhere: it leaks the transport's failure vocabulary into the domain and makes every operator awkward; prefer a domain error type, and only where partial failure is a real state. Third, ownership: a Flow exposed from a singleton repository and shared with an app scope will keep running when no screen is watching unless SharingStarted says otherwise.

At the organisational level, this needs to be a written rule, not per-engineer taste. In a 300-module codebase, inconsistency here means every new engineer relitigates it in review. I'd write it as: observable state → Flow; commands → suspend; never Flow for one-shot reads; sharing configured at the ViewModel unless the data is genuinely app-wide — and back it with a lint rule where it can be mechanically checked."

Follow-up questions

  1. "Where should the loading state be produced?" — Not in the repository. The repository emits data or throws; the state holder converts that into Loading/Ready/Failed, because loading is a UI concept.
  2. "Should the repository return domain models or entities?" — Domain models. Leaking Room entities means @Entity annotations and column names reach the UI, and a schema migration becomes a UI change.
  3. "How do you test a repository that exposes Flow?" — Turbine, with a fake DAO backed by MutableStateFlow so you can drive emissions deterministically, and an injected dispatcher.
  4. "What if the backend has no push and data can only change by polling?" — Then the observability is manufactured, and you should be honest about it: a Flow that polls hides a cost, so make the refresh explicit and let the caller decide the cadence.

Chapter 53Architecting a 200-screen application

This is a common Staff design prompt. The trap is designing the screen architecture; what is being asked is the system that lets 40 teams build 200 screens without collision.

flowchart TD
  App[":app — assembly only"] --> F1[":feature:cart"]
  App --> F2[":feature:search"]
  App --> F3[":feature:orders"]
  F1 --> NavApi[":core:navigation-api"]
  F2 --> NavApi
  F3 --> NavApi
  F1 --> DomC[":core:domain-cart"]
  F3 --> DomO[":core:domain-orders"]
  DomC --> DataC[":core:data-cart"]
  DomO --> DataO[":core:data-orders"]
  DataC --> Net[":core:network"]
  DataO --> Net
  DataC --> DB[":core:database"]
  DataO --> DB
  F1 --> DS[":core:designsystem"]
  F2 --> DS
  F3 --> DS
  

Seven decisions that constitute the answer:

  1. Feature modules never depend on each other. Cross-feature navigation goes through a route contract in :core:navigation-api; the implementation is wired in :app. This is the single most important rule and the one most often broken.
  2. One screen contract (Route/Screen/UiState/Intent), generated from a template, so a reviewer moving between features recognises the shape instantly.
  3. Data ownership is explicit. Each domain concept has exactly one owning module and one source of truth. Two modules caching entitlement is the bug class from Part I.
  4. The design system is a real module with an API review. Otherwise 200 screens produce 200 buttons.
  5. Boundaries are enforced mechanically — dependency rules in the build logic, a lint rule for illegal imports. A documented rule is a suggestion.
  6. State scoping is defined: screen state in a ViewModel; flow state in a nav-graph ViewModel; app state in a repository with a single source of truth. No activity-scoped shared ViewModels.
  7. The paved road is a generator. New feature = one command producing module, DI wiring, screen contract, tests and a CI entry. Consistency at 200 screens comes from defaults, not review.

Chapter 54Multi-surface architecture

Foldables, tablets, Wear, TV and Auto do not need parallel apps; they need a shared domain and a deliberately separate presentation layer.

  • Share: domain, data, sync, auth. These are surface-independent and duplicating them causes divergent bugs.
  • Do not share: navigation topology, layout, input model. A list-detail on a foldable is a two-pane layout with one back-stack entry; on a phone it is two entries. Forcing one navigation model onto both produces the "back button does the wrong thing on a tablet" bug.
  • Drive layout from window size classes, never from isTablet booleans or screen-width dp thresholds sprinkled through the code — a folding phone changes class at runtime, and a resizable window on a large screen changes it continuously.
  • Test the transition, not just the states. Fold/unfold mid-flow is where state loss appears, because it is a configuration change plus a layout topology change at once.

Part VII rapid recall

  • Justify a boundary by the change it isolates; collapse boundaries whose mappers are field-for-field copies.
  • Clean Architecture's real win is a pure-Kotlin domain with millisecond tests, not database swappability.
  • Introduce a use case for logic, multiple sources, or reuse — not per repository method.
  • MVI buys traceability and costs boilerplate; use it where transitions are complex, not everywhere.
  • State survives rotation; events happen once. Per-section state beats one global loading flag.
  • Flow for observable data, suspend for commands; configure sharing or pay N calls for N collectors.
  • Features must not depend on features; route contracts plus assembly in :app.
  • Multi-surface: share domain, diverge navigation; drive layout from window size classes.

Part VIII

Modularization at Scale

Module structure is an organisational instrument as much as a technical one. Every decision here is simultaneously a build-time decision, an ownership decision, and a decision about who can break whom.

Chapter 55Module taxonomy and dependency rules

Module typeMay depend onOwns
:appEverythingAssembly, DI root, navigation wiring. Contains almost no logic.
:feature:*:core:* only — never another featureScreens, state holders, feature-local navigation.
:core:domain-*Nothing AndroidEntities, rules, repository interfaces. Pure Kotlin, JVM tests.
:core:data-*Its domain module, :core:network, :core:databaseRepository implementations, DTOs, mappers, caching policy.
:core:designsystemNothing app-specificTheme, components. Reviewed like a public API.
:core:*-api / :core:*-implAPI depends on nothing; impl depends on apiThe seam that keeps the graph shallow.
:test-fixturesThe module it fixturesFakes and builders shared by consumers' tests.
Avoid — api leaks the whole graph
// core/data-cart/build.gradle.kts
dependencies {
    api(project(":core:network"))     // leaks Retrofit to every consumer
    api(project(":core:database"))    // leaks Room entities too
    api(libs.okhttp)
}

Every consumer now compiles against Retrofit and Room. An OkHttp bump recompiles the world, and a feature engineer can accidentally call the network directly from a composable.

Prefer — implementation by default
dependencies {
    // Only types that appear in this module's public signatures:
    api(project(":core:domain-cart"))

    implementation(project(":core:network"))
    implementation(project(":core:database"))
    implementation(libs.okhttp)
}

api only for types in your public API surface. This both shrinks the compile classpath — a large build-time win — and makes the illegal call a compile error rather than a review comment.

Chapter 56Breaking cycles: feature-to-feature navigation

The recurring problem: :feature:cart must open a product detail owned by :feature:catalog, and :feature:catalog must open the cart. A direct dependency in both directions is a cycle Gradle refuses.

flowchart TD
  subgraph BAD["cycle — Gradle refuses"]
    C1[":feature:cart"] --> C2[":feature:catalog"]
    C2 --> C1
  end
  subgraph GOOD["contract owned by nobody's team"]
    A[":app — the only module that knows both"] --> D1[":feature:cart"]
    A --> D2[":feature:catalog"]
    D1 --> NAV[":core:navigation-api
sealed Dest, interface Navigator"] D2 --> NAV end
Avoid — the "shared" escape hatch
// Everyone depends on :core:shared, which grows to
// hold ProductScreen, CartScreen, the analytics client,
// the date formatter and 400 other things.
:feature:cart    -> :core:shared
:feature:catalog -> :core:shared

The cycle is technically gone and the coupling is worse: :core:shared is now a single point that every team edits and every build recompiles. This is the most common failure of a first modularization attempt.

Prefer — a route contract, assembled at the top
// :core:navigation-api  (depends on nothing)
sealed interface Dest {
    data class Product(val id: String) : Dest
    data object Cart : Dest
}
interface Navigator { fun go(dest: Dest) }

// :feature:cart — depends only on the api
class CartViewModel(private val nav: Navigator) {
    fun onLineClick(id: String) = nav.go(Dest.Product(id))
}

// :app — the only module that knows both features
navController.graph {
    composable<Dest.Product> { ProductRoute(...) }
    composable<Dest.Cart>    { CartRoute(...) }
}

Features depend on an abstraction owned by nobody's team, and :app — which already depends on everything — does the wiring. The graph stays a DAG and stays shallow.

Chapter 57Build performance and graph shape

Two graphs with the same module count can differ by minutes per build. What matters:

  • Depth beats width. A deep chain serialises: nothing downstream compiles until upstream finishes. A wide, shallow graph parallelises across cores. Prefer many small leaf modules over a five-level pyramid.
  • ABI stability decides incremental cost. Changing a function body recompiles one module; changing a public signature recompiles every dependent. This is why implementation and api/impl splits pay off — they shrink the set of modules that can be invalidated.
  • Annotation processing is the usual bottleneck. Migrating kapt → KSP is typically the single largest available win in a Dagger/Room codebase.
  • Measure before restructuring. A build scan tells you which tasks dominate; intuition is reliably wrong. Restructuring a graph on a hunch is weeks of work for a possible regression.
Diagnosing, in order
# 1. Where does the time actually go?
./gradlew assembleDebug --scan            # task timeline, cache hits/misses

# 2. Is the configuration phase the problem? (large graphs: often yes)
./gradlew help --scan                     # config time with no work

# 3. What does a one-line change actually recompile?
touch core/domain-cart/src/.../Cart.kt
./gradlew assembleDebug --scan            # count invalidated modules

# 4. Turn on the cheap wins before restructuring anything:
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true

Chapter 58Convention plugins and version catalogs

Avoid — 180 copies of the same build file
// repeated in every module, drifting over time
android {
    compileSdk = 35
    defaultConfig { minSdk = 24 }
    compileOptions { /* ... */ }
}
kotlin { jvmToolchain(17) }
dependencies {
    implementation("androidx.core:core-ktx:1.13.1")  // version drift
}

Upgrading the compile SDK becomes a 180-file pull request, and three modules will be missed. Version strings drift, producing duplicate transitive versions and confusing resolution failures.

Prefer — convention plugin + catalog
// build-logic/.../AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) = with(target) {
        pluginManager.apply("com.android.library")
        pluginManager.apply("org.jetbrains.kotlin.android")
        configureKotlinAndroid()
        dependencies {
            add("implementation", project(":core:designsystem"))
            add("implementation", libs.findLibrary("hilt").get())
        }
    }
}

// feature/cart/build.gradle.kts — the whole file
plugins { id("myapp.android.feature") }

One place to change the toolchain. New modules are correct by construction, which is what makes a paved road real rather than documented.

Chapter 59Exercise — a travel app, 100+ engineers

Design exercise · 45 minutes

"Design the module architecture for a travel app: flights, hotels, cars, packages, loyalty, payments. 100+ engineers across 12 teams. Current state: one 400k-line module, 25-minute builds."

Requirements to establish first

Before drawing anything, a Staff candidate pins down: how are teams organised (by vertical or by layer)? Is there a shared booking flow, or four independent ones? Is the backend one API or per-vertical? What is the release cadence, and does every team ship on the same train? These change the answer materially — if teams are organised by layer rather than vertical, module-per-vertical will fight the org chart and lose.

Target structure

Ownership-aligned, four tiers deep maximum
:app                                  platform team — assembly only

:vertical:flights:feature-search      flights team
:vertical:flights:feature-booking     flights team
:vertical:flights:domain              flights team  (pure Kotlin)
:vertical:flights:data                flights team
:vertical:hotels:*                    hotels team
:vertical:cars:*                      cars team

:shared:booking-flow-api              platform (contract only)
:shared:payments-api / -impl          payments team
:shared:loyalty-api / -impl           loyalty team
:shared:identity-api / -impl          identity team

:core:designsystem                    design-systems team, API-reviewed
:core:network :core:database          platform team
:core:navigation-api                  platform team
:test:fixtures-*                      per owning team

The five rules that make it hold

  1. No vertical depends on another vertical. Packages (a flight + hotel bundle) is itself a vertical that depends on the shared contracts, not on the flights and hotels features.
  2. Shared capabilities are api/impl pairs. Verticals compile against payments-api; only :app sees payments-impl. A payments refactor then recompiles one module rather than twelve.
  3. One owner per module, encoded in CODEOWNERS. A module with no owner becomes everyone's dumping ground within two quarters.
  4. Boundaries enforced in build logic, failing the build on an illegal dependency — not in a wiki.
  5. The booking flow is a contract, not a shared implementation. Each vertical implements the steps; the shared module defines the state machine and the events. This is the decision that prevents a shared 40k-line "booking" module.

Migration sequence — no big bang

  1. Extract leaves first: design system, network, database. Zero behaviour change, immediately parallelisable, and it proves the tooling.
  2. Extract one vertical end to end — pick the team most willing, not the largest domain. Publish their build-time numbers.
  3. Add the enforcement once one vertical is clean, so new code cannot recreate the monolith while the rest migrates.
  4. Migrate the remainder team by team, each owning their own extraction with the platform team providing the template and codemods.
  5. Delete the monolith module — and treat its deletion as the completion criterion, because a half-migrated codebase carries both costs.

Success metrics, agreed before starting

  • Median incremental build after a one-line feature change: 25 min → under 3 min.
  • Percentage of PRs touching more than one team's modules: target under 10%.
  • Time to first commit for a new hire.
  • CI wall-clock for a feature-only change (should require only affected-module tests).

Follow-up questions to expect

  1. "What if two verticals need the same UI component?" — It goes into the design system with an API review, or it is duplicated deliberately. Duplication between verticals is cheaper than a shared module owned by nobody; say this explicitly, because interviewers expect DRY reflexes and reward the nuance.
  2. "How do you handle a shared entity like User?" — One identity domain module owning the canonical model; verticals map it into their own view if they need extra fields. Do not let verticals add fields to the shared model.
  3. "What about dynamic feature modules?" — Only if install size is a demonstrated conversion problem. They add substantial complexity to DI, navigation and testing; the default answer is no, with data.
  4. "How long?" — Give a real shape: leaves in a quarter, first vertical in a quarter, the rest over three to four quarters at roughly one vertical per team per quarter alongside feature work. A candidate who says "six weeks" has not done it.

Part VIII rapid recall

  • implementation by default; api only for types in your public signatures.
  • Cycles are broken with contracts assembled at :app, never with a :core:shared catch-all.
  • Shallow and wide parallelises; deep serialises. ABI changes, not body changes, drive recompilation.
  • kapt → KSP is usually the largest single build win available.
  • Convention plugins plus version catalogs; module correctness by construction.
  • One owner per module in CODEOWNERS; enforcement in build logic, not documentation.
  • Deliberate duplication between verticals beats an unowned shared module.

Part IX

Networking

From TCP behaviour on a flaky cellular link to the client/server contract negotiation a Staff engineer is expected to lead. Mobile networking is a latency and failure problem, not a bandwidth problem.

Chapter 60The transport stack for mobile engineers

The facts that change client design:

  • Connection setup dominates. On LTE, DNS + TCP handshake + TLS can cost 200–600 ms before a byte of your payload moves. On a cold connection, five sequential requests cost five round-trip penalties — which is why request count matters far more than payload size on mobile.
  • Radio state is a battery cost. The cellular radio ramps to a high-power state for a transfer and stays there for a tail period (seconds) afterwards. Ten small requests spread over a minute cost far more battery than one batched request, even with identical bytes.
  • HTTP/2 multiplexes over one connection, removing head-of-line blocking at the HTTP layer but not at TCP. HTTP/3 (QUIC) removes it at the transport layer too and survives network changes via connection IDs — genuinely valuable when a user walks from Wi-Fi to cellular.
  • TLS 1.3 cuts the handshake to one round trip, and session resumption to zero. Enabling it is usually a server-side change with a measurable client-side latency win.
The number to quote

A useful framing for design interviews: on a typical mobile connection, assume 100–300 ms RTT, 5–15% of requests failing or timing out on cellular during movement, and a 2–5 second cost for a full cold connection setup on a poor link. Designs that assume the desktop-web reality (fast, reliable, always-on) fail on all of these.

Chapter 61REST, GraphQL, gRPC

DimensionRESTGraphQLgRPC
Payload fitFixed per endpoint; over-fetching is normalClient selects fields — the strongest mobile argumentFixed per method, but compact binary
Round tripsOften several per screenOne per screen, by designSeveral, but cheap; streaming built in
CachingHTTP caching works out of the boxNo HTTP caching (POST); needs a normalised client cacheNo HTTP caching; roll your own
Schema evolutionVersioning by convention; easy to break clientsStrongly typed, deprecation built inStrong; field numbers make it safe
Tooling/debuggabilityExcellent; readable in any proxyGood, but queries are opaque in logsBinary — needs tooling to inspect
Client costLowestNormalised cache is a real subsystemCodegen plus a larger binary

The Staff answer is per-surface, not per-company: GraphQL where a screen aggregates many resources and the field set varies by client version; gRPC for high-frequency streaming (location, telemetry) where binary size and bidirectional streams pay; REST for everything else, because its caching and debuggability are free. And name the hidden cost: a GraphQL client without a normalised cache re-fetches everything, and with one you have taken on cache-consistency bugs.

Chapter 62OkHttp and Retrofit

Interceptor placement is the detail interviewers use to check whether you have really operated this stack.

TypeSeesUse for
Application interceptorOne call, the final response; not invoked for cache hits; sees redirects as one callAuth headers, analytics, request tagging
Network interceptorEvery wire request including redirects and retries; sees the raw responseCompression, cache-header rewriting, wire-level logging
AuthenticatorOnly 401 responsesToken refresh with automatic replay of the original request
EventListenerDNS, connect, TLS, request/response timingsReal per-phase latency metrics — the right source for "is it DNS or the server?"

Chapter 63Auth, token refresh, and single-flight coordination

The most-asked networking scenario at Staff level: a token expires while five requests are in flight — design a solution that produces one refresh, not five. It is asked because the naive implementation is both obvious and wrong, and because the failure mode (users randomly logged out) is one most candidates have actually seen without diagnosing.

sequenceDiagram
  participant A as Request A
  participant B as Requests B–E
  participant Au as Authenticator (Mutex)
  participant S as Auth server
  A->>Au: 401, stale = T0
  B->>Au: 401, stale = T0 (queued on the mutex)
  Au->>S: refresh(T0) — one call only
  S-->>Au: T1
  Au-->>A: retry with T1
  Note over Au,B: B–E enter the lock, see stored != T0
  Au-->>B: retry with T1, no second refresh
  
Avoid — refresh in an interceptor
class AuthInterceptor(...) : Interceptor {
    override fun intercept(chain: Chain): Response {
        var res = chain.proceed(withToken(chain.request()))
        if (res.code == 401) {
            val new = runBlocking { api.refresh() }   // no coordination
            res.close()
            res = chain.proceed(withToken(chain.request(), new))
        }
        return res
    }
}

Every concurrent 401 refreshes independently: with rotating refresh tokens, the first succeeds and the rest invalidate each other, logging the user out. Also no retry cap — a permanently-401ing endpoint loops.

Prefer — Authenticator + single-flight
class TokenAuthenticator(
    private val store: TokenStore,
    private val api: RefreshApi,
) : Authenticator {
    private val mutex = Mutex()

    override fun authenticate(route: Route?, response: Response): Request? {
        if (response.priorResponseCount() >= 2) return null   // give up
        val stale = response.request.bearer()

        val fresh = runBlocking {
            mutex.withLock {
                store.access()
                    ?.takeIf { it != stale }        // someone already refreshed
                    ?: runCatching { api.refresh(store.refresh()) }
                        .onSuccess { store.save(it) }
                        .map { it.access }
                        .getOrElse { store.clear(); null }
            }
        } ?: return null

        return response.request.newBuilder()
            .header("Authorization", "Bearer $fresh").build()
    }
}

OkHttp calls Authenticator only on 401 and replays the original request for you. The stale-token comparison collapses N concurrent refreshes into one; the prior-response cap prevents infinite retry.

Chapter 64Resilience: retries, backoff, idempotency

FailureRetry?Why
Connection failure, DNS, timeout on connectYesRequest almost certainly never reached the server.
Read timeout after the request was sentOnly if idempotentThe server may have processed it. This is the dangerous case.
500 / 502 / 503 / 504Yes, with backoff and jitterTransient server-side. Honour Retry-After when present.
429Yes, respecting Retry-AfterRetrying immediately makes the rate-limit worse.
400 / 401 / 403 / 404 / 422NoDeterministic. Retrying wastes battery and hides the bug.
Retry with full jitter — and why jitter is not optional
suspend fun <T> retrying(
    attempts: Int = 4,
    baseMs: Long = 300,
    maxMs: Long = 8_000,
    isRetryable: (Throwable) -> Boolean,
    block: suspend () -> T,
): T {
    var last: Throwable? = null
    repeat(attempts) { i ->
        try { return block() } catch (e: CancellationException) {
            throw e                                  // never swallow
        } catch (e: Throwable) {
            if (!isRetryable(e) || i == attempts - 1) throw e
            last = e
            val ceiling = min(maxMs, baseMs shl i)   // 300, 600, 1200, 2400
            delay(Random.nextLong(0, ceiling))       // FULL jitter
        }
    }
    throw last!!
}

Why jitter matters at scale: a backend blip fails a million clients simultaneously. Without jitter they all retry at exactly t+300 ms, then t+900 ms — a synchronised thundering herd that keeps the backend down. Full jitter spreads the retries uniformly. This is the difference between a 30-second blip and a 20-minute outage, and it is a favourite Staff follow-up.

Interview question · the one that separates payments experience

"A payment request times out. You don't know whether the server processed it. What do you do?"

Short answer

Never blind-retry. The client generates an idempotency key before the first attempt and reuses it for every retry, so a duplicate request is recognised server-side and returns the original result.

Deep explanation L4

"This is the unknown-outcome problem, and it cannot be solved on the client alone — it is a contract with the backend, so the first thing I'd do in a design review is establish that contract.

The client generates a UUID at the moment the user commits, not per attempt, and persists it with the pending operation so it survives process death. Every retry — including one after an app restart — sends the same Idempotency-Key. The server stores key → result for a defined window and returns the original response for repeats.

What if we never get a response at all? Then we do not guess. The client shows the payment as pending, not failed, and reconciles: poll the order status by the idempotency key, or wait for a push. Showing 'payment failed' when it may have succeeded is worse than showing 'processing' — the user retries and, if the key were per-attempt, pays twice.

Things I'd want defined explicitly: how long the server retains keys (if it is 24 hours and our offline queue can be older, we have a correctness gap); whether the key is scoped per user; and what the server returns for a key replayed with a different body, which should be a hard error rather than a silent overwrite.

For observability, I'd emit a metric for 'requests completed with unknown outcome' — it is invisible in normal crash and error dashboards, and it is exactly the number that tells you this system is healthy."

Follow-up questions

  1. "Where is the key stored?" — In the local database alongside the pending operation, written before the network call. In memory is not sufficient; process death is the case you are defending against.
  2. "What if the user force-quits and reopens?" — The pending operation is still in the queue with its key; the sync worker resumes and either learns the result or retries safely.
  3. "How would you test it?" — A fake server that accepts the request and then drops the response, asserting the client retries with the same key and does not create a second order; plus a process-death test around the persisted queue.
  4. "Does this apply outside payments?" — Any non-idempotent write: posting a message, submitting a form, placing a booking. Payments is where the cost of getting it wrong is visible, not where the problem is unique.

Chapter 65Caching and conditional requests

Avoid — a hand-rolled cache next to the HTTP cache
class ProductRepo(private val api: Api) {
    private val memo = mutableMapOf<String, Product>()   // never invalidated

    suspend fun get(id: String): Product =
        memo.getOrPut(id) { api.product(id) }
}

Two caches with different lifetimes and no coordination. The user pulls to refresh, OkHttp revalidates, and this map still returns yesterday's price. Also unbounded — a leak on a long session.

Prefer — one source of truth, HTTP for revalidation
// OkHttp handles conditional requests automatically when
// the server sends ETag / Last-Modified.
OkHttpClient.Builder()
    .cache(Cache(File(ctx.cacheDir, "http"), 20L * 1024 * 1024))
    .build()

// The app's source of truth is the database; the network
// updates it, and the UI observes only the database.
fun product(id: String): Flow<Product> = dao.observe(id)

suspend fun refresh(id: String) {
    val dto = api.product(id)     // 304 → served from HTTP cache, cheap
    dao.upsert(dto.toEntity())
}

One place holds truth. HTTP caching handles revalidation bandwidth; the database handles offline and consistency. The two layers have distinct jobs instead of competing.

Connectivity is not a boolean

A frequent design flaw is gating requests on isConnected. Real states include: no interface; connected to Wi-Fi with no internet (captive portal); connected but metered; connected with high latency; and mid-handover between Wi-Fi and cellular where sockets die but the network is "available". The right posture is attempt and handle failure, using connectivity signals only to schedule retries and to inform the UI — never as a precondition. NetworkCapabilities.NET_CAPABILITY_VALIDATED is the closest thing to "the internet actually works," and even that lags reality.

Part IX rapid recall

  • Latency and round-trip count dominate mobile networking; radio tail time makes chatty clients expensive in battery.
  • Application interceptors miss cache hits; network interceptors see every wire attempt; Authenticator only sees 401s and replays for you.
  • Refresh must be single-flight, compared against the stale token, with a retry cap.
  • Retry connection failures freely; retry post-send timeouts only with an idempotency key; never retry 4xx.
  • Full jitter prevents the synchronised retry herd that turns a blip into an outage.
  • Idempotency keys are generated at user commit, persisted before the call, reused across process death.
  • One source of truth: database for state, HTTP cache for revalidation bandwidth.
  • Connectivity is not a boolean; attempt and handle failure rather than gating.

Part X

Storage & Caching

SQLite behaviour underneath Room, and cache design treated as an architectural decision rather than a utility class.

Chapter 66SQLite mechanics that matter

  • WAL mode (Room's default) lets readers proceed during a write. Without it, a write blocks every reader — a classic source of jank when a sync worker writes while a list scrolls. There is still only one writer at a time.
  • Transactions are the unit of durability and speed. 1,000 individual inserts mean 1,000 fsyncs; the same inserts in one transaction mean one. This is routinely a 50–100× difference and is the correct answer to "the initial sync takes 40 seconds."
  • Index or scan. Without an index, a WHERE is a full table scan. With 200 rows nobody notices; with 200,000 on a low-end device it is seconds.
  • Room's observable queries invalidate per table, so a write to any row of a watched table re-runs the query. Watching a hot table with an unbounded query is a self-inflicted CPU load.
Avoid — per-row inserts and an unindexed filter
suspend fun saveAll(items: List<Item>) {
    items.forEach { dao.insert(it) }        // 5,000 transactions
}

@Query("SELECT * FROM items WHERE category = :c ORDER BY updated DESC")
fun byCategory(c: String): Flow<List<Item>>   // full scan + sort, unbounded

Initial sync takes tens of seconds and the query scans the table on every write to it. Both are invisible on a small dev dataset.

Prefer — batch, index, bound
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<Item>)     // one transaction

@Entity(indices = [Index(value = ["category", "updated"])])
data class ItemEntity(...)

@Query("SELECT * FROM items WHERE category = :c " +
       "ORDER BY updated DESC LIMIT :limit")
fun byCategory(c: String, limit: Int = 100): Flow<List<Item>>

The composite index covers both the filter and the sort, so SQLite avoids a temporary b-tree. Verify with EXPLAIN QUERY PLAN — "SCAN TABLE" means no index was used, "SEARCH TABLE … USING INDEX" means it was.

Chapter 67Schema design and migrations

Migrations are permanent public API: users skip releases, so version 3 must be able to reach version 12. Every shipped version is a path you own forever.

The migration discipline that prevents data-loss incidents
// 1. Commit the exported schema JSON — it is the contract.
room { schemaDirectory("$projectDir/schemas") }

// 2. Test EVERY adjacent pair, plus a full-chain run.
@Test fun migrate_8_to_9() {
    helper.createDatabase(TEST_DB, 8).apply {
        execSQL("INSERT INTO messages VALUES ('m1','hello',1700000000)")
        close()
    }
    val db = helper.runMigrationsAndValidate(TEST_DB, 9, true, MIGRATION_8_9)
    db.query("SELECT read FROM messages WHERE id='m1'").use {
        it.moveToFirst()
        assertEquals(0, it.getInt(0))         // default applied
    }
}

@Test fun migrate_all_the_way() =
    helper.runMigrationsAndValidate(TEST_DB, LATEST, true, *ALL_MIGRATIONS)
When destructive migration is defensible

Only for a pure cache with no user-authored data and no pending write queue, and even then guard it: if the database also holds an offline outbox, destructive migration silently deletes writes the user believes were saved. A useful rule to state in an interview: destructive migration is allowed only in modules that contain no data the user could not regenerate by pulling to refresh.

Chapter 68Cache architecture

"Should we cache?" is the wrong question. The real questions are: what is the source of truth, what staleness is acceptable, and how does a stale entry get corrected? A cache without an answer to the third is a bug generator.

InvalidationMechanismFits / fails
TTLEntry expires after N secondsSimple and predictable. Fails when data changes unpredictably — the user sees stale content for the full TTL and cannot fix it.
Write-throughEvery local write updates the cacheCorrect for data this client owns. Cannot know about other clients' writes.
Event-drivenPush/socket invalidates keysFreshest, and the right answer for multi-device. Requires backend support and a fallback for missed events.
Version/ETagRevalidate cheaply, 304 if unchangedSaves bandwidth, not round trips. Good pairing with a longer TTL.
flowchart LR
  UI["UI"] -- observes --> DB[("Room — source of truth")]
  API["Network"] -- writes --> DB
  HTTP["OkHttp cache"] -. "revalidation bytes only" .- API
  MEM["Bounded memory cache"] -- derived from --> DB
  UI -. "never reads" .-x API
  

The rule that makes this coherent: the network never returns data to the UI. It writes to the store, and the UI observes the store. Each tier then has one job — truth, bandwidth, speed — instead of three tiers competing to answer the same question.

Avoid — the three-cache problem
// Memory map in the repository (never bounded, never invalidated)
// + OkHttp disk cache (its own TTL from server headers)
// + Room (written on some paths, not others)
//
// Result: pull-to-refresh updates two of the three,
// and which value the UI shows depends on which
// screen the user opened first.

Three sources of truth means an unreproducible class of "wrong data" bugs that only appear in specific navigation orders.

Prefer — one source of truth, tiers with distinct jobs
// Truth:      Room (survives process death, offline-capable)
// Bandwidth:  OkHttp cache (revalidation only)
// Speed:      in-memory, bounded, derived from Room

class ProductRepository(
    private val dao: ProductDao,
    private val api: ProductApi,
) {
    // UI observes only this.
    fun product(id: String): Flow<Product> =
        dao.observe(id).map { it.toDomain() }

    suspend fun refresh(id: String) {
        dao.upsert(api.product(id).toEntity())   // network writes to truth
    }
}

The rule that makes this work: the network never returns data to the UI directly. It writes to the store, and the UI observes the store. Every consistency bug in the "avoid" column disappears structurally.

Chapter 69Media, files, and bitmap memory

Bitmap arithmetic is a favourite quick interview probe because it is exact: a bitmap costs width × height × bytes-per-pixel. In ARGB_8888 that is 4 bytes per pixel, so a 4000×3000 photo is 48 MB decoded — several times a typical app's entire heap budget, from one image.

Avoid — decoding at source resolution
val bmp = BitmapFactory.decodeFile(path)     // 48 MB for a 12MP photo
imageView.setImageBitmap(bmp)

AsyncImage(model = url)                       // unbounded in a list

A handful of these produce OutOfMemoryError on mid-tier devices, and the crash lands on whatever allocation happens to be next — so the stack trace usually blames innocent code.

Prefer — decode to display size
// Coil/Glide size the request to the target automatically,
// but be explicit in lists where the target is known:
AsyncImage(
    model = ImageRequest.Builder(ctx)
        .data(url)
        .size(320, 180)              // decode dimensions
        .build(),
    modifier = Modifier.size(160.dp, 90.dp),
)

// Manual decode: two passes.
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, opts)
opts.inSampleSize = calculateInSampleSize(opts, 320, 180)
opts.inJustDecodeBounds = false
val bmp = BitmapFactory.decodeFile(path, opts)   // ~0.2 MB

Also consider RGB_565 (half the memory) for opaque thumbnails, and HARDWARE bitmaps where you never read pixels back — they live outside the Java heap.

Disk budget as a designed constraint

An app that caches media without a quota eventually gets uninstalled for "using 4 GB". The Staff-level position: give every disk cache an explicit budget, an eviction policy (LRU by access time), and a response to onTrimMemory/low-storage broadcasts. Then measure the cache-hit rate — if a 500 MB cache and a 100 MB cache have the same hit rate, you are spending the user's storage for nothing, and that is an argument you can take to a product review with data.

Interview question · synthesis

"Design the storage layer for an app that must work fully offline for a week."

Requirements to pin first

What data must be available offline versus merely nice to have? Can the user write offline, and if so which operations? How much storage may we consume? What is the acceptable staleness for each data class? Is the data sensitive enough to require encryption at rest? Answers change the design more than any technology choice.

Model answer L4

"Room as the single source of truth, with three data classes handled differently.

  • Reference data (catalogue, config): synced on a schedule, TTL-based, safe to evict and re-fetch. Bounded by a row budget with LRU eviction so a week offline does not grow unboundedly.
  • User data (their orders, documents): never evicted while the user is signed in; this is the data whose absence makes the app useless offline.
  • Pending writes (the outbox): a durable queue table with an idempotency key, attempt count, and a terminal-failure state. Never destructively migrated, never cleared by a cache purge — the two mistakes that cause silent data loss.

Schema-wise, the outbox is the interesting part: (id, op_type, payload, idempotency_key, created_at, attempts, last_error, state). State is a real enum — pending, in-flight, failed-retryable, failed-permanent — because a permanently-failed write needs to surface to the user rather than retry forever.

For media, a separate file cache with its own quota, keyed by content hash, with the database holding only paths — you do not want a 200 MB blob inside SQLite.

Failure modes I'd design for explicitly: database corruption (detect on open, recreate reference data, but never silently discard the outbox — surface it); storage full (writes fail; the outbox must degrade gracefully and tell the user, not crash); and logout with a non-empty outbox, which needs a product decision — block logout, warn, or discard. Nobody thinks of that one until it happens.

On encryption: if the data is sensitive, SQLCipher with a Keystore-held key, accepting a measurable performance cost — roughly 5–15% on queries — and the operational reality that a lost key means unrecoverable data. That is a decision to make deliberately, not by default."

Follow-up questions

  1. "How do you know a week of offline data fits?" — Measure per-user row and byte growth in production telemetry, extrapolate at p95, and set the quota from that with headroom. Guessing is how you discover it on low-storage devices.
  2. "What happens when the outbox and server disagree after a week?" — This is Part XVII: conflict resolution, per-field merge policy, and surfacing genuine conflicts to the user rather than silently picking a winner.
  3. "How do you test a week offline?" — A fake clock plus a seeded database at realistic volume, run in CI; plus a manual soak on a real device with airplane mode. Volume matters — every offline bug I have seen appears only at scale.

Part X rapid recall

  • WAL allows concurrent readers with one writer; batch inserts in a single transaction for 50–100× speedups.
  • Verify indexes with EXPLAIN QUERY PLAN; "SCAN TABLE" means you have none.
  • Room observable queries invalidate per table — bound them with LIMIT and indexes.
  • Every shipped schema version needs a tested path forward, forever.
  • Destructive migration is only defensible where no user-authored data or outbox exists.
  • One source of truth; the network writes to the store, the UI observes the store.
  • Bitmap cost = w × h × 4 bytes; always decode to display size.
  • Every disk cache needs a quota, an eviction policy, and a measured hit rate.

Part XI

Performance Engineering

Built around one repeatable protocol — hypothesis, metric, tool, diagnosis, fix, verification, prevention — applied to the regressions interviewers actually describe.

Chapter 70A performance methodology

The reflex that marks a Senior answer is naming a fix ("add a baseline profile"). The reflex that marks a Staff answer is naming a measurement first. The seven-step protocol used throughout this part:

  1. Quantify. Which metric, at which percentile, on which devices, changed by how much and when. "The app is slow" is not actionable; "p90 TTID on Android 13, mid-tier, up 1.4 s since 8.4.0" is.
  2. Hypothesise. Three candidate causes, ranked, each with a prediction that would distinguish it.
  3. Instrument or trace. Perfetto/system trace on a physical mid-tier device, release build, in the failing condition.
  4. Diagnose. Identify the specific frames or spans that account for the regression. Confirm it explains the whole delta, not a plausible slice.
  5. Fix. Smallest change that addresses the cause.
  6. Verify. Same measurement, same conditions, plus a field check after rollout — lab wins that do not reproduce in the field are common.
  7. Prevent. A CI benchmark or a Vitals alert so the regression cannot silently return. Without this you will do the whole investigation again next year.
Device policy

Measure on the 25th-percentile device in your install base, not on a current flagship. Most performance bugs are simply invisible on the newest hardware, which is why they reach production: they were tested on the phone in the engineer's pocket.

MetricField sourceLab source
TTID / TTFDPlay Vitals, Firebase PerformanceMacrobenchmark StartupTimingMetric
Frame timing / jankVitals frame metrics, JankStatsMacrobenchmark FrameTimingMetric, Perfetto
ANR rateVitals ANR clusters (with traces)StrictMode, main-thread tracing
Memory / OOMCrashlytics OOM signals, VitalsStudio Memory Profiler, LeakCanary, heap dumps
BatteryVitals wakelock/wakeup excessBattery Historian, dumpsys batterystats
App sizePlay console size reportAPK Analyzer, size diff in CI

Chapter 71App startup

flowchart LR
  Z["zygote fork"] --> AC["Application.onCreate
tier 1 only"] AC --> ACT["Activity onCreate"] ACT --> M["measure / layout / draw"] M --> TTID["TTID — first frame"] TTID --> DATA["data arrives"] DATA --> TTFD["TTFD — reportFullyDrawn()"] AC -. "tier 2: appScope, off the critical path" .-> BG["Analytics, experiments"] ACT -. "tier 3: by lazy, on first use" .-> LZ["Ads, maps, video"]

Cold = process creation + Application + first Activity + first frame. Warm = process alive, activity recreated. Hot = activity resumed. Only cold start is worth optimising as a programme; it correlates with install-day retention and is the one users notice.

Two metrics: TTID (time to initial display — first frame drawn) and TTFD (time to full display — content actually usable). Optimising TTID alone produces the cheat of drawing an empty skeleton fast; report both, and call reportFullyDrawn() so TTFD is measurable.

Avoid — the startup tax nobody owns
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Analytics.init(this)         // 120 ms
        CrashReporter.init(this)     // 40 ms
        AdsSdk.initialize(this)      // 300 ms, network on main
        ExperimentSdk.fetchSync()    // 250 ms, blocking network!
        Timber.plant(DebugTree())
        preloadEverything()
    }
}
// Every SDK team adds "just one more init".

A blocking network call in Application.onCreate is a startup ANR waiting for a bad network. And nobody owns the cumulative total, so it grows by 50 ms a quarter forever.

Prefer — tiered, measured, budgeted
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        // Tier 1: required before the first frame. Keep tiny.
        CrashReporter.init(this)            // must catch startup crashes

        // Tier 2: needed soon, not now.
        appScope.launch(Dispatchers.Default) {
            Analytics.init(this@App)
            ExperimentSdk.warm()            // async; use cached values meanwhile
        }
    }
}

// Tier 3: on first use.
val ads by lazy { AdsSdk.create(context) }

// Enforced in CI:
@Test fun startup_budget() = benchmarkRule.measureRepeated(
    metrics = listOf(StartupTimingMetric()),
    startupMode = StartupMode.COLD, iterations = 15,
) { pressHome(); startActivityAndWait() }
// fails the build if p50 TTID regresses > 10%

Three tiers, an owner for the total, and a CI gate. The gate is the part that makes it stick — otherwise the budget is re-spent within two quarters.

Baseline profiles

Without a baseline profile, code runs interpreted until the JIT warms up — so the first launch and first scroll, the impressions that matter most, are the slowest the app will ever be. A baseline profile ships AOT-compiled hot paths in the APK, typically improving cold start by 15–30% and largely eliminating first-scroll jank. Generate them from a Macrobenchmark journey that covers startup and the primary scroll, and regenerate when the hot paths change — a stale profile silently stops helping.

Chapter 72Rendering and jank

A frame must complete within the refresh budget: 16.7 ms at 60 Hz, 8.3 ms at 120 Hz. Note that a high-refresh-rate device makes previously-fine code janky — a common cause of "it only stutters on the newer phones."

Symptom in a traceLikely causeFix
Long Choreographer#doFrame, time in your codeMain-thread work: parsing, mapping, formatting, diskMove off main; precompute in the state mapper
Long measure/layout spansDeep nesting, intrinsics, nested scrollables, subcompositionFlatten; avoid intrinsic measurement in hot lists
Long draw, high GPUOverdraw, shadows, blur, large unclipped layersReduce layers, graphicsLayer caching, remove backgrounds
Regular GC pausesPer-frame allocationHoist allocations out of draw/item bodies
Binder transaction spansIPC on main — ContentResolver, PackageManagerCache the result; move the call off main
First-scroll-only jankNo baseline profile / JIT warm-upBaseline profile covering the scroll journey

Chapter 73ANRs

An ANR fires when the main thread cannot service an event in time: ~5 s for input dispatch, ~20 s for a foreground service start, ~10 s for a broadcast. The trap is that the stack trace shows where the main thread was when the timer expired, not what caused the delay — so the top frame is frequently innocent.

ANR patternWhat the trace showsReal cause
Lock contentionMain blocked in lock/monitorA background thread holds the lock while doing I/O. Look at the other threads in the trace.
Binder waitMain in BinderProxy.transactNativesystem_server or another app's provider is slow; often device- or OEM-specific.
Disk I/OMain in SharedPreferences/file readapply() queue drained at onStop, or a first-read of a large prefs file.
Broadcast overrunMain in onReceiveWork done inline in a receiver instead of handed to WorkManager.
DeadlockTwo threads each waitingOrdering bug; frequently a runBlocking on main awaiting work that needs main.

Production scenario

"ANRs jumped 30% after the last release. Walk me through it."

Investigation

"First, quantify and segment before touching code. In Play Vitals I'd check: is it one ANR cluster or a broad rise? Which Android versions, OEMs, device tiers? Did it start exactly at the rollout, or track the rollout percentage — which would confirm causation rather than coincidence? Is a specific screen or entry point over-represented?

Segmentation usually collapses the hypothesis space immediately. A 30% rise concentrated on one OEM and one Android version is almost never our own code path; a rise spread evenly across devices that tracks the rollout curve is.

Hypotheses, ranked

  1. New synchronous work added to a startup or resume path — the most common cause of a step change at release.
  2. A lock now held across I/O because a call became blocking (or a library upgraded and changed its threading).
  3. An SDK upgrade doing work on the main thread — frequently an ads, analytics or attribution SDK.
  4. A broadcast receiver doing more work, or being registered where it previously was not.
  5. Not us: an OS or Play Services rollout coinciding with ours. Checking whether the previous app version's ANR rate also rose over the same window distinguishes this in one query.

Diagnosis

Read the actual ANR traces, and read all the threads, not just main. If main is blocked on a monitor, find the thread holding it and see what it is doing — that is where the bug is. Then reproduce locally with StrictMode in penalty-death mode on the suspected path, and take a system trace on a mid-tier device.

Fix, verification, prevention

Fix the specific blocking call. Verify by checking the ANR rate for the patched version at equivalent rollout percentage, not overall — mixing versions hides the signal. Prevention has three layers: StrictMode with penalty-death in debug and internal builds, so main-thread I/O fails loudly during development; a Macrobenchmark on the affected journey in CI; and an ANR-rate alert with a rollout halt criterion, so the next occurrence stops at 5% of users rather than 100%.

The Staff addition

"I'd also ask why our release process let a 30% ANR regression reach full rollout. If we had a staged-rollout halt criterion on ANR rate, this would have stopped at 5%. Fixing the ANR is the ticket; fixing the release gate is the actual outcome."

Follow-up questions

  1. "What if you cannot reproduce it?" — Ship targeted tracing behind a flag to the affected cohort, and use the Vitals trace clustering rather than trying to reproduce a device-specific timing bug locally.
  2. "What's an acceptable ANR rate?" — Play's bad-behaviour threshold is 0.47% of daily sessions, and exceeding it affects store visibility. Internally, most mature apps target well under 0.2%.
  3. "How do you catch ANRs that Vitals under-reports?" — Vitals only covers Play-installed users with reporting enabled; supplement with a watchdog that samples main-thread responsiveness and reports long stalls with a stack.

Chapter 74Battery and background cost

  • Wakelocks held past their need are the classic drain. Any wakelock should be timeout-bounded and released in a finally.
  • Wakeups cost more than the work: waking the radio and CPU every minute for a 20 ms sync is far worse than one batched sync every 30 minutes. Batch, and let WorkManager coalesce.
  • Location is the largest single drain available to an app. Match accuracy and interval to the actual product need, use passive/balanced modes where possible, and stop when the screen is off unless the product genuinely requires tracking.
  • Attribution: Battery Historian on a dogfood build and Vitals' excessive-wakeup metrics in the field. Users cannot tell you which subsystem drained their battery, but the OS can.
Avoid — polling on a timer
// Every 60 s, forever, whether or not anything changed
PeriodicWorkRequestBuilder<SyncWorker>(15, MINUTES)
    .build()
    .also { wm.enqueue(it) }          // duplicated on every launch

wakeLock.acquire()                     // no timeout
doSync()                               // may throw
wakeLock.release()                     // never reached on failure

Unbatched wakeups plus a leaked wakelock on the error path. This is the shape of "battery consumption doubled after the release."

Prefer — event-driven, bounded, unique
// Server tells us when there is something to sync.
// Periodic work is a safety net, not the mechanism.
wm.enqueueUniquePeriodicWork(
    "sync-safety-net",
    ExistingPeriodicWorkPolicy.KEEP,
    PeriodicWorkRequestBuilder<SyncWorker>(6, HOURS)
        .setConstraints(Constraints(
            requiredNetworkType = NetworkType.UNMETERED,
            requiresBatteryNotLow = true,
        )).build()
)

wakeLock.acquire(30_000)               // hard timeout
try { doSync() } finally { if (wakeLock.isHeld) wakeLock.release() }

Push as the trigger, periodic work as the fallback, constraints so it runs when it is cheap, and a wakelock that cannot outlive a crash.

Chapter 75App size and delivery

Install size measurably affects install conversion, particularly in markets with expensive data — which makes it a business metric, not a hygiene metric. Where the bytes go, and what actually works:

  • R8 in full mode with resource shrinking. Verify it is genuinely on in release — a stray -dontoptimize or an over-broad keep rule from an SDK's consumer rules commonly disables most of the benefit.
  • App Bundle splits by density, ABI and language: typically the single largest win, and free.
  • Native libraries are usually the biggest line item in a media app. Check whether you ship four ABIs when the bundle already splits them.
  • Images: WebP/AVIF over PNG, vector drawables where the asset allows.
  • Dynamic feature delivery only when a large, rarely-used feature is demonstrably costing conversion — it complicates DI, navigation and testing considerably.
  • Gate it in CI: report the size delta on every pull request. Size regresses one library at a time, and nobody notices a 400 KB dependency until the total is 90 MB.

Chapter 76Running a performance programme

A Staff engineer is not asked to fix one screen; they are asked why the app keeps getting slower. The answer is always the same: performance has no owner and no budget, so it loses every trade-off individually while losing badly in aggregate.

  1. Define SLIs and targets per tier. "p90 cold start under 1.5 s on mid-tier devices" — not one number for all hardware.
  2. Give each team a budget for the surfaces they own, visible on a dashboard they see weekly.
  3. Gate in CI with Macrobenchmarks on the top journeys, so regressions are caught before rollout rather than in Vitals.
  4. Halt criteria on staged rollout: a startup or ANR regression stops the rollout automatically.
  5. A standing budget for fixes — commonly 10–20% of capacity. Without it, the ratchet only turns one way.
  6. Report in business terms. "Cold start down 600 ms; day-1 retention up 0.4%" funds the next quarter of work. "We reduced recompositions" does not.

Part XI rapid recall

  • Measure before hypothesising; segment by version, OEM, device tier and rollout percentage.
  • TTID and TTFD together; reportFullyDrawn() or you are optimising a skeleton.
  • Three-tier startup init with an owner and a CI budget; no blocking network in Application.onCreate.
  • Baseline profiles cover startup and the primary scroll; regenerate when hot paths change.
  • ANR traces: read every thread, not just main — the cause is usually the lock holder.
  • Batch wakeups, bound wakelocks with timeouts and finally, push-trigger rather than poll.
  • Size: full-mode R8, bundle splits, native libs, CI size diff on every PR.
  • A programme needs SLIs, per-team budgets, CI gates, rollout halt criteria, and standing capacity.

Part XII

Memory Management

Leak topology, GC behaviour, and hands-on diagnosis. Memory bugs are the ones that reach production most reliably, because they need time and real usage to become visible.

Chapter 77The runtime memory model

  • ART uses a concurrent, mostly-generational collector. Short-lived objects are cheap; long-lived ones that survive several collections are promoted and become expensive to collect. Allocation itself is cheap — the cost is the collection pressure it creates.
  • Your heap is capped per app (ActivityManager.memoryClass, commonly 128–512 MB depending on device). Exceeding it is OutOfMemoryError, regardless of how much RAM the device has.
  • Native memory is separate. Hardware bitmaps, native libraries, and NDK allocations do not count toward the Java heap but do count toward the process's overall footprint — so a process can be killed by the low-memory killer with a nearly-empty Java heap.
  • onTrimMemory is the OS asking you to release caches before it kills you. Apps that ignore it get killed sooner, which shows up as a worse warm-start rate.
  • GC pauses cause jank when they land inside a frame. Per-frame allocation in a scrolling list is the usual source.

Chapter 78Leak topology

flowchart LR
  GC(["GC root"]) --> ST["static / object / companion"]
  GC --> TH["live thread, Handler queue"]
  GC --> SVC["service, app-scoped coroutine"]
  ST --> LST["listener list in a singleton"]
  LST --> VM2["ViewModel or Fragment"]
  TH --> RUN["delayed Runnable"]
  RUN --> V["View"]
  V --> ACT["Activity"]
  ACT --> TREE["whole view hierarchy — megabytes"]
  VM2 --> ACT
  

Read it right to left: anything still reachable from a GC root cannot be collected. Every fix cuts exactly one edge — unregister the listener, cancel the Runnable, null the binding — and none of them involve nulling the Activity.

Every leak is the same shape: a long-lived object holds a reference to a short-lived one. Learning the six common paths lets you spot them in review rather than in a heap dump.

PathMechanismStructural fix
Static holds Contextobject / companion field storing an ActivityApplication context, injected
Unregistered listenerA singleton's callback list holds the FragmentDisposableEffect / mirrored lifecycle callback
Inner class / lambdaNon-static inner class or a lambda capturing thisPass only what is needed; make it top-level
Handler / RunnableDelayed message referencing the ViewremoveCallbacks in the teardown callback
Coroutine in the wrong scopeApp-scoped coroutine capturing a ViewModelCorrect scope; deliver results to a store, not a UI object
Fragment view lifecycleBinding held past onDestroyViewNull the binding, or use viewLifecycleOwner
Avoid — three leaks in ten lines
class DetailFragment : Fragment() {
    private lateinit var binding: FragmentDetailBinding
    private val handler = Handler(Looper.getMainLooper())

    override fun onViewCreated(v: View, s: Bundle?) {
        // 1. Singleton keeps a strong ref to this fragment forever
        LocationManagerSingleton.addListener(this)

        // 2. Delayed runnable holds the view for 30 s past destruction
        handler.postDelayed({ binding.banner.isVisible = true }, 30_000)

        // 3. Binding survives onDestroyView → leaks the whole view tree
    }
}

All three survive navigation. Rotate ten times and you retain ten view hierarchies — this is how an app "gets slower the longer you use it."

Prefer — lifecycle-bounded everything
class DetailFragment : Fragment(R.layout.fragment_detail) {
    private var binding: FragmentDetailBinding? = null

    override fun onViewCreated(v: View, s: Bundle?) {
        binding = FragmentDetailBinding.bind(v)

        viewLifecycleOwner.lifecycle.addObserver(
            LocationObserver(locationManager)      // self-unregistering
        )

        viewLifecycleOwner.lifecycleScope.launch {
            delay(30_000)
            binding?.banner?.isVisible = true      // cancelled on destroy
        }
    }

    override fun onDestroyView() {
        binding = null
        super.onDestroyView()
    }
}

A coroutine on viewLifecycleOwner.lifecycleScope replaces the Handler and is cancelled automatically. In Compose the equivalent is DisposableEffect with a real onDispose.

Chapter 79Diagnosing with heap dumps

The workflow
1. Reproduce:  navigate in and out of the suspect screen 10×,
               with a manual GC between (Profiler → trash icon).
2. Dump:       Studio Memory Profiler → capture heap dump,
               or  adb shell am dumpheap <pid> /data/local/tmp/h.hprof
3. Filter:     group by class, sort by retained size, filter to
               your package. Look for N instances of an Activity
               where N should be 1.
4. Path:       inspect "references to this" → find the shortest
               strong path from a GC root. That path IS the bug.
5. Confirm:    fix, repeat step 1–3, assert the count is 1.

Retained vs shallow size is the distinction interviewers check. Shallow size is the object's own fields; retained size is everything that would be freed if it went away. An Activity has a small shallow size and an enormous retained size — which is why one leaked Activity matters and one leaked String does not.

LeakCanary in CI: beyond the debug-build toast, its detection can run in instrumented tests and fail the build on a new leak. That converts leaks from "someone notices eventually" to a gate — the Staff-level move.

Not every retention is a leak

A cache holding 200 bitmaps is retention by design. The question is whether it is bounded and whether it responds to memory pressure. Calling a deliberate bounded cache a leak in an interview is a small but real signal that the candidate is pattern-matching rather than reasoning.

Chapter 80Exercises — five leaks to find

Exercise 1

Find the leak

Kotlin
class AnalyticsBus {
    companion object {
        private val listeners = mutableListOf<(Event) -> Unit>()
        fun subscribe(l: (Event) -> Unit) { listeners += l }
        fun publish(e: Event) { listeners.forEach { it(e) } }
    }
}

class CartViewModel(private val repo: CartRepo) : ViewModel() {
    init {
        AnalyticsBus.subscribe { event ->
            if (event is PriceChanged) refresh()   // captures `this`
        }
    }
}

Answer

The lambda captures the ViewModel; the companion's list is a GC root for the process lifetime. Every screen visit adds a permanently-retained ViewModel — and each one still reacts to events, so refresh() fires N times after N visits, producing duplicate network calls as well as a leak. Fix: return a subscription handle and dispose it in onCleared(), or better, replace the bus with a SharedFlow collected in viewModelScope, which is cancelled automatically.

Exercise 2

Find the leak

Kotlin
@Composable
fun LocationBanner(manager: LocationManager) {
    var location by remember { mutableStateOf<Location?>(null) }

    LaunchedEffect(Unit) {
        manager.registerListener { location = it }
    }

    location?.let { Text("Near ${it.name}") }
}

Answer

LaunchedEffect cancels its coroutine on leaving composition, but registration is not a coroutine — nothing unregisters the listener, and the callback captures the composition's state object. Fix: DisposableEffect(manager) { val l = manager.registerListener {...}; onDispose { manager.unregister(l) } }. The general rule: LaunchedEffect for suspending work, DisposableEffect for anything with a register/unregister pair.

Exercise 3

Find the leak — and the second bug

Kotlin
@Singleton
class ImageLoader @Inject constructor() {
    private val cache = mutableMapOf<String, Bitmap>()

    fun load(url: String, into: ImageView) {
        cache[url]?.let { into.setImageBitmap(it); return }
        thread {
            val bmp = decode(url)
            cache[url] = bmp
            into.post { into.setImageBitmap(bmp) }
        }
    }
}

Answer

Three defects. Unbounded cache: a map of full-size bitmaps in a singleton grows until OutOfMemoryError — no eviction, no memory-pressure response. View capture: the background thread holds the ImageView, and therefore the Activity, until decode finishes; on a slow network that outlives the screen. Concurrency: a plain mutableMapOf written from arbitrary threads can corrupt or lose entries. Fix: use LruCache sized from memoryClass, decode to the target size, hold the view weakly (or better, use Coil, which solves all three and cancels on detach).

Exercise 4

Why does memory grow over hours with no leaked Activities?

Kotlin
class ChatViewModel(socket: ChatSocket) : ViewModel() {
    private val _messages = MutableStateFlow<List<Message>>(emptyList())
    val messages = _messages.asStateFlow()

    init {
        viewModelScope.launch {
            socket.incoming.collect { msg ->
                _messages.update { it + msg }     // grows forever
            }
        }
    }
}

Answer

Not a leak — unbounded growth, which LeakCanary will never report. A busy channel accumulates every message for the session, each copy also allocating a new list (O(n²) allocation over the session, which is its own performance problem). Fix: keep a bounded window in memory and page older messages from the database; use a persistent list so appends do not copy. This is the pattern behind "memory usage increases gradually over several hours" in Part XIX.

Exercise 5

Find the leak

Kotlin
class MainActivity : ComponentActivity() {
    private val receiver = object : BroadcastReceiver() {
        override fun onReceive(c: Context, i: Intent) { refresh() }
    }

    override fun onCreate(s: Bundle?) {
        super.onCreate(s)
        applicationContext.registerReceiver(receiver, filter)
    }

    override fun onDestroy() {
        super.onDestroy()
        // unregister forgotten
    }
}

Answer

The receiver is an anonymous inner class capturing the Activity, registered against the application context — so it lives for the process lifetime and retains every Activity instance ever created. Registering with the Activity's own context would at least tie it to the Activity, and Android would log the "leaked receiver" warning at destruction. Fix: register in onStart, unregister in onStop, or use a lifecycle-aware wrapper — and prefer a Flow built with callbackFlow so cancellation handles it structurally.

Part XII rapid recall

  • Every leak is a long-lived object referencing a short-lived one; learn the six paths and spot them in review.
  • Retained size, not shallow size, is what matters — one Activity retains its whole view tree.
  • Native and hardware-bitmap memory is outside the Java heap but inside the kill decision.
  • LaunchedEffect for suspending work; DisposableEffect for register/unregister pairs.
  • Unbounded growth is not a leak and LeakCanary will not find it — bound every in-memory collection.
  • Bounded caches with memory-pressure response are design, not defects.
  • Run LeakCanary in instrumented CI so a new leak fails the build.

Part XIII

Testing Strategy

Testing as an economic decision: confidence purchased per unit of authoring time, maintenance and flake. At Staff level the question is never "did you write tests" but "what is your strategy and what did it cost".

Chapter 81What to test, and what not to

Every test has a price: authoring, runtime on every CI job, and — the largest — maintenance when the code changes. A test is worth it when expected defect cost prevented exceeds that. This produces some unpopular conclusions worth stating plainly in an interview:

TestVerdictWhy
Domain rules, mappers, reducersAlwaysCheap, fast, high defect density, stable interfaces.
State holder / ViewModel behaviourAlwaysConcurrency and state transitions are where real bugs live.
Repository with fakesUsuallyCache/network interaction and error mapping.
Room migrationsAlwaysThe failure mode is data loss, not a bug.
A composable's exact layoutRarelyHigh churn, low defect density. Screenshot tests cover it better if it matters.
Getters, data class copy, DI wiringNoTests the compiler and the framework, not your logic.
Mocked interaction verificationMostly noAsserts implementation, not behaviour; breaks on every refactor.
Full E2E of every flowNoSlow and flaky. Reserve for a handful of revenue-critical journeys.
Coverage

Coverage is a diagnostic, not a target. A team told to hit 80% writes tests for getters. The useful version is coverage of changed lines in a PR, reviewed as information rather than enforced as a gate — and even then, treat a drop as a question, not a failure.

Chapter 82Test doubles: why fakes beat mocks at scale

Avoid — mock-heavy, asserts implementation
@Test fun loads_user() = runTest {
    val repo = mockk<UserRepository>()
    coEvery { repo.getUser("1") } returns user
    coEvery { repo.getPrefs("1") } returns prefs
    coEvery { repo.trackView(any()) } just Runs

    val vm = ProfileViewModel(repo)
    vm.load("1")

    coVerify(exactly = 1) { repo.getUser("1") }   // implementation detail
    coVerify { repo.trackView(any()) }
}

Every refactor — caching the result, batching the two calls — breaks the test without any behaviour changing. Multiply by 4,000 tests and refactoring becomes economically impossible, which is how codebases ossify.

Prefer — a shared fake, asserts behaviour
// Shipped from the module's test-fixtures, reused by every consumer.
class FakeUserRepository : UserRepository {
    private val users = MutableStateFlow(mapOf<String, User>())
    var failNext: Throwable? = null

    fun seed(u: User) { users.update { it + (u.id to u) } }
    override fun observe(id: String) = users.map { it[id] }
    override suspend fun getUser(id: String): User {
        failNext?.let { failNext = null; throw it }
        return users.value.getValue(id)
    }
}

@Test fun shows_user_then_error_on_refresh_failure() = runTest {
    val repo = FakeUserRepository().apply { seed(user) }
    val vm = ProfileViewModel(repo)

    vm.state.test {
        assertEquals(Loading, awaitItem())
        assertEquals(Ready(user), awaitItem())
        repo.failNext = IOException()
        vm.refresh()
        assertEquals(Failed(retryable = true), awaitItem())
    }
}

The fake survives refactors, is reused by every team consuming the module, and makes the test read as a description of behaviour. The maintenance saving is the whole argument.

When mocks are right: verifying that a side-effecting collaborator you do not own was called (an analytics SDK, a payment gateway), or standing in for an interface with too many methods to fake in a one-off test. The rule: fake the things you own and reuse; mock the things you neither own nor reuse.

Chapter 83Testing coroutines and Flow

Avoid — real time, hardcoded dispatchers
class SearchViewModel(repo: Repo) : ViewModel() {
    fun search(q: String) {
        viewModelScope.launch(Dispatchers.IO) { ... }   // not injectable
    }
}

@Test fun searches() = runBlocking {
    vm.search("shoes")
    delay(500)                      // real sleep; flaky and slow
    assertEquals(2, vm.state.value.results.size)
}

A hardcoded dispatcher cannot be replaced, so the test races. delay in a test is a bet on machine speed: it passes locally and fails on a loaded CI agent, which is the single largest source of flake in Android codebases.

Prefer — injected dispatchers, virtual time
class SearchViewModel(
    private val repo: Repo,
    private val io: CoroutineDispatcher,
) : ViewModel()

class MainDispatcherRule(
    private val d: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(d0: Description) = Dispatchers.setMain(d)
    override fun finished(d0: Description) = Dispatchers.resetMain()
}

@get:Rule val dispatcherRule = MainDispatcherRule()

@Test fun debounces_and_cancels_previous() = runTest {
    val vm = SearchViewModel(repo, StandardTestDispatcher(testScheduler))
    vm.state.test {
        assertEquals(Idle, awaitItem())
        vm.onQuery("sh"); advanceTimeBy(100)
        vm.onQuery("sho"); advanceTimeBy(100)
        vm.onQuery("shoe")
        advanceTimeBy(300)               // virtual: instant
        assertEquals(Loading, awaitItem())
        assertEquals(Results(shoeResults), awaitItem())
        assertEquals(1, repo.callCount)  // debounce actually worked
    }
}

Virtual time makes a 300 ms debounce test run in microseconds and deterministically. The callCount assertion is what proves the debounce, not just that results eventually arrived.

The StateFlow/Turbine gotcha worth knowing: a StateFlow created with WhileSubscribed does not start until collected, so a test that reads .value without collecting sees only the initial value and appears broken. Inside .test { } the collection exists, which is one reason Turbine is the standard tool rather than a convenience.

Chapter 84UI, screenshot, and end-to-end tests

flowchart TD
  E2E["End-to-end · minutes · highest flake
a handful of revenue-critical journeys"] UIT["UI + screenshot · seconds · on device
screen states across theme, locale, font scale"] INT["Integration · tens of ms · Robolectric
repository + DAO + mappers, with fakes"] UNIT["Unit · milliseconds · JVM
domain rules, reducers, state holders"] E2E --- UIT --- INT --- UNIT

The ordering is economic, not moral: value is confidence bought divided by authoring plus maintenance plus flake, and that ratio collapses as you climb. Which is why the top tier is a few journeys rather than a suite.

LayerRuntimeBuys / costs
Unit (JVM)msLogic correctness. Cannot catch integration or rendering bugs.
Robolectrictens of msFramework interaction without a device. Some behaviours differ from real Android.
Compose UI testseconds, on deviceReal semantics and interaction. Needs an emulator in CI.
ScreenshotsecondsVisual regressions across themes, locales, font scales — cheaply, if the harness is deterministic.
E2EminutesReal confidence in a journey. Highest flake and maintenance; keep the count small and the journeys revenue-critical.

Screenshot testing is the highest-leverage addition for a large app because it covers the combinatorial surface — light/dark, RTL, large font scale, small screen — that nobody tests manually. Its failure mode is nondeterminism: pin the font, disable animation and blinking cursors, use a fixed clock, and render off-device where possible. A flaky screenshot suite gets muted within a month, and then you are paying for nothing.

Chapter 85Contract testing the mobile/backend seam

The most expensive mobile bugs frequently originate in a backend change that no mobile test could catch: a field becomes nullable, an enum gains a value, a list becomes paginated. Both sides' tests pass; production breaks.

Three cheap defences, in increasing order of value
// 1. Parse real recorded payloads, not hand-written happy-path JSON.
@Test fun parses_production_sample() {
    val dto = json.decodeFromString<OrderDto>(readResource("order_prod_sample.json"))
    assertNotNull(dto.toDomain())
}

// 2. Assert the client tolerates the changes the server is allowed to make.
@Test fun unknown_enum_value_does_not_crash() {
    val dto = json.decodeFromString<OrderDto>("""{"status":"QUANTUM_PENDING"}""")
    assertEquals(OrderStatus.Unknown, dto.toDomain().status)
}

// 3. Schema check in CI: fail the mobile build when the published
//    OpenAPI/GraphQL schema removes a field the client still reads.
//    This is the one that actually prevents the incident.

The Staff framing: this is an organisational contract, not a test file. Somebody must own the rule "the mobile client's supported schema range is checked in the backend's CI" — because mobile cannot force-update installed clients, so the backend is always the party that must remain compatible.

Chapter 86Strategy for a large organisation

Interview question · Staff-level, frequently the whole round

"Our test suite takes 90 minutes and 15% of runs fail for unrelated reasons. Engineers have stopped trusting it. What do you do?"

Why interviewers ask this

It is the most common real state of a large Android codebase, and the answer reveals whether the candidate thinks in terms of test counts or test economics.

Answer L4

"A 15% unrelated failure rate is worse than having no tests, because it trains engineers to re-run until green — which means real failures get re-run too. So flake is the first problem, not duration.

Immediate (week one): instrument the suite. Record every test's pass/fail history per commit; a test that fails and then passes on the same commit is flaky by definition. That gives a ranked list, and typically 20 tests cause 80% of the flake.

Quarantine policy: a test identified as flaky is moved out of the blocking suite within 24 hours and assigned to its owning team with a deadline. Not deleted — quarantined, visible on a dashboard, with an expiry after which it is deleted. The critical part is that quarantine is automatic and cheap, because a process requiring a meeting will not be used.

Root causes, in the order I'd expect them: real-time delays instead of virtual time; shared state between tests, especially a singleton or a real database; tests depending on execution order; animations and emulator timing in UI tests; and genuine product race conditions — which are the valuable ones, because a flaky test there is telling you the truth.

Duration: tier the suite. Pre-merge runs unit tests plus the tests affected by the changed modules — with a proper module graph that is usually a few minutes. Post-merge runs everything. Nightly runs E2E and screenshot suites. Add test sharding and remote caching. Most of the 90 minutes is usually unaffected modules being re-run.

Measuring whether it worked: not test count or coverage. Median PR wall-clock time, flake rate, and — the outcome that justifies it — change failure rate and escaped-defect count. If tests are not reducing escaped defects, the suite is a cost centre and I would say so.

The organisational half: ownership. Every test file needs an owning team via CODEOWNERS, otherwise flaky tests belong to nobody and the quarantine list grows forever. And I'd publish the flake dashboard where engineers already look, because visibility does more than policy."

Follow-up questions

  1. "What if a team refuses to fix their quarantined tests?" — The expiry does the work: after the deadline the test is deleted and the coverage loss is recorded. That converts an argument into a visible, owned trade-off.
  2. "How do you decide what to run pre-merge?" — Module-graph-based test impact analysis: changed module plus its dependents. It requires the modularization from Part VIII, which is a good moment to note that these investments compound.
  3. "Is 100% pre-merge ever right?" — For a small codebase with a fast suite, yes — simplicity wins. The tiering only pays above roughly 15–20 minutes.
  4. "How do you measure test quality?" — Mutation testing on the domain layer gives a real signal where it is affordable; escaped-defect analysis (was there a test that should have caught this?) is cheaper and works everywhere.

Part XIII rapid recall

  • Test economics: authoring + runtime + maintenance versus defect cost prevented.
  • Fake what you own and reuse; mock what you neither own nor reuse. Verify behaviour, not calls.
  • Inject dispatchers; use runTest virtual time; never delay in a test to wait for something.
  • Turbine for flows; a WhileSubscribed StateFlow does not start until collected.
  • Screenshot tests cover the theme/locale/font-scale combinatorics — but only if deterministic.
  • Contract checks belong in the backend's CI, because mobile cannot force-update clients.
  • Flake is worse than absence: automatic quarantine, owner, expiry, dashboard.
  • Measure PR wall-clock, flake rate and change failure rate — not coverage.

Part XIV

Build Systems, CI/CD & Release

Build and release treated as a product with its own users — your engineers — and its own metrics. This is the part of Staff work that is invisible until it is terrible.

Chapter 87Gradle mechanics

Gradle runs in phases, and knowing which phase your cost is in is most of the diagnosis:

  1. Initialisation — settings, which projects exist.
  2. Configuration — every build script runs, every task is created. On a 300-module project this can be 30+ seconds before any work, and it happens on every invocation unless the configuration cache is on.
  3. Execution — tasks run, subject to up-to-date checks and the build cache.
Avoid — work at configuration time
// Runs on EVERY build, including `./gradlew tasks`
val gitSha = "git rev-parse HEAD".execute().text.trim()
val buildTime = System.currentTimeMillis()

android {
    defaultConfig {
        buildConfigField("String", "GIT_SHA", "\"$gitSha\"")
        buildConfigField("long", "BUILD_TIME", "${buildTime}L")
    }
}

A process fork per module per build, and buildTime changes every invocation — so BuildConfig is never up to date and everything downstream recompiles. It also breaks the configuration cache.

Prefer — lazy providers, stable inputs
val gitSha: Provider<String> = providers.exec {
    commandLine("git", "rev-parse", "--short", "HEAD")
}.standardOutput.asText.map { it.trim() }        // evaluated on demand, cached

android {
    defaultConfig {
        buildConfigField("String", "GIT_SHA", "\"${gitSha.get()}\"")
        // No timestamp: it would defeat every cache. If you need one,
        // inject it at packaging time only for release builds.
    }
}

Provider APIs defer the work and participate in the configuration cache. Removing volatile inputs is what makes up-to-date checks and the build cache work at all.

FeatureWhat it savesCommon blocker
Configuration cacheThe entire configuration phase on repeat buildsScripts capturing Project at execution time; older plugins
Build cache (local)Re-running tasks whose inputs are unchangedNon-deterministic task inputs — timestamps, absolute paths
Remote build cacheCI results reused on developer machinesEnvironment differences making cache keys diverge
Parallel executionWall time across independent modulesA deep dependency chain, which serialises regardless
KSP over kaptOften 20–40% of annotation-processing timeA library that only ships a kapt processor

Chapter 88Variants and build logic

Variants multiply: 3 flavours × 2 build types = 6 variants, each with its own compile, resource-merge and lint task per module. Teams routinely create dimension combinations nobody builds, and pay for them in configuration time and IDE sync.

Prune deliberately
androidComponents {
    beforeVariants { variant ->
        // Only `internal` needs the debuggable+staging combination.
        variant.enable = !(variant.flavorName == "demo" &&
                           variant.buildType == "release")
    }
}

The Staff-level point: every variant is a build-time and test-matrix cost paid by every engineer on every sync. A dimension needs a named owner and a reason, or it should not exist.

Chapter 89Cutting build times across an organisation

Interview question · a very common Staff round

"Builds take 18 minutes. 120 engineers. What do you do?"

Frame it in money first

"120 engineers, maybe 8 builds a day each, 18 minutes — that is roughly 290 engineer-hours a day of waiting, though not all of it is idle. Even attributing a third of it as genuinely lost, that is on the order of 10–15 full-time engineers' worth of capacity. That framing is what funds the work, and it is the first thing I'd put in the proposal."

Measure before changing anything

"Build scans across the team — not my machine. I want the distribution, not the average: which task types dominate, what the cache hit rate is, how much is configuration versus execution, and crucially which build is the 18 minutes. A clean build being slow matters far less than an incremental one-line change being slow, because that is what engineers do fifty times a day."

Sequence, cheapest first

  1. Configuration cache, build cache, parallel, adequate JVM heap. Days of work, often 30–50%.
  2. kapt → KSP wherever the library supports it. Frequently the single largest execution-phase win in a Dagger/Room codebase.
  3. Remote build cache seeded by CI, so a developer syncing main downloads results rather than compiling them.
  4. Fix the graph: apiimplementation, break the chains that serialise, split the modules everything depends on. Weeks of work, high value, but only after measurement says the graph is the problem.
  5. Cut variants and unnecessary annotation processing.
  6. Hardware — genuinely the cheapest intervention per minute saved, and frequently blocked by a procurement policy rather than by engineering.

Make it stick

"Regression is the default. I'd publish a build-time dashboard (Gradle Enterprise or an equivalent), alert on p50 incremental build time, and add a CI check on configuration time. Otherwise the 18 minutes returns within three quarters, one dependency at a time — and the second time you propose the work, nobody believes it."

Follow-up questions

  1. "What if the remote cache has a low hit rate?" — Usually non-deterministic inputs: absolute paths, timestamps, environment variables leaking into task inputs. Gradle Enterprise's cache-miss analysis identifies the specific input that diverged.
  2. "How do you avoid a six-month platform project nobody sees?" — Ship in increments with published numbers after each, and pick the first increment for visibility rather than magnitude.
  3. "What's the metric you'd report to leadership?" — Median incremental build time and CI wall-clock per PR, converted to engineer-hours a week. Never "we enabled the configuration cache."

Chapter 90Pipelines and static analysis

flowchart LR
  L["Local pre-commit
seconds — format only"] --> PR["Pre-merge
<10 min — compile, lint,
affected-module tests, size diff"] PR --> PM["Post-merge
<30 min — full unit suite,
smoke tests, benchmarks"] PM --> N["Nightly
hours — E2E, screenshots,
device matrix, dep audit"] N --> REL["Release
signed bundle, mapping upload,
baseline profile, staged rollout"]

The budgets are the design. Anything slower than about ten minutes pre-merge gets routed around — engineers batch changes into larger pull requests, which are harder to review, which costs more quality than the extra checks bought.

StageBudgetContains
Pre-commit (local)secondsFormatting only. Anything slower gets bypassed with --no-verify.
PR / pre-mergeunder 10 minCompile, lint/Detekt, unit tests for affected modules, size and API diff.
Post-mergeunder 30 minFull unit suite, instrumented smoke tests, benchmark checks.
NightlyhoursE2E, screenshot suite, full device matrix, dependency and licence audit.
ReleaseSigned bundle, mapping upload, baseline profile, staged rollout.

Static analysis as architecture enforcement

The highest-value custom rules are the ones encoding decisions from other parts of this book — this is how an architectural agreement becomes real rather than aspirational:

Rules worth writing
ban  GlobalScope                        → Part III
ban  Dispatchers.IO referenced directly → inject the dispatcher
ban  feature-to-feature imports          → Part VIII boundary rule
ban  android.* imports in :core:domain-* → keeps the domain pure
warn Composable with > 6 parameters      → hoisting smell
ban  runBlocking outside tests/main()
ban  PendingIntent without FLAG_IMMUTABLE→ Part IV / XV
ban  Gson reflective adapters            → Part II null-safety hole

Signal-to-noise is the whole game. A ruleset with 400 warnings is a ruleset everyone ignores. Introduce rules at error severity with a baseline file for existing violations, so new code is clean and legacy debt is visible but non-blocking.

Chapter 91Release engineering

The defining constraint of mobile release: you cannot recall a binary. A bad server deploy is reverted in minutes; a bad app release is on users' devices until they update, and some never will. Every release practice follows from this.

Avoid — ship and hope
1. Merge everything ready on Friday
2. Build, upload, 100% rollout
3. Watch Crashlytics over the weekend
4. If it's bad: hotfix, 2-day review anxiety, staged panic

No blast-radius control, no halt criteria, and the recovery path costs days. Any serious defect reaches the full user base before anyone is awake.

Prefer — train, staged, gated, killable
1. Release train: branch cuts on a fixed schedule.
   Missing the train is cheap; blocking it is expensive.
2. Internal → closed → 1% → 5% → 20% → 50% → 100%,
   with a soak at each step.
3. Automated halt criteria evaluated per step:
     crash-free sessions  < baseline − 0.1%   → halt
     ANR rate             > baseline + 20%     → halt
     p90 cold start       > baseline + 15%     → halt
     checkout conversion  < baseline − 2%      → halt
4. Every risky feature behind a remote flag, so
   mitigation does not require a new binary.
5. Hotfix path rehearsed: cherry-pick, expedited
   review, targeted rollout. Practised, not improvised.

The kill switch is the most important line. It converts "we need an emergency release" into "we turned it off," which is minutes instead of days.

The rollback question

"How do you roll back a mobile release?" is a trick question, and the L4 answer starts by saying so. You cannot un-install a version from users' devices. Play's halt-rollout stops further distribution but does not touch existing installs; publishing the previous version under a higher version code is possible but disruptive and does not help users who already updated. The real answers are, in order: a server-side kill switch or remote config; a server-side compatibility fix; and only then a forward hotfix. Designing for this in advance — feature flags on anything risky, and server tolerance for old clients — is the actual competence being assessed.

Part XIV rapid recall

  • Know which phase your cost is in; configuration work runs on every invocation.
  • Volatile inputs (timestamps, absolute paths) silently disable caching.
  • Enable configuration cache, build cache, parallel, KSP before restructuring anything.
  • Frame build time in engineer-hours; publish a dashboard or it regresses.
  • Tier pipelines by budget; pre-merge under 10 minutes or engineers route around it.
  • Encode architecture rules in lint at error severity with a baseline for legacy.
  • Release trains, staged rollout, automated halt criteria, feature flags on anything risky.
  • There is no rollback — only kill switches, server-side fixes, and forward hotfixes.

Part XV

Security

Threat modelling first, mitigations second. The organising principle for the whole part: obfuscation ≠ encryption ≠ security, and the client is hostile territory.

Chapter 92Threat modelling a mobile client

The foundational premise, and the sentence that most improves a security answer: the client runs on hardware the attacker controls. Everything shipped in the APK is readable; everything computed on-device can be altered; every network call can be observed and replayed. Security decisions follow from what that does and does not mean.

AttackerCapabilityWhat actually defends against it
Casual, on-deviceReads shared storage, logs, backupsKeystore-backed encryption, no secrets in logs, correct backup rules
Malicious app, same deviceExported components, intent redirection, clipboard, accessibility abuseNon-exported components, permission-protected receivers, immutable PendingIntents
Network attackerMITM proxy, DNS spoofing, replayTLS with correct validation, pinning where justified, idempotency and nonces
Device owner (rooted)Full memory read, hooking, patched APK, disabled pinningEssentially nothing client-side — this is why enforcement must be server-side
Reverse engineerDecompiles the APK, extracts strings and logicNothing durable. Obfuscation raises effort; it does not prevent
The three-way distinction to state explicitly

Obfuscation renames symbols: it raises the cost of understanding, and R8 does it as a side effect of shrinking. Encryption makes data unreadable without a key — but a key shipped in your APK is not a secret, so "encrypted with a hardcoded key" is obfuscation with extra steps. Security is a property of the whole system, and on mobile it comes overwhelmingly from the server enforcing authorisation. Candidates who conflate these get marked down hard on any security round.

Chapter 93Keystore, crypto, and secure storage

The Android Keystore stores keys in hardware (TEE, or StrongBox on supported devices) so the key material never enters your process. You ask the Keystore to perform operations; you never hold the key. That is the entire security value, and it is why "we encrypt with a key in SharedPreferences" is not equivalent.

Avoid — theatre
object Crypto {
    private const val KEY = "MySuperSecretKey123!"   // in the APK
    fun encrypt(s: String): String =
        Base64.encodeToString(xor(s.toByteArray(), KEY.toByteArray()), 0)
}

// Also common and also wrong:
prefs.edit().putString("auth_token", token).apply()   // plaintext
Log.d(TAG, "Login ok, token=$token")                  // in logcat, in bug reports

The key is extractable with strings on the APK. The token in logs reaches crash reports, support bundles and any app with log access on older devices.

Prefer — hardware-backed, biometric-gated where warranted
private fun getOrCreateKey(): SecretKey =
    (KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
        .getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey
        ?: KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
            .apply {
                init(KeyGenParameterSpec.Builder(ALIAS, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
                    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                    .setUserAuthenticationRequired(true)        // biometric gate
                    .setInvalidatedByBiometricEnrollment(true)  // new fingerprint ⇒ key dies
                    .setIsStrongBoxBacked(hasStrongBox)
                    .build())
            }.generateKey()

// GCM: never reuse an IV with the same key. Store the IV with the ciphertext.
fun encrypt(plain: ByteArray): Pair<ByteArray, ByteArray> {
    val c = Cipher.getInstance("AES/GCM/NoPadding")
    c.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
    return c.iv to c.doFinal(plain)
}

setInvalidatedByBiometricEnrollment is the line most implementations miss: without it, an attacker who can add a fingerprint gains access to the key. And IV reuse under GCM is a catastrophic, silent break.

Chapter 94Identity: OAuth2, tokens, biometrics

sequenceDiagram
  participant App
  participant CT as Custom Tab
  participant IdP as Identity provider
  participant API
  App->>App: generate code_verifier, derive challenge
  App->>CT: authorize?code_challenge=…&S256
  CT->>IdP: user authenticates (App never sees credentials)
  IdP-->>App: redirect with authorization code
  App->>IdP: token(code, code_verifier) — no client secret
  IdP-->>App: short access token + rotating refresh token
  App->>App: store refresh token under a Keystore key
  App->>API: Bearer access token
  Note over App,IdP: refresh reuse detected ⇒ IdP revokes the whole family
  

The correct mobile flow is Authorization Code with PKCE, in a Custom Tab — not an embedded WebView. The reasons matter in interviews: an embedded WebView means your app can read the user's credentials (so an identity provider cannot trust you, and increasingly blocks it), it does not share the system's session, and users cannot verify the URL bar to detect phishing.

DecisionRight answerReasoning
Client secret in the appNone — mobile is a public clientIt is extractable; PKCE replaces its role.
Token storageKeystore-encrypted, not plain prefsBackups, rooted devices, other apps on old versions.
Access token lifetimeShort — minutes to an hourLimits the window if it leaks.
Refresh tokenRotating, with reuse detectionA replayed old refresh token signals theft; the server revokes the family.
LogoutServer-side revocation, not just local deletionDeleting locally leaves a valid token in an attacker's hands.
BiometricsA gate on a Keystore key, not a booleanA if (authenticated) check is trivially patched out; a key that will not decrypt is not.
Avoid — biometrics as a boolean
biometricPrompt.authenticate(promptInfo)
// onAuthenticationSucceeded:
override fun onAuthenticationSucceeded(r: AuthenticationResult) {
    unlockApp()                     // patch this branch out: done
    showBalance(cachedBalance)
}

The check and the protected data are independent. Patching the APK, or hooking the callback with Frida, bypasses it entirely because nothing cryptographic depends on the result.

Prefer — biometrics unlocking a key
val cipher = Cipher.getInstance(TRANSFORM).apply {
    init(Cipher.DECRYPT_MODE, keystoreKey, GCMParameterSpec(128, iv))
}
biometricPrompt.authenticate(promptInfo, CryptoObject(cipher))

override fun onAuthenticationSucceeded(r: AuthenticationResult) {
    // The cipher is only usable BECAUSE the user authenticated.
    val token = r.cryptoObject!!.cipher!!.doFinal(encryptedToken)
    session.restore(token)
}

Now the data is inaccessible without a successful authentication, enforced by hardware rather than by a branch in your code.

Chapter 95Transport and platform hardening

Certificate pinning — and its operational risk

Pinning defends against a compromised or user-installed CA. It also creates a way to brick your app remotely: if the pinned certificate is rotated and clients have not updated, every request fails and no app-side fix is possible. The Staff position is that pinning is a decision with an operational cost, and the answer must include: pin to an intermediate or use multiple pins including a backup key; set an expiry; have a remote-config kill switch for the pinning itself; and monitor pin-failure rates so a botched rotation is visible within minutes rather than after the app-store reviews.

Avoid — the classic catastrophic mistake
// "Fixing" a staging certificate error
val trustAll = object : X509TrustManager {
    override fun checkServerTrusted(c: Array<X509Certificate>, a: String) {}
    override fun checkClientTrusted(c: Array<X509Certificate>, a: String) {}
    override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
}
builder.hostnameVerifier { _, _ -> true }

This disables TLS entirely. It is written for staging and shipped to production more often than anyone admits — Google Play actively flags it. Every request becomes trivially interceptable.

Prefer — Network Security Config, per build type
<!-- res/xml/network_security_config.xml (debug flavour only) -->
<network-security-config>
  <domain-config cleartextTrafficPermitted="false">
    <domain includeSubdomains="true">staging.example.com</domain>
    <trust-anchors>
      <certificates src="@raw/staging_ca"/>   <!-- specific, not "all" -->
    </trust-anchors>
  </domain-config>
</network-security-config>

<!-- release: pins with a backup and an expiry -->
<pin-set expiration="2027-01-01">
  <pin digest="SHA-256">base64PrimaryKeyHash=</pin>
  <pin digest="SHA-256">base64BackupKeyHash=</pin>
</pin-set>

Configuration rather than code, scoped per build type, so the permissive setting cannot leak into release. The declared expiry fails open rather than bricking the app if the pin is forgotten.

WebView

If a WebView loads any content you do not fully control: disable JavaScript unless required; never enable allowFileAccessFromFileURLs; treat @JavascriptInterface as remote code execution surface and expose the absolute minimum; and allow-list the loadable hosts. A WebView with a JS bridge and an attacker-controlled URL is the most severe vulnerability commonly found in Android apps.

Chapter 96Integrity and realistic expectations

Interview question · reveals security maturity instantly

"Product wants to prevent users on rooted devices from using the app. How would you do it?"

What is being tested

Whether you will implement a request uncritically, and whether you understand that client-side integrity checks are advisory. A candidate who lists five root-detection libraries has failed; one who reframes the requirement has passed.

Answer L4

"First I'd ask what the actual risk is, because 'block rooted devices' is a solution, not a requirement. If it is fraud, root is a weak proxy — plenty of fraud comes from unrooted devices and plenty of rooted devices are developers. If it is a compliance requirement, that changes the answer, because then we need a defensible control rather than an effective one, and I would want that stated honestly.

Mechanically: any client-side detection can be defeated, because the attacker controls the runtime — Magisk hides root specifically from these checks, and Frida can hook the check itself. So detection raises effort; it does not prevent. The strongest available signal is Play Integrity, because the verdict is signed by Google's servers and validated on our server, which puts the trust decision somewhere the attacker does not control. Client-side root detection that gates a client-side branch is worth very little by comparison.

What I would actually propose: verify the Play Integrity verdict server-side; make the server decide what a device with a weak verdict may do — perhaps allow browsing but require step-up authentication for payments, rather than a binary block; monitor the verdict distribution so we can see whether the risk is real before we degrade anyone's experience; and never enforce it purely client-side.

And I'd name the cost, because this is the part product usually has not considered: hard-blocking rooted devices generates support load and one-star reviews from a technically vocal minority, and it will not stop a determined attacker. If the goal is fraud reduction, server-side behavioural signals will almost certainly outperform it."

Follow-up questions

  1. "What if they insist on the hard block?" — I implement it with Play Integrity server-side enforcement, log the impact, and revisit with data after a month. Disagree and commit, with a measurement attached.
  2. "How would an attacker bypass Play Integrity?" — Not easily on the device, but they can attack the plumbing: replay an old verdict, or relay a verdict from a clean device. Hence nonce-binding each request and validating server-side.
  3. "Does R8 obfuscation help?" — Marginally, against casual analysis. It is a shrinking tool with an obfuscation side effect, not a security control, and treating it as one is the mistake this whole part exists to prevent.

Part XV rapid recall

  • The client is hostile territory; authorisation is enforced server-side or not at all.
  • Obfuscation ≠ encryption ≠ security. A key in the APK is not a secret.
  • Keystore keys never enter your process; gate them with setUserAuthenticationRequired and invalidate on biometric enrolment.
  • Never reuse a GCM IV; store it with the ciphertext.
  • Auth Code + PKCE in a Custom Tab; short access tokens, rotating refresh with reuse detection, server-side logout.
  • Biometrics must unlock a key, not flip a boolean.
  • Pinning needs backup pins, an expiry, a kill switch and monitoring — it is an operational risk.
  • Play Integrity validated server-side beats any client-side root detection.

Part XVI

Mobile System Design

The round that most often decides a Staff offer. One repeatable framework, an explanation of how it is scored, and eighteen complete designs — each ending in the follow-ups the interviewer will actually ask.

Chapter 97A repeatable framework

Forty-five minutes, six phases. The timings are deliberate: candidates lose the round by spending twenty minutes on requirements or by drawing boxes in minute two.

TimePhaseWhat you produce
0–5 minRequirements3–5 functional requirements, agreed and written down. Explicitly out of scope: everything else.
5–10 minNon-functionals & scaleUsers, request rates, payload sizes, offline expectations, latency targets, device mix, and the one constraint that dominates.
10–15 minAPI & data modelThe client/server contract and the on-device schema. This is the highest-signal artefact in a mobile design.
15–30 minClient architectureLayers, state flow, caching, sync, background work, and the component diagram.
30–40 minFailure & edge casesOffline, partial failure, conflict, process death, auth expiry, degraded modes.
40–45 minTrade-offs & observabilityWhat you would do differently at 10×, what you deliberately did not build, and how you would know it is broken.

The scale estimation nobody does — and it is free marks

Worked example: a chat client
10M DAU · 40 messages sent per active user per day
  → 400M messages/day ≈ 4,600 msg/s average, ~15k/s peak

Per device: 20 conversations × 500 messages retained
  → 10k rows × ~400 bytes ≈ 4 MB text + media separately
  → comfortably fits SQLite; media needs its own quota

Socket: 10M DAU, ~15% concurrent → 1.5M live connections
  → that is a backend cost, and it justifies asking whether
    we need a socket at all or whether push + fetch suffices
    for the 85% who are not actively chatting.

The point is not arithmetic accuracy. It is that the numbers drive a design decision — here, "socket only while a conversation is open, push otherwise," which is a real architectural conclusion reached from an estimate rather than from taste.

Chapter 98How mobile design interviews are scored

SignalSenior L3Staff L4
RequirementsAsks a few clarifying questionsNames the dominant constraint and designs to it; states what is out of scope
Client focusDraws a backend architecture with an app attachedKeeps the design on the device: state, storage, sync, lifecycle, battery
Offline"We'd cache it"Source of truth, staleness policy, write queue, conflict resolution
FailureHandles errors when askedEnumerates failure modes unprompted and designs degraded behaviour
Data modelVague entitiesConcrete schema with keys, indexes and a sync cursor
Trade-offsLists optionsChooses, justifies, and names the inversion condition
ObservabilityNot mentionedNames the metric that would reveal each failure mode
The most common way strong candidates fail

They design the backend. A mobile system design interview is about the client: what is stored on device, how state flows, what happens offline, how it behaves under process death and poor connectivity, and what it costs in battery and bytes. Spend two minutes on the server contract and twenty on the device.

Chapter 99API design and the mobile/backend contract

The asymmetry that defines this contract: the server can deploy hourly; installed clients cannot be updated. Every compatibility obligation therefore falls on the server, and a Staff engineer is expected to negotiate that explicitly.

  • Additive-only changes. New optional fields are safe; removing or retyping a field breaks old clients permanently. The client should ignore unknown fields and tolerate unknown enum values by mapping them to an explicit Unknown.
  • Cursor pagination, not offsets. Offsets duplicate and skip items when the underlying list changes — guaranteed on any live feed.
  • Screen-shaped endpoints reduce round trips but couple the server to UI. The honest trade: aggregate where the screen is stable and latency-critical, keep resources granular where the UI churns.
  • Errors need a machine-readable code, not just a message. Clients must branch on code; a localised string is for display only.
  • Every mutation takes an idempotency key. Establish this once, globally, rather than per endpoint.
  • Minimum supported version must be an explicit, agreed policy with a forced-upgrade mechanism — otherwise "old clients" is an unbounded liability.
A contract shape that survives client churn
GET /v1/feed?cursor=eyJ0cyI6MTcwfQ&limit=20
200 {
  "items": [ { "id": "...", "type": "post", "payload": { ... } } ],
  "next_cursor": "eyJ0cyI6MTY5fQ",     // null ⇒ end
  "server_time": "2026-08-12T10:00:00Z" // clock-skew correction
}

POST /v1/orders
Idempotency-Key: 8f14e45f-ea5c-4f2b-9d6b-7c1a2b3c4d5e
409 {
  "code": "INVENTORY_UNAVAILABLE",      // client branches on this
  "message": "That size just sold out", // display only, localised
  "retryable": false,
  "details": { "sku": "A-42" }
}

Chapter 100Design: ride-hailing with live tracking

Requirements: request a ride, see the driver approach in real time, trip state through pickup and drop-off, fare and receipt. Dominant constraint: the app must be correct and cheap in battery while running for 20+ minutes with the screen sometimes off and connectivity varying.

Scale: driver position at 1 Hz; a 20-minute trip is ~1,200 updates. At 1M concurrent trips that is 1M msg/s to the backend — so the rider's client should not receive 1 Hz; 2–4 second interpolated updates are indistinguishable to the user and cut socket traffic by 75%.

flowchart TD
  UI["Trip screen (Compose)"] --> VM["TripViewModel — trip state machine"]
  VM --> TR["TripRepository (source of truth)"]
  TR --> WS["Socket client — driver position, trip events"]
  TR --> API["REST — request, cancel, receipt"]
  TR --> DB[("Room: trip, positions ring buffer")]
  FGS["Foreground service (driver app)"] --> LOC["FusedLocation, batched"]
  LOC --> UP["Upload batcher — 5s batches, compressed"]
  PUSH["FCM"] -. "wake on trip event" .-> TR
  

Client architecture. The trip is an explicit state machine — Requesting → Matched → DriverEnRoute → Arrived → InProgress → Completed — persisted in Room, because it must survive process death mid-trip. The socket delivers position and state transitions; FCM is the fallback that wakes the app when the socket is dead. Position updates feed an interpolator so the marker animates smoothly between sparse updates rather than teleporting.

Failure modes and behaviour: socket drops → exponential reconnect with a resume token, and the UI shows last-known position with a staleness indicator rather than a frozen lie; connectivity lost entirely → trip state persists, UI degrades to "reconnecting", and on resume the client fetches trip state by ID rather than replaying missed events; app killed mid-trip → state restored from Room and reconciled with a single authoritative fetch; driver's app backgrounded → foreground service with a location type keeps uploads alive, batched to protect battery.

Trade-offs to state: socket vs polling (socket for the active trip only, because 1.5M idle sockets is a real cost); server-side vs client-side ETA (server, so all parties agree); precision vs battery on the driver side (batch uploads at 5 s, accept a small staleness).

Follow-ups

Expect these three

  1. "The rider's phone dies mid-trip." — Nothing about the trip's correctness depends on the rider's client; the server is authoritative. On restart the client fetches trip state by ID. The receipt arrives by push and email regardless.
  2. "How do you stop the marker jumping when GPS is noisy?" — Snap positions to the road network server-side, and interpolate client-side along the returned polyline rather than between raw points. Raw GPS on a phone in a car is noisy enough to look broken.
  3. "Battery for the driver app over an 8-hour shift?" — Batched uploads, no per-update wakeup, adaptive interval by speed, and a hard budget measured in a dogfood programme. This is a product-defining metric for driver retention, not a technical detail.

Chapter 101Design: food delivery

Dominant constraint: three actors (customer, restaurant, courier) observing one order whose state changes from outside the app. Correctness of state matters more than latency.

Design: the order is a server-authoritative state machine; the client never infers a transition locally. Push carries the transition; the client fetches the order to confirm rather than trusting the payload, so a lost or reordered push cannot corrupt state. Menu and pricing are cached with a short TTL and revalidated at cart-open and at checkout — the failure to design for is a price change between browsing and paying, which is a trust and support-cost issue.

Cart consistency: local-first with server reconciliation. The cart is a local draft; validation (item availability, price, minimum order) happens server-side at checkout and returns a structured diff the UI explains, rather than a generic failure.

Follow-ups: "Restaurant goes offline with an order in flight" — the order state machine has an explicit RestaurantUnresponsive state with a timeout and an auto-refund path; do not leave it in Preparing forever. "Courier tracking when the app is backgrounded?" — push-driven state, socket only while the tracking screen is visible.

Chapter 102Design: chat and messaging

sequenceDiagram
  participant U as User
  participant DB as Local DB (truth)
  participant Q as Outbox
  participant S as Server
  U->>DB: insert local_id, state = pending
  DB-->>U: message visible immediately, clock icon
  Q->>S: send(local_id, body)
  S-->>Q: ack(local_id → server_id, server_seq)
  Q->>DB: state = sent, re-sort by server_seq
  Note over S,DB: socket broadcast of the same message arrives
  S-->>DB: upsert keyed by local_id — collapses, no duplicate row
  

Dominant constraints: message ordering, exactly-once display, offline send, and multi-device convergence.

On-device schema — the part interviewers most want to see
messages(
  local_id      TEXT PRIMARY KEY,   -- client UUID, created at send time
  server_id     TEXT UNIQUE,        -- null until acked
  conversation  TEXT NOT NULL,
  sender        TEXT NOT NULL,
  body          TEXT,
  created_at    INTEGER NOT NULL,   -- client clock, for local ordering
  server_seq    INTEGER,            -- authoritative order within conversation
  state         TEXT NOT NULL,      -- pending | sent | delivered | read | failed
  INDEX(conversation, server_seq),
  INDEX(state)                      -- the outbox query
)
conversations(id, last_seq_synced, unread_count, updated_at)

Ordering: never order by timestamp — device clocks are wrong, sometimes by hours. Order by the server-assigned monotonic server_seq per conversation, and display pending messages optimistically at the tail until they are acked and re-sorted. Deduplication: the client-generated local_id is echoed by the server, so a message that arrives both as an ack and as a socket broadcast collapses to one row.

Sync: per-conversation cursor (last_seq_synced); on reconnect, fetch the gap rather than replaying everything. If the gap exceeds a threshold, fall back to a bounded resync rather than paging through months of history.

Offline send: the message is written locally as pending and enqueued; the UI shows it immediately with a clock icon. WorkManager drains the queue. A permanently-failed message must reach a failed state with a retry affordance — silently retrying forever is the worse bug.

Follow-ups

The three that always come

  1. "Same account on two devices — how do read receipts converge?" — Read state is a per-conversation high-water mark (last_read_seq), not per message. Max wins, which is conflict-free and idempotent.
  2. "How do you show unread counts without a full sync?" — The server sends the count in the conversation list payload and in push data. Deriving it locally requires full history, which you do not have on a fresh install.
  3. "End-to-end encryption?" — Changes the design materially: the server can no longer order by content or generate previews, key exchange and multi-device key distribution become the hard problem, and search must be local-only. Say this explicitly rather than adding "and it's encrypted."

Chapter 103Design: an Instagram-style feed

Dominant constraint: scroll performance with heavy media, and cursor stability on a list that changes underneath you.

Pagination: cursor-based, with the cursor encoding a snapshot boundary so newly-inserted posts do not cause duplicates mid-scroll; new content surfaces as a "new posts" pill rather than being spliced in, which also avoids scroll-position jumps. Storage: Paging 3 with a RemoteMediator into Room, so the feed is available offline and survives process death with scroll position intact.

Media pipeline: server returns multiple renditions; the client requests the one matching its display size — never the original. Prefetch two screens ahead on unmetered connections only. Memory cache sized from memoryClass, disk cache with a hard quota.

Optimistic interactions: a like writes locally, updates the UI immediately, and enqueues the mutation with an idempotency key. On failure it reverts with a quiet indicator. Never block the UI on a like round-trip.

Follow-ups: "Ranking changes between pages, and the user sees a duplicate" — the snapshot cursor is the answer; if the backend cannot provide one, dedupe client-side by ID and log the rate, because a high rate means the backend contract is wrong. "Memory during fast scroll?" — bounded caches, decode to display size, and verify with a Macrobenchmark scroll journey, not by inspection.

Chapter 104Design: video streaming

Constraints: start-up latency, rebuffer ratio, and data cost. Those three are the product.

Design: adaptive bitrate over HLS/DASH with ExoPlayer/Media3; start at a conservative rendition for fast first-frame and step up once the buffer is healthy. Buffer policy differs by network: larger buffer on unmetered, smaller on cellular to avoid wasting data the user never watches. Prefetch only the first few seconds of likely-next items, and only on Wi-Fi.

Downloads: WorkManager with an unmetered constraint, DRM licence acquisition and renewal handled separately from the media (a downloaded file with an expired licence is a common support case), and playback-position sync so resume works across devices.

Observability is the differentiator here: the metrics that matter are join time, rebuffer ratio, average bitrate delivered, and playback failure rate, segmented by network type and device tier. Say those unprompted.

Follow-ups: "Network drops mid-playback" — keep the buffered content playing, retry segment fetches with backoff, and only surface an error when the buffer is exhausted. "How do you avoid wasting the user's data?" — quality caps on cellular by default, an explicit user setting, and never prefetching on metered connections.

Chapter 105Design: a subscription streaming app (multi-surface)

Requirements: browse a catalogue, play on phone/tablet/TV, download for offline, multiple profiles per account, resume position synced across devices. Dominant constraint: the same account is used on several surfaces with different input models and wildly different network conditions, and entitlement must be correct without making the app unusable during a brief outage.

Entitlement. Checked server-side at playback start — a client-side subscription boolean is trivially bypassed and is the first thing an attacker patches. But a hard server dependency means a backend blip stops all playback, so the client caches a signed entitlement token with a bounded grace period (typically hours, not days) and degrades to cached rights if the check fails. Downloads carry their own offline licence with an explicit expiry, which is why "I downloaded it but it won't play" is almost always a licence-renewal bug rather than a media bug — worth naming, because interviewers who work on streaming have all seen it.

Profiles are a schema decision, not a UI one. Continue-watching, recommendations, maturity rating and download ownership are all per profile, so every relevant table is keyed by profile_id and every query filters on the active profile. The bug class to design out: switching profiles without clearing in-memory caches, so the previous profile's continue-watching row briefly appears — embarrassing, and on a kids' profile it is a policy violation rather than a cosmetic bug. The safe pattern is to treat a profile switch as a scoped teardown, closer to a logout than to a filter change.

Profile-scoped local schema
profiles(id, account_id, name, is_kids, maturity_rating)
watch_state(profile_id, title_id, position_ms, updated_at_server_seq,
            PRIMARY KEY(profile_id, title_id))
downloads(profile_id, title_id, file_path, licence_expires_at, bytes,
          PRIMARY KEY(profile_id, title_id))
catalogue(title_id, ...)        -- shared, not profile-scoped

Resume position across devices is a convergence problem with a forgiving conflict policy: highest position_ms per (profile, title) does not work — a user who rewatches from the start would have their position dragged forward. Use a server sequence number, last-write-wins by server order, and write positions at a throttled cadence (every 15–30 seconds and on pause/stop) rather than continuously, or you generate one write per second per viewer.

Surface divergence. TV is not a layout variation — D-pad focus navigation is a different input model, with focus order, focus memory when returning to a row, and no touch affordances. Wear and Auto differ again. The design that works: share domain, data, entitlement and playback-state logic; write presentation and navigation separately per surface; and test focus traversal on TV as a first-class requirement. Saying "it's responsive" here is a Senior answer and interviewers who own a TV app will say so.

Follow-ups

Expect these

  1. "The user's subscription lapses while they are offline with downloads." — Offline licences expire independently; playback stops at expiry and the UI explains why. Do not silently delete the files; the user may resubscribe.
  2. "Four devices playing simultaneously on a two-stream plan." — Concurrency enforcement is server-side at stream start, with a clear client-side message naming which device to stop. The client cannot arbitrate this.
  3. "How do you keep the catalogue fresh without re-downloading it?" — Versioned catalogue with delta updates and ETags; the catalogue is reference data (evictable, TTL) while watch state is user data (never evicted).

Chapter 106Design: maps and navigation

Dominant constraint: battery and thermal, during long foreground use with the screen on and GPS active.

Design: vector tiles cached on disk with an LRU quota and explicit offline-region downloads; the route is computed server-side and returned as a polyline plus manoeuvres, so recalculation is a server call — but the client must handle the offline case by continuing along the last route and queueing the recalculation. Location at the coarsest accuracy the current mode allows, with sensor fusion for dead-reckoning in tunnels.

Foreground service with a location type, a persistent notification, and adaptive update intervals by speed. Thermal: listen to thermal status and degrade — reduce frame rate, drop map effects, lengthen GPS interval — before the OS throttles you, because uncontrolled throttling looks like a crash-quality bug to users.

Chapter 107Design: e-commerce

Key tensions: catalogue staleness versus freshness, cart consistency across devices, and checkout correctness.

  • Catalogue: aggressively cached, revalidated with ETags. Stale product copy is acceptable; stale price or availability is not — so price and stock are fetched fresh on the product page and re-validated at checkout.
  • Cart: server-owned when signed in (so it follows the user across devices), local draft when anonymous, with a defined merge rule at sign-in. That merge rule is a question interviewers love: union with quantity max, and surface the change rather than silently overwriting.
  • Checkout: a resumable, idempotent, server-driven step machine. The client asks "what is the next step" rather than encoding the flow, because payment and compliance steps vary by market and change without an app release.
  • Search: server-side, debounced, with cancellation of superseded queries (flatMapLatest), and a local recent-searches cache for instant perceived response.

Chapter 108Design: travel booking (flights)

Distinguishing features: search is slow (multi-supplier aggregation, seconds not milliseconds), results expire, and inventory is held rather than owned.

Design: async search — POST creates a search, the client polls or subscribes and renders results progressively as suppliers respond, so the user sees something in 500 ms rather than nothing for 6 seconds. Results carry an explicit expiry; the UI shows a countdown and re-prices on selection rather than failing at payment. The booking flow is a resumable server-side state machine with a held-inventory timer, because a user who backgrounds the app mid-booking must be able to return.

Follow-ups: "The price changed between selection and payment" — a mandatory re-price step before payment, with an explicit confirmation UI. Never silently charge a different amount; it is both a trust and a regulatory problem. "Process death mid-booking?" — the booking ID and step are persisted; on relaunch the client resumes from the server's authoritative step.

Chapter 109Design: hotel booking

Requirements: search by destination and dates, filter and sort, view on a map, book, manage the reservation. Dominant constraints: availability is volatile and supplier-owned, and the result set must be presented two ways at once without the views disagreeing.

Map/list duality is the design's core. The failure everyone hits: list and map maintained as separate states, so panning the map filters one and not the other, and a property tapped on the map is missing from the list. One source of truth for the result set, two projections driven from it, and a single explicit rule for what a map pan does — either it re-queries by viewport (and the list follows) or it does not filter at all. Ambiguity here is the bug.

One state, two projections
@Immutable
data class SearchUiState(
    val query: SearchQuery,                    // destination, dates, guests
    val results: ImmutableList<Property>,      // THE source of truth
    val viewport: MapViewport?,                // null until the map moves
    val selectedId: PropertyId?,               // shared selection
    val mode: Mode,                            // List | Map | Split (tablet)
)
// List renders `results`; the map renders the same `results` as pins.
// Selection is one field, so tapping a pin highlights the list row.

Filtering is split deliberately: filters that affect correctness or price (dates, guests, refundability) are server-side, because the client cannot know availability. Filters that merely narrow already-loaded results (star rating, amenities present in the payload) are applied locally for instant feedback, with a clear indication when the local subset is incomplete. Mixing these silently is how users end up believing a property is unavailable when it was simply not in the loaded page.

Inventory and price behave as in Chapter 108: results carry an expiry, selection triggers a re-price, and booking holds inventory on a timer. The client shows the countdown rather than failing at payment.

Offline requirements are asymmetric and worth stating. Search is useless offline — do not fake it. But a confirmed booking must be fully available with no network: confirmation number, property address and phone, check-in time, cancellation policy, and a map snapshot if you can afford one. The user will open this in an airport, in another country, with no data and a nearly-flat battery. Designing the reservation record as a self-contained offline document, rather than as a view over cached API responses, is the difference between an app that works when it matters and one that shows a spinner.

Follow-ups

Expect these

  1. "The user changes dates while viewing a property." — Invalidate price and availability immediately and show it as unknown while re-querying. Never carry the old price forward against new dates.
  2. "Thousands of pins on the map." — Server-side clustering by viewport and zoom; the client renders clusters, not properties. Client-side clustering of a large set is a main-thread cost.
  3. "The user cancels while offline." — Queue it as a pending operation with an idempotency key, but be honest in the UI: it is requested, not cancelled, until confirmed — because cancellation windows have financial consequences.

Chapter 110Design: a news reader

Requirements: browse sections, read articles, offline reading, breaking-news alerts, read state synced across devices. Dominant constraints: prefetch economics (you are spending the user's storage and data speculatively) and content licensing, which for some publishers forbids caching at all.

Prefetch is a budget decision, not a feature. Scheduled work on unmetered connections with a hard storage quota, prioritised by the sections the user actually opens. Articles stored with a TTL and an explicit "available offline" indicator, so the offline experience is honest rather than a surprise. The metric that justifies the whole subsystem is the prefetch hit rate — the fraction of prefetched articles actually read. If it is low, you are burning storage and data for nothing, and that is an argument you can take to a product review with numbers rather than opinion.

Read state is a small, high-churn dataset: a set of article IDs with timestamps. Sync it as a delta with last-write-wins per article, and say explicitly why that policy is acceptable here — the cost of a wrong resolution is that an article shows as unread. Contrast this with the notes app in Chapter 115, where the same policy would destroy user writing. Being able to justify a cheap conflict policy is as much a signal as designing an expensive one.

Breaking news uses high-priority push, which is a finite reputational resource: abuse it for engagement and the platform degrades your delivery for the messages that matter. Practically that means a governance rule — high priority for genuine time-critical alerts, normal priority for editorial pushes — and a metric tracking the ratio.

Licensing is the constraint outsiders miss. Some content is display-only with no caching permitted, some has a retention limit, some must be removed on takedown. The data model therefore needs a per-article caching policy field, and takedown must purge local copies — which means the sync protocol needs tombstones for content removal, not just for user deletions.

Chapter 111Design: a social network

The feed itself is Chapter 103. What makes a social client distinct are four client-side problems that are each a common production bug.

Notification coherence. A notification must not persist after the user has seen the content elsewhere. That requires server-side dismissal: the server tracks notification state and pushes a dismissal to other devices, because a client cannot know what happened on a different phone. Without it, users clear the same notification on three devices — a small annoyance that generates a surprising volume of complaints.

Account switching is where the serious bugs live. Local storage must be fully partitioned per account — separate database files or a mandatory account column enforced at the DAO level — plus separate image caches, separate outboxes, and separate in-memory state. The catastrophic version of this bug is a queued post from account A being sent from account B after a switch. The safe design treats a switch as a full teardown and rebuild of the object graph, not as a variable change.

The rule that prevents cross-account leakage
// Every user-scoped dependency is created in an account-scoped
// component that is destroyed on switch or logout.
@AccountScope
class AccountComponent(val accountId: AccountId) {
    val db: AppDatabase          // separate file per account
    val outbox: SyncQueue        // never shared
    val imageCache: DiskCache    // separate directory
}

fun switchAccount(to: AccountId) {
    current.outbox.pause()       // do NOT drain into the new account
    current.close()              // tear down everything user-scoped
    current = AccountComponent(to)
}

Privacy state must be enforced server-side. Client-side filtering of blocked users leaks the existence of the content — it is visible in the payload, in a proxy, and to anyone who patches the client. Blocking, private accounts and restricted visibility are authorisation decisions, and authorisation on the client is not authorisation.

Moderation surfaces impose an unusual requirement: removed content must disappear from local caches too. That means content removal is a synced event with a tombstone, image caches are purgeable by content ID, and there is a path for an urgent global purge. Most offline-first designs only consider user-initiated deletion; a moderation takedown is a deletion the user did not perform and cannot decline, and the sync protocol has to accommodate it.

Chapter 112Design: a banking application

Dominant constraint: never display a number that is wrong. Correctness dominates latency, which inverts most of the caching intuition from consumer apps.

  • Balance: shown with an explicit "as of" timestamp when served from cache, and refreshed on foreground. A stale balance presented as current is a complaint and potentially a regulatory issue.
  • Session: short, with step-up authentication for transfers and payee changes. Biometrics gate a Keystore key (Part XV), not a boolean.
  • Local storage: encrypted; transaction history cached for offline viewing but pending transfers never stored as "complete" until server-confirmed.
  • Screenshot and background: FLAG_SECURE on sensitive screens, and a privacy overlay in the recents preview.
  • Audit: security-relevant client events (device change, biometric enrolment change, failed auth) reported server-side, because the client's own log is not trustworthy evidence.

Chapter 113Design: payments

stateDiagram-v2
  [*] --> Draft
  Draft --> Committed: user confirms — key generated and PERSISTED
  Committed --> Submitted: POST with Idempotency-Key
  Submitted --> Succeeded: 2xx
  Submitted --> Failed: 4xx, deterministic
  Submitted --> Unknown: timeout / no response / process death
  Unknown --> Submitted: retry, same key
  Unknown --> Succeeded: reconciliation says it landed
  Unknown --> Failed: reconciliation says it did not
  Succeeded --> [*]
  Failed --> [*]
  note right of Unknown
    UI shows "processing", never "failed".
    Showing failure here is what makes
    users pay twice.
  end note
  

This is Chapter 63's idempotency problem as a full design. The core state machine: Draft → Committed(key persisted) → Submitted → Unknown | Succeeded | Failed, where Unknown is a first-class state rather than an error.

The rule that prevents double charges
// 1. Key is created when the USER commits, persisted BEFORE the call.
val op = PendingPayment(
    id = UUID.randomUUID().toString(),   // == Idempotency-Key
    amount = amount, payeeId = payee.id,
    state = Committed,
)
db.pending().insert(op)                  // survives process death

// 2. Every attempt — including after a restart — reuses op.id.
// 3. No response ⇒ state = Unknown, NOT Failed. Reconcile by polling
//    status with the same key, or wait for push confirmation.
// 4. UI never shows "failed" for an Unknown; it shows "processing".

3DS and app switching add a re-entry problem: the user leaves to a bank app or a browser and returns, possibly after process death. The payment state must be recoverable from the persisted key alone, and the return deep link must be validated. Follow-ups: "What if the server's key retention is shorter than your offline queue's lifetime?" — a correctness gap; either bound the queue's lifetime to match, or require reconciliation by order status rather than by key. Spotting that mismatch is a strong L4/L5 signal.

Chapter 114Design: real-time fleet tracking

The inverse of ride-hailing: the client is a producer of high-frequency data. Design: batch positions into 5–30 second windows depending on speed and battery; compress (delta-encode coordinates, which typically shrinks payloads by an order of magnitude); persist the batch locally before upload so nothing is lost to a crash; and apply client-side backpressure — when the queue exceeds a threshold, downsample rather than growing unboundedly, keeping every N-th point plus all points where direction changed sharply.

That downsampling policy is the whole answer to "the driver was in a dead zone for two hours": you cannot upload everything, so decide in advance what fidelity you sacrifice.

Chapter 115Design: an offline-first notes app

The canonical local-first design. Local database is the source of truth; the UI never waits on the network. Every mutation writes locally and appends to an outbox. Sync is a delta protocol with a server cursor.

Sync loop with conflict handling
suspend fun sync() {
    // 1. Push: drain the outbox in order, idempotently.
    outbox.pending().forEach { op ->
        when (val r = api.apply(op.toRequest(), key = op.id)) {
            is Applied  -> outbox.markDone(op.id)
            is Conflict -> resolve(op, r.serverVersion)   // see below
            is Retryable-> return                        // stop; try later
            is Permanent-> outbox.markFailed(op.id, r.reason)  // surface to user
        }
    }
    // 2. Pull: delta since the cursor, applied in one transaction.
    var cursor = meta.cursor()
    do {
        val page = api.changes(since = cursor, limit = 200)
        db.withTransaction {
            page.changes.forEach { applyRemote(it) }
            meta.setCursor(page.nextCursor)
        }
        cursor = page.nextCursor
    } while (page.hasMore)
}

Conflict policy for notes: per-note last-write-wins loses text, which users notice immediately. The defensible options are three-way merge on the text (base, local, remote), or — the pragmatic one — keep both as a conflict copy and tell the user. Silently discarding a user's writing is the one outcome that is never acceptable, and saying that explicitly matters more than the algorithm you choose.

Chapter 116Design: collaborative documents

OT vs CRDT: OT needs a central server to transform and order operations, which is simpler on the client but useless offline. CRDTs converge without coordination — the right choice for a mobile client that is regularly offline — at the cost of metadata growth and a much harder debugging story.

Mobile-specific realities to raise: CRDT metadata for a long-lived document can exceed the document itself, so you need compaction or a snapshot-plus-tail model; presence and cursors go over a socket and are ephemeral (never persisted); and a large document cannot be held fully in memory on a low-end device, so you need windowed loading, which interacts badly with naive CRDT implementations. Being able to name that interaction is what distinguishes an L5 answer from a recital of "CRDTs converge."

Chapter 117Design: large-scale search

Latency budget: typeahead feels instant under ~100 ms, acceptable to ~300 ms. Since a network round trip alone is often 200 ms, the design must hide latency: show local recent and popular searches instantly, render results progressively, and never clear the current results while the next query is in flight (the flicker that makes search feel broken).

Pipeline: debounce 250–300 ms → distinct → minimum length → flatMapLatest to cancel superseded requests → results. Local index (SQLite FTS) for user-owned content, which works offline and is genuinely instant; remote for the corpus.

Instrumentation: the metrics that matter are time-to-first-result, abandonment rate, zero-result rate, and result-click position. Zero-result rate is the one that drives product change, and it is usually not instrumented.

Part XVI rapid recall

  • Five minutes on requirements, ten on non-functionals and scale, twenty on the client, ten on failure and trade-offs.
  • Do an estimate and let it drive a decision — that is the point of it.
  • Server-authoritative state, client-confirmed: push carries the signal, the client fetches the truth.
  • Cursor pagination with a snapshot boundary; never offsets on a live list.
  • Order by server sequence, not device clocks.
  • Idempotency key created at user commit, persisted before the call, reused across process death.
  • Unknown is a first-class outcome for any mutation, not an error state.
  • Name the metric that would reveal each failure mode — unprompted.

Part XVII

Sync & Distributed Concepts

The distributed-systems subset that genuinely applies to a device that is frequently offline, occasionally wrong about the time, and one of several clients writing the same data.

Chapter 118Local-first architecture

Offline-first is not "cache the responses." It is an inversion of control: the local database is the source of truth, and the network is a background process that reconciles it with the server. The UI never awaits the network for a read, and never awaits it for a write either.

flowchart LR
  UI["UI"] --> S["State holder"]
  S --> R["Repository"]
  R --> DB[("Local DB — source of truth")]
  DB --> R
  R --> OB[["Outbox"]]
  OB --> SY["Sync engine (WorkManager)"]
  SY --> API["Server"]
  API --> SY
  SY --> DB
  PUSH["Push"] -. "wake" .-> SY
  
AspectOnline-firstOffline-first
Read pathNetwork → UI, cache as a fallbackDB → UI, always
Write pathAwait server, then update UIWrite locally, enqueue, reconcile
Failure UXError screens and spinnersDegraded but functional; sync indicators
ComplexityLowHigh — conflicts, queues, schema, reconciliation
When to chooseData is inherently server-live (prices, availability, feeds)Users create content, or connectivity is genuinely unreliable
The trade-off to state out loud

Offline-first roughly doubles the state space of every feature and introduces a permanent class of conflict bugs. It is the right choice when the user authors data or works in poor connectivity; it is over-engineering for a price-comparison app whose data is meaningless when stale. "Should everything be offline-first?" is a Part XXV trap question, and the answer is no.

Chapter 119Sync engines and delta synchronisation

A sync engine has four responsibilities and they must be separable in code, because they fail differently: push local changes, pull remote changes, resolve conflicts, and reconcile after failure.

Avoid — timestamp-based sync
val since = prefs.getLong("last_sync", 0)
val changes = api.changes(since = since)
applyAll(changes)
prefs.putLong("last_sync", System.currentTimeMillis())  // client clock!

Three defects: the client clock may be wrong or moved backwards by the user; a change written server-side during the request window is missed permanently; and if applyAll crashes halfway, the cursor advances anyway and the data is silently lost. Timestamp sync loses data quietly, which is the worst failure mode.

Prefer — server-issued opaque cursor, transactional
var cursor = meta.cursor()          // opaque; the server defines it
do {
    val page = api.changes(since = cursor, limit = 200)
    db.withTransaction {
        page.changes.forEach(::applyRemote)
        meta.setCursor(page.nextCursor)   // advances ONLY with the data
    }
    cursor = page.nextCursor
} while (page.hasMore)

The cursor and the data advance atomically, so a crash resumes exactly where it stopped. The cursor is opaque — the client must not interpret it, which lets the server change its pagination strategy without breaking installed clients.

Tombstones

Deletions must be synced as explicit tombstone records. Without them, a client that was offline during a deletion never learns about it and the row lives forever — which presents as "deleted items keep coming back," one of the most reported sync bugs. Tombstones need a retention window agreed with the backend, and a client that has been offline longer than that window must perform a full resync rather than a delta.

Chapter 120Conflict resolution

sequenceDiagram
  participant A as Device A (offline)
  participant S as Server
  participant B as Device B (offline)
  Note over A,B: both hold base version 7
  A->>S: PATCH note 42, version 7
  S-->>A: 200, now version 8
  B->>S: PATCH note 42, version 7
  S-->>B: 409 VERSION_CONFLICT + current record (v8)
  Note over B: resolve by the TYPE's policy —
LWW, per-field merge, server wins,
or keep both as a conflict copy B->>S: PATCH note 42, version 8 S-->>B: 200, now version 9

Device B needs three things to merge rather than overwrite: its local edit, the server's current record, and the base it started from. Clients that keep only the first two can overwrite but can never merge.

StrategyHow it worksRight for / wrong for
Last-write-wins (server clock)Highest server timestamp survivesRight for low-value, high-churn fields (read state, settings). Wrong for user-authored text — it silently deletes work.
Per-field mergeMerge non-overlapping field changesRight for records with independent fields — a profile where one device changed the name and another the avatar.
Server arbitrationServer holds the rules and decidesRight whenever correctness is business-critical: inventory, balances, bookings.
Conflict copyKeep both, surface to the userRight for documents and notes. Ugly but honest; users prefer it to lost work.
CRDTTypes that merge deterministicallyRight for collaborative editing and counters. Wrong where metadata growth or debuggability matters more than automatic convergence.

The clock problem: never resolve conflicts with client timestamps. Devices have wrong clocks — users change them, timezones shift, and some devices drift by minutes. Use server-assigned versions or sequence numbers. If you must use a client timestamp for ordering, correct it against a server-provided server_time on every response and store the offset.

Version-based optimistic concurrency — the workhorse
// Client sends the version it last saw.
PATCH /notes/42        { "version": 7, "title": "Groceries" }

// Server: if current version != 7 → 409 with the current record.
409 { "code": "VERSION_CONFLICT", "current": { "version": 9, ... } }

// Client resolves according to the type's policy:
when (val r = api.patch(note, version = local.version)) {
    is Ok       -> db.upsert(r.record)                 // version advances
    is Conflict -> when (policy) {
        ServerWins  -> db.upsert(r.current)
        FieldMerge  -> db.upsert(merge(local, r.current, base))
        KeepBoth    -> { db.upsert(r.current); db.insert(local.asCopy()) }
    }
}

Chapter 121Idempotency and the unknown outcome

Distributed systems deliver at least once. Exactly-once delivery does not exist; exactly-once effect is achievable, and only through idempotency. The three places this bites a mobile client:

  1. Client → server writes. Solved with a persisted idempotency key (Parts IX and XVI).
  2. Server → client push. FCM may deliver a message more than once, or reorder it. Push handlers must be idempotent — which is another argument for treating push as a signal to fetch rather than as data to apply.
  3. Sync replay. Applying the same delta page twice must be a no-op, which means upserts keyed by server ID, never blind inserts.
Avoid — trusting push payloads
override fun onMessageReceived(msg: RemoteMessage) {
    val m = msg.data.toMessage()
    db.insert(m)                  // duplicate on re-delivery
    unread++                      // and the count drifts forever
}

Duplicate delivery inserts twice; out-of-order delivery corrupts the sequence; and a payload from a stale push shows content the user already deleted.

Prefer — push as a wake signal
override fun onMessageReceived(msg: RemoteMessage) {
    val conversationId = msg.data["cid"] ?: return
    // Idempotent by construction: fetching twice is harmless.
    SyncWorker.enqueueUnique(context, conversationId)
}

The payload is a hint; the server remains the source of truth. This also solves payload size limits and means a missed push degrades to a delay rather than to missing data.

Chapter 122Real-time transport

TransportStrengthsMobile-specific cost
WebSocketBidirectional, low latencyDies in Doze and on network changes; needs reconnection, resume tokens and a heartbeat; drains battery if held while backgrounded.
SSE / long pollSimpler, works through more proxiesOne-directional; still a held connection with the same background problems.
FCM pushOS-managed, survives Doze, costs nothing to keep "open"Latency of seconds; delivery is best-effort; high-priority quota is a finite reputational resource.
PollTrivially simple, cache-friendlyLatency versus battery trade-off; wasteful when nothing changed. Fine as a fallback.

The standard Staff answer: hybrid. Socket only while the relevant screen is foregrounded; push as the wake signal when backgrounded; a delta fetch on every resume to close gaps. Explain the reconnection protocol — exponential backoff with jitter, a resume token so the server can replay missed events, and a bounded gap after which you resync rather than replay.

Chapter 123Multi-device consistency and client-side backpressure

Multi-device turns several single-client problems into distributed ones: read state must converge (use a monotonic high-water mark, which is conflict-free); notifications must be dismissible across devices (server-side dismissal); and logout on one device must not silently invalidate a pending write queue on another.

Logout is the underrated case. The correct behaviour needs a product decision, and a Staff candidate should force it: block logout while the outbox is non-empty, warn and discard, or complete the queue in the background before clearing. What is never acceptable is clearing local data with pending writes still queued, because the user believes their work was saved.

Client-side backpressure and rate limiting matter because your app can be the source of an outage. A client that retries aggressively on failure, multiplied by ten million devices, is a denial-of-service against your own backend. Every client needs: jittered backoff (Part IX), a circuit breaker that stops hammering a failing endpoint, a cap on queued operations with a downsampling policy, and respect for Retry-After.

Interview question · the classic offline-first opener

"Two devices edit the same record while both are offline. What happens?"

Short answer

It depends on the record's conflict policy, which must be defined per data type at design time — not discovered when the first conflict occurs in production.

Deep answer L4

"First I'd reject the idea that there is one answer for the whole app. The policy belongs to the data type, and I'd want it written down per type: read state is last-write-wins because the cost of getting it wrong is nil; a profile is per-field merge because the fields are independent; an inventory reservation is server-arbitrated because only the server knows the truth; and a note's body is either a three-way merge or a conflict copy, because silently losing someone's writing is the one outcome we never ship.

Mechanically, both devices carry the base version they last saw. Whichever syncs first advances the server version. The second gets a 409 with the current record, and applies its type's policy — for a merge it needs the base, which means the client must retain the last-synced version, not just the local mutation. That is the detail people miss: without the base you cannot distinguish 'I changed this field' from 'they changed it', so you cannot merge, only overwrite.

For observability I'd instrument the conflict rate per type. A rate that is higher than expected usually means the data model is wrong — two independent things sharing one record — and that is a design fix, not a merge-algorithm fix.

And testing: conflicts are the hardest sync case to test manually, so I'd build a deterministic harness with two simulated clients and a fake server that can be told which write lands first, and run every policy through it."

Follow-up questions

  1. "What if sync fails halfway through applying a page?" — The cursor advances only inside the transaction that applies the page, so it resumes exactly where it stopped. Never advance the cursor optimistically.
  2. "What if local storage is corrupted?" — Detect on open, preserve the outbox if readable, rebuild everything else from the server, and tell the user rather than silently resetting. If the outbox is unreadable, that is a data-loss event and it should be reported as one.
  3. "What if the server returns duplicate events?" — Idempotent application keyed by server ID. Blind inserts turn an at-least-once feed into duplicated rows.
  4. "How do you test the whole thing?" — Deterministic two-client harness for conflicts, fault injection for partial failure, a fake clock for TTLs, and a soak test at realistic data volume. Volume matters — sync bugs hide at small scale.
  5. "What breaks first at 10× data?" — The full-resync path and the delta page size. A user with 500k records cannot bootstrap the way a user with 500 does, which usually means a paged bootstrap with progressive availability rather than an all-or-nothing sync.

Part XVII rapid recall

  • Offline-first inverts control: DB is truth, the network reconciles. It doubles the state space — choose it deliberately.
  • Opaque server cursors, advanced inside the same transaction as the data.
  • Deletions need tombstones and a retention window; longer offline than the window ⇒ full resync.
  • Conflict policy is per data type, and the client must retain the base version to merge at all.
  • Never resolve conflicts with client clocks.
  • Push is a wake signal, not data — that makes handlers idempotent for free.
  • Socket while foregrounded, push while backgrounded, delta fetch on resume.
  • Your client can DoS your backend: jittered backoff, circuit breaker, bounded queues.
  • Logout with a non-empty outbox is a product decision, and it must be made before launch.

Part XVIII

Legacy Code & Migration

Staff engineers are hired to change systems that are already running and cannot stop. Every migration here is treated as a programme with risk, rollback, adoption and metrics — never as a rewrite.

Chapter 124The case against rewrites

The rewrite argument is always the same: the old system is unknowable, the new one will be clean, and it will take one quarter. The observed outcome is also always the same. Three reasons, worth being able to articulate:

  • The old system encodes years of undocumented requirements — the edge cases, the OEM workarounds, the regulatory rule added after an incident. A rewrite rediscovers them one production bug at a time.
  • Two systems must be maintained during the transition, so velocity halves precisely when the organisation is being asked to be patient.
  • There is no incremental value. A rewrite delivers nothing until it delivers everything, which makes it the first thing cut when priorities shift — leaving you with two half-systems, the worst possible state.
PatternMechanismUse when
Strangler figNew code intercepts at a boundary; old code shrinks until it can be deletedDefault for almost everything on mobile
Branch by abstractionIntroduce an interface, implement it twice, switch behind a flagSwapping an infrastructure component (networking, DI, storage)
Parallel runBoth implementations execute; compare results, only one is usedHigh-risk logic where correctness must be proven: pricing, sync, crypto
New-code-only ruleOld code frozen, all new work uses the new approachCheap first step for any migration; buys time and stops the bleeding
Big-bang rewriteReplace wholesaleAlmost never. Defensible only for a genuinely small, isolated, well-specified component
Branch by abstraction, with a kill switch — the safest shape
interface PaymentClient { suspend fun charge(req: ChargeRequest): ChargeResult }

class LegacyPaymentClient(...) : PaymentClient
class NewPaymentClient(...)    : PaymentClient

class RoutingPaymentClient(
    private val legacy: PaymentClient,
    private val new: PaymentClient,
    private val flags: FeatureFlags,
) : PaymentClient {
    override suspend fun charge(req: ChargeRequest): ChargeResult =
        if (flags.isEnabled("payments.new_client", req.userId)) {
            runCatching { new.charge(req) }
                .onFailure { metrics.count("new_client_failure") }
                .getOrElse { legacy.charge(req) }      // automatic fallback
        } else legacy.charge(req)
}

Three properties this gives you and a rewrite does not: rollout by percentage, instant rollback without a release, and a metric comparing the two paths. That is what makes a migration safe rather than merely planned.

Chapter 125The eight canonical Android migrations

MigrationInterop mechanismThe trap
Java → KotlinFull bidirectional interop; convert file by fileAuto-converted code is full of platform types and !!. Converting a file without reviewing nullability makes the code less safe.
XML → ComposeComposeView in layouts, AndroidView in ComposeInterop at the leaf level (a Compose item inside a RecyclerView) performs badly and complicates state. Migrate whole screens, not fragments of them.
RxJava → Coroutineskotlinx-coroutines-rx3: asFlow(), rxSingle {}Rx's error handling is not equivalent. An onError that terminated a stream becomes an exception that cancels a scope — a semantic change that surfaces as unexpected screen teardown.
LiveData → StateFlowasFlow(), asLiveData()LiveData is lifecycle-aware by default; StateFlow is not. Migrating without repeatOnLifecycle converts a safe observer into a background-running collector.
MVP → MVVM/MVIScreen by screen; no shared machineryPresenters often hold view references and imperative state. Porting the presenter verbatim into a ViewModel reproduces the problem with new names.
Monolith → modulesExtract leaves first (Part VIII)Extracting features before shared infrastructure creates cycles and forces the :core:shared anti-pattern.
SQLite → RoomRoom over the existing DB file with a matching schema and a starting versionThe legacy schema often violates Room's expectations (missing primary keys, loose types). Validate with MigrationTestHelper against a real production database file, not a fresh one.
Manual DI → HiltCoexistence via @EntryPoint into the legacy graphAttempting the whole graph at once. Migrate leaves upward, keeping the legacy container reachable from the new one.
Avoid — a semantically wrong Rx migration
// Rx: error terminates this stream only; the screen survives.
repo.observeUser()
    .subscribe({ render(it) }, { showError(it) })

// Naive port: an exception here cancels viewModelScope,
// killing every other collector in the ViewModel.
viewModelScope.launch {
    repo.observeUser().collect { render(it) }
}

A behaviour change disguised as a syntax change. It presents in production as unrelated parts of the screen going blank after one failure.

Prefer — model the failure explicitly
viewModelScope.launch {
    repo.observeUser()
        .map<User, UserState> { UserState.Ready(it) }
        .catch { emit(UserState.Failed(it.retryable)) }   // contained
        .collect { _state.update { s -> s.copy(user = it) } }
}

Failure becomes a value in this stream, matching the Rx semantics you are replacing. The general rule for any migration: enumerate the behavioural differences before converting a line of code.

Chapter 126Technical debt as a managed portfolio

The word "debt" is useful precisely because it implies interest. The Staff skill is quantifying that interest so it competes fairly with feature work.

ClassExampleCarrying cost — how to measure it
Velocity debt18-minute builds; a 4,000-line God classEngineer-hours per week; PR cycle time on affected files
Defect debtAn untested legacy sync pathIncidents and support tickets per quarter attributable to it
Risk debtAn unmaintained SDK; a pinned certificate with no rotation planProbability × impact; sometimes a single existential event
Opportunity debtAn architecture that blocks a planned product capabilityDelay in quarters to the roadmap item it blocks
Deliberate debtA shortcut taken to hit a launch, with a documented expiryLegitimate — this is the only class that is a decision rather than a symptom

When debt is the right answer: for code that is about to be deleted, for a validated-then-discarded experiment, or when time-to-market genuinely dominates. The failure is not taking the shortcut; it is taking it without recording the decision, the cost and the expiry. A candidate who says "I always pay down debt immediately" is describing a preference, not a judgment.

Chapter 127Evaluating Kotlin Multiplatform

Interview question · increasingly common at Staff level

"Should we adopt KMP? How would you decide?"

What is being tested

Whether you evaluate a technology against your organisation's actual constraints, or adopt it because it is current. Both "yes, it's the future" and "no, it's immature" are weak answers.

Answer L4

"I'd frame it as a question about where duplication is actually costing us, and start by measuring rather than assuming.

Where KMP genuinely pays: logic that is identical on both platforms and changes often — domain rules, validation, a sync engine, analytics event definitions, API models. If we have had three bugs this year where Android and iOS implemented the same business rule differently, that is a concrete number and it is the case for adoption.

Where it does not: UI. Compose Multiplatform is viable, but on iOS it costs platform fidelity and hiring flexibility, and UI is where the platforms genuinely differ. I would not share UI in a first adoption.

The costs people underestimate: build complexity and CI time; debugging across the boundary, which is meaningfully worse; library availability for anything platform-adjacent; and — the largest — team topology. Shared code needs an owner. If the Android team writes it and the iOS team consumes it, you have created a dependency that will be resented on one side and deprioritised on the other. That organisational question kills more KMP adoptions than any technical one.

How I'd decide: a time-boxed pilot on one bounded domain module with real value — I'd pick something with demonstrated cross-platform divergence bugs. Success criteria agreed in advance: iOS build time impact under a stated threshold, no reduction in iOS engineers' ability to work independently, and the divergence bugs actually stopping. Then a decision with both teams present.

And the exit path, because this is close to a one-way door: shared code must remain plain Kotlin behind interfaces, so if we reverse the decision the iOS side re-implements against a known contract rather than untangling a fused codebase. I would want that written into the pilot's ground rules before we start."

Follow-up questions

  1. "What if the iOS team is against it?" — Then it probably fails regardless of technical merit, and I would say so. Shared code with an unwilling consumer becomes shared code nobody maintains. The right move is to fix the willingness question first, not to mandate.
  2. "How does hiring change?" — The Android pool is unaffected; the iOS pool narrows if you require Kotlin. Worth quantifying in a market where iOS hiring is already hard.
  3. "What about Compose Multiplatform on iOS?" — Viable, and improving, but it is a separate and much larger decision than sharing domain logic. Bundling them is how a modest, defensible proposal becomes an unfundable one.

Part XVIII rapid recall

  • Rewrites lose undocumented requirements, halve velocity, and deliver nothing until the end.
  • Branch by abstraction with a flag gives percentage rollout, instant rollback and a comparison metric.
  • Enumerate behavioural differences before converting: Rx→Flow error semantics, LiveData→StateFlow lifecycle.
  • "New code only" is the cheapest first step in any migration.
  • Migrate whole Compose screens, not leaves inside RecyclerViews.
  • Quantify debt as carrying cost so it competes with features; deliberate debt needs a recorded expiry.
  • KMP is a team-topology decision as much as a technical one; share domain, not UI, and keep an exit path.

Part XIX

Production Incidents

Twelve incidents presented the way an interviewer presents them — with incomplete information. Reason first, then read the walkthrough. The pattern being taught is the reasoning, not the answers.

Chapter 128Incident response for mobile

Mobile incidents differ from server incidents in one decisive way, and stating it early in any incident question is a strong signal: you cannot recall the binary. Everything follows from that.

PhaseServerMobile
DetectSeconds — metrics and alertsMinutes to hours — Vitals and Crashlytics lag, and the signal is diluted across versions
MitigateRoll back the deployHalt the rollout (affects new installs only) → kill switch → server-side fix → forward hotfix
Blast radiusAll users at onceBounded by rollout percentage — which is why staged rollout is the primary control
Recovery timeMinutesHours (flag) to days (release), and never 100% of users
flowchart LR
  D["Detect
Vitals, Crashlytics, support"] --> T["Triage
severity, blast radius, is it us?"] T --> M1["Halt the rollout
seconds, free, reversible"] M1 --> M2["Kill switch / remote config
minutes"] M2 --> M3["Server-side fix
hours"] M3 --> M4["Forward hotfix
days, never reaches everyone"] M4 --> P["Blameless postmortem
fix the class, not the instance"] T -. "not us: OS or Play Services rollout" .-> W["Watch and communicate"]

The order matters and it is the opposite of a server incident: the cheapest, most reversible mitigation is first, and the code fix is last because it is the slowest and never reaches 100% of users.

Severity, defined before you need it: SEV1 — data loss, security exposure, or a core flow broken for a large cohort. SEV2 — significant degradation with a workaround. SEV3 — contained, non-blocking. Publish the definitions, because arguing about severity during an incident is how thirty minutes disappear.

Roles worth naming

Incident commander (decides, does not debug), communications lead (updates stakeholders on a fixed cadence), and investigators. A Staff engineer in an incident is usually the commander, and the most common failure is that they get absorbed into debugging and stop coordinating.

Chapter 129Twelve incidents

Incident 1

ANRs increased 40% after a release

First move: segment. Version, OS, OEM, device tier, and whether the rise tracks the rollout curve. Check whether the previous version's ANR rate also rose over the same window — if it did, it is an OS or Play Services change, not us.

Likely causes, ranked: new synchronous work on a startup or resume path; a lock now held across I/O; an SDK upgrade doing main-thread work; a broadcast receiver doing work inline.

Diagnosis: read the ANR traces and examine every thread. Main blocked on a monitor means the bug is in whatever holds the lock. Mitigation: halt the rollout immediately — that is free and reversible — then fix. Prevention: StrictMode penalty-death in internal builds, and an ANR-rate halt criterion on staged rollout.

Incident 2

Battery consumption doubled

First move: Vitals excessive-wakeup and stuck-wakelock metrics, segmented by version. Battery complaints are also frequently misattributed by users, so confirm with data before investigating.

Likely causes: a wakelock not released on an error path; a periodic job whose interval was reduced; location updates continuing after the screen turns off; a socket held while backgrounded; a retry loop against a failing endpoint (which also explains a simultaneous data-usage rise).

Diagnosis: Battery Historian on a dogfood build reproducing the pattern; look for wakelock duration and wakeup count rather than CPU. The retry-loop case is the one people miss — a backend endpoint started failing, and the client's unbounded retry is now the battery bug. Prevention: wakelocks with timeouts and finally release; a circuit breaker; wakeup-count alerts.

Incident 3

Memory grows steadily over several hours; OOM crashes in long sessions

Key discriminator: a leak (retained garbage) versus unbounded growth (live, reachable, and by design). LeakCanary finds the first and is silent on the second — so silence from LeakCanary does not exonerate.

Diagnosis: heap dumps at 10 minutes and 2 hours, compared. Sort by retained size and diff the class histograms. Growth in your own collection classes points to unbounded accumulation; multiple Activity instances point to a leak.

Common cause: a StateFlow accumulating an ever-growing list of events, messages or log entries (Part XII, Exercise 4). Fix: bound the window and page from disk. Prevention: LeakCanary in instrumented CI plus a long-session soak test with a memory assertion.

Incident 4

Login fails intermittently for about 5% of users

Segment first: 5% is a suspiciously specific number — check whether it maps to a rollout bucket, a region, a device clock skew, an OEM, or a specific auth provider.

Ranked hypotheses: the token-refresh race (Part IX) invalidating rotating refresh tokens for users with concurrent requests; clock skew causing JWT nbf/exp validation to fail on devices with wrong time; a CDN or DNS issue in one region; certificate pinning failing after a partial rotation; or a race between session restore and the first authenticated request on cold start.

Diagnosis: the client must log a structured auth-failure reason — not "login failed." Without that, this is unsolvable, which makes it also a lesson: instrument the failure taxonomy before you need it. Prevention: single-flight refresh, server-side clock in responses with client offset correction, and pin-failure monitoring.

Incident 5

Offline users see stale data after reconnecting

Likely causes: the sync cursor advanced without the data being applied (a non-transactional sync — Part XVII); missing tombstones so deletions never arrive; the reconnect not triggering a delta fetch; or an observable query that is not invalidated because writes bypassed Room's change tracking (raw SQL, or a second database instance).

Diagnosis: reproduce with a deterministic harness — go offline, mutate server-side, reconnect — and log cursor progression. Compare the local row count and version against the server for one affected user. Prevention: cursor advanced only inside the applying transaction; a sync-lag metric (time since last successful sync, p95) alerted on.

Incident 6

A payment was charged twice

This is a SEV1: money, trust, and possibly a regulatory report. Mitigate first — disable the client-side retry path via remote config, and coordinate with the backend on reconciliation and refunds — then investigate.

Cause, almost always: the idempotency key was generated per attempt rather than per user commit, or it was held only in memory and regenerated after process death, or the server's key retention window expired before the client's offline queue drained.

Fix: key created at commit, persisted before the first call, reused forever for that operation (Part XVI, Chapter 113). Prevention: a test asserting the key is stable across process death; a server-side alert on duplicate charges to the same payee for the same amount within a short window — because that detects the class regardless of which client causes it.

Incident 7

Push notifications are delayed by 20+ minutes for some users

Likely causes: messages sent at normal priority rather than high (normal priority is deferred in Doze); the app in a restricted App Standby bucket after low engagement; OEM battery optimisation, which is aggressive on several manufacturers and is often the whole answer; FCM registration token rotation not being re-registered; or the fan-out being backlogged server-side.

Diagnosis: segment by OEM immediately — a delay concentrated on two manufacturers is a platform behaviour, not a bug in your code. Compare server send timestamp with client receipt timestamp; without that pair, you are guessing.

Reality to state: you cannot guarantee push latency. If the product requires timely delivery, the design must tolerate delay — fetch on resume, in-app fallback, and honest UX. Prevention: a send-to-receive latency metric, and a high-priority budget so engagement pushes cannot consume the reputational allowance that transactional ones need.

Incident 8

A Compose screen became extremely slow after a routine change

First check: the Compose compiler metrics diff between the two builds. One class becoming unstable — often by adding a List field or a type from a non-Compose module — can make a whole subtree unskippable.

Other candidates: a key removed from a lazy list; state read moved from a deferred lambda into composition; an expensive derivation added to an item body; a SubcomposeLayout introduced in a hot path; or a baseline profile that no longer covers the changed code path.

Diagnosis: Layout Inspector recomposition counts, then a system trace to distinguish composition cost from layout and draw. Prevention: Macrobenchmark on the screen's scroll journey gated in CI, and compiler-metrics diffing in the PR.

Incident 9

Cold start went from 700 ms to 2.5 s

Bisect by version: a step change points to one release; a gradual slope points to accumulation. Then bisect by content: a build with the new SDK disabled, a build with the new initialiser removed.

Ranked causes: a new SDK initialised eagerly in Application.onCreate (the most common by a wide margin); a blocking network or disk call added to startup; a baseline profile that stopped being generated — silently, in a build-config change; a content provider added by a dependency; or a large increase in classes loaded before the first frame.

Diagnosis: system trace of a cold start on a mid-tier device; look at the span between process fork and first frame. Prevention: the three-tier initialisation policy with an owner, and a Macrobenchmark startup budget failing the build on regression (Part XI).

Incident 10

Crashes only on the newest Android version

Typical causes: a behaviour change gated on targetSdk; a newly-enforced restriction (background start, exact alarms, foreground service type, package visibility); a hidden API that is now blocked; or a stricter permission requirement.

Diagnosis: the stack trace usually names the enforcement directly — SecurityException, MissingForegroundServiceTypeException, and similar are self-describing. Mitigation: if it is targetSdk-gated, the fastest safe mitigation may be a remote-config kill switch on the feature, not a targetSdk downgrade (which Play policy constrains anyway).

Prevention — the real answer: a preview-SDK testing programme. Every Android release ships behaviour changes months in advance; an organisation that first meets them in production has a process gap, not a bug. That framing is what makes this a Staff answer.

Incident 11

Analytics events dropped 30% with no crash-rate change

A silent-failure incident, and the hardest class to detect. Causes: a batching change losing the buffer on process death; events queued in a scope cancelled at navigation; a consent or privacy change suppressing them correctly (i.e. not a bug); a schema change rejected server-side with the client ignoring the error; or a sampling config misapplied.

The lesson worth stating: telemetry needs its own telemetry. A client-side counter of events enqueued versus server-side events received, reconciled daily, is what turns this from an accidental discovery into an alert. Most organisations discover analytics loss weeks later during a business review.

Incident 12

A third-party SDK outage is taking the app down

Symptom: the app hangs at launch or crashes for all users, correlated with a vendor incident rather than with any release of ours.

Cause: an SDK initialised synchronously at startup, performing a blocking network call with a long timeout — so a vendor's slow endpoint becomes our startup ANR. Or the SDK crashes on a malformed response and takes the process with it.

Immediate mitigation: remote-config kill switch on the SDK's initialisation, if one exists. If it does not, that is the finding. Prevention, and the Staff answer: every third-party SDK gets an integration standard — initialised off the critical path, wrapped behind an interface we own, guarded by a kill switch, with a timeout we control, and an owner responsible for evaluating it. "We depend on eleven SDKs and can disable none of them" is an architecture problem, not an incident.

Chapter 130Observability that makes incidents survivable

SignalWhat it must captureCommon gap
Crash-free users and sessionsBoth, segmented by version, OS, device tierReporting only sessions hides a small cohort crashing constantly
BreadcrumbsNavigation, network outcomes, key state transitionsLogging screen names but not the failure taxonomy — see Incident 4
Structured error codesMachine-readable reason on every failure path"Something went wrong" as both the UI copy and the log line
Trace correlationA request ID shared with the backendNo way to join client symptom to server cause during an incident
Sync and queue healthTime since last sync, outbox depth, conflict rateEntirely absent in most apps, and the only way to see silent data problems
PII disciplineAllow-list what may be loggedTokens and emails in breadcrumbs, discovered during a compliance audit

Telemetry cost is a real budget line. A verbose analytics client can generate gigabytes per user per year, costing money on both the ingestion bill and the user's data plan. Sampling policy, event schema review, and a cost-per-event dashboard belong to whoever owns observability — and at Staff level, that is often you.

Part XIX rapid recall

  • No rollback exists: halt rollout → kill switch → server-side fix → forward hotfix.
  • Segment before hypothesising: version, OS, OEM, tier, rollout percentage.
  • Check whether the previous version regressed too — it separates "us" from "the platform".
  • Read every thread in an ANR trace; the cause is usually the lock holder.
  • LeakCanary silence does not rule out unbounded growth.
  • Idempotency keys are per user commit and persisted, or you will double-charge.
  • Push latency cannot be guaranteed; design for delay and measure send-to-receive.
  • Instrument the failure taxonomy before the incident, or the incident is unsolvable.
  • Every third-party SDK needs a kill switch and an owner.

Part XX

The Coding Interview

Weighted toward the concurrency and systems problems that actually appear in Staff Android loops rather than generic puzzles. Every solution is production-shaped: cancellable, testable, and honest about its limits.

Chapter 131How Staff coding rounds are scored

A correct answer can still fail the round. Interviewers score the process, roughly in this order:

  1. Clarification. Bounded or unbounded? Thread-safe? What is the eviction policy? Silence here reads as an engineer who codes before understanding.
  2. API before implementation. Write the signatures first and ask whether they are right. This is the single highest-signal habit in a Staff coding round.
  3. Reasoning aloud about concurrency. "Two callers hit this line simultaneously — what happens?" said unprompted.
  4. Edge cases named before being asked. Empty input, cancellation mid-flight, failure of the underlying operation, capacity zero.
  5. Testing. What you would test, and how you would make it deterministic.
  6. Complexity and trade-offs, including what you would change in production.
The Staff differentiator

Senior candidates implement the thing. Staff candidates implement the thing and then say: "this is correct but it allocates on every access, so under a scroll I'd instead…", or "this is fine for hundreds of entries; at hundreds of thousands I'd change the data structure because…". Volunteering the limits of your own solution is the strongest signal available in this round.

Chapter 132Problem: thread-safe LRU cache

Requirements to establish: bounded by count or by size? Accessed from multiple threads? Do we need TTL? Must reads be lock-free? For this version: bounded by count, thread-safe, O(1) get and put.

Solution
class LruCache<K : Any, V : Any>(private val maxSize: Int) {
    init { require(maxSize > 0) { "maxSize must be positive" } }

    // accessOrder = true makes iteration order = access order,
    // so the eldest entry is the least recently *used*, not inserted.
    private val map = object : LinkedHashMap<K, V>(16, 0.75f, true) {
        override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, V>) =
            size > maxSize
    }
    private val lock = Any()

    fun get(key: K): V? = synchronized(lock) { map[key] }

    fun put(key: K, value: V): V? = synchronized(lock) { map.put(key, value) }

    fun remove(key: K): V? = synchronized(lock) { map.remove(key) }

    val size: Int get() = synchronized(lock) { map.size }
}

Why get must also be synchronised — the question interviewers ask to check depth: with accessOrder = true, a read mutates the linked list. An unsynchronised get can corrupt the structure or throw ConcurrentModificationException. Candidates who synchronise only writes have a subtle, intermittent bug.

Complexity: O(1) for both, with lock contention as the practical bottleneck. Follow-ups and answers: "Make reads concurrent" — segment the map, or accept approximate LRU (Caffeine's approach: buffer access records and replay them in batches). "Bound by bytes" — supply a size function and evict in a loop rather than by count. "Add TTL" — store the insert time and treat expired entries as absent on read, plus a periodic sweep so expired entries do not occupy capacity indefinitely.

Chapter 133Problem: concurrent request deduplicator

The scenario: five composables ask for the same user simultaneously on a cold start. You want one network call, five results, and correct cancellation semantics.

Solution — with the cancellation subtlety that gets asked about
class RequestDeduplicator<K : Any, V>(
    private val scope: CoroutineScope,       // shared, NOT a caller's scope
) {
    private val mutex = Mutex()
    private val inFlight = mutableMapOf<K, Deferred<V>>()

    suspend fun execute(key: K, block: suspend () -> V): V {
        val deferred = mutex.withLock {
            inFlight[key]?.takeIf { it.isActive }
                ?: scope.async {
                    try { block() }
                    finally { mutex.withLock { inFlight.remove(key) } }
                }.also { inFlight[key] = it }
        }
        return deferred.await()
    }
}

The design decision to articulate: the work runs in a shared scope, not the first caller's. If it ran in the first caller's scope, that caller navigating away would cancel the request for the other four — a genuinely nasty production bug, because it only appears when the first subscriber leaves early. The cost of the shared scope is that the work continues even if every caller leaves; if that matters, add reference counting and cancel when the count reaches zero, but say why you did it.

Edge cases: a failure must remove the entry so the next caller retries rather than awaiting a permanently-failed Deferred (the finally handles it); isActive guards against a completed-but-not-yet-removed entry; and each caller's own cancellation only cancels its await, which is exactly what you want.

Tests: launch five concurrent calls with a TestDispatcher, assert the block ran once and all five got the value; cancel the first caller and assert the others still complete; make the block throw and assert the next call re-executes.

Chapter 134Problem: debounce and throttle as Flow operators

Implementing debounce yourself — the mechanism interviewers want
fun <T> Flow<T>.debounceManual(timeoutMs: Long): Flow<T> = channelFlow {
    var job: Job? = null
    collect { value ->
        job?.cancel()                       // supersede the pending emission
        job = launch {
            delay(timeoutMs)
            send(value)                     // only if nothing arrived meanwhile
        }
    }
    job?.join()                             // let the last one through
}

// Throttle-first: emit immediately, then ignore for a window.
fun <T> Flow<T>.throttleFirst(windowMs: Long): Flow<T> = flow {
    var lastEmit = 0L
    collect { value ->
        val now = currentCoroutineContext()[TestTimeSource]?.now() ?: System.currentTimeMillis()
        if (now - lastEmit >= windowMs) { lastEmit = now; emit(value) }
    }
}

The distinction that gets asked: debounce waits for silence (right for search-as-you-type — you want the final query); throttle-first emits immediately then suppresses (right for a button that must not double-fire — you want the first event, and debounce would add latency to every tap). Choosing the wrong one produces either a laggy search or a double-submitted order.

The job?.join() line is what makes the trailing value survive when the upstream completes — omitting it silently drops the last query, which is the classic bug in hand-rolled debounce.

Chapter 135Problem: rate limiter (token bucket)

Suspending token bucket — client-side protection for your own backend
class TokenBucket(
    private val capacity: Int,
    private val refillPerSecond: Double,
    private val clock: () -> Long = System::nanoTime,
) {
    private val mutex = Mutex()
    private var tokens = capacity.toDouble()
    private var last = clock()

    /** Suspends until a token is available. Cancellable. */
    suspend fun acquire() {
        while (true) {
            val waitMs = mutex.withLock {
                refill()
                if (tokens >= 1.0) { tokens -= 1.0; return }
                ((1.0 - tokens) / refillPerSecond * 1000).toLong().coerceAtLeast(1)
            }
            delay(waitMs)                   // outside the lock — important
        }
    }

    private fun refill() {
        val now = clock()
        val elapsedSec = (now - last) / 1_000_000_000.0
        if (elapsedSec <= 0) return
        tokens = min(capacity.toDouble(), tokens + elapsedSec * refillPerSecond)
        last = now
    }
}

Two details that separate levels: delay is called outside the lock — holding a mutex across a delay serialises every waiter and destroys throughput; and the clock is injected, so the test uses virtual time instead of sleeping. Follow-up: "Why token bucket over a fixed window?" — a fixed window allows a double-rate burst across the boundary; token bucket permits a controlled burst up to capacity and then a smooth rate, which is what you actually want for API politeness.

Chapter 136Problem: offline sync queue

The most representative Staff Android coding problem, because it touches persistence, concurrency, retry and idempotency at once.

Solution sketch — the shape matters more than the syntax
@Entity(tableName = "outbox")
data class Operation(
    @PrimaryKey val id: String,          // == idempotency key
    val type: String,
    val payload: String,
    val createdAt: Long,
    val attempts: Int = 0,
    val nextAttemptAt: Long = 0,
    val state: State = State.Pending,    // Pending | InFlight | Failed | Done
    val lastError: String? = null,
)

class SyncQueue(
    private val dao: OutboxDao,
    private val api: Api,
    private val clock: Clock,
) {
    /** Drains in FIFO order. Stops on the first retryable failure so
     *  ordering is preserved — critical when operations are dependent. */
    suspend fun drain(): DrainResult {
        while (true) {
            val op = dao.nextReady(now = clock.millis()) ?: return DrainResult.Empty
            dao.update(op.copy(state = State.InFlight))
            when (val r = runCatching { api.apply(op.type, op.payload, key = op.id) }
                    .fold({ Outcome.Ok }, { it.toOutcome() })) {

                Outcome.Ok        -> dao.delete(op.id)
                Outcome.Duplicate -> dao.delete(op.id)          // idempotent replay
                Outcome.Permanent -> dao.update(op.copy(
                        state = State.Failed, lastError = "permanent"))  // surface to user
                Outcome.Retryable -> {
                    val n = op.attempts + 1
                    if (n >= MAX_ATTEMPTS) {
                        dao.update(op.copy(state = State.Failed, attempts = n))
                    } else {
                        dao.update(op.copy(
                            state = State.Pending, attempts = n,
                            nextAttemptAt = clock.millis() + backoffWithJitter(n)))
                        return DrainResult.RetryLater          // preserve order
                    }
                }
            }
        }
    }
}

Design points to say aloud: the row ID is the idempotency key, so a replay after process death is safe; InFlight must be recoverable — on startup, reset stale InFlight rows to Pending, because a crash mid-request leaves them stranded; draining stops on the first retryable failure to preserve ordering when operations are dependent (if they are independent, you can parallelise, and you should say which assumption you made); and a permanently-failed operation must be visible to the user, not retried forever or silently dropped.

Follow-ups: "How do you handle operations that depend on each other?" — either FIFO with stop-on-failure as here, or an explicit dependency field. "What if the payload references a local ID the server has not seen?" — ID mapping: when the create succeeds, rewrite queued operations that reference the local ID with the server's. That is a real and commonly-missed requirement.

Chapter 137Problem: a typed state machine

stateDiagram-v2
  [*] --> Idle
  Idle --> Loading: Search(q) / Fetch(q)
  Loading --> Ready: Loaded(items)
  Loading --> Failed: Errored(retryable)
  Loading --> Loading: Search(q2) / Fetch(q2)
  Failed --> Loading: Retry / Fetch(q)
  Failed --> Loading: Search(q2) / Fetch(q2)
  Ready --> Loading: Search(q2) / Fetch(q2)
  

Every edge is one line of the reducer, and every edge is one unit test with no coroutines, no mocks and no clock. Events with no edge from the current state are ignored rather than crashing — which is what makes a stale Loaded from a superseded query harmless.

Pure, exhaustive, trivially testable
sealed interface State {
    data object Idle : State
    data class Loading(val query: String) : State
    data class Ready(val items: List<Item>, val query: String) : State
    data class Failed(val query: String, val retryable: Boolean) : State
}
sealed interface Event {
    data class Search(val query: String) : Event
    data class Loaded(val items: List<Item>) : Event
    data class Errored(val retryable: Boolean) : Event
    data object Retry : Event
}
sealed interface Effect { data class Fetch(val query: String) : Effect }

/** Pure function: (State, Event) -> (State, Effects). No coroutines, no I/O. */
fun reduce(state: State, event: Event): Pair<State, List<Effect>> =
    when (state) {
        is State.Idle -> when (event) {
            is Event.Search -> State.Loading(event.query) to listOf(Effect.Fetch(event.query))
            else -> state to emptyList()          // ignore impossible events
        }
        is State.Loading -> when (event) {
            is Event.Loaded  -> State.Ready(event.items, state.query) to emptyList()
            is Event.Errored -> State.Failed(state.query, event.retryable) to emptyList()
            is Event.Search  -> State.Loading(event.query) to listOf(Effect.Fetch(event.query))
            Event.Retry      -> state to emptyList()
        }
        is State.Failed -> when (event) {
            Event.Retry     -> State.Loading(state.query) to listOf(Effect.Fetch(state.query))
            is Event.Search -> State.Loading(event.query) to listOf(Effect.Fetch(event.query))
            else -> state to emptyList()
        }
        is State.Ready -> when (event) {
            is Event.Search -> State.Loading(event.query) to listOf(Effect.Fetch(event.query))
            else -> state to emptyList()
        }
    }

Why separate effects from state: the reducer stays pure, so every transition is a one-line unit test with no coroutines, no mocks and no time. The runtime executes the effects and feeds results back as events. This is the structure that makes complex screens testable, and being able to explain why the effect list is returned rather than executed is the point of the question.

Edge case worth naming: a stale Loaded arriving for a superseded query. Either the effect runner cancels superseded fetches (flatMapLatest), or Loaded carries the query and the reducer ignores mismatches. Say which you chose.

Chapter 138Problem: multi-level cache with coherent invalidation

Requirements to establish: which tier is the source of truth? Must a write be visible immediately to other readers? Is the memory tier shared across screens? For this version: disk is truth, memory is a bounded read-through cache, and all readers must see a write immediately.

Solution — one write path, so the tiers cannot diverge
class ProfileStore(
    private val dao: ProfileDao,             // source of truth
    private val api: ProfileApi,
    maxMemoryEntries: Int = 64,
) {
    private val memory = LruCache<UserId, Profile>(maxMemoryEntries)
    private val single = SingleFlight<UserId, Unit>(appScope)

    /** Reads never bypass the source of truth's change stream. */
    fun observe(id: UserId): Flow<Profile?> =
        dao.observe(id)                       // ← invalidation comes free
            .map { it?.toDomain() }
            .onEach { it?.let { p -> memory.put(id, p) } }

    /** Fast path for a non-observing caller (e.g. a list mapper). */
    fun peek(id: UserId): Profile? = memory.get(id)

    /** The ONLY write path. Every tier updates from here. */
    suspend fun refresh(id: UserId) = single.run(id) {
        val dto = api.profile(id)
        dao.upsert(dto.toEntity())            // triggers observe() → memory
    }

    suspend fun clear(id: UserId) {
        memory.remove(id)
        dao.delete(id)
    }
}

The design point that scores: the memory tier is populated from the database's change stream, not from the network. That single decision makes incoherence structurally impossible — there is no path where the network updates memory without the database seeing it, which is the bug in almost every hand-rolled two-tier cache. SingleFlight prevents N concurrent refreshes for the same key (Chapter 133).

Follow-ups: "What if the memory cache is stale after a write from another process?" — Room's invalidation is process-local, so a multi-process app needs a ContentProvider or an explicit invalidation broadcast; say this rather than assuming. "How do you bound memory by bytes?" — an LruCache with a size function, sized from ActivityManager.memoryClass, and cleared on onTrimMemory.

Chapter 139Problem: bounded parallel execution

Scenario: upload 200 images. Unbounded concurrency exhausts connections and memory; sequential is too slow. Run at most N at a time, preserve failures individually, and cancel cleanly.

Solution
suspend fun <T, R> Iterable<T>.mapParallel(
    concurrency: Int,
    transform: suspend (T) -> R,
): List<Result<R>> = coroutineScope {
    val gate = Semaphore(concurrency)
    map { item ->
        async {
            // withPermit releases on cancellation as well as completion.
            gate.withPermit {
                runCatching { transform(item) }
                    .onFailure { if (it is CancellationException) throw it }
            }
        }
    }.awaitAll()
}

// Usage: 4 at a time, one failure does not abort the rest.
val results = files.mapParallel(concurrency = 4) { uploader.upload(it) }
val failed = results.count { it.isFailure }

Two details interviewers probe. withPermit rather than manual acquire/release, because a cancellation between them leaks a permit and the pool silently shrinks to zero over time — a genuinely nasty production bug. And rethrowing CancellationException out of runCatching, which otherwise captures it as a failure and breaks structured concurrency.

Follow-up: "Why not Dispatchers.IO.limitedParallelism(4)?" — That bounds threads, not in-flight operations. For suspending network calls the coroutine is not holding a thread while awaiting, so a dispatcher limit does not bound concurrency at all. This distinction is a reliable Staff discriminator.

Chapter 140Problem: merge sources with per-source failure isolation

Scenario: a dashboard combines profile, orders and recommendations. One source failing must degrade that section only — never blank the screen.

Solution
sealed interface Section<out T> {
    data object Loading : Section<Nothing>
    data class Ready<T>(val value: T) : Section<T>
    data class Failed(val retryable: Boolean) : Section<Nothing>
}

private fun <T> Flow<T>.asSection(): Flow<Section<T>> =
    map<T, Section<T>> { Section.Ready(it) }
        .onStart { emit(Section.Loading) }
        .catch { e ->
            if (e is CancellationException) throw e
            emit(Section.Failed(retryable = e.isRetryable()))   // contained
        }

val state: StateFlow<DashboardUi> = combine(
    profileRepo.observe().asSection(),
    orderRepo.observe().asSection(),
    recsRepo.observe().asSection(),
) { profile, orders, recs -> DashboardUi(profile, orders, recs) }
    .stateIn(viewModelScope, WhileSubscribed(5_000), DashboardUi())

Why catch per source, before combine: an exception reaching combine terminates the combined flow and the whole screen goes blank. Converting failure into a value inside each branch is what makes partial degradation possible. This is the same principle as Part VII's per-section state, expressed in operators.

Follow-ups: "How does retry work per section?" — each repository exposes a refresh command; the failed section's retry button calls only that one. "What if two sources are logically dependent?" — then they are one source; combine across a dependency produces an intermediate state that is briefly inconsistent, and the fix is to model the dependency upstream rather than in the UI.

Chapter 141Further problems, with the key insight for each

ProblemThe insight being tested
Retry with exponential backoffJitter; rethrowing CancellationException; classifying retryable versus not (Part IX).
Token refresh coordinationCompare against the stale token inside the lock so N concurrent 401s cause one refresh.
Paginated repository with cacheCursor keys, transactional page+key writes, and dedupe on refresh.
Event bus with replaySharedFlow replay semantics, and why replay causes duplicate navigation after rotation.
Custom Flow operatorException transparency and context preservation — do not catch around emit, do not withContext around it.
Incremental search indexTrie or SQLite FTS; the real answer is usually "use FTS", and knowing when hand-rolling is justified.
Priority work queue with starvation avoidanceAgeing: a low-priority item's effective priority rises with wait time, or long uploads never run.
Circuit breakerThree states (closed, open, half-open); the half-open probe is what candidates omit.

Part XX rapid recall

  • Clarify, then write the API, then implement. Reason about two concurrent callers out loud.
  • LRU with accessOrder: reads mutate, so reads must be synchronised too.
  • Deduplicate in a shared scope so one caller's cancellation cannot cancel everyone's work.
  • Debounce waits for silence; throttle-first emits then suppresses. Do not confuse them.
  • Never hold a lock across delay or any suspension you can avoid.
  • Outbox: row ID is the idempotency key; recover stale InFlight on startup; surface permanent failures.
  • Keep reducers pure and return effects — that is what makes them testable.
  • Volunteer the limits of your own solution; it is the strongest available signal.

Part XXI

Code Review Interviews

Snippets with layered defects, and expert reviews that model not only what is wrong but review voice — because how you deliver a review is part of what is being assessed.

Chapter 142What a Staff review looks for

Junior reviewers find style issues. Senior reviewers find bugs. Staff reviewers find the precedent a change sets — because in a 300-module codebase, a pattern merged once is a pattern copied fifty times.

SeverityMeaningExamples
BlockingCorrectness, security, data loss, or a boundary violationRace condition, leaked scope, PII in logs, feature-to-feature dependency
Should fixWill cost us later; author decides timingUntestable construction, missing failure path, unbounded collection
ConsiderA genuine option, author's callNaming, structure, an alternative operator
NoteInformation, no action"This interacts with the migration in #4412"

Label every comment. An unlabelled review is a wall of equally-weighted opinions, and the author cannot tell what blocks the merge. In an interview, saying "these two are blocking, the rest are optional" is itself a strong signal — it demonstrates you can be reviewed by as well as review.

Chapter 143Fifteen snippets

Review 1

ViewModel with four defects

Review this
class OrderViewModel(private val context: Context) : ViewModel() {
    val orders = MutableLiveData<List<Order>>()

    fun load() {
        GlobalScope.launch(Dispatchers.Main) {
            try {
                val result = withContext(Dispatchers.IO) { Api.service.getOrders() }
                orders.value = result
                Log.d("Orders", "Loaded for ${Session.token}")
            } catch (e: Exception) {
                Toast.makeText(context, "Error", Toast.LENGTH_SHORT).show()
            }
        }
    }
}

Expert review

  • [Blocking] Context in a ViewModel. The ViewModel outlives configuration changes, so this leaks the Activity and its whole view tree. Errors belong in state; the UI decides how to display them.
  • [Blocking] GlobalScope. No cancellation when the screen closes; the coroutine keeps a reference to this ViewModel. Use viewModelScope.
  • [Blocking] token in a log line. This reaches logcat, bug reports and crash breadcrumbs. Never log credentials — and I'd add a lint rule rather than only a comment, because this is the third time this quarter.
  • [Blocking] catch (e: Exception) swallows CancellationException, so a cancelled load shows an error toast on a screen the user already left.
  • [Should fix] Mutable state exposed. MutableLiveData is public, so any collaborator can write to it. Expose an immutable type.
  • [Should fix] Singletons. Api.service and Session are static, making this untestable without the whole graph. Inject them.
  • [Note] withContext(IO) is redundant if getOrders() is a Retrofit suspend function — those are already main-safe.

Voice: lead with the two that risk user harm (leak, logged token), group the rest, and offer the mechanism (lint rule) rather than only the criticism. In an interview, say which comments you would leave as questions — "is getOrders already main-safe here?" — because an experienced reviewer does not assert what they can cheaply ask.

Review 2

Compose screen

Review this
@Composable
fun ProductList(viewModel: ProductViewModel) {
    val state by viewModel.state.collectAsState()
    val formatter = SimpleDateFormat("dd MMM yyyy", Locale.getDefault())

    LaunchedEffect(state) { viewModel.trackScreenView() }

    LazyColumn {
        items(state.products) { product ->
            Row(Modifier.clickable { viewModel.onClick(product) }) {
                Text(product.name)
                Text(formatter.format(product.releaseDate))
                AsyncImage(model = product.imageUrl, contentDescription = null)
            }
        }
    }
}

Expert review

  • [Blocking] LaunchedEffect(state) re-fires on every state change, so the screen-view event is sent dozens of times. Key it on Unit, or on a stable screen identifier.
  • [Blocking] no key in items. Item state and animations attach to positions; an insertion at the head shifts everything. Add key = { it.id }, and contentType if rows are heterogeneous.
  • [Should fix] collectAsState keeps collecting while backgrounded. Use collectAsStateWithLifecycle().
  • [Should fix] formatter allocated per composition and used per item. Format in the state mapper so the UI receives a String; SimpleDateFormat is also not thread-safe.
  • [Should fix] the composable takes the ViewModel, so it cannot be previewed or screenshot-tested. Split into a stateful route and a stateless screen (Part VI).
  • [Should fix] image not sized. Constrain the request to the display size or a fast scroll will decode full-resolution bitmaps.
  • [Consider] contentDescription = null — is the image decorative? If it conveys the product, it needs a description.

Review 3

Repository

Review this
@Singleton
class UserRepository @Inject constructor(private val api: Api) {
    private val cache = mutableMapOf<String, User>()

    suspend fun getUser(id: String): User? {
        cache[id]?.let { return it }
        return try {
            val user = api.getUser(id)
            cache[id] = user
            user
        } catch (e: Exception) {
            null
        }
    }

    fun clearCache() { cache.clear() }
}

Expert review

  • [Blocking] the map is not thread-safe and this is a singleton. Concurrent writes from different screens can corrupt it. Use a mutex, a concurrent map, or move the cache to the database.
  • [Blocking] errors collapse into null. The caller cannot distinguish "no such user" from "network down" from "auth expired", so the UI cannot offer retry, and a token expiry silently looks like a missing user. Return a result type or let the exception propagate.
  • [Blocking] catch (e: Exception) swallows cancellation.
  • [Should fix] unbounded cache with no invalidation. It grows for the process lifetime and never refreshes, so a user who edits their profile keeps seeing the old one. What is the intended staleness policy?
  • [Should fix] no way to observe changes. If another screen or a sync worker updates this user, nothing propagates. Consider Room as the source of truth with an observable query.
  • [Consider] clearCache() suggests callers are managing invalidation manually, which never stays correct. Whose responsibility is it?

Review 4

Concurrency

Review this
class UploadManager(private val api: Api) {
    private var isUploading = false
    private val queue = mutableListOf<File>()

    suspend fun upload(file: File) {
        queue.add(file)
        if (isUploading) return
        isUploading = true
        while (queue.isNotEmpty()) {
            val f = queue.removeAt(0)
            api.upload(f)
        }
        isUploading = false
    }
}

Expert review

  • [Blocking] check-then-act race. Two coroutines can both read isUploading == false and both start draining, processing the same file twice — and uploads are rarely idempotent.
  • [Blocking] the list is mutated concurrently without synchronisation.
  • [Blocking] isUploading is never reset on failure. One thrown exception leaves it true forever and uploads stop silently for the rest of the session — the worst kind of bug, because there is no error anywhere.
  • [Blocking] no durability. The queue is in memory; process death loses queued uploads the user believes are pending. Uploads belong in WorkManager or a persisted outbox (Part XX).
  • [Should fix] no retry, no backoff, no cancellation handling.

The Staff comment: "Rather than fixing the mutex here, I'd replace this with the outbox pattern we already use for messages — this class is re-implementing durability and will keep acquiring the same bugs. Happy to pair on it."

Review 5

Flow usage

Review this
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
    val feed: Flow<List<Post>> = repo.observeFeed()

    val unreadCount: Flow<Int> = repo.observeFeed().map { it.count { p -> !p.read } }

    private val _events = MutableSharedFlow<Event>(replay = 1)
    val events: SharedFlow<Event> = _events
}

Expert review

  • [Blocking] replay = 1 on an event flow. After rotation the new collector immediately receives the last event, so navigation happens twice. Events need replay = 0 with buffered capacity, or a Channel.
  • [Blocking] the upstream is collected twice. observeFeed() is cold, so feed and unreadCount each start their own — two database queries or two network calls. Derive both from one stateIn.
  • [Should fix] exposing a raw Flow to the UI means no initial value and re-collection on every configuration change. Use stateIn(viewModelScope, WhileSubscribed(5_000), initial).
  • [Should fix] no error handling. An exception in the upstream cancels the collector and the screen goes blank with no message. Model failure as state.
  • [Consider] _events is exposed by upcast onlyasSharedFlow() is clearer about intent.

Reviews 6–15

Defect catalogue for self-practice

Each of these is a realistic snippet type; the defect list is what an expert review would surface. Write your own review first.

SnippetPlanted defects
6. Fragment with a listenerRegistered against application context; not unregistered; binding held past onDestroyView; anonymous inner class capturing the fragment.
7. Worker doing syncNot unique work (duplicates); no backoff; returns Result.failure() for transient errors; holds a wakelock without timeout; assumes it runs immediately.
8. Deep-link handlerNo host validation; opens a WebView at an arbitrary URL; unvalidated ID parsed straight into a query; auth check performed after the screen renders.
9. Room DAO and migrationfallbackToDestructiveMigration in release; unbounded observable query; missing index on the filter column; relation query without @Transaction.
10. OkHttp setupTrust-all manager left from staging; logging interceptor with headers in release; no timeouts configured; refresh implemented in an interceptor rather than an Authenticator.
11. Over-abstracted featureSix one-line use cases; an interface per class with one implementation; a mapper per layer with identical fields; a factory for a data class.
12. Test filedelay() to wait for async work; mocks verifying call counts; shared mutable state between tests; a real database with no reset; assertion on a formatted string that changes with locale.
13. Custom view / CanvasPaint and Path allocated in onDraw; no save/restore balance; invalidate called from a background thread; no onDetachedFromWindow cleanup for the animator.
14. DI module@Singleton on something holding an Activity context; scope mismatch producing two instances; field injection where constructor injection is possible; a provider doing I/O.
15. Feature-to-feature importDirect dependency between two feature modules; a shared model duplicated with drift; navigation by string route with no type safety; a boundary crossed that the build rules should have blocked.

Chapter 144Review as a leadership instrument

At Staff level, review is one of the few mechanisms that scales your judgment. Four practices worth being able to describe:

  • Encode repeated feedback. If you have left the same comment three times, it becomes a lint rule, a template, or a documented default. Repeating yourself in review does not scale and eventually reads as nitpicking.
  • Review the design, not the diff, when the diff is too late. A 2,000-line PR implementing the wrong architecture cannot be fixed in review. Push the conversation earlier — a design doc or a 20-minute call before the work starts.
  • Disagreeing with a senior author: state the concern, the concrete failure scenario, and the cost — then explicitly say whether it blocks. "I think this races when two screens load simultaneously; here's the interleaving. If you disagree, I'm fine merging — but I'd want a test either way."
  • Review SLAs matter more than review depth. A team where PRs wait two days ships worse code overall, because authors batch changes into larger PRs that are harder to review. Advocating for fast review is a quality intervention, not a velocity one.

Part XXI rapid recall

  • Label every comment blocking / should fix / consider / note.
  • Lead with user harm: leaks, races, data loss, logged credentials.
  • Watch for the recurring set: GlobalScope, catch (Exception), Context in a ViewModel, missing keys, unshared cold flows, replay on event flows, unbounded caches, check-then-act.
  • Ask rather than assert where a question is cheaper.
  • Name the precedent, not just the bug — that is the Staff signal.
  • Encode repeated feedback into lint; move architecture debates before the diff.

Part XXII

Architecture Review

Ten flawed architectures to critique, with the migration plan that keeps production running — because "this is wrong" is a Senior answer and "here is the sequenced path out, and what it costs" is a Staff one.

Chapter 145A critique method

Use the same five steps every time. It stops you from listing preferences and forces you to distinguish real problems from taste.

  1. What breaks, concretely? Name a failure scenario, not a principle. "This violates SRP" is weak; "two teams editing this file conflict every sprint, and last quarter it caused three revert-and-reland cycles" is not.
  2. Who pays, and how often? Every build, every new hire, every incident, every feature.
  3. What would you change? The target state, in one diagram.
  4. How do you get there without stopping? Sequence, first increment, rollback.
  5. What is the trade-off of your own proposal? If you cannot name one, you have not thought about it and the interviewer will notice.

Chapter 146Ten broken architectures

Architecture 1

"Everything lives inside the ViewModel"

What breaks: business rules cannot be tested without Android or a coroutine harness; the same rule is re-implemented in three ViewModels and drifts; a 2,000-line file is a permanent merge-conflict zone; and the rules cannot be reused by a Worker or a widget.

Change: extract pure domain logic into plain Kotlin classes with no framework dependency. Not a use case per method — one per genuine rule cluster (Part VII, Chapter 49).

Migration: purely additive and safe. Extract the logic behind existing tests, delegate from the ViewModel, verify behaviour is unchanged. Ship per screen. No flag needed because there is no behaviour change — which is exactly why it is the right first step in any larger cleanup.

Trade-off: more files, and a rule about where logic belongs that new engineers must learn.

Architecture 2

"The repository exposes UI state"

The smell
interface ProductRepository {
    fun observe(id: String): Flow<ProductUiState>   // Loading / Error / Ready
}

What breaks: the data layer now owns presentation decisions, so two screens wanting different loading behaviour cannot share it; the repository cannot be reused by a Worker (which has no UI state); errors are pre-formatted, often already localised, so testing asserts on strings; and every UI change requires a data-layer change.

Change: the repository emits domain data or throws domain errors. The state holder maps that into Loading/Ready/Failed, because loading is a UI concept — it describes the view's relationship to the data, not the data.

Migration: add the domain-typed method alongside the existing one, migrate screens one at a time, delete the old method when the last caller is gone. Standard strangler.

Architecture 3

"Every feature depends on :core, and :core has 200 files"

flowchart LR
  subgraph BEFORE["before — one edit rebuilds everything"]
    F1[":feature:a"] --> C1[":core (200 files, no owner)"]
    F2[":feature:b"] --> C1
    F3[":feature:c"] --> C1
  end
  subgraph AFTER["after — split by capability, one owner each"]
    G1[":feature:a"] --> N[":core:network"]
    G1 --> DS[":core:designsystem"]
    G2[":feature:b"] --> DS
    G2 --> DB[":core:database"]
    G3[":feature:c"] --> N
  end
    

What breaks: any change to :core recompiles the whole app — the single biggest build-time cost in most large Android codebases; every team edits it, so it is a permanent conflict surface; it has no owner; and it accumulates unrelated things because "shared" is not a criterion.

Change: split by capability, not by layer: :core:network, :core:database, :core:designsystem, :core:analytics-api. Each with one owner. Anything used by fewer than three modules moves to its actual consumer.

Migration: measure first — which :core files actually change often, and which are on the critical build path. Extract the highest-churn, widest-dependency pieces first. Do it incrementally; a big-bang split of :core conflicts with every in-flight PR in the company.

Trade-off: more modules, a longer configuration phase, and more build files — which is why convention plugins come first.

Architecture 4

"All modules depend on :app"

What breaks: this inverts the dependency direction, so nothing can be built or tested without the whole application. Feature modules cannot have isolated tests, cannot be built in parallel, and the module graph provides no benefit at all — you have the build cost of modularization with none of the isolation.

Usually caused by: shared resources, a navigation constant, or a DI component living in :app.

Change: invert. :app depends on everything and contains only assembly. Whatever features need from :app moves into a contract module they can depend on (Part VIII, Chapter 56).

Migration: extract the shared pieces one at a time, lowest-dependency first. This one is usually mechanical but wide; do it with codemods and land it in a quiet week.

Architecture 5

"Every screen has its own database"

What breaks: multiple sources of truth for the same entity. Screen A updates the user, screen B still shows the old one; an offline write queue exists in three places; migrations must be written N times; and the disk cost multiplies.

Change: one database, one entity per concept, one owning module. Screens observe queries; they do not own storage.

Migration: genuinely risky, because it touches user data. Sequence: (1) designate the canonical store; (2) dual-write to it while still reading from the old ones; (3) migrate reads screen by screen behind flags; (4) verify with a consistency metric comparing the stores; (5) delete the old stores and their data only after a full release cycle at 100%. Never delete data in the same release that stops writing it — you need a rollback path.

Architecture 6

"One gigantic shared module contains everything"

The endpoint of taking "don't repeat yourself" as an unconditional rule. What breaks: everything recompiles on any change; ownership is diffuse; unrelated concepts are coupled through it; and it grows monotonically because adding to it is always the path of least resistance.

Change and the counter-intuitive part: some duplication is correct. Two verticals with similar-but-independent models should each own theirs; forcing a shared model couples their release schedules and produces a type that satisfies neither. State this explicitly in an interview — interviewers expect a DRY reflex and reward the nuance.

Architecture 7

"Business logic in composables"

What breaks: logic runs inside recomposition, so it executes an unpredictable number of times — a side effect there fires repeatedly; it cannot be unit-tested without a Compose test harness; and it cannot be reused by any non-UI caller.

Change: composables read state and emit events. Anything conditional beyond presentation moves to the state holder. The heuristic worth quoting: if you would want to write a unit test for it, it does not belong in a composable.

Architecture 8

"Singletons hold mutable app state"

What breaks: after process death the singleton is reconstructed empty while the UI assumes it is populated — presenting as a spurious logout or an empty screen (Part IV, Chapter 27). It is also globally mutable from anywhere, untestable without reset hooks, and prone to races.

Change: state that matters is persisted and observable; the singleton becomes a cache in front of a store, not the store itself. Test: if this value disappeared right now, would the app misbehave? If yes, it must be persisted.

Architecture 9

"A domain layer that only forwards calls"

What breaks: pure cost. Forty use-case classes, forty DI bindings, forty tests asserting delegation, and a dependency graph that is harder to read because the real dependencies are hidden behind indirection.

Change: delete the pass-throughs; keep use cases with real logic. This is the rare architecture review where the correct recommendation is less architecture — and being willing to say so is a genuine signal, because most candidates only ever propose adding structure.

Trade-off, stated honestly: mixed conventions (some screens call repositories, some call use cases) confuse newcomers. Mitigate with a written rule — "use cases exist for logic, multiple sources, or reuse" — rather than by restoring uniform ceremony.

Architecture 10

"DI graph with runtime cycles and unclear scoping"

What breaks: a cycle resolved lazily produces initialisation-order bugs that appear only on certain navigation paths; scope mismatches silently create two instances of something intended to be single (so a cache exists twice and neither is coherent); and startup time grows because eagerly-scoped objects are constructed before they are needed.

Change: a compile-time-validated graph (Part V, Chapter 38); explicit scoping rules per object class; and a startup trace to catch anything expensive constructed eagerly.

Diagnostic worth mentioning: log or assert instance identity for objects that must be singletons. "Why is my cache empty?" is frequently two instances rather than a caching bug, and teams lose days to it.

Chapter 147Writing the ADR that makes it stick

A critique that ends in a conversation changes nothing. The artefact that changes behaviour is a short written decision, and interviewers sometimes ask you to produce one verbally.

ADR-014 · Repositories expose domain types, not UI state
Status      Accepted · 2026-03-14 · Owner: @mobile-arch · Review: 2027-Q1

Context     Three repositories emit Flow<XUiState>. Two screens now need
            different loading behaviour and cannot share them. Worker code
            duplicates the data path. Error strings are localised in the
            data layer, so tests assert on user-facing copy.

Decision    Repositories expose domain models and throw/emit domain errors.
            Mapping to UI state happens in the state holder.

Alternatives
  Keep as is           — rejected: blocks reuse, couples layers.
  UI state in domain   — rejected: same coupling, moved.
  Per-screen repos     — rejected: multiplies sources of truth (see ADR-009).

Consequences
  + Repositories reusable by Workers and widgets; faster tests.
  − One mapping layer per screen; ~15 files to migrate.
  Enforcement: lint rule bans `UiState` types in :core:data-* signatures.

Reversal    If mapping proves to be pure duplication across >80% of screens,
            revisit with a shared mapper rather than by re-coupling layers.

Migration   New code follows immediately. Existing: additive method, migrate
            per screen, delete old method when callers reach zero. ~2 sprints,
            no flag required (no behaviour change).

The two sections that make it real are Enforcement and Reversal. Without enforcement it is a preference; without a reversal condition it is dogma. Both are what a Staff interviewer is listening for.

Part XXII rapid recall

  • Critique with concrete failure scenarios and who pays, not with principles.
  • Always propose the migration sequence, first increment, and rollback — not just the target.
  • Name the trade-off of your own proposal or the critique reads as ideology.
  • Sometimes the right answer is less architecture; be willing to say it.
  • Never delete data in the same release that stops writing it.
  • An ADR needs enforcement and a reversal condition, or nothing changes.

Part XXIII

Behavioral & Leadership

Where Staff offers are most often lost. Not because candidates lack the experience, but because they narrate projects instead of demonstrating judgment, scope and agency.

Chapter 148How Staff behavioral rounds are evaluated

Interviewers are filling in a rubric with roughly these rows. Everything you say is being sorted into them.

SignalWeak evidenceStrong evidence
Scope"My team's feature"Work spanning teams, or a system nobody owned
Agency"My manager asked me to""I noticed, I proposed, I got it funded"
JudgmentDescribes what happenedExplains the options considered and why this one
Impact"It went well"A number, and how it was measured
Self-awarenessFailures are other people'sNames their own contribution to the failure specifically
Collaboration"I convinced them""I understood their constraint and changed my proposal"

STAR, adapted for technical leadership

Standard STAR under-serves Staff answers because it has no slot for the two things being assessed: the alternatives you weighed and the mechanism you left behind. Use STAR-DL:

Answer skeleton — roughly 3 minutes spoken
Situation   20s   Context and stakes. Include the scale: users, teams, money.
Task        15s   What was actually yours, and why it was ambiguous or hard.
Action      60s   What YOU did. Include the alternatives you rejected.
Result      30s   A measured outcome. A number, with how you measured it.
Decision    20s   The key judgment call and what would have changed it.
Learning    20s   What you do differently now — and where you have applied it since.
The "I vs we" calibration

Say "we" for the work and "I" for the decisions. Candidates who say "I" throughout read as credit-takers; candidates who say "we" throughout leave interviewers unable to score them and default to a lower level. The fix is one explicit sentence per story: "The part that was specifically mine was…"

Chapter 149Ten questions, fully graded

Question 1 · influence

"Tell me about a time you influenced a technical decision without authority."

What is being evaluated

Whether you can move an organisation, and whether your mechanism was evidence or seniority. Interviewers are listening for a prototype, data, or an absorbed cost — not "I explained why it was better."

L2
"I explained the benefits in a meeting and eventually the team agreed." No mechanism, no cost, no measurement.
L3
Adds a concrete technical argument and a successful outcome for their own team.
L4
Multi-team scope, a prototype or data as the argument, the other team's constraint understood and accommodated, a measured result, and the mechanism that made it durable.
L5
Adds that they changed the org's default so the next such decision does not need them.

Model answer L4

"Our four Android teams each had their own networking setup — different retry behaviour, different error mapping. It caused a real incident: one team's client retried 500s indefinitely and amplified a backend outage from a blip into twenty minutes.

Nobody owned it and I had no authority over those teams. I did three things. I quantified the cost: I pulled six months of incidents and found four with a networking root cause, plus roughly a day per team per quarter maintaining their own client. Then I built the shared client as a working prototype and migrated one team's screens myself — I absorbed the cost so their first experience was reviewing a PR, not doing a project. And I asked each team lead what they actually needed; the payments team had a hard requirement about not retrying certain calls, which my first design got wrong. Changing it for them was what turned a skeptic into an advocate.

Results: all four teams migrated in two quarters, zero networking-root-cause incidents in the following six months, and the retry-storm class was eliminated because the shared client has jittered backoff and a circuit breaker by default.

The judgment call was absorbing the migration cost for the pilot. It was expensive — about three weeks of my time — but a mandate without absorbed cost produces compliance theatre. What I would do differently: I should have written the ADR at the start rather than after the second team adopted it. Two engineers independently re-litigated decisions I had already made and I had nothing to point at."

Question 2 · failure

"Tell me about a significant technical failure you were responsible for."

What is being evaluated

Self-awareness and whether you fixed the system or just the bug. The two failure modes: choosing a trivially small failure (reads as evasive), and blaming circumstances.

Red flags interviewers note

  • The failure is really someone else's, told from your perspective.
  • "We didn't have enough time" as the root cause with no examination of your own choices.
  • A failure so minor it demonstrates nothing.
  • No systemic fix — the story ends with "we fixed the bug."

Model answer L4

"I designed an offline sync engine that lost user data for about 200 people. The bug: I advanced the sync cursor outside the transaction that applied the changes, so a crash between the two silently skipped a page of updates. It was invisible — no crash, no error — and we found it three weeks later from a support pattern, not from monitoring.

Immediate response: I disabled sync via remote config for affected users, wrote a reconciliation job that rebuilt state from the server, and personally reviewed the affected accounts. We contacted every user.

My contribution to the failure was more than the one-line ordering mistake. I had tested sync on the happy path and on total failure, but not on partial failure — and partial failure is the defining characteristic of the problem domain. I knew that, and I still didn't build the fault-injection harness because we were behind schedule.

The systemic fixes: a fault-injection test harness that kills the process at every await point in the sync loop, which is now used by two other teams; a sync-lag metric with alerting, so silent data problems become visible within an hour instead of three weeks; and a review checklist item for any cursor-advancing code. The last one is the cheapest and has caught it twice since.

What I carry from it: for anything involving data integrity, I now design the observability before the feature. If a failure can be silent, it will be silent for weeks."

Question 3 · conflict

"Tell me about a technical disagreement with another senior engineer. How did it resolve?"

What is being evaluated

Whether you can disagree productively and, critically, whether you can lose well. Interviewers are wary of Staff candidates who cannot be overruled.

Strong answer shape

Include: the substance of both positions stated fairly (if you cannot state theirs convincingly, you did not understand it); how you tried to make it empirical rather than a matter of taste; how the decision was actually made; and — the highest-value element — a case where you were wrong or where you disagreed and committed.

"Our staff iOS engineer wanted a shared KMP sync layer; I thought the coordination cost exceeded the benefit. We were both arguing from experience with nothing to test. We agreed on a time-boxed spike with success criteria set in advance — iOS build time impact and whether iOS engineers could still work independently. The spike showed a build-time cost we could live with, and I was wrong about the independence concern because the interface boundary was cleaner than I expected. We adopted it for the domain layer only. What I would keep from that: turning a disagreement into a falsifiable experiment with agreed criteria, before either of us was publicly committed to a position, is what let me change my mind without it being a defeat."

Question 4 · saying no

"Tell me about a time you said no to a senior stakeholder."

What is being evaluated

Whether you can defend engineering constraints in business language rather than by asserting technical authority. "I told them it wasn't possible" is a weak answer; so is "I did it anyway and we suffered."

The shape that works

Never say no to the goal; say no to the plan, and bring an alternative. "We can't ship the full redesign in six weeks with an acceptable crash rate. Here are three options: the full scope in ten weeks; the top two screens in six with the rest following; or six weeks at full scope with a quality risk I'd estimate at roughly double our current crash rate for a month. My recommendation is the second, and here's why." That converts a refusal into a decision the stakeholder owns, with the trade-off explicit.

Question 5 · mentorship

"Tell me about someone you mentored who wasn't improving."

What is being evaluated

Whether you diagnose rather than repeat. Also whether you know the boundary between mentoring and managing — a Staff engineer who tries to performance-manage someone is overstepping, and one who ignores a real performance problem is under-serving the team.

Strong elements: distinguishing skill gaps from motivation gaps from context gaps (a strong engineer failing because they were given ambiguous work with no context is common and frequently misread as a skill problem); changing the approach when the first one did not work; involving their manager appropriately; and an honest outcome — including one where the person left, if that is what happened.

Question 6 · incident leadership

"Walk me through an incident you led."

What is being evaluated: composure, structure and whether you fixed the class. Structure the answer as: detection (how long, and why not faster), triage and severity call, mitigation (with the reasoning for choosing it over alternatives), communication cadence and to whom, resolution, postmortem, and the systemic changes that shipped.

The Staff differentiator: spending as much time on "why did it take 90 minutes to detect" as on the bug itself. Detection time is almost always the more valuable fix, and most candidates skip it entirely.

Question 7 · prioritisation

"You have three critical things and capacity for one. How do you decide?"

Framework worth stating: reversibility (which decision closes doors), blast radius (who is affected if it goes wrong), decay (which gets more expensive if delayed), and dependency (which unblocks others). Then — the part that distinguishes levels — make the trade-off visible to the people affected rather than silently absorbing it. A Staff engineer who quietly drops the third thing has created a surprise; one who says "we are not doing X this quarter, here is the risk we are accepting" has made a decision.

Question 8 · technical debt vs deadlines

"How do you balance engineering quality with business deadlines?"

Weak answer: "Quality always comes first." It is untrue and signals inflexibility.

Strong answer: distinguish the quality that is negotiable from the quality that is not. Non-negotiable: data integrity, security, anything irreversible, anything that would require a user-visible migration to fix later. Negotiable: test depth on low-risk paths, refactoring, abstraction, polish. Then make the shortcut explicit — recorded, with an owner and an expiry — so it is a decision rather than a slide. "I take on debt deliberately and visibly; what I refuse is undocumented debt, because that is the kind that compounds silently."

Question 9 · ambiguity

"Tell me about a time you worked on something with no clear requirements or owner."

What is being evaluated: this is the single most Staff-specific question in the set. Ambiguity tolerance is the defining behavioural trait of the level.

Strong shape: you produced the missing definition (a written proposal, a scoping document, a prototype that made the choice concrete), you got it ratified by the people who would have to live with it, and you did it without waiting for permission. The best versions include the fact that the problem was not initially recognised as a problem by anyone else.

Question 10 · hiring and calibration

"How do you calibrate when interviewing? Tell me about a hire you got wrong."

Increasingly asked at Staff level, because you will be a significant fraction of the hiring signal. Strong elements: a consistent rubric rather than a vibe; awareness of specific biases (recency, similarity, over-weighting communication polish over substance); writing feedback with evidence rather than conclusions; and being willing to be the dissenting voice in a debrief.

For the "got wrong" half, the most useful answers describe a false negative you later recognised — because everyone can describe a bad hire, and recognising that your bar was miscalibrated in the other direction shows more self-examination.

Chapter 150Forty more questions, grouped

ThemeQuestions
OwnershipA system you owned that nobody assigned · something you deprecated · a process you created · work you inherited in a bad state · something you chose not to fix.
InfluenceChanging a team's mind with data · a proposal that was rejected · building consensus across orgs · convincing product to fund engineering work · a standard you established.
ConflictDisagreeing with your manager · two teams blocked on each other · a peer whose code you consistently had to reject · escalating appropriately · a decision you disagreed with and committed to.
FailureA project that failed · a design you got wrong · an estimate you missed badly · an incident you caused · a hire or a mentee that did not work.
AmbiguityStarting with no requirements · conflicting stakeholder goals · deciding with insufficient data · a problem nobody had noticed · killing your own project.
MentorshipGrowing someone to the next level · giving hard feedback · delegating something you wanted to do · improving a team's review culture · onboarding at scale.
StrategyA multi-quarter technical plan · a build-vs-buy decision · a bet that did not pay off · aligning technical and product roadmaps · what you would change about your current architecture.
OperatingOn-call improvements you made · a postmortem you ran · a metric you introduced · reducing a chronic source of toil · a rollout you halted.

Chapter 151Building your story portfolio

You need eight stories, not forty. Each strong story answers four or five questions with a shift of emphasis. Build the matrix explicitly:

Portfolio coverage — fill this in before any loop
Story                         Scope  Influence Conflict Failure Ambiguity Metric
1. Shared networking client     ✓✓      ✓✓        ✓                ✓        ✓
2. Sync data-loss incident      ✓                  ✓      ✓✓                ✓✓
3. Modularization programme     ✓✓      ✓         ✓               ✓✓        ✓✓
4. KMP disagreement                     ✓         ✓✓              ✓
5. Mentee who plateaued                 ✓         ✓       ✓
6. Said no to a redesign date           ✓✓        ✓✓              ✓         ✓
7. Startup performance push     ✓       ✓                         ✓        ✓✓
8. Deprecating a loved system   ✓✓      ✓✓        ✓✓      ✓
                                                 ↑ every column needs 2+

Extracting the numbers. Most candidates have the impact but not the figure. Before the loop, dig: crash-free rate before and after, build time, incident counts, support ticket volume, conversion or retention deltas, engineer-hours saved. An approximate honest number ("roughly a day per team per quarter, from asking the four leads") is far stronger than no number, and interviewers do not expect audited precision.

Rehearsal. Out loud, timed, to three minutes. The gap between a story you can recall and one you can tell is enormous under pressure. Record yourself once — it is unpleasant and it is the fastest correction available.

If a story is weak, do not inflate it. "This one didn't have the impact I hoped, and here's what I misjudged" scores better than an exaggerated outcome, because interviewers cross-check details across rounds and inconsistency is fatal.

Part XXIII rapid recall

  • Scope, agency, judgment, impact, self-awareness, collaboration — everything you say is sorted into these.
  • STAR-DL: add the Decision and the Learning; that is where Staff signal lives.
  • "We" for the work, "I" for the decisions — say one explicit sentence isolating your part.
  • Influence stories need a mechanism: prototype, data, or absorbed cost.
  • Failure stories need a systemic fix, not just a bug fix.
  • Never say no to the goal; say no to the plan and bring options with costs.
  • Eight stories mapped across every theme, each with a real number.
  • Rehearse aloud, timed. Do not inflate — interviewers cross-check across rounds.

Part XXIV

Organization-Scale Architecture

The questions that only appear at Staff and above, where the binding constraint is people rather than technology. There are no correct answers here — only defensible ones.

Chapter 152Governance without becoming a bottleneck

Every architecture-governance model sits on a spectrum between two failure modes: anarchy (twelve teams, twelve architectures, nothing shared) and the bottleneck (one person approves everything, and they are on holiday). The mechanisms below are ordered by how well they scale.

MechanismScales toCost / failure mode
Automated enforcement (lint, build rules, CI gates)UnlimitedOnly encodes mechanically-checkable rules; a bad rule is expensive to remove
Paved road (templates, generators, defaults)Very largeRequires an owning team; rots if not maintained
ADRs (written, searchable, owned)LargeOnly works if people read them; needs a discovery mechanism
Architecture review for significant changes onlyMediumRequires a clear threshold, or everything becomes significant
Individual review by the architectSmallThe bottleneck. Does not survive growth or absence
Avoid — governance by approval
Rule: "All architectural changes must be approved
       by the Architecture Council, which meets Thursdays."

Outcome after two quarters:
 · A two-week queue for a one-day decision
 · Teams reclassify work to avoid the label
 · The council reviews trivia and misses the real drift
 · The architect becomes a full-time reviewer and stops building

Approval-based governance scales linearly with the reviewer's time and inversely with the org's velocity. Teams route around it, and the routing is invisible.

Prefer — defaults plus a narrow escalation
Default path (no approval needed):
 · Generate the module from the template
 · Build rules enforce dependency boundaries automatically
 · Lint encodes the decisions from published ADRs

Escalation required only for:
 · A new cross-team dependency
 · A new persistence or transport technology
 · Anything shipped to users that cannot be reversed
   (data format, public SDK API, sync protocol)

Escalation is: write a one-page ADR, get two named
reviewers, 48-hour SLA. Not a meeting.

Ninety-plus percent of work needs no human gate because the paved road already encodes the decisions. The remaining decisions are genuinely one-way doors and deserve deliberation.

Chapter 153Funding change with no immediate product value

Interview question · very common at Staff and above

"How do you convince teams to migrate to a new architecture when there's no immediate product value?"

Answer L4

"I'd start by testing the premise, because 'no product value' sometimes means 'no value' and I would rather find that out before spending a quarter. If it holds, four moves.

Translate to a business unit. Not 'the architecture is cleaner' but 'this costs us roughly 1.5 engineer-days per feature in workarounds and produced four incidents last year.' That converts to engineer-months and incident risk, which is a language product leadership can trade against.

Shrink the first increment until it fits inside work teams are already doing. New screens use the new path; nobody backfills. Migration then has near-zero marginal cost, and the codebase converges over time without a project.

Absorb the cost for the pilot. I do the first team's migration myself so their experience is reviewing a PR. The second team gets a codemod and a template; the third gets documentation and a lint rule. My involvement decays deliberately — otherwise I become the migration department.

Attach it to something they already want. Our networking migration landed because it also fixed a flaky-test problem that was costing one team an hour a day. That was the actual reason they adopted it, and it is fine that it was.

What I would not do is mandate it. A mandate without absorbed cost produces compliance theatre — teams add the wrapper, keep the old path, and now you have both."

Follow-ups

  1. "When do you abandon it?" — When the pilot's numbers do not reproduce, when the team who must own it afterwards will not, or when a platform shift makes the target obsolete. I would rather stop at 30% than have a permanently half-migrated codebase carrying both costs.
  2. "How do you handle the half-migrated state?" — Treat completion as the deliverable, not adoption. Publish a burn-down of remaining call sites; a migration without a visible end date does not finish.
  3. "What if leadership just wants features?" — That is a legitimate position. My job is to make the cost visible and let them choose; if they choose features, I record the decision and revisit when the cost changes. Disagree and commit, with a date.

Chapter 154Measuring architecture and preventing drift

"How do you know your architecture is working?" is the question most candidates cannot answer, because architecture feels unmeasurable. It is not — you just have to measure its effects.

TypeMetricWhat it tells you
LeadingMedian incremental build timeModule graph health; the metric engineers feel daily
LeadingPRs touching more than one team's modulesBoundary quality. Rising = boundaries are wrong
LeadingBoundary-violation count (lint suppressions)Drift, before it becomes structural
LeadingTime to first merged commit for a new hireComprehensibility, and it is easy to collect
LaggingChange failure rate; incidents per releaseWhether the structure actually reduces defects
LaggingLead time from merge to productionWhether the delivery pipeline matches the structure
LaggingEffort per feature over timeThe real question: is the system getting easier or harder to change?

Drift is what happens between reviews: a boundary crossed "just this once", a lint suppression added under deadline, a second source of truth introduced by a team who did not know about the first. The counter-measures are mechanical, because vigilance is not a strategy — dependency rules in the build, a lint baseline that can only shrink, a periodic report of new suppressions with owners, and a standing budget so teams have a way to fix drift that does not require permission.

Chapter 155Twenty org-scale scenarios

Scenario 1 · the classic

"You inherit an Android app with 5M DAU and 500 engineers. The architecture has become impossible to change. What do you do?"

The trap

Proposing a target architecture in the first minute. With 500 engineers, the constraint is coordination, not design — and any answer that does not acknowledge that is scored as Senior.

Answer L4 L5

"First 30 days: measure, do not propose. I would want four things. Where does engineering time actually go — build waits, merge conflicts, cross-team blocking, incident response? What does the change-failure rate look like by area? Which files and modules have the highest churn and the highest defect density, since those are where the architecture is hurting most? And what do the engineers say — 20 conversations across teams surfaces the real pain faster than any dashboard.

'Impossible to change' is a symptom with several possible causes: build times, coupling, missing tests, unclear ownership, or fear from past incidents. They have completely different fixes, and choosing before diagnosing is how these programmes fail.

Next 60 days: one visible win. Pick the highest-pain, lowest-risk item — usually build time or test flake — and fix it measurably. With 500 engineers, credibility is the currency for everything after, and nobody grants it based on a strategy document.

Then: a written strategy with a small number of invariants. Not a target architecture diagram — three or four rules that every team can follow independently: features do not depend on features; one owner per module; one source of truth per domain concept; new code uses the paved road. Invariants scale to 500 engineers in a way that a diagram does not.

Then: make the right thing the easy thing. Templates, generators, automated boundary enforcement. At this size, mechanisms beat mandates by an enormous margin — a lint rule reaches 500 engineers and a wiki page reaches none.

Sequencing: stop the bleeding first (enforce boundaries on new code), then migrate the highest-value areas, then the long tail — accepting that some of the long tail will never migrate and that is an acceptable outcome. A permanently half-migrated codebase is bad; a deliberately partially-migrated one with a documented boundary is fine.

What I would explicitly not do: a rewrite; a big-bang reorganisation; or an architecture I personally review. All three fail at this scale, and the third fails slowest, which makes it the most seductive.

Time horizon: 18–24 months for structural change at this size, with quarterly measurable milestones. Anyone promising two quarters has not done it."

ScenarioThe key move
2. Standardise across 20 teams with different stacksStandardise the interfaces between teams, not their internals. Mandate the seams, leave the insides alone.
3. Quality vs a hard external deadlineSeparate non-negotiable (data, security, irreversible) from negotiable; make the shortcut explicit with an owner and expiry.
4. When should you not refactor?Code about to be deleted; code nobody touches; code you do not understand and have no tests for; and when the team lacks capacity to finish — a half-refactor is worse than none.
5. Chartering a mobile platform teamCharter it as a product with internal customers and satisfaction metrics, not as a service desk. Without that, it becomes a ticket queue within two quarters.
6. Build vs buy at scaleBuy anything that is not differentiating; build what is. Then examine the exit cost — the real risk is a vendor you cannot leave, not the licence fee.
7. Surviving a reorg mid-migrationRe-establish ownership immediately; migrations die when their owner changes teams. Get the new owner named in writing in the first week.
8. Two teams building the same thingNot a technical problem. Find out why — usually one team could not get the other's roadmap priority. Fix the incentive, then merge the code.
9. A team consistently ships poor qualityDiagnose before intervening: capacity, unclear ownership, missing tooling, or skill. Only one of those is a people problem, and it is the least common.
10. Deprecating a widely-used internal libraryAnnounce with a date and a migration path; provide a codemod; track the burn-down publicly; do not delete until it reaches zero. Deprecation without a burn-down never completes.
11. Establishing standards without a bottleneckEncode in automation; escalate only one-way doors; publish ADRs; delegate the review authority to a named group, not a person.
12. Justifying a platform investment to a CFO-minded audienceEngineer-hours converted to money, incident cost, and time-to-market. Never technical elegance.
13. Mobile and backend disagree on an API contractEscalate on the asymmetry: the server can deploy hourly, installed clients cannot update. Compatibility obligations therefore sit with the server, and that is a principle, not a preference.
14. Ten thousand-line PRs are normal hereAttack the cause — usually slow review or long-lived branches — not the symptom. Fast review SLAs shrink PRs more reliably than a size limit.
15. Adopting a technology you personally dislikeEvaluate against the org's constraints, state your reservation once with evidence, then commit fully and publicly. Visible reluctance from a Staff engineer poisons an adoption.
16. An SDK your app depends on is abandonedRisk-classify it, wrap it behind your own interface if it is not already, plan replacement, and add "wrapped and replaceable" to the third-party integration standard so the next one is cheaper.
17. Feature flags have accumulated to 400Flags need expiry dates and owners at creation. Stale flags are untested code paths — a correctness risk, not just clutter. Automate the cleanup reminder.
18. Engineers keep re-litigating settled decisionsThe decision was not written down, or it was written where nobody looks. Publish ADRs, link them from the lint failure message — that is where people actually read.
19. Everyone wants to work on the new thing, nobody on the oldMake maintenance visible and credited in promotion cases; rotate ownership; and be honest that some legacy work should be funded as a fixed cost rather than volunteered for.
20. You are becoming the single point of failureWrite down what only you know; delegate a domain entirely, including the authority; and measure your success by decisions made well without you.

Part XXIV rapid recall

  • Governance scales through automation and defaults; approval-based governance does not.
  • Escalate only genuine one-way doors; everything else follows the paved road.
  • Fund change with engineer-months and incident counts, not elegance.
  • Absorb the migration cost for the pilot, then decay your involvement deliberately.
  • Measure architecture by build time, cross-team PRs, boundary violations, change failure rate, and effort per feature.
  • Drift is countered mechanically — baselines that only shrink, suppression reports, standing budget.
  • At 500 engineers: measure first, one visible win, then invariants — never a diagram or a rewrite.
  • 18–24 months for structural change at scale, with quarterly milestones.

Part XXV

Traps & Trick Questions

Questions where the confident, conventional answer is the wrong one. The interviewer is not testing knowledge — they are testing whether you reason from context or recite from dogma.

Chapter 156The answer shape that works every time

Every question in this part has the same structure underneath, so the answer has the same shape:

Four moves, roughly 90 seconds
1. Reject the universal.  "No — and the interesting part is when it's wrong."
2. Name the criterion.    "The decision turns on X, not on the technology."
3. Apply it here.         "For a screen like this, X says …"
4. State the inversion.   "It flips when Y — for example …"
Two ways to fail

Dogma: "Yes, always — it's best practice." Evasion: "It depends." The second is worse than it looks: it sounds senior but conveys nothing. "It depends on whether the data has other writers" is an answer; "it depends" is a stall.

Chapter 157Thirty questions where the obvious answer is wrong

Trap 1

"Should every repository expose Flow?"

No. The criterion is whether the data has other writers. If a sync worker, a push handler or another screen can change it, a one-shot read is a bug waiting to happen and you need observation. If it is a command — submit, upload, refresh — a Flow is worse than a suspend function: the caller must remember to terminate it, and errors become emissions instead of exceptions, losing structured concurrency's propagation. Inversion: if you later add offline sync, previously-static data acquires other writers, and the answer changes for those types specifically — not for all of them.

Trap 2

"Is Clean Architecture always better?"

No. The criterion is the expected rate and cost of change at each boundary. On a six-screen app with three engineers, the layering costs 3–5 files per feature and buys isolation from changes that will not happen. Its genuine win is a pure-Kotlin domain with millisecond tests — so if you are not writing domain tests, you are paying for the architecture and not collecting. Inversion: once several teams share domain concepts, or the domain rules outlive the current backend, the boundary starts paying for itself.

Trap 3

"Is MVI better than MVVM?"

Neither is better. Both are unidirectional with immutable state; the only real difference is whether input is funnelled through one channel. That funnel buys traceability and replayability, and costs an intent type, a reducer and more files per screen. On a settings screen it is pure cost; on a checkout flow with three concurrent async inputs it is worth it. A codebase using both, with a written rule about which goes where, is a sign of judgment — not inconsistency.

Trap 4

"Should every screen have a ViewModel?"

No. A ViewModel exists to survive configuration change and to hold state off the UI. A purely static screen — a legal text page, an onboarding illustration — has no state to survive. Adding one costs a file, a DI binding and a test nobody writes. Inversion: the moment it acquires an async load or any state that must survive rotation, it needs one. And note the opposite trap: several screens sharing an activity-scoped ViewModel is usually worse than each having its own.

Trap 5

"Should everything be immutable?"

Mostly, but not everything, and the exceptions are principled. Immutable state is correct for UI state and domain models — it is what makes Compose skipping and concurrent reads safe. But data class copy() on a large object in a hot loop allocates; a 5,000-element list copied per emission is a measurable performance problem. Use persistent collections for structural sharing, or a bounded mutable buffer behind an immutable interface. Inversion: profile first — this only matters in hot paths, and premature mutability is a much worse bug source than the allocation it saves.

Trap 6

"Should we always use coroutines?"

No. Work that must survive process death belongs in WorkManager — a coroutine of any scope dies with the process. Genuinely synchronous, fast, local computation does not need suspension and adds ceremony. And a callback API with a single call site does not need callbackFlow. Inversion: the moment there is cancellation, concurrency or a lifecycle relationship, coroutines are correct.

Trap 7

"Should we always use Compose?"

For new screens, yes by default. For migrating existing ones, no. The criterion is not which is better but what the migration costs against what it returns. A stable, working XML screen that nobody touches returns nothing for a rewrite. Interop at the leaf level — Compose items inside a RecyclerView — performs poorly and complicates state, so migrate whole screens or not at all. Inversion: a screen under active development, or one whose XML complexity is the reason changes are slow, is worth converting.

Trap 8

"Should we always use dependency injection?"

Yes to constructor injection; no to a DI framework in every case. The distinction matters: constructor injection is a design practice with no cost. A framework buys graph management and scoping, and costs build time (kapt/KSP across every module) and a learning curve. For a small app, manual construction at a composition root is genuinely simpler. For a library you publish, no framework at all — forcing your DI choice on consumers is a hostile API decision.

Trap 9

"Should repositories contain business logic?"

Some, and the line is worth being precise about. Data-access policy — caching, retry, choosing local versus remote, merging sources — is the repository's job and putting it elsewhere leaks storage concerns upward. Domain rules — eligibility, pricing, validation — are not, because they need to be testable and reusable without a data layer. The test: would this rule still exist if we changed how we store data? If yes, it is domain logic.

Trap 10

"Should we always modularize?"

No. Modularization's benefits — parallel builds, enforced boundaries, ownership — mostly accrue with team count, not line count. Below roughly three teams, the coordination and build-configuration overhead can exceed the benefit, and a poorly-shaped module graph builds slower than a monolith. Inversion: more than three teams, or a build already dominated by a single module's recompilation.

Trap 11

"Should we always cache?"

No, and this is the trap most people fall into. A cache adds a second source of truth, an invalidation problem and a staleness bug class. Do not cache data that is cheap to fetch and expensive to be wrong about — a price, an account balance, inventory. The criterion is the cost of staleness versus the cost of fetching. A wrong balance shown confidently is worse than a spinner.

Trap 12

"Should we always retry network requests?"

No, and getting this wrong causes outages. Retry only what is safe and transient: connection failures and 5xx with jittered backoff. Never retry 4xx — the request is deterministic and retrying hides the bug. Never blind-retry a non-idempotent write after a post-send timeout; you need an idempotency key first. And remember that a million clients retrying in unison is a self-inflicted denial of service — which is why jitter and circuit breaking are not optional extras.

Traps 13–30

Rapid versions

QuestionThe criterion, and the inversion
13. "Should everything be offline-first?"Only where users author data or connectivity is unreliable. It roughly doubles every feature's state space. A price-comparison app's stale data is worthless.
14. "Is 100% test coverage the goal?"No. Coverage is a diagnostic. Teams told to hit a number write tests for getters. Target defect escape rate instead.
15. "Should we always use StateFlow over LiveData?"For new code yes, but LiveData is lifecycle-aware by default and StateFlow is not — a careless migration creates background collectors. The migration must be justified per module.
16. "Is suspend always better than a callback?"For request/response yes. For a multi-emission platform callback, callbackFlow — and for a single call site in Java-interop code, a callback is fine.
17. "Should we use a use case for every repository call?"No — pass-through use cases are pure ceremony. Introduce one for logic, multiple sources, or reuse.
18. "Should domain models always differ from API models?"Where the schemas have diverged or are expected to. Field-for-field mappers are a cost with no purchase.
19. "Is a single-activity app always right?"Almost always, but multi-activity is legitimate for genuinely separate task surfaces — a share target, a widget configuration, a picker launched by other apps.
20. "Should we always target the newest API level?"You must, for Play. But adopting new behaviour is separate from targeting, and each targetSdk bump needs a behaviour-change audit, not just a version edit.
21. "Are microservices' lessons applicable to mobile modules?"Partly. Boundaries and ownership transfer; independent deployment does not — you ship one binary, so module boundaries buy build time and clarity, not release independence.
22. "Should the UI ever talk to the repository directly?"Via a state holder, yes — skipping the domain layer is fine when there is no domain logic. Skipping the state holder is not.
23. "Is Room always better than raw SQLite?"Yes for almost all apps. The exception is a highly dynamic schema or query shape that Room's compile-time model cannot express — and then you still use Room's SupportSQLite layer.
24. "Should we always encrypt local data?"No. It costs performance and adds a key-loss failure mode where data becomes unrecoverable. Encrypt what is sensitive; classify first.
25. "Should we pin certificates?"Only with backup pins, an expiry, a kill switch and monitoring. Pinning without an operational plan is a way to brick your app remotely.
26. "Is more abstraction safer?"No. Each layer must be understood by everyone who changes it. Unused flexibility is a permanent tax paid for a change that may never come.
27. "Should we always fix flaky tests immediately?"Quarantine immediately, fix on a deadline. Leaving them in the blocking suite trains engineers to re-run failures, which is the actual damage.
28. "Should feature flags be permanent?"No. A flag is an untested code path; every one needs an owner and an expiry. Kill switches are the deliberate exception and should be labelled as such.
29. "Should we always A/B test?"No. It costs a variant's worth of complexity and needs enough traffic for significance. Test decisions that are contested and reversible; do not test obvious fixes or things you would ship regardless.
30. "Should Staff engineers still write code?"Yes, but selectively — the risky parts, the exemplars, the prototypes that make an argument. An engineer who writes none loses credibility and calibration; one who writes only features is not operating at the level.

Chapter 158Disagreeing with the interviewer's premise

Sometimes the trap is embedded in the question: "How would you make sure the app never shows stale data?" or "How would you guarantee push notifications arrive within a minute?" Both premises are unachievable, and accepting them leads you into an answer that is confidently wrong.

How to push back without friction
1. Acknowledge the underlying goal.
   "The goal is that users trust what they see — agreed, that matters."
2. Name the constraint plainly, without lecturing.
   "Guaranteed freshness isn't achievable on a device that can be offline."
3. Offer the achievable version.
   "What we can do is bound the staleness and make it visible: refresh
    on foreground, show an 'as of' timestamp, and push-invalidate."
4. Check you've understood the intent.
   "Is that the shape you're after, or is there a hard requirement
    behind it I should design to?"

That last step is what keeps it collaborative. Some interviewers are testing whether you push back; others genuinely have a constraint in mind. Asking distinguishes them, and costs nothing.

Part XXV rapid recall

  • Reject the universal, name the criterion, apply it here, state the inversion.
  • "It depends" is only an answer when followed by what it depends on.
  • Flow vs suspend turns on whether the data has other writers.
  • Clean Architecture pays when you actually write domain tests.
  • Caching turns on the cost of staleness, not on whether caching is possible.
  • Never retry 4xx; never blind-retry a non-idempotent write.
  • Modularization pays with team count, not line count.
  • Unachievable premises should be named, reframed, and confirmed — not accepted.

Part XXVI

Comparison Reference

Head-to-head comparisons on fixed dimensions, so they can be scanned side by side under pressure — plus how to turn a table into a spoken answer in ninety seconds.

Chapter 159How to speak a comparison

Reciting a table is a Senior answer. The Staff version leads with the criterion, gives the answer, then the caveat:

Ninety-second structure
Criterion  "The decision turns on whether the data has other writers."
Answer     "So: Flow for the observable case, suspend for commands."
Mechanism  "Because a cold Flow re-executes per collector, five screens
            means five calls unless you share it — that's the trap."
Inversion  "It flips if we add offline sync, because then everything
            has another writer."

Chapter 160Reactive and concurrency

Flow vs LiveData

DimensionFlow / StateFlowLiveData
Use caseAny observable data, any layerUI-layer observation in legacy codebases
AdvantagesRich operators, structured concurrency, platform-independent (KMP), backpressure controlLifecycle-aware by default; near-zero misuse surface
DisadvantagesNot lifecycle-aware — needs repeatOnLifecycle or collectAsStateWithLifecycleAndroid-only, main-thread bound, almost no operators, no backpressure
ComplexityHigher: sharing, scope, cancellation are yours to get rightLow
TestingTurbine plus virtual time; excellent once set upInstantTaskExecutorRule plus observer boilerplate
Staff viewDefault for new code. The migration risk is losing lifecycle awareness silently — that is the thing to check in review.Not a defect. Migrate when touching the code, not as a project.

StateFlow vs SharedFlow vs Channel

DimensionStateFlowSharedFlowChannel
SemanticsAlways a current value; conflated; distinct-until-changedConfigurable replay and buffer; no initial valuePoint-to-point; each element consumed once
ConsumersManyManyOne (effectively)
Right forUI stateEvents with multiple observersOne-shot events with one observer
Failure modeDrops intermediate values — never put events in itreplay > 0 re-fires events after rotationEvents lost if nothing is collecting; gone on rotation once consumed
Staff viewState in StateFlow, events in a Channel exposed via receiveAsFlow() for the single-consumer case. There is no loss-free option — name which loss you are accepting.

suspend vs Flow · RxJava vs Coroutines

Dimensionsuspend functionFlow
ShapeOne request, one resultZero to many results over time
ErrorsExceptions with structured propagationEmissions or terminal failure; needs catch
Right forCommands: submit, refresh, uploadObservable state with other writers
TrapCold: N collectors = N executions unless shared
DimensionRxJavaCoroutines / Flow
AdvantagesVast operator set, mature, explicit schedulersLanguage-level cancellation, readable sequential code, first-class Android support, KMP
DisadvantagesSteep learning curve, manual disposal, large method countFewer operators; sharing and scoping are easy to get wrong
Migration riskError semantics differ: an Rx onError terminates one stream; an uncaught coroutine exception cancels the scope. Enumerate these before converting (Part XVIII).

Chapter 161UI and architecture

XML Views vs Jetpack Compose

DimensionXML + ViewsCompose
AdvantagesMature tooling, huge ecosystem, predictable performance, smaller learning curve for the team you haveLess code, state-driven correctness, previews, testable via semantics, better animation and theming story
DisadvantagesImperative state bugs, boilerplate, findViewById-era hazards, hard to unit testRecomposition and stability as a new failure class, runtime you must understand, needs baseline profiles
PerformanceWell understood; measured over a decadeComparable when done correctly; worse without keys, stability and profiles
Staff viewNew screens in Compose. Migrate existing screens only when they are being changed anyway, and migrate whole screens — leaf-level interop is where the regressions come from.

MVVM vs MVI

DimensionMVVMMVI
InputPublic methodsOne onIntent(Intent)
TraceabilityState mutates from several placesEvery transition through one reducer — loggable and replayable
BoilerplateLowHigher: intent, state, effect types per screen
TestingCall method, assert stateReducer is a pure function — the cheapest tests in the codebase
Right forMost screensComplex flows with concurrent inputs: checkout, editors, wizards

Repository vs UseCase · Singleton vs DI

DimensionRepositoryUseCase
OwnsData access policy: caching, retry, local vs remote, merging sourcesDomain rules: eligibility, pricing, validation, cross-source orchestration
Introduce whenAlways, per domain concept, with one ownerThere is logic, multiple sources, or reuse — not per repository method
SmellExposes UI state, or contains domain rulesOne-line pass-through to a single repository
DimensionSingleton objectDI-provided instance
TestabilityRequires reset hooks; leaks state between testsConstructed per test with fakes
LifetimeProcess; empty after process death — a real bug sourceExplicit and scoped
When acceptableStateless utilities and constantsEverything with state or dependencies

Chapter 162Infrastructure

Hilt vs Koin vs manual

DimensionHilt/DaggerKoinManual
ValidationCompile timeRuntime — fails when the screen opensCompile time
Build costSignificant (KSP across modules)NegligibleNone
ScalingBest for many modules and teamsGood to medium size; KMP-friendlySmall apps and libraries
Staff viewCompile-time validation is decisive above ~10 engineers, because a runtime DI failure in a rarely-visited screen will reach production. Below that, Koin's simplicity and build speed often win. Libraries: no framework.

Room vs raw SQLite · DataStore vs SharedPreferences

DimensionRoomRaw SQLite
AdvantagesCompile-time query verification, observable queries, coroutine and Paging integration, migration testingTotal control; no codegen; fully dynamic schemas and queries
DisadvantagesCodegen build cost; awkward for highly dynamic queriesEverything is manual, including the bugs
VerdictRoom for almost everything; drop to SupportSQLite within Room for the dynamic cases rather than abandoning it.
DimensionDataStoreSharedPreferences
ThreadingFully async, transactional, observablecommit() blocks; apply() drains synchronously at onStop — an ANR source
ErrorsSurfaced (must handle IOException)Silent
LimitationNo multi-process supportMulti-process mode exists but is unreliable
VerdictDataStore for new code. If you have multiple processes, neither is safe — front it with a ContentProvider or use a database.

WorkManager vs Foreground Service vs coroutine

DimensionWorkManagerForeground serviceCoroutine
DurabilitySurvives process death and rebootSurvives backgrounding while runningNone
TimingEventually — can be hours in DozeNow, while visibleNow, while the scope lives
CostSome latency; scheduling overheadA visible notification; policy scrutiny; batteryFree
Right forSync, upload, deferred writesNavigation, media playback, active trackingScreen-scoped reads

Chapter 163Networking and system shape

REST vs GraphQL vs gRPC

DimensionRESTGraphQLgRPC
Round trips per screenOften severalOneSeveral, but cheap
Over-fetchingCommonEliminated by designFixed per method; compact binary
HTTP cachingFreeNone (POST) — needs a normalised client cacheNone
DebuggabilityExcellent in any proxyModerate; queries opaque in logsPoor without tooling
Client costLowestA normalised cache is a real subsystem with its own bugsCodegen and binary size
Choose perSurface, not company. GraphQL where a screen aggregates many resources; gRPC for high-frequency streaming; REST for everything else.

WebSocket vs polling vs push

DimensionWebSocketPollingFCM push
LatencyMillisecondsHalf the interval, on averageSeconds, best-effort
BatteryHigh if held while backgroundedProportional to frequencyNear zero — the OS holds the connection
ReliabilityDies in Doze and on network change; needs resume tokensVery reliable, wastefulBest-effort; OEM-dependent
Staff answerHybrid: socket only while the relevant screen is foregrounded, push as the wake signal when backgrounded, delta fetch on resume to close gaps.

Offline-first vs online-first · Modular vs monolithic · Clean vs simple layering

DimensionOffline-firstOnline-first
Read/write pathDB is truth; network reconcilesNetwork is truth; cache is a fallback
ComplexityHigh: conflicts, queues, tombstones, reconciliationLow
Choose whenUsers author data, or connectivity is genuinely unreliableData is inherently server-live: prices, availability, feeds
DimensionMulti-moduleMonolithic module
BuildParallel and incremental — if the graph is shallow and implementation-scopedEverything recompiles
BoundariesEnforceable by the compilerConvention only
CostBuild config, graph maintenance, longer configuration phaseNone upfront; grows without limit
BreakpointRoughly three teams. Below it, overhead can exceed benefit — and a badly-shaped graph builds slower than a monolith.
DimensionClean ArchitectureSimple layered (UI / repository)
Files per feature5–82–3
Domain testsPure JVM, millisecondsMixed with Android concerns
OnboardingDays to internalise "where does this go?"Immediate
Choose whenNon-trivial domain rules, multiple teams, or KMP ambitionsSmall teams, CRUD-shaped features, short-lived apps

Part XXVI rapid recall

  • Lead with the criterion, not the feature list. Then answer, mechanism, inversion.
  • Flow vs LiveData: the migration risk is silently losing lifecycle awareness.
  • State in StateFlow, events in a Channel; every option loses something — name which.
  • Compose for new screens, whole-screen migration only.
  • Compile-time DI validation is decisive above ~10 engineers.
  • Choose the API paradigm per surface, not per company.
  • Real-time: socket foregrounded, push backgrounded, delta fetch on resume.
  • Modularization pays around three teams; offline-first pays when users author data.

Part XXVII

Rapid Fire

Around 400 questions banded by the answer length they deserve. The banding is the training: mismatching answer length to question size is itself a calibration failure, and interviewers notice it within two questions.

How to use this

Cover the right column. Say your answer out loud, timed to the band. A ten-second question answered in ninety seconds reads as unfocused; a three-minute question answered in ten reads as shallow. Both are correctable in a week of practice, and neither is correctable by reading.

Chapter 164Ten-second answers

Roughly 300 definitions and distinctions that must be instant. Hesitation here costs credibility for the rest of the round, because the interviewer starts wondering what else is shaky.

Kotlin language

val vs const valRuntime vs compile-time constant; const is inlined at call sites, must be top-level or in an object.
lateinit vs by lazyMutable and externally set, throws if unset · immutable and computed on first access.
Why can't lateinit be primitive?No null sentinel to represent "unset".
Can lazy be used with var?No — the delegate has no setter.
lazy thread-safety modesSYNCHRONIZED (default), PUBLICATION, NONE.
== vs ===Structural equality vs referential identity.
What is a platform type?A type from unannotated Java where nullability is unenforced (String!).
Four NPE sources in KotlinPlatform types, reflective deserialisation, lateinit, !!.
?.let vs if (x != null)let captures the value, so it is smart-cast-safe for mutable properties.
Why won't a var property smart-cast?It could change between the check and the use.
out vs inProducer (return position) vs consumer (parameter position).
What is a star projection?"Some unknown type" — safe to read as the upper bound, unsafe to write.
Declaration-site vs use-site varianceDeclared once on the type vs declared per usage (List<out T> at a call).
What does reified require?inline — the type is substituted at each call site.
Cost of a large inline reified functionBody duplicated at every call site: method count and APK size.
noinline — when?You need to store, pass on, or null the lambda.
crossinline — when?The lambda is invoked from another context; non-local return must be forbidden.
When does a value class box?Generics, nullability, interface positions.
@JvmInline — why needed?Marks the value class for the JVM backend's inline representation.
sealed class vs sealed interfaceInterface allows a subtype to implement several sealed hierarchies.
sealed vs enumSealed subtypes carry different data; enum entries are fixed singletons.
data object vs objectdata object gives a sane toString() and proper equality.
What does adding a sealed subtype break?Binary compatibility for separately-compiled exhaustive when.
Risk of data class copy()Bypasses factory-level invariants.
What does componentN() enable?Destructuring — and it is positional, so reordering fields breaks callers silently.
Delegation trapOverriding a member does not affect calls made inside the delegate.
apply vs alsoBoth return the receiver; apply uses this, also uses it.
let vs runBoth return the lambda result; let uses it, run uses this.
When does a Sequence beat an Iterable?Long chain, large source, short-circuiting terminal.
Which operator defeats sequence laziness?sorted — it must materialise everything.
Cheapest ordering optimisation in a chain?Filter before map.
Is Kotlin's List immutable?Read-only interface over java.util.List — mutable from Java.
Is internal a security boundary?No — public in bytecode with a mangled name.
@JvmStatic — why?Makes an object member callable as a static from Java.
@JvmOverloads — why?Generates overloads so Java callers get default arguments.
@Throws — why?Kotlin has no checked exceptions; Java callers otherwise get no signal.
What is @DslMarker for?Prevents calling an outer builder's methods from an inner scope.
Operator overloading smellAnything not arithmetic-shaped; it becomes ungreppable.
What does contract buy?Lets the compiler smart-cast across a function boundary.
Difference between Nothing and UnitNothing has no instances (the function never returns normally); Unit has exactly one.

Coroutines

launch vs asyncFire-and-forget vs returns a Deferred.
withContext vs async{}.await()Same result; withContext is cheaper for a single switch.
What is a CoroutineContext?An immutable indexed set: Job, Dispatcher, Name, ExceptionHandler.
What does a child inherit?The parent context, with its own Job substituted.
What cancels a coroutine?Cancelling its job or scope — cooperatively, at the next suspension point.
Why is a CPU loop uncancellable?It never suspends. Add ensureActive() or yield().
ensureActive() vs yield()Check only vs check plus give up the thread.
Never catch which exception?CancellationException — rethrow it.
Where does cleanup go after cancellation?finally, with withContext(NonCancellable) if it must suspend.
What does SupervisorJob change?A child's failure does not cancel siblings.
Does launch(SupervisorJob()) supervise?No — supervision comes from the scope.
coroutineScope vs supervisorScopeSibling failure cancels all vs siblings continue.
When does an async failure surface?In a plain scope, at throw time (cancels parent); under a supervisor, at await().
Where does an uncaught launch exception go?Nearest CoroutineExceptionHandler, else the thread's default handler.
Does a handler on a child work?No — it must be installed on the scope or the root coroutine.
Dispatchers.IO vs DefaultBlocking I/O (elastic, 64+) vs CPU-bound (CPU-count sized). Shared pool.
What is Main.immediate?Skips re-posting when already on the main thread — what viewModelScope uses.
limitedParallelism(n) — why?Bound a subsystem's concurrency without a separate pool.
When is Unconfined legitimate?Rarely — some tests and operator implementations.
Is Mutex reentrant?No — nested withLock on the same mutex deadlocks.
Mutex vs ReentrantLock in a coroutineMutex suspends; the lock blocks a thread.
What does MutableStateFlow.update do?CAS loop — retries on conflict, so no lost updates.
runBlocking in production?Only main() and tests; never the main thread.
viewModelScope's contextSupervisorJob + Dispatchers.Main.immediate.
When is lifecycleScope cancelled?onDestroy — not on stop.
What is a Semaphore used for?Bounding concurrency; use withPermit so cancellation releases it.
What does select do?Waits on several suspending sources, taking the first to complete.
awaitAll vs sequential awaitSame concurrency; awaitAll fails fast on the first failure.

Flow

Cold vs hotCold runs per collector; hot emits regardless of collectors.
Five collectors on a cold flow?Five executions — unless shared.
flowOn affects what?Everything upstream of it.
catch operator sees what?Upstream failures only.
Why can't you withContext around emit?Context preservation — it throws "Flow invariant is violated".
Why not try/catch around emit?Exception transparency — it would swallow downstream failures.
conflate vs bufferDrop intermediates vs keep them in a buffer.
debounce vs sampleWait for silence vs emit the latest at a fixed rate.
flatMapLatest vs flatMapMerge vs flatMapConcatCancel previous · concurrent · sequential and ordered.
combine vs zipEmit on any change (latest of each) vs pair emissions one-to-one.
stateIn vs shareInstateIn needs an initial value and conflates; shareIn does not.
Why WhileSubscribed(5000)?Survives a configuration change without keeping the upstream alive in the background.
What does replayExpirationMillis control?How long the cached value is kept after the upstream stops.
SharingStarted.Eagerly riskUpstream runs while backgrounded — battery and network.
What happens to an error in a shared flow?Terminates it for every collector.
callbackFlow vs channelFlowBoth allow concurrent emission; callbackFlow requires awaitClose.
What does awaitClose do?Keeps the flow alive and provides the unregistration hook.
StateFlow equality behaviourConflated and distinct-until-changed by equals.
Why is StateFlow wrong for events?It drops intermediates and dedupes identical consecutive values.
SharedFlow replay > 0 failure modeRe-fires old events to a new collector after rotation.
Channel vs SharedFlow for eventsExactly-once to one consumer vs broadcast with possible loss.
receiveAsFlow vs consumeAsFlowMultiple collectors share the channel vs the channel is cancelled on completion.
How do you make a Flow lifecycle-aware?repeatOnLifecycle or collectAsStateWithLifecycle.
What does onEach not do?It does not collect — a flow with only onEach never runs.
distinctUntilChanged caveatUses equals; a data class with a changing timestamp defeats it.

Android platform

What survives process death?SavedStateHandle, rememberSaveable, disk.
What survives rotation?ViewModel, plus everything above.
What survives nothing?Singleton in-memory state, in-flight coroutines, static caches.
Test process death how?adb shell am kill, then return via Recents.
Why not am force-stop?Clears the task — you get a fresh launch, not a restoration.
Is "don't keep activities" equivalent?No — the process survives, so ViewModels and singletons do too.
When is onCleared() called?When the owner finishes — not on configuration change.
What is zygote?The pre-warmed VM your process forks from; shares framework pages copy-on-write.
Binder transaction limit~1 MB per process, shared across in-flight transactions.
Common TransactionTooLargeException causeOversized onSaveInstanceState bundle or Intent extra.
What runs on the main thread?Everything: lifecycle callbacks, input, view invalidation, your post.
What is Choreographer?Runs frame callbacks on VSYNC: input → animation → traversal.
Frame budget at 60/90/120 Hz16.7 / 11.1 / 8.3 ms.
Input-dispatch ANR threshold~5 seconds.
Foreground-service start ANR~20 seconds.
Play's ANR bad-behaviour threshold0.47% of daily sessions.
Which launch mode clears the stack?singleTask.
What must singleTop handle?onNewIntent — otherwise the new intent's data is ignored.
Default PendingIntent flagFLAG_IMMUTABLE, with an explicit component.
When is FLAG_MUTABLE required?Direct-reply notifications and some Bubbles APIs.
What is intent redirection?A mutable PendingIntent wrapping an implicit intent, filled in by an attacker.
Why is the share sheet empty on Android 11+?Package visibility — declare <queries>.
Notification permission since?Android 13, runtime-granted.
What is Doze?Deferred network and jobs when the device is idle; high-priority FCM still wakes.
App Standby buckets affect what?How often deferred work and alarms are allowed to run.
WorkManager's guaranteeEventually, surviving process death and reboot — never "soon".
Unique work policiesKEEP vs REPLACE (one-time), UPDATE for periodic.
Why do foreground services need a type?Policy enforcement — an unjustified type fails Play review.
Exact alarms require?A special permission, and a policy-defensible use case.
Which context for a singleton?Application.
Which context for a dialog?Activity — it needs the themed, windowed context.
What is FLAG_SECURE for?Blocks screenshots and hides content in the recents preview.
What does reportFullyDrawn() do?Marks TTFD so time-to-usable is measurable.

Jetpack

Why does ViewModel exist?To hold state across configuration change without leaking the Activity.
Why never hold a Context in a ViewModel?It outlives the Activity by design.
Nav-graph-scoped ViewModel — when?Multi-screen flows: checkout, onboarding.
Activity-scoped shared ViewModel riskBecomes global mutable state nobody owns.
repeatOnLifecycle vs launchWhenStartedCancels the producer vs only pauses delivery.
Is a Room suspend DAO main-safe?Yes — no withContext(IO) needed.
Is a Retrofit suspend function main-safe?Yes — it dispatches to OkHttp's pool.
Room observable queries invalidate on?Any write to a table the query touches.
Why @Transaction on a relation query?It issues several statements; without it you can observe a torn read.
What does exporting the Room schema give you?A committed contract that migration tests validate against.
When is destructive migration acceptable?Pure cache with no user-authored data and no outbox.
DataStore's main limitationNo multi-process support.
Why must you catch IOException from DataStore?It surfaces read failures instead of hiding them.
Why did SharedPreferences cause ANRs?apply()'s queue is drained synchronously at onStop.
First SharedPreferences read costThe whole file is parsed synchronously.
Paging: cursor or page number for a live feed?Cursor — page numbers duplicate and skip.
What does a RemoteMediator do?Loads network pages into the database, which remains the source of truth.
Why write page and keys in one transaction?A crash between them leaves the list and keys disagreeing.
Hilt vs Koin validationCompile-time vs runtime.
When is field injection acceptable?Only for framework-constructed types: Activity, Fragment, Worker.
What does @ApplicationContext prevent?Accidentally injecting an Activity context into a singleton.
App Startup library solves what?Content-provider-per-library startup cost and initializer ordering.

Compose

What is the slot table?A gap buffer recording, positionally, what was composed and remembered.
How is remember keyed?By call-site position, not by variable name.
What does key() do?Gives a composable semantic rather than positional identity.
What makes a type stable?Consistent equals, no unobserved mutation, all public property types stable.
Why is List unstable?It is an interface — the implementation could be mutable.
What is @Immutable?A promise the compiler trusts and does not verify.
What did strong skipping change?Unstable params compare by instance; lambdas are auto-remembered.
remember vs rememberSaveableSurvives recomposition vs also survives process death.
Why doesn't list.add() recompose?The State reference never changed.
Fix for the abovemutableStateListOf, or replace the value with a new list.
LaunchedEffect vs DisposableEffectSuspending work vs register/unregister pairs.
What is SideEffect for?Publishing to non-Compose objects after a successful composition.
rememberUpdatedState solves what?A stale lambda captured by a long-lived effect.
When does derivedStateOf help?High-frequency input, low-frequency output, cheap derivation.
When does it hurt?When the output changes as often as the input.
produceState — what for?Turning a suspend or callback source into State.
snapshotFlow — what for?Turning Compose state reads into a cold Flow.
The three phasesComposition, layout, drawing.
Why Modifier.offset { } over offset()?The lambda defers the read to layout, skipping composition.
What phase does graphicsLayer { } read in?Draw.
Why keys in a LazyColumn?Stable identity — preserves state, enables animation and reuse.
What does contentType improve?Reuse across heterogeneous item types.
Why never nest same-direction scrollables?Infinite constraints force measuring every item.
Does a LazyColumn compose all items?No — only visible ones plus prefetch.
Legitimate CompositionLocal usesTheme, typography, density, formatters — ambient and rarely changing.
Illegitimate onesViewModels, repositories, screen data.
The semantics tree is also?The accessibility tree, and the test tree.
collectAsState vs ...WithLifecycleThe latter stops collecting when the UI is stopped.
Where do compiler metrics come from?composeCompiler { metricsDestination = ... }.
Recomposition count looks fine but it's slow?Layout, draw, main-thread work, or a missing baseline profile.
Minimum touch target48dp.
Why contentDescription = null on an icon inside a labelled button?Otherwise TalkBack announces it twice.

Architecture and modularization

Justify a layer boundary by?The change it isolates, weighed against mapping and indirection cost.
Clean Architecture's real winA pure-Kotlin domain with millisecond tests.
Weakest Clean Architecture argument"You can swap the database" — it never happens.
When to introduce a use caseLogic, multiple sources, or reuse — not per repository method.
Should a repository expose UI state?No — it cannot then be reused by a Worker, and errors arrive pre-formatted.
Flow or suspend from a repository?Flow if the data has other writers; suspend for commands.
Where does loading state belong?The state holder — loading is a UI concept.
State or event?Survives rotation = state; happens once = event.
MVVM vs MVI in one lineMethods vs one intent channel; traceability bought with boilerplate.
Feature-to-feature dependency?Never — route contracts assembled in :app.
api vs implementationapi only for types in your public signatures.
Which recompiles more, a body or a signature change?Signature — it is an ABI change.
Deep or wide module graph?Wide and shallow parallelises; deep serialises.
Biggest single build win in a Dagger/Room appkapt → KSP.
When does modularization stop paying?Below about three teams.
What replaces a :core:shared module?Capability modules with one owner each.
How are boundaries enforced?Build logic and lint — not documentation.
Dynamic feature modules — when?Only when install size is a demonstrated conversion problem.

Networking, storage, sync

What dominates mobile network cost?Latency and round-trip count, not bandwidth.
What is radio tail time?The radio stays in a high-power state after a transfer — chatty clients drain battery.
What does HTTP/2 fix, and not fix?Fixes HTTP head-of-line blocking; not TCP-level.
What does HTTP/3 add for mobile?Connection migration across network changes.
Application vs network interceptorMisses cache hits vs sees every wire attempt.
When is Authenticator invoked?On 401 — and it replays the original request for you.
What is EventListener for?Per-phase timings: DNS, connect, TLS, request, response.
Which failures are safe to retry?Connection failures and 5xx; post-send timeouts only with an idempotency key.
Never retry which class?4xx — deterministic.
Why jitter?Synchronised retries turn a blip into an outage.
When is the idempotency key created?At user commit, and persisted before the first call.
What is the unknown outcome?No response received — a first-class state, never "failed".
What does an ETag save?Bandwidth via 304 — not the round trip.
Cursor vs offset paginationOffsets duplicate and skip on a live list.
Is connectivity a boolean?No — captive portals, metered links, handovers. Attempt and handle failure.
What does WAL mode allow?Concurrent readers during a write; still one writer.
Speedup from batching insertsTypically 50–100× — one fsync instead of N.
How do you verify an index is used?EXPLAIN QUERY PLAN — "SEARCH … USING INDEX", not "SCAN TABLE".
Bitmap memory formulawidth × height × 4 bytes in ARGB_8888.
A 12 MP photo decoded costs?About 48 MB.
Why must sync cursors advance in-transaction?Otherwise a crash loses or replays a page silently.
Why do deletions need tombstones?An offline client never learns about them otherwise.
Can you resolve conflicts with client clocks?No — devices lie. Use server versions.
Push: data or signal?Signal — makes handlers idempotent for free.
Exactly-once delivery?Does not exist. Exactly-once effect via idempotency does.

Performance, testing, security, build

Which device do you measure on?The 25th-percentile device in your install base.
TTID vs TTFDFirst frame vs content actually usable.
What does a baseline profile do?AOT-compiles hot paths so first launch and first scroll are not interpreted.
Typical baseline profile gain15–30% cold start, plus first-scroll jank.
Why read every thread in an ANR trace?Main shows where it blocked; the cause is usually the lock holder.
Retained vs shallow sizeEverything freed if it went away vs the object's own fields.
Will LeakCanary find unbounded growth?No — those objects are reachable by design.
What does onTrimMemory mean?Release caches now or be killed sooner.
Where do hardware bitmaps live?Outside the Java heap — but still in the process footprint.
Fake or mock?Fake what you own and reuse; mock what you neither own nor reuse.
Why never delay() in a test?It bets on machine speed — the main flake source.
What makes coroutine tests deterministic?Injected dispatchers plus runTest virtual time.
Why does a WhileSubscribed StateFlow look broken in a test?It does not start until collected.
Is coverage a target?No — a diagnostic. Target escaped defects.
What is worse than no tests?A flaky suite — it trains engineers to re-run failures.
Which Gradle phase runs every invocation?Configuration, unless the configuration cache is on.
What silently disables build caching?Volatile inputs: timestamps, absolute paths.
Pre-merge pipeline budgetUnder ten minutes, or engineers route around it.
How do you roll back a mobile release?You don't — halt rollout, kill switch, server fix, forward hotfix.
What does halting a rollout affect?New installs only; existing users keep the bad build.
Obfuscation vs encryption vs securityRenaming vs unreadable-without-a-key vs a system property enforced server-side.
Where do Keystore keys live?Hardware (TEE/StrongBox) — never in your process.
What must never be reused under GCM?The IV, with the same key.
Correct mobile OAuth flowAuthorization Code with PKCE, in a Custom Tab.
Why not an embedded WebView for login?Your app can read credentials; no shared session; no verifiable URL bar.
Client secret in a mobile app?None — it is a public client; PKCE replaces it.
Biometrics: boolean or key?Key — a boolean branch is patchable.
What does certificate pinning risk?Bricking the app on rotation — needs backup pins, expiry, kill switch.
Best root-detection signalPlay Integrity, validated server-side.

Chapter 165Thirty-second answers

Mechanism plus one consequence. If you exceed 45 seconds you are explaining rather than answering.

How does structured concurrency prevent leaks?Every coroutine has a parent; cancelling the parent cancels all descendants transitively, and a scope cannot complete before its children. Work cannot outlive its owner — which is exactly what GlobalScope breaks.
What happens when one of three async calls fails?In a plain coroutineScope the failure cancels the parent at throw time, not at await time, so siblings die and a try/catch around await() does not help. Under a supervisor, siblings continue and each failure surfaces at its own await.
Why can five screens cause five network calls?Flow is cold — the builder runs per collector. stateIn/shareIn, or a single database source of truth, collapses them to one.
How does the slot table give remember its identity?Identity is the call site's position in the composition tree. Move the call into a branch and it becomes a different slot — which is why key() exists.
Why does a 40 ms main-thread block drop three frames?The frame it lands in is missed, and the next two VSYNC pulses arrive while the thread is still busy, so their work queues and runs late.
Why does WAL mode reduce jank?Readers proceed during a write, so a background sync no longer blocks the query feeding a scrolling list. There is still one writer.
Why is jitter necessary in retry backoff?Without it, a million clients that failed together retry together, turning a brief blip into a sustained outage. Full jitter spreads them uniformly.
Why must an idempotency key be persisted?The case you are defending against is process death mid-request. A key held in memory is regenerated on restart, so the retry creates a second order.
Why is a mutable PendingIntent with an implicit intent dangerous?Another app can fill in the intent and have it executed with your app's identity and permissions.
Why doesn't @Immutable make a type safe?It is a promise the compiler trusts, not one it verifies. Breaking it produces stale UI that is very hard to debug.
Why is replay = 1 wrong for events?A collector created after rotation immediately receives the last event, so navigation or a snackbar fires twice.
Why does adding a sealed subtype break binary compatibility?Separately-compiled consumers with exhaustive when throw NoWhenBranchMatchedException until recompiled.
Why does api hurt build times?It puts the dependency on every consumer's compile classpath, so an ABI change there invalidates far more modules than necessary.
What does a baseline profile actually do?Ships a list of hot methods AOT-compiled at install, so first launch and first scroll do not run interpreted while the JIT warms.
Why read every thread in an ANR trace?Main's stack shows where it blocked, not why. If it waits on a monitor, the bug is in whichever thread holds the lock.
Why is unbounded growth invisible to LeakCanary?The objects are reachable and intentionally retained; LeakCanary detects unreachable-but-retained objects.
Why do offsets break pagination on a live feed?Insertions above your offset shift the window, so you see duplicates and skip items. Cursors encode a position in the data.
Why must the sync cursor advance inside the transaction?Otherwise a crash between applying data and saving the cursor loses a page permanently or replays it — silently.
Why can't you resolve conflicts with client timestamps?Device clocks are wrong — users change them and drift is common. Use server-assigned versions.
Why is biometric-as-a-boolean insufficient?Nothing cryptographic depends on the result, so patching the branch or hooking the callback bypasses it.
Why is there no rollback for a mobile release?You cannot remove a binary from devices. Halting affects new installs only; mitigation is a kill switch or server-side fix.
Why does flake matter more than test count?A flaky suite trains engineers to re-run until green, so real failures get re-run too. Worse than no tests.
Why does a config-time timestamp hurt?It changes every invocation, so the task is never up to date, everything downstream recompiles, and the configuration cache breaks.
Why prefer fakes over mocks at scale?Mocks assert implementation, so refactors break tests without behaviour changing. At thousands of tests, refactoring becomes uneconomic.
Why does launchWhenStarted not save battery?It suspends delivery but keeps the upstream producing — a socket or location flow keeps running with results buffered.
Why does the first read of SharedPreferences hurt startup?The entire XML file is parsed synchronously on first access, on whatever thread asked.
Why is catch (e: Exception) around a suspend call a bug?It swallows CancellationException, so the parent believes the child completed and you show errors on abandoned screens.
Why does deduplicating in the caller's scope backfire?The first caller navigating away cancels the shared request for everyone else.
Why compare against the stale token during refresh?Five concurrent 401s enter the lock in turn; those that find a changed token skip refreshing, so rotating refresh tokens are not invalidated.
Why does Room re-query on unrelated writes?Invalidation is per table, not per row — so a hot table plus an unbounded query is a CPU load.
Why can a process be killed with an empty Java heap?Native and hardware-bitmap memory count toward the process footprint the low-memory killer sees.
Why does the same code jank only on newer phones?A 120 Hz display halves the frame budget to 8.3 ms.
Why is push latency unguaranteeable?Doze, standby buckets and OEM battery optimisation all defer delivery; the design must tolerate delay.
Why does an activity-scoped shared ViewModel grow?It is reachable from every screen, so it becomes the path of least resistance for any shared state.
Why does leaf-level Compose interop perform badly?Each ComposeView carries its own composition and layout bridging, so per-item cost multiplies inside a RecyclerView.
Why measure with a release build?Debug builds disable R8, add instrumentation, and run without AOT profiles — the numbers are unrelated to production.
Why does a shared flow's exception affect other screens?It terminates the shared upstream for all collectors; with WhileSubscribed it silently restarts on the next subscription.
Why isn't "clear the cache on logout" enough?The outbox may hold pending writes the user believes are saved, and a cleared session can apply them to the next account.
Why is a mutable Kotlin List a Java problem?List is java.util.List at runtime, so a Java caller can mutate a collection your API presents as read-only.
Why does the Compose compiler care about your module boundaries?Types from modules without the Compose compiler cannot have stability inferred, so they are treated as unstable.

Chapter 166One-minute answers

Trade-off questions. Every answer must contain a criterion and a caveat; without a criterion it is not an answer.

Should this repository return Flow or suspend?Criterion: does the data have other writers? Sync workers, push handlers, other screens → Flow over an observable query. A command → suspend. Caveat: cold flows need sharing or you pay N calls for N collectors, and Flow<Result<T>> leaks transport vocabulary into the domain.
Where should loading and error state live?The repository emits data or throws; the state holder maps to Loading/Ready/Failed, because loading describes the view's relationship to data. Per-section rather than one global flag, so an 80 ms header does not wait on a 2 s list.
MVVM or MVI for this screen?Criterion: how many independent inputs mutate state, and do you need replayable transitions? Settings → MVVM. Checkout with payment, address and inventory racing → MVI's funnel earns its boilerplate. Both in one codebase with a written rule is judgment, not inconsistency.
Hilt or Koin here?Criterion: team size and the cost of a runtime failure. Above ~10 engineers, compile-time validation wins because a runtime DI failure in a rare screen reaches production. Below, Koin's build-time saving is real. Libraries: no framework.
How do you decide what to cache?Criterion: cost of staleness versus cost of fetching. Cheap-to-fetch, expensive-to-be-wrong data — prices, balances, inventory — should not be cached, or should carry an explicit "as of". Every cache needs an invalidation story before it is written.
When would you not modularize?Below roughly three teams, build-config overhead and graph maintenance can exceed the benefit — and a deep, api-heavy graph builds slower than a monolith. It pays with team count and boundary enforcement, not line count.
How do you choose a real-time transport?Socket while the relevant screen is foregrounded, push as the wake signal when backgrounded, delta fetch on resume. Criterion: latency requirement versus battery and connection cost — 1.5M idle sockets is a real bill for a feature most users are not currently using.
How much of a design should be offline-capable?Classify: reference data (evictable, TTL), user-owned (never evicted while signed in), pending writes (durable, never purged by a cache clear). Offline-first roughly doubles each feature's state space, so apply it where users author data.
What is your testing strategy for a new feature?Domain rules and reducers always; state holder behaviour always; repository with fakes usually; a screenshot per significant visual state; UI tests only on the critical path. Explicitly not: mocked interaction verification, or layout assertions that churn weekly.
How do you decide whether to take on technical debt?Non-negotiable: data integrity, security, anything irreversible or needing a user-visible migration later. Negotiable: test depth on low-risk paths, refactoring, polish. Record it with an owner and an expiry — undocumented debt is the kind that compounds.
Foreground service or WorkManager?Criterion: must the user see it happening now, or must it merely eventually happen? Navigation and active tracking → foreground service with a declared type. Sync and upload → WorkManager with unique work and backoff.
How do you approach a performance regression?Quantify and segment first — metric, percentile, device tier, version, rollout correlation. Three ranked hypotheses. Trace on a mid-tier device with a release build. Fix, verify in lab and field, then add the CI gate, or you will repeat this investigation next year.
REST, GraphQL or gRPC for this surface?Criterion: does the screen aggregate many resources, and does the field set vary by client version? Yes → GraphQL, accepting that you lose HTTP caching and take on a normalised cache. High-frequency streaming → gRPC. Otherwise REST, because caching and debuggability are free.
Should this state live in the ViewModel or the repository?Criterion: is it screen state or app state? Selected tab, scroll position, form input → ViewModel with SavedStateHandle. Signed-in user, feature flags, cart → repository with a persisted source of truth, because more than one screen depends on it surviving.
How do you decide the conflict policy for a data type?Criterion: what does a wrong resolution cost? Nil → last-write-wins (read state). Independent fields → per-field merge. Business-critical → server arbitration. User-authored text → conflict copy, because silently losing writing is never acceptable.
When is a custom Compose layout justified?Criterion: can the built-in layouts express it without intrinsics or nested weights in a hot path? If measurement cost is showing in a trace, a custom layout with a single measurement pass is justified. Otherwise it is a maintenance cost with no return.
How do you scope a kill switch?Criterion: what is the smallest thing you would want to disable in an incident? Per-feature, evaluated at the entry point, defaulting to on, with a local default for offline. Anything integrating a third party gets one by policy, because you cannot fix their outage.
How do you pick what to instrument?Work backwards from failure modes: for each way the feature can fail, name the metric that would move. If a failure mode has no metric, it will be discovered by users. Then check the cost — telemetry volume is a real budget line.

Chapter 167Three-minute answers

Mini-designs and debugging prompts that open into longer conversations. Each should end with you naming what you would measure.

Design the offline write queue for a messaging app.Outbox table with the row ID as the idempotency key; states pending/in-flight/failed; stale in-flight rows reset on startup; FIFO drain with stop-on-retryable to preserve order; jittered backoff; permanent failures surfaced to the user; WorkManager for durability; local-ID-to-server-ID rewriting for dependent operations. Measure: outbox depth p95, permanent-failure rate.
A user reports the app "loses their edits sometimes." Diagnose.Candidates: non-transactional cursor advance; state not saved across process death; outbox purged by a cache clear or logout; a conflict policy silently choosing server-wins; destructive migration. Instrument first — sync lag and outbox depth make this observable rather than anecdotal.
Design the state model for a multi-step checkout.Server-driven step machine so payment and compliance steps change without a release; local persistence of step and idempotency key for resumability; explicit Unknown outcome; re-price confirmation before charge; navigation as events, not state. Measure: step abandonment, unknown-outcome rate.
Cold start regressed 800 ms over two quarters with no single culprit.Accumulation, not a regression: three-tier initialisation with an owner for the total, a CI startup budget that fails the build, baseline profile regeneration, an SDK integration standard requiring lazy init. Report in retention terms to keep it funded.
Design a feature-flag system for a 40-engineer Android org.Remote config with local defaults for offline; typed accessors, not string keys; owner and expiry declared at creation; kill switches as a separate permanent category; staged-rollout integration with halt criteria; a stale-flag report, because 400 accumulated flags are 400 untested code paths.
How would you make a 90-minute test suite usable again?Flake first — history-based detection, automatic quarantine with owner and expiry, a visible dashboard. Then tier: affected-module tests pre-merge, full suite post-merge, E2E nightly. Measure PR wall-clock and change failure rate, not coverage. Ownership via CODEOWNERS or the quarantine list grows forever.
Design image loading for a media-heavy feed.Server-side renditions requested at display size; memory cache sized from memoryClass; disk cache with a hard quota and LRU; prefetch two screens ahead on unmetered only; cancel on detach; decode off the main thread. Measure: cache hit rate, decode time p95, OOM rate by device tier.
Your app is the source of a backend outage. What happened and what do you change?Almost certainly a synchronised retry herd, or a client-side loop against a failing endpoint. Changes: full jitter, a client circuit breaker, respect for Retry-After, bounded queues with a downsampling policy, and a kill switch on the calling feature. Measure: request rate per user per minute, and alert on it.
Design analytics for a 200-screen app so it stays trustworthy.A typed event schema reviewed like an API; events enqueued durably and batched; a client-side enqueued counter reconciled daily against server-received counts; consent state as a first-class input; sampling policy with a cost dashboard. Measure: the reconciliation gap — that is the number that catches silent loss.
Walk me through making a legacy screen testable without changing behaviour.Extract pure logic behind the existing behaviour, characterise it with tests written against current output first, inject the clock and dispatchers, split the stateful entry point from the stateless UI, then refactor under the new tests. Ship each step separately. Measure: nothing should move — that is the point.

Part XXVII practice protocol

  • Ten-second band: cover the answers, run 40 in seven minutes, mark every hesitation.
  • Thirty-second band: answer aloud; over 45 seconds means you are explaining rather than answering.
  • One-minute band: every answer needs a criterion and a caveat.
  • Three-minute band: end each one by naming what you would measure.
  • Re-run the bands you failed 48 hours later, not the same day.
  • Two weeks out, run the full ten-second set in one sitting — it should take under 40 minutes.

Part XXVIII

Mock Interview Loops

Complete loops with interviewer scripts, expected reasoning, follow-ups and filled-in scorecards. Run them timed, with a peer as interviewer. Reading them is worth a fraction of performing them.

Chapter 168How to run these

  • Timed and out loud. 45 or 60 minutes per round, no pausing to look things up.
  • Peer as interviewer. Give them the script; they do not need to know the material to run it, because the follow-ups and the scorecard are provided.
  • Score immediately against the rubric, before discussing. Discussing first contaminates the score.
  • One loop per week, not one per day. The value is in fixing what the scorecard exposed between attempts.
Universal scorecard — use for every round
Dimension                       1  2  3  4  5   Evidence
Problem framing / constraints   ○  ○  ○  ○  ○   ______________
Technical depth (mechanism)     ○  ○  ○  ○  ○   ______________
Trade-off reasoning             ○  ○  ○  ○  ○   ______________
Failure & scale thinking        ○  ○  ○  ○  ○   ______________
Communication & structure       ○  ○  ○  ○  ○   ______________
Collaboration (uses hints well) ○  ○  ○  ○  ○   ______________

3 = Senior · 4 = Staff · 5 = Principal
Hire decision at Staff requires 4s in framing, trade-offs and failure thinking.
A single 2 in communication frequently sinks an otherwise strong loop.

Chapter 169Loop 1 — Senior → Staff at a product company

Shape: Kotlin/coroutines · Android platform · architecture · mobile system design · behavioural. This is the calibration loop: the scorecard at the end shows exactly where the Staff line falls.

Round 1 · 45 min · Kotlin and concurrency

Interviewer script

  1. Warm-up (5 min): "Walk me through what happens when three concurrent async calls run inside a coroutineScope and one fails."
  2. Core (25 min): "Implement a request deduplicator: five screens ask for the same user simultaneously, we want one network call." Let them design the API first. Probe: what scope does the work run in? What if the first caller navigates away? What if the request fails?
  3. Extension (10 min): "Now add a 30-second result cache." Probe: where does the clock come from, and how do you test expiry?
  4. Close (5 min): "What would you change about your solution before shipping it?"

What separates the levels here

L3
Working solution with a mutex and a map. Handles the failure case. Answers the scope question when asked.
L4
Raises the scope-ownership question unprompted — "if I run this in the first caller's scope, their navigation cancels everyone's request" — injects the clock for testability, removes the entry in a finally, and names the leak risk of the map growing.
L5
Adds that this belongs in shared infrastructure rather than in one repository, and that the same pattern covers token refresh — generalising without over-generalising.

Round 2 · 45 min · Android platform

Interviewer script

  1. "A user reports that their form data disappears sometimes. Walk me through how you'd investigate." (Target: distinguishing configuration change from process death, and knowing how to reproduce each.)
  2. "What actually survives process death?" Then: "How would you test that in CI?"
  3. "We need to upload a photo the user just took. It must complete even if they leave the app. Design it." (Target: WorkManager, unique work, foreground service for the visible case, idempotency, failure surfacing.)
  4. "The upload works but battery complaints doubled. What are your hypotheses?"

Red flags: confusing "don't keep activities" with process death; proposing a bare coroutine for durable work; no mention of what the user sees when the upload permanently fails.

Round 3 · 45 min · Architecture

Interviewer script

  1. Present the "everything in the ViewModel" architecture (Part XXII, Architecture 1) as a real codebase. "Critique this."
  2. "You have one quarter and the team is shipping features throughout. Sequence the fix."
  3. "Should repositories expose Flow or suspend functions?" — the criterion question.
  4. "Your proposal adds files and a new rule. What is the cost, and who pays it?"

The Staff signal in this round is question 4: candidates who cannot name a downside of their own proposal are scored as advocating a style rather than making a decision.

Round 4 · 60 min · Mobile system design

"Design the client for a food delivery app: browse, order, track the courier."

Interviewer's checklist — tick what the candidate raises unprompted:

Requirements pinned before designing□ Functional □ Non-functional □ Out of scope
Scale estimate that drives a decision□ Update frequency □ Socket vs push conclusion
Order as a server-authoritative state machine□ Client never infers transitions
Push carries the signal, client fetches truth□ Idempotent handling of duplicate push
Price/availability revalidation at checkout□ Structured diff shown to the user
Offline and degraded behaviour□ Cart local-first □ Tracking staleness indicator
Battery for courier tracking□ Batching □ Foreground service type
Failure modes named unprompted□ Restaurant unresponsive □ Payment unknown outcome
Observability□ Names a metric per failure mode

Follow-ups to deploy at 40 minutes: "The courier's phone loses signal for ten minutes — what does the customer see?" · "Two devices, same account, one places an order — what happens on the other?" · "How would you roll this out safely?"

Round 5 · 45 min · Behavioural

Interviewer script

  1. "Tell me about a technical decision you made that turned out to be wrong."
  2. "Tell me about influencing a team you had no authority over." Probe for the mechanism: prototype, data, or absorbed cost.
  3. "Describe a time you disagreed with a decision and had to commit to it anyway."
  4. "What's the most significant thing you've owned that nobody asked you to?"

Loop 1 — worked scorecard for a borderline candidate

RoundScoreEvidence
Kotlin/concurrency4Raised scope ownership unprompted; injected the clock; missed the map-growth leak until prompted.
Platform4Correct on process death and reproduction; strong on WorkManager; did not consider what the user sees on permanent failure.
Architecture3Good critique, sensible sequencing — but could not name a cost of their own proposal. Advocated rather than decided.
System design3Solid client architecture; spent 12 minutes on the backend; offline behaviour only when prompted; no observability.
Behavioural4Genuine failure story with a systemic fix. Influence story lacked a mechanism — "I explained why."

Committee outcome: Senior, not Staff. The technical depth is there. What is missing is visible in two places — an inability to critique their own proposal, and a design round that drifted to the backend and needed prompting for failure and observability. Both are learnable in weeks, and both are exactly what Parts XVI and XXII drill.

Chapter 170Loop 2 — Staff at a large technology company

Shape: advanced Android internals · concurrency debugging · mobile system design · architecture leadership · behavioural. Harder in breadth of probing rather than in question difficulty — expect three levels of follow-up on everything.

  1. Internals (45 min). Looper and Choreographer mechanism; why a 40 ms block drops three frames; Binder and TransactionTooLargeException; then "walk me through everything that happens between tapping the icon and the first frame."
  2. Concurrency debugging (45 min). Presented with the UploadManager snippet from Part XXI, Review 4: "This works in testing and duplicates uploads in production. Why?" Then: "Fix it." Then: "Now make it survive process death."
  3. System design (60 min). "Design a chat client." Follow-ups at 30 minutes: ordering with wrong device clocks; multi-device read state; offline send with permanent failure; what changes with end-to-end encryption.
  4. Architecture leadership (45 min). "You join and find four teams with four networking layers. Walk me through your first 90 days." Probes: how do you know it is worth fixing; who pays; what if a team refuses; how do you know it worked.
  5. Behavioural (45 min). Failure, conflict, saying no, and "what would your most difficult colleague say about working with you?"

Scoring note: at this level, a correct answer that arrives only after two hints scores as a 3. The bar is not "can reach the answer" but "reaches it independently and knows why it is the answer."

Chapter 171Loop 3 — Principal / Mobile Architect

Deliberately brutal. Every round has contradictory constraints and no clean answer; the assessment is entirely about how you reason under them.

  1. Strategy (60 min). "5M DAU, 500 engineers, the architecture is unchangeable, and leadership wants a redesign shipped in two quarters. What do you do?" The trap: accepting both constraints. Expected: reframe, measure, one visible win, invariants, honest timeline (Part XXIV).
  2. Design under contradiction (60 min). "Design multi-device sync for a note app. It must be end-to-end encrypted, support full-text search, work offline for a month, and never lose data." These constraints conflict — E2E prevents server-side search, month-long offline maximises conflicts. The assessment is whether you surface the conflict and negotiate scope rather than pretending to satisfy all four.
  3. Organisational design (45 min). "Design the mobile engineering org for a company scaling from 40 to 200 engineers. Team boundaries, platform team charter, governance model, and what breaks first."
  4. Executive communication (30 min). "Explain to a CFO why we need two quarters of engineering investment with no new features." Assessed on: money and risk framing, no jargon, a concrete decision requested at the end.
  5. Reverse round (30 min). "What questions do you have for us?" At Principal level this is scored — the questions reveal what you think matters. Strong: how decisions actually get made here, what the last failed technical initiative was and why, who owns mobile architecture today.

Chapter 172Loops 4–12 — specialised variants

LoopRound structureWhat it uniquely tests
4 · FintechPayments idempotency design · threat modelling · Kotlin concurrency · incident leadership · behaviouralCorrectness over latency; the unknown-outcome state; regulatory framing; whether you would ever show "failed" when you mean "unknown".
5 · Real-time / locationStreaming concurrency · battery and background policy · live-tracking design · on-call behaviourBattery as a product metric; foreground service policy; degraded behaviour under signal loss.
6 · Media / performanceRendering internals · memory and bitmaps · video design · performance-programme leadershipWhether you profile or guess; frame-timeline reading; running performance as a programme with budgets.
7 · Platform / infrastructureGradle internals · modularization exercise · DX as a product · migration leadershipBuild-time economics in engineer-hours; whether you treat engineers as users; enforcement over documentation.
8 · Startup StaffBreadth screen · pragmatic design under constraint · "what would you not build" · founder conversationSpeed/quality judgment; comfort being the only Staff engineer; willingness to choose the boring option.
9 · Offline-first productSync engine design · conflict resolution · data-integrity incident · testing strategy for syncPer-type conflict policy; transactional cursors; whether you can test partial failure deterministically.
10 · Compose-heavy consumer appCompose internals · state-management design · recomposition debugging · architecture reviewRuntime understanding versus API familiarity; the "low recomposition but slow" diagnosis.
11 · Legacy modernisationCodebase assessment · migration sequencing · stakeholder management · metricsWhether you propose a rewrite; incremental value; how you would know it is working.
12 · KMP / cross-platform orgKMP evaluation · shared-domain architecture · team topology · a design spanning both platformsWhether you treat it as a technical or an organisational decision; whether you keep an exit path.

After every mock — the only three questions that matter

  1. What did I need a hint for? That is the gap, and it is specific enough to fix.
  2. Where did I explain instead of answering? Over-long answers are the most common and most fixable communication failure.
  3. Which lens did I never apply? Most candidates consistently forget the same two of the thirteen — usually observability and organisational impact. Find yours and make them a habit.

Part XXIX

Follow-Up Trees

Staff loops are rarely decided on the opening question. They are decided three questions deep, when the candidate runs out of mechanism and starts repeating vocabulary.

Chapter 173The three-level rule

LevelWhat it probesHow it usually sounds
Level 1Knowledge"How would you design X?" — most candidates prepare only for this.
Level 2Mechanism"What happens when Y fails?" — separates people who have shipped it from people who have read about it.
Level 3Judgment"What if the constraint you assumed away is real?" — no rehearsed answer exists; this is the level that decides the loop.
The three legitimate level-3 responses

Reason from mechanism — derive an answer from what you know about how the system works, narrating the derivation. Name the trade-off space — "there are two families of answer here, and the choice depends on…". Say what you would do to find out — "I don't know; here is the experiment that would tell us in a day." All three score well. Guessing confidently scores worst of all, because a Staff engineer who bluffs is a liability.

Chapter 174Worked tree — offline-first

The full expansion of the single most-asked mobile design question. Each answer is deliberately short, because in a real loop these come rapidly.

Root question

"How would you design an offline-first application?"

Answer: Local database as the single source of truth; the UI reads only from it and never awaits the network. Every mutation writes locally and appends to a durable outbox. A sync engine pushes the outbox, pulls a server-cursored delta, and resolves conflicts by a policy defined per data type. Push is a wake signal, not data.

→ "What happens when two devices modify the same record?"

Version-based optimistic concurrency: each client sends the version it last saw; the server rejects a stale write with a 409 and the current record. Resolution is per data type — last-write-wins for read state, per-field merge for profiles, server arbitration for inventory, conflict copy for documents. The client must retain the last-synced base version, not just its local mutation, or merging is impossible and you can only overwrite.

→→ "What if the field-level merge produces something the user did not intend?"

Then per-field merge was the wrong policy for that type. The test is whether fields are semantically independent — a name and an avatar are; two edits to a paragraph are not. For dependent content, keep both and surface the conflict. Silently producing a merged result the user never wrote is worse than showing them a conflict, because they cannot detect it.

→ "What if synchronisation fails halfway through?"

The cursor advances only inside the transaction that applies the page, so a crash resumes exactly where it stopped. The outbox drains one operation at a time with the operation marked in-flight; on startup, stale in-flight rows are reset to pending, because a crash mid-request leaves them stranded otherwise.

→→ "The operation was actually applied server-side, but you crashed before recording the ack. Now you replay it."

That is why the outbox row ID is the idempotency key. The replay carries the same key, the server recognises it and returns the original result rather than applying it twice. This is the unknown-outcome problem, and the client cannot solve it alone — it is a contract with the backend, which is why I would establish it globally rather than per endpoint.

→→→ "The server only retains idempotency keys for 24 hours, and your outbox can be a week old."

Then there is a correctness gap, and I would rather name it than paper over it. Three options: bound the outbox lifetime to the retention window and fail operations older than that explicitly; reconcile by business identity instead of by key — query whether an order for this cart already exists; or negotiate a longer retention for the operations where duplication is expensive. Which one depends on the cost of a duplicate: for a payment I would push hard for the third; for a "mark as read" I would accept the first.

→ "What if the server returns duplicate events?"

Application must be idempotent by construction: upserts keyed by server ID, never blind inserts. That also makes push-triggered fetches safe to run twice, which is why treating push as a signal rather than as data removes a whole class of bug.

→ "What if the user logs out with pending writes?"

A product decision that must be made before launch, and it is the one nobody thinks of. Options: block logout until the queue drains, warn and discard with an explicit confirmation, or complete the queue in the background before clearing. What is never acceptable is clearing local data with pending writes still queued — the user believes their work was saved. And the outbox must be user-scoped, or a queued write can be applied to the next account signed in on that device.

→ "What if local storage is corrupted?"

Detect on open. Preserve the outbox if it is readable — it contains work the user believes is saved — and rebuild everything else from the server. If the outbox itself is unreadable, that is a data-loss event: report it as one, tell the user specifically what was lost if you can determine it, and do not silently reset. Silent recovery is how corruption bugs go unnoticed for months.

→ "How do you test all of this?"

Four layers. A deterministic two-client harness with a fake server that can be told which write lands first, covering every conflict policy. Fault injection that kills the process at each await point in the sync loop. A fake clock for TTLs and retention. And a soak test at realistic data volume, because every sync bug I have seen hides at small scale.

→→ "How would you roll it out to five million existing users?"

Staged, with a shadow phase first: run the sync engine in read-only mode for a small cohort and compare its computed state against the server's without acting on it. That surfaces disagreement before any user data depends on it. Then enable writes for 1%, with halt criteria on conflict rate, sync-lag p95 and support contact rate. The bootstrap is the risky part — a user with 500k records cannot sync the way a test account with 500 does, so the migration needs a paged bootstrap with progressive availability, and I would explicitly test the largest accounts in the install base before rollout.

Chapter 175Fifty follow-ups to expect, by category

State and lifecycle

"What happens after process death?"Tests whether the design distinguishes memory, saved state and disk.
"What does the user see during rotation?"Tests WhileSubscribed, and whether the upstream restarts.
"Two screens showing the same data — one edits it."Tests single source of truth.
"The user backgrounds the app mid-flow for an hour."Tests token expiry, stale data, resumability.
"What if they force-quit?"Tests durability versus in-memory state.

Failure and partial failure

"One of your three requests fails — what renders?"Per-section state versus a global loading flag.
"The network drops mid-write."Idempotency, unknown outcome, UI honesty.
"The backend returns a 500 for ten minutes."Backoff, jitter, circuit breaking, degraded mode.
"The response is malformed."Parsing tolerance, unknown enum handling, error reporting.
"Wi-Fi to cellular mid-request."Socket death, retry safety, HTTP/3 connection migration.
"Storage is full."Write failure paths, cache eviction, user messaging.
"The token expires during five concurrent calls."Single-flight refresh, stale-token comparison.

Scale

"What breaks at 10× users?"Usually a backend or fan-out concern — say which part is yours.
"10× data on the device?"Bootstrap time, query plans, memory, eviction policy.
"10× engineers on this codebase?"Boundaries, ownership, build time, review capacity.
"10× screens?"Templates, navigation topology, DI graph, consistency mechanisms.
"What is the cost of this at scale?"Telemetry volume, CDN egress, push fan-out, socket count.

Product and edge cases

"A brand-new user with no data?"Empty states, bootstrap, first-run performance.
"A user with five years of history?"Pagination, storage quota, migration time.
"The user changes their device clock?"Never trust client time for ordering or expiry.
"They install on a second device?"Multi-device state, read convergence, notification dismissal.
"They uninstall and reinstall?"What is recoverable, what is lost, what the user expects.
"The device is offline at first launch?"Almost always broken in practice — a very common gap.
"Accessibility for this screen?"Semantics, touch targets, font scaling, screen-reader order.
"Right-to-left and long translations?"Layout assumptions, truncation, hardcoded strings.

Operations

"How do you know this is broken in production?"The single most-skipped question. Name a metric per failure mode.
"How would you roll this out?"Staged, halt criteria, kill switch, cohort selection.
"How do you turn it off without a release?"Remote config; if the answer is "we can't", that is the finding.
"What is your alert threshold, and who is paged?"Ownership, not just instrumentation.
"How long until you detect a silent data problem?"Sync lag, outbox depth, reconciliation counts.
"How do you debug this on a user's device?"Breadcrumbs, remote logging with consent, targeted tracing.

Testing and quality

"How do you test the concurrency here?"Virtual time, deterministic interleavings, no delay.
"How do you test the process-death path?"Saved-state unit tests plus instrumented restoration.
"How do you test conflict resolution?"Two-client harness with controlled ordering.
"What would you deliberately not test?"Tests judgment; "everything" is a weak answer.
"How do you keep this suite from becoming flaky?"Injected clocks and dispatchers, no shared state, quarantine policy.

Organisational

"Who owns this component?"Unowned components are the Staff-level failure mode.
"How do you stop other teams from breaking it?"Enforcement in build logic, not documentation.
"How would 40 engineers build this consistently?"Templates, generators, lint, defaults.
"What if another team disagrees with this design?"Evidence, absorbed cost, escalation path, disagree-and-commit.
"How long, realistically, and who does it?"Estimation honesty; vague answers read as inexperience.
"What would you cut if you had half the time?"Prioritisation under constraint; the answer reveals what you think is essential.

Meta

"What is the weakest part of your design?"Self-critique. Having no answer is scored as not having thought about it.
"What would make you choose differently?"The inversion condition — the single highest-value sentence in any design answer.
"What did you deliberately not build?"Scope discipline.
"What would you do with another week?"Prioritisation, and whether you know what is unresolved.
"What don't you know here?"Calibrated honesty. Answer it directly and follow with how you would find out.

Part XXIX rapid recall

  • Level 1 knowledge, level 2 mechanism, level 3 judgment — prepare for all three.
  • At level 3: reason from mechanism, name the trade-off space, or say how you would find out. Never bluff.
  • "How would you know this is broken in production?" is the most-skipped follow-up in the set.
  • Volunteering the weakest part of your own design is a top-tier signal.
  • Practise trees, not questions — depth is where the level is decided.

Part XXX

Cheat Sheets & Final Checklists

Compression, not new material. Everything here appears earlier in the book; this is what you read in the last week, and on the last day.

Chapter 176One-page cheat sheets

Kotlin

Recall card
NPE sources        platform types · reflective parsers · lateinit · !!
Variance           out = produces (return) · in = consumes (param) · * = unknown
reified            forces inline — keep the inlined body one line thick
value class        boxes under generics, nullability, interface positions
sealed             adding a subtype breaks binary compat for separate compilation
data class         copy() bypasses factory invariants; prefer explicit withX
delegation         overriding a member does NOT affect calls inside the delegate
sequences          win on long chains + large source + short-circuit terminal
                   sorted() defeats laziness
interop            @JvmStatic @JvmOverloads @JvmName @Throws; internal is not private
                   Kotlin List is java.util.List — mutable from Java

Coroutines and Flow

Recall card
Scope rule         repositories expose suspend/Flow; callers own scopes
                   GlobalScope never · injected AppScope deliberately
                   WorkManager for anything that must survive process death
Main-safety        belongs to the function that blocks; inject the dispatcher
                   Room and Retrofit suspend fns are ALREADY main-safe
Cancellation       cooperative · ensureActive() in CPU loops
                   never catch CancellationException · cleanup in finally
                   withContext(NonCancellable) only inside finally, kept tiny
Errors             coroutineScope: sibling failure cancels all, rethrows
                   supervisorScope: siblings survive
                   async in a plain scope fails the parent AT THROW, not at await
                   supervision comes from the SCOPE, not launch(SupervisorJob())
Streams            StateFlow = state (conflated, distinct) · Channel = one-shot events
                   SharedFlow replay>0 re-fires events after rotation
                   cold flow: N collectors = N executions unless shared
                   WhileSubscribed(5000) survives rotation, stops on background
Operators          conflate=drop · buffer=keep · debounce=wait for silence
                   sample=steady rate · flatMapLatest=cancel previous (UI default)
Invariants         flowOn affects upstream · catch sees upstream only
                   never withContext around emit; never try/catch around emit

Compose

Recall card
Identity           positional in the slot table; key() makes it semantic
State              mutating a list inside mutableStateOf does NOT notify
Effects            LaunchedEffect = suspending work (key it correctly)
                   DisposableEffect = register/unregister pairs
                   rememberUpdatedState = fresh lambda in a long-lived effect
                   derivedStateOf = high-freq input → low-freq output, cheap derivation
Stability          List is unstable (interface) · use ImmutableList
                   @Immutable is a promise, not a check
                   strong skipping reduces the tax, doesn't replace immutable modelling
Phases             composition → layout → draw
                   Modifier.offset { } and graphicsLayer { } defer the read, skip phases
Lists              key + contentType · no allocation in the item body
                   never nest same-direction scrollables
Architecture       Route (stateful) / Screen (stateless) — enables preview + screenshot
Slow but low       → layout cost, draw cost, main-thread work, or missing baseline profile
recomposition      → get a system trace; don't guess
Semantics tree     = accessibility tree = test tree

Lifecycle and platform

Recall card
Survives rotation        ViewModel
Survives process death   SavedStateHandle, rememberSaveable, disk
Survives everything      disk (Room, DataStore)
Test process death       adb shell am kill  (NOT force-stop)
Binder                   ~1MB per-process buffer; saved state travels over it
Frame budget             16.7ms @60Hz · 8.3ms @120Hz
ANR                      ~5s input · ~20s FGS start · Play threshold 0.47% sessions
Launch modes             singleTask clears the stack above it
Background work          WorkManager = eventually · FGS = now & visible
                         coroutine = neither durable nor guaranteed
PendingIntent            FLAG_IMMUTABLE + explicit component
Collection               repeatOnLifecycle cancels the producer;
                         launchWhenStarted only pauses delivery

Architecture and modularization

Recall card
Boundary test      justify by the change it isolates; collapse field-for-field mappers
Clean AA           real win = pure-Kotlin domain, millisecond tests
                   not "swap the database" — that never happens
UseCase            introduce for logic, multiple sources, or reuse. Not per method
Repository         data-access policy yes; domain rules no; UI state never
State vs event     survives rotation = state; happens once = event
Loading            per-section, not one global flag
Modules            features never depend on features
                   implementation by default; api only for public signatures
                   shallow+wide parallelises; deep serialises
                   ABI changes invalidate dependents; bodies don't
                   one owner per module in CODEOWNERS
                   enforcement in build logic, not in a wiki
Breakpoint         modularization pays around 3 teams, not at a line count

Networking, storage, sync

Recall card
Mobile network     latency and round-trip count dominate, not bandwidth
                   radio tail time makes chatty clients expensive
Interceptors       application = misses cache hits · network = every wire attempt
                   Authenticator = 401 only, replays for you
Retry              connection failures freely · post-send timeouts only with a key
                   never 4xx · always jitter (herd → self-inflicted outage)
Idempotency        key at USER COMMIT, persisted BEFORE the call, reused forever
                   Unknown is a first-class outcome, never "failed"
Truth              network writes to the store; the UI observes the store
SQLite             WAL = concurrent readers, one writer
                   batch inserts in one transaction (50–100×)
                   EXPLAIN QUERY PLAN: "SCAN TABLE" = no index
Room               observable queries invalidate per TABLE — bound with LIMIT
                   every shipped schema version needs a tested path, forever
Sync               opaque server cursor, advanced INSIDE the applying transaction
                   deletions need tombstones + a retention window
                   conflict policy is per data type; keep the base version to merge
                   never resolve with client clocks
Push               a wake signal, not data — makes handlers idempotent for free
Bitmaps            w × h × 4 bytes; decode to display size

Performance, testing, security, CI

Recall card
Perf method        quantify → segment → hypothesise → trace → fix → verify → gate
                   measure on the 25th-percentile device, release build
Startup            TTID + TTFD; three-tier init with an owner; CI budget
                   baseline profile covers startup AND the primary scroll
ANR                read EVERY thread; the cause is usually the lock holder
Memory             retained ≠ shallow; unbounded growth is not a leak
                   LeakCanary in instrumented CI
Testing            fake what you own and reuse; mock what you don't
                   inject dispatchers; virtual time; never delay() to wait
                   flake is worse than absence → quarantine, owner, expiry
                   measure PR wall-clock + change failure rate, not coverage
Build              config cache, build cache, parallel, KSP — before restructuring
                   volatile inputs (timestamps, abs paths) kill caching
                   frame build time in engineer-hours
Release            trains, staged rollout, automated halt criteria, kill switches
                   THERE IS NO ROLLBACK
Security           the client is hostile territory; enforce server-side
                   obfuscation ≠ encryption ≠ security
                   Keystore keys never enter your process; never reuse a GCM IV
                   biometrics unlock a KEY, not a boolean
                   PKCE in a Custom Tab; rotating refresh with reuse detection
                   pinning needs backup pins, expiry, kill switch, monitoring

Chapter 177100 things every Staff Android engineer should know

A single-sitting read for the night before, weighted toward mechanism and failure modes.

AreaFacts
Kotlin (1–12)Platform types defeat null safety · reflective parsers can write null into non-null fields · reified forces inlining · value classes box under generics · sealed additions break binary compatibility · copy() bypasses invariants · delegation does not intercept internal calls · internal is not private in bytecode · Kotlin's List is mutable from Java · sequences lose on small collections · sorted defeats laziness · variance is a public-API decision.
Coroutines (13–28)Cancellation is cooperative · CPU loops need ensureActive · never catch CancellationException · async fails the parent at throw time · supervision comes from the scope · launch(SupervisorJob()) does nothing useful · Mutex is not reentrant · never hold a lock across a suspension · Dispatchers.IO and Default share a pool · limitedParallelism bounds a subsystem · runBlocking never on main · Room/Retrofit suspend fns are main-safe · injected dispatchers make tests deterministic · GlobalScope breaks ownership · app-scope is not durability · WorkManager is.
Flow (29–40)Cold means per-collector execution · flowOn affects upstream · catch sees upstream only · never withContext around emit · StateFlow conflates and drops · SharedFlow replay re-fires events · Channel delivers once · WhileSubscribed(5000) survives rotation · replayExpiration controls staleness · flatMapLatest is the UI default · debounce adds latency by design · a shared flow's error kills every collector.
Platform (41–56)Zygote makes startup fast · Binder buffer is ~1 MB · saved state travels over Binder · Choreographer runs per VSYNC · a 40 ms block drops consecutive frames · ANR at ~5 s input · ViewModel survives rotation only · am kill not force-stop · "don't keep activities" tests a different path · singleTask clears the stack · onNewIntent must be handled · PendingIntent must be immutable · package visibility filters queries · notifications are permission-gated · background location needs justification · exact alarms need a special permission.
Compose (57–70)Slot-table identity is positional · key makes it semantic · mutating a list in mutableStateOf does not notify · LaunchedEffect keys matter · DisposableEffect for register/unregister · rememberUpdatedState for stale lambdas · derivedStateOf only for coarse outputs · List is unstable · @Immutable is unverified · strong skipping uses instance equality · deferred reads skip phases · lazy lists compose only what is visible · semantics = accessibility = tests · low recomposition can still be slow.
Architecture (71–80)Justify boundaries by isolated change · pure domain = fast tests · pass-through use cases are cost · repositories never emit UI state · state survives rotation, events do not · per-section loading · features never depend on features · one source of truth per concept · enforcement beats documentation · sometimes the answer is less architecture.
Data and network (81–90)Round trips dominate mobile latency · radio tail costs battery · Authenticator handles 401 replay · single-flight refresh by stale-token comparison · never retry 4xx · always jitter · idempotency keys are persisted at commit · Unknown is a first-class outcome · WAL allows concurrent readers · sync cursors advance inside the transaction.
Operations (91–100)Measure on 25th-percentile devices · TTID and TTFD together · baseline profiles cover scroll too · read every thread in an ANR trace · unbounded growth is not a leak · flake is worse than no tests · volatile build inputs kill caching · there is no rollback · the client is hostile territory · every third-party SDK needs a kill switch and an owner.

Chapter 178Fifty questions you must answer cold

GroupQuestions
Level (1–5)Senior vs Staff · what do you own that nobody assigned · a decision you got wrong · how you influence without authority · what you would change about your current architecture.
Kotlin (6–12)Why NPEs still happen · out vs in · cost of reified · when value classes box · sealed vs enum · when a sequence loses · what internal guarantees.
Coroutines (13–22)Structured concurrency in one sentence · three async, one fails · why GlobalScope is architectural · making CPU work cancellable · why never catch CancellationException · where main-safety belongs · Mutex vs atomic vs confinement · single-flight refresh · deduplicating five callers · testing without delay.
Flow (23–29)Cold vs hot · five collectors, how many calls · StateFlow vs SharedFlow vs Channel · why WhileSubscribed(5000) · flatMapLatest vs merge · exception transparency · where stateIn belongs.
Platform (30–36)What survives process death · how to test it · why a 40 ms block drops three frames · WorkManager's actual guarantee · why FLAG_IMMUTABLE · what singleTask does to the back stack · what breaks with a new targetSdk.
Compose (37–42)What makes a type stable · why list.add() does not recompose · when derivedStateOf hurts · the three phases and how to skip one · optimising a 10,000-item list · slow screen with low recomposition.
Design (43–50)Offline-first in 60 seconds · two devices edit the same record · duplicate payment prevention · push vs socket vs poll · cursor vs offset pagination · one source of truth · how you would know it is broken in production · what you would deliberately not build.

Chapter 179The last 24 hours

Review — two hours maximum

  • These cheat sheets, once, out loud where you hesitate.
  • Your eight stories (Part XXIII) — say the opening two sentences of each aloud.
  • The design framework timings (Part XVI) until the phases are automatic.
  • The thirteen lenses (Part I) — you should be able to list them in fifteen seconds.
  • Your two weakest rapid-fire bands from Part XXVII.

Deliberately skip

  • Anything new. A half-learned concept is worse than an honest "I have not used that."
  • Obscure API details. Nobody is failed at Staff for not recalling a parameter name.
  • Reading more model answers. At this point, production beats recognition.

Logistics

  • Confirm the schedule, the format, and what each round covers — ask the recruiter, they will tell you.
  • Test the video and screen-share setup on the actual platform.
  • Have a drawing tool ready and rehearse using it once; fumbling with a whiteboard app costs real minutes.
  • Water, and a hard stop between rounds if you can get one.

First five minutes of each round

  • Ask what the round covers and how long it is. This is normal and it prevents mis-pacing.
  • For design: spend the first five minutes on requirements. Say that you are doing so.
  • For coding: write the signature first, and ask whether it is what they want.
  • For behavioural: pick your story deliberately, and say which one you are choosing and why.

When a round is going badly

  • Say it out loud: "I've gone down a path I don't think is right — let me step back." Recovery is scored positively; silent flailing is not.
  • Return to constraints. "What dominates here is X" re-anchors the conversation.
  • Use the hint. Interviewers offer hints to see whether you collaborate; refusing one scores worse than needing it.
  • If you do not know: say so, then say how you would find out. That is a legitimate Staff answer.
  • A bad round is not a bad loop. Reset between rounds — committees weigh the whole signal.

Questions worth asking them

  • How does a technical decision that spans two teams actually get made here?
  • What was the last significant technical initiative that failed, and why?
  • Who owns mobile architecture today, and how is that changing?
  • What would you want the person in this role to have changed in twelve months?
  • What is the current state of build times, crash-free rate and release cadence?
A closing note

The material in this book will not, by itself, get you the offer. What gets the offer is the habit it is built around: naming the constraint, choosing with a stated criterion, saying what would change your mind, and knowing how you would find out you were wrong. Interviewers can tell within two questions whether that habit is real. Practise it out loud until it is.

Part XXXI

Android Best Practices & Anti-Patterns

A side-by-side catalogue of the Android-specific patterns that separate code that works on your phone from code that survives a million devices. Every pair is a defect that has shipped, not a style preference.

How to read this part

The left column is not strawman code — it is what reasonable engineers write under deadline, and most of it passes review. What makes each one wrong is a failure mode that only appears under a condition your development device rarely reproduces: process death, a slow network, a revoked permission, an OEM battery optimiser, or a hundred thousand rows. The why line under each pair is the interview answer.

Chapter 180Lifecycle and context

Anti-pattern — work keyed to the wrong lifecycle
class MapFragment : Fragment() {
    override fun onCreateView(...): View {
        viewLifecycleOwner // available…
        lifecycleScope.launch {          // …but not used
            locationRepo.updates().collect { drawPin(it) }
        }
        return binding.root
    }
}

lifecycleScope on a Fragment lives until onDestroy, but the view dies at onDestroyView — which happens every time the fragment goes on the back stack. The collector keeps drawing into a destroyed view hierarchy: a leak plus an IllegalStateException when the binding is gone.

Practice — scope to the view, not the fragment
override fun onViewCreated(v: View, s: Bundle?) {
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.repeatOnLifecycle(STARTED) {
            locationRepo.updates().collect { drawPin(it) }
        }
    }
}

Two different lifetimes exist in a Fragment and they differ by a whole back-stack traversal. viewLifecycleOwner plus repeatOnLifecycle tears the collector and the upstream down when the view goes away.

Anti-pattern — state that cannot survive a kill
class SessionManager {
    var currentUser: User? = null       // set at login
    val isLoggedIn get() = currentUser != null
}

// Screen assumes it is populated:
if (session.isLoggedIn) showDashboard() else showLogin()

After process death the singleton is reconstructed with currentUser = null while the task record is restored, so the user returns from Recents and is silently logged out. Reproduces constantly on low-end devices and almost never on a developer's phone.

Practice — persisted, observable session
class SessionRepository(private val store: DataStore<Prefs>) {
    val session: Flow<Session?> = store.data
        .map { it[USER_JSON]?.let(::decodeSession) }

    suspend fun signIn(s: Session) = store.edit { it[USER_JSON] = encode(s) }
    suspend fun signOut() = store.edit { it.remove(USER_JSON) }
}

The test: if this value vanished right now, would the app misbehave? If yes it belongs on disk, observed — not in a field. Verify with adb shell am kill, not by rotating.

Chapter 181Background work

Anti-pattern — durability by optimism
fun submitReview(review: Review) {
    appScope.launch {
        api.postReview(review)          // no retry, no persistence
        toast("Thanks for your review!")
    }
}

Three defects. The coroutine dies with the process, so a review composed on a train is silently lost. A transient 503 discards it. And the toast fires from a scope that outlives the screen, so it can appear over an unrelated part of the app — or crash if the Activity is gone.

Practice — persist first, then sync
suspend fun submitReview(review: Review) {
    val id = outbox.enqueue(                 // durable, idempotency key
        Operation(id = UUID.randomUUID().toString(), payload = review)
    )
    SyncWorker.enqueueUnique(context)        // constraints + backoff
}
// The UI observes the outbox row and shows pending → sent → failed.

Anything the user believes is saved must reach disk before the network is attempted. WorkManager then guarantees it eventually runs across process death and reboot — and the idempotency key means the retry cannot create a second review.

Anti-pattern — polling on a timer
PeriodicWorkRequestBuilder<SyncWorker>(15, MINUTES).build()
    .let { wm.enqueue(it) }        // duplicated every launch

// inside the worker:
wakeLock.acquire()
sync()                              // may throw
wakeLock.release()                  // unreachable on failure

Every launch enqueues another worker, so they race and multiply. The wakelock leaks on the error path and holds the CPU awake indefinitely. This is the shape of "battery consumption doubled after the release".

Practice — push-triggered, unique, bounded
wm.enqueueUniquePeriodicWork(
    "sync", ExistingPeriodicWorkPolicy.KEEP,
    PeriodicWorkRequestBuilder<SyncWorker>(6, HOURS)
        .setConstraints(Constraints(
            requiredNetworkType = NetworkType.UNMETERED,
            requiresBatteryNotLow = true))
        .build())

wakeLock.acquire(30_000)                    // hard timeout
try { sync() } finally { if (wakeLock.isHeld) wakeLock.release() }

Server push is the trigger; periodic work is the safety net, not the mechanism. Unique work makes re-enqueueing idempotent, and a timeout means a crash cannot leave the CPU held.

Chapter 182Permissions and privacy

Anti-pattern — ask at launch, assume forever
override fun onCreate(s: Bundle?) {
    requestPermissions(arrayOf(CAMERA, ACCESS_FINE_LOCATION,
        READ_EXTERNAL_STORAGE, POST_NOTIFICATIONS), 42)
}

fun takePhoto() = camera.capture()   // assumes CAMERA is granted

A wall of prompts before the user knows what the app does produces the worst grant rates in the industry. Worse, permissions are revocable at any time — including while backgrounded — so an assumed grant is a SecurityException waiting for a user who changed their mind.

Practice — in context, and always re-checked
val camera = rememberLauncherForActivityResult(RequestPermission()) { granted ->
    if (granted) vm.onCameraReady() else vm.onCameraDenied()
}

fun onAddPhotoClicked() = when {
    hasPermission(CAMERA) -> openCamera()          // check every time
    shouldShowRationale(CAMERA) -> showWhyWeNeedIt()
    else -> camera.launch(CAMERA)
}

Ask at the moment the value is obvious — when the user taps "Add photo". Every permission-gated call needs a not-granted-right-now branch, not just a request flow. And prefer the photo picker or ACTION_GET_CONTENT, which need no permission at all.

Chapter 183Notifications, intents and deep links

Anti-pattern — untrusted link, mutable intent
val pi = PendingIntent.getActivity(ctx, 0,
    Intent(ACTION_OPEN), PendingIntent.FLAG_MUTABLE)

// deep link handler
val url = intent.data?.getQueryParameter("next")
webView.loadUrl(url!!)                    // whatever the link says

A mutable PendingIntent wrapping an implicit intent lets another app fill it in and have it executed with your identity — intent redirection. And loading an arbitrary next parameter into a WebView is an open redirect: a phishing page rendered inside your app, wearing your branding.

Practice — immutable, explicit, allow-listed
val pi = PendingIntent.getActivity(ctx, requestCode,
    Intent(ctx, DetailActivity::class.java).putExtra(EXTRA_ID, id),
    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)

// One resolver, unit-testable without an emulator:
fun resolve(uri: Uri): Route? {
    if (uri.host !in ALLOWED_HOSTS) return null
    return ProductId.parse(uri.lastPathSegment)?.let(Route::Product)
}

Deep links are untrusted input from the internet. Validate the host, parse IDs into typed values, and route through one resolver that a JVM test can cover in milliseconds.

Chapter 184Storage and data

Anti-pattern — unbounded query on the main path
@Query("SELECT * FROM messages ORDER BY ts DESC")
fun all(): Flow<List<Message>>          // 80k rows, no index

suspend fun importAll(items: List<Message>) {
    items.forEach { dao.insert(it) }     // 80k transactions
}

Room invalidates per table, so every write re-runs this query over the whole table and re-maps 80,000 objects — on a hot table that is a permanent CPU load. And per-row inserts mean one fsync each: an import that should take under a second takes a minute.

Practice — bounded, indexed, batched
@Entity(indices = [Index(value = ["chat_id", "ts"])])
data class MessageEntity(...)

@Query("SELECT * FROM messages WHERE chat_id = :id " +
       "ORDER BY ts DESC LIMIT :limit")
fun page(id: String, limit: Int = 50): Flow<List<Message>>

@Insert(onConflict = REPLACE)
suspend fun insertAll(items: List<MessageEntity>)   // one transaction

The composite index covers both the filter and the sort so SQLite skips the temporary b-tree. Confirm with EXPLAIN QUERY PLAN: "SEARCH … USING INDEX" is what you want, "SCAN TABLE" means you have none.

Anti-pattern — data loss as a migration strategy
Room.databaseBuilder(ctx, Db::class.java, "app.db")
    .fallbackToDestructiveMigration()
    .build()

On the next schema bump this silently deletes everything — including the outbox of writes the user believes were saved. It is a data-loss incident scheduled for whenever someone adds a column.

Practice — real migrations, tested against real files
.addMigrations(MIGRATION_8_9)

@Test fun migrate_8_to_9_preserves_rows() {
    helper.createDatabase(TEST_DB, 8).apply {
        execSQL("INSERT INTO messages VALUES ('m1','hi',1700000000)"); close()
    }
    val db = helper.runMigrationsAndValidate(TEST_DB, 9, true, MIGRATION_8_9)
    db.query("SELECT read FROM messages WHERE id='m1'").use {
        it.moveToFirst(); assertEquals(0, it.getInt(0))
    }
}

Every shipped schema version is permanent public API — users skip releases, so version 3 must still reach version 12. Export the schema, commit it, and test every adjacent pair in CI.

Chapter 185Networking

Anti-pattern — retry everything, jitter nothing
suspend fun <T> retry(block: suspend () -> T): T {
    repeat(5) {
        try { return block() } catch (e: Exception) { delay(1000) }
    }
    return block()
}
retry { api.placeOrder(cart) }

Four separate faults: it retries 4xx, which will never succeed; it swallows CancellationException, breaking structured concurrency; it retries a non-idempotent write with no key, so a post-send timeout double-charges; and with no jitter, a million clients retry in lockstep and turn a brief backend blip into a sustained outage.

Practice — classify, jitter, key
suspend fun <T> retrying(
    attempts: Int = 4, baseMs: Long = 300,
    retryable: (Throwable) -> Boolean, block: suspend () -> T,
): T {
    repeat(attempts) { i ->
        try { return block() }
        catch (e: CancellationException) { throw e }
        catch (e: Throwable) {
            if (!retryable(e) || i == attempts - 1) throw e
            delay(Random.nextLong(0, min(8_000, baseMs shl i)))  // full jitter
        }
    }
    error("unreachable")
}
retrying(retryable = ::isTransient) { api.placeOrder(cart, key = op.id) }

Retry connection failures freely; retry a post-send timeout only behind an idempotency key generated at user commit and persisted before the call; never retry a 4xx.

Chapter 186UI and Compose

Anti-pattern — work inside the item body
LazyColumn {
    items(orders) { order ->                       // no key
        val fmt = SimpleDateFormat("dd MMM", Locale.getDefault())
        val total = order.lines.sumOf { it.price }  // recomputed per frame
        Row {
            Text(fmt.format(order.date))
            AsyncImage(model = order.thumbUrl)      // unbounded decode
        }
    }
}

A formatter allocated per item per frame, a sum recomputed during scroll, and full-resolution bitmap decodes. Without a key, inserting one order at the head shifts every item's remembered state and defeats reuse. This is what "the list is janky but recomposition counts look fine" actually means.

Practice — precompute, key, bound
// Mapped once, off the main thread, in the state layer:
data class OrderRow(val id: String, val dateLabel: String,
                    val totalLabel: String, val thumbUrl: String)

LazyColumn {
    items(rows, key = { it.id }, contentType = { "order" }) { row ->
        Row {
            Text(row.dateLabel)
            AsyncImage(
                model = ImageRequest.Builder(ctx).data(row.thumbUrl)
                    .size(120, 120).build(),
                modifier = Modifier.size(60.dp))
        }
    }
}

The item body should read fields and emit UI — nothing else. Every derivation moves into the state mapping, images decode to display size, and keys let the runtime move slots instead of rebuilding them.

Anti-pattern — events smuggled inside state
data class UiState(
    val items: List<Item> = emptyList(),
    val navigateTo: Route? = null,      // an event
    val toast: String? = null,          // another event
)
LaunchedEffect(state.navigateTo) {
    state.navigateTo?.let { nav.navigate(it); vm.clearNav() }
}

Rotation re-emits the state and navigates a second time. Two identical error messages in a row show once, because StateFlow is distinct-until-changed. And every consumer must remember to call a clear method.

Practice — state and events are different channels
val state: StateFlow<UiState> = ...            // what the screen looks like
private val _events = Channel<UiEvent>(BUFFERED)
val events = _events.receiveAsFlow()            // what happens once

LaunchedEffect(Unit) {
    lifecycle.repeatOnLifecycle(STARTED) {
        vm.events.collect { e -> when (e) {
            is Navigate -> nav.navigate(e.route)
            is ShowSnack -> snackbar.showSnackbar(e.text)
        } }
    }
}

The rule: if it should still be true after rotation it is state; if it should happen exactly once it is an event. A Channel delivers once, buffers while stopped, and replays nothing.

Chapter 187Concurrency

Anti-pattern — lost updates and hidden blocking
private val _state = MutableStateFlow(UiState())

fun addItem(item: Item) {
    _state.value = _state.value.copy(items = _state.value.items + item)
}

fun load() = viewModelScope.launch {
    val data = api.fetchBlocking()      // blocks Dispatchers.Main
    _state.value = _state.value.copy(data = data)
}

Read-modify-write on a StateFlow loses updates under concurrency — two callers read the same value and the second write wins. And a blocking call on the default main dispatcher freezes the frame pipeline: dropped frames now, an ANR under a slow network.

Practice — atomic updates, main-safety at the source
fun addItem(item: Item) =
    _state.update { it.copy(items = it.items + item) }      // CAS loop

class Repo(private val api: Api, private val io: CoroutineDispatcher) {
    /** Safe to call from any dispatcher. */
    suspend fun fetch(): Data = withContext(io) { api.fetchBlocking() }
}

update {} retries on conflict so no write is lost. Main-safety belongs to the function that knows it blocks, with an injected dispatcher so the test can replace it — never as a convention call sites must remember.

Chapter 188Security

Anti-pattern — secrets, logs and a gate that isn't one
object Config { const val API_SECRET = "sk_live_9f2b…" }   // in the APK

Log.d(TAG, "auth ok, token=$accessToken")                  // in bug reports

biometricPrompt.authenticate(info)
override fun onAuthenticationSucceeded(r: AuthResult) {
    unlocked = true; showBalance()                          // patchable branch
}

A constant in the APK is extractable with strings. Tokens in logcat reach crash breadcrumbs and support bundles. And a biometric check that only flips a boolean is bypassed by patching the branch — nothing cryptographic depends on the result.

Practice — server-side authority, hardware-backed keys
// No secret ships. The server authorises; the client proves identity.

val cipher = Cipher.getInstance(TRANSFORM).apply {
    init(Cipher.DECRYPT_MODE, keystoreKey, GCMParameterSpec(128, iv))
}
biometricPrompt.authenticate(info, CryptoObject(cipher))

override fun onAuthenticationSucceeded(r: AuthResult) {
    // usable ONLY because the user authenticated — enforced by hardware
    val token = r.cryptoObject!!.cipher!!.doFinal(encryptedToken)
    session.restore(token)
}

The client runs on hardware the attacker controls. Keys live in the Keystore and never enter your process; biometrics unlock a key rather than setting a flag; and every authorisation decision that matters is made by the server.

Chapter 189Resources, configuration and accessibility

Anti-pattern — hardcoded everything
Text("Delivered " + count + " orders")           // not translatable
Box(Modifier.size(32.dp).clickable { onDelete() }) {
    Icon(Icons.Default.Delete, contentDescription = null)
}
if (screenWidthDp > 600) TwoPane() else OnePane()   // breaks on fold

String concatenation cannot be translated or pluralised — word order differs by language. A 32dp target fails the minimum touch size and the icon announces nothing to TalkBack. And a raw width threshold misses that a foldable changes class at runtime, mid-flow.

Practice — resources, semantics, size classes
Text(pluralStringResource(R.plurals.orders_delivered, count, count))

IconButton(onClick = onDelete,                     // 48dp target, Role.Button
    modifier = Modifier.semantics {
        contentDescription = "Delete ${item.name}"
    }) { Icon(Icons.Default.Delete, contentDescription = null) }

when (windowSizeClass.widthSizeClass) {
    WindowWidthSizeClass.Expanded -> TwoPane()
    else -> OnePane()
}

Plurals handle languages with more than two forms. The semantics annotation serves TalkBack and the test at once — a screen that is hard to test is usually inaccessible. And window size classes react to folding and resizing, which fixed thresholds cannot.

Chapter 190The catalogue as a checklist

AreaThe question to ask in review
LifecycleWhich lifetime does this work belong to — view, screen, user intent, or the device?
StateIf the process died right now, would the user lose something they believe was saved?
BackgroundMust this happen eventually, or now and visibly? Is it unique, constrained and backed off?
PermissionsIs there a branch for "granted before, revoked since"?
IntentsImmutable and explicit? Is every deep-link parameter validated as untrusted input?
StorageBounded query, covering index, batched write, tested migration?
NetworkingWhich errors are retryable, is there jitter, and is the write idempotent?
UIAny allocation or derivation inside an item body? Keys present? Events out of state?
ConcurrencyRead-modify-write anywhere? Is main-safety owned by the blocking function?
SecurityDoes anything in the APK need to stay secret? Does a check gate a key or a boolean?
ResourcesConcatenated strings, fixed touch targets, or hardcoded width thresholds?
ObservabilityIf this fails silently in production, which metric moves?

Part XXXI rapid recall

  • viewLifecycleOwner for anything touching the view; repeatOnLifecycle to stop the producer.
  • State that matters is persisted and observed, never a field on a singleton.
  • Write to disk before the network for anything the user believes is saved.
  • Unique work, constraints, backoff, and wakelocks with a timeout in a finally.
  • Ask for permissions in context and re-check on every use.
  • FLAG_IMMUTABLE plus an explicit component; deep links are untrusted input.
  • Bounded queries, covering indexes, batched inserts, tested migrations — never destructive.
  • Classify errors, jitter the backoff, key the write.
  • Nothing derived or allocated in a lazy item body; keys always; events in a channel.
  • update {} not read-modify-write; main-safety at the blocking function.
  • Biometrics unlock a key; the server is the only real authority.
  • Plurals, semantics, and window size classes rather than concatenation, 32dp and magic numbers.

Part XXXII

End-to-End: a paginated, offline-first feature

One vertical slice built completely — Hilt, Room, Paging 3, coroutines, StateFlow and SharedFlow, WorkManager and Compose — with the trade-off behind every line stated out loud. Then the tasks an interviewer builds out of exactly this slice.

Why one worked slice

Most of this book argues about decisions in isolation. This part does the opposite: it wires one realistic feature end to end so the seams are visible — where pagination meets offline, where an optimistic write meets a server that disagrees, where a Flow stops being cold. Every snippet is production-shaped. Where a shortcut is taken, the why line says so.

Chapter 191The brief, and the one rule

Build a saved-articles feed. A cursor-paginated endpoint returns articles; the list scrolls indefinitely; each row has a save toggle. It must open instantly on a cold start with no network, scroll through everything already cached, accept save toggles while offline, and converge with the server when connectivity returns. Nothing may be lost to process death.

That brief contains the entire difficulty, and it resolves to one rule:

The rule

The UI reads the database and nothing else. The network is a process that writes into the database; so is the sync worker. Neither is ever on the path between the user and the pixels. Every design decision below follows from this, and almost every architecture that fails at this brief has broken it somewhere.

Once the database is the only reader-visible surface, "offline" stops being a mode with its own code path. There is no if (isOnline) anywhere in this feature. Offline is simply the case where the writers are quiet — the reader cannot tell, and does not need to.

flowchart LR
  API["Feed API · cursor pages"] --> RM["RemoteMediator"]
  RM --> DB[("Room · articles + remote_keys")]
  DB --> PS["PagingSource"]
  PS --> PAGER["Pager · PagingData"]
  PAGER --> VM["FeedViewModel · cachedIn"]
  VM --> UI["Compose · LazyColumn"]
  UI -- "toggle save" --> VM
  VM --> REPO["Repository · optimistic write"]
  REPO --> DB
  REPO --> OUT[("Room · outbox")]
  OUT --> W["SyncWorker"]
  W --> API
  W --> DB
  

Read the diagram as two loops that meet only in Room. The read loop is API → mediator → database → paging source → UI. The write loop is UI → repository → database + outbox → worker → API → database. They never call each other. That is what makes each one independently testable, and what makes the feature survive an airplane-mode toggle mid-scroll.

Module layout

ModuleContains
core:networkRetrofit, OkHttp, the auth interceptor, DTOs. Knows nothing about Room.
core:databaseRoom entities, DAOs, migrations, the outbox. Knows nothing about Retrofit.
core:modelDomain types. Depends on nothing.
data:feedThe repository, the RemoteMediator, the mappers, the sync worker. The only module that sees both sides.
feature:feedViewModel and Compose. Depends on data:feed's interface, never its implementation.

The mapper placement is deliberate and gets asked about: DTO→entity and entity→domain both live in data:feed, because it is the only module that should know that a thumb_url column and a thumbUrl JSON field are the same idea. Put the mapper in core:network and the network module now depends on the database; put it in feature:feed and every feature re-implements it.

Chapter 192The API contract

Pagination style is the first real decision, and it is the server's decision that the client must live with. Establish it before writing anything.

StyleMechanismWhere it breaks
Offset / page number?page=3&size=20. Trivial server-side, trivially resumable.An insert at the head shifts every page boundary: the user sees a duplicated row and misses another. Cost grows with offset because the database still walks the skipped rows.
Cursor / keyset?cursor=eyJ0cyI6…&limit=20. The cursor encodes the sort key of the last row.Cannot jump to an arbitrary page, and the cursor is opaque — the client must persist it because it cannot recompute it. Requires a stable, unique sort key.
Sync token / deltaServer returns a token representing "everything you know"; the next call returns changes since.Best for a feed that must converge rather than merely append, but requires server-side tombstones and change tracking. See Part XVII.

This feature uses cursor pagination, which is the correct default for an append-only feed and the one that forces you to solve the interesting problem: the cursor is opaque, so it must be persisted alongside the data or the app cannot resume paging after process death.

core:network — the contract
interface FeedApi {

    @GET("v1/articles")
    suspend fun articles(
        @Query("cursor") cursor: String?,     // null = first page
        @Query("limit") limit: Int,
    ): PageDto<ArticleDto>

    /** Idempotent by header, so a retry after a post-send timeout is safe. */
    @PATCH("v1/articles/{id}/state")
    suspend fun updateState(
        @Path("id") id: String,
        @Header("Idempotency-Key") key: String,
        @Body body: StatePatchDto,
    ): ArticleDto
}

@Serializable
data class PageDto<T>(
    val items: List<T>,
    /** null means the server has nothing more. Not an empty string. */
    @SerialName("next_cursor") val nextCursor: String? = null,
    @SerialName("server_time") val serverTime: Long,
)

@Serializable
data class ArticleDto(
    val id: String,
    val title: String,
    val excerpt: String,
    @SerialName("thumb_url") val thumbUrl: String? = null,
    val saved: Boolean,
    @SerialName("updated_at") val updatedAt: Long,
    /** Server-assigned ordering key. The client never invents one. */
    val position: Long,
)

Three details that are worth arguing for in an interview. The end-of-pagination signal is a nullable cursor, not an empty item list — an empty page with a cursor is a legitimate response when the server filters after paging, and treating it as the end truncates the feed. Every DTO field that the server may add later has a default, so an older client does not crash on a newer payload. And position comes from the server: if the client orders by updated_at, an edit reorders the feed under the user's thumb mid-scroll.

Chapter 193Room as the single source of truth

The schema carries three things that a naive cache does not: which feed a row belongs to, where the server put it, and what the user has done to it that the server has not seen yet. That last column is what makes offline writes work.

core:database — entities
@Entity(
    tableName = "articles",
    indices = [Index(value = ["feed_id", "position"])],   // covers filter + sort
)
data class ArticleEntity(
    @PrimaryKey val id: String,
    @ColumnInfo(name = "feed_id") val feedId: String,
    val title: String,
    val excerpt: String,
    @ColumnInfo(name = "thumb_url") val thumbUrl: String?,
    @ColumnInfo(name = "updated_at") val updatedAt: Long,
    val position: Long,

    /** Server truth. Only sync writes this. */
    @ColumnInfo(name = "saved_remote") val savedRemote: Boolean,

    /** Local intent while a write is unacknowledged. null = agrees with server. */
    @ColumnInfo(name = "saved_local") val savedLocal: Boolean? = null,
)

@Entity(tableName = "remote_keys")
data class RemoteKeyEntity(
    @PrimaryKey @ColumnInfo(name = "feed_id") val feedId: String,
    /** The opaque cursor for the NEXT append. null = end reached. */
    @ColumnInfo(name = "next_cursor") val nextCursor: String?,
    @ColumnInfo(name = "refreshed_at") val refreshedAt: Long,
)

Splitting saved into a remote column and a local override is the single most important decision in this part. One mutable saved boolean cannot answer the question sync must ask on every reconnect — is this value the server's, or something the user did that I still owe the server? — and without that answer a refresh silently reverts the user's taps.

core:database — the DAO the UI actually reads
/** The projection the list renders. Note it has no nullable-override concept:
 *  the merge happens in SQL so no caller can forget it. */
data class ArticleRow(
    val id: String,
    val title: String,
    val excerpt: String,
    @ColumnInfo(name = "thumb_url") val thumbUrl: String?,
    val saved: Boolean,
    val pending: Boolean,
)

@Dao
interface ArticleDao {

    @Query("SELECT id, title, excerpt, thumb_url, " +
           "COALESCE(saved_local, saved_remote) AS saved, " +
           "(saved_local IS NOT NULL) AS pending " +
           "FROM articles WHERE feed_id = :feedId ORDER BY position ASC")
    fun pagingSource(feedId: String): PagingSource<Int, ArticleRow>

    @Upsert
    suspend fun upsertAll(items: List<ArticleEntity>)      // one transaction

    @Query("UPDATE articles SET saved_local = :saved WHERE id = :id")
    suspend fun setSavedLocal(id: String, saved: Boolean)

    @Query("UPDATE articles SET saved_remote = :saved, saved_local = NULL, " +
           "updated_at = :updatedAt WHERE id = :id")
    suspend fun applyServerState(id: String, saved: Boolean, updatedAt: Long)

    /** Refresh clears the server's view of the feed — never the local overrides. */
    @Query("DELETE FROM articles WHERE feed_id = :feedId AND saved_local IS NULL")
    suspend fun clearSyncedRows(feedId: String)
}

Why the merge is a SQL expression. COALESCE(saved_local, saved_remote) means the UI physically cannot render a stale value, and the pending flag that drives the "not synced yet" affordance falls out of the same row for free. Compute this in Kotlin instead and you have introduced a rule that every future call site must remember; here it is impossible to get wrong because there is no other query.

Why clearSyncedRows has a WHERE clause. The standard RemoteMediator sample deletes the whole table on REFRESH. Do that here and a user who saved three articles on the underground loses all three the moment the app reconnects and refreshes — the local intent is deleted before the worker ever sends it. This is the most common data-loss bug in offline-first paging, and it is invisible in testing because it needs an offline write followed by a refresh.

Chapter 194Hilt wiring

Dependency injection earns its cost here in exactly one way: everything that touches the outside world — clock, dispatcher, network, database — arrives through the constructor, so the whole slice runs in a JVM test with no emulator. That is the argument to make, not "it reduces boilerplate".

Qualifiers, so dispatchers are injected rather than named
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class ApplicationScope

@Module @InstallIn(SingletonComponent::class)
object CoroutinesModule {
    @Provides @IoDispatcher fun io(): CoroutineDispatcher = Dispatchers.IO

    /** Survives every screen. For work that must finish even if the user leaves. */
    @Provides @Singleton @ApplicationScope
    fun appScope(@IoDispatcher io: CoroutineDispatcher): CoroutineScope =
        CoroutineScope(SupervisorJob() + io)
}
Anti-pattern — the dispatcher is hardcoded
class ArticleRepository @Inject constructor(private val api: FeedApi) {
    suspend fun refresh() = withContext(Dispatchers.IO) { api.articles(null, 20) }
}

The test now needs a real thread pool, so it cannot use virtual time: every delay in the code under test costs real wall-clock seconds, and assertions race the dispatcher. Suites written this way are the origin of most "flaky on CI, passes locally" tickets.

Practice — injected, replaceable in one line
class ArticleRepository @Inject constructor(
    private val api: FeedApi,
    @IoDispatcher private val io: CoroutineDispatcher,
) {
    suspend fun refresh() = withContext(io) { api.articles(null, 20) }
}

// test:  ArticleRepository(fakeApi, StandardTestDispatcher(scheduler))

One TestDispatcher makes the whole slice deterministic and instant. This is the concrete payoff of DI in Android and the reason to introduce Hilt at all — say it in exactly these terms.

Binding the implementation behind an interface
@Module @InstallIn(SingletonComponent::class)
abstract class FeedDataModule {
    /** @Binds, not @Provides — no factory code is generated for a cast. */
    @Binds @Singleton
    abstract fun repository(impl: ArticleRepositoryImpl): ArticleRepository
}

@Module @InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides @Singleton
    fun db(@ApplicationContext ctx: Context): AppDatabase =
        Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db")
            .addMigrations(MIGRATION_4_5)          // never fallbackToDestructiveMigration
            .build()

    @Provides fun articleDao(db: AppDatabase): ArticleDao = db.articleDao()
    @Provides fun outboxDao(db: AppDatabase): OutboxDao = db.outboxDao()
}

The scoping question interviewers ask: why is the repository @Singleton? Not for performance — because it owns the outbox and the refresh signal, and two instances would mean two independent views of pending work. Scope follows shared mutable state, not object cost. A stateless mapper needs no scope at all, and adding one there is cargo cult.

Chapter 195RemoteMediator — where pagination meets offline

RemoteMediator is the piece people get wrong, because its job is counter-intuitive: it does not supply pages to the UI. It is a write-behind component that notices the list is running out of cached rows and fills the database. The PagingSource over Room is what the UI actually reads, and it re-emits automatically because Room invalidates the query.

data:feed — the mediator in full
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
    private val feedId: String,
    private val api: FeedApi,
    private val db: AppDatabase,
    private val articles: ArticleDao,
    private val keys: RemoteKeyDao,
    private val clock: Clock,
) : RemoteMediator<Int, ArticleRow>() {

    /** Skip the network entirely if the cache is fresh — this is what makes
     *  a warm start render instantly instead of flashing a spinner. */
    override suspend fun initialize(): InitializeAction {
        val age = clock.now() - (keys.refreshedAt(feedId) ?: 0L)
        return if (age < CACHE_TTL_MS) InitializeAction.SKIP_INITIAL_REFRESH
               else InitializeAction.LAUNCH_INITIAL_REFRESH
    }

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, ArticleRow>,
    ): MediatorResult {

        val cursor: String? = when (loadType) {
            LoadType.REFRESH -> null                       // start from the top
            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            LoadType.APPEND -> keys.nextCursor(feedId)
                ?: return MediatorResult.Success(endOfPaginationReached = true)
        }

        return try {
            val page = api.articles(cursor = cursor, limit = state.config.pageSize)

            db.withTransaction {                           // atomic: keys and rows agree
                if (loadType == LoadType.REFRESH) {
                    articles.clearSyncedRows(feedId)       // preserves local overrides
                    keys.clear(feedId)
                }
                keys.upsert(RemoteKeyEntity(feedId, page.nextCursor, clock.now()))
                articles.upsertAll(page.items.map { it.toEntity(feedId) })
            }

            MediatorResult.Success(endOfPaginationReached = page.nextCursor == null)

        } catch (e: CancellationException) {
            throw e                                        // never swallow cancellation
        } catch (e: IOException) {
            MediatorResult.Error(e)                        // offline: cache still renders
        } catch (e: HttpException) {
            MediatorResult.Error(e)
        }
    }
}

Why the cursor comes from a table, not from state.lastItemOrNull(). The samples derive the next key from the last loaded item, which only works when the key is a field of the item. An opaque cursor is not — it encodes server-side sort state the client cannot reconstruct. Persisting it in remote_keys is also what lets paging resume after process death: the user returns from Recents forty rows down and the next append continues correctly instead of re-fetching page one.

Why the transaction wraps both writes. Without it, a crash between "store cursor" and "store rows" leaves a cursor pointing past data that was never saved, and those articles are invisible forever — a gap the user cannot recover from without clearing app data.

Why MediatorResult.Error is not a failure state for the screen. It records that this load failed. The PagingSource over Room keeps emitting whatever is cached, so the list stays fully readable offline. The UI's job — Chapter 198 — is to show that error as a retry affordance without blanking the content, and that distinction is the entire difference between offline-first and offline-hostile.

The question that follows this code

"What happens if the user scrolls fast while offline?" Paging invokes load once per boundary, sees Error, and stops — it does not hammer. Recovery is retry(), driven by the user or by a connectivity signal. If you also want automatic recovery on reconnect, collect connectivity in the ViewModel and call retry(); do not put a network callback inside the mediator, which has no lifecycle of its own.

Chapter 196The repository, and where SharedFlow is genuinely right

The repository assembles the Pager and owns the write path. It deliberately does not call cachedIn — that operator needs a scope, and the only correct scope is the ViewModel's. A repository is a singleton; caching paging state there leaks it across screens.

data:feed — repository
@Singleton
class ArticleRepositoryImpl @Inject constructor(
    private val api: FeedApi,
    private val db: AppDatabase,
    private val articles: ArticleDao,
    private val outbox: OutboxDao,
    private val keys: RemoteKeyDao,
    private val sync: SyncScheduler,
    private val clock: Clock,
    @IoDispatcher private val io: CoroutineDispatcher,
) : ArticleRepository {

    override fun feed(feedId: String): Flow<PagingData<ArticleRow>> = Pager(
        config = PagingConfig(
            pageSize = 20,
            prefetchDistance = 10,      // start the next fetch 10 rows early
            initialLoadSize = 40,       // fill the first screen in one round trip
            enablePlaceholders = false, // count is unknown with cursor paging
        ),
        remoteMediator = ArticleRemoteMediator(feedId, api, db, articles, keys, clock),
        pagingSourceFactory = { articles.pagingSource(feedId) },
    ).flow                              // cachedIn belongs to the ViewModel

    /** Returns as soon as the intent is durable. The network is not on this path. */
    override suspend fun setSaved(articleId: String, saved: Boolean) = withContext(io) {
        db.withTransaction {
            articles.setSavedLocal(articleId, saved)
            outbox.upsert(
                OutboxEntity(
                    articleId = articleId,
                    saved = saved,
                    opId = UUID.randomUUID().toString(),   // fresh idempotency key
                    createdAt = clock.now(),
                )
            )
        }
        sync.requestSync()
    }
}

The PagingConfig numbers are an argument, not a default. initialLoadSize above pageSize exists so the first screen does not need two round trips; setting them equal is the most common cause of "the list loads, then immediately loads again". prefetchDistance trades data for smoothness — raise it on a fast feed, lower it on a metered one. And enablePlaceholders requires a known total count, which a cursor API cannot give you, so it must be off here.

StateFlow, SharedFlow, or Channel

All three appear in this feature, doing three different jobs. Choosing by habit rather than by question is what produces duplicated navigation and lost snackbars.

PrimitiveThe question it answersUsed here for
StateFlow"What does the screen look like right now?" Always has a value, conflated, distinct-until-changed.Screen state — filter selection, sync banner. Survives rotation by definition.
SharedFlow"Something happened that several unrelated observers care about." Configurable replay, multicast, no current value.The app-wide sync-finished signal. Two screens and a badge subscribe; none owns it.
Channel"Something must be handled exactly once by exactly one consumer."Snackbars and navigation. Buffers while the screen is stopped, replays nothing on rotation.
The honest SharedFlow — one publisher, several unrelated subscribers
@Singleton
class SyncSignals @Inject constructor() {

    private val _completed = MutableSharedFlow<SyncResult>(
        replay = 0,                                  // a sync that finished before
                                                     // you opened the screen is not news
        extraBufferCapacity = 8,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )
    val completed: SharedFlow<SyncResult> = _completed.asSharedFlow()

    /** tryEmit, not emit: the worker must never be suspended by a slow subscriber. */
    fun publish(result: SyncResult) { _completed.tryEmit(result) }
}

Why this is a SharedFlow and the snackbar is not. Multicast is the whole point: the feed screen, the settings screen and a toolbar badge each want to know that a sync finished, and none of them owns the signal. A Channel would deliver to exactly one of them, arbitrarily. Conversely a snackbar has exactly one consumer and must not be dropped when the screen is briefly stopped — that is a Channel. Getting these backwards produces the two classic bugs: an event that reaches only one of three collectors, and a toast that vanishes because it fired while the app was backgrounded.

replay = 0 with a non-zero buffer plus DROP_OLDEST is the configuration that makes tryEmit always succeed and never block the worker. A replay = 1 here would re-deliver the last sync result to every newly-opened screen — a stale snackbar on every navigation.

Chapter 197The ViewModel

feature:feed — ViewModel
@HiltViewModel
class FeedViewModel @Inject constructor(
    private val repo: ArticleRepository,
    private val signals: SyncSignals,
    savedState: SavedStateHandle,
) : ViewModel() {

    private val feedId: String = checkNotNull(savedState["feedId"])

    /** cachedIn survives configuration change; without it, rotation refetches
     *  page one and the user loses their scroll position. */
    val items: Flow<PagingData<ArticleRow>> =
        repo.feed(feedId).cachedIn(viewModelScope)

    private val _state = MutableStateFlow(FeedUiState())
    val state: StateFlow<FeedUiState> = _state.asStateFlow()

    private val _events = Channel<FeedEvent>(Channel.BUFFERED)
    val events: Flow<FeedEvent> = _events.receiveAsFlow()

    init {
        viewModelScope.launch {
            signals.completed.collect { result ->
                _state.update { it.copy(syncing = false) }
                if (result is SyncResult.Failed) {
                    _events.send(FeedEvent.Snack("Changes will sync when you're back online"))
                }
            }
        }
    }

    fun onSaveToggled(row: ArticleRow) {
        viewModelScope.launch {
            _state.update { it.copy(syncing = true) }
            try {
                repo.setSaved(row.id, !row.saved)      // returns once durable
            } catch (e: CancellationException) {
                throw e                                 // structured concurrency
            } catch (e: Throwable) {
                _events.send(FeedEvent.Snack("Couldn't save — please try again"))
            }
        }
    }
}

data class FeedUiState(val syncing: Boolean = false, val filter: Filter = Filter.All)
sealed interface FeedEvent { data class Snack(val text: String) : FeedEvent }

Note what is not in FeedUiState: the list. Paging owns its own stream and its own load states; copying rows into the state object means holding every loaded page in memory and re-emitting the whole list on every unrelated state change. Two streams from one ViewModel is the correct shape here, not a smell.

Note the absence of runCatching. It catches Throwable, which includes CancellationException — so a ViewModel cleared mid-write would be reported to the user as a save failure, and the coroutine machinery would be told the cancellation was handled. Explicit rethrow, every time.

Anti-pattern — the shape that leaks a collector
val user: StateFlow<User?> = repo.observeUser()
    .stateIn(viewModelScope, SharingStarted.Eagerly, null)

Eagerly keeps the upstream — and any database or socket it holds — alive for the ViewModel's whole life, including the hours the app spends in the background. On a screen the user is not looking at, that is a battery and memory cost with no reader.

Practice — bounded by subscription, with a rotation grace
val user: StateFlow<User?> = repo.observeUser()
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

The five seconds is not superstition: it is longer than a configuration change and shorter than a user's attention. The upstream survives rotation without restarting, and stops when the screen is genuinely gone.

Chapter 198The Compose layer

Load-state handling is where offline-first is won or lost. There are three independent load states — refresh, append, prepend — and the rule is that an error only takes over the screen when there is nothing to show.

feature:feed — screen
@Composable
fun FeedScreen(
    snackbar: SnackbarHostState,
    vm: FeedViewModel = hiltViewModel(),
) {
    val items = vm.items.collectAsLazyPagingItems()
    val state by vm.state.collectAsStateWithLifecycle()
    val lifecycle = LocalLifecycleOwner.current.lifecycle

    LaunchedEffect(Unit) {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            vm.events.collect { event ->
                when (event) {
                    is FeedEvent.Snack -> snackbar.showSnackbar(event.text)
                }
            }
        }
    }

    val refresh = items.loadState.refresh
    val isEmpty = items.itemCount == 0

    when {
        refresh is LoadState.Loading && isEmpty -> FeedSkeleton()
        refresh is LoadState.Error && isEmpty ->
            ErrorPane(refresh.error, onRetry = items::retry)
        refresh is LoadState.NotLoading && isEmpty -> EmptyPane()
        else -> LazyColumn {
            // Cached content is showing; a failed refresh is a banner, not a takeover.
            if (refresh is LoadState.Error) {
                item { StaleBanner(onRetry = items::retry) }
            }
            items(
                count = items.itemCount,
                key = items.itemKey { it.id },
                contentType = items.itemContentType { "article" },
            ) { index ->
                items[index]?.let { row ->
                    ArticleCard(
                        row = row,
                        onToggleSave = { vm.onSaveToggled(row) },
                    )
                }
            }
            when (val append = items.loadState.append) {
                is LoadState.Loading -> item { AppendSpinner() }
                is LoadState.Error -> item { AppendRetry(onRetry = items::retry) }
                else -> Unit
            }
        }
    }
}
Anti-pattern — the error swallows the cache
when (items.loadState.refresh) {
    is LoadState.Loading -> FullScreenSpinner()
    is LoadState.Error   -> FullScreenError()      // 200 cached rows, hidden
    else -> ArticleList(items)
}

Every one of the user's cached articles is replaced by an error page the moment the device loses signal. All the work in Chapters 193–196 exists to make offline reading possible, and four lines of UI throw it away. This is the single most common bug in Paging 3 code review.

Practice — the error is proportional to what it cost
when {
    refresh is LoadState.Error && items.itemCount == 0 -> FullScreenError(::retry)
    refresh is LoadState.Error -> { StaleBanner(::retry); ArticleList(items) }
    else -> ArticleList(items)
}

Nothing cached means a full-screen error is the only honest UI. Something cached means the content stays and the failure is a dismissible banner. State the rule as: degrade, never blank.

Why key and contentType are not optional here. A save toggle updates one row; without a stable key, Room's invalidation produces a new PagingData and Compose rebuilds every visible item — a visible stutter on a mid-tier device. contentType lets the runtime reuse slots between items of the same shape rather than discarding them.

Why collectAsStateWithLifecycle rather than collectAsState. The latter keeps collecting while the app is in the background: a state flow backed by a database query goes on running against a screen nobody can see. On the events channel the difference is sharper still — repeatOnLifecycle(STARTED) is what makes a snackbar wait for the user to come back instead of being shown to an empty screen.

Chapter 199Writes: optimistic update plus outbox

The write path has one requirement that dictates its whole shape: the tap must be durable before the network is attempted. Everything else follows.

core:database — the outbox
@Entity(tableName = "outbox")
data class OutboxEntity(
    /** Keyed by article, so repeated toggles COALESCE into one pending intent. */
    @PrimaryKey @ColumnInfo(name = "article_id") val articleId: String,
    val saved: Boolean,
    /** Regenerated whenever the intent changes. Sent as Idempotency-Key. */
    @ColumnInfo(name = "op_id") val opId: String,
    @ColumnInfo(name = "created_at") val createdAt: Long,
    val attempts: Int = 0,
)

The primary key is the design. Saving, unsaving and re-saving an article produces one row, not three: for a set-membership toggle only the final intent has meaning and the intermediate states are not business events. That is not universally true — an outbox of "post a comment" operations must be append-only and keyed by operation id, because every comment is a distinct fact. Choosing between these two is a genuine Staff-level question, and the answer is: does replaying only the last operation produce the same server state as replaying all of them? If yes, coalesce; if no, append.

The idempotency key is generated at user commit and persisted with the row — not at send time. A key generated when the request is dispatched changes on every retry, which defeats the entire mechanism: the server sees two distinct operations and applies both.

MomentWhat the user seesWhat is true on disk
TapIcon fills immediately; a subtle pending marksaved_local = 1, outbox row written, same transaction
Offline, app killedReopens with the icon still filledBoth rows survived; worker is queued by constraint
ReconnectPending mark clears; nothing movessaved_remote = 1, saved_local = NULL, outbox row deleted
Server rejects (4xx)Icon reverts, with an explanationsaved_local = NULL, outbox row deleted — server wins

Chapter 200The sync worker

data:feed — worker
@HiltWorker
class SyncWorker @AssistedInject constructor(
    @Assisted ctx: Context,
    @Assisted params: WorkerParameters,
    private val api: FeedApi,
    private val db: AppDatabase,
    private val articles: ArticleDao,
    private val outbox: OutboxDao,
    private val signals: SyncSignals,
) : CoroutineWorker(ctx, params) {

    override suspend fun doWork(): Result {
        var retryable = false

        for (op in outbox.oldestBatch(limit = 50)) {
            try {
                val dto = api.updateState(
                    id = op.articleId,
                    key = op.opId,                          // survives retries
                    body = StatePatchDto(saved = op.saved),
                )
                db.withTransaction {
                    articles.applyServerState(dto.id, dto.saved, dto.updatedAt)
                    outbox.delete(op.articleId)
                }
            } catch (e: CancellationException) {
                throw e
            } catch (e: IOException) {
                retryable = true                            // offline — keep the row
            } catch (e: HttpException) {
                if (e.code() == 429 || e.code() in 500..599) {
                    retryable = true
                } else {
                    // 4xx will never succeed. Drop the intent, revert to server truth,
                    // and make sure the user is told rather than silently overruled.
                    db.withTransaction {
                        articles.clearLocalOverride(op.articleId)
                        outbox.delete(op.articleId)
                    }
                    signals.publish(SyncResult.Rejected(op.articleId))
                }
            }
        }

        signals.publish(if (retryable) SyncResult.Failed else SyncResult.Ok)
        return if (retryable) Result.retry() else Result.success()
    }
}

class SyncScheduler @Inject constructor(@ApplicationContext private val ctx: Context) {
    fun requestSync() = WorkManager.getInstance(ctx).enqueueUniqueWork(
        "outbox-sync",
        ExistingWorkPolicy.KEEP,                            // draining is idempotent
        OneTimeWorkRequestBuilder<SyncWorker>()
            .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
            .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
            .build(),
    )
}

Error classification is the substance of this worker. Retrying a 400 forever burns battery and never succeeds; not retrying a 503 loses the user's data. The three buckets — transient (retry), permanent (drop and inform), cancellation (rethrow) — are what an interviewer is checking for, and most candidates write a single catch (e: Exception) { return Result.retry() }.

Why KEEP and not APPEND. The worker drains the whole outbox, so a second request arriving while one runs is redundant. The honest caveat: an operation enqueued after the batch read but before completion would wait for the next trigger. Two mitigations, and naming one unprompted is a strong signal — re-check outbox.count() at the end of doWork and return Result.retry() if non-zero, and keep a low-frequency periodic worker as the safety net rather than the mechanism.

Why oldestBatch(limit = 50) and not the whole table. A user who spent a week offline can accumulate thousands of operations; an unbounded loop inside a worker with a ten-minute execution limit gets killed halfway through, with no partial progress recorded if the deletes were in one outer transaction. Bounded batches make progress monotonic.

Chapter 201Conflict resolution

Two devices toggle the same article while both are offline. Whatever the strategy, state it explicitly — "the server wins" is a legitimate answer; "I hadn't thought about it" is the one that fails the round.

StrategyMechanismCorrect when
Server authoritativeClient sends intent; the server's response is applied verbatim, overwriting local. What this feature does.The server can validate — entitlements, quotas, moderation. Simplest to reason about and the right default.
Last-write-winsCompare timestamps, newest wins.Only with a server-issued clock. Device clocks are user-settable, so LWW on client time is a data-loss bug wearing a strategy's name.
Merge by operationSend the operation ("add to set"), not the resulting value, and let the server apply it.Concurrent edits to different parts of one object, or set membership. Naturally convergent — order does not matter.
Surface to the userKeep both versions, ask.The conflict is semantically meaningful and expensive to get wrong — a document body, not a bookmark.

This feature is quietly in the third category and it is worth noticing why: "saved" is set membership, and add and remove are idempotent operations. Two devices both saving converge on saved regardless of arrival order. Had the field been a counter or a free-text note, the same architecture with the same code would be wrong — the outbox would need to carry deltas rather than final values. The architecture is not what makes it converge; the shape of the operation is.

Chapter 202Testing the slice

Everything above runs on the JVM. Nothing here needs an emulator except the Room migration test, which needs one because it must exercise real SQLite.

Under testHowThe assertion that matters
RemoteMediatorCall load() directly with a hand-built PagingState. No Pager needed.REFRESH after an offline write leaves saved_local intact — the data-loss regression from Chapter 193.
Paging end to endpaging-testing: repo.feed(id).asSnapshot { scrollTo(60) }Exactly four pages fetched, no duplicate ids across boundaries.
Repository writesIn-memory Room plus a fake FeedApi.After setSaved, both the override and the outbox row exist in the same transaction — kill the process between them and the feature is broken.
ViewModelTurbine on state and events, StandardTestDispatcher.A failed sync emits exactly one snackbar event, and re-collecting after rotation does not replay it.
WorkerConstruct it directly; fake api returns 503 then 200.The first pass returns retry() and keeps the row; the second clears it. Same opId both times.
MigrationsMigrationTestHelper on-device, real files.Every adjacent version pair, with rows present. See Part XXXI.
The regression test worth writing first
@Test fun refresh_preserves_unsynced_local_edits() = runTest {
    // given: an article cached, then saved while offline
    articles.upsertAll(listOf(article(id = "a1", savedRemote = false)))
    repo.setSaved("a1", true)

    // when: the feed refreshes from the network
    mediator.load(LoadType.REFRESH, emptyPagingState())

    // then: the user's intent survived, and is still owed to the server
    assertTrue(articles.row("a1").saved)
    assertEquals(1, outbox.count())
}

This test fails against the canonical RemoteMediator sample, which is the point. It encodes the one behaviour that separates a cache from an offline-first store, and it is cheap enough to run on every commit.

Chapter 203Interview tasks built on this feature

Interviewers rarely ask you to build all of the above — there is no time. They take one seam and press on it. These are the tasks that actually appear, with what each is really measuring and the trap that catches strong candidates.

TaskLevel · timeWhat it is really testing
Wire a paginated list from an existing API to a Compose screen.Core 60 minWhether you reach for a database at all, or paginate straight from the network into memory. The latter is the down-level.
Add offline support to this working online-only feed.Advanced take-homeWhether "offline" becomes a branch or becomes the architecture. Look for a single source of truth and no isOnline checks.
The save button feels laggy. Fix it.Core 30 minOptimistic update plus durable intent — not a faster network call. Follow-up is always "what if it fails?".
Users report saves disappearing after a few minutes.Advanced 45 minDebugging judgment. The bug is refresh deleting unsynced local state. Do you reproduce before theorising?
Make this ViewModel testable.Starter 30 minInjected dispatchers and clock, an interface at the repository boundary, no static System.currentTimeMillis().
Two devices, both offline, both toggle. What ships?Staff+ discussionWhether you have a stated conflict policy and know why device clocks disqualify LWW.
Add pull-to-refresh without losing scroll position.Core 30 mincachedIn, refresh() versus invalidate(), and what a REFRESH does to the remote keys table.
Review this PR (the anti-pattern column of Chapter 198).Core 45 minWhether you spot that a failed refresh blanks 200 cached rows — and whether you lead with it or with naming style.
The list stutters when a save toggles. Diagnose.Advanced 30 minMissing key, work inside the item body, or a state copy that re-emits the whole list. Do you measure or guess?
Extend the outbox to support commenting.Staff+ 45 minWhether you notice that coalescing by entity id is wrong for append-only operations. The best question in this list.

Task · live coding, 45 minutes

"Here is a working online-only feed. Make it work offline."

What the interviewer is watching for

Not the code — the first sentence. A candidate who starts typing is guessing. A candidate who asks "should the user be able to write offline, or only read?" has already separated the two problems that this task hides, and the answer changes the design by an order of magnitude.

Graded expectations

L3 Adds Room, caches responses, reads from the cache when a request fails. Works, and has an if (networkAvailable) in it. Writes are not addressed unless prompted.

L4 Inverts the flow: the database becomes the only thing the UI reads and the network becomes a writer. Names the consequence — there is no offline branch because there is no online branch either. Adds an outbox for writes, generates the idempotency key at commit, and identifies that refresh must not delete unsynced rows. States what is not handled: deletions on the server produce stale local rows without tombstones.

L5 Adds the organisational frame: this is a pattern the whole app will copy, so it ships as a documented module with a template and a lint rule, not as one feature's cleverness. Names the measurable success criterion — not "it works offline" but a drop in save-related support tickets and a rise in successful writes per session. Names the exit: if the product later needs real-time collaboration, this outbox is the wrong shape and here is what replaces it.

Follow-ups you should expect

  1. "The user saves while offline, then the article is deleted server-side. What happens?" — The sync gets a 404, which is permanent: drop the operation, revert the override, tell the user. Without tombstones the row itself lingers until the next full refresh; say so rather than implying convergence you have not built.
  2. "How large can the outbox get?" — Unbounded is a bug. Cap it, and decide what full means: reject new writes with a visible error, or drop the oldest. Never fail silently.
  3. "Why not just retry in a coroutine instead of WorkManager?" — Because a coroutine dies with the process and the user's intent must survive a reboot. WorkManager is the only thing on Android that guarantees eventually.

Task · debugging, 45 minutes

"Users say articles they saved on the train are unsaved by the time they get to work."

Why this is the best task on the list

It has a specific, findable root cause, and the path to it is pure methodology. The bug is DELETE FROM articles on REFRESH destroying rows whose local intent had not yet been sent. Everything about the report points at it — offline writes, then a reconnect — and yet the common failure is to start reading the sync worker, because that is where "saving" feels like it lives.

The path a strong candidate takes

  1. Reproduce before theorising. Airplane mode, save, re-enable, observe. Two minutes, and it converts a vague report into a deterministic case.
  2. Establish where the value dies. Query the table directly after each step. Is the outbox row gone, or the override? Different columns mean different bugs.
  3. Read the write path in reverse from the column that lost its value, not forward from the button.
  4. Write the failing test first — the one in Chapter 202 — then fix. A bug this cheap to reproduce and this expensive in trust must never regress.
  5. Ask what else shares the shape. Any other REFRESH handler in the app that clears a table has the same defect today.

The answer that gets down-levelled

"I'd add logging and ship it to see what happens." Sometimes correct for a genuine heisenbug; here it is an admission that a locally reproducible bug was not reproduced. The reflex an interviewer is checking for is reproduce, then bisect — everything else is commentary.

Task · design discussion, 20 minutes

"Walk me through what happens between the tap and the pixel when a user saves an article."

What this question is for

It cannot be answered from memorised vocabulary, which is exactly why it is asked. Either you have built this path or you have not, and the answer reveals which within thirty seconds.

The shape of a complete answer

Tap arrives at a lambda in the item body, which does no work beyond calling the ViewModel. The ViewModel launches in viewModelScope and calls the repository, which opens one Room transaction writing both the local override and the outbox row — durable before anything else happens. That commit invalidates the Room query; the PagingSource re-emits; COALESCE yields the new value with pending = 1; Compose recomposes just that item because the list has stable keys. The whole loop is local and takes a frame or two. Separately, the repository asks WorkManager for a unique sync; when connectivity allows, the worker sends the intent with the idempotency key generated at tap time, applies the server's response, clears the override, deletes the outbox row, and publishes on a SharedFlow that anyone interested can observe. The user sees the pending mark disappear. The network was never on the path between the tap and the pixel.

Follow-ups

  1. "Where could this lose data?" — Between the two writes if they were not in one transaction; on an unbounded outbox; on a 4xx that is dropped without telling the user.
  2. "Which part would you delete if the deadline halved?" — The pending affordance, and the SharedFlow. Not the transaction, not the idempotency key: those are correctness, and the rest is polish.
  3. "What if the product wants an undo?" — The outbox already is one, for as long as the row is unsent. Making undo reliable after sync is a different feature — a server-side reversal — and it should be scoped as one.

Chapter 204What is actually being graded

SeamThe Senior answerThe Staff answer
Source of truthCache network responses in Room.Invert it: the UI reads only the database, so no code path knows whether the device is online.
PaginationPaging 3 with a RemoteMediator, following the sample.Persist the cursor because it is opaque; keep the mediator's two writes in one transaction; refuse to delete unsynced rows on refresh.
WritesOptimistic update, then call the API.Durable before dispatch; idempotency key generated at commit; a bounded outbox drained by WorkManager.
StreamsExpose StateFlow from the ViewModel.State, multicast signal and one-shot event are three different primitives, chosen by question rather than by habit.
ErrorsShow an error screen when a call fails.Classify transient, permanent and cancellation; degrade the UI in proportion to what is cached.
ConflictsNot mentioned until asked.A stated policy, with the reason device clocks disqualify last-write-wins and why set operations converge for free.
TestingThe ViewModel is unit tested.Injected dispatcher and clock make the whole slice a JVM test; the first test written is the data-loss regression.
ScopeBuilds the feature.Names what was not built — tombstones, outbox bounds, undo after sync — before being asked.

Part XXXII rapid recall

  • The UI reads the database and nothing else; the network and the worker are writers.
  • An opaque cursor must be persisted — it cannot be recomputed after process death.
  • RemoteMediator fills the cache; the Room PagingSource is what the UI reads.
  • Store the cursor and the rows in one transaction, or a crash creates an unreachable gap.
  • REFRESH must never delete rows carrying unsynced local intent.
  • Split server truth from local intent into two columns; merge them with COALESCE in SQL.
  • Durable before dispatch: one transaction writes the override and the outbox row.
  • Generate the idempotency key at user commit, persist it, reuse it on every retry.
  • Coalesce the outbox by entity for toggles; append by operation id for distinct facts.
  • cachedIn(viewModelScope) in the ViewModel, never in the repository.
  • StateFlow for state, SharedFlow for multicast signals, Channel for one-shot events.
  • Classify errors three ways: retry, drop and inform, rethrow cancellation.
  • Degrade, never blank — a refresh error with cached rows is a banner, not a takeover.
  • Injected dispatcher and clock are what make the entire slice a JVM test.

Part XXXIII

Greenfield build tasks

Sixty build-from-nothing exercises, graded by difficulty and timed the way an interviewer times them. Each one names the signal it produces and the trap that catches strong candidates — because on a greenfield task the code is rarely what decides the round.

Starter 30–60 min · one concept, correctly Core 1–3 h · a real feature with real failure modes Advanced half day · concurrency, scale or platform depth Staff+ open · architecture, org impact, no single right answer

Chapter 205How greenfield tasks are actually graded

A greenfield task is the most misread format in an Android loop. Candidates treat it as "show me you can build things", which is the one thing your résumé already established. The interviewer is measuring something narrower: what you do with an under-specified problem and a clock.

The scoring, in the order it is applied:

  1. Scoping in the first five minutes. What did you cut, and did you say so out loud? A candidate who builds 40% of the problem deliberately outscores one who builds 70% by accident every time.
  2. The boundary you drew first. Whatever you wrote before the UI — the repository interface, the state model, the error type — is the strongest single signal in the round, because it is the decision that is expensive to reverse.
  3. Failure modes, unprompted. Empty, offline, slow, rotated, process-killed, permission-revoked. Naming them costs a sentence; discovering them under follow-up costs the level.
  4. Whether it runs. Genuinely fourth. A compiling skeleton with honest gaps beats a half-finished monolith that does not build.
  5. What you would do next. The closing sentence. Candidates who end with "that's it" lose points that candidates who end with "given another two hours I'd add X, and here's what worries me" collect for free.
The sentence that changes the round

Before writing anything: "Here's what I'm going to build, here's what I'm deliberately not building, and here's the assumption I'm making that I'd normally verify with product." Say it aloud, and every later shortcut reads as a decision rather than an omission. This single habit moves more candidates from L3 to L4 than any technical knowledge in this book.

The rubric applied to any task in this part

LevelWhat the submission looks like
L2Feature works on the happy path. One layer — logic in the composable or the Activity. Errors are a toast. No tests.
L3Layered, injected, testable. Loading and error states modelled. A few unit tests on the state holder. Sensible library choices.
L4The scoping conversation happened first. Failure modes are designed for, not caught. State model makes illegal states unrepresentable. Tests target the risky logic, not the getters. The README names what was cut and why.
L5Treats the exercise as a template others will copy: a documented boundary, a migration story, a measurable success criterion, and an explicit statement of the conditions under which this design becomes wrong.

Chapter 206Foundations — one concept, done properly

These look trivial and are not. Each has exactly one trap, and the trap is what is being measured.

StarterUnit converter40 min

Two text fields, a unit picker, live bidirectional conversion.

Tests State hoisting and whether you can keep two inputs consistent without an infinite update loop.

Trap Two-way binding where each field updates the other. Model a single source value plus which field is focused; derive the other.

StarterCountdown timer with laps45 min

Start, pause, reset, lap list. Must survive rotation and backgrounding.

Tests Whether time lives in the ViewModel and whether you store an instant or a duration.

Trap Counting ticks. Background the app and the count drifts. Store the start timestamp and derive elapsed on every frame.

StarterNotes list with local persistence60 min

Create, edit, delete notes. Room-backed. Survives process death.

Tests Room basics, reactive queries, and whether the list re-renders without manual refresh.

Trap Re-querying after each write instead of collecting a Flow. Also: deleting without an undo affordance.

StarterSearch-as-you-type over a local list40 min

Filter 5,000 in-memory items as the user types.

Tests debounce, distinctUntilChanged, flatMapLatest, and whether filtering happens off the main thread.

Trap Filtering inside the composable body, so every recomposition re-scans the list.

StarterForm with validation50 min

Email, password, confirm. Inline errors, submit disabled until valid.

Tests State modelling. Whether validation is a pure function you can unit test.

Trap Showing "invalid email" before the user has finished typing. Validate on blur or after first submit, not on every keystroke.

StarterTheme + language switcher45 min

Light/dark/system and two locales, persisted, applied without a restart.

Tests DataStore, AppCompatDelegate / AppLocaleManager, and configuration handling.

Trap SharedPreferences read on the main thread at startup, and hardcoded strings that cannot switch locale.

StarterOnboarding pager with skip40 min

Three pages, dots, skip, shown only on first launch.

Tests "First launch" as persisted state rather than an in-memory flag.

Trap A boolean in a singleton. Kill the process and onboarding returns forever.

StarterExpandable nested list45 min

Categories that expand to items, with animated open/close.

Tests Flattening a tree into a lazy list rather than nesting scrollables.

Trap LazyColumn inside LazyColumn — an unbounded-height crash. Flatten to one list with type markers.

Chapter 207Data, networking and pagination

CorePaginated feed from a public API2 h

Infinite scroll over a REST endpoint, with loading, error and retry.

Tests Paging 3, load-state handling, and whether a database appears at all.

Trap Paginating into an in-memory list, so rotation refetches from page one. See Part XXXII.

CoreMaster–detail with shared cache2 h

List screen and detail screen backed by the same store; detail opens instantly from cache then refreshes.

Tests Single source of truth, and stale-while-revalidate as a deliberate pattern.

Trap Passing the whole object through the navigation argument. Pass an id; read from the store.

CoreToken refresh with a single flight2 h

OkHttp Authenticator that refreshes on 401 — once, no matter how many requests fail together.

Tests Concurrency under a thundering herd, and mutex discipline.

Trap Five parallel refreshes, four of which invalidate the token the fifth just obtained, logging the user out.

CoreTyped error model end to end90 min

Map transport failures to a domain error type the UI can switch on exhaustively.

Tests Whether HttpException and IOException leak into the UI layer.

Trap Result<T> everywhere, including where failure is not a state the screen can render.

CoreMulti-source search2 h

Query three endpoints in parallel, merge, show partial results as they land, never block on the slowest.

Tests coroutineScope vs supervisorScope, and partial-failure UX.

Trap awaitAll, so one failing source produces an empty screen instead of two-thirds of the answer.

AdvancedBidirectional (cursor) pagination3 h

A chat-style list that pages both up and down from an arbitrary anchor message.

Tests RemoteMediator PREPEND handling, remote keys, and scroll-anchor preservation.

Trap Prepending shifts content under the user's thumb. You need a stable anchor and reversed layout, not just a working query.

AdvancedGraphQL-backed screen with normalised cache3 h

Two screens sharing entities; an edit on one updates the other with no refetch.

Tests Cache normalisation as a concept, not a library feature.

Trap Treating the response document as the cache key, so the same entity exists twice with divergent values.

AdvancedResumable chunked upload4 h

Upload a 200 MB file across process death, network loss and app restart.

Tests Durable progress, chunk idempotency, WorkManager foreground service, and honest progress reporting.

Trap Progress held in memory. Kill the app at 90% and the upload starts over — the defect the exercise exists to expose.

Chapter 208Offline-first and synchronisation

CoreRead-only offline cache2 h

A list that renders fully with the network disabled, and refreshes when it returns.

Tests Whether the UI reads the database or the network.

Trap An if (isOnline) branch. Correct designs have no such check anywhere.

AdvancedOffline write queue (outbox)4 h

Toggle favourites offline; converge on reconnect; survive reboot.

Tests Durable-before-dispatch, idempotency keys, error classification. Part XXXII in one sitting.

Trap Refresh deleting rows that still carry unsent local intent.

AdvancedTwo-device convergence4 h

Same account on two devices, both edit offline, both reconnect. Define and implement the conflict policy.

Tests Whether you have a stated policy and know why device clocks disqualify last-write-wins.

Trap Comparing System.currentTimeMillis() across devices. Clocks are user-settable.

AdvancedDelta sync with tombstones4 h

Sync token in, changed and deleted entities out, applied atomically.

Tests Deletion propagation — the half of sync that most designs forget.

Trap No tombstones, so remotely deleted rows live locally forever.

Staff+Offline-capable collaborative editoropen

Two users editing one document offline. Design the convergence model and build a vertical slice.

Tests Whether you can articulate operational transform versus CRDT trade-offs and pick one with reasons.

Trap Reaching for a CRDT library without noting the memory cost of tombstone growth on a mobile device.

Staff+Sync engine as a shared platform moduleopen

Generalise the outbox so eight feature teams use it without reading its source.

Tests API design for other engineers; what you refuse to make configurable.

Trap Every knob exposed. A platform module's value is the decisions it takes away.

Chapter 209Compose and design systems

StarterPixel-accurate card from a mock45 min

Reproduce a supplied design, including dark mode and dynamic type.

Tests Whether you use theme tokens or literals.

Trap Color(0xFF1B4A8F) inline. Invisible until dark mode, then everything is unreadable.

CoreReusable component with a slot API90 min

A card that other teams compose into, with no boolean flags in its signature.

Tests Slot-based API design and the modifier parameter convention.

Trap showIcon: Boolean, showBadge: Boolean, isCompact: Boolean — the flag explosion a slot API exists to prevent.

CoreAdaptive layout for phone, tablet and fold2 h

List-detail that becomes two panes, and survives folding mid-flow.

Tests Window size classes, and state preservation across a layout change.

Trap A raw screenWidthDp > 600 check that misses runtime folding.

CoreComplex list with mixed item types2 h

Headers, ads, cards and footers in one scroller, sticky headers, smooth at 120 Hz.

Tests key, contentType, and keeping derivations out of the item body.

Trap Date formatters and sums computed per item per frame.

AdvancedCustom layout and drawing3 h

A radial menu or flow layout via the Layout composable, plus a Canvas chart.

Tests Measure/place mechanics and the three phases.

Trap Measuring a child twice without SubcomposeLayout, which throws at runtime.

AdvancedGesture-driven swipe-to-dismiss with undo3 h

Velocity-aware swipe, spring-back, snackbar undo, correct on interruption.

Tests Gesture and animation coordination, and undo as a state machine rather than a delay.

Trap Deleting after a 3-second delay. Navigate away and the row is gone with no undo.

AdvancedScreenshot-tested component library4 h

Ten components, each with light, dark, RTL and large-font baselines in CI.

Tests Whether you treat visual regression as infrastructure with a flake budget.

Trap Baselines that differ per machine. Pin the renderer and the font.

Staff+Design system for 40 engineersopen

Token pipeline, component API rules, deprecation policy, adoption metric.

Tests Governance, not components. How a token change reaches 200 screens safely.

Trap Building components and calling it a system. The system is the rules and the migration path.

Chapter 210Concurrency, streams and real time

CoreLive location tracker2 h

Stream updates into a map, stop cleanly when the screen stops, resume on return.

Tests callbackFlow, awaitClose, repeatOnLifecycle, conflation.

Trap The callback outliving the collector — a leak plus battery drain nobody notices for weeks.

AdvancedWebSocket chat with reconnect4 h

Live messages, exponential backoff with jitter, gap-fill via REST after a reconnect, ordered delivery.

Tests Transport as a Flow, and the realisation that reconnect leaves a hole that must be backfilled.

Trap Reconnecting and resuming the stream without fetching what was missed while disconnected.

AdvancedJob scheduler with priorities and cancellation3 h

Bounded parallelism, priority queue, per-job cancellation, cooperative shutdown.

Tests Structured concurrency under pressure; Semaphore and Channel discipline.

Trap Cancelling a job cancels the scope and takes down its siblings.

AdvancedReactive shopping cart3 h

Cart, live pricing, stock checks, promo codes — all derived, all consistent, no duplicated state.

Tests combine, derived state, and avoiding read-modify-write on a StateFlow.

Trap Storing the total. It drifts from the lines within a day. Derive it.

AdvancedRate-limited API client2 h

Ten requests per second across the whole app, cancellable, fair, no busy-waiting.

Tests Token bucket, suspension rather than blocking, releasing the lock before delaying.

Trap delay while holding the mutex, serialising every caller behind one waiter.

Staff+Event pipeline with backpressureopen

Analytics events from many producers, batched, persisted, retried, bounded memory under a burst.

Tests An explicit overflow policy — the question most candidates never ask.

Trap An unbounded buffer. It is an OOM with a delay fuse.

Chapter 211Media, camera and device capability

CoreImage picker with crop and upload2 h

Pick, crop, downscale, upload with progress.

Tests Photo Picker over storage permission, and scaled decoding.

Trap Requesting READ_EXTERNAL_STORAGE when the Photo Picker needs no permission at all.

CoreAudio recorder with waveform3 h

Record, live amplitude waveform, pause, playback, survive interruption by a call.

Tests Audio focus, foreground service, lifecycle of a hardware resource.

Trap Not releasing the recorder on interruption, so the mic stays held and the next start fails.

AdvancedCamera with real-time analysis4 h

CameraX preview plus a barcode analyser at 30 fps without dropping frames.

Tests Backpressure strategy on the analyser and closing every ImageProxy.

Trap Forgetting imageProxy.close() — the pipeline stalls after exactly three frames.

AdvancedOffline video player with caching4 h

Media3 playback, partial-download cache, background audio, notification controls.

Tests Media session, cache eviction policy, and playback across process death.

Trap An unbounded disk cache that quietly consumes the device.

AdvancedOn-device ML inference3 h

Classify camera frames locally, keeping the main thread free and the battery intact.

Tests Threading around a native interpreter, and throttling inference to a useful rate.

Trap Inferring on every frame. Ten per second is plenty and a third of the power.

Chapter 212Platform, background and integration

CoreDeep links and app links2 h

Route external URLs to the right screen with a correct back stack, from cold start.

Tests A single testable resolver, and synthesised parent destinations.

Trap Treating the URL as trusted. Validate the host; parse ids into typed values.

CorePush notifications with actions2 h

FCM receipt, channels, inline reply, correct deep link, Android 13 permission flow.

Tests PendingIntent flags and asking for POST_NOTIFICATIONS in context.

Trap FLAG_MUTABLE with an implicit intent — an intent-redirection vulnerability.

CoreHome-screen widget2 h

Glance widget showing live data, refreshed on a budget, tappable into the app.

Tests Update cadence versus battery, and reading the same store as the app.

Trap A 15-minute periodic refresh nobody asked for.

AdvancedBiometric-gated secure storage3 h

Encrypt a token with a Keystore key that requires user authentication.

Tests CryptoObject, key invalidation on biometric enrolment, and a real fallback.

Trap Biometrics flipping a boolean. Patch the branch and the gate is gone.

AdvancedWear OS companion4 h

A watch surface sharing data with the phone, functioning while the phone is away.

Tests Data layer sync and designing for an intermittent peer.

Trap Assuming the phone is reachable. It frequently is not.

AdvancedAccessibility-complete checkout3 h

A three-step flow fully operable by TalkBack and switch access, at 200% font.

Tests Semantics, focus order, live regions, and touch targets.

Trap Adding contentDescription everywhere, including decorative images, so TalkBack becomes unusable noise.

Staff+In-app purchase with server verificationopen

Play Billing, entitlement as server truth, restore, refunds, offline grace.

Tests Treating the client as untrusted, and designing for the refund case.

Trap Granting entitlement on the client's purchase callback. Trivially spoofable.

Chapter 213Architecture and platform engineering

AdvancedModularise a single-module app4 h

Split a 60-screen monolith into a layered graph with enforced boundaries.

Tests Dependency direction and mechanical enforcement, not just folders.

Trap A :common module everything depends on. You renamed the monolith.

AdvancedFeature-flag and experiment framework4 h

Remote flags, local overrides, typed access, consistent within a session, testable.

Tests Flag lifecycle — the removal plan is the interesting half.

Trap Flags that re-evaluate mid-session, so the UI changes under the user.

AdvancedType-safe navigation for 40 screens3 h

Compile-time-checked routes, deep-link parity, no feature module importing another.

Tests Decoupling navigation from feature modules.

Trap String routes assembled by hand — a runtime crash per typo.

Staff+Analytics and observability layeropen

A typed event schema, sampling, PII policy, offline buffering, and mobile SLIs that alert.

Tests Whether you can name what you would alert on and what you would deliberately not measure.

Trap Logging everything. Cost and PII exposure both scale with volume.

Staff+KMP evaluation and a vertical sliceopen

Share one real domain across Android and iOS; report honestly on the cost.

Tests Organisational reasoning — build times, hiring, debugging across the boundary.

Trap Selling the code-sharing percentage. Interviewers want the tax, not the brochure.

Staff+Cut CI from 45 minutes to 10open

Profile the graph, cache, parallelise, shard tests, define what runs per-PR versus nightly.

Tests Measuring before optimising, and trading confidence against latency explicitly.

Trap Deleting tests to hit the number.

Staff+Greenfield app for a 30-engineer orgopen

Day-one architecture: modules, DI, navigation, state, testing, release, and the written rules.

Tests Which decisions are one-way doors, and what you defer on purpose.

Trap Deciding everything up front. The Staff answer names what it is deliberately leaving open.

Chapter 214Take-home projects

Full-weekend scope. The README is graded as heavily as the code — often more, because it is the only place your reasoning is visible.

AdvancedPodcast client8–12 h

Search, subscribe, download for offline, background playback, resume position across devices.

Tests Nearly everything: paging, downloads, media session, sync, notifications.

Trap Attempting all of it. Ship three features properly and document the rest as scoped-out.

AdvancedExpense tracker with receipts8 h

Camera capture, offline entry, categories, monthly aggregation, CSV export.

Tests Money handling, aggregation queries, and file sharing via FileProvider.

Trap Currency in Double. Use minor units as Long, and say why in the README.

AdvancedFitness tracker10 h

Foreground-service GPS session, live stats, route map, history, battery-conscious.

Tests Long-running background work and location accuracy versus power.

Trap Maximum-accuracy GPS at 1 Hz for an hour. Adaptive intervals, and measure the drain.

AdvancedMarketplace app10 h

Browse, filter, cart, checkout, orders — with optimistic cart edits and typed errors.

Tests A realistic multi-screen state model and idempotent checkout.

Trap A retried order placement without an idempotency key — a double charge.

Staff+Messaging app12 h+

Offline send queue, delivery states, bidirectional paging, presence, reconnection gap-fill.

Tests The hardest common mobile problem. Ordering, idempotency and convergence in one.

Trap Ordering by client timestamp. Two devices, two clocks, a scrambled conversation.

Chapter 215Scoping any greenfield task in five minutes

The same six questions work on every task above. Ask them aloud; the answers are the design.

QuestionWhat the answer decides
Read-only, or does the user write?Writes bring durability, optimism, idempotency and conflicts. This one answer can triple the scope.
Must it work offline?Yes means the database is the source of truth and the whole architecture inverts.
How much data, and how does it grow?Decides pagination, indexing and whether the list can be held in memory at all.
Who else changes this data?Another device, a worker or a push handler forces reactive reads rather than one-shot loads.
What must never be lost?Anything the user believes is saved must reach disk before the network is attempted.
What is the deadline?Determines what you cut — and cutting explicitly is the behaviour being scored.

Part XXXIII rapid recall

  • Say what you are not building before you build anything.
  • The boundary you write first is the strongest signal in the round.
  • Name failure modes unprompted: empty, offline, slow, rotated, killed, revoked.
  • A deliberate 40% beats an accidental 70%.
  • Compiling with honest gaps beats broken with ambition.
  • Writes plus offline is the combination that triples scope — price it out loud.
  • Money in minor units as Long; ordering by server key, never a device clock.
  • Unbounded anything — buffer, cache, outbox, retry — is a bug with a delay fuse.
  • On a take-home the README carries the reasoning; it is graded as heavily as the code.
  • Close with what you would do next and what worries you.

Part XXXIV

Rapid recall

Every term you must be able to define in one breath, with the shortest example that makes it true. Built for the morning of the interview and for the five minutes before a call — definitions first, mechanism second, nothing you cannot say out loud in fifteen seconds.

Chapter 216How to drill this part

Reading definitions is close to worthless; retrieving them is where the learning is. The protocol that works:

  1. Cover the right column. Read the term, say your definition aloud, then uncover. The friction is the point.
  2. Grade yourself in two buckets, not three. Either you produced a mechanism, or you produced vocabulary. "It's lifecycle-aware" is vocabulary.
  3. Chase every miss one level deeper. If you could not define SupervisorJob, you also cannot answer "what happens when a child fails?" — which is the question that will actually be asked.
  4. Re-drill misses only. Three passes over what you missed beats one pass over everything.
What this part is not

These are the opening answers — the thirty seconds that buy you the right to keep talking. A Staff loop is decided by the follow-ups: mechanism, failure mode, alternative, and the condition that flips the choice. Use this part to make the recall automatic so your attention is free for the part that scores.

Chapter 217Coroutines — the core vocabulary

Coroutine

An instance of a suspendable computation. Not a thread: it is an object the runtime can pause and resume, so tens of thousands can share a handful of threads. Cheap to create — the cost is an allocation, not a stack.

suspend function

A function that can pause without blocking its thread, and can only be called from another suspending function or a coroutine builder. The compiler rewrites it into a state machine that takes a hidden Continuation parameter.

suspend fun user(id: String): User = api.fetch(id)   // no thread held while waiting
Continuation

"The rest of the function", captured as an object. Suspension means storing the continuation and returning the thread; resumption means calling it back with a result.

CoroutineContext

An indexed set of elements carried by every coroutine — Job, dispatcher, name, exception handler. Combined with +; children inherit it and may override individual elements.

Dispatchers.IO + SupervisorJob() + CoroutineName("sync")
CoroutineScope

An object holding a context whose Job defines a lifetime. Everything launched in it becomes a child of that job, so cancelling the scope cancels all of it. A scope is a lifetime, not a thread pool.

Job

The handle and lifecycle of a coroutine: New → Active → Completing → Completed, or Cancelling → Cancelled. Jobs form a parent–child tree, which is what makes cancellation and error propagation automatic.

Structured concurrency

Every coroutine has a parent; a scope does not complete until all its children complete; and cancellation and failures propagate along that tree. The consequence is the point: no coroutine can outlive the scope that started it, so work cannot leak.

Unsupervised scope

The default — a plain Job, as in coroutineScope { }. One child failing cancels the parent, which cancels every sibling. Correct when the children are parts of one indivisible result.

coroutineScope {
    val a = async { first() }
    val b = async { second() }   // if first() throws, this is cancelled
    a.await() + b.await()
}
Supervised scope

SupervisorJob or supervisorScope { }. A child's failure stops at the supervisor: siblings and the parent survive. Cancellation still flows downward normally — supervision changes upward failure propagation only.

supervisorScope {
    launch { risky() }      // may fail
    launch { other() }      // still runs
}
Cancellation

Cooperative. Cancelling moves the job to Cancelling and makes every suspension point throw CancellationException. Code that neither suspends nor checks isActive keeps running to completion regardless.

while (isActive) { crunch() }        // cooperative
while (true) { crunch() }            // uncancellable
CancellationException

The normal, expected signal of cancellation — the parent ignores it rather than treating it as a failure. Catching it without rethrowing silently breaks structured concurrency, which is why catch (e: Exception) and runCatching are hazards in coroutine code.

ensureActive() / yield()

Cooperation points for CPU-bound loops that never suspend. ensureActive() throws if cancelled; yield() also gives other coroutines a turn on the dispatcher.

NonCancellable

The context that lets cleanup suspend after cancellation. Without it, a suspending call inside finally throws immediately and the cleanup never happens.

try { work() } finally {
    withContext(NonCancellable) { db.close() }
}
Dispatcher

Decides which thread a coroutine resumes on. Main — UI. Default — CPU work, pool sized to cores. IO — blocking calls, up to 64 threads by default. Unconfined — resumes wherever it was resumed; for tests and internals, not app code.

withContext

Runs a block in a different context and returns its result, suspending the caller. The tool for main-safety, and it belongs to the function that knows it blocks — never to its call sites.

suspend fun read() = withContext(io) { file.readText() }
launch vs async

launch returns a Job for fire-and-forget work; an exception propagates to the parent immediately. async returns a Deferred<T> for a value; the exception is held until await(). An async whose result is never awaited swallows its failure.

coroutineScope vs supervisorScope

Both are suspending builders that wait for all children. They differ in exactly one way: whether one child's failure cancels the others. Neither creates a new thread.

CoroutineExceptionHandler

A last-resort handler for uncaught exceptions from launch at the root of a scope. It does nothing for async, whose exception belongs to the Deferred, and nothing for a non-root coroutine, which delegates to its parent.

runBlocking

Bridges blocking and suspending worlds by blocking the current thread until the block completes. Legitimate in main and in tests; in app code it is how you write an ANR.

Mutex / Semaphore

Suspending mutual exclusion and bounded permits. They suspend rather than block, and are not reentrant — taking the same mutex twice in one call chain deadlocks.

mutex.withLock { state = state + 1 }
viewModelScope / lifecycleScope

Prebuilt scopes cancelled at onCleared and onDestroy respectively. In a Fragment, lifecycleScope outlives the view — anything touching the view belongs to viewLifecycleOwner.lifecycleScope.

Chapter 218Flow and reactive streams

Flow

A cold asynchronous stream. Nothing executes until a terminal operator collects it, and each collector gets its own independent execution of the whole chain.

flow { emit(1); emit(2) }.collect { println(it) }
Cold vs hot

Cold produces per collector and starts on collection (Flow). Hot exists independently of collectors and is shared (SharedFlow, StateFlow, Channel). Five collectors on a cold network flow means five requests.

StateFlow

A hot flow with exactly one current value, conflated and distinct-until-changed. The state holder's primitive: it always has a value to render, and it never re-emits an equal one.

private val _s = MutableStateFlow(UiState())
val s: StateFlow<UiState> = _s.asStateFlow()
SharedFlow

A hot multicast flow with configurable replay and buffer, and no notion of a current value. For signals several unrelated observers care about.

Channel

A hot queue where each element is delivered to exactly one consumer. The right primitive for one-shot events — navigation, snackbars — because nothing is replayed and nothing is dropped while the screen is stopped.

_.update { }

Atomic compare-and-set on a MutableStateFlow. Required because value = value.copy(...) is a read-modify-write that loses updates under concurrency.

_state.update { it.copy(items = it.items + x) }
stateIn / shareIn

Convert a cold flow into a hot one shared by all collectors, within a scope. stateIn also keeps a current value.

.stateIn(viewModelScope, WhileSubscribed(5_000), Loading)
SharingStarted

The sharing policy. Eagerly — starts now, never stops. Lazily — starts on first collector, never stops. WhileSubscribed(5_000) — the Android default, because five seconds outlives a rotation and stops when the screen is genuinely gone.

flowOn

Changes the dispatcher of everything upstream of it, and nothing downstream. Context preservation means a flow can never change the collector's context from inside emit.

map vs transform

map emits exactly one value per input; transform may emit zero or many. filter, take, and friends are all transform underneath.

flatMapLatest

Cancels the previous inner flow when a new value arrives. The correct operator for search-as-you-type, where an in-flight query for a stale term is waste.

query.debounce(300).flatMapLatest { repo.search(it) }
flatMapMerge / flatMapConcat

Merge runs inner flows concurrently and interleaves results; Concat runs them strictly in sequence. Merge is faster and unordered; concat preserves order and serialises.

combine vs zip

combine emits on any source emitting, using each other's latest — for derived state. zip pairs elements strictly by index and waits for both — for correlated streams.

buffer / conflate / collectLatest

Three answers to a slow collector: buffer keeps everything and runs producer and consumer concurrently; conflate drops intermediate values; collectLatest cancels the in-progress collector body when a newer value arrives.

Backpressure

What happens when a producer outpaces its consumer. Flow handles it by suspending the producer — which is why an unbounded buffer, added to "fix" slowness, converts a stall into an out-of-memory error.

callbackFlow / awaitClose

Wraps a callback API as a flow. awaitClose is mandatory — it keeps the flow alive and is where the listener is unregistered when the collector goes away.

callbackFlow {
    val l = Listener { trySend(it) }
    api.register(l)
    awaitClose { api.unregister(l) }
}
catch and retry

catch handles exceptions from upstream only — it cannot catch a failure in the collector's own lambda. retryWhen resubscribes; use it with a predicate and jitter, never unconditionally.

Turbine

The test library for flows: awaitItem(), awaitComplete(), and an assertion that nothing more was emitted. Its real value is failing loudly on unconsumed emissions.

Chapter 219Kotlin language

data class

Generates equals, hashCode, toString, copy and componentN from the primary constructor properties only. Adding a property is a binary-incompatible change to copy.

sealed class / sealed interface

A closed hierarchy known at compile time, so when can be exhaustive without an else. Prefer sealed interface: it permits multiple implementation and avoids the constructor.

object / companion object

A singleton declaration, and the per-class singleton that holds what other languages call statics. A companion object is a real object, so its members are not free — const val and top-level functions are.

value class

A single-property wrapper erased at runtime, giving type safety with no allocation. Boxes when used as a generic argument, as a nullable, or through an interface.

@JvmInline value class UserId(val raw: String)
inline / reified

inline copies a function's body to the call site, removing the lambda allocation — worth it for small higher-order functions, harmful for large ones. reified is only possible because of inlining: it keeps the type argument at runtime.

inline fun <reified T> Bundle.get(k: String): T? = get(k) as? T
crossinline / noinline

crossinline forbids a non-local return from an inlined lambda; noinline exempts a parameter from inlining so it can be stored or passed on.

Variance — out / in

out T — covariant, a producer, T appears only in return position. in T — contravariant, a consumer, T only in parameters. The mnemonic is producer-extends, consumer-super.

Star projection <*>

"Some specific type, unknown here." You may read values as the upper bound and may not write any.

Delegation — by

Forwards interface implementation to another object, or a property's get/set to a delegate. Composition without boilerplate.

class Repo(db: Dao) : Dao by db
val heavy by lazy { build() }
lazy vs lateinit

lazyval, computed once on first access, thread-safe by default. lateinitvar, non-null, assigned later, throws if read first. Neither works for primitives via lateinit.

Scope functions

let — transform, receiver as it. run — transform, receiver as this. apply — configure, returns receiver. also — side effect, returns receiver. with — non-extension run.

Extension function

Syntactic sugar for a static function taking the receiver as its first parameter. Resolved statically, so it never participates in polymorphism and never overrides a member.

Sequence vs List

List operators are eager and allocate an intermediate collection per step. Sequence is lazy and element-by-element. Sequences win on long chains over large data and lose on short chains over small data.

Nullability

Encoded in the type system: T and T? are different types. ?: supplies a fallback, ?. short-circuits, and !! is a deliberate crash you should be able to justify.

Platform type T!

A type from Java whose nullability is unknown, so the compiler cannot check it. This is why annotating Java interop boundaries matters — an unannotated getter is a silent NPE.

typealias

A name for an existing type. No new type, no safety — for readability only. Use a value class when you actually want distinctness.

internal

Visible within the compilation module. The mechanism that makes a Gradle module's public surface intentional rather than accidental.

@JvmStatic, @JvmOverloads, @JvmName

Interop controls: expose a companion member as a real static, generate overloads for default parameters, and rename for Java callers.

Contracts and smart casts

The compiler narrows a type after a check, but only when it can prove the value cannot change — so a mutable property from another module never smart-casts. A local val copy is the fix.

Chapter 220Jetpack Compose

Composable function

A function that describes UI by emitting into a composition rather than returning a view. The compiler plugin gives it a Composer parameter and positional memory.

Recomposition

Re-running composables whose inputs changed. It is optimistic, may be cancelled and restarted, can run out of order, and may execute in parallel — so a composable must be side-effect free and idempotent.

Slot table

The gap buffer holding the composition's structure and every remembered value, keyed by call-site position. It is why remember works and why an unkeyed list reuses the wrong state.

Snapshot state

MutableState read inside a composable registers that composable as a reader; writing it invalidates exactly those readers. Automatic, minimal-scope observation.

remember vs rememberSaveable

remember survives recomposition and dies with the composition. rememberSaveable also survives configuration change and process death via saved instance state — and therefore only holds what a Bundle can carry.

State hoisting

Move state to the lowest common ancestor of its readers and pass value plus callback down. Produces stateless, previewable, testable composables.

@Composable fun Counter(n: Int, onInc: () -> Unit)
The three phases

Composition — what to show. Layout — measure and place. Drawing — render. Reading state later rather than earlier skips phases: Modifier.offset { } costs layout only, while a recomposing offset costs all three.

derivedStateOf

For state derived from other state where the derived value changes far less often than its inputs — the canonical case being scrollPosition > 0, which changes twice across hundreds of scroll emissions.

LaunchedEffect

Runs a coroutine tied to the composition, restarted when its keys change and cancelled on leaving. key = Unit means once per entry into composition.

DisposableEffect

For non-suspending resources needing symmetric cleanup — registering and unregistering a listener. The onDispose block is mandatory.

SideEffect

Runs after every successful composition. For publishing state to non-Compose code that must not see a composition that was discarded.

rememberUpdatedState

Keeps a long-lived effect referencing the latest lambda without restarting it — the fix for a timer whose callback is stale but which must not be cancelled.

produceState

Converts non-Compose async sources into State, with a coroutine scoped to the composition.

Stability

A type is stable if equals is consistent, public properties do not change without notifying, and all its properties are stable. Unstable parameters make a composable non-skippable, so it recomposes even when nothing changed.

Skippable / restartable

Restartable — can be recomposed on its own. Skippable — can be skipped when all parameters compare equal. Strong skipping mode relaxes this by comparing unstable parameters by instance.

CompositionLocal

Implicit data passed down the tree. Right for genuinely ambient values — theme, density; wrong for domain data, because it makes a composable's dependencies invisible at the call site.

Modifier order

Modifiers apply left to right, so padding().background() and background().padding() render differently. Always accept a modifier parameter, defaulted, applied to the outermost element.

key in lazy lists

Ties remembered state to identity rather than index. Without it, inserting at the head shifts every item's state and defeats reuse.

SubcomposeLayout

Composes children during layout, so a child's content can depend on measurements. Powerful and expensive — it is why BoxWithConstraints should not be a default.

Semantics

The parallel tree describing meaning rather than pixels. It serves accessibility services and UI tests from one source, which is why an untestable screen is usually an inaccessible one.

Chapter 221Platform, lifecycle and process

Process death

The system reclaims a backgrounded app's process. The task record survives, so the user returns to what looks like their screen with every in-memory value gone. Reproduce with adb shell am kill — rotation does not test this.

Configuration change

Rotation, locale, dark mode, window resize. Recreates the Activity but not the process, which is why a ViewModel survives it and a saved-state bundle survives more.

SavedStateHandle

The ViewModel's bundle-backed store: survives process death, is size-limited, and is the correct home for navigation arguments and small user input — not for cached lists.

viewLifecycleOwner

A Fragment has two lifetimes — the fragment and its view — separated by a back-stack traversal. Anything touching the view must use the view's owner or it leaks into a destroyed hierarchy.

repeatOnLifecycle

Runs a block when the lifecycle reaches a state and cancels it when it drops below — restarting on return. Unlike flowWithLifecycle on a hot flow, it stops the upstream too.

Binder

Android's IPC mechanism, backed by a fixed ~1 MB per-process transaction buffer shared by all in-flight calls. Exceeding it throws TransactionTooLargeException — the usual cause of a crash when saving a large bundle.

Looper, Handler, MessageQueue

The main thread is a loop draining a message queue. Every frame, touch and lifecycle callback is a message — which is why a blocking call on it stalls all of them at once.

Choreographer

Schedules input, animation and draw against the display's vsync. Missing the frame budget — 16.6 ms at 60 Hz, 8.3 ms at 120 Hz — is jank by definition.

ANR

Input unhandled for 5 s, a broadcast for 10–20 s, or a service start for 20 s. Usually main-thread I/O, a lock held by a blocked thread, or a synchronous Binder call to a busy process.

Launch modes

standard, singleTop, singleTask, singleInstance — plus intent flags that can override them. They decide task and back-stack behaviour, and are the usual cause of "back goes somewhere strange".

PendingIntent

A token letting another process execute an intent with your identity. Must be FLAG_IMMUTABLE unless mutation is genuinely required; a mutable one wrapping an implicit intent is an intent-redirection vulnerability.

Doze and App Standby

Battery restrictions that batch or defer background work and network access when the device or app is idle. The reason "it works on my device" and "it works for a user overnight" are different claims.

Foreground service

For work the user is actively aware of, with a mandatory notification and a declared type. Since Android 12 it cannot generally be started from the background — WorkManager's expedited work is the supported path.

Runtime permissions

Revocable at any time, including while your app is backgrounded. Every permission-gated call therefore needs a not-granted-right-now branch, not just a request flow.

Scoped storage

Apps get their own directory and access others' media through MediaStore or the picker. The Photo Picker requires no permission at all, which makes a storage-permission request a design smell.

Chapter 222Jetpack, architecture and data

ViewModel

A state holder scoped to a ViewModelStoreOwner, surviving configuration change but not process death. It must never hold a reference to a View, Activity or Context that is not the application.

UDF — unidirectional data flow

State flows down, events flow up. One writer per piece of state, which is what makes a bug traceable to a single place.

MVVM vs MVI

Both are UDF. MVI adds an explicit intent type and a single reducer, buying traceability and replay at the cost of ceremony. The honest answer is that most "MVVM" with one immutable state object already is MVI.

Repository

Owns where data comes from and which source is authoritative. It should not know about screens, and it should return domain models rather than DTOs or Room entities.

Single source of truth

One place the UI reads; everything else writes into it. The property that makes offline behaviour fall out of the architecture rather than being coded as a special case.

Room

A compile-time-verified SQLite layer. Observable queries invalidate per table, not per row — so an unbounded query over a hot table re-runs on every write.

Room migration

Every shipped schema version is permanent public API because users skip releases. Export the schema, commit it, test adjacent pairs; fallbackToDestructiveMigration is a scheduled data-loss incident.

DataStore

The asynchronous, transactional replacement for SharedPreferences, exposing a Flow. Preferences DataStore is untyped; Proto DataStore is schema-backed.

WorkManager

The only Android mechanism guaranteeing deferrable work runs eventually, across process death and reboot. For "must happen eventually", never "must happen now and visibly".

Unique work

enqueueUnique* with KEEP, REPLACE or APPEND makes scheduling idempotent. Without it, every app launch enqueues another copy and they multiply.

Paging 3

PagingSource supplies pages; RemoteMediator writes network pages into the database when the cache runs low; cachedIn keeps the stream alive across configuration change.

Hilt

Dagger with Android's component hierarchy predefined. Its real payoff is that clock, dispatcher, network and database arrive through constructors, so the slice runs as a JVM test.

@Binds vs @Provides

@Binds for "this implementation satisfies that interface" — abstract, no generated factory. @Provides when construction requires code.

Idempotency key

A client-generated id, created at user commit and persisted before the request, that lets a server deduplicate retries. Without it, a retry after a post-send timeout charges twice.

Outbox pattern

Persist the user's intent in the same transaction as the optimistic UI change, then let a worker drain it. The mechanism behind offline writes surviving process death.

Optimistic update

Show the result before the server confirms. Correct only when you can both persist the intent and reverse it coherently on rejection.

Chapter 223Testing, build and performance

Fake vs mock vs stub

A fake is a working lightweight implementation; a stub returns canned values; a mock asserts on interactions. Prefer fakes: mocks encode implementation detail and break on every refactor.

runTest

Runs a coroutine test with virtual time, so delay(10_000) completes instantly and deterministically.

StandardTestDispatcher vs Unconfined

Standard queues coroutines until you advance time, exposing ordering bugs. Unconfined runs eagerly, which is convenient and hides them.

Test pyramid, mobile shape

Many JVM unit tests, fewer Robolectric or component tests, few instrumented end-to-end tests. Driven by cost and flake rate, not ideology.

Flake budget

The declared rate above which a test is quarantined rather than retried. A suite without one degrades into a suite nobody trusts.

Baseline Profile

A shipped list of hot methods AOT-compiled at install, typically cutting cold start and first-scroll jank by double-digit percentages. The cheapest large startup win available.

R8

Shrinking, optimisation and obfuscation in one step. Reflection and serialisation need keep rules, and a missing one fails only in release.

Configuration cache

Gradle caches the configured task graph, so subsequent builds skip configuration entirely. Requires tasks not to reference Project at execution time.

Cold, warm, hot start

Cold — new process. Warm — process alive, Activity recreated. Hot — Activity resumed. Measure with Time to Initial Display and Time to Full Display, and report percentiles rather than means.

Jank

A frame that misses its deadline. Diagnose with a system trace rather than recomposition counts: the usual causes are main-thread work, unbounded queries and unscaled bitmap decodes.

Memory leak topology

Almost all Android leaks are one shape: a long-lived object holding a short-lived one — a static holding a Context, a listener never unregistered, or an inner class capturing its outer.

Strict mode

Development-time detection of main-thread disk and network access and of leaked closables. Enable in debug builds and fix what it finds rather than suppressing it.

Chapter 224The sixty-second self-test

If you cannot answer these out loud without hesitating, that topic is your next study block. Each maps to a definition above.

PromptThe one-line answer
What makes a coroutine cheaper than a thread?It is an object, not a stack — suspension stores a continuation and releases the thread.
What does structured concurrency guarantee?No coroutine outlives its scope; failure and cancellation propagate along the parent–child tree.
Supervised versus unsupervised, in one sentence?Supervision stops a child's failure from reaching siblings; downward cancellation is unchanged.
Why can a coroutine ignore cancellation?Cancellation is cooperative — code that never suspends and never checks isActive keeps running.
Why is catching Exception in a coroutine dangerous?It swallows CancellationException and breaks structured concurrency.
When does a cold flow become expensive?With several collectors — each gets its own execution, so five collectors means five network calls.
Why WhileSubscribed(5_000)?Longer than a configuration change, shorter than the user's attention.
State, or event?True after rotation → state. Must happen exactly once → event on a channel.
Why does _state.value = _state.value.copy() lose updates?It is a read-modify-write; update {} retries on conflict.
What survives process death?Disk, and SavedStateHandle. Not a ViewModel, not a singleton's fields.
Which lifecycle owner for a Fragment's view?viewLifecycleOwner — the fragment outlives its view by a back-stack traversal.
Why does an unkeyed LazyColumn misbehave?Remembered state is bound to index, so a head insert shifts every item's state.
What makes a composable skippable?All parameters stable and comparing equal.
When is derivedStateOf the right tool?When the derived value changes far less often than the state it is derived from.
Why persist an opaque pagination cursor?It cannot be recomputed from the data, so paging cannot resume after process death without it.
Where must an idempotency key be generated?At user commit, persisted before the request, reused on every retry.
Which errors are safe to retry?Connection failures freely; post-send timeouts only behind an idempotency key; never a 4xx.
Why not last-write-wins on device time?Device clocks are user-settable, so it is data loss wearing a strategy's name.
What does Room invalidate on a write?Every observing query on that table — hence bounded, indexed queries.
Why FLAG_IMMUTABLE?A mutable PendingIntent wrapping an implicit intent lets another app act as you.
What does a foreground service require since Android 12?A declared type, a notification, and a foreground start — background starts are blocked.
Cheapest large startup win?A Baseline Profile.
Why prefer fakes over mocks?Mocks assert on interactions, so they break on refactors that change nothing observable.
What does flowOn affect?Everything upstream of it, and nothing downstream.

Part XXXIV rapid recall

  • A coroutine is an object, not a thread; a scope is a lifetime, not a pool.
  • Structured concurrency: no work outlives its scope, and errors travel the job tree.
  • Supervision changes upward failure propagation only — cancellation still flows down.
  • Cancellation is cooperative; never swallow CancellationException.
  • Cold flows run per collector; hot flows exist without one.
  • StateFlow state, SharedFlow multicast signal, Channel one-shot event.
  • flowOn is upstream-only; withContext belongs to the function that blocks.
  • Recomposition is optimistic, reorderable and cancellable — so composables must be pure.
  • remember survives recomposition; rememberSaveable survives process death.
  • Only disk and SavedStateHandle survive a process kill.
  • Room invalidates per table; keep observed queries bounded and indexed.
  • Persist the cursor, generate the idempotency key at commit, classify errors three ways.

Part XXXV

Best practices by area

Fifty-odd side-by-side pairs organised by the thing you are actually working on — Kotlin, coroutines, Flow, Compose, Fragments, ViewModels, DI, Room, networking, background work, testing and build. Part XXXI catalogued platform defects; this part is the working reference for the code you write today.

The rule behind the rules

Almost every pair below reduces to one of four questions. What lifetime does this belong to? What happens when it fails? Who else can change this at the same time? How would I test it without a device? If you can ask those four about any snippet in review, you will independently rediscover most of this part — which is the point, because the API surface changes every year and those questions do not.

Chapter 225Kotlin — types and API design

Anti-pattern — primitive obsession
fun transfer(from: String, to: String, amount: Double)

transfer(to, from, 10.0)     // compiles. wrong direction, wrong money type.

Every argument is a String, so the type system cannot catch a swap — and this shape of bug reaches production because it is invisible in review. Double for money compounds it: 0.1 + 0.2 is not 0.3, and rounding errors accumulate into reconciliation tickets.

Practice — make the wrong call not compile
@JvmInline value class AccountId(val raw: String)
@JvmInline value class Minor(val cents: Long)   // never Double for money

fun transfer(from: AccountId, to: AccountId, amount: Minor)

value class costs nothing at runtime — it erases to the underlying type — and converts a class of runtime bug into a compile error. The argument to make in review: this is not ceremony, it is the cheapest possible test.

Anti-pattern — illegal states are representable
data class UiState(
    val isLoading: Boolean = false,
    val data: List<Item>? = null,
    val error: String? = null,
)
// loading AND error AND data — what does the screen render?

Three independent fields encode eight states, of which perhaps three are meaningful. Every consumer then invents its own precedence rules, and two screens reading the same state render differently.

Practice — a closed set of real states
sealed interface UiState {
    data object Loading : UiState
    data class Ready(val items: List<Item>, val refreshing: Boolean) : UiState
    data class Failed(val reason: ErrorReason) : UiState
}

when (state) {          // exhaustive; the compiler enforces completeness
    Loading -> Skeleton()
    is Ready -> List(state.items)
    is Failed -> Error(state.reason)
}

Note refreshing lives inside Ready — a refresh with content on screen is a different state from a cold load, and modelling it there is what lets the UI degrade instead of blanking.

Anti-pattern — a sealed class as a namespace
sealed class Result<T> {
    class Success<T>(val data: T) : Result<T>()
    class Error<T>(val e: Throwable) : Result<T>()
}
// Error<User> and Error<Order> are different types for no reason

The type parameter is meaningless on the failure branch, so every mapping function needs a pointless cast, and variance is wrong: Result<Dog> is not a Result<Animal>.

Practice — variance and a non-generic failure
sealed interface Outcome<out T> {
    data class Ok<T>(val value: T) : Outcome<T>
    data class Err(val reason: ErrorReason) : Outcome<Nothing>
}

out T makes Outcome<Dog> usable as Outcome<Animal>, and Nothing lets one Err instance satisfy every call site. This is the standard shape — recognising it is a Kotlin-depth signal.

Anti-pattern — data class in a published API
data class Config(val host: String, val port: Int)
// v2 adds a field: every copy() call site in every module breaks

copy and componentN are generated from the constructor, so adding a parameter — even with a default — is a binary-incompatible change. For a library or a shared module this forces a lockstep upgrade across the whole org.

Practice — a builder or a regular class at the boundary
class Config private constructor(val host: String, val port: Int) {
    class Builder {
        var host: String = "localhost"; var port: Int = 443
        fun build() = Config(host, port)
    }
}

Use data class freely inside a module; think twice at a boundary other teams compile against. The distinction is binary compatibility, not style.

Chapter 226Kotlin — collections and performance

Anti-pattern — an eager chain over a large collection
val names = users                 // 100k users
    .filter { it.active }         // allocates a 60k list
    .map { it.name }              // allocates a 60k list
    .take(20)                     // …to use twenty

Each operator materialises a full intermediate list, so this allocates roughly 120,000 objects to produce twenty. On a scroll frame that is a guaranteed jank spike and a GC pause.

Practice — lazy when the chain is long or the data is big
val names = users.asSequence()
    .filter { it.active }
    .map { it.name }
    .take(20)
    .toList()                     // one allocation, 20 elements examined

Sequences process element-by-element and short-circuit. The honest caveat, worth volunteering: for small collections or a single operator, sequences are slower — the iterator overhead dominates. Rule of thumb: two or more operators over hundreds of items.

Anti-pattern — quadratic lookup hidden in a loop
orders.forEach { order ->
    val user = users.first { it.id == order.userId }   // O(n) per order
    render(order, user)
}

O(n×m). With 1,000 orders and 5,000 users that is five million comparisons on the main thread. It is invisible in a test fixture of ten rows and catastrophic in production — the classic "slow only for our biggest customers" bug.

Practice — index once, look up in constant time
val byId = users.associateBy { it.id }        // O(n) once
orders.forEach { order ->
    val user = byId[order.userId] ?: return@forEach
    render(order, user)
}

Also note the explicit miss branch. first { } throws on no match; a lookup that returns null forces you to decide what a dangling reference means instead of crashing on it.

Anti-pattern — mutable state leaked through a getter
class Cart {
    val items = mutableListOf<Item>()     // anyone can mutate
}
cart.items.clear()                        // from anywhere, silently

Exposing a mutable collection means the class cannot maintain any invariant, and no reader can trust a value it holds. It also makes concurrent modification a live possibility with no lock to point at.

Practice — expose the read-only view
class Cart {
    private val _items = mutableListOf<Item>()
    val items: List<Item> get() = _items.toList()   // or persistent list

    fun add(item: Item) { _items += item; recalculate() }
}

The backing-field-plus-immutable-view convention is the same one used for StateFlow, and for the same reason: exactly one writer. Prefer a genuinely immutable snapshot over a cast, since List is only read-only by convention.

Chapter 227Kotlin — nullability and errors

Anti-pattern — null as every kind of absence
suspend fun findUser(id: String): User?     // not found? offline? bad id?

val user = findUser(id)
if (user == null) showError("Something went wrong")

One null collapses three distinct outcomes that need three distinct UIs: an empty state, a retry affordance, and a bug report. The user gets "something went wrong" for a search that simply had no results.

Practice — name the outcomes
sealed interface UserLookup {
    data class Found(val user: User) : UserLookup
    data object NotFound : UserLookup
    data class Unavailable(val cause: ErrorReason) : UserLookup
}

null is fine when there is exactly one reason for absence and it needs no explanation. The moment the caller would ask "why?", it needs a type.

Anti-pattern — !! and blanket catch
val id = intent.getStringExtra("id")!!        // crash, no context

try { risky() } catch (e: Exception) { Log.e(TAG, "failed") }

The !! crashes with no indication of which extra was missing or who launched the Activity. The blanket catch is worse: it swallows CancellationException, hides programming errors alongside expected failures, and leaves the app in an undefined state while claiming to have handled something.

Practice — assert with a message, catch what you can handle
val id = checkNotNull(intent.getStringExtra("id")) {
    "DetailActivity launched without EXTRA_ID from ${callingActivity}"
}

try { risky() }
catch (e: CancellationException) { throw e }
catch (e: IOException) { showRetry() }        // an outcome you can act on

checkNotNull with a message turns a crash report into a diagnosis. And a catch clause should name the exception you have a plan for — anything broader is a decision to continue in a state you have not reasoned about.

Chapter 228Coroutines — scopes and lifetime

Anti-pattern — a scope with no owner
class SyncManager {
    fun start() {
        GlobalScope.launch { while (true) { sync(); delay(60_000) } }
    }
}

GlobalScope has no lifetime, so nothing can cancel this, nothing knows it failed, and every call to start() adds another copy. It also opts out of structured concurrency entirely — the one guarantee coroutines exist to provide.

Practice — an injected scope that something owns
class SyncManager @Inject constructor(
    @ApplicationScope private val scope: CoroutineScope,
) {
    private var job: Job? = null
    fun start() {
        job?.cancel()                     // idempotent
        job = scope.launch { … }
    }
}

An application scope is legitimate for work that must outlive any screen — but it must be injected so a test can replace it, and each starter must be idempotent. "Which scope?" is really "what should cancel this?", and that question always has an answer.

Anti-pattern — one failure takes down the batch
coroutineScope {
    sources.forEach { launch { refresh(it) } }   // one throws…
}                                                // …all are cancelled

Independent refreshes are not one indivisible result, but coroutineScope treats them as one: a single flaky source cancels the other nine and the user sees nothing refresh.

Practice — supervise independent children
supervisorScope {
    sources.map { src ->
        async { runCatchingNonCancellation { refresh(src) } }
    }.awaitAll()
}
// then render what succeeded, mark what didn't

Choose by asking whether a partial result is meaningful. Rendering nine of ten sources is useful; half a bank transfer is not. That question — not a habit — is what selects the scope.

Anti-pattern — the dispatcher chosen at the call site
viewModelScope.launch(Dispatchers.IO) {   // caller guesses
    val data = repo.load()                 // …which may itself switch
    _state.value = data                    // now writing state off-main
}

Main-safety becomes a convention every caller must remember, and forgetting it is silent. Worse, the state write now happens on an IO thread, which is fine for StateFlow and a crash for a View.

Practice — main-safety owned by the function that blocks
// repository:
suspend fun load(): Data = withContext(io) { blockingRead() }

// ViewModel — no dispatcher, because it needs none:
viewModelScope.launch { _state.value = repo.load() }

A suspend function should be safe to call from any dispatcher. Once that holds, ViewModels never mention dispatchers, and the injected io makes the repository a JVM test.

Chapter 229Coroutines — cancellation and exceptions

Anti-pattern — runCatching in coroutine code
viewModelScope.launch {
    runCatching { repo.save(draft) }         // catches Throwable…
        .onFailure { showError() }           // …including cancellation
}

runCatching catches Throwable, so a cleared ViewModel reports a save failure to a screen that no longer exists, and the coroutine machinery is told the cancellation was handled. The same defect hides in catch (e: Exception).

Practice — rethrow cancellation, always
suspend inline fun <T> runCatchingNonCancellation(block: () -> T): Result<T> =
    try { Result.success(block()) }
    catch (e: CancellationException) { throw e }
    catch (e: Throwable) { Result.failure(e) }

Write it once, ban runCatching in coroutine code with a lint rule, and the whole class of bug disappears. Naming this unprompted in a code-review round is a strong Kotlin-depth signal.

Anti-pattern — cleanup that cannot run
try {
    upload(file)
} finally {
    api.releaseSlot(id)      // a suspend call — throws immediately
}                            // if the job was cancelled

After cancellation every suspension point throws, so a suspending call inside finally fails before doing anything. The server-side slot leaks, and the failure is invisible because the exception is swallowed as ordinary cancellation.

Practice — NonCancellable for cleanup only
try {
    upload(file)
} finally {
    withContext(NonCancellable) {
        withTimeout(5_000) { api.releaseSlot(id) }
    }
}

Scope it to the cleanup, never around the work itself, and bound it with a timeout — otherwise a cancelled screen can hang on a cleanup that never returns.

Anti-pattern — an uncancellable loop
viewModelScope.launch(Dispatchers.Default) {
    for (frame in frames) { analyse(frame) }   // pure CPU, never suspends
}

Cancellation is cooperative and this loop never suspends, so leaving the screen does not stop it. The work runs to completion against a dead ViewModel, holding its captured references alive.

Practice — cooperate explicitly
viewModelScope.launch(Dispatchers.Default) {
    for (frame in frames) {
        ensureActive()          // cheap; throws if cancelled
        analyse(frame)
    }
}

Any CPU-bound loop longer than a frame needs a cooperation point. ensureActive() is nearly free; yield() additionally lets other coroutines run, which matters on a shared dispatcher.

Chapter 230Flow — operators and sharing

Anti-pattern — a cold flow collected many times
class Repo {
    fun user(): Flow<User> = flow { emit(api.fetchUser()) }
}
// three composables collect it → three network calls

Flow is cold: each collector re-executes the whole builder. This is the mechanism behind "why does my API get called five times on this screen", and it worsens as the screen grows.

Practice — one source, shared deliberately
// Best: make the database the shared source.
fun user(): Flow<User> = dao.observeUser()      // Room dedupes naturally

// Otherwise share explicitly, with an owner:
val user: StateFlow<User?> = flow { emit(api.fetchUser()) }
    .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)

Sharing needs a scope, and a scope means an owner — so decide who owns it rather than sprinkling shareIn. A single database-backed source is usually the better answer because it also survives process death.

Anti-pattern — stale work not cancelled
query.collect { q ->
    val results = repo.search(q)     // previous search still running
    _state.update { it.copy(results = results) }
}

Every keystroke starts a search and none are cancelled, so responses race: a slow query for "an" can land after a fast one for "android" and overwrite it. The user watches the results flicker backwards.

Practice — debounce, then switch
val results = query
    .debounce(300)
    .distinctUntilChanged()
    .flatMapLatest { q -> repo.search(q) }   // cancels the previous
    .stateIn(viewModelScope, WhileSubscribed(5_000), emptyList())

flatMapLatest guarantees the newest query wins by cancelling the older one, which is both correct and cheaper. debounce then removes the calls nobody needed.

Anti-pattern — a leaked listener
fun locations(): Flow<Location> = callbackFlow {
    val l = LocationListener { trySend(it) }
    manager.requestUpdates(l)
    // no awaitClose — the flow completes immediately, listener stays
}

Without awaitClose the builder returns at once and the flow completes, but the listener is never unregistered: GPS keeps running and the callback holds its captures forever. A battery complaint with no obvious cause.

Practice — awaitClose is mandatory
fun locations(): Flow<Location> = callbackFlow {
    val l = LocationListener { trySend(it) }
    manager.requestUpdates(l)
    awaitClose { manager.removeUpdates(l) }
}.conflate()                       // drop stale fixes under backpressure

awaitClose both keeps the flow alive and provides the unregister hook. trySend rather than send because a callback is not a coroutine and must not block; conflate because a stale position has no value.

Anti-pattern — SharedFlow for one-shot UI events
private val _nav = MutableSharedFlow<Route>(replay = 1)
val nav = _nav.asSharedFlow()
// rotation re-collects → replays → navigates twice

replay = 1 re-delivers on every new subscriber, so a configuration change navigates a second time. With replay = 0 the opposite bug appears: an event emitted while the screen is stopped is dropped entirely.

Practice — a channel for exactly-once delivery
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()   // buffers while stopped, replays never

Reserve SharedFlow for genuinely multicast signals with several independent observers. One consumer that must not miss and must not repeat is precisely a Channel.

Chapter 231Compose — state and effects

Anti-pattern — side effects in the composable body
@Composable
fun Screen(vm: VM) {
    val state by vm.state.collectAsState()
    vm.trackScreenView()               // fires on every recomposition
    if (state.done) navController.navigate("next")   // and again, and again
}

A composable body may run many times per frame, may be cancelled, and may run in parallel. Analytics fire dozens of times and the navigation call re-enters mid-composition, corrupting the back stack.

Practice — effects, keyed by what should retrigger them
LaunchedEffect(Unit) { vm.trackScreenView() }     // once per entry

LaunchedEffect(Unit) {
    lifecycle.repeatOnLifecycle(STARTED) {
        vm.events.collect { if (it is Navigate) navController.navigate(it.route) }
    }
}

The rule: the composable body describes UI and does nothing else. Anything that touches the world outside belongs in an effect whose keys state exactly when it should restart.

Anti-pattern — a stale capture in a long-lived effect
@Composable
fun Timer(onTimeout: () -> Unit) {
    LaunchedEffect(onTimeout) {        // restarts on every recomposition
        delay(10_000); onTimeout()      // …so the timer never fires
    }
}

A lambda parameter is a new instance on each recomposition, so keying on it restarts the effect continuously and the delay never elapses. Keying on Unit instead fixes the restart but captures the first lambda forever — a stale callback pointing at old state.

Practice — rememberUpdatedState
@Composable
fun Timer(onTimeout: () -> Unit) {
    val current by rememberUpdatedState(onTimeout)
    LaunchedEffect(Unit) {             // runs once…
        delay(10_000); current()        // …but calls the latest lambda
    }
}

This is the canonical answer to "an effect that must not restart but must not go stale". It is asked verbatim in Compose-heavy loops.

Anti-pattern — state that dies at the wrong moment
var draft by remember { mutableStateOf("") }   // lost on rotation

val items = viewModel.repo.loadSync()          // re-fetched every recomposition

Two different lifetime errors. remember survives recomposition but not configuration change, so the user's half-typed message vanishes on rotation. And a blocking load in the body re-runs on every frame.

Practice — pick the lifetime deliberately
// survives rotation and process death, Bundle-sized:
var draft by rememberSaveable { mutableStateOf("") }

// survives rotation, arbitrary size, owned by the ViewModel:
val items by viewModel.items.collectAsStateWithLifecycle()

Three lifetimes, three tools: recomposition → remember; configuration change → rememberSaveable or a ViewModel; process death → saved state or disk. Naming which one a value needs is the whole decision.

Chapter 232Compose — performance and API design

Anti-pattern — reading state too early
@Composable
fun Header(scroll: ScrollState) {
    // recomposes on EVERY scroll pixel
    Box(Modifier.offset(y = (scroll.value / 2).dp).alpha(
        if (scroll.value > 100) 0.5f else 1f))
}

Reading scroll.value in composition invalidates this composable on every frame of a scroll, so all three phases re-run for what is purely a visual offset. Recomposition counts look terrible and the fix is not "memoize harder".

Practice — defer the read to layout or draw
@Composable
fun Header(scroll: ScrollState) {
    val faded by remember { derivedStateOf { scroll.value > 100 } }
    Box(Modifier
        .offset { IntOffset(0, scroll.value / 2) }   // lambda → layout phase
        .graphicsLayer { alpha = if (faded) 0.5f else 1f })  // draw phase
}

The lambda overloads read state in a later phase, so composition is skipped entirely. derivedStateOf collapses hundreds of scroll values into two boolean changes. This pair is the highest-value Compose performance idiom.

Anti-pattern — unstable parameters defeat skipping
@Composable
fun ItemRow(item: Item, tags: List<String>, onClick: () -> Unit)
// List is an interface — could be mutable — so it is unstable;
// the row recomposes even when nothing about it changed.

One unstable parameter makes the whole composable non-skippable, and the cost multiplies down the tree. List, Map, Set and any class from a module without the Compose compiler are all unstable by default.

Practice — stable types at the boundary
@Immutable
data class ItemUi(val id: String, val title: String,
                  val tags: ImmutableList<String>)   // kotlinx.collections.immutable

@Composable
fun ItemRow(item: ItemUi, onClick: () -> Unit)

Verify rather than guess: run the compiler metrics report and read which composables are marked skippable. Strong skipping mode softens this, but a stable UI model is still the design that scales — and it belongs to the UI layer, not the domain.

Anti-pattern — boolean-flag component API
@Composable
fun AppCard(title: String, subtitle: String?, icon: ImageVector?,
            showBadge: Boolean, isCompact: Boolean, isSelectable: Boolean,
            trailingText: String?, onClick: (() -> Unit)?)

Every new consumer adds a parameter, combinations multiply beyond what anyone can test, and the component ends up encoding eight teams' layout decisions. This is how a design system becomes the thing teams work around.

Practice — slots, and a modifier parameter
@Composable
fun AppCard(
    modifier: Modifier = Modifier,      // first optional param, by convention
    leading: @Composable (() -> Unit)? = null,
    trailing: @Composable (() -> Unit)? = null,
    content: @Composable ColumnScope.() -> Unit,
)

Slots let callers supply arbitrary content without the component knowing about it, so the API stops growing. The modifier parameter is not optional practice: without it callers cannot size, pad or add semantics, and will fork the component instead.

Chapter 233Fragments and Activities

Anti-pattern — a binding that outlives its view
class ListFragment : Fragment() {
    private lateinit var binding: FragmentListBinding   // never nulled
    override fun onCreateView(...) = FragmentListBinding
        .inflate(inflater).also { binding = it }.root
}

The fragment outlives its view across a back-stack traversal, so the binding — and the entire view hierarchy it references — leaks for as long as the fragment is retained. Returning to the fragment then touches a destroyed hierarchy.

Practice — clear it in onDestroyView
private var _binding: FragmentListBinding? = null
private val binding get() = checkNotNull(_binding) { "view is destroyed" }

override fun onDestroyView() {
    super.onDestroyView()
    _binding = null
}

The check message turns the eventual misuse into a diagnosis rather than an NPE. A delegate that does this automatically is worth writing once per codebase — this is boilerplate that is always identical and always forgotten.

Anti-pattern — fragments talking to each other directly
(parentFragment as FilterHost).onFilterChosen(filter)
// or: targetFragment, or a static callback, or an EventBus

A cast to a host type couples the two fragments and crashes when the fragment is reused elsewhere. targetFragment is deprecated because it does not survive process death — the reference is gone and the result never arrives.

Practice — the Fragment Result API, or a shared ViewModel
// child:
setFragmentResult("filter", bundleOf("value" to filter))

// parent:
setFragmentResultListener("filter") { _, bundle -> apply(bundle) }

The result API is bundle-backed, so it survives process death, and neither fragment names the other's type. For continuous shared state rather than a one-shot result, a ViewModel scoped to the navigation graph is the better fit.

Anti-pattern — constructor arguments and manual back stack
class DetailFragment(private val id: String) : Fragment()
// after process death the system calls the no-arg constructor → crash

startActivityForResult(intent, 42)      // deprecated, unstructured

The system recreates fragments reflectively with the no-arg constructor, so a constructor parameter guarantees a crash on process death — reproducible only with "Don't keep activities" or am kill. Numeric request codes are the same class of problem: unowned global state.

Practice — arguments bundle and the Result API contract
fun newInstance(id: String) = DetailFragment().apply {
    arguments = bundleOf(ARG_ID to id)
}

private val picker = registerForActivityResult(PickContact()) { uri -> … }
picker.launch(Unit)

Arguments are part of the saved state, so they survive anything the system does. ActivityResultContracts replaces request codes with a typed contract registered before STARTED — which is also why registration must not be conditional.

Chapter 234ViewModels and state holders

Anti-pattern — a ViewModel holding Android objects
class ProfileViewModel(
    private val activity: Activity,          // leaks on rotation
    private val resources: Resources,
) : ViewModel() {
    val title = resources.getString(R.string.title)   // wrong after locale change
}

The ViewModel outlives the Activity by design, so holding one leaks the whole view hierarchy on every rotation. Resolving strings there also freezes them: a locale change recreates the Activity but not the ViewModel, so the old language persists.

Practice — emit identifiers, resolve in the UI
data class ProfileUi(val titleRes: Int, val name: String)

// Compose resolves against the current configuration:
Text(stringResource(state.titleRes))

The ViewModel decides what to say; the UI layer decides how to render it in the current configuration. This also makes the ViewModel testable without a Context and keeps it usable from a screenshot test at any locale.

Anti-pattern — loading in init
class FeedViewModel : ViewModel() {
    init { viewModelScope.launch { _state.value = repo.load() } }   // fires always
}

The load runs when the ViewModel is constructed, whether or not anything is observing, and there is no way to retry it or to test the class without triggering it. On a screen the user never scrolls to, it is pure cost.

Practice — let subscription drive the work
val state: StateFlow<FeedUiState> = repo.observeFeed()
    .map(::toUiState)
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Loading)

Work starts when the screen collects and stops when it stops — no manual lifecycle handling, no retry plumbing, and the test controls everything by choosing when to collect. Keep init for genuinely eager, subscription-independent work.

Chapter 235Dependency injection

Anti-pattern — a service locator wearing DI's clothes
object ServiceLocator {
    lateinit var api: Api
    val repo by lazy { Repo(api) }
}
class VM : ViewModel() { private val repo = ServiceLocator.repo }

Dependencies are invisible in the constructor, so nothing tells you what this class needs or lets a test substitute it. Tests must mutate global state and therefore cannot run in parallel — and one leaked assignment makes an unrelated suite fail.

Practice — constructor injection, always
@HiltViewModel
class FeedViewModel @Inject constructor(
    private val repo: ArticleRepository,
    private val clock: Clock,
) : ViewModel()

The constructor is the honest dependency list. If it is uncomfortably long, that is real information about the class doing too much — which a service locator merely hides.

Anti-pattern — everything is a singleton
@Provides @Singleton fun mapper(): ArticleMapper = ArticleMapper()
@Provides @Singleton fun validator(): Validator = Validator()

Scoping is not an optimisation. A stateless mapper allocates faster than the scope lookup costs, and marking it @Singleton only adds lifetime to something that has no state to share. Meanwhile a genuinely shared cache scoped too narrowly silently duplicates.

Practice — scope follows shared mutable state
@Provides fun mapper(): ArticleMapper = ArticleMapper()   // unscoped

@Provides @Singleton
fun outbox(db: AppDatabase): OutboxDao = db.outboxDao()   // one shared queue

The test: would two instances of this cause a correctness problem? Yes → scope it. No → do not. Say it in those terms and the follow-up about component hierarchies answers itself.

Chapter 236Room and data access

Anti-pattern — non-atomic multi-step writes
suspend fun replaceFeed(items: List<Article>) {
    dao.deleteAll()          // crash here → empty database
    dao.insertAll(items)
}

Two independent transactions. A crash, a cancellation or a process kill between them leaves the user with an empty feed and no way to recover except a manual refresh they do not know to perform.

Practice — one transaction, cancellation-aware
suspend fun replaceFeed(items: List<Article>) = db.withTransaction {
    dao.deleteAll()
    dao.insertAll(items)
}

withTransaction is the coroutine-aware form — it uses the transaction dispatcher so a nested suspending call cannot deadlock the way runInTransaction plus runBlocking does. Either the whole swap happens or none of it does.

Anti-pattern — the N+1 query
val orders = dao.orders()
orders.forEach { it.lines = dao.linesFor(it.id) }   // one query per order

Fifty orders means fifty-one queries, each with its own cursor and transaction overhead. It looks fine at ten rows in a test and is a visible freeze at fifty on a real device.

Practice — let Room do the join
data class OrderWithLines(
    @Embedded val order: OrderEntity,
    @Relation(parentColumn = "id", entityColumn = "order_id")
    val lines: List<LineEntity>,
)

@Transaction @Query("SELECT * FROM orders WHERE user_id = :id")
fun ordersWithLines(id: String): Flow<List<OrderWithLines>>

@Relation issues two queries total regardless of row count. @Transaction is required, not decorative: without it the two queries can observe different states and produce a result that never existed.

Anti-pattern — a query that re-runs on every write
@Query("SELECT * FROM events ORDER BY ts DESC")
fun all(): Flow<List<Event>>        // 200k rows, observed by a badge count

Room invalidates per table, so every insert re-runs this and re-maps 200,000 objects — to display a number. On a table written by a sync worker this is a permanent background CPU load and a steady allocation rate.

Practice — ask the database the actual question
@Query("SELECT COUNT(*) FROM events WHERE unread = 1")
fun unreadCount(): Flow<Int>         // one integer, index-backed

@Query("SELECT * FROM events WHERE chat_id = :id ORDER BY ts DESC LIMIT 50")
fun recent(id: String): Flow<List<Event>>

Push filtering and aggregation into SQL rather than mapping rows to count them in Kotlin. Confirm with EXPLAIN QUERY PLAN: "SEARCH … USING INDEX" is the goal, "SCAN TABLE" means you have none.

Chapter 237Networking and serialization

Anti-pattern — DTOs used as domain and UI models
@Serializable data class UserDto(val id: String, val display_name: String?)

Text(user.display_name ?: "")     // JSON naming and server nullability in the UI

The server's field names, nullability and encoding conventions propagate to every screen. A backend rename becomes a UI change; an optional field becomes a null check in forty composables; and the UI cannot be tested without the network's shape.

Practice — map once, at the boundary
data class User(val id: UserId, val displayName: String)   // domain: no nulls

fun UserDto.toDomain() = User(
    id = UserId(id),
    displayName = display_name?.takeIf { it.isNotBlank() } ?: "Unknown",
)

One mapper is the single place where server reality is reconciled with what the app guarantees. It is also the natural place for a contract test: feed it a captured payload and assert the domain object.

Anti-pattern — auth refresh as an interceptor race
class AuthInterceptor : Interceptor {
    override fun intercept(chain: Chain): Response {
        val res = chain.proceed(chain.request().withToken(token))
        if (res.code == 401) { token = api.refreshBlocking() }   // every thread
        return res
    }
}

Six parallel requests hitting a 401 trigger six refreshes; five of them invalidate the token the sixth just obtained, and the user is logged out. It also blocks an OkHttp dispatcher thread and never retries the original request.

Practice — Authenticator plus single-flight
class TokenAuthenticator(private val store: TokenStore) : Authenticator {
    private val mutex = Mutex()
    override fun authenticate(route: Route?, response: Response): Request? {
        if (responseCount(response) >= 2) return null      // give up, no loop
        val fresh = runBlocking {
            mutex.withLock {
                val current = store.token()
                if (current != response.request.token()) current   // someone else did it
                else store.refresh()
            }
        }
        return response.request.newBuilder().withToken(fresh).build()
    }
}

Authenticator is the correct hook — OkHttp calls it on 401 and retries the request for you. The mutex plus the "has someone already refreshed?" check is the single-flight pattern, and the response-count guard is what prevents an infinite refresh loop against a permanently rejecting server.

Anti-pattern — logging everything, everywhere
OkHttpClient.Builder()
    .addInterceptor(HttpLoggingInterceptor().setLevel(Level.BODY))
    .build()

Full bodies at BODY level put tokens, personal data and payment details into logcat — which reaches crash breadcrumbs, support bundles and any app with log access on older devices. It also costs real time serialising strings nobody reads.

Practice — debug only, redacted
if (BuildConfig.DEBUG) {
    addInterceptor(HttpLoggingInterceptor().apply {
        level = Level.BASIC
        redactHeader("Authorization")
        redactHeader("Cookie")
    })
}

The build-type guard means the release binary does not contain the interceptor at all. In production, ship structured metrics — status code, latency, endpoint — rather than payloads, which is what you actually need to diagnose an incident.

Chapter 238Background work

Anti-pattern — passing data through WorkRequest input
val data = workDataOf("payload" to gson.toJson(largeList))   // 200 KB
OneTimeWorkRequestBuilder<UploadWorker>().setInputData(data).build()

Data is capped at roughly 10 KB and throws above it. More fundamentally, work input is not a transport for state — the row can sit in the queue for hours, by which time the serialised copy is stale.

Practice — pass an id, read from the store
val data = workDataOf("batch_id" to batchId)      // a few bytes

// in the worker:
val items = dao.itemsForBatch(inputData.getString("batch_id")!!)

The worker reads current state at execution time, so a batch edited between enqueue and run is handled correctly. The same reasoning applies to notifications and deep links: pass identity, resolve state at use.

Anti-pattern — retrying failures that will never succeed
override suspend fun doWork(): Result =
    try { api.upload(file); Result.success() }
    catch (e: Exception) { Result.retry() }      // including 400 and 413

A malformed payload retries with exponential backoff forever, waking the device on a schedule to fail identically. It also catches CancellationException and reports it as a retryable failure.

Practice — classify, and cap
override suspend fun doWork(): Result = try {
    api.upload(file); Result.success()
} catch (e: CancellationException) { throw e }
  catch (e: IOException) { if (runAttemptCount < 5) Result.retry() else Result.failure() }
  catch (e: HttpException) {
      if (e.code() == 429 || e.code() >= 500) Result.retry() else Result.failure()
  }

Three buckets — transient, permanent, cancellation — plus an attempt cap so a persistently unreachable server does not retry indefinitely. And Result.failure() must be visible to the user when it represents lost intent.

Chapter 239Testing

Anti-pattern — asserting on interactions
@Test fun loads() {
    viewModel.load()
    verify(repo).fetch()                 // passes even if nothing is displayed
    verify(analytics).track(any())
}

This asserts that a particular implementation was chosen, not that the feature works. Refactor fetch into observe and the test fails while the app is fine — the definition of a brittle test, and the reason suites get deleted.

Practice — assert on observable state
@Test fun shows_items_after_load() = runTest {
    val repo = FakeArticleRepository(items = listOf(article("a1")))
    val vm = FeedViewModel(repo, TestClock)

    vm.state.test {
        assertEquals(Loading, awaitItem())
        assertEquals(listOf("a1"), (awaitItem() as Ready).items.map { it.id })
    }
}

A fake plus a state assertion survives any refactor that preserves behaviour, which is exactly the property a test should have. Reserve mocks for genuine boundaries where the interaction is the contract — an analytics call, a payment SDK.

Anti-pattern — real time in tests
@Test fun debounces() {
    vm.onQueryChanged("an")
    Thread.sleep(400)                    // slow, and flaky on a loaded CI box
    assertEquals(1, repo.searchCount)
}

Every such test adds real seconds to the suite and fails intermittently when CI is busy — the two properties that make a team stop trusting its tests. It is also untestable for anything longer than a few hundred milliseconds.

Practice — virtual time
@Test fun debounces() = runTest {
    vm.onQueryChanged("a"); vm.onQueryChanged("an")
    advanceTimeBy(299); assertEquals(0, repo.searchCount)
    advanceTimeBy(2);   assertEquals(1, repo.searchCount)
}

Virtual time makes a 300 ms debounce assertable to the millisecond, instantly and deterministically. This requires the dispatcher to be injected — which is the concrete return on the DI argument in Chapter 235.

Chapter 240Gradle, modules and release

Anti-pattern — api everywhere and a :common module
dependencies {
    api(project(":common"))     // leaks the whole graph transitively
    api(libs.retrofit)
}

api puts a dependency on every consumer's compile classpath, so a change to one class recompiles modules that never used it. A :common module everything depends on is the monolith with extra build-graph overhead — the dependency graph is now a star, not a tree.

Practice — implementation by default, split by responsibility
dependencies {
    implementation(project(":core:database"))   // not on consumers' classpath
    api(project(":core:model"))                 // api ONLY for types in the surface
}

Use api only when a type genuinely appears in your public signatures. Replace :common with narrow modules — :core:model, :core:ui, :core:testing — so a change touches a leaf rather than the root.

Anti-pattern — release-only failures
android { buildTypes { release { isMinifyEnabled = true } } }
// R8 strips the reflectively-created adapter → crash only in production

Debug builds do not run R8, so every keep-rule bug ships. Reflection, serialization adapters and anything referenced only from XML are all invisible to the shrinker and fail at runtime in the one build users get.

Practice — test the shrunk artifact
// A minified debuggable variant, run in CI on every PR:
create("releaseTest") {
    initWith(getByName("release"))
    isMinifyEnabled = true
    signingConfig = signingConfigs.getByName("debug")
    matchingFallbacks += "release"
}

Smoke-test the actually-shipped shape of the binary. Prefer compile-time serialization (kotlinx.serialization) over reflective libraries so there are fewer keep rules to get wrong in the first place.

Chapter 241The review checklist

One question per area. If a change touches the area, the question must have an answer in the diff or the description.

AreaThe question
TypesCan a caller pass the arguments in the wrong order and still compile?
State modellingHow many states does this type represent, and how many are meaningful?
CollectionsIs there a lookup inside a loop, or an eager chain over something large?
NullabilityDoes this null have exactly one meaning?
ScopesWhat cancels this coroutine, and what happens if a sibling fails?
CancellationIs CancellationException rethrown on every catch path? Can this loop be cancelled?
FlowCold and collected more than once? Is stale work cancelled? Does the callback unregister?
Compose effectsAnything in the body that is not describing UI? Are the effect keys right?
Compose perfIs state read in composition that could be read in layout or draw? Any unstable parameters?
FragmentsIs the binding cleared? Do arguments survive process death?
ViewModelDoes it hold a Context, a View or a resolved string?
DIWould two instances of this cause a correctness problem? If not, why is it scoped?
RoomMulti-step write in one transaction? Bounded, indexed query? A join rather than N+1?
NetworkingDo DTOs stop at the boundary? Is refresh single-flight? Are bodies logged?
BackgroundIs work input an id rather than a payload? Are permanent failures retried?
TestingDoes this test assert behaviour or implementation? Does it use real time?
Buildapi where implementation would do? Has the minified build been run?

Part XXXV rapid recall

  • Wrap identifiers and money in value class; money is Long minor units, never Double.
  • Model states as a sealed hierarchy so illegal combinations cannot be constructed.
  • data class is a binary-compatibility hazard at a module boundary.
  • Sequences for long chains over large data; associateBy instead of a lookup in a loop.
  • Never expose a mutable collection; back it with a private field and a read-only view.
  • null needs exactly one meaning, or it needs a type.
  • No GlobalScope; every scope has an owner and every starter is idempotent.
  • coroutineScope when children are one result, supervisorScope when they are independent.
  • Main-safety belongs to the function that blocks, with an injected dispatcher.
  • Rethrow CancellationException; NonCancellable only around bounded cleanup.
  • ensureActive() in any CPU loop longer than a frame.
  • Cold flows re-execute per collector — share deliberately or read from the database.
  • flatMapLatest for superseded work; awaitClose is mandatory in callbackFlow.
  • Channel for one-shot events; SharedFlow only when several observers care.
  • Composable bodies describe UI and do nothing else; keys decide when effects restart.
  • rememberUpdatedState for an effect that must not restart but must not go stale.
  • Read state in layout or draw with the lambda modifier overloads, not in composition.
  • Slots, not boolean flags; every component takes a modifier.
  • Null the view binding in onDestroyView; fragment arguments go in the bundle.
  • ViewModels emit resource ids, never resolved strings or a Context.
  • Let subscription start the work rather than loading in init.
  • Constructor injection always; scope only what shares mutable state.
  • Multi-step writes in withTransaction; @Relation with @Transaction instead of N+1.
  • Ask SQL the real question — COUNT and LIMIT, not a full-table observe.
  • DTOs stop at the boundary; refresh is single-flight with a loop guard.
  • Work input carries an id, not a payload; classify failures and cap attempts.
  • Assert observable state with fakes and virtual time, not interactions and sleep.
  • implementation by default; run the minified build in CI.