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の型を自動的に利用します。