🇫🇷

フロントエンドもアーキテクチャに向き合う!

に公開

フロントエンドもアーキテクチャに向き合う!

こんにちは!フロントエンドエンジニアの浅川です!

この記事では、社内向け管理画面(以降「社内システム」と呼びます)を段階的に整えながら、今の形へたどり着くまでの考え方を、時系列でまとめてみたいと思います。

この記事でまとめること

  • 各レイヤーの責務と、ディレクトリ構造
  • 「どこに何を置くか」の分割の目安

前提:社内システムの構成

まず、社内システムの技術スタックを簡単に紹介します。

  • フレームワーク: Vue 3 + Nuxt 4(SPA、SSR なし)
  • バックエンド: Firebase(Firestore / Cloud Storage / Cloud Functions)
  • API: 当初はフロントから Firestore を直接参照する形。現在は Hono と Zod OpenAPI Hono による REST API を介する構成へ切り替えていく方針をとりました
  • デプロイ: Firebase Hosting

ポイントは、フロント側がバックエンド寄りの責務もそこそこ抱えていることです。Firebaseはクライアントからデータベースへ直接アクセスできる構成をとれるため、開発初期などスピード重視の時期はFirestoreを直接取り扱っていました。そういった歴史的背景もあり、一般的にAPIが担うべき処理もフロントエンドに書かれていたりします。後述する repository / infrastructure / API のレイヤー分けは、このあたりの整理に深く関係してきます。

なぜアーキテクチャに向き合うのか

スタートアップの立ち上げ初期は、「来月この会社あるんだっけ?」みたいな時代もあります。そういうフェーズでは、設計の美しさよりも動くものを早く出すことのほうがずっと大事で、フロントエンドのコードは Vue SFC の <script> に詰め込み、まず形にすることを最優先にしていました。実際、社内システムの初期もそうでした。

ただ、事業の成長とともに状況が変わります。社内システムは複数の事業部で使われ始め、機能数や画面数が増え、関わる開発者の数も伸びていきました。そうなると、

  • 「今の挙動を壊さず機能を足したい」
  • 「仕様変更を怖がらず画面を直したい」
  • 「新しく入った人が、迷子にならず開発へ入れるようにしたい」

といったニーズが、画面を増やす速度と同じくらい重みを持つようになります。品質と変更容易性、それを支えるコードの読みやすさを意識する必要が出てきた、ということです。

フロントエンドは技術やトレンドの移り変わりが早く、バックエンドの DDD ほど長く積み上がってきた定石もないため、「これが正解」という決め打ちが置きづらい領域です。そんな中で、社内システムでは「ドメイン単位で関心ごとを集める」考え方を採用し、features/{domain}/ を軸にした構成へ少しずつ寄せていきました。ここからは、その移行を順を追って振り返ります。

なお、以降はわかりやすさを優先して時系列のフェーズごとに整理しています。実際は並走していた作業や、書ききれなくて省いた話もあります。

フェーズ① features と repository を導入する

立ち上げ初期の社内システムは、Vue SFC の <script> に Firestore のクエリと npm ライブラリの取り回しが全部混ざって書かれていました。dayjs での日付整形ロジックや、Firestore の TimestampDate に変換するコードが、画面ごとにそのまま散らばっている状態です。ドメイン層にも Timestamp のような外部依存の型が顔を出していました。そのまま放っておくと、同じ概念が画面ごとに微妙に違う実装で散らばってしまうので、最初のステップとして「同じ関心ごとを 1 箇所に集める」ことから手をつけました。

このフェーズでやったのは、次の3つです。

  1. ルート直下で散らばっていた composables/domains/ を、ドメイン単位の features/{domain}/ 配下へ集約する
  2. ビジネスロジックを持つ Vue コンポーネントも features/{domain}/components/ に移す(見た目だけの共通部品は別途扱う)
  3. Firestore へのアクセスを repository/ に切り出し、ドメイン層から外部依存を取り除く

ディレクトリのイメージはこんな形です。

src/
├── features/
│   └── user/
│       ├── components/        # ドメイン固有 Vue コンポーネント
│       ├── composables/
│       └── domains/           # 後に models/ が分離される
└── repository/                # Firestore / Storage との直接のやり取り

repository をなぜ分けるか

repository/ の導入はクリーンアーキテクチャを参考にしました。社内システムは Firestore を直接読み書きしている部分が多かったのですが、DB アクセスは本来 API 側の責務でフロントが直接見るべきではありません。将来 API 化されたときに置き換えやすくしておきたかったのと、今どんなクエリが Firestore に飛んでいるのかを 1 箇所で俯瞰できるようにしておきたかった、というのが大きな動機です。

何が嬉しかったか

  • 「ユーザー機能を触りたい」が features/user/ だけで完結し始めた
  • ドメイン層から Timestamp などの外部依存型が抜け、純粋な「アプリの言葉」だけで書けるレイヤーの境界が生まれ始めた
  • Firestore のクエリ実装が repository/ に集約され重複コードが減り、features/ 側が DB を意識しなくてよくなった(クリーンアーキテクチャ的に「内側は外側を知らない」状態に近づき始めた)
  • レイヤーを設けることで分割PRなども可能になった

苦労した点

  • 既存コードを一気には倒せないので、「新規追加分は新ルールで、既存は触ったときに移す」という温度感で進めるしかなかった
  • domains/ に Firestore 由来の型が混入していた箇所を、1つ1つひっぺがしてアプリ独自の型に置き換えるリファクタが地味に長かった
  • 「これは repository に切り出すべき? フィルター条件はユースケース?」のような線引きは、最初は人によってブレた

それでも、「features/ はビジネスロジックに集中する」という旗を立てられたことは、ここからの改善を進める上でとても大きな転換点でした。

フェーズ② domains と models に分割する

features/ 配下へ型とロジックを集めたものの、ドメインのルール(クーポンの有効期限の判定など)と form / UI 都合の表現(zod の form schema、dropdown 選択肢、ラベルマップなど)がひとつのファイル内で同居している状態でした。

形だけ見れば動くのですが、

  • ドメインルールを直したい人と、form を直したい人が同じファイルを触る
  • 「テストしたいのはルール部分だけなのに、UI 都合のコードが邪魔をする」
  • 「新しい画面を作るとき、初期値や options のためだけにドメイン型を import してしまう」

といった引っかかりが、機能を増やすたびに少しずつ広がっていました。

そこで features/{domain}/ の中身を、責務ごとに2つへ分けました。

features/{domain}/
├── domains/   # 型 + 単一ドメインに閉じた純粋関数(不変条件・refine 用ルール)
└── models/    # form の zod schema + UI 表現(dropdown 選択肢、ラベルマップ)

イメージとしてはこんな書き分けです。

// domains/Coupon.ts  ── ルールはここに置く
export type Coupon = { id: string; expiresAt: Date };

export const isExpired = (coupon: Coupon, now: Date): boolean =>
  coupon.expiresAt.getTime() < now.getTime();
// models/couponModel.ts  ── form / UI 都合はここに置く
import { z } from "zod";
import { isExpired } from "../domains/Coupon";

export const couponFormSchema = z.object({
  expiresAt: z.date().refine((d) => !isExpired({ id: "", expiresAt: d }, new Date()), "期限切れです"),
});

export const couponStatusOptions = [
  { value: "active", label: "有効" },
  { value: "expired", label: "期限切れ" },
] as const;

ポイントは、ルールの本体は domains/ にだけ書くということです。models/ の zod schema 内で refine を書きたくなったときは、domains/ の純粋関数を呼ぶ形をとり、同じルールを二重実装しないよう揃えています。

このあたりから、ドメインの不変条件などにユニットテストが書けるようになってきたのも大きな変化でした。isExpired のようなルールは外部依存を持たない純粋関数なので、domains/Coupon.test.ts に淡々と入出力を並べていくだけでカバレッジが伸びていきます。フェーズ①までは UI や Firestore と一体化していて手をつけづらかったテストが、ここから素直に書ける状態になりました。

副次的なメリットですが、models/ を分けたことで「ドメインのルールは長く生きるけれど、form の表現は画面の都合でよく変わる」という変更頻度の違いへも素直に対応できる形となりました。

フェーズ③ クリーンアーキテクチャを参考に infrastructure を導入

repository で Firestore の関心ごとは隔離できたものの、dayjs や地図 SDK、geojson、infinite-scroll といった npm ライブラリ依存はまだ features の中から直接 import されている 状態でした。これも整理することにしました。

導入したのが infrastructure/ 層です。役割はシンプルで、

  • npm ライブラリのラッパーを集約する
  • ラッパーは「利用用途ごとに関数を切り出す」 (原則、ライブラリの再エクスポートにはしない)
  • ライブラリ独自の型は アプリの独自型に変換してから返す
  • features/ からの npm ライブラリの直接 import は禁止

たとえば日付ユーティリティはこんな雰囲気です。

// infrastructure/date/format.ts
import dayjs from "dayjs";

/** 日付を「YYYY/MM/DD」表記に整形する */
export const formatYmdSlash = (date: Date): string => {
  return dayjs(date).format("YYYY/MM/DD");
};

呼び出し側はライブラリの存在を意識せず、用途ベースの関数だけを見ます。

// features/user/composables/useUserList.ts
import { formatYmdSlash } from "~/infrastructure/date/format";
// dayjs は features から見えない

地図の中心座標のような計算が絡む処理でも、同じ思想がそのまま効きます。

// infrastructure/map/getCenter.ts

type LatLng = { latitude: number; longitude: number };
type MapBounds = {
  northEast: LatLng;
  southWest: LatLng;
};

/** 地図の表示領域から中心座標を取得する */
export const getMapCenter = ({ mapBounds }: { mapBounds: MapBounds }): LatLng => {
  // SDK の API でも、単純な平均計算でも、実装の選択はここに閉じる
  return {
    latitude: (mapBounds.northEast.latitude + mapBounds.southWest.latitude) / 2,
    longitude: (mapBounds.northEast.longitude + mapBounds.southWest.longitude) / 2,
  };
};

呼び出し側は 「中心座標が欲しい」だけ を表明します。

// features/store/composables/useStoreMap.ts
import { getMapCenter } from "~/infrastructure/map/getCenter";

const center = getMapCenter({ mapBounds });
// 中心がどう計算されているか・どの SDK を使っているかは features からは見えない

何が変わったか

主な狙いはライブラリ固有の実装詳細をビジネスロジックから隠すことです。
たとえば「地図の表示領域から中心座標を取得する」といった処理を呼び出す側が、座標の算出式や SDK のメソッド名を知る必要はありません。features 側は「中心座標が欲しい」という用途だけを見ればよく、「どう計算しているか」は infrastructure の中に閉じるようにしました。

  • ライブラリ固有の型・呼び出し方が features/ から消え、さらに「ビジネスロジックに集中する場所」へ寄った
  • ラッパー単位でユニットテストが書けるようになった(「この関数は何を保証するか」が明示的なので、入出力テストが素直に並ぶ)
  • 「日付フォーマットが画面ごとに微妙に違う」といった重複の大幅な減少(利用用途で関数が定義されているので、再利用が前提となる)
  • ライブラリ差し替え(dayjs → date-fns のような)を検討するときの影響範囲が infrastructure/ の中に閉じる(副産物)

見えてきた次の課題

上の例にあるように、LatLngMapBounds といった アプリ独自型を infrastructure の中で個別に定義していました。「ライブラリ独自型を features へ漏らさない」目的にはきちんと効いていたのですが、しばらく運用していると違和感がはっきりしてきます。

  • 同じような「緯度経度」型が、infrastructure の複数の場所で 似て非なる定義で存在する
  • features 側で複数の infrastructure を組み合わせるとき、似ているけど別の型同士の変換コードが随所に書かれる
  • features をまたいで使う概念(緯度経度、ID 系の branded type など)の置き場所が無い

この課題感が、次の core/ 導入につながります。

フェーズ④ Core 概念の導入と、共通 UI の NuxtUI 置き換え

ここで features/{domain}/ の内部もだいぶ整ってきました。次は infrastructure フェーズの宿題だった「feature や infrastructure をまたぐ概念の置き場所」問題と向き合います。あわせて、肥大化していた共通コンポーネントの整理にも手をつけました。

Core 概念の導入:feature や infrastructure を跨ぐ最内側の型

緯度経度や地図のバウンディングボックス、ID 系の branded type のような概念は複数の feature に登場しますし、infrastructure/ 側の地図 SDK ラッパーや位置情報ラッパーでも同じものを扱います。各レイヤーで個別に DTO を定義していると、似て非なる型が増えてしまうし、向きの揺らいだ import も発生してしまいます。

この種の「複数文脈で共有される最内側の概念」は、DDD では Shared Kernel にあたります。素直に当てはめると features/shared/ のような置き場を作り、feature 同士の import で共通化する形が候補に上がり、当初はその案も検討しました。
ただ、 「feature と infrastructure を横断するアプリ共通の最内側の型」 という定義を features 配下に置くと、安易に共通型が増えやすいという懸念がありました。features と並列の shared/ は、心理的に「とりあえず置ける場所」として機能してしまいます。

そこで features よりも一段上の独立したレイヤーとして src/core/ を導入し、ここへfeature と infrastructure を横断するアプリ共通の最内側の型を集める形をとりました。features と並列ではなく一段上に置くことで、coreに上げる判断に意識的なレビューを通す運用にしています。新規の型には Core 接頭辞を付け、依存方向を明示的にしました。

// core/map/type.d.ts (抜粋)
type CoreLatLng = [number, number];

type CoreGeoPosition = {
  latitude: number;
  longitude: number;
};

type CoreMapBounds = {
  northEast: CoreGeoPosition;
  southWest: CoreGeoPosition;
};

ポイントは、地図 SDK のレスポンスを infrastructure/ 側でこの core 型に変換してから返すようにしたことです。こうすると「SDK の生の型 → アプリ独自型」の変換が一度だけ行われ、features/ の中で型変換コードを書く必要がなくなります。infrastructure フェーズで発生していた「似て非なる DTO が複数」問題も解消されました。

プリミティブな型を超えた制約のために、汎用的な zod スキーマも core/ に置いています。

// core/map/schema.ts (抜粋)
import { z } from "zod";

export const latitudeSchema = z
  .number()
  .min(-90, { message: "緯度は -90 以上である必要があります" })
  .max(90, { message: "緯度は 90 以下である必要があります" });

export const longitudeSchema = z
  .number()
  .min(-180, { message: "経度は -180 以上である必要があります" })
  .max(180, { message: "経度は 180 以下である必要があります" });

export const latLngSchema = z.tuple([latitudeSchema, longitudeSchema]);

各 feature の models/ 側では、これらも組み合わせて form schema を組み立てる形になります。不変条件は最内側で 1 度だけ宣言する、というルールが zod schema にも適用されるイメージです。

共通コンポーネントの整理:神化していた便利部品を NuxtUI に寄せる

立ち上げ初期に作った「便利な共通コンポーネント」が、しばらく経つとビジネスロジックを内側へ抱えた神コンポーネントへと育っていました。あるドメインの都合で増えた分岐が、別のドメインの画面の挙動を変えてしまう、というあるあるです。

そこで、

  • ビジネスロジックを持つコンポーネントは features/{domain}/components/
  • ビジネスロジックを持たない共通 UI は、Nuxt UI v4 に置き換える

というルールへ切り替え、自家製の共通コンポーネントを段階的に NuxtUI へ寄せていく流れを作りました。これは今も継続中の作業です。

UI ライブラリへの置き換えは、半ば強引な関心の分離でもあります。NuxtUI へ置き換える際、ビジネスロジック入りのコンポーネントはそのままでは差し替えできないため、自然と features 側へロジックが押し出されます

目指している src/ の全体像

src/
├── core/                  # 共通の最内側の型(Core 接頭辞)
├── features/              # ドメイン単位のモジュール
│   └── {domain}/
│       ├── apis/
│       ├── components/
│       ├── composables/
│       ├── domains/
│       ├── models/
│       └── services/
├── infrastructure/        # npm ライブラリラッパー
├── repository/            # Firestore / Storage 連携
├── ui/                    # NuxtUI
├── pages/                 # Nuxt ページ
└── layouts/, middleware/, plugins/

auto import は意図的に off にしている

Nuxt はデフォルトで components/composables/ といったディレクトリを備え、auto import が効くようになっています。社内システムでは config 上で auto import をすべて無効化しています。

すべての import を明記することで、

  • ファイル冒頭の import 一覧だけで依存関係が見える
  • import 行の量から、そのコンポーネントの複雑性を推測できる

ようになりました。

また、AI 駆動開発において、明示的な import があった方が AI がファイルの依存関係を正確に把握しやすく、文脈を見失った AI が「インポートが足りない」と誤認して壊れたコードを生成するのを防げる、という副次的なメリットもあります。AI と協調して高速に開発を進める上でも、この「明示性」が活きています。


features/{domain}/ の中身と依存ルール

ここまでで src/ の外形は見えました。最後に、features/{domain}/ の中身と、レイヤー間の依存ルールを整理しておきます。

features/{domain}/ の内部はおおよそ次のサブディレクトリで構成されています。

features/{domain}/
├── apis/           # API リクエスト関数・API 型契約(feature スコープの infrastructure)
├── components/     # ドメイン固有 Vue コンポーネント
├── composables/    # 状態管理 + 副作用 + 複数依存を束ねるユースケース
├── domains/        # 型 + 単一ドメインに閉じた純粋関数
├── models/         # form の zod schema + UI 表現(dropdown / label など)
└── services/       # 複数ドメインをまたぐビジネスロジック純粋関数

クリーンアーキテクチャの用語にざっくり当てはめると、こんな対応です。

クリーンアーキテクチャ 社内システムでの実体
Entities core/features/*/domains/
Use Cases features/*/composables/features/*/services/
Interface Adapters features/*/apis/features/*/models/ui/
Frameworks & Drivers infrastructure/repository/
Use Case + Presentation のハイブリッド pages/features/*/components/

pages/components/ を独立して切り出しているのは、Vue SFC の <script> がどうしてもユースケースの薄い実装を兼ねるためです。これは後述します。

依存方向のルール

依存は 外側 → 内側 の一方通行です。違反すると「画面に近いものほど、コアの概念より先んじて変わる」というアンチパターンが発生します。

import 可否のざっくりイメージはこちらです(細かい行は社内ドキュメントに正本があります)。

import 元 → 先 core domains models composables/services apis infrastructure/repository ui
core - NG NG NG NG NG NG
domains OK - NG NG NG NG NG
models OK OK - NG NG NG NG
composables/services OK OK OK - OK OK NG
apis OK OK NG NG - OK NG
pages/components OK OK OK OK OK OK OK
infrastructure/repository OK OK NG NG NG - NG
ui OK NG NG NG NG NG -

フロントエンドならではの調整

Clean Architecture をそのままフロントエンドに当てはめると、現場の手触りと合わない部分が出てきます。社内システムでは、いくつかフロントエンド寄りの調整を入れています。

Vue SFC の二層性

Vue の <template> はそのまま Presentation ですが、<script> の中身は「依存をかき集めて画面で必要なデータを並べる Use Case の薄い実装」です。そのため pages/ / components/「Use Case と Presentation のハイブリッド」 として扱っています。

この前提を取ると、「API を 1 本呼んで ref へ入れるだけ」のような短いロジックは composable へ切り出さず <script> 内で直接書いてしまって構わない、という運用がきれいに説明できます。

抽象化の目安

社内システムは repository/ を切り出したときから、続く infrastructure/ui/ に至るまで、各レイヤーに役割を引いて「外側を隠蔽する」 ことを一貫してやってきていました。うまく言葉できていない部分もあったのですが、Claude Code Skill improve-codebase-architecture を読んで、そのぼんやりした感覚が「抽象化は本数を増やすより深さを選ぶ」「消してみて複雑性が散らないなら、それは不要な間接層」といった形で見事に言語化されているのに出会い、ようやく腑に落ちました。社内システムの抽象化方針は、結果としてこのスキルの考え方に大きく寄っています。

composable や service へ切り出すかどうかは、次のいずれかに当てはまるかで判断しています。

  • 状態管理(ref / useState)が必要
  • 複数の API・domain・repository を束ねるロジックがある
  • 複数の page/component から再利用される
  • 副作用(タイマー、ライフサイクル)が必要

たとえば getUser(id) を呼ぶだけの useGetUser は、上のどれにも当てはまりません。これは「消しても複雑性が散らない」=「ただの pass-through」なので、新規では作らず <script> 側で直接呼ぶ運用にしています。

これから

ここまでで「features をビジネスロジック専念の場とする」「ライブラリと UI を端へ追いやる」という方向はだいぶ揃ってきましたが、まだ手をつけたいことはたくさん残っています。

  • キャッシュ層の導入:TanStack Query などを入れて、画面間で「同じデータを別々に取り直す」のをやめてパフォーマンスを向上させたい
  • テストの拡充:仕組み上ユニットテストはだいぶ書きやすくなったので、カバレッジを上げて品質を定量的に向上させていく
  • E2E 基盤:CI に E2E を載せて、見落としを減らす
  • レバレッジの高い抽象化:薄い間接層を量産せず、improve-codebase-architecture の指針に沿って「深さ」のある抽象を選んでいく

まとめ

社内システムは、

  1. features/repository/関心ごとを 1 箇所に集める
  2. domains/models/ドメインルールと form / UI 表現を分ける
  3. infrastructure/ライブラリ依存を端に追いやる
  4. core/features をまたぐ概念を最内側へ置き、共通 UI は NuxtUI で揃える

という順で、「features がビジネスロジックへ集中できる」状態へ段階的に近づけてきました。

大事だなと感じているのは、最初から完成形を目指さないことでした。社内システムは事業の中で動き続けているので、「全部止めて全リファクタ」はそもそも選択肢になりません。「新規追加分は新ルールで、既存は触ったときに少しずつ移す」という温度感で進めるのが、結局いちばん良かった気がします。

もうひとつは、抽象化を増やすこと自体が目的化しないよう注意すること。useGetUser の話のように、レイヤーの数や use プレフィックスの多さは品質と無関係です。「これを消すと複雑性が散る」と言える抽象だけを選ぶ、という姿勢を今のチームでは大事にしています。

フロントエンドのアーキテクチャは「正解」がある世界ではなく、プロダクトと向き合い続けることで形が決まっていくものだと思っています。社内システムもまだ完成形ではなく、これからも変わっていく前提でいます。同じようにフロントエンドのアーキテクチャに向き合っている方の、何かのヒントになれば嬉しいです。

あとがき

ところでこの記事のアイキャッチが 🇫🇷 なのは、私の 2026 ワールドカップ優勝予想です。アーキテクチャと一切関係ありません。お付き合いありがとうございました。


We are hiring

Luupでは、組織と個人の技術面での成長をどちらも大事にしながら、未来のモビリティインフラを一緒に創っていけるソフトウェアエンジニアを積極的に募集しています。

CTOや各開発チームのリーダーともカジュアル面談で直接話せる応募フォームも掲載しておりますので、ぜひお気軽にお声掛けください!
Luup採用情報

また技術発信も多くしているので、ぜひ興味ある方はそちらも覗いてみてください!
Luup Developers(Zenn)

Luup Developers Blog

Discussion