記事のサマリー(TL;DR)
- freee販売が手書きコード数十万行・画面数三桁のReact 17 SPAを、機能追加を止めずにReact 18基盤へ段階移行中
- 新旧を別々のSPAとしてビルドし、Railsサーバーが URL+フラグでどちらのHTMLを返すかを出し分ける方式を採用
- 「旧FEを読む工程」と「新FEで実装する工程」を分離することで、仕様負債の引き継ぎとデグレを同時に回避
大規模SaaS開発の内製チームが参照すべき「止まらない移行」の設計パターン
freee販売が公開したこの事例は、「サービスを止められない」という制約のもとでフロントエンド基盤をまるごと差し替えた実践記録です。対象は手書きコードだけで数十万行・画面数三桁という規模であり、同様の状況にある国内 SaaS プロダクトチームにとって参照価値の高い構成です。
特に注目すべきは、技術的な手法だけでなく「規約の理由を言語化する」「定型作業をコマンド化して人間の判断余地をなくす」という運用設計の部分です。これらは人間の開発者だけでなく、AIエージェントによるコーディング支援にも直接効く設計指針として、記事中で明示的に言及されています。kintone や Salesforce などの業務 SaaS に自前 UI を被せて運用している構成でも、同様の「旧画面を止めずに段階移行する」ニーズは発生しやすく、Railsベースの出し分けロジックや Feature 単位のディレクトリ設計は転用可能な発想です。
詳細
リプレイスに至った経緯
移行前のフロントエンドは以下の状態でした。
- React 17 を使用。周辺エコシステムが 18 前提に移行する中、社内標準ライブラリの新バージョンへ対応できなくなっていた
- router は v5 系。v6 系への移行用互換パッケージを噛ませたまま数年間止まっている
- ディレクトリは「機能別」と「種類別」が混在。ある画面を修正するために触るファイルがリポジトリ全体に散在する
- 状態管理・データフェッチ・スタイリングの流儀が、書かれた時期ごとに異なる
- 手書きコードだけで数十万行規模、画面数は三桁
そして、その間もプロダクトの機能開発は止まりません。これは日々機能を追加・更新し続けるプロダクトであれば、必ず一度は直面する課題です。
リプレイスの方法
内容はシンプルです。
- 新旧を別パッケージとして管理(monorepo の workspace、ロックファイルも分離)し、それぞれ独立してビルドする
- ビルド成果物として2つの HTML(旧FE用・新FE用)を配信ディレクトリに置く
- サーバー(Rails)は、リクエストされた URL とフラグを見て、どちらの HTML を返すかを決める
def index
serve_new_frontend? ? render_spa(:new) : render_spa(:legacy)
end
private
def serve_new_frontend?
return true if request.path == '/foos'
return true if feature_enabled?(:release_foo) && request.path.match?(%r{\A/foos/(new|[^/]+)\z})
false
end
ブラウザに届いた時点では、そのページは新旧どちらか一方の SPA しか動いていません。そのため、技術構成を揃える必要がそもそもありません。
実際に、同じプロダクト内で以下のような異なる技術スタックが並走しています。
| 観点 | 旧FE | 新FE |
|---|---|---|
| React | 17 | 18(Compiler も導入済み) |
| router | v5 + v6 互換レイヤ | v7 |
| データフェッチ | SWR | TanStack Query(Suspense 前提) |
| スタイリング | CSS-in-JS | CSS Modules |
| 型の厳しさ | strict のみ | strict に加え、未チェックの添字アクセス・厳密なオプショナルなどを追加で有効化 |
| テスト | 単体テストと Storybook が混在、方針は暗黙 | レイヤごとに「何を書くか」を明文化。型レベルのテストも実行 |
| 1画面あたりの触るファイル | 種類別に分散 | Feature ディレクトリ内に集約 |
新FEの設計:Feature-Sliced Design ベースのレイヤ構成
旧FE では components / hooks / utils といった「種類」による分割でした。新FE ではこれをやめ、Feature(機能)単位の分割を採用しています。Feature-Sliced Design をベースに、必要な分だけ簡略化したものです。
| レイヤ | 依存してよい先 |
|---|---|
| pages(URLと1対1) | features / presentational |
| features(業務機能) | subSystems / presentational(他の features は全面禁止) |
| subSystems(横断的な仕組み) | presentational |
| presentational(見た目のみ) | なし |
ポイントは、features 同士の相互 import を型情報まで含めて全面禁止にしたことです。
「型くらいは共有してもいいのでは」という議論は当然ありました。それでも禁止した理由は、型の共有が事実上の仕様の共有になり、そこから機能同士が依存していく旧FE の状況を脱却したかったためです。「共有したくなったら、それは subSystems か presentational に上げるべきもの」という判断基準として機能しています。
また、この厳しい設計は AIエージェントがコーディングする際のガードレールとしても機能しており、実装スピードの改善やコスト削減にも寄与していると言及されています。
ファイルの同時更新を自動化するコマンド
新FE へ画面を1枚移すたびに、以下の5箇所を同時に更新する必要があります。
| 更新対象 | 役割 |
|---|---|
| サーバーのルーティング定義 | その URL を出し分けコントローラに向ける |
| 出し分けを行うコントローラ | パスとフラグの判定を追加 |
| 新FEのルート定義 | 新しい Page コンポーネントを登録 |
| 旧FE側の遷移テーブル | 「この画面はもう新FE側」と印をつける |
| ローカル開発用のプロキシ設定 | dev server 間の振り分け |
この5点セットを手作業で揃えるのは確実に事故につながります。パス表記の揺れが本番環境に大きな影響を与えるリスクがあるためです。
そこで、フラグ名・パス・画面 ID を渡すと5ファイルを自動的に書き換えるコマンドを用意し、人間が判断する余地をなくしました。命名変換もパスマッチのパターン(完全一致/末尾がID/途中にID/前方一致)も、すべてそのコマンドの中に規則として書き下しています。
大変だったこと
共存させたつもりが共有していたもの
① URL一覧の二重管理
新FE の router は、当然ながら新FE に存在する画面の URL しか知りません。しかし新FE の画面から旧FE にしかない画面へ遷移するケースは常にあります。
対処として、旧FE が持つ全画面の URL ツリーから、新FE 用に URL の union 型を手で写した定義ファイルを作成しました。ファイル冒頭にはこう書かれています。
移行が完了するまで=ルート定義にすべての URL が書けるまでのあいだ、存在する URL にのみ安全に遷移させるための型。すべての URL がルート定義に書かれたら、このファイルは削除して実際のルート定義からの型計算に切り替える。
同じファイルの末尾には、その「型計算に切り替えた版」がコメントアウトされたまま置かれており、書いた人が「いつかこれに差し替える」と宣言している状態です。これは二重メンテですが、「存在しない URL に遷移してしまうバグ」を型で防ぐ価値の方が上回ると判断しました。
② 画面間メッセージの共有
「保存しました」というトーストを遷移先の画面で出す、よくあるパターンです。SPA 内なら遷移時の history state に載せれば済みますが、新旧の境界をまたぐ遷移はフルリロードになるため history state が引き継がれません。
対処として、sessionStorage を経由してメッセージを受け渡す仕組みを作りました。さらに、個々の実装者にこれを意識させないよう、router の遷移フック(useNavigate)をラップし、「遷移先が新FEのルートにマッチするか」を自動判定する仕組みにしています。
export const useNavigate = () => {
const navigate = useRouterNavigate();
return (to: AvailablePathname, options) => {
storeMessage(options.state);
if (matchesNewFrontendRoutes(to)) {
return navigate(to, options);
}
location.assign(to);
};
};
重要なのは中身よりも、素の router を直接 import できなくした点です。ESLint の noRestrictedImports で react-router からの useNavigate と Link を禁止しています。ドキュメントに書いただけでは素の方が使われてしまうのを防げないためです。
lint などで守れていない依存ルール
レイヤ間の依存方向は lint でほぼ防げていません。実際の違反件数はこうなっています。
- features 同士の相互 import 禁止 → 十数件(ほぼ全てがパンくずに関わる実装)
- presentational から上位レイヤへの参照 → 数十件
- pages から横断レイヤへの参照 → 多数(構造上やむを得ない部分が多い)
ここで得た知見として、「規約が守られるかどうかは、強制手段の有無よりも『理由が合理的か』で決まる」と記事は述べています。腹落ちしていない規約を強制すると、ignore コメントが増えるだけです。
一番厳しく宣言した「features 同士の import 禁止」が最もよく守られている理由は2つです。
- 「型を共有すると機能同士が依存する」という、実感を伴う根拠を明文化したこと
- 違反が1箇所かつ、全員が「パンくずだけは例外」と認識している状態を維持できていること
人間が書くにしても、AIに実施させるとしても、この理由づけは有効だと強調されています。
旧FEの仕様を踏襲する
数年分の画面には、「なぜこの条件でボタンが押せないのか」を説明するために調査が必要な分岐が埋まっています。ステータスと権限の組み合わせで活性が変わる、特定条件でだけ確認ダイアログが出る、といった複雑なロジックです。
新FE で作り直す際にそのまま引き継ぐと負債ごと引き継ぐことになり、落とすとデグレになります。
対処として、実装を読む工程と、テストを書く工程を分離しました。
- 前工程:旧FE の実装から、ロジックへの言及を一切含まないテストケースおよび補完・初期値に関わる資料を書き起こす。「コンポーネント名」「ファイルパス」「〜と同じように」は書いてはいけない
- 後工程:そのテストケースと補完資料だけを見て、新FEのルールで実装する。基本的に旧FEのコードは読まない
また、前工程では読み取れない仕様を「要確認」としてリストアップするよう運用しました。この「要確認リスト」が結果として旧FE 側の挙動自体がおかしいケース(仕様の不具合)の発見につながったのは想定外の収穫でした。
今後について
現在の進捗は、主要画面のうち過半が新FE に移行済みです。開始から3ヶ月ほどで新FE へのコミット数が旧FE を初めて上回り、開発の重心もはっきり移っています。
移行完了後のゴールは、移行期にのみ必要だった仕組み(出し分け判定、遷移テーブル、逆流防止、プロキシ定義、URL の写し、sessionStorage 経由のメッセージ)をすべて削除することです。
もう一つ意識されているのが、AIエージェントが画面を作る前提でのアーキテクチャ設計です。「旧FEを読む工程と書く工程を分ける」「規約の理由を言語化する」「定型作業をコマンド化する」は、人間のために作ったのと同時に、そのままエージェントが動ける環境整備にも繋がっています。人間が暗黙知で補っていた部分がそのままエージェントの詰まりどころになるため、「同じ形の作業を大量に繰り返す」移行プロジェクトはエージェント活用の相性を試すのに好適な題材だったとまとめられています。