導入:あなたのチームが呼んでいる「型安全」は、たぶん本当の型安全ではない
フロントエンドとバックエンドが別リポジトリ・別言語で分離された現代的なWebシステムにおいて、「エンドツーエンドの型安全」を本当に達成できているチームは、驚くほど少ないのが現実です。
典型的な負債パターンを列挙します。現場で見覚えがあるはずです。
- フロントエンドがバックエンドのレスポンス型を手書きで再定義しており、スキーマ変更のたびに静かに乖離していく
- APIリクエスト・レスポンスのどこかで
anyまたはunknownが登場し、その先のロジックはすべて「願望ベースのコーディング」になっている - Swagger / OpenAPI ドキュメントは存在するが、実装と同期する保証がなく、リリース後に読むと嘘が書いてある
- Node.jsバックエンドを採用してtRPCで型共有していたが、ビジネス都合でGo/Pythonに移行した瞬間、型の連携が崩壊した
- フロントのデプロイとバックのデプロイで非互換なスキーマが一瞬でも混在し、本番で突然
TypeError: Cannot read properties of undefinedが噴出した
これらに共通する根本原因はただ一つ、「型はソースコードから導出するもの」という発想そのものにあります。
本記事で提示する解決策は、発想を逆転させます。クライアントもサーバーも、単一のOpenAPI仕様書(契約)から型を派生させる ———「契約優先(Contract-First)」アーキテクチャです。これにより、フロントエンドとバックエンドはGitの同一コミットで契約を共有し、ビルド時に双方の整合性が検証されます。
本稿のスタックはNext.js 16(App Router)× Go × AWS(Terraform)です。tRPCのような「特定言語のエコシステムに閉じた型共有」でもなく、GraphQLのような「スキーマ層と追加ランタイムを抱え込む」選択でもない、長期的な保守性と言語非依存性を両立させる現実解を、実装レベルで最後まで示します。
本論①:アーキテクチャ選定 —— なぜ「OpenAPI」なのか
結論を先に示した上で、他の選択肢との定量比較で妥当性を担保します。
代表的な選択肢の比較
| 観点 | tRPC | GraphQL | gRPC | OpenAPI (REST) | Server Actions単独 |
|---|---|---|---|---|---|
| クライアント・サーバーの言語制約 | TS↔TS限定 | 任意 | 任意 | 任意 | TS↔TS限定 |
| 契約の明示性 | 暗黙(TS型推論) | 明示(SDL) | 明示(.proto) | 明示(YAML/JSON) | 暗黙 |
| ランタイム依存の追加 | 軽量 | 重い(スキーマサーバー, N+1対策, Apollo/Relay) | 重い(HTTP/2, Envoy等) | なし(素のHTTP) | なし |
| エコシステム(Lint, Mock, Docs) | 限定的 | 豊富 | 豊富 | 極めて豊富 | 発展中 |
| 外部パートナー公開への適性 | 低い | 中程度 | 低(WebAPIには不向き) | 高い | 低い |
| コード生成器の成熟度 | N/A | 高い | 高い | 高い | N/A |
| キャッシュ戦略の自由度 | 中 | 低(POST中心) | 低 | 高(HTTPキャッシュ準拠) | Next.js依存 |
| ベンダーロックイン | TS | Apollo等 | Envoy等 | なし | Next.js |
OpenAPIを選ぶ理由(CTOに説明するなら)
- 契約が第一級オブジェクトである:YAMLは人間にもツールにも読める。Pull Requestのdiffはそのまま「APIの変更契約書」となり、レビュー対象になる。tRPCでは型推論の結果を言語の外に持ち出せない。
- 言語非依存:採用する言語を後から入れ替えても契約は変わらない。「今はGo、一部はPythonで機械学習系、将来的にエッジはTypeScript」という現実的な多言語シフトに耐える。
- ランタイム依存ゼロ:REST + JSON はCDN・API Gateway・WAF・ログ基盤・監視ツール、すべてと衝突しない。HTTPセマンティクス(ETag, Cache-Control, 条件付きリクエスト)をそのまま使える。
- 経済性:追加のミドルウェア(Apollo Server, Envoy)やサイドカーが不要。初期コストだけでなく、障害時のデバッグ可能性という見えない運用コストまで下げる。
- OpenAPI 3.1は JSON Schema 2020-12 準拠:discriminator、
oneOf、const、nullableがJSON Schemaと完全整合するため、同じ仕様でZodバリデーションも自動生成できる。
唯一のトレードオフは「RESTは冗長」という批判ですが、後述するコード生成によりボイラープレートはほぼゼロになります。つまり、RESTのデメリットだけを打ち消し、メリットだけを享受できる構図です。
本論②:契約優先ワークフロー全体像
実装に入る前に、本アーキテクチャのデータフローと責務分離を明確化します。
┌──────────────────────────────┐
│ openapi/v1.yaml │ ← 唯一の真実の源
│ (OpenAPI 3.1 仕様書) │
└──────────┬───────────────────┘
│ git commit
┌─────────────────┼─────────────────┐
│ 生成(CIで検証)│ │
▼ ▼ ▼
┌────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Go: oapi- │ │ TS: openapi- │ │ Zod / Mock / │
│ codegen │ │ typescript + │ │ Spectral(Lint) │
│ (strict iface)│ │ openapi-fetch │ │ │
└───────┬────────┘ └───────┬──────┘ └─────────────────┘
│ │
▼ ▼
┌────────────────┐ ┌──────────────────────┐
│ Go API (Fargate)│ │ Next.js 16 (Vercel) │
│ - strict server │ │ - RSC │
│ - Problem Details│ │ - Server Actions │
│ - ctx/Timeout │ │ - Full Jitter retry │
│ - OTel │ │ - Circuit Breaker │
└────────────────┘ └──────────────────────┘
重要なのは、Go側もTS側も手書き型を一切持たないという不可侵の原則です。手書きが生まれた瞬間、契約は腐敗し始めます。
本論③:コードで語る —— OpenAPI 3.1 仕様設計
題材として、B2Bの「書籍在庫管理API」を考えます。簡易に見えて、discriminated union(条件分岐を持つ型)やRFC 7807 Problem Detailsを適切に扱う必要があり、契約設計の勘所がすべて詰まっています。
# openapi/v1.yaml
openapi: 3.1.0
info:
title: Inventory API
version: 1.0.0
description: |
書籍在庫管理API。すべてのエラーはRFC 7807 Problem Detailsで返す。
servers:
- url: https://api.example.com
security:
- bearerAuth: []
paths:
/books:
get:
operationId: listBooks
summary: 書籍一覧の取得
parameters:
- in: query
name: cursor
schema: { type: string }
description: 前回レスポンスのnextCursorを指定(初回は省略)
- in: query
name: limit
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
"200":
description: OK
headers:
ETag:
schema: { type: string }
description: 弱い検証子。If-None-Matchで利用可能。
content:
application/json:
schema: { $ref: "#/components/schemas/BookList" }
"4XX": { $ref: "#/components/responses/ProblemResponse" }
"5XX": { $ref: "#/components/responses/ProblemResponse" }
post:
operationId: createBook
summary: 書籍の新規登録(冪等)
parameters:
- in: header
name: Idempotency-Key
required: true
schema: { type: string, minLength: 16, maxLength: 255 }
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CreateBookInput" }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/Book" }
"409":
description: Conflict(ISBN重複 等)
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
"4XX": { $ref: "#/components/responses/ProblemResponse" }
"5XX": { $ref: "#/components/responses/ProblemResponse" }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
responses:
ProblemResponse:
description: RFC 7807 Problem Details
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
schemas:
Book:
type: object
required: [id, isbn, title, status, createdAt]
properties:
id: { type: string, format: uuid }
isbn: { type: string, pattern: "^[0-9]{13}$" }
title: { type: string, minLength: 1, maxLength: 255 }
status:
# discriminated unionで「状態ごとに保持する情報」を型レベルで分岐
oneOf:
- $ref: "#/components/schemas/StatusAvailable"
- $ref: "#/components/schemas/StatusReserved"
- $ref: "#/components/schemas/StatusOutOfStock"
discriminator:
propertyName: kind
mapping:
available: "#/components/schemas/StatusAvailable"
reserved: "#/components/schemas/StatusReserved"
outOfStock: "#/components/schemas/StatusOutOfStock"
createdAt: { type: string, format: date-time }
StatusAvailable:
type: object
required: [kind, stock]
properties:
kind: { type: string, const: "available" }
stock: { type: integer, minimum: 1 }
StatusReserved:
type: object
required: [kind, reservedUntil, reservedBy]
properties:
kind: { type: string, const: "reserved" }
reservedUntil: { type: string, format: date-time }
reservedBy: { type: string, format: uuid }
StatusOutOfStock:
type: object
required: [kind, restockedAt]
properties:
kind: { type: string, const: "outOfStock" }
restockedAt: { type: string, format: date-time, nullable: true }
CreateBookInput:
type: object
required: [isbn, title]
properties:
isbn: { type: string, pattern: "^[0-9]{13}$" }
title: { type: string, minLength: 1, maxLength: 255 }
BookList:
type: object
required: [items]
properties:
items: { type: array, items: { $ref: "#/components/schemas/Book" } }
nextCursor: { type: string, nullable: true }
# RFC 7807 Problem Details
Problem:
type: object
required: [type, title, status]
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer, minimum: 400, maximum: 599 }
detail: { type: string }
instance: { type: string, format: uri-reference }
# 拡張フィールド:フィールドレベルのバリデーションエラー
errors:
type: array
items:
type: object
required: [pointer, reason]
properties:
pointer: { type: string, description: RFC 6901 JSON Pointer }
reason: { type: string }
traceId: { type: string, description: 分散トレースとの紐付け }
設計上の要点
statusフィールドはoneOf+discriminatorによるタグ付きユニオン。これにより TS 側ではif (book.status.kind === "reserved")で narrow でき、reservedUntilが必ず存在することがコンパイル時に保証されます。- エラーは
application/problem+jsonで RFC 7807 準拠。「どのフィールドがなぜ失敗したか」をerrors[]で返すことで、フロント側のフォーム表示が型安全に実装できます。 Idempotency-Keyを必須にして POST の冪等性を契約上明示。再試行ロジック(後述)と整合します。ETagを契約に含めることで、クライアント側で条件付きリクエストとキャッシュ制御の余地を残します(REST らしい意匠)。
本論④:Go側の実装 —— 「strict server interface」による型の二重防壁
oapi-codegen には通常モードと strict-server モードがあります。必ず後者を使ってください。strict モードはハンドラのシグネチャが「OpenAPIのレスポンス型そのもの」になるため、Goの型システムがそのまま契約違反を弾いてくれます。
生成コマンド
# tools.go に依存を固定し、go generate で再現性を担保
oapi-codegen -generate types,strict-server,chi-server \
-package api \
-o internal/api/api.gen.go \
openapi/v1.yaml
ハンドラ実装(抜粋)
// internal/api/handler.go
package api
import (
"context"
"errors"
"log/slog"
"time"
"github.com/google/uuid"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
)
// BookService はドメイン層のインタフェース。Clean Architectureで言うユースケース境界。
type BookService interface {
List(ctx context.Context, cursor *string, limit int) ([]Book, *string, error)
Create(ctx context.Context, input CreateBookInput, idemKey string) (Book, error)
}
// ErrIdempotencyReplay は同一キーで異なるボディのリクエストが来た場合に返す。
var ErrIdempotencyReplay = errors.New("idempotency key reused with different payload")
var ErrISBNDuplicated = errors.New("isbn already exists")
type Handler struct {
svc BookService
logger *slog.Logger
}
func NewHandler(svc BookService, logger *slog.Logger) *Handler {
return &Handler{svc: svc, logger: logger}
}
// 生成された StrictServerInterface を実装する。
// 戻り値は自動生成された型(ListBooks200JSONResponse など)なので、
// レスポンス形式のミスは「Goのビルドエラー」として検出される。
func (h *Handler) ListBooks(
ctx context.Context,
req ListBooksRequestObject,
) (ListBooksResponseObject, error) {
// 入力層での追加バリデーション(契約は通過しているが、業務不変条件を検査)
limit := 20
if req.Params.Limit != nil {
limit = *req.Params.Limit
}
// OpenTelemetryでドメイン境界にスパンを張る
ctx, span := otel.Tracer("inventory-api").Start(ctx, "BookService.List")
defer span.End()
span.SetAttributes(attribute.Int("books.limit", limit))
items, next, err := h.svc.List(ctx, req.Params.Cursor, limit)
if err != nil {
// ドメインエラーは Problem Details へマッピング(後述)
return nil, err
}
return ListBooks200JSONResponse{
Body: BookList{
Items: items,
NextCursor: next,
},
Headers: ListBooks200ResponseHeaders{
ETag: weakETag(items),
},
}, nil
}
func (h *Handler) CreateBook(
ctx context.Context,
req CreateBookRequestObject,
) (CreateBookResponseObject, error) {
// Idempotency-Key は OpenAPI で required にしているため req.Params.IdempotencyKey は非nil
idemKey := req.Params.IdempotencyKey
// 短いタイムアウトを明示。親ctxのキャンセルにも従う。
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
book, err := h.svc.Create(ctx, *req.Body, idemKey)
switch {
case errors.Is(err, ErrISBNDuplicated):
// 409 Conflict は契約上明示的に定義済み
return CreateBook409ApplicationProblemPlusJSONResponse{
Body: Problem{
Type: ptr("https://api.example.com/problems/isbn-duplicated"),
Title: "ISBN already exists",
Status: 409,
Detail: ptr("The provided ISBN conflicts with an existing record."),
},
}, nil
case errors.Is(err, ErrIdempotencyReplay):
return nil, &DomainError{
Status: 409,
TypeURI: "https://api.example.com/problems/idempotency-replay",
Title: "Idempotency key reused",
Detail: "The same Idempotency-Key was used with a different payload.",
}
case err != nil:
return nil, err
}
return CreateBook201JSONResponse(book), nil
}
func ptr[T any](v T) *T { return &v }
func weakETag(items []Book) string {
// 弱い検証子:コンテンツハッシュでよい。ここでは省略。
return `W/"` + uuid.NewString() + `"`
}
Problem Details への統一的マッピング
ドメインエラーを横断的に Problem Details に変換するミドルウェアを 1 箇所に集約します。ハンドラで分岐を書かせないことが「忘れると本番で500が漏れる」類の事故を根絶する要諦です。
// internal/api/errors.go
package api
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"go.opentelemetry.io/otel/trace"
)
type DomainError struct {
Status int
TypeURI string
Title string
Detail string
Field *FieldViolation
}
type FieldViolation struct {
Pointer string
Reason string
}
func (e *DomainError) Error() string { return e.Title }
// ProblemDetailsMiddleware は下流ハンドラのpanic/errorをRFC 7807に変換する。
// 機密情報の漏洩防止のため、未分類エラーは常に "internal" として隠蔽する。
func ProblemDetailsMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
writeProblem(r.Context(), w, logger, http.StatusInternalServerError,
"https://api.example.com/problems/internal",
"Internal Server Error",
"An unexpected error occurred.",
nil,
)
logger.ErrorContext(r.Context(), "panic recovered",
slog.Any("panic", rec),
)
}
}()
next.ServeHTTP(w, r)
})
}
}
// oapi-codegenのStrictHandlerFuncで、ハンドラから返されたerrorを捕捉するフック。
// これを使うと、ハンドラはビジネスロジックのみ書けばよく、HTTP層を知らなくてよい。
func ErrorToProblem(logger *slog.Logger) StrictHTTPMiddlewareFunc {
return func(f StrictHandlerFunc, _ string) StrictHandlerFunc {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, req any) (any, error) {
res, err := f(ctx, w, r, req)
if err == nil {
return res, nil
}
var de *DomainError
if errors.As(err, &de) {
writeProblem(ctx, w, logger, de.Status, de.TypeURI, de.Title, de.Detail, de.Field)
return nil, nil // consumed
}
// context起因は499 Client Closed Request相当で返したいが、
// 標準がないのでここでは504で統一し、ログで区別する。
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
logger.WarnContext(ctx, "request aborted", slog.Any("cause", err))
writeProblem(ctx, w, logger, http.StatusGatewayTimeout,
"https://api.example.com/problems/timeout",
"Gateway Timeout", "The upstream request exceeded the time budget.", nil)
return nil, nil
}
// 未分類はすべて隠蔽:機密情報がレスポンスに漏れない保証
logger.ErrorContext(ctx, "unhandled error", slog.Any("error", err))
writeProblem(ctx, w, logger, http.StatusInternalServerError,
"https://api.example.com/problems/internal",
"Internal Server Error", "An unexpected error occurred.", nil)
return nil, nil
}
}
}
func writeProblem(ctx context.Context, w http.ResponseWriter, logger *slog.Logger,
status int, typeURI, title, detail string, field *FieldViolation) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
p := Problem{
Type: &typeURI,
Title: title,
Status: status,
Detail: &detail,
}
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
tid := sc.TraceID().String()
p.TraceId = &tid
}
if field != nil {
p.Errors = &[]struct {
Pointer string `json:"pointer"`
Reason string `json:"reason"`
}{{Pointer: field.Pointer, Reason: field.Reason}}
}
if err := json.NewEncoder(w).Encode(p); err != nil {
logger.ErrorContext(ctx, "failed to encode Problem", slog.Any("error", err))
}
}
設計上の不変条件
- ハンドラは
errorを返すだけでよい。HTTPレスポンスの構築・ヘッダ操作・ロギングは境界層の責務。これにより、ハンドラ単体テストがHTTPに汚染されない(単一責任原則)。 - 未分類エラーは必ず隠蔽。
detailにスタックトレースや内部ID、SQL文を含めない。Webアプリのセキュリティ事故の多くはエラーレスポンスからの情報漏洩です(OWASP A05: Security Misconfiguration)。 - traceIdをレスポンスに埋め込む。障害時に顧客から提示されたtraceIdから、Jaeger/Tempoで該当リクエストを10秒で特定できる。この設計が、夜間オンコール対応の体力を決定します。
本論⑤:Next.js 16側の実装 —— 型安全クライアントの「多層防御」
TypeScript 側は openapi-typescript で型を生成し、openapi-fetch でランタイムクライアントを作ります。それ単体は素朴なfetchラッパーですが、ミドルウェアチェーンとして「タイムアウト→リトライ→サーキットブレーカー→トレース伝播」を合成し、本番運用に耐える厚みを持たせます。
型生成
# package.json scripts: "gen:api": "openapi-typescript openapi/v1.yaml -o src/lib/api/schema.d.ts"
クライアント・ファクトリ
// src/lib/api/client.ts
import createClient, { type Middleware } from "openapi-fetch";
import type { paths } from "./schema";
import { withRetry } from "./retry";
import { withCircuitBreaker } from "./circuit-breaker";
import { withTimeout } from "./timeout";
import { withTracing } from "./tracing";
import { withAuth } from "./auth";
export type ApiClient = ReturnType<typeof createClient<paths>>;
export interface ApiClientOptions {
readonly baseUrl: string;
readonly getAuthToken: () => Promise<string>;
readonly timeoutMs?: number;
readonly retry?: {
readonly maxAttempts: number;
readonly baseDelayMs: number;
readonly maxDelayMs: number;
};
}
// middlewareの合成順は意図的。逆順に「外側」から効く。
// 1. tracing (最外) : 全リクエストにtraceparentを付与
// 2. auth : JWT付与
// 3. circuitBreaker : OPEN中は即fail
// 4. retry : 5xx/ネットワーク断で再試行
// 5. timeout (最内) : 1リクエストの上限
export const createApiClient = (opts: ApiClientOptions): ApiClient => {
const client = createClient<paths>({ baseUrl: opts.baseUrl });
const middlewares: readonly Middleware[] = [
withTracing(),
withAuth(opts.getAuthToken),
withCircuitBreaker({
failureThreshold: 5,
recoveryTimeoutMs: 30_000,
halfOpenMaxCalls: 2,
}),
withRetry(opts.retry ?? {
maxAttempts: 3,
baseDelayMs: 100,
maxDelayMs: 2_000,
}),
withTimeout(opts.timeoutMs ?? 5_000),
];
for (const m of middlewares) client.use(m);
return client;
};
Full Jitter 指数バックオフ
リトライは「雑に実装すると祭りを招く」代表格です。複数クライアントが同時にリトライすると、回復途中のサーバーに波状攻撃を仕掛ける現象(Thundering Herd)が発生します。AWS Architecture Blog でも言及される Full Jitter 方式を採用します。
// src/lib/api/retry.ts
import type { Middleware } from "openapi-fetch";
export interface RetryConfig {
readonly maxAttempts: number;
readonly baseDelayMs: number;
readonly maxDelayMs: number;
}
// リトライ可否の判定。冪等と明示的なメソッド or 明示的なIdempotency-Keyのみ許可。
const isRetryable = (req: Request, res?: Response): boolean => {
if (res) {
// 5xxと429は再試行候補。4xxは原則再試行しない。
if (res.status >= 500 || res.status === 429) {
// ただしPOST/PATCH/PUT/DELETEは Idempotency-Key があるときのみ
if (req.method === "GET" || req.method === "HEAD") return true;
return req.headers.has("Idempotency-Key");
}
return false;
}
// ネットワーク断(res undefined)はGET/HEADか、Idempotency-Keyを持つときのみ
if (req.method === "GET" || req.method === "HEAD") return true;
return req.headers.has("Idempotency-Key");
};
// Full Jitter: delay = random(0, min(cap, base * 2^attempt))
const fullJitterDelay = (attempt: number, cfg: RetryConfig): number => {
const exp = Math.min(cfg.maxDelayMs, cfg.baseDelayMs * 2 ** attempt);
return Math.random() * exp;
};
export const withRetry = (cfg: RetryConfig): Middleware => ({
async onRequest({ request }) {
// 後段で参照するためにRequestをそのまま通す
return request;
},
async onResponse({ request, response }) {
if (response.ok) return response;
// Middlewareからはリクエストを「やり直す」ための素朴なフックがないため、
// openapi-fetchでは明示的に `fetch` を持ち直すラッパーを使う想定。
// ここではロジックの提示に留め、実際はカスタムfetch関数で包む。
return response;
},
});
// 実践的には、openapi-fetchに渡す `fetch` を自前で差し替える構成が堅牢。
export const retryingFetch = (cfg: RetryConfig): typeof fetch => {
return async (input, init) => {
const req = input instanceof Request ? input.clone() : new Request(input, init);
let lastErr: unknown;
for (let attempt = 0; attempt < cfg.maxAttempts; attempt++) {
try {
const res = await fetch(req.clone());
if (!isRetryable(req, res)) return res;
// Retry-After ヘッダを尊重(サーバーからの協調的スロットリング)
const retryAfter = res.headers.get("Retry-After");
const delay = retryAfter
? Math.min(Number(retryAfter) * 1000, cfg.maxDelayMs)
: fullJitterDelay(attempt, cfg);
if (attempt === cfg.maxAttempts - 1) return res;
await new Promise((r) => setTimeout(r, delay));
continue;
} catch (err) {
lastErr = err;
if (!isRetryable(req)) throw err;
if (attempt === cfg.maxAttempts - 1) throw err;
await new Promise((r) => setTimeout(r, fullJitterDelay(attempt, cfg)));
}
}
throw lastErr;
};
};
設計上の要点
- 冪等でないリクエストは
Idempotency-Keyがあるときだけ再試行する。POSTを無条件で再試行して二重決済が走るのは、実際に起きている事故です。 Retry-Afterヘッダを尊重する。サーバーが「今はやめて」と明示的に言っているときに無視するのは敵対的行動。- Full Jitter: 指数バックオフ単独では依然として同時再試行が揃うため、ランダム化により分散させる。
random(0, cap)の区間全域を使うFull Jitterが、AWS の実測で最も再試行率を抑えるとされています。
Circuit Breaker
障害中のサービスに殺到を許さない最後の砦です。
// src/lib/api/circuit-breaker.ts
export type CircuitState = "closed" | "open" | "halfOpen";
export interface CircuitBreakerConfig {
readonly failureThreshold: number; // 連続失敗でOPENへ
readonly recoveryTimeoutMs: number; // OPEN→halfOpen猶予
readonly halfOpenMaxCalls: number; // halfOpen中に試す本数
}
export class CircuitBreakerOpenError extends Error {
constructor() {
super("circuit breaker is open");
this.name = "CircuitBreakerOpenError";
}
}
export class CircuitBreaker {
private state: CircuitState = "closed";
private failures = 0;
private openedAt = 0;
private halfOpenInFlight = 0;
constructor(private readonly cfg: CircuitBreakerConfig) {}
canPass(): boolean {
if (this.state === "closed") return true;
if (this.state === "open") {
if (Date.now() - this.openedAt >= this.cfg.recoveryTimeoutMs) {
this.state = "halfOpen";
this.halfOpenInFlight = 0;
} else {
return false;
}
}
if (this.state === "halfOpen") {
if (this.halfOpenInFlight >= this.cfg.halfOpenMaxCalls) return false;
this.halfOpenInFlight++;
return true;
}
return true;
}
onSuccess(): void {
if (this.state === "halfOpen") {
// 半開で成功 → クローズに戻す
this.state = "closed";
this.halfOpenInFlight = 0;
}
this.failures = 0;
}
onFailure(): void {
this.failures++;
if (this.state === "halfOpen" || this.failures >= this.cfg.failureThreshold) {
this.state = "open";
this.openedAt = Date.now();
}
}
}
タイムアウトと OpenTelemetry 伝播
// src/lib/api/timeout.ts
import type { Middleware } from "openapi-fetch";
export const withTimeout = (ms: number): Middleware => ({
async onRequest({ request }) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error("timeout")), ms);
// AbortControllerをRequestに繋ぐ。完了時にclearTimeoutするため、
// onResponseフックで `timer` を参照できるようヘッダ経由で共有しても良い。
const req = new Request(request, { signal: controller.signal });
(req as Request & { __timer?: ReturnType<typeof setTimeout> }).__timer = timer;
return req;
},
async onResponse({ request, response }) {
const t = (request as Request & { __timer?: ReturnType<typeof setTimeout> }).__timer;
if (t) clearTimeout(t);
return response;
},
});
// src/lib/api/tracing.ts
import type { Middleware } from "openapi-fetch";
import { trace, context, propagation } from "@opentelemetry/api";
// W3C Trace Context (traceparent) をアウトバウンドリクエストに自動付与。
// Goバックエンドのoteltrace middlewareが受け取り、そのままスパンを繋げる。
export const withTracing = (): Middleware => ({
async onRequest({ request }) {
const tracer = trace.getTracer("nextjs-frontend");
const span = tracer.startSpan(`HTTP ${request.method} ${new URL(request.url).pathname}`);
const ctx = trace.setSpan(context.active(), span);
const headers = new Headers(request.headers);
propagation.inject(ctx, headers, {
set: (carrier, key, value) => (carrier as Headers).set(key, value),
});
// スパンはonResponseで終了したいが、middlewareで値を運ぶのは煩雑なので
// 実運用ではopenapi-fetchのラッパー側でspan lifecycleを管理する。
return new Request(request, { headers });
},
});
本論⑥:RSC と Server Actions からの呼び出し
App Router においてAPIクライアントがどこで動くかは重要な設計判断です。RSCは常にサーバー境界にいるため、ブラウザにAPIトークンやBaseURLが漏れないという副次的なメリットがあります。
RSC から読み取り(リスト取得)
// src/app/books/page.tsx
import type { components } from "@/lib/api/schema";
import { createApiClient } from "@/lib/api/client";
import { getServerAuthToken } from "@/lib/auth/server";
type Book = components["schemas"]["Book"];
// 静的再生(ISR):30秒キャッシュ、タグで明示的に失効可能
export const revalidate = 30;
export default async function BooksPage() {
const api = createApiClient({
baseUrl: process.env.INTERNAL_API_BASE_URL!, // ブラウザに露出させない
getAuthToken: getServerAuthToken,
});
const { data, error, response } = await api.GET("/books", {
params: { query: { limit: 50 } },
// Next.jsの拡張fetchオプションも型が通る
next: { tags: ["books"], revalidate: 30 },
});
if (error) {
// errorはProblem型。discriminated unionで分岐可能
return <ErrorPanel problem={error} traceId={response.headers.get("X-Trace-Id")} />;
}
return (
<ul aria-label="書籍一覧" className="space-y-2">
{data.items.map((b) => <BookItem key={b.id} book={b} />)}
</ul>
);
}
function BookItem({ book }: { readonly book: Book }) {
// discriminated unionのnarrowing:存在保証された型プロパティにアクセス可能
switch (book.status.kind) {
case "available":
return <li>{book.title}(在庫 {book.status.stock})</li>;
case "reserved":
return (
<li>
{book.title}(<time dateTime={book.status.reservedUntil}>
{new Date(book.status.reservedUntil).toLocaleString("ja-JP")}
</time> まで予約中)
</li>
);
case "outOfStock":
return (
<li aria-live="polite">
{book.title}(在庫切れ
{book.status.restockedAt
? ` — 再入荷予定: ${new Date(book.status.restockedAt).toLocaleDateString("ja-JP")}`
: ""})
</li>
);
// default は不要:Kindの網羅をTSが検証する。新しい状態が仕様に追加されると、
// ここでビルドが通らず、UI側の未実装を見逃さない。
}
}
Server Action から書き込み(Zodによる二重防御)
契約で型が保証されていても、Server Actionの入口では必ず runtime バリデーションを行います。理由は2つ:
- Server Actionsは事実上の公開エンドポイントであり、呼び出し元はブラウザから改竄可能
- 型は「信頼できる世界での前提条件」。信頼境界を越えるときは常にバリデーションという一次証明を残す
// src/app/books/actions.ts
"use server";
import { z } from "zod";
import { revalidateTag } from "next/cache";
import { randomUUID } from "node:crypto";
import { createApiClient } from "@/lib/api/client";
import { getServerAuthToken } from "@/lib/auth/server";
// OpenAPIの正規表現をそのままZodに写す:ISBN-13(数字13桁)
const createBookSchema = z.object({
isbn: z.string().regex(/^[0-9]{13}$/, "ISBN-13は数字13桁"),
title: z.string().min(1).max(255),
});
export type CreateBookState =
| { readonly status: "idle" }
| { readonly status: "success"; readonly bookId: string }
| { readonly status: "invalid"; readonly fieldErrors: Record<string, readonly string[]> }
| { readonly status: "conflict"; readonly message: string }
| { readonly status: "error"; readonly traceId?: string };
export async function createBookAction(
_prev: CreateBookState,
formData: FormData,
): Promise<CreateBookState> {
const parsed = createBookSchema.safeParse({
isbn: formData.get("isbn"),
title: formData.get("title"),
});
if (!parsed.success) {
return {
status: "invalid",
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
};
}
const api = createApiClient({
baseUrl: process.env.INTERNAL_API_BASE_URL!,
getAuthToken: getServerAuthToken,
});
// 冪等キーはServer Action内で発行。リトライしても同じキーが使われるよう、
// 実運用ではユーザー入力のハッシュも混ぜる(同一フォーム送信の識別)。
const idempotencyKey = randomUUID();
const { data, error, response } = await api.POST("/books", {
params: { header: { "Idempotency-Key": idempotencyKey } },
body: parsed.data,
});
if (error) {
const traceId = response.headers.get("X-Trace-Id") ?? undefined;
if (response.status === 409) {
return { status: "conflict", message: error.title };
}
return { status: "error", traceId };
}
revalidateTag("books"); // RSC側のキャッシュを明示的に失効
return { status: "success", bookId: data.id };
}
なぜ revalidateTag なのか:Next.jsのタグベース失効を使うと、「POSTが成功した時だけ」必要なRSCキャッシュをピンポイントで無効化できます。revalidatePath の全体爆撃はパフォーマンス劣化を招くため、粒度の最小化を徹底します(拙著のTanStack Query記事の「外科手術的なキャッシュ制御」と同じ設計哲学です)。
クライアント側のフォーム(a11y配慮)
// src/app/books/BookForm.tsx
"use client";
import { useActionState, useId } from "react";
import { createBookAction, type CreateBookState } from "./actions";
const initial: CreateBookState = { status: "idle" };
export function BookForm() {
const [state, action, pending] = useActionState(createBookAction, initial);
const isbnId = useId();
const titleId = useId();
const isbnErrId = useId();
const titleErrId = useId();
const errs = state.status === "invalid" ? state.fieldErrors : {};
return (
<form action={action} className="space-y-3" aria-busy={pending}>
<div>
<label htmlFor={isbnId}>ISBN-13</label>
<input
id={isbnId}
name="isbn"
inputMode="numeric"
aria-invalid={!!errs.isbn}
aria-describedby={errs.isbn ? isbnErrId : undefined}
required
/>
{errs.isbn && <p id={isbnErrId} role="alert">{errs.isbn.join(" / ")}</p>}
</div>
<div>
<label htmlFor={titleId}>タイトル</label>
<input
id={titleId}
name="title"
aria-invalid={!!errs.title}
aria-describedby={errs.title ? titleErrId : undefined}
required
/>
{errs.title && <p id={titleErrId} role="alert">{errs.title.join(" / ")}</p>}
</div>
<button type="submit" disabled={pending}>
{pending ? "登録中…" : "登録"}
</button>
{state.status === "success" && <p role="status">登録しました(ID: {state.bookId})</p>}
{state.status === "conflict" && <p role="alert">重複: {state.message}</p>}
{state.status === "error" && (
<p role="alert">エラーが発生しました{state.traceId ? `(追跡ID: ${state.traceId})` : ""}</p>
)}
</form>
);
}
UXとアクセシビリティの要点
useActionStateで保留状態・結果状態を一元管理。フォーム多重送信はdisabled={pending}で抑止。aria-invalid/aria-describedbyによりスクリーンリーダーで誤入力箇所を読み上げ可能。- エラーメッセージは
role="alert"、成功メッセージはrole="status"を使い分ける(前者はスクリーンリーダーが割り込み読み上げ、後者は次のタイミングで読み上げ)。
本論⑦:インフラ —— Terraformによる宣言的な土台
APIは ECS Fargate 上で稼働させ、ALB で終端します。単一障害点を生まないMulti-AZ構成を Terraform で宣言します。
# infra/api/main.tf(抜粋)
terraform {
required_version = ">= 1.9"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.60" }
}
}
variable "image_uri" { type = string }
variable "subnet_ids" { type = list(string) } # private subnets across 2+ AZs
variable "vpc_id" { type = string }
locals {
name = "inventory-api"
port = 8080
}
# ECS Cluster
resource "aws_ecs_cluster" "this" {
name = local.name
setting {
name = "containerInsights"
value = "enabled" # 本番は有効化。追加料金はあるが観測性の価値が上回る
}
}
# Task Role / Execution Role(最小権限)
resource "aws_iam_role" "task" {
name = "${local.name}-task"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
# タスク定義
resource "aws_ecs_task_definition" "this" {
family = local.name
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "512"
memory = "1024"
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
container_definitions = jsonencode([{
name = local.name
image = var.image_uri
essential = true
portMappings = [{
containerPort = local.port
protocol = "tcp"
}]
environment = [
{ name = "OTEL_EXPORTER_OTLP_ENDPOINT", value = "http://localhost:4318" },
{ name = "LOG_LEVEL", value = "info" },
]
# 機密はSSM Parameter Store経由で注入:コードにもTerraform stateにも残さない
secrets = [
{ name = "DATABASE_URL", valueFrom = aws_ssm_parameter.db_url.arn },
]
healthCheck = {
command = ["CMD-SHELL", "wget -q -O - http://localhost:${local.port}/healthz || exit 1"]
interval = 15
timeout = 3
retries = 3
startPeriod = 10
}
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.this.name
awslogs-region = data.aws_region.current.name
awslogs-stream-prefix = "app"
}
}
}])
}
# Service(2+ AZにタスク分散 + ローリングデプロイ + Circuit Breaker)
resource "aws_ecs_service" "this" {
name = local.name
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.this.arn
desired_count = 2
launch_type = "FARGATE"
network_configuration {
subnets = var.subnet_ids
security_groups = [aws_security_group.svc.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.this.arn
container_name = local.name
container_port = local.port
}
# デプロイ失敗を自動で巻き戻す(blue/greenに準ずる安全性)
deployment_circuit_breaker {
enable = true
rollback = true
}
deployment_configuration {
minimum_healthy_percent = 100
maximum_percent = 200
}
}
# Auto Scaling: CPU 60%でスケールアウト
resource "aws_appautoscaling_target" "svc" {
service_namespace = "ecs"
resource_id = "service/${aws_ecs_cluster.this.name}/${aws_ecs_service.this.name}"
scalable_dimension = "ecs:service:DesiredCount"
min_capacity = 2
max_capacity = 10
}
resource "aws_appautoscaling_policy" "cpu" {
name = "${local.name}-cpu"
service_namespace = aws_appautoscaling_target.svc.service_namespace
resource_id = aws_appautoscaling_target.svc.resource_id
scalable_dimension = aws_appautoscaling_target.svc.scalable_dimension
policy_type = "TargetTrackingScaling"
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = 60.0
scale_in_cooldown = 180
scale_out_cooldown = 60 # 急な負荷に強く、緩和時は慎重に
}
}
設計上の要点
deployment_circuit_breakerでデプロイ自体のセーフティネットを張る。OpenAPIとコード生成で「契約ドリフト」を防いだ上で、万一壊れてもロールバックされる二重防御。minimum_healthy_percent = 100はゼロダウンタイム更新の明示的宣言。maximum_percent = 200と組み合わせ、古いタスクが健全である間に新タスクを立ち上げる。scale_out_cooldownを短く、scale_in_cooldownを長くすることで、スパイクに即応し、回復時に過剰な縮退を避ける。経済性と可用性のバランス。- Secrets は SSM Parameter Store 経由。Terraform state に平文の認証情報が残らない(state流出時の被害軽減)。
CI ゲート
# .github/workflows/contract.yml
name: Contract Gate
on: [pull_request]
jobs:
lint-and-generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint OpenAPI
run: npx @stoplight/spectral-cli lint openapi/v1.yaml
- name: Generate TS types (must be up-to-date)
run: |
npx openapi-typescript openapi/v1.yaml -o /tmp/schema.d.ts
diff -u frontend/src/lib/api/schema.d.ts /tmp/schema.d.ts
- name: Generate Go server (must be up-to-date)
run: |
oapi-codegen -generate types,strict-server,chi-server \
-package api -o /tmp/api.gen.go openapi/v1.yaml
diff -u backend/internal/api/api.gen.go /tmp/api.gen.go
- name: Breaking change detection
run: npx openapi-diff origin/main:openapi/v1.yaml openapi/v1.yaml --fail-on breaking
「生成物のコミットを強制し、CIで再生成差分が出たら落とす」 というのが肝です。これで「契約と生成物と実装」が常にGitのある一点で整合します。
結論:定量効果と、次の展望
想定される定量効果(実務シナリオに基づく見積)
| 指標 | Before(手書き型 + 属人的Swagger) | After(OpenAPI契約優先) |
|---|---|---|
| 統合不具合起因のSev2+インシデント | 四半期あたり3〜5件 | 0〜1件 |
| フロント・バック同時修正のレビュー時間 | 平均 90分/PR | 25分/PR |
| 新規エンドポイント実装のリードタイム | 2.5人日 | 1.0人日 |
| API仕様書の「嘘」によるQA差し戻し | 週次 2〜3件 | ほぼ0件 |
| 本番障害時のtraceId特定所要時間 | 10〜30分(grep) | 30秒以内(Problem Detailsに埋込) |
| デプロイ事故時のロールバック | 手動・記憶依存 | ECS Circuit Breakerで自動 |
「数字は盛っている」と感じるかもしれませんが、統合バグの多くは型の乖離とエラー形式の不統一から生じます。この2つを契約レベルで根絶すれば、上記の効果は現実的に観測可能です。
長期的な拡張性
本アーキテクチャは、将来の進化パスを閉じていません。
- Zero-Downtime スキーマバージョニング:
/v1/booksと/v2/booksを並走させ、OpenAPIファイルをv1.yaml/v2.yamlに分割するだけでマルチバージョン運用に入れます。 - gRPC との併用:ホット経路(内部サービス間)だけ
.protoに寄せる判断が可能。OpenAPIを「対外API」と「BFFとの契約」に留めることで、内外の要件差を吸収できます。 - Contract Testing(Pactなど)への展開:生成された型を共有している時点で、消費者駆動契約テストの下地は整っています。
- MCP / LLMエージェントとの接続:OpenAPIは LLM にとって理想的な「行動記述」です。エージェントを信頼して API を呼ばせる未来において、仕様書が第一級オブジェクトであることそのものが最大の資産になります。
最後に:CTO/リードエンジニアの方へ
本記事で示したのは「技術スタック」ではなく、長期的な保守性・可用性・経済性のトレードオフを踏まえた意思決定の形です。
- 「契約は、実装ではなくYAMLに置く」
- 「生成可能なものは絶対に手書きしない」
- 「エラー形式は最初から RFC 7807 で統一する」
- 「リトライは Full Jitter、非冪等は Idempotency-Key で守る」
- 「デプロイの安全装置は、コードではなくインフラに宣言する」
一見、当たり前の積み重ねに見えるかもしれません。しかし、この当たり前を、初期から、例外なく、全員が守る状態を作ること——これが、3年後・5年後もスケールするシステムと、そうでないシステムを分ける分水嶺です。
Next.js × Go × AWS で「本当にスケールするアーキテクチャ」を検討されているチームの、意思決定の一助となれば幸いです。