/note/tech

決定的なフロントエンドアーキテクチャがいい

要約:

■ 1. 問題提起: アーキテクチャレビューのボトルネック

  • AI時代のコード品質:
    • AIでコーディング速度は爆速になったが、コードの品質が上がったかは別問題
  • 典型的なレビュー指摘:
    • utilsへの切り出し、composable化の提案
    • データfetchをページ層で行うべきという指摘
    • watchではなくcomputedで書くべきという指摘
    • アーキテクチャに関するレビューはボトルネックになりがち
  • 「あとでクリーンにすればいい」の帰結:
    • 先に市場に出すことを優先すると、プロダクト成長とともに開発効率が低下する
    • 重複したコードや影響範囲が分からないコードが生まれる
    • バグが混入し、効率低下を補うための増員が必要になる
    • 市場からのプレッシャーは止まらないため、あとでクリーンにすることはない(Robert C. Martin『Clean Architecture』)
  • 解決策:
    • 曖昧さを排除した決定的なアーキテクチャで、堅牢さと開発効率を両立する
    • 決定的なアーキテクチャはリンタールール化の対象にできる
    • 誰が書いても決められたアーキテクチャに沿ってコーディングされる状態になる

■ 2. 決定的なアーキテクチャの定義

  • 決定的なアーキテクチャとは:
    • 次の3つの判定結果が一意に決まる状態
      • 何をどこに記述・配置するか
      • 依存の向き
      • どう書くか
  • リンタールールの3パターン:
    • 必須: 無いことを検出して、作れと要求する
    • 禁止: あることを検出して、やめろと要求する
    • 置換: あることを検出し、行き先を名指しして移れと要求する
  • 何をどこに記述・配置するか:
    • 必須の例: .vueファイルには必ず対応するテストファイル(.spec)を置く
    • 禁止の例: types/に関数や定数を置いてはいけない
    • 置換の例: 1箇所でしか使わない関数は共通フォルダではなく、使用ファイルの隣に置く
    • 同じものが常に同じ場所にある状態に整う
  • 依存の向き:
    • 必須: 依存はA層からB層の向きに限る
    • 必須はリンタールールで直接表現できないため、禁止・置換で経路を塞いで実現する
    • 禁止の例: components/やutils/からpages/のコードをimportしてはいけない
    • 置換の例: 日付ライブラリを直接importせず、自前で用意した関数を通して使う
    • 依存が常に一方向・非循環に整う
  • どう書くか:
    • 必須の例: composableが返す状態にはreadonly()を付けて返す
    • 禁止の例: propsを直接書き換えてはいけない
    • 置換の例: 値を代入するだけのwatchはcomputedで書く
    • 同じ処理が常に同じ形で書かれている状態に整う

■ 3. 実例: 前提

  • 対象と実装手段:
    • Nuxtで開発中のプロジェクトを例に、現場で構築した決定的なアーキテクチャを示す
    • ルールはESLintのカスタムルールとして自作
  • 注意事項:
    • あくまで一例であり、必ずしも正解ではない
    • import文を手がかりにルールを定義するため、auto importは無効化している
    • CSRのプロジェクトであり、SSRではうまくいかない例もありうる

■ 4. 実例: utils

  • 公式ドキュメントの定義:
    • Nuxtではutils/をユーティリティ関数を入れるディレクトリと定義している
    • しかしユーティリティ関数が何かは明確でない
  • ユーティリティ関数の3条件:
    • VueやNuxtのAPI、composableを使わない(例: useRoute()を使う関数は不可)
    • API通信をしない(例: fetchを使う関数は不可)
    • 関数の外で作った変数を関数の中で使わない(例: 外部のcountを加算する関数は不可)
  • リンタールール化:
    • 禁止: 3条件を満たさない関数をutils/に書くこと
    • 置換: 3条件を満たす関数は.vueやcomposables/に書かず、utils/へ移す
  • 結果:
    • utils/にユーティリティ関数が一元管理される
    • pagesやcomposablesからutils/への依存の向きが決定的になる

■ 5. 実例: composables

  • 公式ドキュメントの定義:
    • Vueではcomposableを、状態を持つロジックをカプセル化して再利用するための関数と定義している
    • しかし「状態を持つロジックのカプセル化」が何かは明確でない
  • 当初の仮説と失敗:
    • 全てを状態を持つロジックとみなし、全てcomposableにすれば完全に決定的になると考えた
    • 決定的ではあるが、可読性が下がった
  • 目的の再設定:
    • 決定的であること自体を目的にしない
    • 決定的にすることで保守性・可読性を高め、開発効率を上げることを目的にする
  • カプセル化すべき4パターン:
    • 既存コードやVueUseを参考に、カプセル化すると嬉しいコードを4つのルールとして定義した
  • パターン1: 非同期処理のローディング状態がある:
    • isLoadingとsave処理をuseSaveTaskに切り出す
    • isLoadingを書き換えられるのがsaveだけになる
  • パターン2: 3つ以上の状態を複数箇所で切り替えている:
    • idle、loading、errorの状態と切替関数をuseStatusに切り出す
    • statusを変えられるのがstartとfailだけになる
  • パターン3: 片方だけ変えるとバグるrefのペアがある:
    • keywordとpageをuseSearchに切り出し、setKeywordでpageを1に戻す
    • keywordの変更がsetKeywordだけになり、必ず1ページ目に戻る
  • パターン4: 外部ライブラリをVueに繋ぎ込んでいる:
    • ReconnectingWebSocketの生成・購読・切断をuseSocketで包み、状態はreadonlyで返す
    • socketに触れるのがcomposable内だけになり、切断し忘れが起きなくなる
  • リンタールール化:
    • 置換: 4パターンに当てはまるロジックは.vueに直書きせず、composableに切り出す
  • 結果:
    • 4パターンのロジックは.vue(pages、components)に書けなくなる
    • 状態を持つロジックがcomposableにカプセル化され、一元管理される

■ 6. 実例: pages

  • page単位のコロケーション:
    • コードベースの見通しをよくするためにルール化した
    • 置換: そのpageでしか使わないものは共有フォルダではなく、page同階層の_components/等に置く
      • 対象は_components/、_composables/、_utils/
  • 結果:
    • 共有フォルダには複数のpageから使われるものだけが置かれる
    • 各pageの_ディレクトリにはそのpageでしか使われないものだけが置かれる
    • コードの利用範囲が置き場所から読み取れるようになる
  • その他のpages関連ルール:
    • 置換: pageは1ファイルではなく、1ディレクトリ1ページで切る(tasks.vueではなくtasks/index.vue)
    • 禁止: データフェッチはページ層でのみ行う
    • 禁止: 別pageの_ディレクトリをimportしない

■ 7. 実例: components

  • 公開コンポーネント単位のコロケーション:
    • コンポーネントの公開範囲を明確にするためにルール化した
    • 置換: 複数のコンポーネントを同居させず、公開コンポーネント1つと内部実装でディレクトリを切る
    • 置換: ディレクトリに独自の名前を付けず、公開コンポーネント名に揃える
    • 禁止: 内部実装のファイルをディレクトリの外からimportしてはいけない
  • 結果:
    • 例としてtable/ディレクトリでは、外から使えるのはBaseTable.vueだけになる
    • BaseTableContent.vueや_composables/等は内部実装として閉じる
    • 置き場所・ディレクトリ名・公開範囲が機械的に一意に決まるようになる

■ 8. 実例: どう書くか

  • 状態を外から書き換えさせない:
    • 必須: composableが返すrefにはreadonly()を付けて返す
    • 状態を書き換えられるのが専用関数だけになり、外からは読むことしかできなくなる
  • 計算できる値を状態として持たない:
    • 置換: 値を代入するだけのwatchはcomputedで書く
    • watchで代入すると状態が2つできてしまう
    • computedで宣言的に定義すると、合計値が常に元データと一致する
  • レビューの自動化:
    • 人間が行っていた指摘・提案をリンターが代わりに行うようになった

■ 9. 決定的にできないこと

  • 決定性の限界:
    • アーキテクチャ判断で決定的にできる部分には限界がある
    • 更なる改善には、決定的にできない部分が何かを知る必要がある
  • 機械的判定の可否:
    • 判定可能なのは事実の判定(例: .specが無い、utils/でVueを使う、watch内で代入する)
    • 判定不可能なのは意味の判定(何が1つのfeatureか、その単位・粒度・同一性)
  • 意味の答えはコードの外:
    • 意味を決める答えはコードの中ではなく、コードの外にある
  • ドメインとドメインモデリング:
    • ドメインはソフトウェアが扱う問題領域であり、解決方法が無数にあるため設計は非決定的
    • 概念・関係・ルール・制約を構造化することで、解決方法を実装可能な設計として定義する
  • バックエンドのドメインモデリング:
    • 実装前に業務ルールの表現方法をモデリングすることが多い
    • 例: ユースケース図(タスクの一覧・検索・作成・完了)
    • 例: ドメインモデル図(タスクと担当メンバーの関係、完了タスクは元に戻せない制約)
  • フロントエンドではデザイナーが担う:
    • ユーザーフロー図でユーザーの行動とUIの遷移をモデル化する
    • デザインモックでUIの構造・配置・見た目をモデル化する
    • デザイナーはユーザーの課題をどんな体験で解決するかをモデリングし、実装可能にしている
  • feature境界の決定:
    • このドメインモデルの構造をヒントに、人間が自然なfeatureの境界を引く

■ 10. ドメインモデルによるfeatureの切り分け

  • コロケーションで分類できたこと(事実):
    • page配下の_ディレクトリには、pageで使われるfeatureがカプセル化される
    • 共有フォルダは、複数箇所から使われるfeatureか汎用モジュールのどちらかに機械的に分類される
  • 分類できなかったこと(意味):
    • 共有フォルダは複数箇所から使われるfeatureと、ドメインを持たない汎用モジュールの2つの責務を持つ
    • 共有フォルダではドメインを持たない汎用モジュールだけを管理したい
  • 複数箇所で使われるfeatureの例:
    • タスク一覧とタスク作成履歴の2つのデザインに、同じfeatureが現れる
    • タスク検索featureとタスク作成featureが該当する
  • Nuxt Layersによる分類:
    • Nuxt LayersはNuxtアプリと同じ構造を外に持てる仕組み
    • layers/配下にtaskCreate/やtaskSearch/としてfeatureを配置した
  • 結果:
    • app/の共有フォルダにはドメインを持たない汎用モジュールが残る
    • page配下にはpageで使われるfeature、layers/には複数箇所から使われるfeatureが置かれる
    • featureをカプセル化し、影響範囲と依存関係を閉じ込めることができる

■ 11. おわりに

  • 運用結果:
    • フロントエンドのレビューで、アーキテクチャに関する指摘が激減した
    • 安心してフロントエンドのコードを変更できるようになった
    • フロントエンドに知見がないエンジニアやデザイナーも積極的に開発するようになった
    • 結果として機能開発の速度が劇的に上がった
  • 言語化の難しさとコツ:
    • 決定的にするための言語化は難しい
    • 既存コードやライブラリを参考に、AIと対話して確信を持てるまで自然言語に落とし込む必要がある
    • ある程度確信を持ったらルール化し、より良い案が出たら追加・修正するのがよい
    • レビュー指摘を受ける度に、リンタールール化できないかAIに聞くのもよい
    • AIは人間が思っているよりルール化できる
  • 空いた時間の使い道:
    • アーキテクチャが決定的になると開発が効率化され、時間が生まれる
    • その時間でユーザーにとって意義のあることに向き合う
    • 例: 仕様・要件の整理
    • 例: アクセシビリティ、パフォーマンスチューニング、セキュリティ
    • 例: バックエンド・インフラ・デザインなどへの越境