Press enter or click to view image in full size
Photo by Mario Verduzco on Unsplash
6 min readJust now
–
In this post, I’ll share my experience migrating the Medium Android app from Apollo Kotlin version 3 to version 4, including the challenges I encountered and how I solved them to improve our GraphQL implementation.
Understanding Our Apollo Cache Implementation
Before diving into the migration, it’s important to understand how we use Apollo’s cache in the Medium Android app. Our app relies heavily on Apollo’s normalized cache for several critical purposes:
Performance Optimization: We use FetchPolicy.CacheFirst
as our default strategy, wh…
Press enter or click to view image in full size
Photo by Mario Verduzco on Unsplash
6 min readJust now
–
In this post, I’ll share my experience migrating the Medium Android app from Apollo Kotlin version 3 to version 4, including the challenges I encountered and how I solved them to improve our GraphQL implementation.
Understanding Our Apollo Cache Implementation
Before diving into the migration, it’s important to understand how we use Apollo’s cache in the Medium Android app. Our app relies heavily on Apollo’s normalized cache for several critical purposes:
Performance Optimization: We use FetchPolicy.CacheFirst
as our default strategy, which means we always try to serve data from the cache first before making network requests. This significantly reduces loading times and provides a smooth user experience, especially when users navigate between screens that display similar content.
Real-time Updates: We use Apollo’s watch()
functionality extensively to observe cache changes and automatically update our UI when data changes. This is particularly useful for features like:
- Live clap counts on posts
- Real-time follower updates
- Post viewed updates
- and more…
Starting the Migration
The initial plan was straightforward: update Apollo Kotlin from version 3 to 4. The IntelliJ plugin made this process seem simple at first glance.
Key Changes in Apollo Kotlin 4
- Group id / plugin id / package name: Apollo Kotlin 4 uses a new identifier (
com.apollographql.apollo
) for its maven group id, Gradle plugin id, and package name. This change fromcom.apollographql.apollo3
allows running version 4 alongside version 3 if needed. Source: Apollo Kotlin Migration Guide - Group id / plugin id / package name - Exception handling: Apollo 4 has a new way of handling exceptions. Instead of throwing exceptions directly, they’re now passed through ApolloResponse. Source: Apollo Kotlin Migration Guide — Fetch errors do not throw
- ApolloCompositeException: In Apollo Kotlin 3, when both cache and network operations failed with a CacheFirst policy, you’d get an ApolloCompositeException containing both errors. Apollo Kotlin 4 simplifies this by throwing only the primary exception while adding any secondary failures as suppressed exceptions, making error handling more straightforward. Source: Apollo Kotlin Migration Guide — ApolloCompositeException is not thrown
The Challenge: Cache Miss Exceptions
After making the initial changes, I encountered a major issue: CacheMissException
errors were appearing throughout our UI wherever we used watch()
. This was happening because Apollo 4 passes all exceptions to ApolloResponse
instead of silently ignoring them when using FetchPolicy.CacheFirst
.
This exposed an underlying issue: our cache configuration wasn’t optimal.
Fixing the Cache Implementation
Our app was using Programmatic cache IDs, but the implementation had issues:
- Some IDs were missing from our CacheKeyGenerator
- Some cache key generation logic was incorrect
- These problems led to frequent cache misses
Solution: Declarative Cache IDs & __typename on all Operations
I decided to switch from Programmatic to Declarative cache IDs, which made it significantly easier to match IDs to types and fields. Here’s how I implemented it:
# Typesextend type Catalog @typePolicy(keyFields: "id")extend type CatalogViewerEdge @typePolicy(keyFields: "id")extend type Collection @typePolicy(keyFields: "id")extend type CollectionViewerEdge @typePolicy(keyFields: "id")extend type Post @typePolicy(keyFields: "id")extend type PostViewerEdge @typePolicy(keyFields: "id")extend type User @typePolicy(keyFields: "id")extend type UserViewerEdge @typePolicy(keyFields: "id")# etc# Fieldsextend type Query @fieldPolicy(forField: "collection", keyArgs: "id")extend type Query @fieldPolicy(forField: "post", keyArgs: "id")extend type Query @fieldPolicy(forField: "publication", keyArgs: "id")extend type Query @fieldPolicy(forField: "user", keyArgs: "id")# etc
To further improve cache hit rates, I added the __typename
to all operations by configuring it in the Gradle build file:
apollo { service("service") { addTypename.set("always") }}
Cache Update Extension Functions
To further improve our caching logic, I created extension functions for updating cache fragments that make both reading and writing more intuitive:
internal suspend inline fun <D : Fragment.Data> ApolloStore.updateCache( fragment: Fragment<D>, cacheKey: CacheKey, customScalarAdapters: CustomScalarAdapters = CustomScalarAdapters.Empty, cacheHeaders: CacheHeaders = CacheHeaders.NONE, publish: Boolean = true, crossinline block: (cachedData: D) -> D,): Set<String> { val cachedFragment = getCachedFragment( fragment = fragment, cacheKey = cacheKey, customScalarAdapters = customScalarAdapters, cacheHeaders = cacheHeaders, ) ?: return emptySet() return writeFragment( fragment = fragment, cacheKey = cacheKey, fragmentData = block(cachedFragment), customScalarAdapters = customScalarAdapters, cacheHeaders = cacheHeaders, publish = publish, )}suspend fun <D : Fragment.Data> ApolloStore.getCachedFragment( fragment: Fragment<D>, cacheKey: CacheKey, customScalarAdapters: CustomScalarAdapters = CustomScalarAdapters.Empty, cacheHeaders: CacheHeaders = CacheHeaders.NONE,): D? = runCatching { readFragment( fragment = fragment, cacheKey = cacheKey, customScalarAdapters = customScalarAdapters, cacheHeaders = cacheHeaders, )}.onFailure { e -> when (e) { is CacheMissException -> Timber.e(e, "Cache miss on fragment $fragment with cache key $cacheKey.") is ApolloException -> Timber.e(e, "Cache read error on fragment $fragment with cache key $cacheKey.") else -> Timber.e(e, "Unexpected error while reading fragment $fragment with cache key $cacheKey.") }}.getOrNull()
These extension functions significantly improved the readability of our cache manipulation code and provided better error handling for cache operations.
Testing and Handling Cache Exceptions
To ensure our Apollo response handling was correct, I first wrote unit tests for our methods that transform ApolloResponse into Kotlin Result types.
@Testfun `safeWatch with CacheMissException and FetchPolicy#CacheFirst should return success flow`() = runTest { // Given val requestUuid = Uuid.randomUUID() val cacheResponse = ApolloResponse.Builder( operation = IsFollowingCatalogQuery("ID"), requestUuid = requestUuid, ) .exception(CacheMissException(CacheKey(Catalog.type.name, "CATALOG_ID").toString())) .build() val data = IsFollowingCatalogQuery.Data( catalogById = IsFollowingCatalogQuery.CatalogById( __typename = Catalog.type.name, catalogFollowData = CatalogFollowData( __typename = Catalog.type.name, id = "CATALOG_ID", viewerEdge = CatalogFollowData.ViewerEdge( __typename = CatalogViewerEdge.type.name, id = "VIEWER_EDGE_ID", isFollowing = true, ) ), ) ) val networkResponse = ApolloResponse.Builder( operation = IsFollowingCatalogQuery("CATALOG_ID"), requestUuid = requestUuid, ) .data(data) .build() every { mockApolloCall.watch() } returns flowOf(cacheResponse, networkResponse) // When mockApolloCall.safeWatch(FetchPolicy.CacheFirst) { it }.test { // Then val result: Result<IsFollowingCatalogQuery.Data> = awaitItem() assertTrue(result.isSuccess) assertEquals(expected = data, actual = result.getOrNull()) assertNull(result.exceptionOrNull()) awaitComplete() ensureAllEventsConsumed() }}
Then I fixed the exception propagation in our watchers with this approach:
- We added fetchPolicy and refetchPolicy parameters to our watchers with default values. These defaults match those used by the Apollo Kotlin SDK.
- We transform the ApolloResponse into a Result, enabling us to handle either Success or Failure cases.
- If the fetchPolicy is CacheFirst or CacheAndNetwork, we are skipping the CacheMissException, as Network will emit after either a Success or an ApolloNetworkException.
- If the fetchPolicy is NetworkFirst, we are skipping the ApolloNetworkException, as Cache will emit after either a Success or an CacheMissException.
inline fun <D : Query.Data, R> ApolloCall<D>.safeWatch( fetchPolicy: FetchPolicy = FetchPolicy.CacheFirst, refetchPolicy: FetchPolicy = FetchPolicy.CacheOnly, crossinline transform: (D) -> R,): Flow<Result<R>> = this .fetchPolicy(fetchPolicy) .refetchPolicy(refetchPolicy) .watch() .mapNotNull { response -> val result = response.toResult(transform) val exception = result.exceptionOrNull() when { exception is CacheMissException && fetchPolicy == FetchPolicy.CacheFirst -> null exception is CacheMissException && fetchPolicy == FetchPolicy.CacheAndNetwork -> null exception is ApolloNetworkException && fetchPolicy == FetchPolicy.NetworkFirst -> null else -> result } }
The key insight here is that different fetch policies have different fallback strategies, and our exception handling needs to respect these strategies. By filtering out expected exceptions that will be followed by either success or a different type of exception, we ensure that our UI only receives meaningful errors that require user attention.
Completing the Migration
After resolving these cache-related issues, I was finally able to complete the migration to Apollo 4:
- Replaced
executeV3()
withexecute()
- Updated
watch()
calls to removefetchThrows = true
- Fixed ApolloCompositeException handling
Additional Improvements: Custom Type Adapters
While diving deep into the Apollo documentation, I also discovered we could use type adapters for scalar values. I implemented this for our Currency scalar:
import java.util.Currencyobject CurrencyAdapter : Adapter<Currency> { override fun fromJson(reader: JsonReader, customScalarAdapters: CustomScalarAdapters): Currency = Currency.getInstance(reader.nextString()) override fun toJson(writer: JsonWriter, customScalarAdapters: CustomScalarAdapters, value: Currency) { writer.value(value.currencyCode) }}
Future Improvements
HTTP Batching
HTTP batching allows multiple GraphQL operations to be sent in a single HTTP request, reducing network overhead. This is particularly useful for applications that execute multiple queries simultaneously, as it can significantly improve performance by reducing the number of network requests. We are currently using HTTP batching on our Web platform without encountering any issues.
Persisted Queries
Persisted Queries improve network performance by sending a query hash instead of the full query text. This reduces payload size and can improve security. The server maintains a mapping of hashes to query strings, allowing it to execute the appropriate query when it receives a hash. Note that implementing Persisted Queries requires backend support.
Conclusion
What began as a simple version upgrade became a comprehensive overhaul of our GraphQL implementation. By switching to Declarative cache IDs, adding __typename
to all operations, and properly handling cache exceptions, we’ve significantly improved the cache hit of our Apollo GraphQL integration.
The key takeaway: when upgrading Apollo Kotlin, be prepared to revisit your caching strategy. The improvements in version 4 expose issues that might have been hidden in version 3, but fixing them leads to a more robust implementation.