Free lesson · Mobile System Design Interviews
Social Feed App - High-Level Architecture
Step 3: How Does the Mobile App Actually Organize Itself?
We've defined what the app does (Step 1) and how it talks to the server (Step 2). Now comes Step 3: Architecture. This is where we design the internal structure of the mobile app.
Think of architecture like the blueprint of a house:
- Where does the living room go?
- How do the rooms connect?
- Where's the bathroom?
- Where does the power and water come from?
A bad architecture means the app is messy, hard to change, and crashes easily. A good architecture means the app is organized, easy to test, and can grow.
The Big Picture: What Components Does Our App Have?
Here's the architecture at 50,000 feet:
┌─────────────────────────────────────────────────────────┐
│ MOBILE DEVICE │
│ ┌─────────────────────────────────────────────────────┐│
│ │ UI LAYER (Screens) ││
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐││
│ │ │ Feed Screen │ │ Post Detail │ │ Create Post │││
│ │ │ ViewModel │ │ ViewModel │ │ ViewModel │││
│ │ └──────────────┘ └──────────────┘ └──────────────┘││
│ │ │ │ │ ││
│ │ └─────────────────┼───────────────┘ ││
│ │ │ ││
│ │ ┌───────────────────────────────────────────────────┐││
│ │ │ DATA LAYER (Repository Pattern) │││
│ │ │ │││
│ │ │ ┌─────────────────────────────────────────┐ │││
│ │ │ │ PostsRepository │ │││
│ │ │ │ (Single source of truth) │ │││
│ │ │ └────────────┬────────────────────────────┘ │││
│ │ │ │ │││
│ │ │ ┌────────────┴──────────────┬──────────────┐ │││
│ │ │ │ │ │ │││
│ │ │ ▼ ▼ ▼ │││
│ │ │ Local DB Remote API Image │││
│ │ │ (SQLite) (Retrofit) Loader │││
│ │ └─────────────────────────────────────────────────┘││
│ │ │ ││
│ └─────────────────────────────┼────────────────────────┘│
│ │ │
│ ┌─────────────────────────────┼────────────────────────┐│
│ │ DEVICE RESOURCES │
│ │ - Memory Cache │
│ │ - Disk Storage (SQLite Database) │
│ │ - Image Cache Folder │
│ └─────────────────────────────┼────────────────────────┘│
│ │ │
│ ▼ │
└─────────────────────────────────────────────────────────┘
│
│ Network calls
▼
┌───────────────────────┐
│ BACKEND SERVER │
│ - REST API │
│ - Database │
└───────────────────────┘
│
▼
┌───────────────────────┐
│ CLOUD STORAGE │
│ - Image CDN (S3) │
└───────────────────────┘
Let's break this down into layers.
Layer 1: UI Layer (Screens & ViewModels)
The UI layer is what users see and touch. It's built on the MVVM (Model-View-ViewModel) pattern.
FeedScreen (Kotlin Example)
// The View (what user sees)
@Composable
fun FeedScreen(viewModel: FeedViewModel = hiltViewModel()) {
val feedState by viewModel.feedState.collectAsState()
LazyColumn(
state = rememberLazyListState(),
modifier = Modifier.fillMaxSize()
) {
when (feedState) {
is FeedState.Loading -> {
item {
CircularProgressIndicator()
}
}
is FeedState.Success -> {
val posts = (feedState as FeedState.Success).posts
items(posts) { post ->
PostCard(post) { action ->
viewModel.onPostAction(post.id, action)
}
}
// When user reaches bottom, load more
if (/* user near end */) {
viewModel.loadMore()
}
}
is FeedState.Error -> {
item {
Text("Error loading feed: ${(feedState as FeedState.Error).message}")
}
}
}
}
}
// The ViewModel (business logic)
class FeedViewModel @Inject constructor(
private val postsRepository: PostsRepository
) : ViewModel() {
private val _feedState = MutableStateFlow<FeedState>(FeedState.Loading)
val feedState = _feedState.asStateFlow()
init {
loadFeed()
}
fun loadFeed() {
viewModelScope.launch {
_feedState.value = FeedState.Loading
try {
val feed = postsRepository.getFeed()
_feedState.value = FeedState.Success(feed.posts)
} catch (e: Exception) {
_feedState.value = FeedState.Error(e.message ?: "Unknown error")
}
}
}
fun loadMore() {
// More logic in the deep dive
}
fun onPostAction(postId: String, action: PostAction) {
when (action) {
is PostAction.Like -> {
viewModelScope.launch {
postsRepository.likePost(postId)
}
}
is PostAction.Comment -> {
// Navigate to comment screen
}
}
}
}
// State machine (what the UI could be in)
sealed class FeedState {
object Loading : FeedState()
data class Success(val posts: List<Post>) : FeedState()
data class Error(val message: String) : FeedState()
}
Same in Swift
class FeedViewModel: ObservableObject {
@Published var feedState: FeedState = .loading
private let postsRepository: PostsRepository
init(postsRepository: PostsRepository) {
self.postsRepository = postsRepository
loadFeed()
}
func loadFeed() {
Task {
do {
let feed = try await postsRepository.getFeed()
DispatchQueue.main.async {
self.feedState = .success(feed.posts)
}
} catch {
DispatchQueue.main.async {
self.feedState = .error(error.localizedDescription)
}
}
}
}
}
enum FeedState {
case loading
case success([Post])
case error(String)
}
What does MVVM give us?
- ✓ Testable: We can test the ViewModel without UI
- ✓ Reusable: Multiple screens can share the same ViewModel logic
- ✓ Reactive: When data changes, UI automatically updates
Layer 2: Data Layer (The Repository Pattern)
The repository is the single source of truth for all post data. It decides whether to return cached data or fetch from the network.
PostsRepository
class PostsRepository @Inject constructor(
private val localDataSource: FeedLocalDataSource,
private val remoteDataSource: FeedRemoteDataSource,
private val imageLoader: ImageLoader
) {
// This is the main function ViewModels call
suspend fun getFeed(cursor: String? = null): FeedResponse {
// Step 1: Check if we have data locally
val cachedFeed = localDataSource.getFeed(cursor)
// Step 2: If it's fresh and recent, return it immediately
if (cachedFeed != null && !isCacheStale(cachedFeed)) {
return cachedFeed
}
// Step 3: Fetch fresh data from the server
try {
val freshFeed = remoteDataSource.getFeed(cursor)
// Step 4: Save to local database (update cache)
localDataSource.saveFeed(freshFeed)
// Step 5: Return the fresh data
return freshFeed
} catch (e: Exception) {
// Step 6: If network fails but we have cached data, return that
if (cachedFeed != null) {
return cachedFeed
}
// If no cache, throw the error to the UI
throw e
}
}
suspend fun likePost(postId: String) {
// Step 1: Update local database immediately (optimistic update)
localDataSource.likePost(postId)
// Step 2: Send request to server
try {
remoteDataSource.likePost(postId)
} catch (e: Exception) {
// Step 3: If server rejects, revert the local change
localDataSource.unlikePost(postId)
throw e
}
}
}
Key insight: The Repository is the gatekeeper. All data flows through here. If we want to change caching strategy, we change the Repository, and the rest of the app doesn't care.
Layer 3: Remote Data Source (Network)
This talks to the API we designed in Step 2.
class FeedRemoteDataSource @Inject constructor(
private val api: SocialFeedApi // Retrofit or similar
) {
suspend fun getFeed(cursor: String?): FeedResponse {
return api.getFeed(
cursor = cursor,
limit = 20
)
}
suspend fun likePost(postId: String) {
api.likePost(postId)
}
}
Layer 4: Local Data Source (Database)
This talks to a local SQLite database on the device.
class FeedLocalDataSource @Inject constructor(
private val postDao: PostDao, // Database access object
private val preferences: SharedPreferences
) {
suspend fun getFeed(cursor: String?): FeedResponse? {
val posts = postDao.getAllPosts() // Fetch from SQLite
if (posts.isEmpty()) return null
return FeedResponse(
posts = posts,
nextCursor = preferences.getString("last_cursor", null),
hasMore = preferences.getBoolean("has_more", true)
)
}
suspend fun saveFeed(feed: FeedResponse) {
postDao.insertPosts(feed.posts)
preferences.edit().apply {
putString("last_cursor", feed.nextCursor)
putBoolean("has_more", feed.hasMore)
}.apply()
}
suspend fun likePost(postId: String) {
postDao.updateLike(postId, isLiked = true)
}
}
Layer 5: Image Loading
Loading images efficiently is its own component.
class ImageLoader @Inject constructor(
private val imageCache: LruCache<String, Bitmap>,
private val diskCache: File, // /app/cache/images/
private val httpClient: OkHttpClient
) {
suspend fun loadImage(url: String): Bitmap {
// Check memory cache first
imageCache.get(url)?.let { return it }
// Check disk cache
val file = File(diskCache, hashFileName(url))
if (file.exists()) {
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
imageCache.put(url, bitmap) // Add back to memory
return bitmap
}
// Download from network
val bitmap = downloadImage(url)
// Save to memory and disk
imageCache.put(url, bitmap)
saveToDisk(bitmap, file)
return bitmap
}
private suspend fun downloadImage(url: String): Bitmap {
val response = httpClient.newCall(
Request.Builder().url(url).build()
).execute()
return BitmapFactory.decodeStream(response.body!!.byteStream())
}
}
Data Flow: Step-by-Step Example
Let's trace what happens when a user opens the app:
-
User opens FeedScreen
- Screen is created with FeedViewModel
-
FeedViewModel asks Repository: "Get me the feed"
viewModel.loadFeed() → repository.getFeed() -
Repository checks local database:
- Is there data? Is it fresh (less than 5 minutes old)?
- If yes: return immediately to ViewModel
- If no: go to step 4
-
Repository fetches from API:
remoteDataSource.getFeed() → network request to "GET /feed" -
Server responds with 20 posts
-
Repository saves posts to local database:
localDataSource.saveFeed(response) // SQLite gets updated -
Repository returns to ViewModel
-
ViewModel updates UI state:
_feedState.value = FeedState.Success(posts) -
FeedScreen renders the posts
- RecyclerView/LazyColumn displays each post
- For each post, ImageLoader loads images from CDN
-
User scrolls down
- ViewModel detects scroll near end
- Calls
repository.getFeed(cursor = "nextCursor") - Loop back to step 3
Single Source of Truth
This is critical: The local database is the boss, not the API response.
Here's why:
- User is offline? They see data from the local database.
- User goes online and new posts arrive? Local database updates first, then UI reads from it.
- Two screens both need the same post? They both read from the database. Guaranteed to be the same.
This prevents bugs where different screens show different data for the same post.
Theory: Why Feeds Use Pull vs Push
This is a fundamental design decision that affects every layer of your architecture. Let me explain.
The Two Approaches: Pull vs Push
Pull (Fan-Out-On-Read): When Alice's phone requests the feed, the server looks at who Alice follows, then fetches posts from all those people's feeds, and combines them. This happens at read time.
Alice opens feed
↓
Server: "Who does Alice follow?" (query database)
↓
Server: "Fetch latest posts from all 500 users Alice follows"
↓
Server: Sort posts by timestamp
↓
Return top 20 posts to Alice
Push (Fan-Out-On-Write): When Bob posts something, the server immediately pushes his new post to all 10,000 of his followers' feed caches. When Alice opens the app, her feed is already pre-computed.
Bob submits post
↓
Server: "Who follows Bob?" (query database)
↓
Server: "Insert this post into the feed cache for each of 10,000 followers"
↓
Later: Alice opens app
↓
Server: "Return cached feed" (instant!)
Trade-Offs: When to Use Each
Pull (Fan-Out-On-Read) wins when:
- ✓ Users have millions of followers (writing to 1M caches is expensive)
- ✓ Most posts have few interactions
- ✓ Storage is limited
Push (Fan-Out-On-Write) wins when:
- ✓ Feed reads are much more common than posts (100 reads per 1 write)
- ✓ Users have modest follower counts (< 100K)
- ✓ You want instant updates
Real-world example: Twitter uses both. Regular users get push (write to follower caches). Celebrities with 50M followers use pull (too many writes). Twitter detects this and switches strategies per user.
Why We Chose Push (Social Feed Case Study)
In our social feed app, we assume:
- Average user follows 200-500 people
- Millions of reads per day
- Users expect fresh feeds instantly
So we use fan-out-on-write: When a user posts, we push to their followers' caches immediately.
Implementation cost:
Write cost: Insert post into 500 follower caches (expensive at write time)
Read cost: Fetch pre-computed feed from cache (instant at read time)
Diagram:
┌──────────────────┐
│ Bob posts "Hi" │
└────────┬─────────┘
│
┌────▼────┐
│ Server │ Query: Who follows Bob?
│ Database│ Answer: 10,000 users
└────┬────┘
│
┌────▼──────────────────────────┐
│ For each of 10K followers: │
│ INSERT into feed_cache │
│ WHERE user_id = follower_id │
└────┬──────────────────────────┘
│
├─→ Alice's cache: + "Bob's post"
├─→ Carol's cache: + "Bob's post"
└─→ ... 9,998 more
Later: Alice opens app
│
┌────▼────────────────┐
│ SELECT from cache │
│ WHERE user_id=alice │
│ LIMIT 20 │
└─────────────────────┘
│
└─→ Instant! No computation needed
What About the Mobile App?
Your app implements this by:
- Caching the feed locally (in SQLite)
- Fetching fresh feed from server (which has already done the push)
- Showing posts in order (which the server sorted at write time)
The server does the heavy lifting. Your app just displays what it receives.
💡 Tip: If an interviewer asks "How would you handle millions of followers?", say: "Switch to pull mode. Distribute the computation across followers' devices (each device computes their own feed from raw posts) or use a stream processor (like Kafka) to handle the massive write volume."
Theory: Optimistic Updates
When you like a post, what happens?
Naive approach (pessimistic):
User taps "Like"
↓
Send HTTP request to server
↓
Wait for response (200-500ms)
↓
Server confirms: "Post liked"
↓
Update UI: Show filled heart ❤️
Problem: The user taps, waits, then sees the heart fill. It feels sluggish.
Smart approach (optimistic):
User taps "Like"
↓
Immediately update UI: Show filled heart ❤️
↓
Meanwhile, send HTTP request to server
↓
Server confirms after 200ms
↓
If server says "OK", we're done (user never knew we were waiting)
↓
If server says "Error", revert UI: Show empty heart 🤍
The UI is optimistic - it assumes the server will say yes, then fixes it if wrong.
Why This Matters
With optimistic updates:
- ✓ App feels instant (no waiting for network)
- ✓ User taps, sees feedback immediately
- ✓ If server confirms (99% of the time), perfect UX
- ✓ If server rejects (1% of the time), show error toast
Without optimistic updates:
- ✗ App feels laggy
- ✗ Users think it's broken
- ✗ Higher chance of accidental double-taps (user taps again while waiting)
How to Implement
// ViewModel
fun onLikePost(postId: String) {
// Step 1: Optimistic update
val post = allPosts.find { it.id == postId }!!
val previousLikeState = post.isLikedByMe
// Immediately update the UI
post.isLikedByMe = true
post.likeCount += 1
_feedState.value = FeedState.Success(allPosts.toList())
// Step 2: Send to server
viewModelScope.launch {
try {
postsRepository.likePost(postId)
// Server confirmed - we're done!
} catch (e: Exception) {
// Server rejected - revert
post.isLikedByMe = previousLikeState
post.likeCount -= 1
_feedState.value = FeedState.Success(allPosts.toList())
// Show error toast
_errorMessage.value = "Failed to like post"
}
}
}
In the repository:
// Repository
suspend fun likePost(postId: String) {
// Update local database first
localDataSource.likePost(postId)
// Then send to server
try {
remoteDataSource.likePost(postId)
} catch (e: Exception) {
// Server failed; local already updated
// The UI will revert it
throw e
}
}
Handling Conflicts
What if the user is offline when they like a post?
- Optimistic update: Like button fills immediately
- Save to local database: Post is marked as "pending like"
- When internet returns: Sync the like to server
- If sync fails: Revert in UI, show error
// ViewModel detects internet restored
fun onConnectivityRestored() {
viewModelScope.launch {
val pendingLikes = localDataSource.getPendingLikes()
pendingLikes.forEach { postId ->
try {
remoteDataSource.likePost(postId)
localDataSource.markLikeSynced(postId)
} catch (e: Exception) {
// Sync failed; keep in pending state
_syncError.value = "Some likes failed to sync"
}
}
}
}
🎯 Interviewer's Perspective: This is a real-world technique. Every successful mobile app uses optimistic updates. If you say "Wait for server response", they know you've never shipped a product.
Navigation Architecture
Screens connect through a navigation graph. Typically:
- FeedScreen: Shows the feed. User can tap a post to go to PostDetailScreen.
- PostDetailScreen: Shows one post and its comments. User can write a comment.
- CreatePostScreen: User writes and uploads a new post, then returns to FeedScreen.
NavHost(navController, startDestination = "feed") {
composable("feed") { FeedScreen(
onPostTapped = { postId ->
navController.navigate("post_detail/$postId")
}
)}
composable("post_detail/{postId}") {
PostDetailScreen()
}
composable("create_post") {
CreatePostScreen()
}
}
Architecture Diagram Description
The architecture diagram should show:
- Top section (UI): Three boxes for FeedScreen, PostDetailScreen, CreatePostScreen, each with its ViewModel
- Middle section (Data): A big box labeled "PostsRepository" in the center, with arrows pointing to three sources:
- Left arrow: "Local Data Source (SQLite Database)"
- Right arrow: "Remote Data Source (API)"
- A small component: "Image Loader"
- Bottom section (Device/Network): SQLite database, HTTP client connecting to Backend API and CDN
The key visual: All arrows point to the Repository, making it obvious that's where decisions happen.
⚠️ Watch out: Don't mix layers! ViewModels should never talk directly to the API. Always go through the Repository. This separation makes the code testable and maintainable.
Summary: Why This Architecture?
| Benefit | How We Get It |
|---|---|
| Testable | Repository can be mocked for unit tests |
| Flexible | Want to change caching? Change Repository, not ViewModel |
| Offline-first | Local database is the source of truth |
| Scalable | Adding new screens (like notifications) just adds new ViewModels |
| Maintainable | Clear separation of concerns; each layer has one job |
🎯 Interviewer's Perspective: This architecture pattern (MVVM + Repository) is so common in industry that interviewers expect you to know it. The fact that you separated UI (ViewModel) from data (Repository) shows you understand fundamental design principles.
🔗 Building on Previous: Remember the caching strategies from Module 3? This architecture is how we implement them:
- Memory cache: ImageLoader's LruCache
- Disk cache: SQLite database in LocalDataSource
- Network cache: API responses in RemoteDataSource All three work together in the Repository!
What's Next?
We've designed the overall architecture. Now comes the hard part: the details. How exactly do we:
- Load the infinite feed smoothly as the user scrolls?
- Cache data smartly?
- Keep the app responsive?