32,000拠点*を支えるトラック予約受付サービスのフロントエンドリアーキテクチャ戦略 〜 AIフレンドリーなコードベースを目指して 〜
*利用事業者数とは、MOVO導入拠点に加えて、MOVOを利用する事業者のIDを合計した数字
はじめに
こんにちは!2025年5月から株式会社Hacobu(以下、Hacobu)で、トラック予約受付サービスMOVO Berthの設計・開発を担当している高橋 悟生です。
今回は、現在進行中のBerthフロントエンド・リアーキテクチャ戦略についてご紹介します。
少しでも、同じくフロントエンドのリアーキテクチャに取り組んでいる方の参考になれば幸いです。
戦略の背景と目的
Hacobuでは、日常業務や開発などあらゆる場面でAIを積極的に活用していく方針が全社的に進んでいます。
チームとしても今期の目標として「AI活用による生産性の爆上げ」を掲げ、その第一歩として AIフレンドリーなコードベース への移行を進めています。
ここでいう「AIフレンドリー」とは、AIの認知負荷を軽減し、コードを理解・生成しやすい状態を指します。
この方針のもと、フロントエンドのリアーキテクチャに踏み切ることを決定しました。
戦略の全体像
まずは、非メインライブラリの移行や細かいコード改善に着手する前に、以下のシステム根幹部分を優先的に改善し、AIフレンドリー化と保守性向上の両立を目指します。
- ディレクトリ構造(ルーティングを含む)
- APIクライアント
- 状態管理
- フォーム管理
- バリデーション
- スタイル
- テスト
これらの要素は相互依存関係があるため、以下の図のように段階的な移行計画を立てています。
本記事では、この中から 現在進行中の「ディレクトリ移行」における設計部分 にフォーカスして解説していきます。
ディレクトリ設計
前提
Berthシステムの特徴
Berthは、管理者・拠点などの企業コンテキスト(テナントグループ)ごとに提供される画面が決まっています。
また、利用者ごとに「読取・作成・更新・削除」などの権限や、従量課金によって提供される機能が制限されます。
ドメイン自体は複雑ですが、アプリケーション構造としてはシンプルなCRUD処理をベースに構築されています。
技術スタック
- React.js
- TypeScript
- Zod
- Final Form
- Redux / Redux Thunk
- React Router
- styled-components
- Axios
- Vitest
- Vite
現状のディレクトリ構造
src
├── App // ルート定義
│ └── components
├── assets // 静的データ
│ ├── images
│ └── json
├── components // 再利用可能コンポーネント(domainのみドメイン知識あり)
│ ├── atoms
│ ├── domain
│ ├── molecules
│ ├── organisms
│ └── pages
├── config // 設定・共通定数
│ ├── EnvSetting.ts
│ ├── constants
│ │ ├── enums
│ │ │ ├── errorResponses
│ │ │ └── helper.ts
│ │ └── texts
│ └── map
├── gateways // APIクライアント設定
├── hooks // 共通hooks
├── index.tsx // エントリポイント
├── layouts // 共通レイアウト
├── libs // ライブラリ設定
├── models // ドメイン依存型・定数・関数
├── modules // Redux関連(Action, Selector, Reducer等)
├── pages // 各ページに属するコンポーネント
├── styles // スタイル設定
├── tests // テスト設定
├── types // 共通型
├── typings // 型拡張定義
└── utils // 共通ユーティリティ
課題感
このディレクトリ構造を運用する中で、以下の課題が顕著になってきました。
-
開発コストの増加
- 機能追加や小規模修正でも複数ディレクトリにまたがるファイル作成・編集が必要。
AI利用時にもコンテキストが広がりすぎ、ハルシネーションのリスクが高まる。
- 機能追加や小規模修正でも複数ディレクトリにまたがるファイル作成・編集が必要。
-
再利用性の欠如
- pages配下にコロケートされており、コンポーネントの再利用が難しく、メンテナンスコストが肥大化。
-
命名と役割の不明確さ
- ディレクトリ名と役割が直感的でなく、既存メンバーや新規参加者の学習コストが増大。実装時の迷いも発生。
-
Atomicデザインのあいまいさ
- 各レイヤーの粒度や役割(特にデータフェッチの責務)が明確でなく、自由度が高すぎて一貫性を欠く。
-
古いアーキテクチャやライブラリの継続利用
- Reactとの互換性や最新ベストプラクティスとの乖離が発生。
移行設計
上記の再利用性に関する課題感を除き、それ以外の課題を解消するため、以下のディレクトリ構成へ移行します。
再利用性の向上を考慮しない理由
各企業コンテキスト毎にエンドポイントが分かれており、featureを分割してもデータフェッチ層を別々に配置する必要があります。また、データフェッチのレスポンス型が異なるため、共通化が困難です。これにより、feature配下で企業コンテキスト毎のコンポーネントを分けることになるので、Pages配下でのコロケーションを継続します。
src
├── api // APIクライアントおよびモックの設定
├── assets // 静的データ
│ ├── images // 画像データ
│ └── json // JSONデータ
├── components // 再利用可能なコンポーネント
│ ├── ui // ドメインに依存しないコンポーネント
│ │ ├── SquareButton
│ │ ├── icons
│ │ ├── layouts
│ │ └── pages
│ └── domain // ドメインに依存するコンポーネント
│ └── master
│ └── home
├── constants // 共通定数
│ ├── phrases // ラベル関連の定数
│ │ ├── errorResponses
│ │ ├── texts
│ │ └── options
│ └── settings // 設定関連の定数
├── hooks // 共通hooks
├── index.tsx // エントリポイント
├── libs // ライブラリ設定
├── helpers // ドメインに依存した共通便利関数
├── stores // 共通Store
├── _pages // (旧)各ルートを構成するページコンポーネント
├── pages // (新)各ルートを構成するページコンポーネント
│ ├── _app.tsx // 企業コンテキストの自動切り替え
│ ├── (public) // 認証なしで公開されるページコンポーネント
│ │ ├── _layout.tsx
│ │ └── eos_internet_explorer
│ └── (auth) // 認証後に公開されるページコンポーネント
│ ├── _layout.tsx // 認証・認可
│ ├── (common) // 企業コンテキストに依存しないページコンポーネント
│ │ ├── external_reservations
│ │ │ ├── edit
│ │ │ └── new
│ │ └── (withLMain)
│ │ ├── _layout.tsx
│ │ ├── index.tsx
│ │ └── (error)
│ │ ├── 403
│ │ ├── 404
│ │ └── 500
│ ├── (companies) // 企業コンテキストに依存するページコンポーネント
│ │ ├── _layout.tsx
│ │ │ // その他のディレクトリ...
│ │ └── admin
│ └── (tmp) // 一時的な移行前の遷移先(移行後の遷移先にリダイレクトされる)
│ ├── reservations
│ └── master
│ ├── _layout.tsx
│ │ // その他のディレクトリ...
│ └── admin
│ └── [...all]
│ └── index.tsx
├── providers // 共通プロバイダー
├── styles // スタイル設定
├── schemas // 共通スキーマ
├── testUtils // テスト設定
├── types // 共通型
├── typings // 型拡張定義
└── utils // ドメインに依存しない共通便利関数
ディレクトリ/ファイル | 補足 |
---|---|
api | orvalで生成したTanstack Queryのhooksやmswのhandlerを配置 |
components/ui | 粒度の小さいコンポーネントからPageコンポーネントまで配置。現在、icons、layouts、pagesのみグループ化しているが、適宜追加可能。基本的にはフラットにコンポーネントディレクトリを配置してよい。 コンポーネントディレクトリ例) SquareButton ├── index.tsx ├── index.test.tsx └── index.stories.tsx |
components/domain | src/pages配下で再利用可能なドメイン知識に依存するコンポーネント。グローバル状態へのアクセス可能。API連携を許可しているが、条件分岐が増える場合は上位コンポーネントに移譲するか再利用しない方針とする。 |
_pages | 移行後に削除予定 |
pages | URL文字列内の単語はスネークケースなので、ディレクトリ名もスネークケースとする |
pages/_app.tsx | 企業コンテキストの自動切り替え |
pages/(public) | 認証なしで公開されるページコンポーネント |
pages/(auth) | 認証後に公開されるページコンポーネント |
pages/(auth)/_layout.tsx | 認証・認可(宣言的) |
pages/(auth)/(common) | 権限制御は発生しない |
pages/(auth)/(companies) | 企業コンテキスト毎に切る |
pages/(auth)/(tmp) | 移行後にアクセス数が減ったタイミングで削除予定。 |
constants/phrases | orvalで生成された型を利用 |
libs | 腐敗防止として、極力ライブラリをラップしてエクスポート |
schemas | 共通で利用するzodの汎用schemaを定義 |
改善されたポイント
Tanstack Routerの採用と活用
Tanstack Routerの採用背景
当初はgeneroutedを使ったReact Routerによるファイルベースルーティングを導入する予定でした。しかし、既存コードベースではgeneroutedが正常に動作せず、さらにgeneroutedが要求するReact Routerのバージョンがv7である可能性が高いことが判明しました(リリースノートでは最新版のみv7を強制する方針とされていますが、古いバージョンでもv7 APIを要求する挙動が見られました)。
これに伴い、use-query-paramsがReact Router v7に未対応という別の課題も浮上しました。
今回のディレクトリ移行の目的は、ファイルベースルーティングで企業コンテキストごとのコロケートを実現することであり、この前提は崩したくありませんでした。
そのため、次のような代替案を検討しました。
- generoutedと同等の機能を自前で実装する
- React Router v7(
ssr: false
)への移行
最終的には、以下の理由からTanstack Routerを採用しました。
- Search Paramsのバリデーションが優れており、型安全性が高い
- 新規性が高いが、他社での採用実績あり
- URL間での内部状態の受け渡しは型安全ではないが、Reduxを利用しているため問題なし
- 報告されている5件のバグはいずれもコア機能外であり、導入に支障なし
Pathless Route Group Directories
企業コンテキストごとにコンポーネントをコロケートするため、Pathless Route Group Directoriesを活用しました。
企業コンテキストごとにコンポーネントをコロケートする理由
Clean Architecture などのドメイン層に依存する設計思想を取り入れ、安定性の高い要素に依存する構造を目指しています。
UI は状況に応じて変化する可能性がありますが、企業コンテキストや権限の概念は揺らぎにくいと判断したため、この方針を採用しています。
Pathless Route Group Directoriesは()
を用いて、パスに関係なくルートファイルをグルーピングする機能です。ルートツリーやコンポーネントツリーには影響せず、あくまで整理目的で利用できます。
この機能を使い、未認証・認証・企業コンテキスト依存/非依存・一時的遷移先といったグループを明確に分類しました。これにより、開発や調査時のファイル探索時間の短縮や関連ファイルの分散防止が期待できます。
routeFileIgnorePattern
pages
配下でコロケートすると、意図しないファイルまでルートとして認識される可能性があります。別ディレクトリ(routes
)を作る選択肢もありますが、routes
とpages
の構造を同期させるために静的解析ルールが必要になるため、この方法は避けました。
代わりに、routeFileIgnorePatternを利用し、不要なファイルをルーティング対象から除外しました。
既存pages
配下の構造がパターン化されていたため、以下のようにvite.config.mts
でroute.tsx
のみをルートとして認識させています。
export default defineConfig(() => {
return {
plugins: [
react(),
tanstackRouter({
target: "react",
autoCodeSplitting: true,
routesDirectory: "./src/pages",
// routeFilePrefixが機能しないため、代わりにIgnorePatternを使用
routeFileIgnorePattern:
"Container.tsx|styled.ts|index.tsx|__tests__|components|hooks|stores|contexts",
}),
],
};
});
宣言的な認可処理
移行前は、PermissionRoute
と呼ばれるラッパーコンポーネントで全ルートを包み、ユーザロールと必要ロールを比較していました。
しかし、この方法ではどのページがどの権限を必要とするのかの情報が分散してしまいます。
移行後は、src/pages/(auth)/route.tsx
にPermissionProvider
を設置し、ページ内でcheckPathPermissions
関数を呼び出して宣言的に認可処理を記述できるようにしました。
これにより、認可ロジックの責務を1箇所に集約しています。
export const PERMISSION_PATHS = {
// Public routes
"/arranger_registration": [],
// Admin master routes
"/admin/master": [],
"/admin/master/users": ["MASTER_USER_READ"],
// その他のルート...
// NOTE: 一時的な移行前の遷移先(移行後の遷移先にリダイレクトされる)
...PERMISSION_TMP_PATHS,
} as const satisfies Record<keyof typeof PATH_MAP, PermissionKey[]>;
/**
* パスに対して必要な権限があるかをチェックする
* @param pathname - チェックするパスの名前
* @param userPermissions - ユーザーの権限一覧(ログインしているユーザーの権限)
* @returns 必要な権限があるかどうか
*/
export const checkPathPermissions = ({
pathname,
userPermissions,
}: CheckPathPermissionsArgs): CheckPathPermissionsReturns => {
const requiredPermissions = PERMISSION_PATHS[pathname] || [];
return requiredPermissions.reduce<boolean>(
(prev, current) => userPermissions[current] && prev,
true
);
};
移行におけるポイント
FeatureFlagの活用
不具合発生時に即座に旧ルートへ戻せるよう、既存Routeと新規RouteをFeatureFlagで切り替え可能にしました。
export const Router = () => {
const { enabled: isDirectoryMigrationEnabled } = useFeatureFlag(
FEATURE_FLAG.DIRECTORY_MIGRATION
);
return isDirectoryMigrationEnabled ? (
<RouterProvider router={router} /> // TanStack Router
) : (
<AppContainer /> // React Router
);
};
React RouterとTanstack Routerの互換レイヤ
TanStack Router内ではReact RouterのuseNavigate
などが使えません。
そこで、useNavigate
・useLocation
・useParams
について、両ルーター環境で動作する互換レイヤを作成しました。
この方法はSiketyanさんの記事を参考にしています。
移行前URLの互換
URL構造が変わるため、既存ユーザがブックマークしている旧URLから新URLへリダイレクトする仕組みを導入しました。
これを実現するために、src/pages/(auth)/(tmp)
を設け、Tanstack RouterのPath Paramsの$
キャッチオール機能を使い、以降のパスをすべて受け取ってリダイレクト処理を行っています。
今後の展望
今後は、ディレクトリ移行の進捗に合わせてAI AgentsのACUやトークン使用量の計測・活用度分析を行い、得られた知見と共にその結果を次回の記事で報告する予定です。
Discussion