Interactive

Interface層

ランタイムのAPIからRequestオブジェクトを受け取りResponseを返すことで、APIエンドポイントを生成できます。弊社ではHonoをメインフレームワークとして、リソース操作にGraphQL(Pothos+Yoga)、非リソース操作にRESTを併用します。

弊社の推奨ルール

  • Honoがメイン - すべてのリクエストはHonoが受け取る
  • ContextStorage必須 - AsyncLocalStorageでコンテキストを管理
  • APIの併用 - リソース操作はGraphQL(Pothos+Yoga)、非リソース操作はREST
  • GraphQLならPrisma必須 - N+1問題の自動解決とコード簡潔性のため
  • RESTならDrizzleも可 - Hono単体の場合はDrizzleも選択可能
  • バリデーション優先 - リクエストは必ずZodでバリデーション
  • 薄い実装 - ビジネスロジックはApplication層に委譲

ClaudeCodeでの利用

REST APIエンドポイントの作成

HonoでRESTfulなエンドポイントを実装する場合

ユーザー登録のPOSTエンドポイントを作成して。Zodバリデーション付き

GraphQL スキーマの定義

PothosでGraphQLスキーマを定義する場合

UserとPostのGraphQLスキーマをPothosで定義。リレーションも含める

認証ミドルウェアの実装

JWT認証などのミドルウェアが必要な場合

JWT認証のミドルウェアを作成。Honoのc.setで認証情報を設定

ファイルアップロード処理

マルチパートフォームデータを扱う場合

画像アップロードのエンドポイントを作成。S3への保存を含む

Honoアプリケーションの基本構成

弊社では、Honoをメインフレームワークとして、すべてのHTTPリクエストを処理します。ContextStorageを使用してリクエストスコープのコンテキストを管理します。

型定義とContextStorage

import { Hono } from 'hono'
import { contextStorage } from 'hono/context-storage'
import { createYoga } from 'graphql-yoga'

// 環境変数の型定義
type Env = {
  stripeApiKey: string
  openaiApiKey: string
  jwksUrl: string
  firebaseConfig: string
  googleApiKey: string
  googleTokenUrl: string
  googleRefreshToken: string
  firebasePrivateKey: string
  firebaseProjectId: string
  firebaseClientEmail: string
  stripeWebhookSecret: string
}

// Bindingsの型定義(Cloudflare Workers等で使用)
type Bindings = {
  DB: D1Database
  QUEUE: Queue
  KV: KVNamespace
  AI: Ai
  BUCKET: R2Bucket
}

// Variablesの型定義(リクエストごとの変数)
type Variables = {
  user?: User
  session?: Session
  requestId: string
}

// Contextの完全な型定義
type Context = {
  Bindings: Bindings
  Variables: Variables
  env: Env
}

// Honoアプリケーションの初期化
const app = new Hono<Context>()

// ContextStorageミドルウェアを必ず使用
app.use(contextStorage())

// リクエストIDの生成
app.use('*', async (c, next) => {
  c.set('requestId', crypto.randomUUID())
  await next()
})

Yogaの統合

GraphQLサーバー(Yoga)はHonoにマウントして使用します。

import { createYoga } from 'graphql-yoga'
import { getContext } from '@/lib/context'
import { schema } from './schema'

// YogaContext型の定義
type YogaContext = {
  params: Record<string, string>
  request: Request
  env: Env
  var: Variables
}

// Yogaサーバーの作成
export const graphql = createYoga<YogaContext>({
  graphqlEndpoint: '/',
  schema: schema,
  context(ctx) {
    // ContextStorageからHonoのコンテキストを取得
    const honoContext = getContext<Context>()

    return {
      ...ctx,
      params: ctx.params,
      request: ctx.request,
      env: honoContext.env,
      var: honoContext.var,
    }
  },
})

// YogaをHonoにマウント
app.mount('/graphql', graphql)

ORMの選定基準

弊社では、APIの種類によってORMを使い分けます。GraphQLを使用する場合は必ずPrismaを選択し、REST APIのみの場合はDrizzleも選択可能です。

GraphQLでPrismaが必須の理由

GraphQLのリゾルバーで発生しやすいN+1問題を、Prismaは内部で自動的に解決します。これによりDataLoaderのような追加実装が不要になり、コードが簡潔になります。

// PrismaとPothosの統合でN+1問題を自動解決
builder.queryField('posts', (t) =>
  t.prismaField({
    type: ['Post'],
    resolve: async (query, root, args, ctx) => {
      // queryオブジェクトが自動的に最適化されたクエリを生成
      return ctx.prisma.post.findMany({
        ...query,  // Pothosが必要なフィールドを自動的に含める
        where: { published: true }
      })
    }
  })
)

// 自動的にauthorやcommentsも効率的に取得
builder.prismaObject('Post', {
  fields: (t) => ({
    id: t.exposeID('id'),
    title: t.exposeString('title'),
    author: t.relation('author'),     // N+1問題なし
    comments: t.relation('comments')   // N+1問題なし
  })
})

REST APIでのORM選択

REST APIのみの場合、明確なエンドポイントごとに必要なデータが決まるため、DrizzleでもPrismaでも実装可能です。

// Drizzleを使用したREST API
import { drizzle } from 'drizzle-orm/d1'
import { users, posts } from './schema'

app.get('/users/:id', async (c) => {
  const result = await db.select()
    .from(users)
    .where(eq(users.id, c.req.param('id')))
    .leftJoin(posts, eq(users.id, posts.authorId))

  return c.json(result)
})

// Prismaを使用したREST API
app.get('/users/:id', async (c) => {
  const user = await prisma.user.findUnique({
    where: { id: c.req.param('id') },
    include: { posts: true }
  })

  return c.json(user)
})

GraphQLとRESTの使い分け

弊社では、操作の性質によってGraphQLとRESTを使い分けます。

GraphQL(Pothos+Yoga+Prisma)を使用する場合

リソースのCRUD操作やリレーションを含むデータ取得に使用します。必ずPrismaと組み合わせ、Pothosでスキーマを定義し、YogaでGraphQLサーバーを提供します。

// ユーザー作成(リソース操作) - GraphQL
builder.mutationField('createUser', (t) =>
  t.prismaField({
    type: 'User',
    args: {
      email: t.arg.string({ required: true }),
      name: t.arg.string({ required: true })
    },
    resolve: async (query, root, args, ctx) => {
      return ctx.prisma.user.create({
        ...query,
        data: args
      })
    }
  })
)

// ポスト一覧取得(リレーション含む) - GraphQL
builder.queryField('posts', (t) =>
  t.prismaField({
    type: ['Post'],
    args: {
      published: t.arg.boolean()
    },
    resolve: async (query, root, args, ctx) => {
      return ctx.prisma.post.findMany({
        ...query,
        where: { published: args.published },
        include: { author: true, comments: true }
      })
    }
  })
)

REST(Hono)を使用する場合

ログイン、コールバック、ファイルアップロードなど、リソース操作ではない処理に使用します。

// ログイン(非リソース操作) - REST
app.post('/auth/login',
  zValidator('json', loginSchema),
  async (c) => {
    const { email, password } = c.req.valid('json')

    // ログインはリソースの要求ではない
    const token = await authService.authenticate(email, password)

    return c.json({ token })
  }
)

// OAuthコールバック - REST
app.get('/auth/callback/google', async (c) => {
  const code = c.req.query('code')
  const user = await googleAuthService.handleCallback(code)

  // リダイレクトやセッション設定
  return c.redirect('/dashboard')
})

// ファイルアップロード - REST
app.post('/upload/avatar',
  async (c) => {
    const body = await c.req.parseBody()
    const file = body['file'] as File

    const url = await uploadService.uploadToS3(file)
    return c.json({ url })
  }
)

Honoで非リソース操作を実装する

認証、コールバック、ファイル処理など、リソースモデルに適合しない操作はRESTで実装します。

基本的なエンドポイント定義

シンプルなCRUD操作のエンドポイントを定義します。

import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

const app = new Hono<{ Variables: Env }>()

// GETエンドポイント
app.get('/users/:id', async (c) => {
  const id = c.req.param('id')
  const user = await prisma.user.findUnique({
    where: { id }
  })

  if (!user) {
    return c.json({ error: 'User not found' }, 404)
  }

  return c.json(user)
})

Zodによるバリデーション

すべての入力値をZodでバリデーションし、型安全性を保証します。

// リクエストボディのスキーマ定義
const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
  password: z.string().min(8)
})

// POSTエンドポイントでバリデーション
app.post('/users',
  zValidator('json', createUserSchema),
  async (c) => {
    const data = c.req.valid('json')

    // Application層のServiceを呼び出し
    const service = new UserRegistrationService(
      c,
      { db: prisma, email: new EmailService() }
    )

    try {
      const user = await service.execute(data)
      return c.json(user, 201)
    } catch (error) {
      if (error instanceof BusinessError) {
        return c.json({ error: error.message }, error.statusCode)
      }
      throw error
    }
  }
)

ContextStorageを活用したコンテキスト管理

弊社では、AsyncLocalStorageベースのContextStorageを使用して、リクエストスコープのコンテキストをすべての層で利用できるようにします。

getContext関数の実装

// lib/context.ts
import { getContext as getHonoContext } from 'hono/context-storage'

/**
 * 現在のリクエストコンテキストを取得
 */
export function getContext<T = Context>(): T {
  const context = getHonoContext<T>()
  if (!context) {
    throw new Error('Context not found. Ensure contextStorage middleware is used.')
  }
  return context
}

Application層からのコンテキスト利用

// Application層でContextStorageを活用
export class PaymentService {
  constructor(
    private readonly c: Context,
    private readonly deps = {
      db: prisma,
      stripe: new StripeAdapter({
        apiKey: c.env.stripeApiKey  // 環境変数へアクセス
      })
    }
  ) {}

  async execute(props: Props) {
    // ユーザー情報はコンテキストから取得
    const user = this.c.var.user
    if (!user) {
      throw new Error('認証が必要です')
    }

    // リクエストIDを使ったロギング
    console.log(`[${this.c.var.requestId}] Processing payment for user ${user.id}`)

    // ビジネスロジック...
  }
}

PothosとPrismaでGraphQLスキーマを定義する

ユーザー、投稿、コメントなどのリソース操作と、それらのリレーションは、PothosとPrismaを連携させて型安全にスキーマ定義します。この組み合わせによりN+1問題が自動的に解決されます。

スキーマビルダーの設定

PrismaとPothosを連携させて、型安全かつ最適化されたスキーマを構築します。

import SchemaBuilder from '@pothos/core'
import PrismaPlugin from '@pothos/plugin-prisma'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

// ビルダーの初期化(Yogaコンテキストを含む)
const builder = new SchemaBuilder<{
  PrismaTypes: PrismaTypes
  Context: {
    user: User | null
    prisma: PrismaClient
    env: Env              // Honoからの環境変数
    var: Variables        // セッション変数
  }
}>({
  plugins: [PrismaPlugin],
  prisma: {
    client: prisma
  }
})

// ルートクエリの定義
builder.queryType({})
builder.mutationType({})

// スキーマのエクスポート
export const schema = builder.toSchema()

オブジェクトタイプの定義

Prismaモデルと連携したオブジェクトタイプを定義します。

// Userタイプの定義
builder.prismaObject('User', {
  fields: (t) => ({
    id: t.exposeID('id'),
    email: t.exposeString('email'),
    name: t.exposeString('name'),
    posts: t.relation('posts'),
    createdAt: t.expose('createdAt', { type: 'DateTime' })
  })
})

// Postタイプの定義
builder.prismaObject('Post', {
  fields: (t) => ({
    id: t.exposeID('id'),
    title: t.exposeString('title'),
    content: t.exposeString('content'),
    author: t.relation('author'),
    published: t.exposeBoolean('published')
  })
})

リゾルバーの実装

クエリとミューテーションのリゾルバーを実装します。

// クエリリゾルバー
builder.queryField('user', (t) =>
  t.prismaField({
    type: 'User',
    nullable: true,
    args: {
      id: t.arg.id({ required: true })
    },
    resolve: async (query, root, args, ctx) => {
      return ctx.prisma.user.findUnique({
        ...query,
        where: { id: args.id }
      })
    }
  })
)

// ミューテーションリゾルバー
builder.mutationField('createUser', (t) =>
  t.prismaField({
    type: 'User',
    args: {
      email: t.arg.string({ required: true }),
      name: t.arg.string({ required: true })
    },
    resolve: async (query, root, args, ctx) => {
      // Application層のServiceを呼び出し
      // Hono経由のコンテキストを利用
      const service = new UserRegistrationService(
        ctx as Context,  // YogaコンテキストをContextとして渡す
        {
          db: ctx.prisma,
          stripe: new StripeAdapter({ apiKey: ctx.env.stripeApiKey })
        }
      )

      return service.execute(args)
    }
  })
)

認証と認可の実装

JWTミドルウェア(Hono)

リクエストヘッダーからJWTトークンを検証し、ユーザー情報をコンテキストに設定します。

import { jwt } from 'hono/jwt'

// JWTミドルウェアの設定
app.use('/api/*', jwt({
  secret: c.env.JWT_SECRET
}))

// カスタム認証ミドルウェア
app.use('/api/*', async (c, next) => {
  const payload = c.get('jwtPayload')

  if (!payload?.userId) {
    return c.json({ error: 'Unauthorized' }, 401)
  }

  // ユーザー情報を取得
  const user = await prisma.user.findUnique({
    where: { id: payload.userId }
  })

  if (!user) {
    return c.json({ error: 'User not found' }, 404)
  }

  // コンテキストにユーザー情報を設定
  c.set('user', user)
  await next()
})

GraphQLコンテキスト(Pothos)

GraphQLのコンテキストに認証情報を含めます。

// コンテキスト生成関数
async function createContext({ req }: { req: Request }) {
  const token = req.headers.get('authorization')?.replace('Bearer ', '')

  let user = null
  if (token) {
    try {
      const payload = await verify(token, JWT_SECRET)
      user = await prisma.user.findUnique({
        where: { id: payload.userId }
      })
    } catch {
      // トークンが無効な場合はnull
    }
  }

  return {
    user,
    prisma
  }
}

// 認証が必要なフィールド
builder.queryField('me', (t) =>
  t.prismaField({
    type: 'User',
    nullable: true,
    resolve: async (query, root, args, ctx) => {
      if (!ctx.user) {
        throw new Error('Not authenticated')
      }
      return ctx.user
    }
  })
)

エラーハンドリング

統一されたエラーレスポンス

エラーの種類に応じて適切なHTTPステータスコードを返します。

// エラーハンドリングミドルウェア
app.onError((err, c) => {
  console.error(err)

  // ビジネスエラー
  if (err instanceof BusinessError) {
    return c.json({
      error: {
        code: err.code,
        message: err.message
      }
    }, err.statusCode)
  }

  // バリデーションエラー
  if (err instanceof z.ZodError) {
    return c.json({
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Invalid input',
        details: err.errors
      }
    }, 400)
  }

  // その他のエラー
  return c.json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'Something went wrong'
    }
  }, 500)
})

よくある問題

ビジネスロジックがInterface層に漏れる

Interface層は薄く保ち、ビジネスロジックはApplication層に委譲します。バリデーションと認証のみを担当します。

エラーレスポンスが統一されていない

すべてのエンドポイントで同じ形式のエラーレスポンスを返すよう、エラーハンドリングミドルウェアを使用します。

型定義の重複

ZodスキーマからTypeScriptの型を推論し、手動での型定義を避けます。PothosではPrismaの型を自動的に利用します。