Lesson 79 of 83 advanced

GraphQL with Apollo Kotlin

Schema-first API development with normalized caching and real-time subscriptions

Open interactive version (quiz + challenge)

Real-world analogy

REST is like ordering from a fixed menu — you get the whole plate even if you only want the salad. GraphQL is like a build-your-own-bowl restaurant — you specify exactly which ingredients (fields) you want, and the kitchen (server) gives you precisely that. Apollo Kotlin is your personal waiter who remembers your preferences (cache), can take complex orders (queries with fragments), and alerts you when new dishes are ready (subscriptions).

What is it?

Apollo Kotlin is a strongly-typed GraphQL client for Android/Kotlin that generates Kotlin data classes from your GraphQL schema and queries at build time. It provides a normalized cache that intelligently deduplicates data across queries, supports real-time subscriptions over WebSocket, and integrates with Kotlin coroutines and Flows for reactive data fetching. It replaces the traditional Retrofit+REST approach for APIs backed by GraphQL servers.

Real-world relevance

A social media app migrated from REST to GraphQL with Apollo Kotlin to solve the 'profile screen problem' — the profile needed data from 5 different REST endpoints (user, posts, followers, following, settings), causing waterfall requests and over-fetching. With GraphQL, a single query fetches exactly the needed fields. The normalized cache meant that when a user updates their avatar in settings, the profile screen, comment sections, and follower lists all reflect the change instantly through cache watchers. Optimistic updates for likes made the UI feel instant — the heart animates immediately while the mutation fires in the background.

Key points

Code example

// 1. build.gradle.kts setup
plugins {
    id("com.apollographql.apollo3") version "3.8.2"
}

dependencies {
    implementation("com.apollographql.apollo3:apollo-runtime:3.8.2")
    implementation("com.apollographql.apollo3:apollo-normalized-cache-sqlite:3.8.2")
}

apollo {
    service("api") {
        packageName.set("com.example.app.graphql")
        // Custom scalar mapping
        mapScalar("DateTime", "java.time.Instant",
            "com.example.app.graphql.DateTimeAdapter")
    }
}

// 2. GraphQL query file: src/main/graphql/GetUser.graphql
// query GetUser($userId: ID!) {
//     user(id: $userId) {
//         ...UserFields
//         posts(first: 10) {
//             edges {
//                 node {
//                     id
//                     title
//                     createdAt
//                 }
//                 cursor
//             }
//             pageInfo {
//                 hasNextPage
//                 endCursor
//             }
//         }
//     }
// }
//
// fragment UserFields on User {
//     id
//     name
//     avatarUrl
//     followerCount
// }

// 3. Apollo client with normalized cache
object GraphQLClient {

    val apolloClient: ApolloClient by lazy {
        val sqlNormalizedCache = SqlNormalizedCacheFactory("apollo.db")

        ApolloClient.Builder()
            .serverUrl("https://api.example.com/graphql")
            .normalizedCache(sqlNormalizedCache)
            .addHttpHeader("Authorization", "Bearer ${TokenManager.accessToken}")
            .build()
    }
}

// 4. Repository with cache policies
class UserRepository(
    private val apollo: ApolloClient = GraphQLClient.apolloClient
) {

    // Cache-first: show cached data immediately, refresh in background
    fun getUser(userId: String): Flow<UserState> = flow {
        emit(UserState.Loading)

        val response = apollo
            .query(GetUserQuery(userId))
            .fetchPolicy(FetchPolicy.CacheFirst)
            .execute()

        response.data?.user?.let { user ->
            emit(UserState.Success(user.userFields))
        } ?: emit(UserState.Error("User not found"))
    }

    // Watch for cache changes reactively
    fun watchUser(userId: String): Flow<GetUserQuery.User?> {
        return apollo
            .query(GetUserQuery(userId))
            .watch()
            .map { it.data?.user }
    }

    // Mutation with optimistic update
    suspend fun toggleFollow(userId: String, isFollowing: Boolean) {
        val mutation = ToggleFollowMutation(userId)

        val optimisticData = ToggleFollowMutation.Data(
            toggleFollow = ToggleFollowMutation.ToggleFollow(
                id = userId,
                isFollowedByMe = !isFollowing,
                followerCount = if (isFollowing) -1 else 1 // relative
            )
        )

        apollo.mutation(mutation)
            .optimisticUpdates(optimisticData)
            .execute()
    }

    // Cursor-based pagination
    suspend fun loadMorePosts(
        userId: String,
        cursor: String?
    ): PostPage {
        val response = apollo
            .query(GetUserPostsQuery(userId, first = 10, after = cursor))
            .fetchPolicy(FetchPolicy.NetworkOnly)
            .execute()

        val connection = response.data?.user?.posts
        return PostPage(
            posts = connection?.edges?.map { it.node } ?: emptyList(),
            endCursor = connection?.pageInfo?.endCursor,
            hasNextPage = connection?.pageInfo?.hasNextPage ?: false
        )
    }
}

// 5. ViewModel using Apollo
class ProfileViewModel(
    private val userRepo: UserRepository
) : ViewModel() {

    private val _userId = MutableStateFlow<String?>(null)

    val userState: StateFlow<UserState> = _userId
        .filterNotNull()
        .flatMapLatest { userRepo.getUser(it) }
        .stateIn(viewModelScope, SharingStarted.Lazily, UserState.Loading)

    // Reactive cache watcher — auto-updates when cache changes
    val user: StateFlow<GetUserQuery.User?> = _userId
        .filterNotNull()
        .flatMapLatest { userRepo.watchUser(it) }
        .stateIn(viewModelScope, SharingStarted.Lazily, null)

    fun loadUser(id: String) {
        _userId.value = id
    }

    fun toggleFollow(userId: String, isFollowing: Boolean) {
        viewModelScope.launch {
            userRepo.toggleFollow(userId, isFollowing)
        }
    }
}

Line-by-line walkthrough

  1. 1. apollo { service('api') { packageName.set(...) } } — configures the Apollo Gradle plugin to generate code in the specified package; processes all .graphql files in src/main/graphql
  2. 2. mapScalar('DateTime', 'java.time.Instant', ...) — maps the GraphQL custom scalar DateTime to Kotlin's Instant type with a custom adapter for serialization
  3. 3. fragment UserFields on User { ... } — defines reusable field selections; Apollo generates a UserFields Kotlin class that can be used across queries
  4. 4. SqlNormalizedCacheFactory('apollo.db') — creates a persistent SQLite-backed normalized cache; survives app restarts unlike the in-memory cache
  5. 5. addHttpHeader('Authorization', ...) — adds auth headers to every request; for dynamic tokens use an HttpInterceptor instead
  6. 6. .fetchPolicy(FetchPolicy.CacheFirst) — checks the normalized cache first; only hits the network if data is not cached; ideal for reducing redundant API calls
  7. 7. .watch() — returns a Flow that re-emits data whenever the normalized cache entries for this query change; enables reactive UI updates across screens
  8. 8. .optimisticUpdates(optimisticData) — immediately applies the provided data to the cache before the network response; rolls back automatically on failure
  9. 9. GetUserPostsQuery(userId, first = 10, after = cursor) — cursor-based pagination; 'after' is the cursor of the last item; 'first' limits the page size
  10. 10. flatMapLatest { userRepo.watchUser(it) } — switches to the latest user's watcher Flow when userId changes; cancels the previous watcher automatically

Spot the bug

class BookRepository(private val apollo: ApolloClient) {

    suspend fun getBook(bookId: String): Book? {
        val response = apollo
            .query(GetBookQuery(bookId))
            .fetchPolicy(FetchPolicy.CacheOnly)  // Bug 1
            .execute()

        return response.data?.book
    }

    suspend fun addReview(bookId: String, text: String, rating: Int) {
        apollo
            .mutation(AddReviewMutation(bookId, text, rating))
            .execute()
        // Bug 2: No optimistic update, no error handling
    }

    fun watchBooks(): Flow<List<Book>> {
        return apollo
            .query(GetBooksQuery())
            .toFlow()  // Bug 3: not using watch()
            .map { it.data?.books ?: emptyList() }
    }
}
Need a hint?
Three bugs: a cache policy that will never show data on first launch, a mutation without optimistic updates or error handling, and a Flow that does not react to cache changes
Show answer
Bug 1: FetchPolicy.CacheOnly will return null on first launch because the cache is empty — nothing ever fetches from the network. Fix: Use CacheFirst or NetworkFirst so the data is fetched from the network when not cached. Bug 2: The mutation has no optimistic update (UI waits for network response) and no error handling (failures are silently ignored). Fix: Add .optimisticUpdates() for instant UI feedback, and check response.errors or wrap in try/catch. Bug 3: .toFlow() executes the query once and completes. It does NOT re-emit when the cache is updated by mutations. Fix: Use .watch() instead to get a Flow that re-emits whenever the normalized cache changes for this query.

Explain like I'm 5

Imagine you are at a salad bar. With REST, you order a 'Caesar salad' and get everything on it — even croutons you do not want. With GraphQL, you hand the chef a note saying 'I want lettuce, cheese, and dressing — skip the croutons.' The chef gives you exactly what you asked for. Apollo is your smart lunchbox that remembers what you ordered before — if you already have the lettuce from your last order, it only gets the new stuff from the chef.

Fun fact

Apollo Kotlin was originally called 'Apollo Android' and was written in Java. The complete Kotlin rewrite (Apollo Kotlin 3.x) was one of the most significant migrations in the Android library ecosystem — it took 2 years and introduced a completely new API surface. The normalized cache was inspired by how Facebook's Relay client works internally. Fun fact: a well-configured Apollo cache can reduce API calls by 60-80% in typical social media apps because the same User/Post objects appear across dozens of different screens.

Hands-on challenge

Build a book review app using Apollo Kotlin with: (1) A GraphQL schema defining Book (id, title, author, rating, reviews) and Review (id, text, rating, createdAt) types with queries, mutations, and a subscription. (2) Write the .graphql query files for: GetBooks (paginated with cursor), GetBookDetail (with reviews), AddReview mutation, and OnNewReview subscription. (3) Implement a repository with CacheFirst for book list, NetworkFirst for book detail, and optimistic updates for AddReview. (4) Create a ViewModel that uses watch() to reactively update the book list when a new review changes the average rating in the cache.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery