
はじめに
こんにちは。CommuneのGlobalチームのAlekseiです。今回は、私が優秀なチームとafkmさんの貴重な支援を受けて取り組んできた、プロジェクトにおけるアーキテクチャ変革について共有したいと思います。
テックリードとして、特にアーキテクチャ設計の決定といった技術的な側面に焦点を当てます。プロジェクトの管理面や歴史的背景に興味がある方は、チームリードのyoshifumi.kondoによる前回の記事を強くお勧めします。
プロジェクト概要
アーキテクチャの決定に関するコンテキストを提供するため、プロジェクトを簡単に紹介します(プロジェクトの詳細については、前回の記事を参照してください)。
新しいアーキテクチャは3つの主要な目標に焦点を当てています:
- 現実的な締め切りを実現する:複数のエンジニアがお互いをブロックすることなく作業できる並行開発を可能にする
- 技術的負債を解決する:フレームワークと戦うのをやめ、React Server Componentsを適切に使用する
- 再利用可能な基盤を作る:組織全体で他のチームがコピーできるパターンを構築する
それでは、継承したレガシーアーキテクチャと、それをどのように変革しているかを見ていきましょう。
レガシー「レイヤード」アーキテクチャ
古いアーキテクチャを説明するために「レイヤード」という言葉を使いたいと思います。組織内の公式な用語ではありませんが、機能が複数に分断されたレイヤーに散在していたことをうまく表現していると思います。
技術スタック
「レガシー」と呼んでいますが、実際にはほとんどの技術をそのまま維持しています。問題はツールではなく、それらの使い方にありました。
インフラストラクチャ
- Google Cloud
コア
- Next.js 14(App Router)
- スタイリング用のCSS Modules
- Radix UI上に構築されたカスタムM3(Material Design 3)コンポーネント
テスト
- ユニット/統合テスト用のBun test(ほとんど使用されていない)
- E2Eとビジュアルリグレッション用のPlaywright(計画されたが、使用されなかった)
- Storybook
主要なライブラリ
- i18n用の
next-international - タイプセーフなAPI呼び出し用の
openapi-fetch - ORM として
Prisma - ファイルジェネレーターとして
plop - バリデーション用の
zod
ディレクトリ構造
作成した元のディレクトリ構造はこちらです。おそらくすぐに問題を見つけることができると思います:
📁 components/ // 1つまたは複数のページで使用されるすべてのコンポーネント ┣━━ 📁 BadgeTable/ ┃ ┣━━ 📄 index.tsx // データを取得するサーバーコンポーネント ┃ ┣━━ 🧩 component.tsx // サーバーコンポーネントからのデータを表示する共有/クライアントコンポーネント ┃ ┣━━ 🎨 index.module.css ┃ ┣━━ 📚 index.stories.css ┃ ┣━━ 📁 types/ ┃ ┃ ┗━━ 📜 index.ts ┃ ┣━━ 📁 hooks/ ┃ ┃ ┗━━ 🪝 index.ts ┃ ┗━━ 📁 modules/ ┃ ┗━━ 📦 index.ts ┣━━ 📁 BadgeDetails/ ┗━━ 📁 UsersTable/ 📁 actions/ // すべてのCRUD操作用のサーバーアクション ┣━━ 📁 getBadgeTableAction/ ┃ ┣━━ 📄 index.tsx ┃ ┣━━ 📁 types/ ┃ ┃ ┗━━ 📜 index.ts ┃ ┣━━ 📁 mock/ ┃ ┃ ┗━━ 🧪 index.ts ┃ ┗━━ 📁 modules/ ┃ ┗━━ 📦 index.ts ┣━━ 📁 getBadgeDetailsAction/ ┗━━ 📁 getUsersTableAction/ 📁 pages/ // nextページ ┣━━ 📁 badges/ ┃ ┣━━ 📄 index.tsx ┃ ┣━━ 🎨 index.module.css ┃ ┣━━ 📁 types/ ┃ ┃ ┗━━ 📜 index.ts ┃ ┣━━ 📁 hooks/ ┃ ┃ ┗━━ 🪝 index.ts ┃ ┗━━ 📁 modules/ ┃ ┗━━ 📦 index.ts ┗━━ 📁 users/ 📁 domain/ // ドメインロジック ┗━━ 📁 badge/ ┣━━ 📄 index.ts ┣━━ 📁 types/ ┃ ┗━━ 📜 index.ts ┗━━ 📁 modules/ ┗━━ 📦 index.ts
混乱を招く関心の分離
関連するコードをcomponents/、actions/、pages/、domain/フォルダに分割しました。単一の機能に取り組むには、コードベース全体に散在する4つ以上の異なるディレクトリ間をジャンプする必要がありました。50行のコードしか必要ないシンプルなBadgeコンポーネントでさえ、複数のフォルダに7つ以上のファイルを生成することになりました。開発者は構築している機能の全体像を見失い、本来一緒にあるべきコードを適切にカプセル化できませんでした。この断片化により、すべての変更が不必要に複雑になりました。
時期尚早な抽象化
必要かどうか判断されないうちに、すべてのコンポーネントにtypes/、hooks/、modules/フォルダを作成しました。ほとんどが空のままか、単一のエクスポートしか含まれず、複雑さを増すことになっていました。この時期尚早な構造により、どのコードが実際にどの機能に属するかを理解することがさらに難しくなりました。開発者は、フォルダが存在するという理由だけでロジックをこれらのフォルダに分散させ、本来は凝集性のあるコンポーネントであるべきものをさらに断片化していました。
Next.jsの規約と戦う
私たちの構造は、Next.js App Routerのファイルベースルーティングとコロケーション機能を無視していました。フレームワークのパターンを活用する代わりに、独自の組織システムを構築しました。技術的には、ローディング状態、エラーバウンダリ、インターセプトルートなどのNext.js機能を使用することはできました。しかし、それぞれに別のカスタムディレクトリ構造を考案するか、一貫性を諦める必要がありました。これらの機能をどこに配置するかを考える精神的なオーバーヘッドのため、単純に使用しない選択を取っていました。フレームワークから恩恵を受ける代わりに、Next.jsの規約と戦っていました。
このアーキテクチャは、AIが予測可能なパターンを生成できるシンプルなCRUDアプリには機能するかもしれませんが、私たちのドメインは複雑で、まだ進化の途中です。要件を探索し、迅速に反復する柔軟性が必要でした。この剛直な構造が足枷となっていました。
なぜ失敗したか
この構造は元々AI駆動開発のために設計されました。事前生成されたフォルダを持つ剛直なテンプレートは、シンプルなルールを使用してAIツールがコード生成を支援することを目的としていました。フックは1行だけでも常にhooks/に入れ、コンポーネントは機能の境界に関係なく常にcomponents/に入れる、というようなルールです。
しかし、各ページを担当するエンジニアによる並行開発に方向転換した今、レイヤードアーキテクチャは障害となっています。
「レイヤード」から「コロケーテッド」へ
レイヤードアーキテクチャの弱みである、ディレクトリ間のジャンプ、Next.js機能を使用できないことを経験した後、根本的に異なるアプローチが必要だとわかりました。関連するコードを「コロケート」し、Next.jsのベストプラクティスに従うというシンプルな原則に基づいてアーキテクチャを再構築しました。
「コロケーション」の原則は新しいものではありません。モダンアプリケーションの複雑さを管理するための確立されたパターンです。このアプローチについては、O'Reillyの書籍 React Application Architecture for Production や、変革中に助言をいただいたakfmさんの優れた記事シリーズ Next.jsの考え方で詳しく学ぶことができます。彼の指導のおかげで、Next.jsのApp Router構造を中心に自信を持って、アーキテクチャを再形成し、コロケーションを北極星とすることができました。
新しいアーキテクチャ図はこちらです:
// すべて/appフォルダー配下 📁 _lib // 複数のページで使用される共有ユーティリティ関数、フック、データアクセス、型など ┣━━ 📄 auth-guard.ts (例) ┗━━ 📄 fetch-brand.ts 📁 _components // 複数のページで使用される共通の共有/クライアントコンポーネント ┣━━ 📁 <component-name> ┃ ┣━━ 📄 index.tsx // コンポーネントのコード ┃ ┣━━ 🎨 index.module.css // スタイル ┃ ┣━━ 📚 index.stories.tsx // ストーリー(play関数を含む) ┃ ┣━━ 🧪 index.test.tsx // [オプション] ┗━━ ... その他 📁 <route> // 例:badges ┣━━ 📄 page.tsx // スケルトンコンポーネントでSuspenseバウンダリを管理 ┣━━ 📄 layout.tsx // loading.tsxなども ┣━━ 📁 _containers // このルートまたはネストされたルートで使用されるコンテナ ┃ ┗━━ 📁 container-name // 例:UserTable、PermissionBadge ┃ ┣━━ 📄 index.ts/tsx // container.tsxをエクスポート ┃ ┣━━ 🧩 container.tsx // データを取得してプレゼンテーションに渡すコンポーネント ┃ ┣━━ 🧪 container.test.tsx // [オプション] 複雑な機能がある場合のデータ取得テスト ┃ ┣━━ 📄 presentation.tsx // container.tsxから渡されたデータを表示するコンポーネント ┃ ┣━━ 🎨 presentation.module.css // presentation.tsx用のスタイル ┃ ┣━━ 📚 presentation.stories.tsx // インタラクションテスト付きのpresentation.tsxのストーリー(Storybook `play`) ┃ ┣━━ 🧪 presentation.test.tsx // [オプション] ┃ ┣━━ 📄 other.tsx | ts | ...etc; // [オプション] ┃ ┗━━ ... その他は柔軟に ┣━━ 📁 _components // このページとネストされたページで使用される共有/クライアントコンポーネント ┃ ┣━━ 📁 component-name/ ┃ ┃ ┣━━ 📄 index.tsx // コンポーネントのコード ┃ ┃ ┣━━ 🎨 index.module.css // スタイル ┃ ┃ ┣━━ 📚 index.stories.tsx // ストーリー(play関数を含む) ┃ ┃ ┣━━ 🧪 index.test.tsx // [オプション] ┃ ┃ ┗━━ ... その他は柔軟に ┃ ┣━━ 📁 <container-name>-skeleton // コンテナ用のスケルトン ┃ ┃ ┣━━ 📄 index.tsx ┃ ┃ ┗━━ 🎨 index.module.css ┃ ┗━━ ... // その他のコンポーネント ┣━━ 📁 _lib // このページとネストされたページで使用される共有ユーティリティ関数、フック、データアクセス、型など ┃ ┣━━ 📄 create-user.ts // データアクセス関数の例 ┃ ┗━━ ... ┣━━ 📁 <nested route> // 例:[id]、親の_lib、_componentsなどを使用可能 ┃ ┗━━ ... // <route>構造を繰り返す ┗━━ ...
一見すると、アンダースコアプレフィックスのフォルダや様々なコンポーネントタイプがあり、この構造は複雑に見えるかもしれません。しかし、以前のアーキテクチャとは異なり、すべての決定には目的があり、Next.jsの規約に沿っています。では、主要な変更点を説明していきます。
技術スタック
前述したように、技術スタックはほとんど変更されていません。同じ基盤—Next.js、CSS Modules、カスタムM3コンポーネント、ほとんどのツールを維持しました。主な追加は:
next-intl- より良い共有コンポーネントサポートのためにnext-internationalを置き換えOrval- OpenAPIスキーマから実行時レスポンスバリデーターを自動生成
これらの最小限の変更は、私たちの焦点を反映しています。問題はツールではなく、それらをどのように整理し、使用(または使用しなかった)かにありました。技術スタックを安定させることで、アーキテクチャの改善に集中できました。
アーキテクチャの変更
すべての中で最大の利点は、開発者が一つのディレクトリから離れることなく機能全体を所有できることです。テストも同様に何をテストすべきかわからない場合は、プレゼンテーションをテストするようにしました。そうすることでNext.jsの機能は文書通りに動作します。何か新しいことを試したい場合は、公式ドキュメントを確認すれば、動作するようになりました。
ルートベースの組織化
一つの機能開発に取り組むために4つ以上のディレクトリ間をジャンプしていましたが、今では各ルートが機能全体を所有しています。バッジページで作業する場合、データ取得用のコンテナ、UI用のコンポーネント、ビジネスロジック用のユーティリティなど、必要なものはすべてapp/badges/にあり、コードベース全体を探し回ることは不要になりました。
フラットなコンポーネント構造
空のまま残っていた事前生成されたネストされたフォルダを排除しました。コンポーネントは今やindex.tsx、スタイル、ストーリー、テストを持つフォルダだけになりました。50行のコードが必要だったBadgeコンポーネントは、今では一つのファイルに対して50行のコードだけとなりました。
コンテナファーストパターン
データ取得(サーバーコンポーネント - コンテナ)をプレゼンテーション(クライアント/共有コンポーネント)から分離しますが、同じルートフォルダに保持します。これにより、テストの麻痺が解決されます。コンテナはすべてのサーバーサイドロジックとデータ取得を処理し、クリーンなpropsをプレゼンテーションコンポーネントに渡します。
StorybookはまだServer Componentsを完全にサポートしていないため、コンテナが提供するpropsをモックして、すべてのユーザーインタラクションを含むプレゼンテーションコンポーネントのテストに集中できます。現在では、何をテストすべきか、テストがどこに存在すべきかが正確にわかりますようになりました。
共有コードはローカルに留まる
共通のユーティリティとコンポーネントは、ルートレベルの_lib/と_components/フォルダに存在します。複数のルートが何かを必要とする場合、自然にバブルアップします。これにより、事前に決定されたアーキテクチャではなく、実際の使用に基づいた有機的な階層が作成されます。
ここでは簡単に紹介しただけになるので、今後の記事で詳細を改めて書きたいと思います。
その他の変更
レイヤードからコロケーテッドアーキテクチャへの移行が主要な変革でしたが、新しい開発ワークフローをサポートするために他のいくつかの戦略的な変更も行いました。
洗練されたテスト戦略
プロジェクトがグリーンフィールドから本番システムに成熟するにつれて、テストアプローチが負債になっていることを認識しました。適切なテストカバレッジなしにリファクタリングはリスクが高く、デプロイメントに自信が持てませんでした。長期的なコードの保守、開発者が並行して開発できるようにするために、実際の課題に合わせたテスト戦略が必要です。
そこで、以下に焦点を当てました:
- テストはサポートが簡単でなければならない
- テストはコンポーネントライブラリへの移行とリファクタリングを容易にすべき
- 開発者は変更をプッシュする際に、他のコードを壊さないという自信を持つべき(リグレッションを防ぐ)
これらの目標を達成するために、4つの補完的なテストレイヤーを実装しました:
- ユニットテスト - Bun testを使用して、非自明なロジックを含むユーティリティ関数とヘルパーをテスト
- Storybookインタラクションテスト - Storybookのplay関数とVitestを使用して、分離されたコンポーネント内のユーザーインタラクションをテスト
- ビジュアルリグレッションテスト(VRT) - StorybookとPlaywrightを使用して、コミット間でスクリーンショットを比較し、意図しないUIの変更をキャッチ
- E2Eスモークテスト - Playwrightを使用して、デプロイメント前に重要なユーザーパスがエンドツーエンドで動作することを確認
Series#3の『テスト戦略概要』の記事で紹介します。
データアクセス
すべてのデータ取得を、認証、レスポンスバリデーション、エラー処理、HTTPクライアント設定を自動的に処理する単一のdataAccess関数を通じて標準化しました。一貫性のないパターンで散在するfetch呼び出しの代わりに、すべてのAPIリクエストがこの統一されたインターフェースを通過します:
import "server-only"; // サーバーでのみ実行されることを保証 const getBadge = await dataAccess({auth: Role.ADMIN})(({ fetcher }) => return fetcher.GET("/badges/{id}", { params: { query: { page: 1 }, path: { id: 1 } }, }) ); const updateBadge = await dataAccess()(({ fetcher, mlClient }) => await fetcher.PUT("/badges", { params: { query: { page: 1 }, body }, }) await mlClient.doStuffWithBadge(...) );
実装の詳細と使用されているライブラリについては、今後の記事で説明します。
i18nライブラリの変更
より良い長期的な持続可能性のために、next-internationalからnext-intlに移行しました。next-internationalは最初はうまく機能しましたが、より強力なコミュニティサポート、アクティブなメンテナンス、Next.js App RouterとServer Components、特に共有コンポーネントとのより良い統合を備えたライブラリが必要でした。詳細な移行戦略と学んだ教訓については、専用の記事で説明します。
カスタムリンター
Reactのドキュメントが明示的に「サーバー関数を常に信頼できない入力として扱う」と述べているため、コードベース全体でこれを強制するカスタムリンタールールを作成しました。実際の動作は次のとおりです:
*// ❌ リンターエラー:サーバー関数で検証されていない入力* export async function deleteUser(userId: string) { "use server"; await db.users.delete(userId); *// 信頼できない入力の直接使用* } // ✅ 合格:validateServerInputが呼ばれている export async function deleteUser(userId: string) { "use server"; validateServerInput(userId, z.string().uuid()); await db.users.delete(validatedId); } // ✅ 合格:ファイルレベルでも動作 "use server"; export async function deleteUser(userId: string) { validateServerInput(userId, z.string().uuid()); await db.users.delete(validatedId); }
リンターは、validateServerInputが「use server」の後の最初のステートメントでなければならないことを強制します。これにより、信頼できない入力が処理されることがないことが保証されます。実装の詳細については、今後の記事で詳しく説明します。
まとめ
振り返ってみると、私たちのアーキテクチャ変革は、派手な新技術を採用したり、最新のトレンドに従ったりすることではありませんでした。技術スタックのほとんどをそのまま維持し、本当に重要なことを実際の作業方法に合わせてコードを整理することに焦点を当てました。
レイヤードからコロケーテッドアーキテクチャへの移行は、すでに成果を上げています。エンジニアは、ディレクトリ間の行き来に伴う認知的負荷を抑えつつ、機能全体を一貫して所有できるようになりました。意図に沿った有効なテストを構築し、不具合の早期検知と開発者に自信を与えています。何より、Next.jsと"対立"するのではなく、その規約とベストプラクティスに沿って開発を進められるようになりました。
もっとも、ここからが本番です。真価が問われるのは、コンポーネントライブラリを全面的に置き換える時です。コロケーテッド構造と包括的なテスト戦略により、旧アーキテクチャに比べてはるかに管理しやすい移行になると見込んでおり、その妥当性は今後の段階的リリースで検証していきます。
時に最良の解決策は、ツールを変更することではなく、それらをどのように整理し使用するかを変更することです。コロケーションから始め、フレームワークの規約を受け入れ、明日必要になるテストインフラストラクチャを構築すること。技術的負債、フレームワークとの摩擦、大規模リファクタリングの準備など、同様の課題に直面している方にとって、私たちの経験が参考になると嬉しいです。
この変革中に学んだ具体的なパターン、ツール、教訓について共有することがまだたくさんあります。詳細に深く掘り下げる今後の記事にご期待ください。
それまで、ハッピーコーディング!