RSSAmplifier

Medium Engineering - Medium · Oct 6, 2025

Medium Android App — Migrating from Apollo Kotlin 3 to 4: Lessons Learned

0
Sign in to vote or save

This page did not load. You can still read it on the original site — the toolbar below keeps your place in the directory.

Photo by Mario Verduzco on Unsplash Medium Android App — Migrating from Apollo Kotlin 3 to 4: Lessons Learned 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…

Photo by Mario Verduzco on Unsplash

Medium Android App — Migrating from Apollo Kotlin 3 to 4: Lessons Learned

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

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:

# Types
extend 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

# Fields
extend 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.

@Test
fun `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() with execute()
  • Updated watch() calls to remove fetchThrows = 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.Currency
object 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.


Medium Android App — Migrating from Apollo Kotlin 3 to 4: Lessons Learned was originally published in Medium Engineering on Medium, where people are continuing the conversation by highlighting and responding to this story.

Read on medium.engineering

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.

    Reading · Medium Engineering - Medium · RSS Amplifier