スキーマ駆動開発の実践:React × Express × GraphQLで効率的なAPI設計を実現

  • graphql
    graphql
  • typescript
    typescript
  • expressjs
    expressjs
  • react
    react
  • vite
    vite
  • apollo
    apollo
2024/10/12に公開

はじめに

近年、フロントエンドとバックエンドの開発を効率化するアプローチとして スキーマ駆動開発(Schema-Driven Development, SDD) が注目されています。
GraphQL を活用した SDD では、まず GraphQL のスキーマを定義し、それを基にバックエンドとフロントエンドを実装 していきます。
これにより、API の仕様が明確になり、フロントエンド・バックエンドの開発を並行して進めることが可能になります。

本記事では、React(Vite)+ Express(Apollo Server)を使用して、スキーマ駆動開発の実践方法を解説 します。
また、GraphQL のスキーマから TypeScript の型を自動生成することで、型安全な開発を実現 します。

この記事を読むことで、以下のことが学べます:

  • スキーマ駆動開発のメリット
  • React(Vite)+ Express(Apollo Server)を活用した GraphQL API の構築
  • GraphQL スキーマを基に TypeScript の型を自動生成
  • フロントエンドとバックエンドの連携方法

スキーマ駆動開発とは

スキーマ駆動開発(SDD: Schema-Driven Development)とは、API の仕様を GraphQL のスキーマ定義(SDL: Schema Definition Language)を基盤に設計・開発を進める手法 です。

従来の REST API では、API 仕様書を別途作成し、フロントエンドとバックエンド間で共有する必要がありました。しかし、GraphQL ではスキーマがそのまま API の仕様となるため、開発の進行がスムーズになります。

メリット

  • 仕様の明確化
    スキーマがそのまま API 仕様となるため、フロントエンド / バックエンド間で仕様の認識相違が起きづらくなります。
  • 自動ドキュメント化
    GraphQL Playground や Apollo Studio などを使えば、スキーマ定義から自動的にドキュメントを生成でき、API の利用者が理解しやすくなります。
  • 変更点の検知が容易
    スキーマに変更がある場合、実装との整合性チェックも自動で発生しやすくなるため、変更の影響範囲が明確になります。

このステップを踏むことで、チーム全体の作業効率も上がり、スムーズなコミュニケーションとプロジェクト運営を実現できます。

今回のゴール

下記の流れで実装を進めていきます。

  1. GraphQLスキーマ定義を作成
  2. サーバーでスキーマ定義を読み込んでGraphQLサーバーを起動
  3. GraphQLスキーマ定義、GraphQLクエリからTypeScriptの型定義を作成
  4. フロントエンドからGraphQLでサーバーのデータを取得

今回は投稿データをサンプルとして扱い、
最終的には投稿データのタイトルおよび著者の情報をブラウザで表示させます。

Image from Gyazo

セットアップ

まずは全体のプロジェクトのセットアップから始めます。

mkdir Documents/workspace/schema-driven-development-react-express-graphql
cd Documents/workspace/schema-driven-development-react-express-graphql
npm init -y

Turborepoを使えるようインストールします。

npm install turbo -D

Turborepo(ターボレポ)は、モノレポ(monorepo)構成を管理するためのツールで、主にJavaScriptやTypeScriptのプロジェクトに利用されます。モノレポとは、複数のプロジェクトやパッケージを一つのリポジトリ内で管理するアプローチです。Turborepoは、このようなモノレポを効率的に扱うためのツールです。

次にTurborepoの設定を行います。
タスクという形でビルドや開発モードでの起動などの設定ができます。

turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

次にルート直下のpackage.jsonの設定をします。

package.json
{
  "name": "react-express-graphql-turborepo",
+ "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "version": "1.0.0",
  "description": "",
+ "packageManager": "npm@10.9.2",
- "main": "index.js",
  "scripts": {
+   "dev": "turbo dev",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "turbo": "^2.4.0"
  }
}
  • workspaces: モノレポ(monorepo)を構築するための設定です。モノレポとは、複数のパッケージやアプリケーションを単一のリポジトリ内で管理するアプローチです。
    workspacesを使用すると、異なるパッケージ(アプリケーションやライブラリ)を同じリポジトリ内で効率的に管理でき、依存関係の共有やインストールの高速化、開発の効率化ができます。

フロントエンド(React + Vite)の作成

まずはフロントエンドから設定してきます。

mkdir apps
cd apps
npm create vite@latest frontend

viteでフロントエンドを構築する際に幾つか質問が出ますが、下記で設定します。

Select a framework: React
Select a variant: TypeScript + SWC

次にスコープ付きパッケージの設定をします。

apps/package.json
{
-  "name": "frontend",
+  "name": "@react-express-graphql-turborepo/frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@eslint/js": "^9.21.0",
    "@types/react": "^19.0.10",
    "@types/react-dom": "^19.0.4",
    "@vitejs/plugin-react-swc": "^3.8.0",
    "eslint": "^9.21.0",
    "eslint-plugin-react-hooks": "^5.1.0",
    "eslint-plugin-react-refresh": "^0.4.19",
    "globals": "^15.15.0",
    "typescript": "~5.7.2",
    "typescript-eslint": "^8.24.1",
    "vite": "^6.2.0"
  }
}

ViteとReactの必要なファイルをダウンロードできたのでパッケージをインストールします。

cd apps/frontend
npm install

インストールが終わったら念のため起動し動作確認をしておきます。

npm run dev

http://localhost:5173/にアクセスできればフロントエンドの設定完了です。

バックエンド(Experss.js)の作成

次にバックエンドとしてExpressの設定を行います。

まずはパッケージのインストールを行います。

mkdir apps/backend && cd apps/backend
npm init -y
npm i express cors dotenv
npm i -D  typescript ts-node @types/node @types/express @types/cors

その後、スコープ付きパッケージの設定およびtsconfig.jsonの設定を行います。

apps/backend/package.json
{
- "name": "backend",
+ "name": "@react-express-graphql-turborepo/backend",
  "version": "1.0.0",
tsconfig.json
{
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "module": "CommonJS",
    "target": "ES6",
    "esModuleInterop": true
  }
}

Expressを動かせる環境ができましたので、起動するファイルを作成します。

apps/backend/src/index.ts
import express from "express";
import cors from "cors";

const app = express();
const PORT = process.env.PORT || 4000;

app.use(cors());
app.use(express.json());

app.get("/", (req, res) => {
  res.send("Hello from Express!");
});

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});
  • /でGETリクエストを送信すると、Hello from Express!という文字列が返却されます

ファイルが作成できましたら、package.jsonで起動する処理を定義します。

package.json
- "main": "index.js",
+ "main": "index.ts",
  "scripts": {
+   "dev": "ts-node src/index.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

念のためExpressが起動できるか確認します。

npm run dev
Server is running on http://localhost:4000

起動できましたら、curlでレスポンスを確認します。

curl http://localhost:4000
Hello from Express!

以上でExperssのセットアップは終了となります。

フロントエンドとバックエンドの連携

fetchモジュールを使ってExpressにアクセスしGETメソッドのレスポンスであるHello from Express!をブラウザで表示させます。

apps/frontend/src/App.tsx
import { useEffect, useState } from "react";

function App() {
  const [message, setMessage] = useState("");

  useEffect(() => {
    fetch("http://localhost:4000")
      .then((res) => res.text())
      .then((data) => setMessage(data));
  }, []);

  return (
    <div>
      <h1>React + Express</h1>
      <p>API Response: {message}</p>
    </div>
  );
}

export default App;

初期設定のCSSが残って影響を与えるので一旦削除します。

rm apps/frontend/src/index.css
rm apps/frontend/src/App.css

またcssを読み込んでいるファイルも修正しておきます。

apps/frontend/src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
- import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

一通り設定ができましたのでReactとExpressをTurborepoから起動します。
ルート直下で下記コマンドを実行します。

npm run dev

Image from Gyazo

turborepoを使ってフロントエンド、バックエンドのリポジトリを管理し、双方を連携できました。

ここから今回のブログの本題である「スキーマ駆動開発」を実施していきたいと思います。

スキーマ定義作成

GraphQLの定義をフロントエンドとバックエンドの両方から参照できるようにするために、モノレポの共通ディレクトリに配置していきます。

mkdir apps/packages
mkdir apps/packages/graphql
mkdir apps/packages/graphql/schema

この GraphQL スキーマは、ブログの投稿 (Post) とその著者 (Author) を扱う API です。

apps/packages/graphql/schema/post.graphql
type Author {
  id: Int!
  firstName: String!
  lastName: String!
}
 
type Post {
  id: Int!
  title: String!
  author: Author
}
 
type Query {
  posts: [Post] # 単一の投稿を取得する
  post(id: ID!): Post # 単一の投稿を取得する
}
  • Author は投稿 (Post) を書いた著者

    • id: Int! → 著者のユニークなID(! は必須フィールドを意味する)
    • firstName: String! → 著者の名前(必須)
    • lastName: String! → 著者の名字(必須)
  • Post はブログの投稿データ

    • id: Int! → 投稿のユニークなID(必須)
    • title: String! → 投稿のタイトル(必須)
    • author: Author → 投稿の著者(Author 型)
  • author: Author を定義することで、投稿 (Post) から著者 (Author) を取得できます

  • クライアントが呼び出せるエンドポイント

    • posts: [Post] → すべての投稿を取得する
    • post(id: ID!): Post → ID を指定して特定の投稿を取得する
      • id: ID! は GraphQL の ID 型(通常 StringInt を格納)

バックエンド実装

ExpressでGraphQLサーバーを起動するために幾つかライブラリをインストールします。

npm i @apollo/server graphql graphql-tag graphql-tools/load-files
npm i -D @types/express-serve-static-core
パッケージ名 説明
@apollo/server Apollo Server(GraphQL API を提供するサーバーライブラリ)
graphql GraphQL のコアライブラリ(スキーマのパース、バリデーションなど)
graphql-tag GraphQL のクエリ (gql テンプレート) をパースするユーティリティ
graphql-tools/load-files GraphQL のスキーマやリゾルバーをファイルから読み込むライブラリ
@types/express-serve-static-core express の型定義(express の内部機能 serve-static などを含む)

次にリゾルバの定義をします。

// ダミーデータ
const posts = [
  { id: 1, title: "GraphQL Introduction", authorId: 1 },
  { id: 2, title: "Advanced GraphQL", authorId: 1 },
  { id: 3, title: "GraphQL with Apollo", authorId: 2 },
];

export const resolvers = {
  Query: {
    // すべての投稿を取得
    posts: () => posts,

    // IDで特定の投稿を取得
    post: (_parent: any, args: { id: number }) =>
      posts.find((post) => post.id === args.id),
  },
};
  • GraphQL の Query.posts に対応するリゾルバー

    • 全ての投稿 (posts 配列) を返す
    • データベース不要で簡単に動作
  • GraphQL の Query.post(id: ID!) に対応するリゾルバー

    • args.id の値に一致する投稿 (post.id) を 検索
    • 該当する post があれば返し、なければ undefined を返す
    • データがないと null になる可能性があるので、クライアント側で処理が必要

次にGraphQL スキーマ (.graphql ファイル) を読み込んで Apollo Serverにスキーマ定義を渡せるための設定を行います。

apps/backend/src/graphql/type.ts
import { readFileSync } from "fs";
import { gql } from "graphql-tag";
import path from "path";

const schemaPath = path.resolve(process.cwd(), "../packages/graphql/schema/post.graphql");

const typeDefs = gql(readFileSync(schemaPath, "utf-8"));

export { typeDefs };
  • GraphQL のクエリやスキーマを gql(GraphQL タグ)としてパースする
    • graphql-tag は、文字列として読み込んだ GraphQL スキーマを gql タグで解析するためのライブラリ
    • gql を使うことで、Apollo Server に正しくスキーマを渡せる
apps/backend/src/index.ts
import express from "express";
import cors from "cors";
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { resolvers } from './graphql/resolvers';
import { typeDefs } from "./graphql/type";

const server = new ApolloServer({ typeDefs, resolvers });

const app = express();
const PORT = process.env.PORT || 4000;

const startServer = async () => {
  await server.start();

  app.use(
    '/graphql',
    cors(),
    express.json(),
    expressMiddleware(server)
  );

  app.listen(PORT, () => {
    console.log('GraphQL server is running at http://localhost:4000/graphql');
  });
};

startServer();
  • ライブラリ

    • express → HTTP サーバーを作成するためのフレームワーク
    • cors → CORS(クロスオリジンリクエスト)を許可するためのミドルウェア
    • @apollo/server → Apollo Server(GraphQL API を提供するライブラリ)
    • expressMiddleware → Express で Apollo Server をミドルウェアとして組み込むための関数
    • resolvers → GraphQL のリゾルバー(クエリを処理する関数群)
    • typeDefs → GraphQL のスキーマ(データの構造を定義)
  • GraphQL サーバー (Apollo Server) を作成

    • typeDefs(スキーマ)と resolvers(データ取得の処理)を Apollo Server に渡す
    • この server が GraphQL API のエンドポイントを提供
  • GraphQL サーバー (server) を Express に組み込んで起動

    • server.start() → Apollo Server を起動
    • app.use('/graphql', ...)/graphql エンドポイントを作成
      • cors() → CORS を許可(ブラウザからのリクエストを制限しない)
      • express.json() → JSON 形式のリクエストをパース
      • expressMiddleware(server) → Apollo Server を Express のミドルウェアとして登録
    • app.listen(PORT, () => { ... }) → 指定したポート (4000) でサーバーを起動

設定が終わりましたらExpressサーバーを起動した後にcurlでレスポンスが返ってくるか確認します。

curl -X POST http://localhost:4000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ posts { id title } }"}' | jq

レスポンスとしてリゾルバで設定したダミーデータが返ってきたらサーバー側の設定は完了です。

{
  "data": {
    "posts": [
      {
        "id": 1,
        "title": "GraphQL Introduction"
      },
      {
        "id": 2,
        "title": "Advanced GraphQL"
      },
      {
        "id": 3,
        "title": "GraphQL with Apollo"
      }
    ]
  }
}

おまけ:タイトルによるフィルター

クエリ実行時にtitleでフィルターをかける設定を入れてみます。

export const resolvers = {
  Query: {
-   // すべての投稿を取得
+   // タイトルでフィルタリング可能な全投稿取得
-   posts: () => posts,
+   posts: (_parent: any, args: { findTitle?: string }) => {
+     let filteredPosts = posts;

+     if (args.findTitle) {
+       filteredPosts = filteredPosts.filter((post) =>
+         post.title.toLowerCase().includes(args.findTitle!.toLowerCase())
+       );
+     }

      return filteredPosts;
    },

    // IDで特定の投稿を取得
    post: (_parent: any, args: { id: number }) =>
      posts.find((post) => post.id === args.id),
  },
};
  • findTitleがあった場合はタイトルと部分一致したものを返却します。
  • findTitleがない場合は全件を返却します。

クエリ定義でfindTitleを引数に設定します。

apps/packages/graphql/schema/post.graphql
type Query {
- posts: [Post]
+ posts(findTitle: String): [Post] # タイトルでフィルタリング可能に
  post(id: ID!): Post # 単一の投稿を取得する
}

curl

$ curl -X POST http://localhost:4000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ posts(findTitle: \"Apollo\") { id title } }"}' | jq
{
  "data": {
    "posts": [
      {
        "id": 3,
        "title": "GraphQL with Apollo"
      }
    ]
  }
}

Apolloという文字が入ったタイトルのみレスポンスにセットされています。

スキーマを基に TypeScript 型を自動生成

次はフロントエンドでTypeScript型を使用するためにGraphQL定義から生成する対応を行います。

GraphQL Code Generatorのインストール

npm i -D @graphql-codegen/cli

GraphQL Code Generator (@graphql-codegen/cli) の設定ファイル を作成し、
GraphQL のスキーマとクエリから 型付きの TypeScript コードを自動生成するための設定を行います。

apps/frontend/config/codegen.ts
import { CodegenConfig } from '@graphql-codegen/cli';
 
const config: CodegenConfig = {
  schema: '../packages/graphql/schema/**/*.graphql',
  generates: {
    "generated/graphql.ts": {
      plugins: [
        'typescript',
        'typescript-operations',
      ],
    },
  } 
};
export default config;
  • schema: GraphQL のスキーマファイルのパスを指定

    • ../packages/graphql/schema/**/*.graphql
      • packages/graphql/schema/ 配下の すべての .graphql ファイル を対象
      • **/*.graphql は 再帰的にすべての GraphQL スキーマを探す
    • スキーマには type Query, type Mutation, type Post などの型定義が含まれる
  • generates: どのファイルに型を生成するか設定

    • "generated/graphql.ts"generated/graphql.ts に TypeScript 型を生成
    • plugins → どのプラグインを使うか指定
  • plugins: GraphQL のスキーマとクエリから型を生成するプラグイン

    • 'typescript' → GraphQL のスキーマ (schema.graphql) から TypeScript 型を生成
    • 'typescript-operations' → GraphQL クエリ (.graphql) から TypeScript 型を生成
apps/frontend/package.json
{
  "name": "@react-express-graphql-turborepo/frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview",
+   "codegen": "graphql-codegen --config scripts/codegen.ts"
  },
  • graphql-codegenを実行するためのコマンドを設定
npm run codegen
  • generated/graphql.ts に TypeScript 型が生成されます。
apps/frontend/generated/graphql.ts
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: { input: string; output: string; }
  String: { input: string; output: string; }
  Boolean: { input: boolean; output: boolean; }
  Int: { input: number; output: number; }
  Float: { input: number; output: number; }
};

export type Author = {
  __typename?: 'Author';
  firstName: Scalars['String']['output'];
  id: Scalars['Int']['output'];
  lastName: Scalars['String']['output'];
  posts?: Maybe<Array<Maybe<Post>>>;
};


export type AuthorPostsArgs = {
  findTitle?: InputMaybe<Scalars['String']['input']>;
};

export type Post = {
  __typename?: 'Post';
  author?: Maybe<Author>;
  id: Scalars['Int']['output'];
  title: Scalars['String']['output'];
};

export type Query = {
  __typename?: 'Query';
  post?: Maybe<Post>;
  posts?: Maybe<Array<Maybe<Post>>>;
};


export type QueryPostArgs = {
  id: Scalars['ID']['input'];
};

これでGraphQLスキーマ定義からTypeScriptの型定義を生成できました。

複数のGraphQLスキーマ定義を作成しても一括してTypeScriptの型定義を生成できます。

apps/packages/graphql/schema/recipe.graphql
type Recipe {
  id: Int!
  title: String!
  imageUrl: String
}

type Query {
  recipe(id: Int!): Recipe!
}

再度、実行

npm run codegen
apps/frontend/generated/graphql.ts
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: { input: string; output: string; }
  String: { input: string; output: string; }
  Boolean: { input: boolean; output: boolean; }
  Int: { input: number; output: number; }
  Float: { input: number; output: number; }
};

export type Author = {
  __typename?: 'Author';
  firstName: Scalars['String']['output'];
  id: Scalars['Int']['output'];
  lastName: Scalars['String']['output'];
  posts?: Maybe<Array<Maybe<Post>>>;
};


export type AuthorPostsArgs = {
  findTitle?: InputMaybe<Scalars['String']['input']>;
};

export type Post = {
  __typename?: 'Post';
  author?: Maybe<Author>;
  id: Scalars['Int']['output'];
  title: Scalars['String']['output'];
};

export type Query = {
  __typename?: 'Query';
  post?: Maybe<Post>;
  posts?: Maybe<Array<Maybe<Post>>>;
  recipe: Recipe;
};


export type QueryPostArgs = {
  id: Scalars['ID']['input'];
};


export type QueryRecipeArgs = {
  id: Scalars['Int']['input'];
};

export type Recipe = {
  __typename?: 'Recipe';
  id: Scalars['Int']['output'];
  imageUrl?: Maybe<Scalars['String']['output']>;
  title: Scalars['String']['output'];
};

Queryにはpostsrecipeが生成されており、まとめて処理されていることがわかります。

フロントエンド実装

まずはGraphQLクエリを作成します。

この GraphQL クエリ GetPosts は、
すべての投稿 (posts) を取得し、それぞれの投稿の id・title・著者 (author) の情報を取得する クエリです。

apps/packages/graphql/operations/getPost.graphql
query GetPosts {
  posts {
    id
    title
    author {
      firstName
      lastName
    }
  }
}

GraphQL クエリを型生成できるようconfig.tsの設定を追加します。

apps/frontend/config/config.ts
import { CodegenConfig } from '@graphql-codegen/cli';
 
const config: CodegenConfig = {
  schema: '../packages/graphql/schema/**/*.graphql',
+ documents: '../packages/graphql/operations/**/*.graphql',
  generates: {
    "generated/graphql.ts": {
      plugins: [
        'typescript',
        'typescript-operations',
      ],
    },
  } 
};
export default config;

すると下記のGetPostsQueryが生成されます。
getPostsの戻り値の型になります。

export type GetPostsQuery = { 
  __typename?: 'Query', 
  posts?: Array<{
     __typename?: 'Post', 
     id: number, 
     title: string, 
     author?: { 
      __typename?: 'Author', 
      firstName: string, 
      lastName: string 
    } | null 
  } | null> | null 
};
npm i -D @graphql-codegen/typescript-graphql-request
npm i graphql-request

@graphql-codegen/typescript-graphql-request は、GraphQL Code Generator のプラグインの一つで、GraphQL のスキーマやクエリから graphql-request に適した TypeScript の型やクライアントコードを自動生成するためのものです。

apps/frontend/config/config.ts
import { CodegenConfig } from '@graphql-codegen/cli';
 
const config: CodegenConfig = {
  schema: '../packages/graphql/schema/**/*.graphql',
  documents: '../packages/graphql/operations/**/*.graphql',
  generates: {
    "generated/graphql.ts": {
      plugins: [
        'typescript',
        'typescript-operations',
+       'typescript-graphql-request',
      ],
    },
  } 
};
export default config;

Code Generatorのconfigで追加し、再度型を生成すると下記が追加されます。

export const GetPostsDocument = gql`
    query GetPosts {
  posts {
    id
    title
    author {
      firstName
      lastName
    }
  }
}
    `;

export type SdkFunctionWrapper = <T>(action: (requestHeaders?:Record<string, string>) => Promise<T>, operationName: string, operationType?: string, variables?: any) => Promise<T>;


const defaultWrapper: SdkFunctionWrapper = (action, _operationName, _operationType, _variables) => action();

export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
  return {
    GetPosts(variables?: GetPostsQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise<GetPostsQuery> {
      return withWrapper((wrappedRequestHeaders) => client.request<GetPostsQuery>(GetPostsDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'GetPosts', 'query', variables);
    }
  };
}
export type Sdk = ReturnType<typeof getSdk>;
service.ts
import { GraphQLClient } from "graphql-request";
import { getSdk } from "../generated/graphql"; 

const client = new GraphQLClient("http://localhost:4000/graphql");
const sdk = getSdk(client);

export async function getPost() {
  const response = await sdk.GetPosts();
  return response.posts
}

ポイント

  • graphql-request は、シンプルな GraphQL クライアントライブラリ で、HTTP リクエストを GraphQL API に送信するために使います。
  • getSdkGraphQL Code Generator によって自動生成された API クライアント です。
    • graphql-code-generator を使うと、GraphQL のクエリを TypeScript の型付き関数 (getSdk) に変換できる。
    • getSdk を使うことで、GraphQL クエリを簡単に実行し、型安全なデータ取得ができます。
import { useEffect, useState } from "react";
import { GetPostsQuery } from "../generated/graphql";
import { getPosts } from "./service";

function App() {
  const [posts, setPosts] = useState<GetPostsQuery["posts"] | null>(null);

  useEffect(() => {
    getPosts()
      .then((data) => {
        setPosts(data);
      })
      .catch((error) => {
        console.error(error);
      });
  }, []);

  return (
    <div>
      <h1>React + Express</h1>
      {posts === null || posts === undefined ? (
        <p>Loading...</p>
      ) : posts.length === 0 ? (
        <p>No posts found.</p>
      ) : (
        <ul>
          {posts.map((post) => (
            <li key={post?.id}>
              <h2>{post?.title}</h2>
              <p>Author: {post?.author?.firstName} {post?.author?.lastName}</p>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default App;
  • GetPostsQuerygetPostsの戻り値の型として使えます。

Image from Gyazo

結果を確認したところ、投稿タイトルは表示されていますが、著者が表示されていません。

リゾルバを再度確認します。
どうやら投稿一覧を返すときに著者の情報をセットするのを忘れていたようです。

export const resolvers = {
  Query: {
  // タイトルでフィルタリング可能な全投稿取得
  posts: (_parent: any, args: { findTitle?: string }) => {
    let filteredPosts = posts;

    if (args.findTitle) {
      filteredPosts = filteredPosts.filter((post) =>
        post.title.toLowerCase().includes(args.findTitle!.toLowerCase())
      );
    }

      return filteredPosts;
    },

    // IDで特定の投稿を取得
    post: (_parent: any, args: { id: number }) =>
      posts.find((post) => post.id === args.id),
  },
};

著者の情報を設定するよう修正します。
なお、こちらの情報もダミーデータで今回は一旦対応します。

// ダミーデータ
+ const authors = [
+   { id: 1, firstName: "John", lastName: "Doe" },
+   { id: 2, firstName: "Jane", lastName: "Smith" },
+ ];

const posts = [
  { id: 1, title: "GraphQL Introduction", authorId: 1 },
  { id: 2, title: "Advanced GraphQL", authorId: 1 },
  { id: 3, title: "GraphQL with Apollo", authorId: 2 },
];

export const resolvers = {
  Query: {
    // タイトルでフィルタリング可能な全投稿取得 + `author` を追加
    posts: (_parent: any, args: { findTitle?: string }) => {
+    let filteredPosts = posts;

      if (args.findTitle) {
        filteredPosts = filteredPosts.filter((post) =>
          post.title.toLowerCase().includes(args.findTitle!.toLowerCase())
        );
      }

+     // `authorId` を `author` に変換
+     return filteredPosts.map((post) => ({
+       ...post,
+       author: authors.find((author) => author.id === post.authorId) || null,
+     }));
    },

    // IDで特定の投稿を取得 + `author` を追加
    post: (_parent: any, args: { id: number }) => {
      const post = posts.find((post) => post.id === args.id);
      if (!post) return null;

      return {
        ...post,
        author: authors.find((author) => author.id === post.authorId) || null,
      };
    },
  },
};

再度、ブラウザで確認をすると著者の情報も正しくセットされていました。

Image from Gyazo

ディレクトリ構成

最後にディレクトリ構成を確認し全体像を改めて整理しておきます。

tree -I node_modules -I .git -I .turbo --dirsfirst -a
.
├── apps
│   ├── backend
│   │   ├── dist
│   │   │   ├── graphql
│   │   │   │   ├── resolvers.js
│   │   │   │   └── type.js
│   │   │   └── index.js
│   │   ├── src
│   │   │   ├── graphql
│   │   │   │   ├── resolvers.ts
│   │   │   │   └── type.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── tsconfig.tsbuildinfo
│   ├── frontend
│   │   ├── config
│   │   │   └── codegen.ts
│   │   ├── generated
│   │   │   └── graphql.ts
│   │   ├── public
│   │   │   └── vite.svg
│   │   ├── src
│   │   │   ├── assets
│   │   │   │   └── react.svg
│   │   │   ├── App.tsx
│   │   │   ├── main.tsx
│   │   │   ├── service.ts
│   │   │   └── vite-env.d.ts
│   │   ├── .gitignore
│   │   ├── README.md
│   │   ├── eslint.config.js
│   │   ├── index.html
│   │   ├── package.json
│   │   ├── tsconfig.app.json
│   │   ├── tsconfig.json
│   │   ├── tsconfig.node.json
│   │   └── vite.config.ts
│   └── packages
│       └── graphql
│           ├── operations
│           │   └── getPost.graphql
│           └── schema
│               ├── post.graphql
│               └── recipe.graphql
├── package-lock.json
├── package.json
└── turbo.json

プロジェクトのトップレベル

.
├── apps          # 各アプリケーション (`backend`, `frontend`) を管理
├── packages      # 共通パッケージ (`graphql` スキーマ・クエリなど)
├── package.json  # プロジェクト全体の依存関係
├── turbo.json    # `Turborepo` の設定ファイル
  • Turborepo を利用したモノレポ構成
  • apps/backendfrontend のコードを分ける
  • packages/ → 共通の GraphQL スキーマ (graphql/) を格納
  • turbo.json → Turborepo の設定ファイル

apps/backend/(バックエンド GraphQL API)

├── backend
│   ├── dist       # TypeScript をコンパイルした JavaScript (`.js`) ファイル
│   ├── src        # バックエンドの TypeScript (`.ts`) ソースコード
│   ├── package.json  # バックエンドの依存関係
│   ├── tsconfig.json  # TypeScript の設定
フォルダ / ファイル 説明
dist/ TypeScript をビルドした JavaScript ファイル
src/ バックエンドの TypeScript ソースコード
src/graphql/ GraphQL のリゾルバー (resolvers.ts), スキーマ (type.ts)
index.ts メインのエントリーポイント (Express + Apollo Server)
package.json backend の依存関係
tsconfig.json TypeScript の設定

apps/frontend/(フロントエンド React アプリ)

├── frontend
│   ├── config        # 設定ファイル (`codegen.ts`)
│   ├── generated     # GraphQL Code Generator による型定義
│   ├── public        # 静的ファイル (`vite.svg`)
│   ├── src           # フロントエンドのソースコード
│   ├── package.json  # フロントエンドの依存関係
│   ├── vite.config.ts # `Vite` の設定ファイル
フォルダ / ファイル 説明
config/codegen.ts GraphQL Code Generator の設定ファイル
generated/graphql.ts GraphQL Code Generator で生成された型
public/ 静的ファイル (vite.svg)
src/ React (Vite) のソースコード
src/App.tsx React のメインコンポーネント
src/service.ts GraphQL API への通信ロジック
vite.config.ts Vite の設定ファイル

packages/graphql/(GraphQL の共通パッケージ)

│   └── packages
│       └── graphql
│           ├── operations
│           │   └── getPost.graphql  # クエリ (`GetPost`)
│           └── schema
│               ├── post.graphql     # `Post` のスキーマ定義
│               └── recipe.graphql   # `Recipe` のスキーマ定義
フォルダ / ファイル 説明
operations/getPost.graphql GetPost クエリの定義
schema/post.graphql Post の GraphQL スキーマ (type Post { ... })
schema/recipe.graphql Recipe の GraphQL スキーマ

さいごに

本記事では、スキーマ駆動開発(SDD)を活用した React + Express(GraphQL)アプリケーションの構築方法 を解説しました。
GraphQL を使うことで、API のスキーマが明確になり、バックエンドとフロントエンドの開発をスムーズに進めることができます。

特に、GraphQL Code Generator を利用して TypeScript の型を自動生成することで、型安全な開発を実現 できました。
これにより、フロントエンドとバックエンドでデータの整合性を確保しつつ、開発の効率化が可能になりました。

✅ まとめ

  • スキーマ駆動開発(SDD)とは? → API の仕様をスキーマで定義し、それを基に開発を進めるアプローチ
  • メリット → フロントエンド・バックエンドの並行開発が可能、型安全な開発ができる
  • 技術スタック → React(Vite)、Express(Apollo Server)、GraphQL
  • スキーマから型を自動生成 → GraphQL Code Generator を活用

スキーマ駆動開発を活用すれば、API の変更にも柔軟に対応でき、よりメンテナブルなコードを書くことができます。

今回の内容を参考に、ぜひ実際のプロジェクトでも SDD を取り入れてみてください。

Xでシェア
Facebookでシェア
LinkedInでシェア

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ

関連する技術ブログ

弊社の技術支援サービス

お問い合わせ

経営と現場をつなぐ“共創型”の技術支援。
成果に直結するチーム・技術・プロセスを共に整えます。

お問い合わせ