GraphQL with Apollo Kotlin
Schema-first API development with normalized caching and real-time subscriptions
Open interactive version (quiz + challenge)Real-world analogy
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
- GraphQL basics: queries, mutations, subscriptions — Queries read data (GET equivalent), mutations write data (POST/PUT/DELETE equivalent), subscriptions push real-time updates via WebSocket. Unlike REST, all requests go to a single endpoint. The client specifies exactly which fields to return, solving over-fetching and under-fetching problems.
- Apollo Kotlin client setup — Add the Apollo Gradle plugin and runtime dependency. The plugin downloads your schema.graphqls, and you write .graphql query files. At build time, Apollo generates type-safe Kotlin data classes for every query, mutation, and fragment. Call apolloClient.query(MyQuery()).execute() to fetch data.
- Schema-first development & codegen — Download the schema using Apollo CLI or Gradle task (./gradlew downloadApolloSchema). Write .graphql files alongside your code. Apollo codegen generates: query/mutation data classes, input types, enum types, and fragment classes. The generated code is type-safe — schema mismatches are caught at compile time, not runtime.
- Normalized cache & cache policies — Apollo's normalized cache breaks responses into individual objects keyed by __typename + id. When two queries return the same User, both read from the same cache entry. Cache policies: CacheOnly, NetworkOnly, CacheFirst, NetworkFirst, CacheAndNetwork. CacheFirst checks cache before network — ideal for reducing API calls.
- Optimistic UI updates — For mutations, provide optimistic data that immediately updates the UI before the server responds. If the mutation fails, the cache rolls back to the previous state. Use .optimisticUpdates(MyMutation.Data(...)) on the mutation call. Critical for responsive UIs in social apps (likes, follows, etc.).
- Error handling & partial data — GraphQL can return partial data with errors — the errors array accompanies whatever data could be resolved. Apollo models this as ApolloResponse containing both data (nullable) and errors (list). Handle gracefully: show available data and display errors for failed fields. This is fundamentally different from REST's all-or-nothing responses.
- Pagination with GraphQL — Cursor-based pagination uses endCursor/hasNextPage and is preferred for infinite lists (stable across insertions). Offset-based pagination uses offset/limit and is simpler but breaks when items are inserted mid-page. Apollo supports both via @typePolicy and custom merge functions in the normalized cache for seamless list appending.
- Apollo vs Retrofit+REST comparison — Apollo advantages: type-safe queries generated from schema, precise field selection (no over-fetching), normalized caching built-in, real-time subscriptions. REST advantages: simpler caching (HTTP cache headers), broader backend support, easier file uploads, better tooling ecosystem. Use GraphQL for complex data graphs; REST for simple CRUD or when the backend mandates it.
- Fragments for reusable field sets — GraphQL fragments define reusable field selections. fragment UserFields on User { id name avatar } can be spread into multiple queries with ...UserFields. Apollo generates a reusable Kotlin class for each fragment. Fragments reduce duplication and ensure consistency across queries that fetch the same entity type.
- Watchers for reactive cache updates — apolloClient.query(MyQuery()).watch() returns a Flow that emits whenever the normalized cache changes for that query's data. This enables reactive UIs — when a mutation updates a User in cache, all watchers that include that User automatically re-emit. Pairs naturally with Compose's collectAsState().
- Custom scalars & type adapters — GraphQL schemas define custom scalars (DateTime, URL, JSON). Apollo requires type adapters to map them to Kotlin types. Register adapters via apollo { customScalarsMapping.set(mapOf('DateTime' to 'java.time.Instant')) } in build.gradle, then implement the adapter to serialize/deserialize. Without adapters, custom scalars default to Any.
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. 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. mapScalar('DateTime', 'java.time.Instant', ...) — maps the GraphQL custom scalar DateTime to Kotlin's Instant type with a custom adapter for serialization
- 3. fragment UserFields on User { ... } — defines reusable field selections; Apollo generates a UserFields Kotlin class that can be used across queries
- 4. SqlNormalizedCacheFactory('apollo.db') — creates a persistent SQLite-backed normalized cache; survives app restarts unlike the in-memory cache
- 5. addHttpHeader('Authorization', ...) — adds auth headers to every request; for dynamic tokens use an HttpInterceptor instead
- 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. .watch() — returns a Flow that re-emits data whenever the normalized cache entries for this query change; enables reactive UI updates across screens
- 8. .optimisticUpdates(optimisticData) — immediately applies the provided data to the cache before the network response; rolls back automatically on failure
- 9. GetUserPostsQuery(userId, first = 10, after = cursor) — cursor-based pagination; 'after' is the cursor of the last item; 'first' limits the page size
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Apollo Kotlin Documentation (apollographql.com)
- GraphQL Introduction (graphql.org)
- Apollo Normalized Cache Guide (apollographql.com)
- Apollo Pagination Guide (apollographql.com)
- GraphQL Cursor Connections Spec (relay.dev)