GraphQL vs REST
REST exposes fixed endpoints that return fixed shapes. GraphQL exposes a single endpoint where the client specifies exactly what data it needs.
The over-fetching problem
# REST: GET /users/42 returns everything
{
"id": 42,
"name": "Alice",
"email": "alice@example.com",
"bio": "...",
"avatar_url": "...",
"phone": "...", // ← not needed in this screen
"address": {...}, // ← not needed
"preferences": {...} // ← not needed
}
# GraphQL: ask for exactly what you need
query GetUserName {
user(id: 42) {
name
avatarUrl
}
}
# Response contains only name and avatarUrl
The under-fetching problem (N+1 requests)
# REST: to show a post list with author names requires:
GET /posts → 20 posts with author_id fields
GET /users/1 → post 1's author
GET /users/7 → post 2's author
... 18 more requests
# GraphQL: one request for everything
query GetPostsWithAuthors {
posts(first: 20) {
title
createdAt
author {
name
avatarUrl
}
}
}
Core GraphQL Concepts
Query — reading data
# Simple query
query GetProduct($id: ID!) {
product(id: $id) {
id
name
price
inStock
category {
id
name
}
}
}
# Query with multiple fields and aliases
query GetDashboard($userId: ID!) {
user(id: $userId) {
name
email
}
recentOrders: orders(userId: $userId, first: 5) {
id
total
status
}
notifications(userId: $userId, unreadOnly: true) {
count
items {
id
message
}
}
}
Mutation — writing data
mutation CreateOrder($input: CreateOrderInput!) {
createOrder(input: $input) {
id
status
total
estimatedDelivery
}
}
# Variables passed separately (not inline)
# {
# "input": {
# "productIds": ["prod_1", "prod_2"],
# "shippingAddressId": "addr_42"
# }
# }
Subscription — real-time updates
subscription OnOrderStatusChanged($orderId: ID!) {
orderStatusChanged(orderId: $orderId) {
id
status
updatedAt
}
}
Apollo Android Setup
1. Add dependencies and plugin
// build.gradle.kts (project level)
plugins {
id("com.apollographql.apollo3") version "3.8.2" apply false
}
// build.gradle.kts (module level)
plugins {
id("com.apollographql.apollo3")
}
dependencies {
implementation("com.apollographql.apollo3:apollo-runtime:3.8.2")
implementation("com.apollographql.apollo3:apollo-normalized-cache:3.8.2")
// For coroutines support
implementation("com.apollographql.apollo3:apollo-coroutines-support:3.8.2")
}
apollo {
service("example") {
packageName.set("com.example.graphql")
// schema.json or schema.graphqls must be in src/main/graphql/
}
}
2. Download schema
# Using Apollo CLI
npx apollo client:download-schema schema.graphqls \
--endpoint https://api.example.com/graphql \
--header "Authorization: Bearer $TOKEN"
# Or using Apollo Gradle plugin
./gradlew :app:downloadApolloSchema \
--endpoint="https://api.example.com/graphql" \
--schema="src/main/graphql/schema.graphqls"
3. Write .graphql files
Place .graphql files in src/main/graphql/com/example/:
# GetProduct.graphql
query GetProduct($id: ID!) {
product(id: $id) {
id
name
price
inStock
}
}
# CreateOrder.graphql
mutation CreateOrder($input: CreateOrderInput!) {
createOrder(input: $input) {
id
status
total
}
}
Apollo generates type-safe Kotlin classes from these files at build time.
ApolloClient Setup
@Module
@InstallIn(SingletonComponent::class)
object GraphQLModule {
@Provides
@Singleton
fun provideApolloClient(
tokenStore: TokenStore
): ApolloClient {
val memoryCache = MemoryCacheFactory(maxSizeBytes = 10 * 1024 * 1024)
return ApolloClient.Builder()
.serverUrl("https://api.example.com/graphql")
.okHttpClient(
OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header("Authorization", "Bearer ${tokenStore.getAccessToken()}")
.build()
chain.proceed(request)
}
.build()
)
.normalizedCache(memoryCache)
.build()
}
}
Executing Queries
class ProductRepository @Inject constructor(
private val apolloClient: ApolloClient
) {
suspend fun getProduct(id: String): Result<Product> {
return try {
val response = apolloClient
.query(GetProductQuery(id = id))
.execute()
if (response.hasErrors()) {
val error = response.errors?.firstOrNull()
Result.failure(Exception(error?.message ?: "GraphQL error"))
} else {
val data = response.data?.product
?: return Result.failure(Exception("Product not found"))
Result.success(data.toProduct())
}
} catch (e: ApolloException) {
Result.failure(e)
}
}
// Cache policy control
suspend fun getProductFresh(id: String): Result<Product> {
val response = apolloClient
.query(GetProductQuery(id = id))
.fetchPolicy(FetchPolicy.NetworkOnly) // ignore cache
.execute()
// ...
}
suspend fun getProductCached(id: String): Result<Product> {
val response = apolloClient
.query(GetProductQuery(id = id))
.fetchPolicy(FetchPolicy.CacheFirst) // serve cache, fallback to network
.execute()
// ...
}
// Execute mutation
suspend fun createOrder(productIds: List<String>, addressId: String): Result<Order> {
return try {
val input = CreateOrderInput(
productIds = productIds,
shippingAddressId = addressId
)
val response = apolloClient
.mutation(CreateOrderMutation(input = input))
.execute()
if (response.hasErrors()) {
Result.failure(Exception(response.errors?.firstOrNull()?.message))
} else {
val order = response.data?.createOrder
?: return Result.failure(Exception("No order in response"))
Result.success(order.toOrder())
}
} catch (e: ApolloException) {
Result.failure(e)
}
}
}
Normalized Cache
The normalized cache stores each object by its ID, not by query. When the same object appears in multiple queries, it is stored once. Updating it in one place automatically updates all queries that reference it.
val memoryCache = MemoryCacheFactory(maxSizeBytes = 10 * 1024 * 1024)
val diskCache = SqlNormalizedCacheFactory("apollo_cache.db")
// Chain: memory cache → disk cache → network
val combinedCache = memoryCache.chain(diskCache)
val apolloClient = ApolloClient.Builder()
.serverUrl("...")
.normalizedCache(
normalizedCacheFactory = combinedCache,
cacheKeyGenerator = TypePolicyCacheKeyGenerator, // use __typename + id as key
cacheResolver = FieldPolicyCacheResolver
)
.build()
The cache key is typically "TypeName:id" (e.g., "Product:42"). Configure this in your type policies:
// apollo.config.js (or via Gradle)
// Type policies define the key field per type
// Product.id → cache key "Product:42"
Optimistic Updates
Show the result of a mutation immediately, before the server confirms it:
suspend fun toggleLike(postId: String, currentlyLiked: Boolean) {
apolloClient.mutation(
ToggleLikeMutation(postId = postId)
)
.optimisticUpdates(
ToggleLikeMutation.Data(
toggleLike = ToggleLikeMutation.ToggleLike(
id = postId,
liked = !currentlyLiked,
likeCount = if (currentlyLiked) likeCount - 1 else likeCount + 1
)
)
)
.execute()
// If the server returns an error, Apollo automatically rolls back the optimistic update
}
Subscriptions over WebSocket
// Subscription — returns a Flow
fun watchOrderStatus(orderId: String): Flow<OrderStatus> {
return apolloClient
.subscription(OnOrderStatusChangedSubscription(orderId = orderId))
.toFlow()
.filter { !it.hasErrors() }
.mapNotNull { it.data?.orderStatusChanged?.toOrderStatus() }
}
// Usage in ViewModel
fun watchOrder(orderId: String) {
viewModelScope.launch {
repository.watchOrderStatus(orderId)
.catch { e ->
_uiState.update { it.copy(error = e.message) }
}
.collect { status ->
_uiState.update { it.copy(orderStatus = status) }
}
}
}
Apollo automatically manages the WebSocket connection lifecycle and reconnects on disconnect.
When GraphQL Is Worth the Complexity
| GraphQL Wins | REST Wins |
|---|---|
| Mobile clients need different shapes per screen | Simple CRUD with stable, predictable shapes |
| Multiple teams own different parts of the schema | Small team, tight schema control |
| Significant over-fetching bandwidth issues | Low-bandwidth concerns are minimal |
| Real-time requirements (subscriptions) | No real-time needs |
| Strong type safety between frontend and backend | Backend already has well-typed REST contracts |
| Schema stitching / federation needed | Single monolithic backend |
GraphQL adds schema management, codegen tooling, cache complexity, and a learning curve. For simple apps with a few endpoints, REST is usually the pragmatic choice.
Key Takeaways
| Concept | Key Point |
|---|---|
| Queries | Read-only; declare exactly which fields to fetch |
| Mutations | Write operations; return the modified object |
| Subscriptions | Real-time updates over WebSocket; returns a Flow |
| Codegen | .graphql files → type-safe Kotlin; build fails on schema mismatch |
| Normalized cache | Objects stored by ID; one update reflects everywhere |
| Optimistic updates | Show result instantly; auto-rollback on server error |
| Fetch policies | CacheFirst, NetworkOnly, CacheAndNetwork for different needs |
| When to use | Justified when over/under-fetching or multi-team schema ownership is a real pain |