AlgoInsight

Free lesson · Mobile System Design Interviews

Social Feed App - High-Level Architecture

Medium12 min readmobilemobile-system-designinterview-prep

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:

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?

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:

  1. User opens FeedScreen

    • Screen is created with FeedViewModel
  2. FeedViewModel asks Repository: "Get me the feed"

    viewModel.loadFeed() → repository.getFeed()
    
  3. 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
  4. Repository fetches from API:

    remoteDataSource.getFeed() → network request to "GET /feed"
    
  5. Server responds with 20 posts

  6. Repository saves posts to local database:

    localDataSource.saveFeed(response)  // SQLite gets updated
    
  7. Repository returns to ViewModel

  8. ViewModel updates UI state:

    _feedState.value = FeedState.Success(posts)
    
  9. FeedScreen renders the posts

    • RecyclerView/LazyColumn displays each post
    • For each post, ImageLoader loads images from CDN
  10. 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:

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:

Push (Fan-Out-On-Write) wins when:

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:

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:

  1. Caching the feed locally (in SQLite)
  2. Fetching fresh feed from server (which has already done the push)
  3. 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:

Without optimistic updates:

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?

  1. Optimistic update: Like button fills immediately
  2. Save to local database: Post is marked as "pending like"
  3. When internet returns: Sync the like to server
  4. 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:

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:

  1. Top section (UI): Three boxes for FeedScreen, PostDetailScreen, CreatePostScreen, each with its ViewModel
  2. 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"
  3. 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:

  1. Load the infinite feed smoothly as the user scrolls?
  2. Cache data smartly?
  3. Keep the app responsive?

Back to Mobile System Design Interviews