Free lesson · Mobile System Design Interviews
Architecture Patterns for Mobile Apps
Think of your app's architecture like organizing a business. The bigger and more complex your business gets, the more you need clear rules about who does what. The same goes for mobile apps.
Let's explore the main patterns you'll encounter and use in your career.
MVC: The One-Person Food Truck
Imagine you're running a food truck alone. You take orders from customers, cook the food, and serve it. That's MVC: Model-View-Controller.
- Model = Your ingredients and recipes (the data and business logic)
- View = The serving window (what customers see)
- Controller = You, the owner (handles the flow between everything)
The Controller sits in the middle, taking commands from the View and telling the Model what to do.
The Problem with MVC
As your food truck grows and orders pile up, you're overwhelmed. The Controller ends up doing too much: managing the UI, updating the display, fetching data, everything. This is called "Massive View Controller" in iOS development—a real problem.
⚠️ Watch out: MVC can lead to tightly coupled code. The View and Controller often become difficult to test separately because they're so intertwined.
When to Use MVC
- Small projects with limited complexity
- Prototypes where you just want something working quickly
- Learning basic concepts before moving to better patterns
MVP: The Restaurant with a Waiter
Now imagine a real restaurant. You have:
- Model = The kitchen (prepares food, manages recipes)
- View = The dining room (customers see and interact here)
- Presenter = The waiter (takes orders from customers, tells the kitchen what to make, brings food back)
The waiter (Presenter) keeps the kitchen and dining room separate. The waiter is the only bridge between them.
How MVP Improves Things
- The kitchen doesn't care about the dining room—it just takes orders
- The dining room doesn't care how food is made—it just sees the results
- The waiter can be replaced without breaking anything
MVP in Practice
The View tells the Presenter about user actions (button taps, text input). The Presenter asks the Model for data, processes it, and tells the View what to display. The View never talks to the Model directly.
💡 Tip: MVP makes testing easier. You can test the Presenter logic without needing a real UI.
The Trade-off
MVP requires more boilerplate code. You have to wire everything together explicitly. The Presenter can become thick if you're not careful about what logic lives there.
MVVM: The Smart Restaurant Display Board
Now imagine your restaurant installed a magical display board in the kitchen that automatically shows:
- How many orders are waiting
- Which dishes are ready
- Real-time inventory
The board updates automatically whenever something changes in the kitchen.
- Model = The kitchen (recipes, inventory, order history)
- View = The dining room (customers see and interact)
- ViewModel = The display board (always shows the latest kitchen state)
The Magic: Data Binding
The key difference is data binding. When the Model changes, the ViewModel automatically notices and tells the View to update. This creates a one-way flow: Model → ViewModel → View.
[Model State] → [ViewModel Logic] → [Automatic UI Update]
Why MVVM is Popular
- Reactive - Changes flow automatically through your app
- Testable - Test the ViewModel without the View
- Separation - View knows how to display things, ViewModel knows business logic
- Less boilerplate than MVP because of automatic binding
📝 Note: MVVM is especially popular on Android with LiveData and on iOS with Combine framework. If you see "reactive programming," you're usually looking at MVVM.
Code Example: Simple Counter
Here's a counter app in MVVM—same logic in both platforms:
| Kotlin (Android) | Swift (iOS) |
|---|---|
class CounterViewModel : ViewModel() {<br/> private val _count = MutableLiveData(0)<br/> val count: LiveData<Int> = _count<br/><br/> fun increment() {<br/> _count.value = (_count.value ?: 0) + 1<br/> }<br/>`` }` |
class CounterViewModel: ObservableObject {<br/> @Published var count = 0<br/><br/> func increment() {<br/> count += 1<br/> }<br/>} |
In UI (Jetpack Compose):<br/>@Composable<br/>fun CounterScreen(<br/> vm: CounterViewModel<br/>){<br/> val count by vm.count.observeAsState(0)<br/> Button(onClick = { vm.increment() })<br/>} |
In UI (SwiftUI):<br/>struct CounterView: View {<br/> @StateObject var vm = CounterViewModel()<br/><br/> var body: some View {<br/> Button(action: { vm.increment() }) {<br/> Text("\(vm.count)")<br/> }<br/> }<br/>} |
Notice how the UI automatically reflects changes? That's MVVM in action.
Clean Architecture: The Corporate Office
Picture a large corporation:
- UI Layer = Marketing department (presents information to customers)
- Presentation Layer = Middle management (handles presentations, coordinates between teams)
- Domain Layer = Leadership (business rules: "we only sell to companies with 10+ employees")
- Data Layer = Logistics department (gets data from warehouse, APIs, databases)
Each layer has a specific job and only talks to the layer directly below it. The CEO doesn't talk to the delivery driver directly—there are channels.
The Core Principle: Independence
With Clean Architecture, your business logic doesn't depend on your database choice. You could swap SQLite for Realm without changing a line of business logic. You could change your UI framework without affecting your network code.
UI Layer
↓
Presentation Layer
↓
Domain Layer (Business Rules - independent!)
↓
Data Layer
In Mobile Terms
| Layer | Android | iOS | Responsibility |
|---|---|---|---|
| UI | Activities, Fragments, Composables | ViewControllers, SwiftUI Views | Display data, capture user input |
| Presentation | ViewModels | ViewModels | UI logic, state management |
| Domain | Use Cases | Use Cases | Business rules, decision-making |
| Data | Repositories, DAOs | Repositories | Fetch/store from all sources |
When to Use Clean Architecture
- Large projects where different teams work on different parts
- Long-lived apps that need to survive technology changes
- Code-heavy business logic that needs to be tested thoroughly
⚠️ Watch out: Clean Architecture introduces complexity. For a small app, it's overkill and makes everything slower to build.
Key Principles Behind All These Patterns
Separation of Concerns
Each class should have one reason to change. If your ViewController displays a list AND fetches data AND formats dates, that's too much.
Single Source of Truth
When data changes, it should change in exactly one place. Then everything else updates from that single source. This prevents bugs where you update the UI but forget to update the model, or vice versa.
Unidirectional Data Flow
Data flows in one direction, like a river. Don't create cycles where A updates B which updates A. It becomes impossible to debug.
User taps button → Presenter/ViewModel → Model updates → ViewModel notifies → UI updates
Dependency Injection (DI)
Instead of your ViewController creating its own database, pass it in:
// ❌ Bad: tight coupling
class UserViewModel {
val database = UserDatabase()
}
// ✅ Good: loose coupling via injection
class UserViewModel(val database: UserDatabase)
If you need a test database later, you can easily swap it in without changing UserViewModel.
💡 Tip: Use dependency injection frameworks like Hilt (Android) or SwiftUI's @Environment (iOS) to manage dependencies cleanly.
Choosing Your Architecture: A Decision Guide
Is This Your First App?
Start with MVP or MVVM. They're much clearer than MVC but simpler than Clean Architecture.
Is Your App Growing?
Migrate toward MVVM + Clean Architecture as complexity increases. You'll thank yourself later.
Do You Have Complex Business Logic?
Clean Architecture ensures your business logic can be tested independently of the database or UI framework.
Are You Working in a Team?
Clean Architecture with clear layer boundaries helps everyone understand the codebase.
Is It a Prototype or Throwaway Code?
MVC is fine. Don't over-engineer. You'll throw it away anyway.
Interview Insights: Talking About Architecture
Interviewers want to hear that you think about trade-offs, not just pick a pattern because it's trendy.
What NOT to Say
❌ "I always use MVVM" ❌ "Clean Architecture is the best" ❌ "I don't really think about architecture"
What TO Say
✅ "I'd start with MVVM because the project is medium-sized, data binding makes reactive updates easy, and it's well-supported by frameworks. If the business logic grows complex, I'd refactor toward Clean Architecture to isolate that logic."
✅ "For this feature, I chose MVP because the View and Presenter can be tested separately without heavy mocking, and the team already uses it elsewhere."
✅ "I considered Clean Architecture but decided against it here because the complexity isn't justified yet. As the code grows, we can refactor."
Questions They Might Ask
- "How would you test this ViewModel?" → Show a unit test that doesn't require the UI
- "Why not use Clean Architecture?" → Explain the trade-off: more code, more files, but clearer separation
- "How would you handle dependency injection here?" → Show how you'd pass dependencies rather than creating them inside the class
- "What if the API response format changed?" → Explain that the business logic wouldn't change, only the data mapping layer
Quick Reference: Pattern Comparison
| Pattern | Learning Curve | Testability | Boilerplate | Best For |
|---|---|---|---|---|
| MVC | Easy | Poor | Low | Tiny apps, learning |
| MVP | Moderate | Good | Moderate | Small-to-medium apps |
| MVVM | Moderate | Good | Low | Modern apps with data binding |
| Clean | Hard | Excellent | High | Large teams, complex logic |
Key Takeaways
- Architecture is about managing complexity as your app grows
- No pattern is perfect—they're all trade-offs
- MVVM is the modern standard for most mobile apps
- Choose based on your app's needs, not trends
- Clean Architecture wins for team projects with complex business logic
- Be ready to explain why you chose a pattern in interviews
Choose your foundation wisely, and your app will be much easier to maintain, test, and scale.
How Data Flows in Each Pattern: The Complete Picture
Understanding data flow is the difference between thinking you understand a pattern and actually understanding it. Let's trace how information moves through each architecture, from user action to screen update.
MVC Data Flow: Single-Path Communication
User taps button
↓
Controller
(Receives tap)
↓
Model
(Updates state: count++)
↓
Controller
(Told model changed)
↓
View
(Re-reads model: "count is 5")
↓
Screen updates
(Shows "5")
The Problem: Controller handles both incoming events and outgoing updates. It's responsible for:
- Detecting the tap
- Deciding what to do with it
- Asking the model to change
- Telling the view to update
- Formatting data for display
One mistake in any step breaks everything. The controller becomes a bottleneck.
⚠️ Watch out: With MVC, data can flow from Model → Controller → View AND Controller can read Model directly. This dual path creates confusion about which data is current.
MVP Data Flow: The Mediator
User taps button
↓
View
(Detects tap)
↓
Presenter
(Receives tap event)
↓
Model
(Updates: count++)
↓
Presenter
(Reads updated model)
↓
View
(Presenter tells it: "Display 5")
↓
Screen updates
Key difference: The View NEVER talks to the Model. It only talks to the Presenter. The Presenter acts as a strict intermediary:
- View: "User tapped the button"
- Presenter: reads the model, does calculations
- Presenter → View: "Display this text"
This creates a clear contract. The View is "dumb"—it does exactly what the Presenter tells it to do.
MVVM Data Flow: Automatic Binding
User taps button
↓
View
(Detects tap, calls viewModel.increment())
↓
ViewModel
(_count.value = 5)
↓
Data binding system
(Notices _count changed)
↓
View
(Automatically re-reads _count from ViewModel)
↓
Screen updates
(Shows "5" - NO explicit call needed!)
The Magic: The View doesn't ask for updates. The ViewModel announces changes, and the View listens. This is reactive programming.
The View has an observer attached:
val count by vm.count.observeAsState(0) // Watches for changes
// Whenever count changes, recompose and update screen automatically
One-way flow: Model → ViewModel → View. Never backwards. The View can't accidentally bypass the ViewModel and read the Model.
Clean Architecture Data Flow: Multi-Layer Orchestration
User taps button on UI
↓
UI Layer
(Captures tap, passes to Presentation)
↓
Presentation Layer (ViewModel)
(Tap event received, decides what Use Case to call)
↓
Domain Layer (Use Case)
(Business logic: validate input, apply rules)
↓
Data Layer (Repository)
(Fetch from database or API)
↓
Model object is created
↓
Data Layer returns to Domain
↓
Domain returns to Presentation
↓
Presentation sends Observable/LiveData update to UI
↓
UI Layer recomposes
↓
Screen updates
Multi-step journey: Each layer receives a request, processes it, and passes it down. Each layer receives a response and passes it up. No layer talks across (UI can't call Data Layer directly).
This makes the flow predictable but adds latency—more steps = more moving parts. However, it gives you isolation. You can test Domain logic without touching UI or Data layer.
💡 Tip: Clean Architecture's main benefit is independence. You could swap Room for Firebase without touching a single line in the Domain or UI layers.
The Dependency Rule: Why Layers Matter
Clean Architecture has one core rule: Inner layers don't know about outer layers.
┌─────────────────────────┐
│ UI Layer │
│ (Android, iOS, Web) │
└────────────┬────────────┘
↓
┌─────────────────────────┐
│ Presentation Layer │
│ (ViewModels) │
└────────────┬────────────┘
↓
┌─────────────────────────┐
│ Domain Layer │
│ (Use Cases, Entities) │
└────────────┬────────────┘
↓
┌─────────────────────────┐
│ Data Layer │
│ (Repositories) │
└─────────────────────────┘
The rule: Arrows point inward only. The Domain layer doesn't know that the Data layer uses Room. The UI layer doesn't know that the Presentation layer uses Kotlin Coroutines.
Why This Matters: A Concrete Example
Scenario: You need to switch from REST API to gRPC.
Without clean architecture (tightly coupled):
// ❌ Bad: Business logic knows about the API format
class UserViewModel {
fun getUser(id: Int) {
val json = api.getJsonUser(id) // Business logic tied to JSON format
val user = parseJson(json)
_userData.value = user
}
}
With clean architecture (loose coupling):
// ✅ Good: Use Case doesn't care about API format
class GetUserUseCase(val repository: UserRepository) {
suspend fun execute(id: Int): User {
return repository.getUser(id) // Repository is abstract
}
}
// Repository implementation can change
interface UserRepository {
suspend fun getUser(id: Int): User
}
// Today: REST
class RestUserRepository : UserRepository {
override suspend fun getUser(id: Int): User {
return api.getJsonUser(id) // REST details here
}
}
// Tomorrow: gRPC (no changes to Use Case or ViewModel!)
class GrpcUserRepository : UserRepository {
override suspend fun getUser(id: Int): User {
return api.getGrpcUser(id) // gRPC details here
}
}
The Use Case never changes. It just uses the Repository interface. Implementation details are hidden. This is the power of the dependency rule.
State Management Theory: Why Mutable Shared State is Dangerous
Every architecture pattern ultimately addresses one fundamental problem: managing state correctly.
State is data that changes over time. In a chat app:
- The message list is state (messages are added, deleted, edited)
- The current user's typing indicator is state
- Whether notifications are enabled is state
The challenge: When multiple parts of your code can modify the same state, chaos ensues.
The Mutable Shared State Problem
Imagine two developers working on user profile data:
// ❌ BAD: Shared mutable state
object UserManager {
var currentUser: User = User() // Mutable, shared
}
// Developer A's code
fun updateUserName(name: String) {
UserManager.currentUser.name = name
database.saveUser(UserManager.currentUser)
}
// Developer B's code
fun loadUserFromNetwork() {
val user = api.getUser()
UserManager.currentUser = user // Overwrites without saving!
}
What happens?
- User loads from network →
UserManager.currentUser= {id: 1, name: "Alice"} - User changes their name to "Bob" →
updateUserNameruns, saves to database - Developers B's code runs (maybe loading profile again) → overwrites with old "Alice" from network
- User sees their change reverted! Bug!
Nobody meant for this to happen. Both pieces of code did reasonable things independently, but together they broke.
Unidirectional Data Flow: The Solution
Instead of multiple sources mutating the same data, create a single source of truth and a one-way flow:
User Action (tap button)
↓
State Change (increment count from 4 to 5)
↓
UI Re-render (show 5)
Never:
┌─────────────────┐
↓ │
View ←→ ViewModel ←→ Model // Bidirectional mutation = chaos
↑ │
└─────────────────┘
Immutable State: The Key to Reasoning
// ✅ GOOD: Immutable data classes
data class User(val id: Int, val name: String)
fun updateUserName(user: User, newName: String): User {
return user.copy(name = newName) // Creates NEW object, doesn't mutate
}
// Before: User(id=1, name="Alice")
// After: User(id=1, name="Bob")
// Original object unchanged!
Benefits of immutability:
- Thread-safe by default - No race conditions if multiple threads read the old object
- Time-travel debugging - You can see every version of state
- Easy undo/redo - Just keep the previous objects
- Predictable - If you pass an object to a function, it won't change behind your back
- Easier testing - No hidden side effects
The Problem with Shared Mutable State in a Diagram
Multiple Components Reading/Writing to One Object
┌─────────────────────────────────────────────────┐
│ Shared Mutable User Object │
│ { id: 1, name: "Alice", email: "..." } │
└─────────────────────────────────────────────────┘
↑ Component A writes ↑ Component B writes
│ │
┌─────┴─────┐ ┌──────┴──────┐
│ ProfileUI │ │ SyncService │
└───────────┘ └─────────────┘
↓ Component C reads ↓ Component D reads
│
What value does Component C see? Nobody knows!
With unidirectional data flow:
Immutable User Objects Flow in One Direction
View Input (tap)
↓
ViewModel Processes
↓
New Immutable State Created
↓
View Reads (no mutations possible)
↓
UI Updates
Every step is predictable. Every component receives new data, never mutates it.
💡 Tip: This is why Redux, MobX, and Flux patterns became popular. They enforce unidirectional flow. Modern frameworks like Jetpack Compose and SwiftUI are designed around immutability.
Code Example: MVVM with Immutable State
// ✅ GOOD: Immutable ViewModel state
class CounterViewModel : ViewModel() {
private val _state = MutableLiveData(CounterState(count = 0))
val state: LiveData<CounterState> = _state
data class CounterState(val count: Int, val isLoading: Boolean = false)
fun increment() {
val currentState = _state.value ?: return
_state.value = currentState.copy(count = currentState.count + 1)
// New object created, old one untouched
// View always receives latest state, never stale data
}
}
// View consumes
val state by vm.state.observeAsState(CounterViewModel.CounterState())
Text("Count: ${state.count}") // Always sees current state
// ✅ GOOD: Immutable SwiftUI state
struct CounterState {
var count: Int = 0
var isLoading: Bool = false
}
class CounterViewModel: ObservableObject {
@Published var state = CounterState()
func increment() {
var newState = state
newState.count += 1
state = newState // Publish new state, old one unchanged
}
}
// View consumes
Text("Count: \(viewModel.state.count)")
Every time increment() is called, a new CounterState object is created. The old one is discarded. The UI always sees the latest version. No mutations. No surprises.
MVVM Deep Dive: Complete Example with ViewModel
Here's a realistic MVVM implementation showing proper data flow and state management:
Kotlin/Android Example
// Model: Plain data
data class User(
val id: Int,
val name: String,
val email: String
)
// ViewModel: Business logic and state
class UserViewModel(private val repository: UserRepository) : ViewModel() {
// Immutable state
private val _userState = MutableLiveData<UserState>()
val userState: LiveData<UserState> = _userState
sealed class UserState {
object Loading : UserState()
data class Success(val user: User) : UserState()
data class Error(val message: String) : UserState()
}
fun loadUser(userId: Int) {
_userState.value = UserState.Loading
viewModelScope.launch {
try {
val user = repository.getUser(userId)
_userState.value = UserState.Success(user)
} catch (e: Exception) {
_userState.value = UserState.Error(e.message ?: "Unknown error")
}
}
}
fun updateUserName(newName: String) {
val current = _userState.value as? UserState.Success ?: return
val updatedUser = current.user.copy(name = newName)
_userState.value = UserState.Success(updatedUser)
viewModelScope.launch {
try {
repository.updateUser(updatedUser)
} catch (e: Exception) {
// Revert on error
_userState.value = UserState.Success(current.user)
}
}
}
}
// View: Observes ViewModel state
@Composable
fun UserScreen(viewModel: UserViewModel) {
val state by viewModel.userState.observeAsState(UserViewModel.UserState.Loading)
when (state) {
is UserViewModel.UserState.Loading -> {
CircularProgressIndicator()
}
is UserViewModel.UserState.Success -> {
val user = (state as UserViewModel.UserState.Success).user
Column {
Text("Name: ${user.name}")
Button(onClick = { viewModel.updateUserName("Charlie") }) {
Text("Change Name")
}
}
}
is UserViewModel.UserState.Error -> {
Text("Error: ${(state as UserViewModel.UserState.Error).message}")
}
}
}
Data flow:
- View calls
viewModel.loadUser(42) - ViewModel sets state to Loading
- View sees Loading, shows spinner
- ViewModel fetches from repository
- ViewModel sets state to Success
- View sees Success, automatically recomposes, shows user data
- User taps "Change Name"
- View calls
viewModel.updateUserName("Charlie") - ViewModel creates new state with updated name
- View observes change, recomposes, shows new name
No manual updates, no missed syncs, one way flow.
Swift/iOS Equivalent
// Model
struct User {
let id: Int
let name: String
let email: String
}
// ViewModel
class UserViewModel: ObservableObject {
enum UserState {
case loading
case success(user: User)
case error(message: String)
}
@Published var state: UserState = .loading
private let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func loadUser(userId: Int) {
state = .loading
Task {
do {
let user = try await repository.getUser(userId)
self.state = .success(user: user)
} catch {
self.state = .error(message: error.localizedDescription)
}
}
}
func updateUserName(_ newName: String) {
guard case .success(var user) = state else { return }
let oldUser = user
user.name = newName
state = .success(user: user)
Task {
do {
try await repository.updateUser(user)
} catch {
state = .success(user: oldUser) // Revert on error
}
}
}
}
// View
struct UserView: View {
@StateObject var viewModel: UserViewModel
var body: some View {
switch viewModel.state {
case .loading:
ProgressView()
case .success(let user):
VStack {
Text("Name: \(user.name)")
Button("Change Name") {
viewModel.updateUserName("Charlie")
}
}
case .error(let message):
Text("Error: \(message)")
}
}
.onAppear {
viewModel.loadUser(42)
}
}
The pattern is identical. One source of truth in the ViewModel. View observes changes. All mutations create new objects. Predictable, testable, maintainable.