Skip to main content
友田 陽大
Type safety & validation
Next.js
TypeScript
Go
OpenAPI
アーキテクチャ設計
型安全
Server Actions
AWS
Terraform

Implementing 'End-to-End Type Safety' with Next.js 16 × Go × OpenAPI: The Complete Practice of Contract-First Architecture

A contract-first architecture connecting a Next.js 16 App Router and a Go backend with OpenAPI 3.1, guaranteeing client⇄server consistency at build time. Explained at production-operation level, all the way through Problem Details, Full Jitter backoff, the Circuit Breaker, OpenTelemetry propagation, and infrastructure definition with Terraform.

Published
Reading time
27 min read
Author
友田 陽大
Share

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, any or unknown appears, 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 undefined suddenly 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

AspecttRPCGraphQLgRPCOpenAPI (REST)Server Actions alone
Client-server language constraintTS↔TS onlyAnyAnyAnyTS↔TS only
Explicitness of the contractImplicit (TS type inference)Explicit (SDL)Explicit (.proto)Explicit (YAML/JSON)Implicit
Added runtime dependencyLightweightHeavy (schema server, N+1 measures, Apollo/Relay)Heavy (HTTP/2, Envoy, etc.)None (plain HTTP)None
Ecosystem (Lint, Mock, Docs)LimitedRichRichExtremely richDeveloping
Fitness for external-partner exposureLowModerateLow (unsuited for web APIs)HighLow
Maturity of the code generatorN/AHighHighHighN/A
Freedom of caching strategyMediumLow (POST-centric)LowHigh (HTTP-cache-compliant)Next.js-dependent
Vendor lock-inTSApollo, etc.Envoy, etc.NoneNext.js

The Reasons to Choose OpenAPI (If Explaining to a CTO)

  1. 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.
  2. 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."
  3. 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.
  4. 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.
  5. OpenAPI 3.1 is JSON Schema 2020-12-compliant: discriminator, oneOf, const, and nullable are 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 status field is a tagged union via oneOf + discriminator. With this, on the TS side you can narrow with if (book.status.kind === "reserved"), and that reservedUntil always exists is guaranteed at compile time.
  • Errors are RFC 7807-compliant via application/problem+json. Returning "which field failed and why" in errors[] lets the front's form display be implemented type-safely.
  • Making Idempotency-Key required makes POST's idempotency explicit in the contract. It's consistent with the retry logic (later).
  • Including ETag in 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-After header. 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:

  1. Server Actions are effectively a public endpoint, and the caller is tamperable from the browser.
  2. 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

  • useActionState centrally manages the pending and result states. Multiple form submission is suppressed with disabled={pending}.
  • aria-invalid / aria-describedby let a screen reader read aloud the location of the input error.
  • Use role="alert" for error messages and role="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 = 100 is an explicit declaration of a zero-downtime update. Combined with maximum_percent = 200, it stands up new tasks while the old tasks are healthy.
  • Making scale_out_cooldown short and scale_in_cooldown long 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)

MetricBefore (hand-written types + ad-hoc Swagger)After (OpenAPI contract-first)
Sev2+ incidents caused by integration defects3–5 per quarter0–1
Review time for simultaneous front/back fixesaverage 90 min/PR25 min/PR
Lead time to implement a new endpoint2.5 person-days1.0 person-day
QA send-backs due to "lies" in the API spec2–3 weeklynearly 0
Time to identify a traceId at a production failure10–30 min (grep)within 30 sec (embedded in Problem Details)
Rollback on a deploy accidentmanual, memory-dependentautomatic 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.

  1. Zero-Downtime schema versioning: just run /v1/books and /v2/books in parallel and split the OpenAPI file into v1.yaml / v2.yaml, and you enter multi-version operation.
  2. 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.
  3. 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.
  4. 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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading