この記事は、 WiseVine Advent Calendar 2025の記事です。
はじめに
OpenAPI仕様が肥大化してくると、1つのファイルで管理するのは困難になります。
本記事では、openapi.yamlを人間が管理しやすいよう役割ごとに分割した方法を書いていきます。
背景
今回参加させて頂いているプロジェクトでOpenAPI-First開発(FE・BE・バリデーションを単一ソースから自動生成する)としていました。
API仕様をopenapi.yaml 1ファイルに書き切っていたため、FE・BE共に影響を受ける重要ファイルになっています。
┌─────────────────────────────────────────────────────────────────┐
│ OpenAPI 仕様 (openapi.yaml) │
│ Single Source of Truth │
└─────────────────────────────────────────────────────────────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ TypeScript │ │ Python │ │ Zod │
│ 型定義 │ │ Pydantic │ │ スキーマ │
│ (Frontend) │ │ (Backend) │ │ (Validation) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
元々のディレクトリ構造
backend側にopenapi.yamlファイルを置き、これに全てを書き切っていました。
backend
┗─ openapi.yaml
課題:巨大なopenapi.yamlの問題点
単一ファイルで管理していったことで、以下の問題が発生しました:
- 可読性の低下: 数千行のYAMLは見通しが悪い。PJ開始2ヶ月で1,500行に
- コンフリクトの多発: 複数人で編集するとGitマージが困難
- 再利用性の欠如: 共通スキーマのコピペが発生
- レビューの困難さ: 変更箇所の特定が難しい
- エディタの遅延: 読み込み箇所が多く、動きが鈍くなる
- 生成AIもギブアップ: こんなたくさん読めへんでとギブアップをもらうこともしばしば
解決策:ドメイン別ファイル分割
単一ファイルに書き切ることが問題なのだからまずはシンプルに分割するのが良いと思いました。
ディレクトリ構造
分け方はAPI firstということでurlのサブドメインごとに分割。urlはいったん第一階層までの分割として、必要性が出てきたら第二階層の分割も検討ということで、今回は第一階層まで。
backend/openapi/
├── openapi.yaml # エントリーポイント($ref で各ファイルを参照)
├── openapi.bundled.yaml # 生成物:単一ファイルに結合したもの
├── paths/ # パス定義(エンドポイント)
│ ├── common.yaml # /health, /version, /me
│ ├── auth.yaml # /auth/login, /auth/logout
│ ├── users.yaml # /users, /users/{id}
│ ├── orders.yaml # /orders, /orders/{id}
│ ├── products.yaml # /products, /products/{id}
│ └── categories.yaml # /categories
└── components/ # 再利用可能なコンポーネント
├── schemas/ # データスキーマ定義
│ ├── common.yaml # UUID, ErrorResponse, PaginationResponse
│ ├── auth.yaml # LoginRequest, LoginResponse
│ ├── users.yaml # UserResponse, UserCreateRequest
│ ├── orders.yaml # OrderResponse, OrderCreateRequest
│ ├── products.yaml # ProductResponse, ProductCreateRequest
│ └── categories.yaml # CategoryResponse
└── parameters/ # パラメータ定義
├── common.yaml # PageParam, PageSizeParam
├── users.yaml # UserIdPathParam
├── orders.yaml # OrderIdPathParam
└── products.yaml # ProductIdPathParam
ディレクトリ配置の基準
調べた中でこれが結構スタンダードな感じだったのでそのまま踏襲。
もっと細かくexampleなど分割できそうだが、いったん事足りそうだったのここまで。
| 分類 | ディレクトリ | 役割 |
|---|---|---|
| エントリーポイント | openapi.yaml |
サーバー情報、セキュリティスキーム、全パスの参照 |
| パス定義 | paths/ |
HTTPメソッドとエンドポイントの組み合わせ |
| スキーマ定義 | components/schemas/ |
リクエスト/レスポンスのデータ構造 |
| パラメータ定義 | components/parameters/ |
再利用可能なパスパラメータ、クエリパラメータ |
実装例
1. エントリーポイント(openapi.yaml)
openapi: 3.0.3
info:
title: Sample API
version: 1.0.0
description: サンプルECサイトAPI
servers:
- url: http://localhost:8000/api
description: 開発環境
- url: https://api.example.com
description: 本番環境
components:
securitySchemes:
SessionAuth:
type: apiKey
in: cookie
name: sessionid
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
# 各ファイルのスキーマを参照
schemas:
# common
UUID:
$ref: './components/schemas/common.yaml#/UUID'
ErrorResponse:
$ref: './components/schemas/common.yaml#/ErrorResponse'
PaginationResponse:
$ref: './components/schemas/common.yaml#/PaginationResponse'
# users
UserResponse:
$ref: './components/schemas/users.yaml#/UserResponse'
UserCreateRequest:
$ref: './components/schemas/users.yaml#/UserCreateRequest'
# orders
OrderResponse:
$ref: './components/schemas/orders.yaml#/OrderResponse'
OrderCreateRequest:
$ref: './components/schemas/orders.yaml#/OrderCreateRequest'
OrderDetailResponse:
$ref: './components/schemas/orders.yaml#/OrderDetailResponse'
# パラメータ
parameters:
PageParam:
$ref: './components/parameters/common.yaml#/PageParam'
UserIdPathParam:
$ref: './components/parameters/users.yaml#/UserIdPathParam'
OrderIdPathParam:
$ref: './components/parameters/orders.yaml#/OrderIdPathParam'
# パス定義
paths:
/health:
$ref: './paths/common.yaml#/health'
/users:
$ref: './paths/users.yaml#/users'
/users/{user_id}:
$ref: './paths/users.yaml#/users_{user_id}'
/orders:
$ref: './paths/orders.yaml#/orders'
/orders/{order_id}:
$ref: './paths/orders.yaml#/orders_{order_id}'
2. パス定義(paths/orders.yaml)
orders:
get:
summary: 注文一覧取得
description: 注文の一覧を取得します
operationId: listOrders
tags:
- Orders
security:
- SessionAuth: []
- BearerAuth: []
parameters:
- $ref: '../components/parameters/common.yaml#/PageParam'
- $ref: '../components/parameters/common.yaml#/PageSizeParam'
responses:
'200':
description: 注文一覧取得成功
content:
application/json:
schema:
$ref: '../components/schemas/orders.yaml#/PagedOrderListResponse'
'401':
description: 認証エラー
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
post:
summary: 注文作成
operationId: createOrder
tags:
- Orders
security:
- SessionAuth: []
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../components/schemas/orders.yaml#/OrderCreateRequest'
responses:
'201':
description: 注文作成成功
content:
application/json:
schema:
$ref: '../components/schemas/orders.yaml#/OrderCreateResponse'
orders_{order_id}:
get:
summary: 注文詳細取得
operationId: getOrder
tags:
- Orders
parameters:
- $ref: '../components/parameters/orders.yaml#/OrderIdPathParam'
responses:
'200':
description: 注文詳細取得成功
content:
application/json:
schema:
$ref: '../components/schemas/orders.yaml#/OrderDetailResponse'
'404':
description: 注文が見つかりません
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
delete:
summary: 注文キャンセル
operationId: deleteOrder
tags:
- Orders
parameters:
- $ref: '../components/parameters/orders.yaml#/OrderIdPathParam'
responses:
'200':
description: 注文キャンセル成功
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
'404':
description: 注文が見つかりません
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
3. スキーマ定義(components/schemas/common.yaml)
UUID:
type: string
format: uuid
ErrorResponse:
type: object
required:
- error_code
description: 統一エラーレスポンス
properties:
error_code:
type: string
description: エラーコード
example: VALIDATION_ERROR
message:
type: string
nullable: true
description: エラーメッセージ
details:
type: array
items:
$ref: '#/DetailItem'
default: []
SuccessResponse:
type: object
required:
- success
properties:
success:
type: boolean
description: 操作が成功したかどうか
example:
success: true
PaginationResponse:
type: object
required:
- count
properties:
count:
type: integer
description: 総件数
4. スキーマ定義(components/schemas/orders.yaml)
OrderStatus:
type: string
enum:
- pending
- processing
- shipped
- delivered
- cancelled
description: 注文ステータス
OrderItemResponse:
type: object
required:
- id
- product_id
- product_name
- quantity
- unit_price
- subtotal
properties:
id:
$ref: './common.yaml#/UUID'
product_id:
$ref: './common.yaml#/UUID'
product_name:
type: string
quantity:
type: integer
minimum: 1
unit_price:
type: integer
format: int64
subtotal:
type: integer
format: int64
OrderResponse:
type: object
required:
- id
- status
- total_amount
- created_at
properties:
id:
$ref: './common.yaml#/UUID'
status:
$ref: '#/OrderStatus'
total_amount:
type: integer
format: int64
created_at:
type: string
format: date-time
OrderDetailResponse:
allOf:
- $ref: '#/OrderResponse'
- type: object
required:
- items
- shipping_address
properties:
items:
type: array
items:
$ref: '#/OrderItemResponse'
shipping_address:
type: string
PagedOrderListResponse:
allOf:
- $ref: './common.yaml#/PaginationResponse'
- type: object
required:
- items
properties:
items:
type: array
items:
$ref: '#/OrderResponse'
OrderCreateRequest:
type: object
required:
- items
- shipping_address
properties:
items:
type: array
items:
type: object
required:
- product_id
- quantity
properties:
product_id:
$ref: './common.yaml#/UUID'
quantity:
type: integer
minimum: 1
shipping_address:
type: string
maxLength: 500
example:
items:
- product_id: "11111111-1111-1111-1111-111111111111"
quantity: 2
- product_id: "22222222-2222-2222-2222-222222222222"
quantity: 1
shipping_address: "東京都渋谷区..."
OrderCreateResponse:
type: object
required:
- id
properties:
id:
$ref: './common.yaml#/UUID'
description: 作成された注文ID
5. パラメータ定義(components/parameters/common.yaml)
PageParam:
name: page
in: query
description: ページ番号
required: false
schema:
type: integer
default: 1
minimum: 1
PageSizeParam:
name: page_size
in: query
description: 1ページあたりの件数
required: false
schema:
type: integer
default: 100
minimum: 0
maximum: 1000
6. パラメータ定義(components/parameters/orders.yaml)
OrderIdPathParam:
name: order_id
in: path
description: 注文ID
required: true
schema:
type: string
format: uuid
openapi.bundled.yamlとは
最初は分割したopenapi.yaml で型生成すればいいと思っていたのだが、BE, FE, zodそれぞれでファイル間の参照の仕方の文法($ref)の書き方が違うことが判明...
各ツールが期待する $ref 形式の違い
| 環境 | ツール | 期待する形式 | 必要な設定/オプション |
|---|---|---|---|
| BE(python) | datamodel-codegen | ディレクトリ入力 or バンドル済み |
--input にディレクトリ指定、または事前バンドル |
| FE(React) | Orval | parserOptions で設定可能 |
parserOptions.resolve でカスタマイズ |
| バリデーション(zod) | swagger-codegen | 事前バンドル推奨 | swagger-cli bundle |
なので結局、型生成時はopenapi.yaml(分割ファイル)→openapi.bundled.yaml(統合ファイル)と生成しなおしてここから型生成を行うことにした
ツールチェーン
Redocly CLIの活用
バンドル(分割ファイル → 統合ファイル)
npx @redocly/cli bundle openapi/openapi.yaml -o openapi/openapi.bundled.yaml
バンドルが必要な理由:
- 多くのコード生成ツールは
$refの外部参照を解決できない - CI/CDでの検証は単一ファイルの方が扱いやすい
- API ドキュメント公開用
Lint(検証)
npx @redocly/cli lint openapi/openapi.bundled.yaml
コード自動生成
バックエンド(Python Pydantic)
datamodel-codegen \
--input openapi/openapi.bundled.yaml \
--input-file-type openapi \
--output generated/schemas.py \
--output-model-type pydantic_v2.BaseModel \
--use-subclass-enum \
--use-standard-collections \
--target-python-version 3.12
フロントエンド(TypeScript)
npx orval --config orval.config.ts
Taskfile によるワークフロー統合
# Taskfile.yml
tasks:
api:generate:
desc: OpenAPI仕様からクライアントコード生成
cmds:
- task: api:clean
- task: api:bundle
- task: backend:codegen
- task: frontend:orval
sources:
- ./backend/openapi/**/*.yaml
api:bundle:
desc: OpenAPI仕様をバンドル
cmds:
- npx @redocly/cli bundle backend/openapi/openapi.yaml -o backend/openapi/openapi.bundled.yaml
- npx @redocly/cli lint backend/openapi/openapi.bundled.yaml
sources:
- ./backend/openapi/**/*.yaml
generates:
- ./backend/openapi/openapi.bundled.yaml
api:lint:
desc: OpenAPI仕様のlint実行
cmds:
- npx @redocly/cli lint backend/openapi/openapi.bundled.yaml
$ref の書き方
同一ファイル内の参照
$ref: '#/OrderStatus'
別ファイルの参照
# 相対パスで指定
$ref: '../components/schemas/common.yaml#/UUID'
$ref: './components/schemas/orders.yaml#/OrderDetailResponse'
パス名でのアンダースコア
パスに {parameter} が含まれる場合、YAMLのキー名に / は使えないため、アンダースコアで代用します:
# paths/orders.yaml
orders: # /orders
get: ...
post: ...
orders_{order_id}: # /orders/{order_id}
get: ...
delete: ...
# openapi.yaml での参照
paths:
/orders:
$ref: './paths/orders.yaml#/orders'
/orders/{order_id}:
$ref: './paths/orders.yaml#/orders_{order_id}'
ベストプラクティス
1. ドメイン単位で分割する
機能ドメイン(認証、ユーザー、注文、商品など)ごとにファイルを分けることで:
- 担当者が明確になる
- コンフリクトが減少する
- 変更の影響範囲が限定される
2. 共通コンポーネントを積極的に抽出
以下は必ず common.yaml に切り出す:
UUIDErrorResponseSuccessResponsePaginationResponse-
PageParam,PageSizeParam
まとめ
| 項目 | 単一ファイル | ファイル分割 |
|---|---|---|
| 可読性 | 低(数千行) | 高(ドメイン別) |
| コンフリクト | 多い | 少ない |
| 再利用性 | コピペ | $ref で参照 |
| ツール対応 | 直接利用可 | バンドル必要 |
ファイル分割 + Redocly CLI + コード生成を組み合わせることで、大規模なAPI開発でもOpenAPI仕様を効率的に管理できます。