#154 RESTのプロジェクトに部分的にGraphQLを導入する場合のアプローチ方法
はじめに
RESTベースのプロジェクトにおいては、GETやPOSTだけでは表現が難しい「複雑な条件を持つデータ取得処理」が求められる場面もあります。
そうした場面で「部分的にGraphQLを導入する」という選択肢があると、プロジェクトの統一性や保守性を守りつつ、柔軟なAPI設計が可能となります。
前回記事では、「複雑な取得処理」を例としてREST・GraphQLによる設計を比較し、それぞれがどのような場面に適しているのかをケーススタディごとに紹介しました。
前回記事:
複雑な取得処理におけるRESTの限界とGraphQLという選択肢
今回は、実際にRESTベースのプロジェクトで部分的にGraphQLを導入しようとする場合に、ポイントとなる設計の視点や意識したい観点について整理し、そのアプローチ方法を考えていきたいと思います。
GraphQLを部分導入する際の視点整理
以下のような観点が、設計判断の際のポイントとなります。
- RESTとのレイヤーの切り分け
- スキーマ・型定義の整合性
- クエリ層の抽象化と再利用性
これらの視点に沿って、次章以降で順に解説していきます。
1. RESTとGraphQLのレイヤー設計をどう切り分けるか
説明のイメージをしやすくするため、本記事では「バックエンドにExpressなどのNode.jsベースのサーバーを採用している”ある程度レイヤー構造を保ったREST設計”のアプリケーション」であることを前提として、GraphQLを導入する場合のレイヤー設計について考えていきたいと思います。
既存のディレクトリ構造を以下と仮定します。
.
├── src
│ ├── routes # ルーティング
│ │ └── potatoRoutes.ts
│ ├── controllers # HTTPレイヤーの処理
│ │ └── potatoController.ts
│ ├── services # ビジネスロジック(ユースケース層)
│ │ └── potatoService.ts
│ ├── repositories # データアクセス層
│ │ └── potatoRepository.ts
│ ├── models # DBスキーマやドメインモデル定義
│ │ └── potato.ts
│ ├── utils # 補助関数・共通ロジック
│ └── app.ts # Expressアプリケーションのエントリーポイント
├── package.json
└── tsconfig.json
今回は /api/graphql のように、単一のエンドポイントをGraphQL用として使用する方針で考えてみましょう。
既存のRESTルーティングを維持しつつ、特定の取得処理だけをGraphQLで提供するイメージです。
そのため、GraphQLのエンドポイント(/api/graphql)は、既存のAPIルート群と併存する形で設計します。
GraphQLの部分導入後のディレクトリ構造のイメージは、以下の通りです。
.
├── src
│ ├── routes
│ │ └── potatoRoutes.ts
│ ├── controllers
│ │ └── potatoController.ts
│ ├── services
│ │ └── potatoService.ts
│ ├── repositories
│ │ └── potatoRepository.ts
│ ├── models
│ │ └── potato.ts
│ ├── graphql # GraphQLの導入に関するファイル群(※部分導入)
│ │ ├── resolvers
│ │ │ └── potatoResolver.ts
│ │ ├── typeDefs
│ │ │ └── potato.graphql
│ │ └── index.ts # Apollo Serverの設定など
│ ├── utils
│ └── app.ts
├── package.json
└── tsconfig.json
graphql/index.ts を app.ts に統合する
GraphQLのエンドポイント(/api/graphql)を Express アプリケーションに組み込む方法についても、簡単に確認しておきましょう。
GraphQLに関する初期化処理や設定(スキーマ・リゾルバの定義など)は、src/graphql/index.ts に集約させます。
この集約したモジュールを app.ts から呼び出すことで、RESTとGraphQLの責務を分離したまま統合することが可能となります。
構成例は以下の通りです。
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import typeDefs from './typeDefs'
import resolvers from './resolvers'
export async function setupGraphQL(app: Express) {
const server = new ApolloServer({ typeDefs, resolvers })
await server.start()
app.use('/api/graphql', expressMiddleware(server))
}
import express from 'express'
import { setupGraphQL } from './graphql'
import potatoRoutes from './routes/potatoRoutes'
// NOTE: Apollo Serverの初期化には非同期処理が含まれるため、
// main()関数を用意することで明示的に順序を制御して、意図しない動作を防止
async function main() {
const app = express()
const port = 3000
// RESTルートの登録
app.use('/api/potato', potatoRoutes)
// GraphQLの初期化
await setupGraphQL(app)
// サーバーの起動
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`)
})
}
main().catch((err) => {
console.error('Failed to start server:', err)
})
このように「GraphQLの初期化処理を graphql/index.ts にまとめ、app.ts へミドルウェアとして組み込む」ことで、ルーティング構成の見通しが良くなり、部分導入にも柔軟に対応できる設計になります。
補足:context の設定について
上記の例では、導入の構成を中心に取り扱いたかったので context については触れませんでしたが、以下のようなケースで使用することが多く、実運用を考えるとほぼ必須の設定と言えるでしょう。
context を使用するケース例:
- JWT、セッションなどの認証情報の注入
- DBコネクションやDIコンテナの共有
- ロギング、トレーシング、リクエストIDなどの付与
- リクエスト情報に応じたスキーマの切り替え
取得系(Query)のみの部分導入における開発の初期段階では、context が設定されていなくても特に支障はありません。
ただし、拡張性を意識するのであれば、初期段階で以下のように context をセットアップしておくことで、後からの変更を最小限に抑えることができます。
Apollo Server v4 における context の設定は、 expressMiddleware の第2引数にて行います。
export async function setupGraphQL(app: Express) {
const server = new ApolloServer({ typeDefs, resolvers })
await server.start()
app.use('/api/graphql', expressMiddleware(server, {
context: async ({ req }) => ({
// NOTE: 現在は未設定。将来的に、認証情報や共通コンテキストをこちらに設定していく想定
})
}))
}
2. 型・スキーマの一貫性をどう保つか
GraphQLを部分的に導入する場合、「RESTとGraphQLの型定義のズレ」をどのように管理・解消していくのかが課題として挙げられることが多いのではないでしょうか。(例:命名の違い、nullableの扱いの差など)
複雑な取得処理のみにGraphQLを利用するとしても、「レスポンスの形はRESTのAPIと同じでOK」「GraphQLの柔軟なスキーマ設計を有効的に利用したい」など、”GraphQLにどこまで求めるか”によっても採用するべき方針は異なります。
本記事では「共通のDTOを設定する(型共有)」と「GraphQLの専用型を設定する(型分離)」の2つの方針について整理していきます。
どちらの方針とするかは「導入スコープの範囲」を判断軸とするとわかりやすいですが、「まずは型共有の方針ではじめ、GraphQLが肥大化していくようであれば型分離に切り替える」というような段階的なアプローチも選択肢に入れておくと、柔軟に対応しやすくなります。
型共有/型分離 それぞれのメリット・デメリット
型共有
⇒ RESTとGraphQLで共通の型を使いまわす設計
-
メリット
- メンテナンス箇所が少なく、修正漏れが起きにくい
- ドメインロジックやレスポンス整形の一貫性が保てる
- バリデーション処理などを再利用しやすい
-
デメリット
- REST/GraphQLの表現力やユースケースに引きずられやすい
- スキーマの「目的に応じた最適化」がしづらくなる
型分離
⇒ REST用のDTOとGraphQL用の型を分けて管理する設計
-
メリット
- GraphQL特有の柔軟なスキーマ設計(nullable、ネスト、field限定)を自由に行える
- API設計ごとに責務が明確になる(例:RESTはサービス連携用、GraphQLはUI特化など)
-
デメリット
- フロントエンド・バックエンド間での仕様同期コストが増える
- 型の重複定義による保守コスト増のおそれ
補足:それぞれのレイヤー設計について
「型共有」と「型分離」は、コード上のレイヤー設計や責務の持ち方にも影響を与えます。
-
型共有(共通DTO)を採用する場合:
- サービス層を通じてREST・GraphQLで同じ整形処理を再利用する設計
-
型分離(GraphQL専用型)を採用する場合:
- GraphQL用の整形処理をリゾルバ側で個別に持つ設計
といったような切り分けができると、アプリケーションの構成としてもわかりやすくなるかと思います。
型共有/型分離 それぞれの実装例
型共有の実装例
以下のようにDTOをREST・GraphQLの両方で利用することで、サービス層の型とレスポンス仕様を一貫した形で管理することができます。
export type PotatoDto = {
id: number
name: string
size: string
}
import { PotatoDto } from '../dto/potatoDto'
import { getPotatoes } from '../services/potatoService'
export const potatoResolver = {
Query: {
potatoes: async (): Promise<PotatoDto[]> => {
return getPotatoes() // サービス層の戻り値をそのまま返す
}
}
}
型分離の実装例
GraphQL側の整形ロジックをリゾルバ側で設定することで、RESTとは異なる設計思想を反映したレスポンスを作ることができます。
type Potato {
id: ID!
displayName: String!
}
import { getPotatoes } from '../../../services/potatoService'
export const potatoResolver = {
Query: {
potatoes: async () => {
const items = await getPotatoes()
return items.map((item) => ({
id: item.id,
displayName: `${item.name} (${item.size})`,
}))
}
}
}
補足:型の自動生成ツールの利用
GraphQLスキーマからTypeScript型を自動生成するツール(例:GraphQL Code Generator)を使うことで、型の重複定義やズレを防ぎやすくなります。
設定や習得には一定のコストが多少かかりますが、特に型分離を採用する場合には強力な支援となるため、こうしたツールの利用も検討しておくと良いかもしれませんね。
3. フロントエンドにおけるクエリ再利用と責務の分離
GraphQLを導入する場合、バックエンドでのスキーマ設計と併せて、フロントエンドでのクエリの扱い方も設計する上で重要なポイントとなります。
GraphQLでは、1つのエンドポイントに複数の型付きクエリを送る構成になるため、フロントエンド側でのクエリ定義や取得ロジックの整理・管理を疎かにすると、再利用性や保守性が低下するおそれがあります。
実装例
本記事では、以下のような方針による実装例を簡単に紹介させていただきます。
- GraphQLのクエリ定義:クエリファイルに分離
- Apollo Clientのロジック:カスタムHookに集約
query GetPotatoes {
potatoes {
id
displayName
}
}
import { useQuery } from '@apollo/client'
import GET_POTATOES_QUERY from '../graphql/queries/potatoes.graphql'
export const usePotatoesQuery = () => {
const { data, loading, error } = useQuery(GET_POTATOES_QUERY)
return {
potatoes: data?.potatoes ?? [],
loading,
error,
}
}
上記のように実装することで、UIコンポーネント側は usePotatoesQuery() を呼び出すだけとなります。
これにより責務の分離と再利用性が向上し、保守性の高い構成が実現できました。
補足:エラーハンドリングの統一
RESTとGraphQLではエラーレスポンスの形式が異なります。
事前にクライアント側での扱い方を決めておくと、GraphQLの部分導入がスムーズに進められるかと思います。
おわりに
いかがだったでしょうか。
今回は、柔軟性と一貫性のための選択肢としてGraphQLを部分的に導入する場合に、ポイントとなる視点や観点とそのアプローチ方法について紹介させていただきました。
部分導入においても、初期構造から「どのような方針とするか」を意識することで、将来的な拡張性を考慮した設計を実現できると考えております。
特に、RESTとGraphQLを併存させる場合は「どこまでをRESTが行い、どこからがGraphQLの範囲か」という線引きをチームの方針としてルール化しておくことで、高い保守性が維持できます。
RESTとGraphQLの責務分離を明確にすることが、部分導入の鍵となるのではないでしょうか。
また、本記事ではRESTとGraphQLを併存させるアプローチとして、単一のエンドポイントをGraphQL用として使用する構成を紹介させていただきましたが、「RESTと完全に分けて、別のエンドポイントとして設定する」というような方針なども考えられるかと思います。
今回、文章量が多くなってしまうため触れませんでしたが、RESTとの併存やGraphQLを導入する場合に意識したい観点として「GraphQLのN+1問題」や「ドキュメントの整備」なども挙げられるかと思います。
こうした観点についても、機会があれば取り上げてみたいです。
以上です。最後まで閲覧いただき、ありがとうございます。
参考
Apollo 公式ドキュメント
- https://www.apollographql.com/docs/apollo-server/api/express-middleware
- https://www.apollographql.com/docs/graphos/routing/migration/from-gateway
GraphQL Code Generator
- https://github.com/dotansimha/graphql-code-generator
- https://techlife.cookpad.com/entry/2021/03/24/123214
- https://graphql.org/blog/2024-09-19-codegen/
RESTとGraphQLの併存について
- https://genee.jp/contents/how-to-use-grahp-ql/
- https://www.apollographql.com/blog/layering-graphql-on-top-of-rest
その他
Discussion