KINTO Tech Blog
Frontend

Orval × Feature-Sliced Design で実現するルールベースなスキーマ駆動開発

Cover Image for Orval × Feature-Sliced Design で実現するルールベースなスキーマ駆動開発

この記事は KINTOテクノロジーズ Advent Calendar 2025 の2日目の記事です🎅🎄

はじめに

こんにちは!
KINTO開発部 KINTOバックエンド開発G マスターメンテナンスツール開発チーム、Osaka Tech Lab 所属の high-g(@high_g_engineer)です。

API 連携が多い現代のフロントエンド開発において、こんな課題を感じたことはないでしょうか?

  • API の型定義を手動で書いていて、仕様変更のたびに修正漏れが発生する
  • 自動生成ファイルの置き場所がバラバラで、どこから何を import すればいいか分からない
  • チームメンバーごとにディレクトリ構造の解釈が異なり、コードレビューで議論になる

これらの課題を解決するキーワードが、「型安全」「スキーマ駆動」「自動生成」「ディレクトリ設計」 です。

この記事では、OpenAPI を唯一の情報源として型安全なコードを自動生成し、それを Feature-Sliced Design のルールに則って管理するアプローチを紹介します。

具体的には、Orval で「どんなコードが生成されるのか」を見ながら、Feature-Sliced Design のディレクトリ構造に沿った効果的な設計パターンを解説します。

この記事で紹介すること

  • OpenAPI を元に Orval から型とカスタムフックを出力する流れ
  • Orval が実際に出力するコード例の詳細
  • Feature-Sliced Design の層構造とインポートルール
  • Orval 生成コードを Feature-Sliced Design のディレクトリ構造で管理する設計パターン

想定読者

  • REST API と型定義の手動管理に疲れているフロントエンド開発者
  • TypeScript + React を使っている方
  • API 変更に強い設計に興味がある方
  • ディレクトリ構造のルール化に関心がある方

基礎となる知識

OpenAPI について

OpenAPI は、HTTP API をプログラムで解釈可能な形式で定義するための標準です。API の仕様を YAML または JSON で記述することで、以下のようなメリットがあります。

  • API の入出力が明確に定義される
  • ドキュメント生成が自動化される
  • クライアント・サーバー間での齟齬を防ぐ

例:OpenAPI の記述の一部(簡略版)

openapi: 3.1.0
paths:
  /posts:
    get:
      summary: 投稿一覧を取得
      parameters:
        - name: page
          in: query
          schema:
            type: integer
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GetPostsResponse"
    post:
      summary: 投稿を作成
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePostRequest"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreatePostResponse"
  /posts/{postId}:
    put:
      summary: 投稿を更新
      parameters:
        - name: postId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdatePostRequest"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Post"
    delete:
      summary: 投稿を削除
      parameters:
        - name: postId
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: No Content
components:
  schemas:
    Post:
      type: object
      required: [id, title, createdAt, updatedAt, status]
      properties:
        id:
          type: string
        title:
          type: string
        body:
          type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        status:
          type: string
          enum: [draft, published, archived]
    GetPostsResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Post"
        total:
          type: integer
        page:
          type: integer
    CreatePostRequest:
      type: object
      required: [title]
      properties:
        title:
          type: string
        body:
          type: string
    CreatePostResponse:
      allOf:
        - $ref: "#/components/schemas/Post"
        - type: object
          properties:
            createdBy:
              type: string
    UpdatePostRequest:
      type: object
      properties:
        title:
          type: string
        body:
          type: string
        status:
          type: string
          enum: [draft, published, archived]

この YAML は以下を定義しています。

  • /posts エンドポイント:一覧取得(GET)と作成(POST)
  • /posts/{postId} エンドポイント:更新(PUT)と削除(DELETE)

スキーマ駆動開発について

従来の手動管理の問題

これまで、私たちフロントエンド開発者は以下のような作業を手動で行っていました。

// 手動で型定義を書く
type Post = {
  id: string;
  title: string;
  body?: string;
  createdAt: string;
  updatedAt: string;
  status: "draft" | "published" | "archived";
};

// API 呼び出しも手で書く
const getPost = async (id: string): Promise<Post> => {
  const response = await fetch(`/api/posts/${id}`);
  return response.json();
};

この方法には以下の問題があります。

  1. 手動修正のコスト

    • OpenAPI を確認 → 型定義を手で修正 → 利用箇所をすべて修正
  2. 修正漏れのリスク

    • 型定義と実際の API 仕様がズレる
    • 複数の箇所で同じ型を使っていると漏れが生じやすい
  3. ドキュメントとコードの非同期

    • OpenAPI ≠ 実装コード になることもある

スキーマ駆動開発のアプローチ

上記で挙げた手動対応の問題を解消するために考えられたのが、「スキーマ(API 仕様)を最初に定義し、そこから実装を進める」開発手法です。

従来:実装 → ドキュメント(後付け) → 仕様との齟齬

スキーマ駆動:スキーマ定義 → 自動生成 → 実装 → 完了
              (実装 = ドキュメント、常に同期)

スキーマ駆動開発の特徴

  • 実装 = ドキュメント:常に API 仕様とコードが同期
  • 型安全性:コンパイル時に API 不整合を検出
  • 開発効率:型定義の手作業が不要
  • チーム連携:フロントエンドとバックエンド両方が同じ OpenAPI を参照

Orval について

Orval は、OpenAPI の仕様書から TypeScript の型定義やカスタムフックなどのコードをコマンドひとつで自動生成してくれるツールです。

Orval の主な特徴

特徴 説明
型定義の自動生成 API のリクエスト・レスポンスの型を自動で作成
カスタムフックの自動生成 TanStack Query などのフックも自動生成
複数ライブラリ対応 TanStack Query だけでなく、Axios などのライブラリにも対応
モック生成 テスト用のモックデータも生成可能

Orval を使うメリット

  • 時間の節約:型定義や API 呼び出しコードを手書きする時間がゼロに
  • ミスの防止:手書きによるタイプミスや仕様の読み間違いがなくなる
  • 常に最新:OpenAPI が更新されたら、再生成するだけで最新の状態に

スキーマ駆動開発における Orval の役割

ここまでの内容をまとめると、スキーマ駆動開発における Orval の役割は以下のようになります。

OpenAPI(信頼できる唯一の情報源)
    ↓
Orval によるコードの自動生成で、 型定義 + TanStack Query フックを常に同期
    ↓
低コストで型安全な開発が可能

Feature-Sliced Design について

冒頭でも触れた通り、現在開発中のプロジェクトでは、フロントエンドのコード構成に Feature-Sliced Design というアーキテクチャパターンを採用しています。

Feature-Sliced Design は、コードベースを レイヤースライスセグメント という3つの概念で整理するアーキテクチャです。

概念 説明
レイヤー (Layer) アプリケーションの責務による分割。上位から apppagesfeaturesentitiesshared の5層。app はルーティングやレイアウトなどアプリ全体の設定、pages は URL に対応する画面を担当 app/, pages/, features/
スライス (Slice) 各レイヤー内でのビジネスドメインや機能ごとの分割単位 features/auth/, entities/user/
セグメント (Segment) スライス内での技術的な役割による分割 ui/, model/, api/
src/
├── features/          ← レイヤー
│   ├── auth/          ← スライス
│   │   ├── ui/        ← セグメント
│   │   ├── model/     ← セグメント
│   │   └── index.ts

この構造により、「どこに何を置くか」が明確になり、チーム全体でコードの配置ルールを統一できます。

Feature-Sliced Design のディレクトリ構造

私たちのチームでは、以下のようなディレクトリ構造で運用しています。セグメント(api/model/ui/ など)の分け方はプロジェクトに合わせてカスタマイズしています。

workspaces/typescript/src/
├── app/               ← ① アプリケーション層:ルーティング、グローバル設定
│   ├── layouts/         全体的なページで利用するレイアウト
│   ├── routes/          ルーティング定義
│   └── App.tsx          ルートとなるtsxファイル
│
├── pages/             ← ② ページ層:各ページコンポーネント(URLに対応)
│   ├── users/
│   └── login/
│
├── features/          ← ③ 機能層:再利用可能なビジネスロジック
│   ├── {slice}/           ドメインごとにスライスという単位で分割 (例:user, auth)
│   │   ├── {component}/   ドメインに属するコンポーネント
│   │   │   ├── model/       ロジック部分
│   │   │   ├── ui/          UI部分
│   │   │   └── index.ts     公開API(バレルファイル)
│   │   ...
│   ...
│
├── entities/          ← ④ エンティティ層:ビジネスドメインの定義
│   ├── user/            各種ドメイン
│   │   ├── @x             クロスインポート記法 ※後述
│   │   ├── api/           shared/ の自動生成ファイルを import して利用(ファサード)
│   │   │   ├── hooks.ts     apiフック
│   │   │   └── index.ts     公開API(バレルファイル)
│   │   ├── model/       ドメインロジック
│   │   ├── ui/          ドメイン内にとどまる最小レベルのUI
│   │   └── index.ts     公開API(バレルファイル)
│   ...
│
└── shared/            ← ⑤ 共有層:プロジェクト非依存のユーティリティ
    ├── api/
    │   └── generated/   Orval による自動生成ファイル(修正禁止)
    │       ├── types.ts
    │       ├── hooks.ts
    │       └── client.ts
    ├── config/          設定定数
    ├── errors/          共通利用エラー関数
    ├── lib/             ユーティリティ関数
    └── ui/              汎用UIコンポーネント

Feature-Sliced Design の層間インポート制限ルール

Feature-Sliced Design の最も重要なルール:レイヤーは自身より下位のレイヤーのみをインポート可能 です。
また、同一レイヤー間の相互インポートも原則不可です(例外は後述の @x 記法)。

app ← 最上位(抽象度が高い)
  ↓ import可能
pages
  ↓
features
  ↓
entities

※ shared はどのレイヤーからもインポート可能

つまり、以下のようなルールがプロジェクト内で設けられています。

  • ✅ pages/ は features/、entities/、shared/ を import 可能
  • ✅ features/ は entities/、shared/ を import 可能
  • ✅ entities/ は shared/ を import 可能
  • ❌ entities/ は features/ や pages/ を import してはいけない
  • ❌ shared/ は他のどのレイヤーも import してはいけない

entities 層の特別な役割:entities/@x(クロスインポート記法)

しかし、entities 層ではビジネスドメイン同士が関連することが多く、例えば「Post が User を参照する」といったケースが発生します。

これを解決するために、entities 層内でのみ許可される特別なインポート方法が @x 記法です。

ディレクトリ構造の例
entities/
├── user/
│   ├── @x/
│   │   └── post.ts       # 外部スライス向けに公開する型・関数
│   ├── model/
│   │   └── types.ts      # 内部で使用する型定義
│   ├── ui/
│   └── index.ts          # 通常の公開API
│
└── post/
    ├── model/
    │   └── usePost.ts    # ここから user の型を参照したい
    └── index.ts
使用例
// entities/post/model/usePost.ts から entities/user を使用する場合

// ❌ 通常のインポート(Feature-Sliced Design違反:同一レイヤー間のインポート)
import type { User } from "@/entities/user";

// ✅ @x を使ったクロスインポート(許可)
import type { User } from "@/entities/user/@x/post";

// @x ディレクトリは「このスライスが外部に公開する、クロスインポート専用のAPI」を表します。

@x を使うことで、「意図的に外部公開している」ことが明示され、依存関係が追跡しやすくなります。

では、基礎となる知識が押さえられたところで、ここから本題に入っていきます。

Orval の使い方と出力コード

セットアップ

現在開発中のプロジェクトでは pnpm をパッケージマネージャーとして使用しています。
また、OpenAPI は予め定義された前提で話を進めます。

# Orval のインストール
pnpm add -D orval

次に、Orval の設定ファイルを作成します。
※ hooks は、自動生成されたコードを Biome で format するために定義しています。

// orval.config.ts
import { defineConfig } from "orval";

const API_DIR = "./src/shared/api";
const INPUT_DIR = "../../docs/api";
const GENERATED_DIR = `${API_DIR}/generated`;

export default defineConfig({
  postApi: {
    hooks: {
      afterAllFilesWrite: "pnpm format:write:generate",
    },
    input: {
      target: `${INPUT_DIR}/openapi.yaml`,
    },
    output: {
      clean: true,
      biome: true,
      client: "react-query",
      override: {
        mutator: {
          path: `${API_DIR}/customInstance.ts`,
          name: "useCustomInstance",
        },
        query: {
          useSuspenseQuery: true,
          version: 5,
        },
      },
      schemas: `${GENERATED_DIR}/model`,
      target: `${GENERATED_DIR}/hooks/index.ts`,
    },
  },
});

次に、API リクエストを実行するカスタムインスタンスを作成します。これは Orval の設定で指定した mutator として使用されます。

// src/shared/api/customInstance.ts

import { ApiHttpError, type ErrorDetail } from "../errors";
import { getAccessToken } from "../lib";

const BASE_URL = import.meta.env.VITE_API_BASE_URL || "";

// リクエスト設定の型定義
export type RequestConfig = {
  url: string;
  method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
  headers?: Record<string, string>;
  params?: Record<string, unknown>;
  data?: unknown;
  signal?: AbortSignal;
};

// Fetch API を使用したリクエスト関数
const fetchApi = async <T>(config: RequestConfig): Promise<T> => {
  const { url, method, headers = {}, params, data, signal } = config;

  // 認証トークンを取得
  const token = getAccessToken();

  // クエリパラメータの構築
  const queryString = params
    ? `?${new URLSearchParams(params as Record<string, string>).toString()}`
    : "";
  const fullUrl = `${BASE_URL}${url}${queryString}`;

  // ヘッダーの構築
  const requestHeaders: Record<string, string> = {
    "Content-Type": "application/json",
    ...headers,
  };

  if (token) {
    requestHeaders.Authorization = `Bearer ${token}`;
  }

  // リクエストオプションの構築
  const options: RequestInit = {
    method,
    headers: requestHeaders,
    signal,
  };

  if (data && ["POST", "PUT", "PATCH"].includes(method)) {
    options.body = JSON.stringify(data);
  }

  const response = await fetch(fullUrl, options);

  // エラーハンドリング
  if (!response.ok) {
    let errorMessage = `API error: ${response.status}`;
    let errorDetails: ErrorDetail[] = [];

    try {
      const errorData = await response.json();
      errorDetails = errorData?.errors?.details ?? [];
      if (typeof errorData.message === "string") {
        errorMessage = errorData.message;
      }
    } catch {
      // JSONパースに失敗した場合はデフォルトメッセージを使用
    }

    throw new ApiHttpError({
      status: response.status,
      message: errorMessage,
      details: errorDetails,
    });
  }

  // 204 No Content の場合
  if (response.status === 204) {
    return null as T;
  }

  return response.json();
};

// Orval で使用するカスタムインスタンス関数
export const useCustomInstance = <T>(config: RequestConfig): Promise<T> => {
  const controller = new AbortController();

  const promise = fetchApi<T>({
    ...config,
    signal: controller.signal,
  });

  // TanStack Query のキャンセル機能用
  // @ts-expect-error cancel プロパティを動的に追加
  promise.cancel = () => controller.abort();

  return promise;
};

export default useCustomInstance;

この useCustomInstance は Orval が生成するフック内で HTTP リクエストを実行する際に使用されます。認証トークンの付与やエラーハンドリングなど、プロジェクト固有の設定をここに集約できます。

実際のプロジェクトでは、トークンリフレッシュ処理やリトライロジックなどを追加することが多いです。詳細は Orval 公式ドキュメント - Custom Client を参照してください。

あとは、コード生成を実行するだけです。

# コード生成実行
pnpm orval

現在開発中のプロジェクトでは、定期的に pnpm orval を実行し、API 仕様の変更をまとめて反映する運用をしています。

Orval が生成するコード実例

それでは、Orval が実際に何を生成するか、具体例で見ていきます。

生成物 1:型定義

OpenAPIの Post スキーマから、以下のような TypeScript 型が自動生成されます。

// src/shared/api/generated/types.ts
// ↓ OpenAPIから自動生成される

export type Post = {
  id: string;
  title: string;
  body?: string;
  createdAt: string; // ISO 8601形式
  updatedAt: string;
  status: "draft" | "published" | "archived";
};

export type GetPostsResponse = {
  items: Post[];
  total: number;
  page: number;
};

export type CreatePostRequest = {
  title: string;
  body?: string;
};

export type CreatePostResponse = Post & {
  createdBy: string;
};

export type UpdatePostRequest = {
  title?: string;
  body?: string;
  status?: "draft" | "published" | "archived";
};
重要なポイント
  • OpenAPIのスキーマがそのまま型になる
  • enum は TypeScript の Union Type に変換される
  • 必須・オプション(?)の区別も自動判定される
  • 生成ファイルなので修正してはいけない(次の再実行で上書きされる)

生成物 2:TanStack Query カスタムフック

Orval は TanStack Query のフックも自動生成します。以下は理解しやすいよう簡略化した例です(実際の生成コードはカスタムインスタンスや詳細な型定義を含みます)。

// src/shared/api/generated/hooks.ts
// ↓ Orval が TanStack Query のフックを生成(簡略化した例)

import { useSuspenseQuery, useMutation } from "@tanstack/react-query";
import type {
  UseSuspenseQueryOptions,
  UseMutationOptions,
} from "@tanstack/react-query";
import type {
  Post,
  GetPostsResponse,
  CreatePostRequest,
  CreatePostResponse,
  UpdatePostRequest,
} from "./model";
import { useCustomInstance } from "../customInstance";

type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];

// GET リクエスト → useSuspenseQuery フック
export const useGetPosts = <
  TData = Awaited<ReturnType<ReturnType<typeof useCustomInstance<GetPostsResponse>>>>,
  TError = Error,
>(
  options?: {
    query?: Partial<UseSuspenseQueryOptions<GetPostsResponse, TError, TData>>;
    request?: SecondParameter<ReturnType<typeof useCustomInstance>>;
  }
) => {
  const customInstance = useCustomInstance<GetPostsResponse>();

  return useSuspenseQuery({
    queryKey: ["posts"],
    queryFn: () => customInstance({ url: `/api/posts`, method: "GET" }),
    ...options?.query,
  });
};

// POST リクエスト → useMutation フック
export const useCreatePost = <TError = Error, TContext = unknown>(
  options?: {
    mutation?: UseMutationOptions<CreatePostResponse, TError, CreatePostRequest, TContext>;
    request?: SecondParameter<ReturnType<typeof useCustomInstance>>;
  }
) => {
  const customInstance = useCustomInstance<CreatePostResponse>();

  return useMutation({
    mutationFn: (data: CreatePostRequest) =>
      customInstance({
        url: `/api/posts`,
        method: "POST",
        data,
      }),
    ...options?.mutation,
  });
};

// PUT リクエスト
export const useUpdatePost = <TError = Error, TContext = unknown>(
  postId: string,
  options?: {
    mutation?: UseMutationOptions<Post, TError, UpdatePostRequest, TContext>;
    request?: SecondParameter<ReturnType<typeof useCustomInstance>>;
  }
) => {
  const customInstance = useCustomInstance<Post>();

  return useMutation({
    mutationFn: (data: UpdatePostRequest) =>
      customInstance({
        url: `/api/posts/${postId}`,
        method: "PUT",
        data,
      }),
    ...options?.mutation,
  });
};

// DELETE リクエスト
export const useDeletePost = <TError = Error, TContext = unknown>(
  postId: string,
  options?: {
    mutation?: UseMutationOptions<void, TError, void, TContext>;
    request?: SecondParameter<ReturnType<typeof useCustomInstance>>;
  }
) => {
  const customInstance = useCustomInstance<void>();

  return useMutation({
    mutationFn: () =>
      customInstance({
        url: `/api/posts/${postId}`,
        method: "DELETE",
      }),
    ...options?.mutation,
  });
};
このフックの便利さ
  • TypeScript の型推論により、data の型が自動的に GetPostsResponse に推論される
  • エラーハンドリングも型安全(Error の型が決まっている)
  • TanStack Query のキャッシング、再フェッチなどの機能もそのまま使える
  • API URL の手入力が不要(URL の記述ミスを防げる)

Orval 生成コードの活用ポイント

特徴 メリット
OpenAPI の自動追跡 API 仕様変更 → 再実行 → 完全に同期
型とフックが連動 useGetPosts の戻り値の型も自動推論
TypeScript ジェネリクスを活用 エラーハンドリングも型安全
プラグイン拡張可能 カスタム生成ロジックを追加できる
API のバージョン管理に強い 古いバージョンの API 仕様からの生成もサポート

生成コードは修正禁止

pnpm orval を実行すると型定義とカスタムフックが上書きされるため、 src/shared/api/generated/ 配下のファイルは 修正禁止 です。

// ❌ こうやって直接修正してはいけない
// src/shared/api/generated/hooks.ts

export const useGetPosts = () => {
  // ↓ このコードは Orval の再実行で上書きされる
  return useSuspenseQuery({
    // ...
  });
};

カスタマイズは entities 層で行う

カスタマイズが必要な場合は、entities 層でラップして独自のインターフェースを提供します。これにより、生成コードへの依存を一箇所に集約できます。

// src/entities/post/api/hooks.ts

import { useGetPosts as useGetPostsGenerated } from "@/shared/api/generated";

/**
 * 利用側に使いやすいインターフェースを提供
 * - Orval 生成コードの詳細を隠蔽
 * - 戻り値を整理して返す
 */
export const usePosts = () => {
  const { data, isLoading, error } = useGetPostsGenerated();

  return {
    posts: data?.items ?? [],
    isLoading,
    hasError: !!error,
  };
};

詳細な実装パターンは次章で解説します。

実装パターンと構造設計

ここから、Orval が生成したコードを効果的に使うための設計パターンを 3 つ紹介します。

パターン A:単純なラッピング

シナリオ:投稿一覧を取得する API

ステップ 1:Orval による生成コードを確認

前述の「生成物 2:TanStack Query カスタムフック」で示した useGetPosts がそのまま使用されます。

ステップ 2:entities 層でラッピング

// src/entities/post/api/hooks.ts

import { useGetPosts as useGetPostsGenerated } from "@/shared/api/generated";

/**
 * 投稿一覧を取得するカスタムフック
 * shared/api/generated への依存を entities層に隔離
 */
export const usePosts = () => {
  const { data, isLoading, error } = useGetPostsGenerated();

  return {
    posts: data?.items ?? [],
    isLoading,
    hasError: !!error,
  };
};

ステップ 3:公開 API

// src/entities/post/api/index.ts

export { usePosts } from "./hooks";

ステップ 4:features 層で使用

// src/features/PostManagement/ui/PostList.tsx

import { usePosts } from "@/entities/post/api";

function PostList() {
  const { posts, isLoading } = usePosts();

  if (isLoading) return <div>読み込み中...</div>;

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

このパターンのメリット

  • Orval の生成コード変更が entities/post/api に限定される
  • features/PostManagement はシンプルなインターフェースだけを知ればいい
  • テストも entities/post/api をモック一箇所で OK

Feature-Sliced Design の視点

  • entities/post/api が Orval(外部)と features(内部)の境界を作る
  • features は generated コードの詳細を知らない
  • 修正範囲を entities に限定できる

パターン B:複数 API の組み合わせ

シナリオ:「投稿一覧 + 投稿の詳細」が必要な場合

複数の API 呼び出しを組み合わせる必要があります。これも entities 層で対応します。

※ 以下の例では、投稿詳細を取得する useGetPostDetails フックが別途 Orval で生成されている想定です。

ステップ 1:entities 層で複数 API を組み合わせ

// src/entities/post/api/hooks.ts

import {
  useGetPosts as useGetPostsGenerated,
  useGetPostDetails as useGetPostDetailsGenerated,
} from "@/shared/api/generated";

/**
 * 複数のAPI呼び出しを組み合わせる
 * 呼び出し側はこの複雑性を意識しない
 */
export const usePostWithDetails = (postId: string) => {
  const { data: posts, isLoading: postsLoading } =
    useGetPostsGenerated();

  const { data: details, isLoading: detailsLoading } =
    useGetPostDetailsGenerated(postId);

  return {
    posts: posts?.items ?? [],
    details: details ?? null,
    isLoading: postsLoading || detailsLoading,
    // 便利な導出データも提供
    hasDetails: !!details,
  };
};

ステップ 2:features 層から利用

利用側は複雑さを知らなくて OK です。

// src/features/PostManagement/ui/PostDetail.tsx

import { usePostWithDetails } from "@/entities/post/api";

function PostDetail({ postId }: Props) {
  const { posts, details, isLoading, hasDetails } = usePostWithDetails(postId);

  // 複雑さは entities層に隠蔽!
  return <div>{hasDetails && <PostInfo details={details} />}</div>;
}

パターン C:エラーハンドリングの統一

シナリオ:エラーを共通のフォーマットで扱いたい場合

Orval 生成のエラー型を、独自のエラー型に変換します。

ステップ 1:entities 層でエラー型を定義・変換

// src/entities/post/api/hooks.ts

export type ApiError = {
  message: string;
  code: "NETWORK_ERROR" | "NOT_FOUND" | "UNAUTHORIZED" | "SERVER_ERROR";
  details?: unknown;
};

export type UsePostsResult = {
  posts: Post[];
  isLoading: boolean;
  error: ApiError | null;
  retry: () => void;
};

export const usePosts = (): UsePostsResult => {
  const { data, isLoading, error, refetch } = useGetPostsGenerated();

  // Orval生成のエラー型を独自のエラー型に変換
  const mappedError: ApiError | null = error
    ? {
        message: error.message || "エラーが発生しました",
        code: mapErrorCode(error),
        details: error,
      }
    : null;

  return {
    posts: data?.items ?? [],
    isLoading,
    error: mappedError,
    retry: () => refetch(),
  };
};

// ヘルパー関数
// TanStack Query の error は Error 型として扱われる
// カスタムインスタンス側でステータスコードを含めた Error を throw する想定
type ApiErrorWithStatus = Error & { status?: number };

function mapErrorCode(error: unknown): ApiError["code"] {
  if (!navigator.onLine) return "NETWORK_ERROR";

  const apiError = error as ApiErrorWithStatus;
  if (apiError.status === 404) return "NOT_FOUND";
  if (apiError.status === 401) return "UNAUTHORIZED";

  return "SERVER_ERROR";
}

ステップ 2:features 層で統一的にエラー処理

利用側ではエラーハンドリングが統一されます。

// src/features/PostManagement/ui/PostList.tsx

import { usePosts } from "@/entities/post/api";

function PostList() {
  const { posts, isLoading, error, retry } = usePosts();

  if (error) {
    return (
      <div>
        <p>エラー: {error.message}</p>
        <button onClick={retry}>再試行</button>
      </div>
    );
  }

  // ... 以下、通常の処理
}

Orval + Feature-Sliced Design のアーキテクチャ図

ここまでのパターンを図にまとめました。依存の方向が統一されているため、変更の影響範囲が明確になります。

shared/api/generated/  ← Orvalの生成物(修正禁止)
  ├─ useGetPosts
  ├─ useCreatePost
  ├─ useGetPostDetails
  └─ types.ts
       ↓
    [境界線]
       ↓
entities/post/api/     ← Orvalの生成物をラッピングする層(修正可能)
  ├─ usePosts(カスタマイズ版)
  ├─ usePostWithDetails(複数API組み合わせ)
  ├─ ApiError型
  └─ index.ts(公開API)
       ↓
features/              ← 機能層
  ├─ PostManagement/
  │   ├─ ui/PostList.tsx
  │   ├─ ui/PostDetail.tsx
  │   ├─ lib/...
  │   └─ index.ts
  ...
       ↓
pages/                 ← ページ層
  └─ PostPage/
       ↓
app/                   ← アプリケーション層
  ├─ routes/
  └─ ...

導入してみた感想

✅ メリット

  • 型安全性が圧倒的に向上:手入力で型を定義する開発には戻れません。
  • API 変更への耐性が高い:一箇所修正(entities 層)で全てが完了します。
  • ドキュメント = コード:OpenAPI とコードを常に同期できます。
  • チーム全体の効率が向上:API 設計 → 実装 → テストの流れがスムーズです。
  • バグが減る:型不整合によるバグがほぼなくなります。

⚠️ 注意点

  • チーム全体での学習コストがかかる:Feature-Sliced Design は習得に時間がかかるアーキテクチャであり、チーム全員の理解が必要です。
  • OpenAPI 確定までの待ち時間が発生:API 仕様変更を伴う UI 実装では、OpenAPI の更新完了を待つ必要があります。対策として MSW などのモック API を活用することで、フロントエンド開発を並行して進められます。
  • Orval のバージョンアップ時の互換性確認の必要性:Orval のメジャーバージョンアップ時には生成コードの形式が変わる可能性があるため、アップグレード前にリリースノートの確認が必要です。

まとめ

Orval を利用したスキーマ駆動開発は、フロントエンド開発における 「API 変更への耐性」「型安全性」 を大幅に向上させます。

現在開発中のプロジェクトでは、立ち上げ当初から Orval を導入しており、バックエンドとフロントエンドのエンジニア間のコミュニケーションコストが軽減され、無駄な実装コストもほとんどなくなりました。

また、Feature-Sliced Design の導入により、チーム全体で理解しコードに落とし込むまでに時間がかかったものの、明確なルールのおかげでコードの可読性と保守性が向上しました。

以下のような課題を感じている方は、ぜひ Orval × Feature-Sliced Design の組み合わせを試してみてください。

  • API の型やカスタムフックを手書きしていて、コストがかかっている
  • スキーマ駆動開発は導入済みだが、ディレクトリ構造にルールがない
  • 自動生成ファイルを様々な場所から import している状態

最後までお読みいただきありがとうございました。

参考文献

Facebook

関連記事 | Related Posts

We are hiring!

【ソフトウェアエンジニア(リーダークラス)】共通サービス開発G/東京・大阪・福岡

共通サービス開発グループについてWebサービスやモバイルアプリの開発において、必要となる共通機能=会員プラットフォームや決済プラットフォームなどの企画・開発を手がけるグループです。KINTOの名前が付くサービスやKINTOに関わりのあるサービスを同一のユーザーアカウントに対して提供し、より良いユーザー体験を実現できるよう、様々な共通機能や顧客基盤を構築していくことを目的としています。

【PjM】KINTO開発推進G/東京

KINTO開発部 KINTO開発推進グループについて◉KINTO開発部 :58名 - KINTOバックエンドG:17名 - KINTO開発推進G:8名★  ←こちらの配属になります - KINTOフロントエンドG:19名 - KINTOプロダクトマネジメントG:5名  - KI...

イベント情報