Kotlin/JS + Compose for Web に Apollo Kotlin を入れたら踏みまくったハマり集
はじめに
Compose for Web (Kotlin/JS) のフロントエンドで GraphQL を Ktor の手書き POST で叩いていたのを、Apollo Kotlin 3.8.5 に置き換えました。
設計上の判断や「なぜ Apollo か」みたいな話は別途 Zenn にまとめています (Kotlin/JS に Apollo Kotlin 入れて型安全 GraphQL に倒した話)。
この記事では、実際に Kotlin/JS のプロジェクトに Apollo Kotlin を導入した際に 踏み抜いた地雷 を、再現できる形でまとめます。同じスタックで導入を考えている人向けの実装ログ的な記事です。
環境
- Kotlin: 1.9.22 (Multiplatform)
- Compose for Web: 1.5.12
- Apollo Kotlin: 3.8.5 (4 系を選ばなかった理由は後述)
- Ktor: 2.3.7 (health endpoint 用に残した)
- Node.js: 20.11.0 (Kotlin/JS の wrapped node)
- backend: Rust + async-graphql 7.x (前記事で SDL を CI で守る側)
実装
1. Apollo Gradle plugin を入れる
// frontend/build.gradle.kts
plugins {
kotlin("multiplatform") version "1.9.22"
id("org.jetbrains.compose") version "1.5.12"
id("com.apollographql.apollo3") version "3.8.5"
}
kotlin {
js(IR) {
browser { /* ... */ }
binaries.executable()
}
sourceSets {
val jsMain by getting {
dependencies {
implementation("com.apollographql.apollo3:apollo-runtime:3.8.5")
// (Ktor は health endpoint 用に残す)
implementation("io.ktor:ktor-client-core:2.3.7")
implementation("io.ktor:ktor-client-js:2.3.7")
}
}
}
}
apollo {
service("tradenews") {
packageName.set("graphql")
srcDir("src/jsMain/graphql")
// backend (async-graphql) は DateTime を RFC3339 文字列で返す
// → 生成側も String に倒して、UI 層は今までの String 受けを継続
mapScalarToKotlinString("DateTime")
}
}
ポイントは apollo-runtime を入れるだけで、Kotlin Multiplatform の解決機構が apollo-runtime-js を自動で引っ張ってくる点。jsMain で個別に -js 派生を書く必要はない。
2. クエリを .graphql ファイルに分離
Ktor 時代はクエリ文字列が Kotlin の """...""" インライン文字列でクライアントクラスに直書きでしたが、Apollo に寄せるタイミングで src/jsMain/graphql/*.graphql に切り出します。
# frontend/src/jsMain/graphql/GetAllTradeNews.graphql
query GetAllTradeNews {
tradeNews {
id
title
description
link
source
publishedAt
category
titleJa
descriptionJa
translationStatus
translatedAt
}
}
スキーマファイル (schema.graphqls) と同じディレクトリに置けば Apollo plugin がスキーマと照合して クエリ自体の typo もビルド時に弾く ようになります。これが今回のメインゲイン。
3. クエリ生成
(cd frontend && ./gradlew generateApolloSources)
build/generated/source/apollo/tradenews/graphql/ 配下にクエリクラスとレスポンスデータクラスが生成されます。
4. クライアント実装
生成された GetAllTradeNewsQuery / GetTradeNewsByCategoryQuery を使ってクライアントを書き換え:
class GraphQLClient(
private val apolloClient: ApolloClient,
private val healthClient: HttpClient,
private val endpoint: String,
) : TradeNewsApiClient {
override suspend fun fetchNewsItems(category: String?): List<NewsItem> {
return if (category == null) {
executeQuery(GetAllTradeNewsQuery(), context = "trade news") { data ->
data.tradeNews.map { it.toNewsItem() }
}
} else {
executeQuery(GetTradeNewsByCategoryQuery(category), context = "category news") { data ->
data.tradeNewsByCategory.map { it.toNewsItem() }
}
}
}
private suspend fun <D : Query.Data, T> executeQuery(
operation: Query<D>,
context: String,
map: (D) -> T,
): T {
val response: ApolloResponse<D> = try {
apolloClient.query(operation).execute()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw GraphQLClientException(
"Failed to fetch $context: ${e.message ?: "unknown error"}", e
)
}
val errors = response.errors
if (!errors.isNullOrEmpty()) {
throw GraphQLClientException(errors.joinToString(", ") { it.message })
}
val data = response.data
?: throw GraphQLClientException("GraphQL response did not contain $context data")
return map(data)
}
}
UI 層に渡す型 (NewsItem) は今まで通り。生成型 → NewsItem のマッピングは private fun ...toNewsItem() で薄く挟む形にしてあります。
ここまでだけ見れば順調そうですが、実際は色んなところで踏みました。
ハマったポイント
① Apollo Kotlin 4 が Kotlin/JS で Kotlin 2.0 を要求してくる
最初、当然のように Apollo Kotlin 4 系 (リリース済の最新) を入れようとしました。
> Task :compileKotlinJs FAILED
e: ...kotlin compiler version mismatch... requires Kotlin 2.0+
調べると、Apollo Kotlin 4 は Kotlin/JS ターゲットで Kotlin 2.0 を必須にしていました (4.x の migration ガイドにも記載あり)。
このリポは現状 Kotlin 1.9.22 + Compose 1.5.12 で安定運用していて、Kotlin 2.0 に上げると Compose 側のバージョン整合も同時に動かす必要があります。Apollo 導入と Kotlin/Compose アップグレードが 1 PR に同居すると、レビューも検証も大変になるので、
今回は Apollo Kotlin 3.8.5 を採用、Kotlin 2.0 アップグレードは別 PR で扱う
という方針に切り替えました。「最新を入れない」のも 1 つの判断、って話です。
② apollo-mockserver-js:3.8.5 が Maven Central から降りてて使えない
テストを書こうとして、公式の com.apollographql.apollo3:apollo-mockserver-js:3.8.5 を入れたら:
Could not find org.jetbrains.kotlinx:kotlinx-nodejs:0.0.7.
Searched in the following locations:
- https://repo.maven.apache.org/maven2/...
apollo-mockserver-js が依存している org.jetbrains.kotlinx:kotlinx-nodejs:0.0.7 が Maven Central から降ろされている ようで解決不能でした。
// dependencies block より排除
// implementation("com.apollographql.apollo3:apollo-mockserver:3.8.5") // 死亡
代わりに、自前で NetworkTransport をスタブする方針に切り替えました:
private class StubNetworkTransport(
private val respond: (ApolloRequest<*>) -> Any
) : NetworkTransport {
@Suppress("UNCHECKED_CAST")
override fun <D : Operation.Data> execute(
request: ApolloRequest<D>
): Flow<ApolloResponse<D>> {
val result = respond(request)
return if (result is Throwable) {
flow { throw result }
} else {
flowOf(result as ApolloResponse<D>)
}
}
override fun dispose() {}
}
使い方:
val stubTransport = StubNetworkTransport { request ->
ApolloResponse.Builder(
operation = request.operation,
requestUuid = request.requestUuid,
).data(allTradeNewsData()).build()
}
val apollo = ApolloClient.Builder().networkTransport(stubTransport).build()
NetworkTransport インターフェースは execute() と dispose() だけで構成されているので、ライブラリが死んでても自前で書くのは現実的でした。
ついでに、wire レベル (HTTP リクエストのバイト列) でも Authorization ヘッダや POST body が組み立てられているかを確かめたかったので、HttpEngine を差し替える wire-check テストも別途 1 件追加しています。
val httpEngine = HttpEngine { request ->
val body = Buffer().apply { request.body?.writeTo(this) }.readUtf8()
capturedBody = body
ApolloHttpResponse.Builder(statusCode = 200)
.body(Buffer().writeUtf8(stubResponseBody))
.build()
}
val apollo = ApolloClient.Builder()
.serverUrl("https://example.com/graphql")
.httpEngine(httpEngine)
.build()
③ ApolloResponse.exception は 3.x にはない
最初、エラーハンドリングを「公式ドキュメントっぽい」書き方で:
val response = apolloClient.query(operation).execute()
if (response.exception != null) {
throw GraphQLClientException("...", response.exception)
}
と書こうとしたら、Unresolved reference: exception でビルドが通りません。
調べると ApolloResponse.exception は 4.x 以降の API で、3.x 系では transport エラー (ネットワーク失敗とか) は .execute() 自体で throw されます (Apollo Kotlin 4 release notes に該当変更点が載っている)。
なので 3.x 系では:
val response = try {
apolloClient.query(operation).execute()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// ネットワーク失敗等はここに落ちる
throw GraphQLClientException("Failed: ${e.message}", e)
}
// GraphQL 仕様の errors はレスポンスに含まれる
if (!response.errors.isNullOrEmpty()) {
throw GraphQLClientException(response.errors.joinToString(", ") { it.message })
}
の二段構えで処理する必要があります。バージョン跨ぎでドキュメントの読み分けが必要なので、リリースノートを必ず確認しておくのが吉。
④ Apollo の生成クラス命名が直感に反する
スキーマ側はこう定義してあります:
type Query {
tradeNews: [TradeNews!]!
tradeNewsByCategory(category: String!): [TradeNews!]!
}
クエリも対応する形で query GetAllTradeNews { tradeNews { ... } } と書きます。生成されたクラスを叩こうとして data.tradeNews.first().{何か} でフィールドに何が入ってくるかを見ると…
data.tradeNews.first() // 型は GetAllTradeNewsQuery.TradeNew ← 末尾の s が剥がれてる
data.tradeNewsByCategory.first() // 型は GetTradeNewsByCategoryQuery.TradeNewsByCategory ← capitalize のみ
tradeNews → TradeNew (末尾 s を取って単数化したつもり?)、tradeNewsByCategory → TradeNewsByCategory (capitalize のみ) と 規則が一貫してないので、生成型を直接書こうとすると割と外します。
回避策はシンプルで:
(cd frontend && ./gradlew generateApolloSources)
ls frontend/build/generated/source/apollo/tradenews/graphql/
を一回叩いて、実際に何という名前のクラスが生成されたか を見てから参照を書く、これに尽きます。IDE 補完もある程度効きますが、テストデータ生成 (GetAllTradeNewsQuery.TradeNew(...) を直書き) する場面では補完が回らないことが多いです。
⑤ endpoint 末尾 / で 404 が出る (Copilot レビューで発覚)
これは PR merge 後の Copilot レビューで指摘してもらった話。
設定で apiEndpoint = "https://api.example.com/graphql/" のように 末尾 / 付きで渡された場合、
- backend (axum) は
/graphqlに固定でルーティング →/graphql/で 404 - 一方で health endpoint 側 (
buildHealthEndpoint) はtrimEnd('/')で吸収済み
という非対称があり、health だけ通って GraphQL だけ落ちる地味に怖い不整合が起きうる状態でした。
companion object {
operator fun invoke(endpoint: String = ApiConfig.apiEndpoint): GraphQLClient {
val normalized = normalizeGraphQLEndpoint(endpoint)
return GraphQLClient(
apolloClient = ApolloClient.Builder().serverUrl(normalized).build(),
healthClient = createDefaultHealthClient(),
endpoint = normalized, // ← 同じ正規化済み値を両側に
)
}
}
internal fun normalizeGraphQLEndpoint(raw: String): String = raw.trimEnd('/')
正規化を companion object operator fun invoke ファクトリに寄せて、Apollo の serverUrl と health 側で使う endpoint の 両方に同じ値が渡るようにしました。境界値テスト (空文字 / 末尾 / 1 個 / 複数 /) も internal 公開で 3 件追加。
地味だけど、こういう非対称な吸収処理が複数の関数に散ってる状態は、バグが「境界の片側だけで」生え続ける温床 なので、ファクトリで 1 度だけ正規化する形に倒したのは良い follow-up でした。
⑥ webpack production bundle が 1.56 MiB に
ビルドすると warning:
WARNING in entrypoint size limit: The following entrypoint(s) combined asset size
exceeds the recommended limit (244 KiB). This can impact web performance.
Entrypoints:
main (1.56 MiB) frontend.js
機能影響はなし (ただの warning) ですが、Apollo ランタイム + Ktor (health 用) の HTTP スタック二重化 が効いていそう。
割り切りとして:
- 今回は GraphQL=Apollo / health=Ktor で並行運用
- bundle size 圧縮は別 PR の future work — health 側を fetch API ベースの軽量ラッパーに置換すれば Ktor の依存を落とせる
「今 PR では bundle size に踏み込まない」という線引きを置いて、follow-up issue に切り出しました。1 PR に詰め込みすぎない方が、レビュー・検証コストが増えなくて済みます。
まとめ
Apollo Kotlin 3.8.5 を Kotlin/JS + Compose for Web プロジェクトに入れる、というニッチなことをすると以下くらいは踏みます:
| # | 内容 | 回避策 |
|---|---|---|
| ① | 4 系は Kotlin/JS で Kotlin 2.0 必須 | 3.8.5 を採用、Kotlin 2.0 アップグレードは別 PR |
| ② |
apollo-mockserver-js が kotlinx-nodejs:0.0.7 経由で死亡 |
NetworkTransport を自前スタブ |
| ③ |
ApolloResponse.exception は 4.x 以降 |
3.x では .execute() の throw + response.errors の二段で処理 |
| ④ | 生成クラス命名 (TradeNew vs TradeNewsByCategory) |
./gradlew generateApolloSources で実物を見てから書く |
| ⑤ | endpoint 末尾 / で 404 (非対称な吸収処理) |
ファクトリで 1 度だけ正規化、両側に同じ値を渡す |
| ⑥ | bundle size 1.56 MiB | follow-up で health を fetch ベースに置換予定 |
最終的に得られた価値は、「クエリも型もスキーマもコンパイラに守ってもらえる」 こと。前記事の CI drift チェックと組み合わせて「backend SDL → frontend クエリ実行」の整合がツールチェーンで保証される構成になりました。
サンプルリポジトリ
👉 toguri/apollo-kotlin-js-sample
記事内の各ハマりポイント (StubNetworkTransport の自前実装、二段 catch、生成クラス命名、endpoint 正規化) は全部このリポに含まれているので、./gradlew generateApolloSources && ./gradlew jsBrowserTest で実物を見ながら読める構成にしてあります。