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:
/postsendpoint: 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:
-
Cost of manual updates
- Check OpenAPI → manually update type definitions → update all usage sites
-
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
-
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: app → pages → features → entities → shared (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
enumis 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
datatype asGetPostsResponse - 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/PostManagementonly needs to know the simple interface- Testing also works with mocking just
entities/post/api
From a Feature-Sliced Design Perspective
entities/post/apicreates 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
関連記事 | 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 』を中心とした国内向けサービスのプロジェクト立ち上げから運用保守に至るまでの運営管理を行っています。



