11
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?

はじめに

「必要なデータだけを、1回のリクエストで取得したい」

REST APIでは、複数のエンドポイントを叩いて必要なデータを集めたり、不要なデータまで取得してしまうことがあります。GraphQLはこれらの問題を解決する、Facebookが開発したクエリ言語です。

この記事では、GraphQLの基礎から実践的な実装まで、詳しく解説します。

ただ 実務で重要なのは
GraphQLを使えることより
GraphQLを導入すべきか どこで詰まるか を理解することです。

GraphQLは万能ではなく
次のトレードオフがあります。

  • 取得形が自由になる代わりに サーバー側の防御と観測が必須
  • フロントは便利になるが N+1や権限制御の設計が難しくなる
  • キャッシュ戦略がRESTより難しいケースがある

本記事は
RESTとの差分だけでなく
運用で困りがちなポイントも含めて整理します。

先に結論 GraphQLが向くケース 向かないケース

向く

  • 画面要件が頻繁に変わる BFF的なAPIが欲しい
  • 複数リソースの組み合わせが多い
  • 型を中心にクライアントとサーバーを同期したい

向かない

  • 単純なCRUDのみで変化が少ない
  • キャッシュやCDNで軽く配りたいだけ
  • クエリの自由度を許容できない 要件が厳しすぎる

最初に押さえる 実務の落とし穴

N+1問題

GraphQLはフィールドごとにresolverが動くため
素直に書くとDBアクセスが爆発します。
DataLoaderやバッチ取得が重要になります。

認可

Queryが自由度を持つので
どのフィールドを誰が読めるか を設計しておかないと
情報漏洩が起きます。

コスト制御

深いネストや大量フィールドで
高コストクエリを投げられる可能性があります。
深さ制限 複雑度制限 タイムアウト を最初から設計します。

GraphQLとは

REST APIとの比較

特徴 REST API GraphQL
エンドポイント 複数(/users, /posts等) 単一(/graphql)
データ取得 サーバーが決めた固定形式 クライアントが必要なフィールドを指定
オーバーフェッチ 発生しやすい 発生しない
アンダーフェッチ 複数リクエスト必要 1リクエストで解決
型システム オプション 必須(スキーマ定義)

具体例

# REST APIの場合
GET /users/1           → ユーザー情報
GET /users/1/posts     → ユーザーの投稿一覧
GET /posts/1/comments  → 投稿のコメント一覧
# 合計3回のリクエストが必要

# GraphQLの場合(1回のリクエスト)
query {
  user(id: "1") {
    name
    email
    posts {
      title
      comments {
        content
        author { name }
      }
    }
  }
}

GraphQLの基本

スキーマ定義

# schema.graphql

# スカラー型
# String, Int, Float, Boolean, ID が組み込み型

# カスタムスカラー
scalar DateTime

# Enum
enum UserRole {
  ADMIN
  EDITOR
  VIEWER
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

# オブジェクト型
type User {
  id: ID!
  email: String!
  name: String!
  role: UserRole!
  posts: [Post!]!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  content: String!
  status: PostStatus!
  author: User!
  comments: [Comment!]!
  tags: [Tag!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Comment {
  id: ID!
  content: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}

type Tag {
  id: ID!
  name: String!
  posts: [Post!]!
}

# 入力型(Mutationで使用)
input CreateUserInput {
  email: String!
  name: String!
  password: String!
  role: UserRole = VIEWER
}

input UpdateUserInput {
  email: String
  name: String
  role: UserRole
}

input CreatePostInput {
  title: String!
  content: String!
  status: PostStatus = DRAFT
  tagIds: [ID!]
}

# ページネーション用
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type PostEdge {
  cursor: String!
  node: Post!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

# クエリ(読み取り)
type Query {
  # 単一取得
  user(id: ID!): User
  post(id: ID!): Post
  
  # 一覧取得
  users(role: UserRole): [User!]!
  posts(
    status: PostStatus
    authorId: ID
    first: Int
    after: String
  ): PostConnection!
  
  # 検索
  searchPosts(query: String!): [Post!]!
  
  # 現在のユーザー
  me: User
}

# ミューテーション(書き込み)
type Mutation {
  # ユーザー
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!
  
  # 投稿
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: CreatePostInput!): Post!
  deletePost(id: ID!): Boolean!
  publishPost(id: ID!): Post!
  
  # コメント
  addComment(postId: ID!, content: String!): Comment!
  deleteComment(id: ID!): Boolean!
  
  # 認証
  login(email: String!, password: String!): AuthPayload!
  logout: Boolean!
}

type AuthPayload {
  token: String!
  user: User!
}

# サブスクリプション(リアルタイム)
type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

クエリの書き方

# 基本的なクエリ
query {
  user(id: "1") {
    id
    name
    email
  }
}

# 変数を使用
query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
  }
}

# 複数のクエリを同時に実行
query GetUserAndPosts($userId: ID!) {
  user(id: $userId) {
    name
    email
  }
  posts(authorId: $userId, first: 10) {
    edges {
      node {
        title
        createdAt
      }
    }
  }
}

# エイリアス
query {
  admin: user(id: "1") {
    name
  }
  guest: user(id: "2") {
    name
  }
}

# フラグメント(再利用可能なフィールドセット)
fragment UserFields on User {
  id
  name
  email
  role
}

query {
  user(id: "1") {
    ...UserFields
    posts {
      title
    }
  }
}

# ディレクティブ
query GetUser($id: ID!, $includeEmail: Boolean!, $skipPosts: Boolean!) {
  user(id: $id) {
    name
    email @include(if: $includeEmail)
    posts @skip(if: $skipPosts) {
      title
    }
  }
}

ミューテーションの書き方

# ユーザー作成
mutation CreateUser($input: CreateUserInput!) {
  createUser(input: $input) {
    id
    name
    email
  }
}

# 変数
{
  "input": {
    "email": "user@example.com",
    "name": "田中太郎",
    "password": "password123"
  }
}

# 投稿の作成と公開
mutation CreateAndPublishPost($input: CreatePostInput!) {
  post: createPost(input: $input) {
    id
    title
    status
  }
}

Apollo Serverでの実装

プロジェクトのセットアップ

mkdir graphql-server
cd graphql-server
npm init -y
npm install @apollo/server graphql
npm install -D typescript @types/node ts-node nodemon
// package.json
{
  "scripts": {
    "dev": "nodemon --exec ts-node src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

基本的なサーバー実装

// src/index.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

// 型定義
const typeDefs = `#graphql
  type User {
    id: ID!
    email: String!
    name: String!
    posts: [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
    posts: [Post!]!
    post(id: ID!): Post
  }

  type Mutation {
    createUser(email: String!, name: String!): User!
    createPost(title: String!, content: String!, authorId: ID!): Post!
  }
`;

// サンプルデータ
const users = [
  { id: '1', email: 'alice@example.com', name: 'Alice' },
  { id: '2', email: 'bob@example.com', name: 'Bob' },
];

const posts = [
  { id: '1', title: 'GraphQL入門', content: 'GraphQLは...', authorId: '1' },
  { id: '2', title: 'TypeScript Tips', content: 'TypeScriptでは...', authorId: '1' },
  { id: '3', title: 'React Hooks', content: 'useStateは...', authorId: '2' },
];

// リゾルバー
const resolvers = {
  Query: {
    users: () => users,
    user: (_: unknown, { id }: { id: string }) => 
      users.find(user => user.id === id),
    posts: () => posts,
    post: (_: unknown, { id }: { id: string }) => 
      posts.find(post => post.id === id),
  },
  
  Mutation: {
    createUser: (_: unknown, { email, name }: { email: string; name: string }) => {
      const newUser = {
        id: String(users.length + 1),
        email,
        name,
      };
      users.push(newUser);
      return newUser;
    },
    createPost: (_: unknown, args: { title: string; content: string; authorId: string }) => {
      const newPost = {
        id: String(posts.length + 1),
        title: args.title,
        content: args.content,
        authorId: args.authorId,
      };
      posts.push(newPost);
      return newPost;
    },
  },
  
  // フィールドリゾルバー
  User: {
    posts: (parent: { id: string }) => 
      posts.filter(post => post.authorId === parent.id),
  },
  
  Post: {
    author: (parent: { authorId: string }) => 
      users.find(user => user.id === parent.authorId),
  },
};

// サーバー起動
const server = new ApolloServer({
  typeDefs,
  resolvers,
});

startStandaloneServer(server, {
  listen: { port: 4000 },
}).then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});

実践的な構成

src/
├── index.ts
├── schema/
│   ├── index.ts
│   ├── typeDefs/
│   │   ├── index.ts
│   │   ├── user.ts
│   │   ├── post.ts
│   │   └── comment.ts
│   └── resolvers/
│       ├── index.ts
│       ├── user.ts
│       ├── post.ts
│       └── comment.ts
├── datasources/
│   ├── index.ts
│   ├── UserDataSource.ts
│   └── PostDataSource.ts
├── models/
│   ├── User.ts
│   ├── Post.ts
│   └── Comment.ts
├── utils/
│   ├── auth.ts
│   └── errors.ts
└── context.ts
// src/schema/typeDefs/user.ts
export const userTypeDefs = `#graphql
  type User {
    id: ID!
    email: String!
    name: String!
    role: UserRole!
    posts: [Post!]!
    createdAt: DateTime!
  }

  enum UserRole {
    ADMIN
    EDITOR
    VIEWER
  }

  input CreateUserInput {
    email: String!
    name: String!
    password: String!
    role: UserRole
  }

  input UpdateUserInput {
    email: String
    name: String
    role: UserRole
  }

  extend type Query {
    users(role: UserRole): [User!]!
    user(id: ID!): User
    me: User
  }

  extend type Mutation {
    createUser(input: CreateUserInput!): User!
    updateUser(id: ID!, input: UpdateUserInput!): User!
    deleteUser(id: ID!): Boolean!
    login(email: String!, password: String!): AuthPayload!
  }

  type AuthPayload {
    token: String!
    user: User!
  }
`;
// src/schema/resolvers/user.ts
import { GraphQLError } from 'graphql';
import { Context } from '../../context';

export const userResolvers = {
  Query: {
    users: async (_: unknown, args: { role?: string }, context: Context) => {
      return context.dataSources.users.findAll(args.role);
    },
    
    user: async (_: unknown, { id }: { id: string }, context: Context) => {
      return context.dataSources.users.findById(id);
    },
    
    me: async (_: unknown, __: unknown, context: Context) => {
      if (!context.user) {
        throw new GraphQLError('Not authenticated', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      return context.dataSources.users.findById(context.user.id);
    },
  },
  
  Mutation: {
    createUser: async (
      _: unknown,
      { input }: { input: { email: string; name: string; password: string; role?: string } },
      context: Context
    ) => {
      const existingUser = await context.dataSources.users.findByEmail(input.email);
      if (existingUser) {
        throw new GraphQLError('Email already exists', {
          extensions: { code: 'BAD_USER_INPUT' },
        });
      }
      
      return context.dataSources.users.create(input);
    },
    
    updateUser: async (
      _: unknown,
      { id, input }: { id: string; input: { email?: string; name?: string; role?: string } },
      context: Context
    ) => {
      // 認証チェック
      if (!context.user) {
        throw new GraphQLError('Not authenticated', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      
      // 権限チェック
      if (context.user.id !== id && context.user.role !== 'ADMIN') {
        throw new GraphQLError('Not authorized', {
          extensions: { code: 'FORBIDDEN' },
        });
      }
      
      return context.dataSources.users.update(id, input);
    },
    
    login: async (
      _: unknown,
      { email, password }: { email: string; password: string },
      context: Context
    ) => {
      const user = await context.dataSources.users.authenticate(email, password);
      if (!user) {
        throw new GraphQLError('Invalid credentials', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      
      const token = context.dataSources.users.generateToken(user);
      return { token, user };
    },
  },
  
  User: {
    posts: async (parent: { id: string }, _: unknown, context: Context) => {
      return context.dataSources.posts.findByAuthorId(parent.id);
    },
  },
};
// src/datasources/UserDataSource.ts
import { PrismaClient, User } from '@prisma/client';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';

export class UserDataSource {
  private prisma: PrismaClient;

  constructor(prisma: PrismaClient) {
    this.prisma = prisma;
  }

  async findAll(role?: string): Promise<User[]> {
    return this.prisma.user.findMany({
      where: role ? { role } : undefined,
      orderBy: { createdAt: 'desc' },
    });
  }

  async findById(id: string): Promise<User | null> {
    return this.prisma.user.findUnique({
      where: { id },
    });
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.prisma.user.findUnique({
      where: { email },
    });
  }

  async create(input: {
    email: string;
    name: string;
    password: string;
    role?: string;
  }): Promise<User> {
    const hashedPassword = await bcrypt.hash(input.password, 10);
    
    return this.prisma.user.create({
      data: {
        email: input.email,
        name: input.name,
        password: hashedPassword,
        role: input.role || 'VIEWER',
      },
    });
  }

  async update(
    id: string,
    input: { email?: string; name?: string; role?: string }
  ): Promise<User> {
    return this.prisma.user.update({
      where: { id },
      data: input,
    });
  }

  async authenticate(email: string, password: string): Promise<User | null> {
    const user = await this.findByEmail(email);
    if (!user) return null;
    
    const valid = await bcrypt.compare(password, user.password);
    if (!valid) return null;
    
    return user;
  }

  generateToken(user: User): string {
    return jwt.sign(
      { userId: user.id, role: user.role },
      process.env.JWT_SECRET!,
      { expiresIn: '7d' }
    );
  }
}
// src/context.ts
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';
import { UserDataSource } from './datasources/UserDataSource';
import { PostDataSource } from './datasources/PostDataSource';

const prisma = new PrismaClient();

export interface Context {
  user: { id: string; role: string } | null;
  dataSources: {
    users: UserDataSource;
    posts: PostDataSource;
  };
}

export async function createContext({ req }: { req: { headers: { authorization?: string } } }): Promise<Context> {
  let user = null;
  
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (token) {
    try {
      const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
        userId: string;
        role: string;
      };
      user = { id: decoded.userId, role: decoded.role };
    } catch (error) {
      // トークンが無効な場合は無視
    }
  }
  
  return {
    user,
    dataSources: {
      users: new UserDataSource(prisma),
      posts: new PostDataSource(prisma),
    },
  };
}

クライアント側の実装

Apollo Client(React)

npm install @apollo/client graphql
// src/lib/apollo.ts
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';

const httpLink = createHttpLink({
  uri: 'http://localhost:4000/graphql',
});

const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('token');
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    },
  };
});

export const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          posts: {
            keyArgs: ['status', 'authorId'],
            merge(existing, incoming, { args }) {
              // ページネーションのマージロジック
              if (!args?.after) {
                return incoming;
              }
              return {
                ...incoming,
                edges: [...(existing?.edges || []), ...incoming.edges],
              };
            },
          },
        },
      },
    },
  }),
});
// src/App.tsx
import { ApolloProvider } from '@apollo/client';
import { client } from './lib/apollo';
import { UserList } from './components/UserList';

function App() {
  return (
    <ApolloProvider client={client}>
      <UserList />
    </ApolloProvider>
  );
}
// src/graphql/queries.ts
import { gql } from '@apollo/client';

export const GET_USERS = gql`
  query GetUsers($role: UserRole) {
    users(role: $role) {
      id
      name
      email
      role
    }
  }
`;

export const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      role
      posts {
        id
        title
        status
        createdAt
      }
    }
  }
`;

export const GET_POSTS = gql`
  query GetPosts($first: Int, $after: String, $status: PostStatus) {
    posts(first: $first, after: $after, status: $status) {
      edges {
        cursor
        node {
          id
          title
          content
          status
          author {
            id
            name
          }
          createdAt
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
      totalCount
    }
  }
`;
// src/graphql/mutations.ts
import { gql } from '@apollo/client';

export const CREATE_USER = gql`
  mutation CreateUser($input: CreateUserInput!) {
    createUser(input: $input) {
      id
      name
      email
      role
    }
  }
`;

export const UPDATE_USER = gql`
  mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
    updateUser(id: $id, input: $input) {
      id
      name
      email
      role
    }
  }
`;

export const LOGIN = gql`
  mutation Login($email: String!, $password: String!) {
    login(email: $email, password: $password) {
      token
      user {
        id
        name
        email
        role
      }
    }
  }
`;
// src/components/UserList.tsx
import { useQuery } from '@apollo/client';
import { GET_USERS } from '../graphql/queries';

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

export function UserList() {
  const { loading, error, data, refetch } = useQuery<{ users: User[] }>(GET_USERS);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>ユーザー一覧</h1>
      <button onClick={() => refetch()}>更新</button>
      <ul>
        {data?.users.map(user => (
          <li key={user.id}>
            {user.name} ({user.email}) - {user.role}
          </li>
        ))}
      </ul>
    </div>
  );
}
// src/components/CreateUserForm.tsx
import { useState } from 'react';
import { useMutation } from '@apollo/client';
import { CREATE_USER } from '../graphql/mutations';
import { GET_USERS } from '../graphql/queries';

export function CreateUserForm() {
  const [email, setEmail] = useState('');
  const [name, setName] = useState('');
  const [password, setPassword] = useState('');

  const [createUser, { loading, error }] = useMutation(CREATE_USER, {
    // キャッシュの更新
    update(cache, { data: { createUser } }) {
      const existingUsers = cache.readQuery<{ users: any[] }>({ query: GET_USERS });
      if (existingUsers) {
        cache.writeQuery({
          query: GET_USERS,
          data: { users: [...existingUsers.users, createUser] },
        });
      }
    },
    // または refetchQueries を使用
    // refetchQueries: [{ query: GET_USERS }],
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await createUser({
        variables: {
          input: { email, name, password },
        },
      });
      setEmail('');
      setName('');
      setPassword('');
    } catch (err) {
      console.error(err);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
        placeholder="メールアドレス"
        required
      />
      <input
        type="text"
        value={name}
        onChange={e => setName(e.target.value)}
        placeholder="名前"
        required
      />
      <input
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
        placeholder="パスワード"
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? '作成中...' : 'ユーザー作成'}
      </button>
      {error && <p className="error">{error.message}</p>}
    </form>
  );
}

N+1問題の解決

DataLoaderの使用

npm install dataloader
// src/loaders/index.ts
import DataLoader from 'dataloader';
import { PrismaClient, User, Post } from '@prisma/client';

export function createLoaders(prisma: PrismaClient) {
  return {
    userLoader: new DataLoader<string, User | null>(async (ids) => {
      const users = await prisma.user.findMany({
        where: { id: { in: ids as string[] } },
      });
      
      const userMap = new Map(users.map(user => [user.id, user]));
      return ids.map(id => userMap.get(id) || null);
    }),
    
    postsByAuthorLoader: new DataLoader<string, Post[]>(async (authorIds) => {
      const posts = await prisma.post.findMany({
        where: { authorId: { in: authorIds as string[] } },
      });
      
      const postsByAuthor = new Map<string, Post[]>();
      posts.forEach(post => {
        const existing = postsByAuthor.get(post.authorId) || [];
        postsByAuthor.set(post.authorId, [...existing, post]);
      });
      
      return authorIds.map(id => postsByAuthor.get(id) || []);
    }),
  };
}
// リゾルバーでの使用
const resolvers = {
  Post: {
    author: (parent: { authorId: string }, _: unknown, context: Context) => {
      return context.loaders.userLoader.load(parent.authorId);
    },
  },
  
  User: {
    posts: (parent: { id: string }, _: unknown, context: Context) => {
      return context.loaders.postsByAuthorLoader.load(parent.id);
    },
  },
};

ベストプラクティス

エラーハンドリング

import { GraphQLError } from 'graphql';

// カスタムエラー
export class AuthenticationError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
}

export class ForbiddenError extends GraphQLError {
  constructor(message: string) {
    super(message, {
      extensions: { code: 'FORBIDDEN' },
    });
  }
}

export class ValidationError extends GraphQLError {
  constructor(message: string, field?: string) {
    super(message, {
      extensions: { code: 'BAD_USER_INPUT', field },
    });
  }
}

ページネーション

# Cursor-based pagination(推奨)
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  cursor: String!
  node: Post!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

バリデーション

import { z } from 'zod';

const CreateUserSchema = z.object({
  email: z.string().email('有効なメールアドレスを入力してください'),
  name: z.string().min(2, '名前は2文字以上必要です').max(50),
  password: z.string().min(8, 'パスワードは8文字以上必要です'),
});

const resolvers = {
  Mutation: {
    createUser: async (_: unknown, { input }: any, context: Context) => {
      const validated = CreateUserSchema.parse(input);
      return context.dataSources.users.create(validated);
    },
  },
};

まとめ

概念 説明
スキーマ APIの型定義(Query, Mutation, Type)
リゾルバー データ取得のロジック
Query 読み取り操作
Mutation 書き込み操作
Subscription リアルタイム更新
DataLoader N+1問題の解決

GraphQLを活用して、効率的で型安全なAPIを構築しましょう!

11
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
11
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?