こちらはフリュー Advent Calendar 2024の22日目の記事になります。
目次
はじめに
ピクトリンク事業部の角谷です。好きな言語はScalaとRust(最近追加された。CLIツールの作成楽しい)。
現在は認証認可などのプラットフォーム基盤開発に携わっており、主にクラウドインフラやバックエンド開発を担当しています。
今年の上旬に、10年以上にわたり運用してきたWebアプリケーションのフロントエンド部分を、モダン化を目的としてリプレースしました。本記事では、そのリプレースにおいて検討した設計方針の一部をご紹介します(現在は旧システムと並行稼働中です)。
※あくまで検討しただけの部分もあります!
背景
今回のタイトルにある通り、リプレースにあたっては「変更に強い」というキーワードを重視しました。旧システムはHTMLテンプレートエンジンとSpring MVC
といったサーバーサイド技術を組み合わせ、サーバー側で完全なHTMLを生成して返す構成が中心となっていました。
当時としては、スタンダードで学習コストも低く、成果をすぐに出しやすいというメリットがありましたが、10年以上の運用や機能追加により、次第に以下のような課題が顕在化してきました。
技術的負債による課題
- HTMLテンプレートとビジネスロジックが密結合しており、新機能やUI変更がサーバーサイド改修と不可分になることが多かった。その結果、小さな変更が全体に波及しやすくなり、テスト工数増や影響範囲の把握の難しさ、デプロイコストの高さが課題となった
- SPAやAPIファーストなど新しい流れを取り入れる際に整合性をとりづらく、無理に
Vue.js
を導入したことでかえって複雑性を増していた(Viewとの結合度が原因でAPI化もなかなか進まなかった)
SSR構成(サーバーサイドレンダリング)であっても、適切な設計によりViewとビジネスロジックを分離することは可能です。旧システムでも「テンプレートは純粋にViewModelを受け取って描画する」方針でしたが、長い運用の中で境界が曖昧になりがちでした。個人的には、ある程度割り切ることも必要だと思っており、極力そのような状況に陥りにくい設計を目指そうというモチベーションで臨んでいます。
結果的に、既存システムのフロントエンド部分を段階的にモダン化していく方針ではなく、新規にフロントエンドプロジェクトを立ち上げ、機能単位で段階的にリプレースする方針に転換しました。そういった経緯もあり、独立したフロントエンドであっても、変更に強いソフトウェア設計を意識していくことを重視しました(2、3年のうちに再度リプレースするのも嫌なので...)。
特にフロントエンドのトレンドは移り変わりが激しいため、将来的な技術スタックの変更にも耐えられる柔軟性と、小さなPoCをプロダクトコードで試しやすい環境が欲しかった、というのもあります。
変更に強いとは?
ソフトウェア設計論でよく言われる「変更に強い」という表現には多面的な意味が含まれます。本記事では以下のような視点を意図しています。
- 新機能の追加や仕様変更があっても、影響範囲が必要最小限で済み、関連コード全体に波及しにくい
- UIの見た目や使用ライブラリ、フレームワークを将来的に切り替えたい場合、コード構造がそれに耐えうる柔軟性がある
- 外部APIやバックエンドとの通信形態が変化しても、フロントエンド側でスムーズに対応可能
端的に言うと、機能単位やレイヤー単位で独立性を高めることでアプリケーション全体の保守性を向上させる という方針です。
様々なソフトウェア設計論で語られている二番煎じの物ですが、実際にどのように適用するかは技術スタックや現場環境によって異なるため、少しでも参考になればと思い、以下の内容を紹介します。
採用技術
その前に採用技術をざっくりと紹介します。Next.js
を中心とした割とスタンダードな選定になっています。
技術 | 項目 | 採用理由 |
---|---|---|
Next.js | フレームワーク | - SSR/CSRのハイブリッド構成が可能 - Reactベースで学習コストが低い - ファイルベースルーティングがpackage by featureの構成にマッチ - ビルド最適化やパフォーマンス改善機能が充実 |
React | UIライブラリ | - コンポーネントベースの開発による再利用性の向上 - 豊富なエコシステムとコミュニティ - 宣言的UIによる保守性の向上 |
TypeScript | 言語 | - 静的型付けによる開発時の安全性確保 - IDEサポートによる開発効率向上 - リファクタリング時の影響範囲の把握が容易 |
pnpm | パッケージマネージャー | - 高速なインストールとビルド - ディスク容量の効率的な利用 - 厳格なパッケージ管理による依存関係の一貫性確保 |
tRPC | RPCフレームワーク | - エンドツーエンドの型安全な通信を実現 - デバッグとテストの容易性 - フロントエンドとバックエンドの結合度の低減 |
zustand | 状態管理 | - シンプルで軽量、直感的なAPI設計 - Reactに依存しないvanillaなパッケージを提供 - ミドルウェアによる拡張性の高さ - 非同期処理やDevToolsとの統合が容易 |
Vitest/React Testing Library | テストフレームワーク | - 高速な実行とHMRによる開発体験の向上 - ユーザー視点でのテスト記述が可能 - 優れたモック機能とjestライクなAPI群 |
Storybook | UI開発環境 | - コンポーネント単位での開発・テストが可能 - ビジュアルリグレッションテストとの連携 - デザイナーとの協業がしやすい |
Playwright | E2Eテスト | - クロスブラウザテストが容易 - 自動テスト生成機能による効率化 - ネットワークモックやデバッグ機能が充実 |
Note
今回の主題ではありませんが、デプロイ先はAWS Lambda
とAmazon S3
です。今後別の基盤に切り替える場合でも柔軟に対応できる点は間接的に関係しています。
1. モジュール分割と境界の明確化
ソフトウェア設計を語る上で、結合度と凝集度は2大金字塔と思っており、多くのアーキテクチャやデザインパターン、パラダイムはこれらの指標をバランスよく調整することを目指している気がします。
境界を明確化することもその手段の一つであり、境界を越える依存関係を極力減らし、結合度を下げることが結果的に変更に強いソフトウェア設計につながります。たとえば境界には以下のような例があります。
- レイヤー単位:UI層、アプリケーション層、ドメイン層、インフラ層など
- 機能単位:ユーザー管理、マイページ、チャット機能など
これらの境界を明示的に管理することで、一部の変更が別の機能やレイヤーに無秩序に波及するのを防ぐ狙いがあります。
モノレポによる境界の物理的な明示
今回のフロントエンドプロジェクトはpnpm workspace
を用いたモノレポ構成を採用しています。1つのsrcディレクトリにコードが雑多に集約されてしまうと、境界が曖昧になりやすく、別のレイヤーのコードをうっかり参照しやすいという問題が起きがちです。
そこで、下記のようにモノレポ構成をとることで、論理的な境界や技術的な境界をモジュール単位で切り分け、それぞれの依存関係を明示できます。
モノレポとは、1つのコードベース(リポジトリ)で複数のモジュールを管理するアプローチです。
ディレクトリ分割やモジュール分割など、どの粒度で分離するかは凝集度とのバランス次第であり、プロジェクトの特性によって異なります。
# すべてNodeモジュールであることを前提としています.
# apps以下はデプロイや実行の単位であり、packages以下はモジュールの単位.
my-project/
├─ pnpm-workspace.yaml
├─ package.json
├─ apps/
│ ├─ iac/ # AWS CDKなどのインフラコード
│ ├─ e2e/ # Playwrightを用いたE2Eテスト
│ └─ web/ # フロントエンドエントリ(Next.js)
│ └─ app/ # App Router
│ └─ mypage/ # 機能単位
└─ packages/
├─ bff/ # バックエンドとの通信を抽象化したAPI層
├─ config/ # linterやformatterなどの設定関連
├─ state or domain/ # 状態管理やドメインロジック (Zustand等)
├─ logger/ # ログ関連
└─ shared/ # ユーティリティ、型定義、定数などVanillaなパッケージ
├─ utils/
├─ types/
├─ constants.ts
└─ index.ts
※pnpm workspace
についてはこちらを参照。
webのpackage.jsonの例(抜粋)
{
"name": "@my-project/web",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@my-project/logger": "1.0.0",
"@my-project/state": "1.0.0",
"@my-project/shared": "1.0.0",
"next": "^14.1.3"
},
"devDependencies": {
"@my-project/config": "1.0.0"
}
}
stateのpackage.jsonの例(抜粋)
{
"name": "@my-project/state",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": {
".": "./index.ts"
},
"dependencies": {
"@my-project/shared": "1.0.0",
"zustand": "^4.5.5"
},
"devDependencies": {
"@my-project/config": "1.0.0"
}
}
sharedのpackage.jsonの例(抜粋)
{
"name": "@my-project/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": {
".": "./index.ts"
},
"devDependencies": {
"@my-project/config": "1.0.0"
}
}
モジュール間の依存関係の例
このように構成しておくと、モジュール間の不要な依存を抑えられ、保守性を高められます。また、単一リポジトリ管理のため、開発効率を損なわずに適度な凝集度を維持できます。モノレポ構成は比較的導入しやすく、コストパフォーマンスに優れた手段の一つだと考えています。
モノレポ構成下ではnode_modulesが肥大化する場合があります。原因が同一パッケージの異なるバージョンインストールであれば、pnpm.overridesの指定やsyncpackなどを活用して、バージョンを統一することを推奨します。
2. インテグレーション設計
境界を明確化して依存関係を整理したら、次は複数のモジュールをどのように接合するかという「インテグレーション設計」も重要です。基本的には、インタフェースやAdapterを通して最小限のつながりを持たせることを心がけると、以下のようなメリットが得られます。
- モジュール間の結合度を低く保てる
- インタフェースを介すことで実装の詳細を隠蔽できる(認知負荷の軽減)
- 将来的に技術選定を変更する際、Adapterの差し替えで対応できる場合が多い
- テストしやすい(モック/スタブの作成が容易)
インタフェースを介したモジュール間の接続例
2.1. BFF
今回は、Next.js
のAPI Routes
とtRPC
を用いてBFFを実装しました。tRPCはフロントエンドとバックエンド間で型情報を共有し、型安全な通信を実現できるライブラリです。また、様々なフレームワークとの接続方法を提供するAdaptersが充実しています。
モノレポのディレクトリ構成例では、bff
モジュールがpackages以下に配置する構成になっていましたが、Adapterを活用してNext.jsのAPI Routesと連携する構成となっているためです。現在はNext.jsのAPI Routes(同一プロセス内)でホスティングしているBFFを、将来的には独立したHonoアプリケーションに乗り換えるなど、フレームワークの差し替えが容易になります。
フロントエンドはtRPCのクライアントを通してBFFを呼び出すだけであるため、下図のようにBFF(=tRPCサーバ) + Adapter
の接続先が切り替わっても、UI側への影響は最小限で済みます。特に本プロジェクトでは、ストラングラーパターンによるトラフィックの変化やバックエンドのマイクロサービスアーキテクチャ推進に関連して、デプロイ先が変更になる可能性も十分にあり得るため、このような技術選定が有効でした。
API RoutesをBFFとして選択した理由:
- 同一プロジェクト内でホスティングでき、開発・デプロイがシンプル
- デプロイ先としてAWS Lambdaを選んでいたため、スケーリング要件の違いをあまり考慮しなくてもよかった
- 同一プロセス内で動作するため、
RSC(React Server Components)
から直接呼び出せる
フロントエンドとBFFの接続(依存関係イメージ)
スタンドアロンなtRPCサーバを利用する例
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { apiRouter } from '@my-project/bff/api/router';
const server = createHTTPServer({
router: apiRouter, // BFFで定義されたtRPCのルーター
createContext: () => ({ ... })
});
server.listen(8080);
Next.js(API Routes)を利用する例
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { apiRouter } from '@my-project/bff/api/router';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: apiRouter, // BFFで定義されたtRPCのルーター
createContext: () => ({ ... })
});
export { handler as GET, handler as POST };
Honoアプリを利用する例
ミドルウェアとして、trpc-serverが提供されています。
import { Hono } from 'hono';
import { trpcServer } from '@hono/trpc-server';
import { apiRouter } from '@my-project/bff/api/router';
const app = new Hono();
app.use(
'/trpc/*',
trpcServer({
router: apiRouter, // BFFで定義されたtRPCのルーター
})
);
export default app;
2.2. 状態管理
今回のリプレースでは、UIと状態管理をできるだけ疎結合にしておくように意識しました。UIライブラリを将来的に変える可能性を考慮しておきたい、あるいは部分的なPoCを試しやすくしたいという背景があります。
そこで、UIライブラリに依存しないvanillaなAPIを提供するzustand
を活用しました。通常、zustandはReactフックを通じて使われるケースが多いですが、zustand/vanilla
以下のAPIを使えばReact
に依存しない純粋なストアを構築できます。
userStore.ts
import { createStore } from 'zustand/vanilla'; // Reactに依存しないAPIを利用
import { User } from '@my-project/state/entities';
interface UserState {
user: User?;
login: (name: string, password: string) => void;
logout: () => void;
}
export const userStore = createStore<UserState>((set) => ({
user: null,
login: (name, password) => { ... },
logout: () => { ... },
}));
Reactコンポーネントで利用したい場合は、zustandが提供するuseStoreというヘルパー関数(Adapter)を用いてラップします。
import { useStore } from 'zustand';
import { userStore } from '@my-project/state/stores/userStore';
const user = useStore(userStore, (state) => state.user);
下図のようにtRPCクライアントとzustand (vanilla)を組み合わせることで、UIコンポーネントを純粋に見た目とレンダリングに集中させる構成を目指しました。
- UI Layer
- ReactやVueなど実際に画面を描画する部分。状態はStoreを通じて読み書きする
- State Layer
- 必要に応じてBFFを呼び出し、取得データをStateへ反映。エラーハンドリングやリトライの仕組みなども含め、通信周りのオーケストレーションを担う場合もある
- BFF Layer
- tRPCの型共有によって、安全かつスムーズにデータ通信を行う
状態管理ロジックをUIから独立させることは一般的ですが、UIの技術スタックに依存しないように考慮すると柔軟性が高まります。
正直な話を言いますと、フロントエンドの将来的な変遷は読めませんし、Reactから移行する場合は再リプレースも検討するような状態な気もします。
3. テストの粒度や検証範囲
変更に強いソフトウェア設計を支えるには、テストの粒度やカバレッジ範囲を適切に決めることも重要です。上記で紹介したモジュール分割の恩恵として、自然とテストの独立性が高まり、テストが壊れにくくなるというメリットがあります。
テストが壊れにくいということは、変更にも強いということです(無理やり繋げます!)。
本プロジェクトでは、以下の方針でテストを導入しています。
3.1. E2Eテストはなるべく小さく
E2EテストはフロントエンドからBFF、バックエンドを本番さながらにつなぐため、テストフローが複雑化しやすく、実行やメンテナンスのコストが高くなりがちです。また、UIの見た目が少し変わっただけでテストが壊れることも多く、flakyな状態になりやすい側面があります。
そのため、ビジネス的に価値の高い重要なユーザーフローに絞って実施することを方針としています。モジュール間やAdapterの接続が正しく機能するかを、実際のユーザー操作視点で検証し、フロー数を厳選することで保守性も高めています。
本プロジェクトではPlaywright
を採用しており、テストコードは他のモジュールから独立させています(apps/e2e/
)。
Productionに近い環境を対象にテストを実行することで、テストの信頼性も高められます。
3.2. インテグレーションテストは、BFFが提供するAPIやページ単位で実施
一方で、BFFとUIの間の結合やページ単位の動作など、広範囲に確認したいケースがあります。かといってUI全体を巻き込む大きなE2Eテストを増やしすぎるとコストが膨らむため、APIエンドポイント単位やNext.jsのページ単位など、境界が明確な単位でインテグレーションテストを行うのが良いと考えました。
外部やBFFへの通信部分はmswなどでネットワークモックを行い、Adapterの結合テストも包含されるようにしています。
3.3. 各モジュールのユニットテストは結合部分をモック/スタブで代替
モノレポ構成のメリットとして、他モジュールの変更が波及しにくい形でユニットテストを保つことができます。Adapterなど外部との結合部分はモックで置き換え、純粋なロジックのみをテストすることで保守性を高められます。
もしドメインロジックがフロントエンド側にも存在する場合は、ユニットテストで正常系・異常系を網羅的にカバーするようにします。
3.4. UIのコンポーネントテストは、複雑なロジックを含むコンポーネントに限定
UIコンポーネントを詳細にテストしすぎると、ちょっとした要素の並びやデザイン変更でもテストが頻繁に壊れやすくなります。そこで、状態管理やビジネスロジックが入り組んだコンポーネントのみ、React Testing Library
やVitest
でテストを書くようにしています。
単純なUI表示に関してはビジュアルリグレッションテストでの差分チェック程度に留め、テストのメンテナンスコストを抑えています。
これらをまとめると、下図のように「Small(ユニットテスト)」「Medium(インテグレーション)」「Large(E2E)」の3段階でテスト範囲を分け、結合部分をモックで必要最小限に抑えるイメージです。
テストの適切な粒度を意識しつつモジュール分割とモックを活用すれば、メンテナンスコストを抑えながら変更に強い設計を支えるテスト戦略を構築できると考えています。
需要があればテスト周りだけを深掘りした記事を別途書くかもしれません。
おわりに
以上が、10年以上運用されてきたWebアプリケーションをフロントエンド観点でリプレースした際に意識したソフトウェア設計のポイントです。将来的な技術やデザイン、ビジネス要件の変化に柔軟に対応できるよう、境界を意識した構成を取っています。もちろん、要件や規模によって、YAGNI
(必要になるまでやらない)やKISS
(シンプルに保つ)といった原則とのバランスが大切です(自分はやりすぎる設計が得意です)。
本記事がフロントエンドの設計をされている方の参考になれば幸いです。
見返してみると、ほんとんどが抽象化とAdapterの話を冗長にしているだけでした😰。