KINTO Tech Blog
Frontend

Rule-Based Schema-Driven Development with Orval × Feature-Sliced Design

Cover Image for Rule-Based Schema-Driven Development with Orval × Feature-Sliced Design

This article is the Day 2 entry of the KINTO Technologies Advent Calendar 2025.

Introduction

Hello!
I'm high-g (@high_g_engineer) from the Master Maintenance Tool Development Team in the KINTO Backend Development Group, KINTO Development Division at Osaka Tech Lab.

In modern frontend development with heavy API integration, have you ever experienced challenges like these?

  • Manually writing API type definitions often leads to missed updates when the spec changes
  • Auto-generated files scattered everywhere often make it unclear where to import from
  • Team members interpreting directory structures differently often lead to debates during code reviews

The keywords to solve these challenges are type safety, schema-driven, auto-generation, and directory design.

This article introduces an approach where OpenAPI serves as the single source of truth for auto-generating type-safe code, managed according to Feature-Sliced Design rules.

Specifically, we'll walk through what code Orval generates and explain effective design patterns aligned with Feature-Sliced Design's directory structure.

What This Article Covers

  • The flow of outputting types and custom hooks from OpenAPI using Orval
  • Detailed examples of the code Orval generates
  • Feature-Sliced Design's layer structure and import rules
  • Design patterns for managing Orval-generated code within Feature-Sliced Design's directory structure

Target Audience

  • Frontend developers tired of manually managing REST APIs and type definitions
  • Developers using TypeScript + React
  • Those interested in designs resilient to API changes
  • Those interested in establishing directory structure rules

Foundational Knowledge

OpenAPI

OpenAPI is a standard for defining HTTP APIs in a machine-readable format. By describing API specifications in YAML or JSON, you gain benefits like:

  • Clearly defined API inputs and outputs
  • Automated documentation generation
  • Prevention of discrepancies between client and server

Example: Partial OpenAPI Definition (Simplified)

openapi: 3.1.0
paths:
  /posts:
    get:
      summary: Get list of posts
      parameters:
        - name: page
          in: query
          schema:
            type: integer
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GetPostsResponse"
    post:
      summary: Create a post
      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: Update a post
      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: Delete a post
      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]

This YAML defines the following:

  • /posts endpoint: list retrieval (GET) and creation (POST)
  • /posts/{postId} endpoint: update (PUT) and deletion (DELETE)

About Schema-Driven Development

Problems with Traditional Manual Management

Previously, frontend developers performed tasks like these manually:

// Manually writing type definitions
type Post = {
  id: string;
  title: string;
  body?: string;
  createdAt: string;
  updatedAt: string;
  status: "draft" | "published" | "archived";
};

// Manually writing API calls
const getPost = async (id: string): Promise<Post> => {
  const response = await fetch(`/api/posts/${id}`);
  return response.json();
};

This approach has the following problems:

  1. Cost of manual updates

    • Check OpenAPI → manually update type definitions → update all usage sites
  2. Risk of missed updates

    • Type definitions and actual API specs get out of sync
    • Easy to miss updates when the same type is used in multiple places
  3. Documentation and code desynchronization

    • OpenAPI ≠ implementation code can happen

The Schema-Driven Development Approach

To address these manual management problems, a development methodology emerged: define the schema (API specification) first, then proceed with implementation.

Traditional: Implementation → Documentation (afterthought) → Discrepancies with spec

Schema-driven: Schema definition → Auto-generation → Implementation → Done
              (Implementation = Documentation, always in sync)

Characteristics of Schema-Driven Development

  • Implementation = Documentation: API specs and code are always synchronized
  • Type safety: API inconsistencies detected at compile time
  • Development efficiency: No manual type definition work
  • Team collaboration: Both frontend and backend reference the same OpenAPI

Orval

Orval is a tool that auto-generates TypeScript type definitions and custom hooks with a single command from OpenAPI specifications.

Main Features of Orval

Feature Description
Auto-generated type definitions Automatically creates types for API requests and responses
Auto-generated custom hooks Also auto-generates hooks for TanStack Query and others
Multiple library support Supports not just TanStack Query but also Axios and other libraries
Mock generation Can also generate mock data for testing

Benefits of Using Orval

  • Time savings: Zero time spent hand-writing type definitions or API call code
  • Error prevention: Eliminates typos and spec misreadings from manual writing
  • Always current: Just regenerate when OpenAPI is updated to stay current

Orval's Role in Schema-Driven Development

Summarizing the content so far, Orval's role in schema-driven development is as follows:

OpenAPI (single source of truth)
    ↓
Auto-generation by Orval keeps type definitions + TanStack Query hooks always in sync
    ↓
Low-cost, type-safe development is possible

Feature-Sliced Design

As mentioned at the beginning, the ongoing project adopts Feature-Sliced Design as an architectural pattern for frontend code organization.

Feature-Sliced Design is an architecture that organizes the codebase using three concepts: Layers, Slices, and Segments.

Concept Description Examples
Layer Division by application responsibility. From top: apppagesfeaturesentitiesshared (5 layers). app handles routing and layouts for the entire app, pages handles screens corresponding to URLs app/, pages/, features/
Slice Division unit by business domain or feature within each layer features/auth/, entities/user/
Segment Division by technical role within a slice ui/, model/, api/
src/
├── features/          ← Layer
│   ├── auth/          ← Slice
│   │   ├── ui/        ← Segment
│   │   ├── model/     ← Segment
│   │   └── index.ts

This structure clarifies where to put what, enabling the team to unify code placement rules.

Feature-Sliced Design Directory Structure

Our team operates with the following directory structure. The segment divisions (api/, model/, ui/, etc.) are customized to fit the project.

workspaces/typescript/src/
├── app/               ← ① Application layer: routing, global settings
│   ├── layouts/         Layouts used across all pages
│   ├── routes/          Routing definitions
│   └── App.tsx          Root tsx file
│
├── pages/             ← ② Pages layer: each page component (corresponds to URL)
│   ├── users/
│   └── login/
│
├── features/          ← ③ Features layer: reusable business logic
│   ├── {slice}/           Divided by domain into units called slices (e.g., user, auth)
│   │   ├── {component}/   Components belonging to the domain
│   │   │   ├── model/       Logic portion
│   │   │   ├── ui/          UI portion
│   │   │   └── index.ts     Public API (barrel file)
│   │   ...
│   ...
│
├── entities/          ← ④ Entities layer: business domain definitions
│   ├── user/            Various domains
│   │   ├── @x             Cross-import notation *described later
│   │   ├── api/           Imports and uses auto-generated files from shared/ (facade)
│   │   │   ├── hooks.ts     API hooks
│   │   │   └── index.ts     Public API (barrel file)
│   │   ├── model/       Domain logic
│   │   ├── ui/          Minimal UI staying within the domain
│   │   └── index.ts     Public API (barrel file)...
│
└── shared/            ← ⑤ Shared layer: project-independent utilities
    ├── api/
    │   └── generated/   Auto-generated files by Orval (modification prohibited)
    │       ├── types.ts
    │       ├── hooks.ts
    │       └── client.ts
    ├── config/          Configuration constants
    ├── errors/          Commonly used error functions
    ├── lib/             Utility functions
    └── ui/              Generic UI components

Feature-Sliced Design Layer Import Restriction Rules

The most important rule of Feature-Sliced Design: A layer can only import from layers below itself.
Additionally, mutual imports between the same layer are also prohibited in principle (exception described later with @x notation).

app ← Top level (highest abstraction)import allowed
pages
  ↓
features
  ↓
entities

* shared can be imported from any layer

This means the following rules are established within the project:

  • ✅ pages/ can import from features/, entities/, shared/
  • ✅ features/ can import from entities/, shared/
  • ✅ entities/ can import from shared/
  • ❌ entities/ must not import from features/ or pages/
  • ❌ shared/ must not import from any other layer

Special Role of the Entities Layer: entities/@x (Cross-Import Notation)

However, in the entities layer, business domains often relate to each other. For example, cases like "Post references User" occur.

To solve this, a special import method allowed only within the entities layer is the @x notation.

Directory Structure Example
entities/
├── user/
│   ├── @x/
│   │   └── post.ts       # Types/functions exposed for external slices
│   ├── model/
│   │   └── types.ts      # Type definitions used internally
│   ├── ui/
│   └── index.ts          # Normal public API
│
└── post/
    ├── model/
    │   └── usePost.ts    # Wants to reference user's types from here
    └── index.ts
Usage Example
// When using entities/user from entities/post/model/usePost.ts

// ❌ Normal import (Feature-Sliced Design violation: import between same layer)
import type { User } from "@/entities/user";

// ✅ Cross-import using @x (allowed)
import type { User } from "@/entities/user/@x/post";

// The @x directory represents "cross-import-specific API that this slice exposes externally."

By using @x, it becomes explicit that something is intentionally exposed externally, making dependency tracking easier.

Now that we've covered the foundational knowledge, let's get into the main topic.

How to Use Orval and Output Code

Setup

The ongoing project uses pnpm as the package manager.
Also, we'll proceed assuming OpenAPI is already defined.

# Install Orval
pnpm add -D orval

Next, create the Orval configuration file.
Note: hooks are defined to format auto-generated code with Biome.

// 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`,
    },
  },
});

Next, create a custom instance that executes API requests. This is used as the mutator specified in the Orval configuration.

// 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 || "";

// Type definition for request configuration
export type RequestConfig = {
  url: string;
  method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
  headers?: Record<string, string>;
  params?: Record<string, unknown>;
  data?: unknown;
  signal?: AbortSignal;
};

// Request function using Fetch API
const fetchApi = async <T>(config: RequestConfig): Promise<T> => {
  const { url, method, headers = {}, params, data, signal } = config;

  // Get authentication token
  const token = getAccessToken();

  // Build query parameters
  const queryString = params
    ? `?${new URLSearchParams(params as Record<string, string>).toString()}`
    : "";
  const fullUrl = `${BASE_URL}${url}${queryString}`;

  // Build headers
  const requestHeaders: Record<string, string> = {
    "Content-Type": "application/json",
    ...headers,
  };

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

  // Build request options
  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);

  // Error handling
  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 {
      // Use default message if JSON parsing fails
    }

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

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

  return response.json();
};

// Custom instance function used by Orval
export const useCustomInstance = <T>(config: RequestConfig): Promise<T> => {
  const controller = new AbortController();

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

  // For TanStack Query's cancel functionality
  // @ts-expect-error dynamically adding cancel property
  promise.cancel = () => controller.abort();

  return promise;
};

export default useCustomInstance;

This useCustomInstance is used when executing HTTP requests within the hooks that Orval generates. You can centralize project-specific settings here, such as attaching authentication tokens and error handling.

In actual projects, token refresh processing and retry logic are often added. For details, see the Orval Official Documentation - Custom Client.

All that’s left is to run the code generation.

# Run code generation
pnpm orval

In the ongoing project, we periodically run pnpm orval to batch-apply API spec changes.

Actual Examples of Orval-Generated Code

Now let's look at specific examples of what Orval actually generates.

Generated Output 1: Type Definitions

From OpenAPI's Post schema, TypeScript types like the following are auto-generated.

// src/shared/api/generated/types.ts
// ↓ Auto-generated from OpenAPI

export type Post = {
  id: string;
  title: string;
  body?: string;
  createdAt: string; // ISO 8601 format
  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";
};
Important Points
  • OpenAPI schemas become types directly
  • enum is converted to TypeScript Union Types
  • Required/optional (?) distinction is automatically determined
  • Since it's a generated file, do not modify it (will be overwritten on next run)

Generated Output 2: TanStack Query Custom Hooks

Orval also auto-generates TanStack Query hooks. The following is a simplified example for easier understanding (actual generated code includes custom instances and detailed type definitions).

// src/shared/api/generated/hooks.ts
// ↓ Orval generates TanStack Query hooks (simplified example)

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 request → useSuspenseQuery hook
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 request → useMutation hook
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 request
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 request
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,
  });
};
Convenience of These Hooks
  • TypeScript type inference automatically infers data type as GetPostsResponse
  • Error handling is also type-safe (Error type is determined)
  • TanStack Query features like caching and refetching work as-is
  • No manual API URL entry needed (prevents URL typos)

Key Points for Using Orval-Generated Code

Feature Benefit
Automatic OpenAPI tracking API spec change → re-run → fully synchronized
Types and hooks are linked Return type of useGetPosts is also auto-inferred
Utilizes TypeScript generics Error handling is also type-safe
Plugin extensible Can add custom generation logic
Strong for API versioning Supports generation from older API spec versions

Generated Code Must Not Be Modified

Running pnpm orval overwrites type definitions and custom hooks, so files under src/shared/api/generated/ are modification prohibited.

// ❌ Do not modify directly like this
// src/shared/api/generated/hooks.ts

export const useGetPosts = () => {
  // ↓ This code will be overwritten on Orval re-run
  return useSuspenseQuery({
    // ...
  });
};

Customization Is Done in the Entities Layer

When customization is needed, wrap in the entities layer to provide your own interface. This centralizes dependencies on generated code in one place.

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

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

/**
 * Provides a user-friendly interface
 * - Hides details of Orval-generated code
 * - Returns organized return values
 */
export const usePosts = () => {
  const { data, isLoading, error } = useGetPostsGenerated();

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

Detailed implementation patterns are explained in the next chapter.

Implementation Patterns and Structural Design

From here, we'll introduce 3 design patterns for effectively using Orval-generated code.

Pattern A: Simple Wrapping

Scenario: API to get a list of posts

Step 1: Check Orval-Generated Code

The useGetPosts shown in "Generated Output 2: TanStack Query Custom Hooks" above is used as-is.

Step 2: Wrap in Entities Layer

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

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

/**
 * Custom hook to get list of posts
 * Isolates dependency on shared/api/generated to entities layer
 */
export const usePosts = () => {
  const { data, isLoading, error } = useGetPostsGenerated();

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

Step 3: Public API

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

export { usePosts } from "./hooks";

Step 4: Use in Features Layer

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

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

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

  if (isLoading) return <div>Loading...</div>;

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

Benefits of This Pattern

  • Orval-generated code changes are limited to entities/post/api
  • features/PostManagement only needs to know the simple interface
  • Testing also works with mocking just entities/post/api

From a Feature-Sliced Design Perspective

  • entities/post/api creates a boundary between Orval (external) and features (internal)
  • Features don't know the details of generated code
  • Modification scope can be limited to entities

Pattern B: Combining Multiple APIs

Scenario: When both "list of posts + post details" are needed

Multiple API calls need to be combined. This is also handled in the entities layer.

Note: The following example assumes a useGetPostDetails hook is separately generated by Orval.

Step 1: Combine Multiple APIs in Entities Layer

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

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

/**
 * Combines multiple API calls
 * Callers don't need to be aware of this complexity
 */
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,
    // Also provide convenient derived data
    hasDetails: !!details,
  };
};

Step 2: Use from Features Layer

Callers don't need to know the complexity.

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

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

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

  // Hide Complexity in entities layer!
  return <div>{hasDetails && <PostInfo details={details} />}</div>;
}

Pattern C: Unified Error Handling

Scenario: When you want to handle errors in a common format

Convert Orval-generated error types to custom error types.

Step 1: Define and Convert Error Types in Entities Layer

// 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();

  // Convert Orval-generated error type to custom error type
  const mappedError: ApiError | null = error
    ? {
        message: error.message || "An error occurred",
        code: mapErrorCode(error),
        details: error,
      }
    : null;

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

// Helper function
// TanStack Query's error is treated as Error type
// Assumes custom instance throws Error with status code
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";
}

Step 2: Unified Error Processing in Features Layer

Error handling becomes unified on the caller side.

// 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: {error.message}</p>
        <button onClick={retry}>Retry</button>
      </div>
    );
  }

  // ... normal processing below
}

Architecture Diagram: Orval + Feature-Sliced Design

Here's a diagram summarizing the patterns so far. Since dependency directions are unified, the scope of change impact becomes clear.

shared/api/generated/  ← Orval output (modification prohibited)
  ├─ useGetPosts
  ├─ useCreatePost
  ├─ useGetPostDetails
  └─ types.ts
       ↓
    [Boundary]
       ↓
entities/post/api/     ← Layer wrapping Orval output (modifiable)
  ├─ usePosts (customized version)
  ├─ usePostWithDetails (multiple API combination)
  ├─ ApiError type
  └─ index.ts (public API)
       ↓
features/              ← Features layer
  ├─ PostManagement/
  │   ├─ ui/PostList.tsx
  │   ├─ ui/PostDetail.tsx
  │   ├─ lib/...
  │   └─ index.ts
  ...
       ↓
pages/                 ← Pages layer
  └─ PostPage/
       ↓
app/                   ← Application layer
  ├─ routes/
  └─ ...

Impressions After Adoption

✅ Benefits

  • Dramatically improved type safety: Cannot go back to development with manually typed definitions.
  • High resilience to API changes: Modifications complete in one place (entities layer).
  • Documentation = Code: OpenAPI and code can always stay synchronized.
  • Improved team-wide efficiency: Smooth flow from API design → implementation → testing.
  • Fewer bugs: Bugs from type mismatches have nearly disappeared.

⚠️ Important Notes

  • Learning cost for the entire team: Feature-Sliced Design is an architecture that takes time to master, requiring understanding from all team members.
  • Wait time until OpenAPI is finalized: For UI implementation involving API spec changes, you need to wait for OpenAPI updates to complete. As a countermeasure, using mock APIs like MSW allows frontend development to proceed in parallel.
  • Need for compatibility checks during Orval version upgrades: During Orval major version upgrades, generated code format may change, so checking release notes before upgrading is necessary.

Summary

Schema-driven development using Orval significantly improves resilience to API changes and type safety in frontend development.

In the ongoing project, Orval was introduced from the start, reducing communication costs between backend and frontend engineers and nearly eliminating wasteful implementation costs.

Additionally, while adopting Feature-Sliced Design took time for the entire team to understand and implement in code, the clear rules improved code readability and maintainability.

If you're experiencing challenges like the following, please try the Orval × Feature-Sliced Design combination:

  • Manually writing API types and custom hooks, incurring costs
  • Schema-driven development is already adopted, but there are no directory structure rules
  • Auto-generated files are imported from various places

Thank you for reading to the end.

References

Facebook

関連記事 | Related Posts

We are hiring!

【フロントエンドエンジニア(リードクラス)】KINTO中古車開発G/東京

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

【リードエンジニア】業務システムG/東京・大阪・福岡

業務システムグループについて TOYOTAのクルマのサブスクリプションサービスである『 KINTO ONE 』を中心とした国内向けサービスのプロジェクト立ち上げから運用保守に至るまでの運営管理を行っています。

イベント情報

CO-LAB Tech Night vol.7 AWS で実践するAI・セキュリティ・o11y