AWS AppSync で GraphQL API を使ってみた
製造ビジネステクノロジー部の小林です。
GraphQL は Meta(旧 Facebook)が公開している API のためのクエリ言語兼ランタイムです。REST が「エンドポイントごとに固定のレスポンスを返す」のに対して、GraphQL は「クライアントが欲しいフィールドをクエリで指定すると、サーバはそれに合わせて必要な分だけ返す」という考え方です。
要素は主に 3 つです。
| 操作 | 役割 | REST でいうと |
|---|---|---|
| Query | データを取得する(読み取り) | GET |
| Mutation | データを変更する(作成・更新・削除) | POST / PUT / DELETE |
| Subscription | データの変化をリアルタイムに受け取る | WebSocket |
エンドポイントは基本的に 1 つ(例:/graphql)で、リクエストの中身がクエリかミューテーションかによって挙動が変わります。
GraphQL でうれしいことと気をつけること
うれしいこと
- 必要なデータだけ取れる
クライアントが「欲しいフィールド」を指定できるので、データを取りすぎたり(オーバーフェッチ)、足りなくて追加リクエストが必要になったり(アンダーフェッチ)することを防げます。 - 1 回のリクエストでまとめて取れる
関連するデータを一度に取得できるため、1 つの画面を表示するための API 呼び出し回数を減らせます。 - 型をフロントとバックで共有できる
スキーマ(型定義)がそのまま API の仕様書になるので、フロントエンドとバックエンドで同じ型情報を使えます。 - リアルタイム更新も同じ仕組みで扱える
Subscription を使えば、リアルタイムなデータ更新も同じスキーマの中で表現できます。
気をつけること
- サーバ側の実装が複雑になりやすい
REST に比べて、次のような課題への対応が必要です。- N+1 問題(関連データの取得でクエリが大量に発行される)
- 認可(どのフィールドを誰に見せるか)の細かい制御
- レゾルバ(データ取得処理)の設計
- HTTP キャッシュが効きにくい
REST のような HTTP レベルのキャッシュが使いづらく、専用のクライアントキャッシュに頼ることが多くなります。 - 重いクエリへの対策が必要
「何でも自由に取れる」がゆえに、サーバへの負荷が大きくなりすぎないよう、以下のような制限を設ける必要があります。- クエリの深さ制限
- レート制限(リクエスト回数の制限)
- 学習コスト
向いているユースケース
「GraphQL を選ぶといい」と感じたのは、次のようなケースです。
- 画面ごとに必要なデータがバラバラなアプリ
画面によって必要なデータの組み合わせが違い、REST だと画面用の API(BFF)を毎回作りたくなるようなケース。 - 通信回数や転送量を抑えたいクライアント
モバイルアプリや SPA など、ネットワークの負荷をなるべく減らしたい場面。 - 複数のデータソースをまとめたいケース
DynamoDB・RDS・外部 API など、バラバラなデータソースを 1 つの API に集約したい場合。 - リアルタイム更新を扱いたい機能
チャットや通知など、データの変化をすぐに反映したい機能。
AWS AppSync とは
今回は、AWS AppSync を利用して GraphQL API を作成します。
AWS AppSync は、GraphQL API をマネージドで提供してくれる AWS のサービスです。自分でサーバを立てず、スキーマとリゾルバ(データの取得ロジック)を定義するだけで GraphQL API が使えます。
主な特徴は次のとおりです。
- スキーマとリゾルバの管理をおまかせできる
GraphQL のスキーマ定義とリゾルバの管理を、マネージドで提供してくれます。
リゾルバは、GraphQL のスキーマに定義された「各フィールドの値を、実際にどうやって取ってくるか」を決める処理のことです。 - さまざまなデータソースにそのまま接続できる
DynamoDB・Aurora・Lambda・OpenSearch・HTTP エンドポイントなどを、データソースとして直接つなげられます。 - 複数の認可方式に対応している
API キー・IAM・Cognito・OpenID Connect・Lambda オーソライザーといった認可方式を選べます。 - リアルタイム通信をサーバレスで実現できる
Subscription 用の WebSocket 基盤を、サーバの管理なしで提供してくれます。
料金は、次の要素で構成されます。
- リクエスト数
- リアルタイムメッセージ数
- キャッシュ料金(有効にした場合のみ)
今回作るもの
今回は AppSync + DynamoDB のシンプルな構成で、ブログ記事っぽい Post を CRUD できる GraphQL API を作ります。認可方式は最短で試せる API キーにします。
ワークフローは次のとおりです。
- クライアントは GraphQL over HTTPS で AppSync のエンドポイントに Query / Mutation を投げる
- AppSync はスキーマの各フィールドに紐づくリゾルバを実行し、DynamoDB を読み書きする
- 結果は GraphQL のレスポンス(JSON)として返る
プロジェクト構造
appsync-graphql-sample/
├── bin/
│ └── appsync-graphql-sample.ts
├── lib/
│ ├── appsync-graphql-sample-stack.ts
│ └── schema.graphql # GraphQL スキーマ
├── cdk.json
├── package.json
└── tsconfig.json
やってみる
CDK プロジェクトを用意する
まずは空のディレクトリで cdk init を実行して、TypeScript のひな型を作ります。
mkdir appsync-graphql-sample && cd appsync-graphql-sample
npx cdk init app --language typescript
GraphQL スキーマを書く
lib/ 直下に schema.graphql を新規作成し、Post 型とその CRUD を扱う Query / Mutation を定義します。
# ブログ投稿を表す型。id は自動採番、createdAt はサーバ側で生成する前提。
type Post {
id: ID!
title: String!
body: String!
createdAt: AWSDateTime!
}
# 作成時はサーバ側で id / createdAt を採番するのでクライアントからは受け取らない
input CreatePostInput {
title: String!
body: String!
}
# 更新は id 必須、それ以外は null 許容にして「変更するフィールドだけ渡す」形にした
input UpdatePostInput {
id: ID!
title: String
body: String
}
type Query {
getPost(id: ID!): Post
listPosts: [Post!]!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(input: UpdatePostInput!): Post!
deletePost(id: ID!): Post
}
ポイントは以下です。
!は「必須(non-null)」を表す。ID!は「必ず ID が入る」の意味AWSDateTimeは AppSync が用意しているスカラ型で、ISO 8601 の文字列として扱われる- 入力は
input型として明示的に定義しておくと、Mutationの引数の再利用がしやすい
CDK スタックを書く
cdk init が生成した lib/appsync-graphql-sample-stack.ts の中身を、次の内容で置き換えます。
aws-cdk-lib/aws-appsync の GraphqlApi コンストラクトで API 本体を、Table で DynamoDB を作り、addDynamoDbDataSource でデータソースとして紐づける流れです。
使い方は公式の AppSync Construct Library の README にも同様のサンプルがあります。
import * as path from "node:path";
import {
Stack,
StackProps,
Duration,
Expiration,
CfnOutput,
RemovalPolicy,
} from "aws-cdk-lib";
import * as appsync from "aws-cdk-lib/aws-appsync";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import { Construct } from "constructs";
export class AppsyncGraphqlSampleStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
/**
* Posts CRUD 用の GraphQL API。
* 認可は最短で試せる API キー方式にしている
*/
const api = new appsync.GraphqlApi(this, "PostsApi", {
name: "posts-api",
definition: appsync.Definition.fromFile(
path.join(__dirname, "schema.graphql"), // スキーマは別ファイルに切り出して IDE の GraphQL 補完を効かせる
),
authorizationConfig: {
defaultAuthorization: {
authorizationType: appsync.AuthorizationType.API_KEY,
apiKeyConfig: {
expires: Expiration.after(Duration.days(30)), // AppSync の API キーは最長 365 日。検証用途は 30 日で十分
},
},
},
});
/**
* 投稿データを保存する DynamoDB テーブル。
*/
const postsTable = new dynamodb.Table(this, "PostsTable", {
partitionKey: {
name: "id", // 単一アイテム取得を最速にしたいので id をハッシュキーに採用
type: dynamodb.AttributeType.STRING, // AppSync の $util.autoId() が UUID 文字列を返すため
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: RemovalPolicy.DESTROY, // 本番では RETAIN 推奨。ハンズオン用に destroy で消える設定
});
// AppSync から DynamoDB を読み書きする IAM ロールもここで自動生成される
const postsDS = api.addDynamoDbDataSource("PostsDataSource", postsTable);
// GetItem は CDK 標準のヘルパーで済ませて VTL の直書きを避ける
postsDS.createResolver("QueryGetPostResolver", {
typeName: "Query",
fieldName: "getPost",
requestMappingTemplate: appsync.MappingTemplate.dynamoDbGetItem("id", "id"),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
// Scan は件数が増えると高コストだが、検証用テーブルなので割り切って採用
postsDS.createResolver("QueryListPostsResolver", {
typeName: "Query",
fieldName: "listPosts",
requestMappingTemplate: appsync.MappingTemplate.dynamoDbScanTable(),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultList(),
});
/**
* createPost はサーバ側で id と createdAt を採番したいので VTL を直書き。
* ヘルパー(dynamoDbPutItem)だと自動採番+タイムスタンプが両立させにくいのが理由。
*/
postsDS.createResolver("MutationCreatePostResolver", {
typeName: "Mutation",
fieldName: "createPost",
requestMappingTemplate: appsync.MappingTemplate.fromString(`
{
"version": "2018-05-29",
"operation": "PutItem",
"key": {
"id": $util.dynamodb.toDynamoDBJson($util.autoId())
},
"attributeValues": {
"title": $util.dynamodb.toDynamoDBJson($ctx.args.input.title),
"body": $util.dynamodb.toDynamoDBJson($ctx.args.input.body),
"createdAt": $util.dynamodb.toDynamoDBJson($util.time.nowISO8601())
}
}
`),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
/**
* updatePost は SET 式で title と body だけを更新する。
*/
postsDS.createResolver("MutationUpdatePostResolver", {
typeName: "Mutation",
fieldName: "updatePost",
requestMappingTemplate: appsync.MappingTemplate.fromString(`
{
"version": "2018-05-29",
"operation": "UpdateItem",
"key": {
"id": $util.dynamodb.toDynamoDBJson($ctx.args.input.id)
},
"update": {
"expression": "SET title = :title, body = :body",
"expressionValues": {
":title": $util.dynamodb.toDynamoDBJson($ctx.args.input.title),
":body": $util.dynamodb.toDynamoDBJson($ctx.args.input.body)
}
}
}
`),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
postsDS.createResolver("MutationDeletePostResolver", {
typeName: "Mutation",
fieldName: "deletePost",
requestMappingTemplate: appsync.MappingTemplate.dynamoDbDeleteItem("id", "id"),
responseMappingTemplate: appsync.MappingTemplate.dynamoDbResultItem(),
});
// curl から動作確認できるようにエンドポイントと API キーをスタック出力に出す
new CfnOutput(this, "GraphQLApiUrl", {
value: api.graphqlUrl,
});
new CfnOutput(this, "GraphQLApiKey", {
value: api.apiKey ?? "", // API_KEY 認可時のみ値が入る。他の認可方式に切り替えたら undefined
});
}
}
リゾルバはヘルパーで済むもの(Get / Scan / Delete)と VTL 直書きが必要なもの(Create / Update)を混ぜて、両方の書き方を体験できるようにしています。
VTL(Velocity Template Language)とは、AppSync のリゾルバでリクエストやレスポンスの変換ロジックを記述するためのテンプレート言語です。「VTL 直書き」とは、ヘルパー(用意された関数)に頼らず、VTL を自分で書いてデータの変換処理を組み立てることを指しています
デプロイ
デプロイが成功すると、Outputs として GraphQL エンドポイントの URL と API キーが表示されます。

AppSync のコンソールを見ると GraphQL API が作成されたことが分かります。

動作確認
マネジメントコンソールのクエリエディタから叩く
AppSync のコンソールには、その場でクエリを実行できるエディタが用意されています。まずはデプロイした API を開き、左メニューの「クエリ」を選択します。

Mutation で投稿を作成する
左側のエディタに createPost の Mutation を書いて実行すると、記事を 1 件作成できます。
mutation CreatePost {
createPost(input: { title: "はじめての投稿", body: "GraphQL 便利です" }) {
id
title
body
createdAt
}
}
実行すると、右側にレスポンスが表示されます。サーバ側で採番された id(UUID)と createdAt(ISO 8601 のタイムスタンプ)が含まれていることが確認できます。

Query で一覧を取得する
続いて listPosts で投稿の一覧を取得します。ここでは id title createdAt だけを指定しています。
query ListPosts {
listPosts {
id
title
createdAt
}
}

注目したいのは、クエリで body を指定していないため、レスポンスの JSON にも body が含まれていない点です。「クライアントが欲しいフィールドだけが返ってくる」 という GraphQL の特徴を体感できるところです。
(createPost API を 2 回叩いてしまったので 2 件のデータが返ってきています)
curl から叩く
外部のクライアントから叩く場合は、x-api-key ヘッダーに API キーを載せて、GraphQL エンドポイントに POST します。
先ほど CDK が出力した GraphQLApiUrl と GraphQLApiKey の値を、シェル変数にセットしておきます。
export GRAPHQL_URL="https://xxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql"
export GRAPHQL_API_KEY="da2-xxxxxxxxxxxxxxxxxxxxxxxxxx"
続いて、listPosts を実行するリクエストを送ってみます。
curl -s -X POST "$GRAPHQL_URL" \
-H "Content-Type: application/json" \
-H "x-api-key: $GRAPHQL_API_KEY" \
-d '{"query":"query { listPosts { id title createdAt } }"}'
レスポンスとして、投稿の一覧が JSON で返ってきます。コンソールから作成した投稿が正しく取得できていることが確認できます。
{
"data": {
"listPosts": [
{
"id": "c9eb15f1-a41f-4039-998c-75bde1e7da38",
"title": "はじめての投稿",
"createdAt": "2026-07-19T05:58:43.124Z"
},
{
"id": "103f82fb-c005-4698-8cad-adb1601927b7",
"title": "はじめての投稿",
"createdAt": "2026-07-19T05:18:02.512Z"
}
]
}
}

ここでも、クエリで指定した id title createdAt だけがレスポンスに含まれており、body は返っていません。コンソールでも curl でも、同じように「欲しいフィールドだけが返る」挙動になっているのがわかります。
DynamoDBのテーブルはこのようにデータが保存されています。

リクエストの流れをおさらい
Query を投げてからレスポンスが返るまでの流れを、シーケンス図にしてみます。
図のとおり、クライアントが指定したフィールドに基づいて、AppSync がリゾルバを実行し、必要なだけ DynamoDB からデータを取ってきて返す形になります。REST でこれをやろうとすると、フィールドの組み合わせごとにエンドポイントを分けたり、クエリパラメータで制御したりする必要があり、その部分をスキーマだけで表現できるのが GraphQL の心地よさだと感じました。
まとめ
REST に慣れた身からすると「クライアント側でクエリを組み立てる」というのが最初は戸惑うところでしたが、画面ごとの必要データが違うアプリでは確かに便利そうですし、DynamoDB や Lambda を組み合わせやすいので AWS の中で完結させたい場合には有力な選択肢だと感じました。
この記事がどなたかの参考になれば幸いです。





