In short
- In a mobile architecture interview, "I would modularize everything" is a belief, and interviewers score decisions under change.
- Pick the first module boundary by change axis and ownership, not by technical layer.
- Dependencies point inward: features depend on a small API module, and only the app's composition root sees the implementation.
- A modularization plan needs a measure and a stop rule, written in an ADR, or it cannot be defended when the build gets slower.
- Migrate one user flow at a time behind a flag, so feature delivery never stops and every step can be reversed.
The mobile architecture interview at senior and staff level is not a test of whether you know what a module is. Everyone in the loop knows. It is a test of whether you can pick one boundary in a codebase that is already on fire, defend why that one goes first, and change your plan when the interviewer changes a fact. Correct answers are the entry fee. The score comes from the decision and what it costs.
I have run this round in many of my 500+ technical interviews on the hiring side, as a staff engineer, mobile architect and tech lead. The prompt barely varies, and neither does the answer that loses the point.
The prompt in the architecture round
A large app has tangled dependencies, unclear ownership and slow builds. Feature delivery cannot stop. Propose a boundary and a migration sequence.
Read the last sentence again. The prompt asks for a boundary, singular, and a sequence. It does not ask for a target architecture. The constraint in the middle, feature delivery cannot stop, is the whole problem. Any plan that needs a freeze has already failed, even if the diagram at the end is beautiful.
What I write in my notes during the first five minutes is not the module list. It is whether the candidate asked what "slow builds" means here, who owns what today, and which part of the app changes most often. Those three questions tell me the candidate treats architecture as a decision under change, not a belief.
Why "I would modularize everything" fails
The weak answer sounds competent. It names a layered target: a Core module, a Networking module, a UI module, a module per feature, clean architecture inside each. It might even be the right end state. It still scores low, for three reasons an interviewer can write down.
- It has no first step. A target without a sequence is a rewrite, and the prompt ruled out a rewrite.
- It splits by layer. A typical feature change touches the screen, the view model, the repository and the network call. Split by layer and that one change now crosses four modules, and still has no single owner.
- It has no cost. More modules means more build configuration, more public API to maintain, and more places for a shared hub module to invalidate everything downstream. A plan with no stated cost is a plan that has not been thought through.
Asked in the loopWhere would you start?
"I'd break the monolith into layers first: core, networking, data, UI. Then each feature gets its own module on top. That gives us separation of concerns and faster builds."
"Before I pick anything I want two numbers from version control: which directories change together most often, and which teams touch them. My guess is that a flow like checkout changes weekly, is owned by one team, and is tangled with half the app through a shared session object. That's my first boundary. Not because it's the cleanest, but because it's where a boundary pays back fastest and where one team can own the result. Layers can come later inside that module, if they earn it."
The strong answer picks by change axis and ownership, states how it would check the guess, and declines to promise faster builds before measuring.
Picking the first module boundary by change axis
A change axis is the set of files that tend to change together for one reason. A good module boundary follows a change axis, so that a typical change stays inside one module and one team. That is the definition I want to hear, in any wording.
Git history gives you the change axis cheaply. Files that appear in the same commits, week after week, belong together. Files that are owned by different teams but always change together are the tangle you were asked about. The first boundary goes where both signals agree: one team, one flow, high change rate, and a clear entry point from the rest of the app.
The candidates who stand out also say what they would not pick first. The shared design system is tempting, because everything uses it. It is the worst first boundary, because it is depended on by everything, owned by nobody, and every change to its public surface recompiles the world. Extract it later, once feature boundaries have shown you which parts of it are actually shared.
Dependency direction: API modules and implementations
Once the boundary is chosen, the next question is which way the arrows point. Dependency inversion at a module boundary means callers depend on a small interface module, and only the composition root depends on the implementation. The cart screen should be able to start checkout without being able to see how checkout works.
On iOS, a Swift package with two library products does this directly. The API target has no dependencies. The implementation target depends on the API and on whatever it needs privately.
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Checkout",
platforms: [.iOS(.v16)],
products: [
.library(name: "CheckoutAPI", targets: ["CheckoutAPI"]),
.library(name: "CheckoutImpl", targets: ["CheckoutImpl"]),
],
dependencies: [
.package(path: "../Networking"),
],
targets: [
.target(name: "CheckoutAPI"),
.target(
name: "CheckoutImpl",
dependencies: [
"CheckoutAPI",
.product(name: "Networking", package: "Networking"),
]
),
.testTarget(
name: "CheckoutImplTests",
dependencies: ["CheckoutImpl"]
),
]
)The API target holds the protocol and the value types that cross the boundary, and nothing else. No view controllers, no networking types, no singletons.
// CheckoutAPI
public struct CartID: Hashable, Sendable {
public let rawValue: String
public init(_ rawValue: String) { self.rawValue = rawValue }
}
public enum CheckoutResult: Sendable {
case completed(orderID: String)
case cancelled
}
@MainActor
public protocol CheckoutStarting {
func startCheckout(for cart: CartID) async -> CheckoutResult
}On Android, the same shape is two Gradle modules, and the part interviewers probe is the difference between api and implementation. An `implementation` dependency is private to the module: it is not on the compile classpath of the module's consumers. An api dependency is exported, so consumers compile against it and recompile when its ABI changes.
// checkout/impl/build.gradle.kts
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.checkout.impl"
compileSdk = 35
}
dependencies {
// Exported: whoever wires this module sees the API types.
api(project(":checkout:api"))
// Private: an ABI change in :core:network recompiles
// this module, not the modules that depend on it.
implementation(project(":core:network"))
}
// cart/build.gradle.kts, a caller of checkout
dependencies {
implementation(project(":checkout:api"))
}// :checkout:api
@JvmInline
value class CartId(val value: String)
sealed interface CheckoutResult {
data class Completed(val orderId: String) : CheckoutResult
data object Cancelled : CheckoutResult
}
interface CheckoutLauncher {
suspend fun start(cart: CartId): CheckoutResult
}Two details separate a staff answer from a senior one here. First, :cart depends on :checkout:api with implementation, not api, because nothing downstream of cart needs checkout's types. Defaulting to api everywhere quietly rebuilds the transitive graph you just cut. Second, the only module that depends on both :checkout:impl and :cart is the app module, which binds CheckoutLauncher to its implementation. That composition root is the one place allowed to see everything.
Asked in the loopWhy not just put the interface in the same module as the implementation?
"It's simpler. Callers can depend on the checkout module and only use the interface. We can enforce it in code review."
"Then every caller recompiles when the implementation changes, and every caller can reach the implementation's types, so the boundary lives in review comments instead of the build. Splitting the API out costs one more module and a public surface I have to keep small. For checkout, with several callers and one owning team, that's worth it. For a leaf screen with one caller, I wouldn't split it. The API module earns its place when there are multiple callers or when I want to swap the implementation behind a flag."
The weak answer relies on discipline. The strong answer names what the build enforces, what the split costs, and when it is not worth it.
The migration sequence: one flow at a time, behind a flag
Feature delivery cannot stop, so the sequence must keep the old path shippable at every step. The shape I want to hear is close to this.
- Create the API module with the protocol and value types only. Make the existing tangled code conform to it, in place. Nothing moves yet, and nothing breaks.
- Change callers to depend on the protocol, injected from the composition root. This is where the hidden dependencies surface: the shared session object, the global analytics call, the singleton that reads from three places.
- Build the new implementation in its own module, one flow at a time. Checkout first, and within checkout the payment step first if that is where changes concentrate.
- Ship both implementations and select one with a remote flag. Roll out, watch crash and conversion metrics for that flow, keep the old path as the rollback.
- Delete the old path once the flag has been at 100 percent long enough to trust it. Deletion is a step in the plan, with a date, or it never happens.
The flag matters more than candidates expect. It is what makes the migration reversible, and reversibility is what lets product keep shipping on the old path while you work. Without it, every step is a release risk, and the plan stalls the first time a step goes wrong.
What to measure, and the stop rule
"Faster builds" is a hope. An interviewer wants the number you will watch. I listen for three.
- Incremental build time for the target after a typical change. Not a clean build. The loop that engineers feel is: edit one file in checkout, rebuild, run. On Android, a build scan or
--profileshows which tasks ran. On iOS, the Xcode build timeline orxcodebuild -showBuildTimingSummaryshows where time went. - Files and modules touched per typical change. From version control, per team. If a checkout change still touches five modules, the boundary is in the wrong place.
- Dependencies pointing the wrong way. Gradle and SwiftPM both reject a direct cycle between modules, so the real cycles hide in service locators, reflection and notification buses. Count lookups that let a lower module reach a feature.
Then the stop rule. A stop rule is the condition under which you stop extracting modules, decided before you start. For example: stop when a typical change for each team stays inside that team's modules, or when a new extraction no longer moves incremental build time for anyone. Without a stop rule, modularization becomes a permanent project that absorbs a platform team.
The decision, the measures and the stop rule go into an ADR. An architecture decision record is a short document that states one decision, its context, the options rejected and the consequences you accepted. It is what lets someone joining in a year understand why checkout has an API module and settings does not. Candidates who mention the ADR unprompted, and say what goes in the consequences section, are showing me they have lived with a decision after the meeting ended.
Mobile architecture interview follow-up questions
Everything above is the first answer. The follow-ups decide the level, and each one changes a single fact. I cover why that moment carries the offer in the follow-up is the interview. Here are the four I use most in this round.
"The team just doubled."
This is an ownership question wearing a headcount costume. The strong answer redraws boundaries along the new team lines, because a module owned by two teams has the same coordination cost as the monolith, just smaller. It also notes that the API modules become contracts between teams, so changes to them now need a review from the consuming team, and that a CODEOWNERS file per module is the cheapest way to make that real.
"A shared schema is written by three legacy flows."
Now the boundary cannot be clean on day one. A weak answer moves the table into the new module and breaks two flows. A strong answer names a single owner for writes, keeps the schema where it is, and routes the three legacy writers through one write interface owned by that module, one flow at a time. Reads can stay direct for now. The migration is done when the other two flows no longer write directly, and that is a measurable step, not a vibe.
"Two teams need different retention rules."
This tests whether the candidate knows when shared code is the problem. If two teams need different rules for how long data lives, one shared persistence module with a flag per team will turn into a pile of conditionals. The strong answer splits ownership of the data, gives each team its own store behind its own API module, and keeps only the mechanics shared: the database wrapper, not the policy. The cost is some duplication, stated out loud and accepted in the ADR.
"The build got slower after modularizing."
This is the follow-up that separates people who have done it from people who have read about it. It happens, and the strong answer lists concrete suspects before proposing anything.
- A hub module, often
coreorcommon, that everything depends on and that changes often. Every edit to it invalidates the whole graph. Split it or stop it growing. apiused whereimplementationwould do, so ABI changes leak through several layers of consumers.- Fixed cost per module: configuration, plugin setup and annotation processing multiplied across many small modules. Too many tiny modules can cost more than they save.
- Measuring the wrong build. Clean builds can get slower while the incremental loop gets faster. Ask which one people complained about.
Then the candidate goes back to the measure and the stop rule from the ADR. If extractions stopped moving the incremental number, the plan said to stop, and the answer is to merge back the modules that did not earn their cost. Saying "I would merge modules back" in an architecture interview is a strong signal. It shows the candidate treats the module graph as a decision that can be wrong, which is exactly what staff level asks of you. The senior versus staff article goes deeper on that difference.
Practicing the architecture round
You cannot rehearse this round by memorizing a module diagram. You can rehearse it by answering the prompt out loud, finding the one gap in your answer, then answering again after one fact changes. That loop is how both books are built. iOS Interview Training and Android Interview Training work through architecture, concurrency and system design questions with the weak answer, the strong answer and the follow-ups that move a level. There are free samples for iOS and Android if you want to see the format before anything else.
Questions readers ask
© 2026 Mike Salari, Interview Runtime. All rights reserved. Quoting a short excerpt with a link to this page is welcome; republishing the article, in full or in part, is not.
