0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Kotlin/JS + Compose for Web に Apollo Kotlin を入れたら踏みまくったハマり集

0
Last updated at Posted at 2026-05-02

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 で実物を見ながら読める構成にしてあります。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?