Introduction: What Your Team Calls "Type Safety" Is Probably Not Real Type Safety
In a modern web system where the frontend and backend are separated into different repos and different languages, the reality is that surprisingly few teams have truly achieved "end-to-end type safety."
Let me enumerate the typical debt patterns. You'll surely recognize them from the field.
- The frontend hand-redefines the backend's response types, and silently diverges with every schema change.
- Somewhere in the API request/response,
anyorunknownappears, and all the logic beyond it becomes "wish-based coding." - Swagger / OpenAPI docs exist, but there's no guarantee they're synced with the implementation, and reading them after release reveals lies.
- You adopted a Node.js backend and shared types with tRPC, but the moment business needs forced a move to Go/Python, the type linkage collapsed.
- An incompatible schema mixed, even for a moment, between the front's deploy and the back's deploy, and
TypeError: Cannot read properties of undefinedsuddenly erupted in production.
The single root cause common to these is the very idea that "types are derived from source code."
The solution this article presents reverses the idea. Both the client and the server derive types from a single OpenAPI spec (the contract) — a "Contract-First" architecture. With this, the frontend and backend share the contract at the same Git commit, and the consistency of both is verified at build time.
This article's stack is Next.js 16 (App Router) × Go × AWS (Terraform). Neither "type sharing closed to a specific language's ecosystem" like tRPC, nor a choice that "holds a schema layer and an extra runtime" like GraphQL — I'll show, all the way to the implementation level, a realistic solution that achieves both long-term maintainability and language-agnosticism.
Main Part ①: Architecture Selection — Why "OpenAPI"
I'll show the conclusion first, then secure its validity with a quantitative comparison against other options.
A Comparison of the Representative Options
| Aspect | tRPC | GraphQL | gRPC | OpenAPI (REST) | Server Actions alone |
|---|---|---|---|---|---|
| Client-server language constraint | TS↔TS only | Any | Any | Any | TS↔TS only |
| Explicitness of the contract | Implicit (TS type inference) | Explicit (SDL) | Explicit (.proto) | Explicit (YAML/JSON) | Implicit |
| Added runtime dependency | Lightweight | Heavy (schema server, N+1 measures, Apollo/Relay) | Heavy (HTTP/2, Envoy, etc.) | None (plain HTTP) | None |
| Ecosystem (Lint, Mock, Docs) | Limited | Rich | Rich | Extremely rich | Developing |
| Fitness for external-partner exposure | Low | Moderate | Low (unsuited for web APIs) | High | Low |
| Maturity of the code generator | N/A | High | High | High | N/A |
| Freedom of caching strategy | Medium | Low (POST-centric) | Low | High (HTTP-cache-compliant) | Next.js-dependent |
| Vendor lock-in | TS | Apollo, etc. | Envoy, etc. | None | Next.js |
The Reasons to Choose OpenAPI (If Explaining to a CTO)
- The contract is a first-class object: YAML is readable by both humans and tools. A Pull Request diff becomes "the API's change contract" as-is and a review target. With tRPC, you can't take the result of type inference outside the language.
- Language-agnostic: even if you swap the adopted language later, the contract doesn't change. It withstands the realistic multi-language shift of "now Go, part of it in Python for ML, eventually the edge in TypeScript."
- Zero runtime dependency: REST + JSON collides with nothing — CDN, API Gateway, WAF, logging foundations, monitoring tools. You can use HTTP semantics (ETag, Cache-Control, conditional requests) as-is.
- Economy: no additional middleware (Apollo Server, Envoy) or sidecar needed. It lowers not just the initial cost but the invisible operational cost of debuggability at failure time.
- OpenAPI 3.1 is JSON Schema 2020-12-compliant: discriminator,
oneOf,const, andnullableare fully consistent with JSON Schema, so you can also auto-generate Zod validation from the same spec.
The only trade-off is the criticism that "REST is verbose," but the code generation discussed later makes the boilerplate nearly zero. In other words, it's a structure where you cancel only REST's downsides and enjoy only its upsides.
Main Part ②: The Overall Contract-First Workflow
Before getting into implementation, let's clarify this architecture's data flow and responsibility separation.
┌──────────────────────────────┐
│ openapi/v1.yaml │ ← the single source of truth
│ (OpenAPI 3.1 spec) │
└──────────┬───────────────────┘
│ git commit
┌─────────────────┼─────────────────┐
│ generate (CI verified) │
▼ ▼ ▼
┌────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ 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 │
└────────────────┘ └──────────────────────┘
What's important is the inviolable principle that neither the Go side nor the TS side holds any hand-written types. The moment a hand-written one is born, the contract starts to rot.
Main Part ③: Telling It with Code — OpenAPI 3.1 Spec Design
As the theme, consider a B2B "book-inventory-management API." It looks simple, but it requires properly handling a discriminated union (a type with conditional branching) and RFC 7807 Problem Details, and packs in all the crux of contract design.
# 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: 分散トレースとの紐付け }
Key design points
- The
statusfield is a tagged union viaoneOf+discriminator. With this, on the TS side you can narrow withif (book.status.kind === "reserved"), and thatreservedUntilalways exists is guaranteed at compile time. - Errors are RFC 7807-compliant via
application/problem+json. Returning "which field failed and why" inerrors[]lets the front's form display be implemented type-safely. - Making
Idempotency-Keyrequired makes POST's idempotency explicit in the contract. It's consistent with the retry logic (later). - Including
ETagin the contract leaves room on the client side for conditional requests and cache control (a RESTful design touch).
Main Part ④: Implementation on the Go Side — A Double Bulwark of Types via the "strict server interface"
oapi-codegen has a normal mode and a strict-server mode. Always use the latter. In strict mode the handler's signature becomes "the OpenAPI response type itself," so Go's type system rejects contract violations directly.
The Generation Command
# tools.go に依存を固定し、go generate で再現性を担保
oapi-codegen -generate types,strict-server,chi-server \
-package api \
-o internal/api/api.gen.go \
openapi/v1.yaml
Handler Implementation (Excerpt)
// 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() + `"`
}
Unified Mapping to Problem Details
Consolidate the middleware that cross-cuttingly converts domain errors to Problem Details into one place. Not letting handlers write the branching is the crux of eradicating the "forget it and a 500 leaks in production" class of accidents.
// 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))
}
}
Design Invariants
- A handler only needs to return an
error. Constructing the HTTP response, manipulating headers, and logging are the boundary layer's responsibility. With this, a handler's unit test isn't contaminated by HTTP (single responsibility principle). - Always hide unclassified errors. Don't include a stack trace, internal ID, or SQL statement in
detail. Many web-app security incidents are information leakage from error responses (OWASP A05: Security Misconfiguration). - Embed the traceId in the response. From a traceId presented by a customer at failure time, you can identify the relevant request in Jaeger/Tempo in 10 seconds. This design determines your stamina for nighttime on-call.
Main Part ⑤: Implementation on the Next.js 16 Side — The "Defense-in-Depth" of a Type-Safe Client
On the TypeScript side, generate types with openapi-typescript and make the runtime client with openapi-fetch. Alone it's a naive fetch wrapper, but as a middleware chain we compose "timeout → retry → circuit breaker → trace propagation" to give it thickness that withstands production operation.
Type Generation
# package.json scripts: "gen:api": "openapi-typescript openapi/v1.yaml -o src/lib/api/schema.d.ts"
The Client Factory
// 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 Exponential Backoff
Retry is a textbook example of "implement it sloppily and you invite a festival." When multiple clients retry simultaneously, the phenomenon of a wave attack on a recovering server (Thundering Herd) occurs. We adopt the Full Jitter scheme, also mentioned on the AWS Architecture Blog.
// 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;
};
};
Key design points
- Retry a non-idempotent request only when it has an
Idempotency-Key. Retrying a POST unconditionally and running a double charge is an accident that actually happens. - Respect the
Retry-Afterheader. Ignoring it when the server explicitly says "stop for now" is hostile behavior. - Full Jitter: exponential backoff alone still has simultaneous retries align, so randomize to spread them. Full Jitter, which uses the whole interval of
random(0, cap), is said by AWS's measurements to suppress the retry rate the most.
Circuit Breaker
The last bastion that doesn't allow a rush onto a service that's failing.
// 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();
}
}
}
Timeout and OpenTelemetry Propagation
// 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 });
},
});
Main Part ⑥: Calls from RSC and Server Actions
In the App Router, where the API client runs is an important design decision. Since an RSC is always at the server boundary, there's the secondary merit that the API token and BaseURL don't leak to the browser.
Reading from an RSC (Fetching a List)
// 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側の未実装を見逃さない。
}
}
Writing from a Server Action (Double Defense with Zod)
Even when the type is guaranteed by the contract, always do runtime validation at a Server Action's entrance. Two reasons:
- Server Actions are effectively a public endpoint, and the caller is tamperable from the browser.
- Types are "preconditions in a trusted world." When crossing a trust boundary, always leave the primary proof of validation.
// 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 };
}
Why revalidateTag: using Next.js's tag-based invalidation, you can pinpoint-invalidate the necessary RSC cache "only when a POST succeeds." revalidatePath's carpet bombing of everything invites performance degradation, so enforce minimizing the granularity (the same design philosophy as the "surgical cache control" in my TanStack Query article).
The Client-Side Form (with a11y Consideration)
// 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>
);
}
The crux of UX and accessibility
useActionStatecentrally manages the pending and result states. Multiple form submission is suppressed withdisabled={pending}.aria-invalid/aria-describedbylet a screen reader read aloud the location of the input error.- Use
role="alert"for error messages androle="status"for success messages, distinguishing them (the former is read aloud interruptively by a screen reader, the latter at the next opportunity).
Main Part ⑦: Infrastructure — A Declarative Foundation with Terraform
The API runs on ECS Fargate and is terminated by an ALB. We declare a Multi-AZ configuration that produces no single point of failure with 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 # 急な負荷に強く、緩和時は慎重に
}
}
Key design points
- Lay a safety net for the deploy itself with
deployment_circuit_breaker. On top of preventing "contract drift" with OpenAPI and code generation, it's a double defense that rolls back even if it breaks. minimum_healthy_percent = 100is an explicit declaration of a zero-downtime update. Combined withmaximum_percent = 200, it stands up new tasks while the old tasks are healthy.- Making
scale_out_cooldownshort andscale_in_cooldownlong lets you respond immediately to spikes and avoid excessive scale-down on recovery. A balance of economy and availability. - Secrets are via SSM Parameter Store. No plaintext credentials remain in the Terraform state (mitigating damage on state leakage).
The CI Gate
# .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
The crux is "force the commit of generated artifacts and fail CI on a regeneration diff." With this, "the contract, the artifacts, and the implementation" are always consistent at a certain point in Git.
Conclusion: Quantitative Effects, and the Next Outlook
Expected Quantitative Effects (Estimates Based on a Practical Scenario)
| Metric | Before (hand-written types + ad-hoc Swagger) | After (OpenAPI contract-first) |
|---|---|---|
| Sev2+ incidents caused by integration defects | 3–5 per quarter | 0–1 |
| Review time for simultaneous front/back fixes | average 90 min/PR | 25 min/PR |
| Lead time to implement a new endpoint | 2.5 person-days | 1.0 person-day |
| QA send-backs due to "lies" in the API spec | 2–3 weekly | nearly 0 |
| Time to identify a traceId at a production failure | 10–30 min (grep) | within 30 sec (embedded in Problem Details) |
| Rollback on a deploy accident | manual, memory-dependent | automatic via the ECS Circuit Breaker |
You may feel "the numbers are inflated," but many integration bugs arise from type divergence and inconsistency of error format. Eradicate these two at the contract level and the above effects are realistically observable.
Long-Term Extensibility
This architecture doesn't close off future evolution paths.
- Zero-Downtime schema versioning: just run
/v1/booksand/v2/booksin parallel and split the OpenAPI file intov1.yaml/v2.yaml, and you enter multi-version operation. - Combined use with gRPC: you can decide to lean only the hot path (between internal services) onto
.proto. Keeping OpenAPI for the "external API" and "the contract with the BFF" absorbs the difference in requirements between inside and outside. - Expansion to Contract Testing (Pact, etc.): at the point where you share the generated types, the groundwork for consumer-driven contract testing is in place.
- Connecting to MCP / LLM agents: OpenAPI is an ideal "behavior description" for an LLM. In a future where you trust agents to call APIs, the spec being a first-class object itself becomes the greatest asset.
Finally: To CTOs / Lead Engineers
What this article showed is not a "tech stack" but a form of decision-making that accounts for the trade-offs of long-term maintainability, availability, and economy.
- "Put the contract in YAML, not the implementation."
- "Never hand-write what can be generated."
- "Unify the error format with RFC 7807 from the start."
- "Retry with Full Jitter; protect non-idempotent operations with an Idempotency-Key."
- "Declare the deploy safety mechanism in the infrastructure, not the code."
At a glance, it may look like a pile of the obvious. But creating a state where everyone observes this obvious, from the start, without exception — this is the watershed that separates a system that scales 3 and 5 years later from one that doesn't.
I hope this aids the decision-making of teams considering "an architecture that truly scales" with Next.js × Go × AWS.