🫙

フロントエンドのモジュール分割を考える

に公開

最近、わかる!ソフトウェア設計トレーニング を読みました。以下の紹介文にある通り、普遍的な設計理論をどういった場合に適用するか判断力を養う問題が載っています。

本書は、その問いに正面から向き合います。 ソフトウェア設計には、確かに現場の経験が欠かせません。 しかし、その根底には、学ぶことで誰もが習得できる、普遍的な「理論」と「判断基準」が存在します。 それは、数学の公式を学んだ後、問題集を解いて応用力を身につける学習プロセスと何ら変わりません。本書は、その訓練を積むための本格的な「設計問題集」です。

第 2 章ではモジュール分割に関する説明と問題があるのですが、「ソフトウェア設計の主要な目標は、人間が一度に扱うべき情報量を減らすことである」というシンプルで強力なセンテンスで頭に残っており、普段のフロントエンド開発に当てはめて考えてみたくなりました。

この記事では、React のフロントエンド開発で慣習的・経験的に取り入れているプラクティスをモジュール分離の観点から考えた結果をまとめます。

モジュールとは

この記事ではあるデータブロックに名前を付けた変数や関数、ファイルからディレクトリまでを広くモジュールとして扱います。モジュール分割とは、名前付けなのかもしれません。

https://r-west.hatenablog.com/entry/20090510/1241962864

ディレクトリ構成

React のディレクトリ構成では著名な Bulletproof React をベースにして、プロジェクトに応じたテイストを加えています。ルートディレクトリとその中の 1 つである features ディレクトリに分解して見ていきましょう。

ルートディレクトリ

以下の例は Next.js アプリケーションにおけるプロジェクトルート、または src ディレクトリを単純化したものです。

/src
├── app
├── components
│   ├── layouts
│   ├── providers
│   └── ui
│       ├── button
│       ├── index.module.css
│       └── index.tsx
├── constants
├── features
│   ├── sales
│   └── order
├── lib
├── hooks
├── types
└── utils

特徴的なのは、技術による分離機能による分離という対照的な 2 つの軸でスコープを明示している点です。

分離の軸 特徴と説明
技術による分離 「○○ という種類に該当するファイル群」としてまとめる方法。ディレクトリ内のコードはプロジェクト全体から呼び出される前提で設計されており、誰でも知っている汎用的な知識を提供する hooks, utils
機能による分離 特定の機能に関連するコードをまとめる方法。ディレクトリ内のコードはその機能からのみ呼び出され、限定的な知識として閉じている features/order, features/sales

この 2 つの軸は「汎用的」と「限定的」の線引きでもあります。hooks/ に置かれたカスタムフックはどの機能からでも呼び出せますが、features/order/hooks に置かれたものは受注という文脈に閉じています。こうすることで、あるモジュールを見たときディレクトリ構造から誰が使うものなのか推測できるようになります。

features ディレクトリ

続いて features/order のような機能モジュールの内部を見ていきます。

features/order/
├── actions/
├── api/
│   └── get-cart-items.ts
├── components/
│   └── cart/
│       ├── cart-item-list/
│       │   ├── cart-item/
│       │   │   ├── index.tsx
│       │   │   └── index.module.css
│       │   ├── index.module.css
│       │   └── index.tsx
│       ├── cart-summary/
│       │   ├── index.tsx
│       │   ├── index.module.css
│       │   └── index.test.tsx
│       ├── index.tsx
│       ├── index.module.css
│       ├── index.test.tsx
│       └── index.stories.tsx
├── helpers/
├── hooks
├── schemas/
├── tests/
└── types/

features/order で受注ドメインにスコープを絞った後、さらに技術による分離を行っている点に注目してください。このようなハイブリット構成にすることでディレクトリツリーの見通しがよくなり、特定の機能に特化した開発を効率的に進めることができます。

汎用的と限定的

汎用的なモジュール

汎用的なモジュールの特徴は、プロジェクト内の誰もが知っている共通項まで情報が削ぎ落されていることです。次のような日本の住所を扱うカスタムフックがあったとします。

/hooks/use-get-address.ts
// 郵便番号から検索した住所を返す
export function useGetAddress() {
  return useMutation({
    mutationFn: (zipCode: string) => getAddressByZipCode(zipCode),
  });
}

このカスタムフックは日本の住所を扱う機能であれば、どこからでも呼び出せます。理解に必要なのは「郵便番号から住所を検索できる」という事実だけであり、ドメイン知識などの余計な情報は必要ありません。このように、汎用的なモジュールは単体で理解するのが容易であり、重複の排除に役立ちます。

限定的なモジュール

その一方、限定的なモジュールの特徴は利用側に特定の知識を要求する代わりに処理を特化できることです。次のようなカートに入っている商品の合計金額を計算する関数があったとします。

/features/order/helpers/cart.ts
import { TAX_RATE } from "@/features/order/constants";

type CartItem = {
  id: string;
  name: string;
  price: number;
  quantity: number;
};

type Cart = {
  items: CartItem[];
};

export function calculateTotalAmount(cart: Cart) {
  const subtotal = cart.items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0,
  );

  return subtotal * (1 + TAX_RATE);
}

この関数を呼び出すモジュールは受注ドメインに限定されるため、カートなどのドメイン知識の言葉を使うことができます。よって、限定的なモジュールは文脈を知っている人にとって理解が容易であり、複雑な処理を隠蔽するのに役立ちます。仮に上記の関数をドメインの言葉を使わずに書くとしたら、以下のような文脈が消えた読みにくいコードになることが想像できそうです。

type Item = {
  price: number;
  quantity: number;
};

type List = {
  items: Item[];
};

export function calculateTotalAmount(list: List, taxRate: number) {
  const subtotal = list.items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0,
  );

  return subtotal * (1 + taxRate);
}

一方で、この関数は受注ドメインに依存していないため、請求など異なるドメインから再利用できるとも捉えられます。このように、汎用化は再利用を促すと同時に意図を希薄化させるため、トレードオフがあることを意識して設計する必要があります。

誰が何を知っているか

機能による分割で直面する課題として典型的なのは、機能を跨いだ処理が出てくることです。例えば、飲食量品を扱うアプリケーションは軽減税率を考える必要があり、受注と請求ドメインから税率を参照するといったケースが往々にして発生します。この時、受注ドメインで定義した軽減税率のコードを請求ドメインから参照してしまうと、依存方向に一貫性がなくなり、機能としての独立性まで失われてしまいます。

その解決として、features/ の隣に shared/ ディレクトリを作成することが多いです。shared/ に共通のロジックを配置する (閉じ込める) ことで、依存が一方向に流れるようになります。


機能を跨いで共通するコードの依存を一方向にする

コロケーション

関連するコードを近くに配置するコロケーションという考え方があります。UI コンポーネントのスタイルや Storybook、テストはそのコンポーネントだけが知っていればよい知識です。これらを同じディレクトリにまとめることで、あるモジュールに関する変更が一箇所で完結し、利用側は知らなくて良いことを忘れられる というメリットがあります。

以下はボタンコンポーネントをコロケーションした例と、技術で分離して CSS を別ディレクトリに移動した例です。

# コロケーション: 関連ファイルを同じ場所に配置
components/ui/button/
├── index.tsx
├── index.module.css
└── index.stories.tsx
# 技術による分離: スタイルを別ディレクトリに配置
components/ui/button/
├── index.tsx
└── index.stories.tsx

styles/ui/button/
└── index.module.css

コロケーションでは、ボタンに関するすべてのファイルが一箇所にまとまっているため、変更時の影響範囲が明確です。一方、CSS を styles/ に分離してしまうと、ボタンを修正するために複数のディレクトリを行き来する必要があります。モジュールが不要になった場合も、コロケーションされていればディレクトリごとバッサリと削除できるため、捨てやすいとも言えます。

この捨てやすさを徹底する意味でも、外からの呼び出しを想定していないコンポーネントは親のディレクトリにネストするのが好みです。例えば、カートコンポーネントの場合は以下のようになります。

features/order/components/cart/
├── cart-item-list/
│   ├── cart-item/
│   │   ├── index.tsx
│   │   └── index.module.css
│   ├── index.module.css
│   └── index.tsx
├── cart-summary/
│   ├── index.tsx
│   ├── index.module.css
│   └── index.test.tsx
├── index.tsx
├── index.module.css
├── index.test.tsx
└── index.stories.tsx

cart-item-list.tsxcart-summary.tsx は カートコンポーネントの子としてネストされており、直近の親である cart.tsx だけがその存在を知っています。カート機能が不要になれば cart/ ディレクトリごと削除するだけで済みますし、外部から参照可能なのはカートコンポーネントだけであることをディレクトリ構造から推測できます。

モジュール単位の参照を強制する

TypeScript の仕組み上、エクスポートしたモジュールはどこからでも参照できてしまいます。コーディング規約だけでは心もとない場合、uhyo さんの開発した eslint-plugin-import-access を使うことでディレクトリ (モジュール) 単位で公開レベルを制御しつつ、ルールに違反したコードは ESLint でエラーとして検知できます。

features/order/components/cart/cart-item-list/index.tsx
/** @package */
export { CartItemList } from "./container";
features/order/components/cart/cart-summary/index.tsx
/** @package */
export function CartSummary() {
  // ...
}

@package を付けたエクスポートは、同一ディレクトリとそのサブディレクトリからのみインポートできます。さらに、ルール設定で indexLoophole: true とすることで、index.ts からの @package export は一階層上の親ディレクトリからもインポートが許可されます。これにより、カートコンポーネントの外からの子コンポーネントへの直アクセスを CI で防止できます。

テスト

モジュール分割はテストとの関連も深いと思っています。ここでは、コロケーションとテスト容易性を取り上げて、テストの配置を決めるのに必要な観点を整理します。

テストとコロケーション

先ほどのボタンやカートコンポーネントの例では、テストファイルをコロケーションしていました。同じように「誰がこのモジュールを呼び出せるか」を考えることで、共通化が必要であるテストフィクスチャやヘルパーのスコープが定まります。

種類 スコープ
テストフィクスチャ 受注ドメインのテストに閉じている カートアイテムを作成するファクトリ関数 features/order/tests/fixtures/cart.ts
ヘルパー プロジェクト全体のテストで使う Testing Library React で TanStack Query のプロバイダを提供するカスタムラッパー tests/helpers/testing-library.tsx
features/order/tests/fixtures/cart.ts
// ランダムな値を生成するライブラリ
import { faker } from "@faker-js/faker";
import type { CartItem } from "@/features/order/types";

// カートアイテムのファクトリ関数 (引数で指定したプロパティはテスト内でアサーションに用いる)
export function createCartItem(override?: Partial<CartItem>) {
  const defaultCartItem: CartItem = {
    id: faker.string.uuid(),
    name: faker.commerce.productName(),
    price: faker.number.int({ min: 10, max: 100 }),
    quantity: faker.number.int({ min: 1, max: 10 }),
  };

  return { ...defaultCartItem, ...override };
}
tests/providers/query-wrapper.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { PropsWithChildren } from "react";

// TanStack Query のプロバイダを提供するカスタムラッパー
export function QueryWrapper({ children }: PropsWithChildren) {
  const queryClient = new QueryClient();

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}
tests/helpers/testing-library/index.tsx
import { render } from "@testing-library/react";
import { QueryWrapper } from "@/tests/providers/query-wrapper";
import type { ReactElement } from "react";
import type { RenderOptions } from "@testing-library/react";

// React Testing Library の render 関数を拡張して、TanStack Query のプロバイダを提供
function customRender(
  ui: ReactElement,
  options?: Omit<RenderOptions, "wrapper">,
) {
  return render(ui, { wrapper: QueryWrapper, ...options });
}

export * from "@testing-library/react";
export { customRender as render };

しかし、テストに関しては呼び出し側のスコープだけでディレクトリ構成やファイルの配置が決まる訳ではありません。E2E テストや VRT など、CI 上で分離して実行したいテストもあります。また、実装者と QA が分かれている場合は、ディレクトリで分割した方が管理しやすいかもしれません。これらを複合的に考えて、テストの配置を決定する必要があります。

テスト容易性

一般的にテスト対象を入出力で決まる純粋関数 (JavaScript の世界) に近づけるほど、単体テストは書きやすくなります。ただ、フロントエンドでは「何らかの処理を経てビューが変わる」という結果になることが多いため、関数単体では UI としての振る舞いを保証しにくいという性質があります。


*UI テストにおける、テスト容易性と振る舞い担保の関係

React Testing Library や Storybook Test の助けもあり、比較的容易にテストできるケースも少なくありませんが、複雑な処理は関数レベルでテストしたいと思うのも当然です。パターンが何十通りもあるのに、そのすべてをビューで確認する必要があるでしょうか?

ここで改めて考えたいのがモジュール分割と関心の分離です。関数に切り出してテスト容易性を向上させたとしても、今度はその関数をどこに配置すれば良いか悩みます。helpers/ でしょうか?これがコンポーネントを跨いで普遍的な処理であれば良いのですが、そうでない場合はモジュールとしての凝集性を損ねています。

未だに迷いますが、私の場合は以下の順番で考えることが多いです。

  1. 基本的にはコンポーネント単位でテストする
  2. コンポーネント単位のテストが厳しいと感じたら、処理をいくつかのコードブロックに分けてフックや関数に切り出す
  3. 「誰がそれを呼び出せるか」を考えて、その共通項となるディレクトリにファイルをつくって配置する

つまり、コンポーネント固有の処理であればコロケーションしますし、機能内で汎用的な処理であれば features ディレクトリ内の hooks/helpers/ に配置します。

# コンポーネント固有の並び替えである場合
features/order/
└── components/
    └── cart/
        └── cart-item-list/
            ├── index.tsx
            ├── index.test.tsx
            ├── sort-cart-items.ts        # コンポーネント固有のロジック → コロケーション
            └── sort-cart-items.test.ts
# 受注ドメインで共通の並び替えである場合
features/order/
├── components/
│   └── cart/
│       └── cart-item-list/
│           ├── index.tsx
│           └── index.test.tsx        # コンポーネントのテスト → コロケーション
├── helpers/
│   ├── sort-cart-items.ts            # 機能内で汎用的なロジック → helpers に配置
│   └── sort-cart-items.test.ts
└── hooks/
    ├── use-cart-items.ts             # 機能内で汎用的なフック → hooks に配置
    └── use-cart-items.test.ts
private 関数をテストするか

コンポーネント固有のロジックを関数に切り出してテストするのは、いわゆる private 関数のテストに該当すると思っています。テストのためだけにフックや関数をエクスポートすることへの違和感や、実装の詳細に依存したテストは壊れやすいという指摘は妥当です。

https://t-wada.hatenablog.jp/entry/should-we-test-private-methods

Vitest には In Source Testing という仕組みがあり、エクスポートせずにモジュール内部でテストを記述できます。もしテストのために関数を公開することに抵抗がある場合は選択肢に入るかもしれません。個人的にフロントエンドのビューを含むテストにおいては、乱用さえしなければ害は少ないというスタンスに変わりました。

関心の分離

モジュールを適切に分割することで必要のない知識を忘れられるため、関心の分離が促進されます。わかる!ソフトウェア設計トレーニングでは分割したモジュールの親子関係において、子は親の何を忘れることができるのかを紹介していました。これを React 開発でよく用いられるパターンに当てはめて考えてみようと思います。

親の呼び出し手順を忘れる

次のように 3 つの処理があったとき、関数 B は 関数 A の次に呼ばれることを忘れることができます。

  • A: カートアイテムを取得する (useGetCartItems)
  • B: カートアイテムを並び替える (sortCartItems)
  • C: 在庫切れの商品を除外する (filterInStockItems)

親であるカートコンポーネントは A → B → C の順に処理して画面を表示します。一方で関数 B (sortCartItems) は、自分が 関数 A の後に呼ばれることを知りません。データの取得元が API であってもダミーデータであっても、渡された配列を並び替えるだけです。このように親の呼び出し手順を忘れることで、子は自分の仕事に専念できるようになり、テスト容易性が高まります。

親の呼び出し意図を忘れる

汎用化を進めると子は親の呼び出し意図を忘れます。たとえばカードコンポーネントのフッターを React.ReactNode で受け取るようにしたとします。

type CardProps = {
  title: string;
  children: ReactNode;
  footer?: ReactNode;
};

// 注文確認画面:送信ボタンを表示
<Card title="注文内容" footer={<SubmitButton />}>
  <CartItemList items={cartItems} />
</Card>

// カート確認画面:フッターに合計金額を表示
<Card title="注文内容" footer={<CartSummary cartItems={cartItems} />}>
  <CartItemList items={items} />
</Card>

カードコンポーネントはフッターに何が来るか知らないため、無限の可能性を考慮する必要があります。一見するとその柔軟性で重複を排除しているように思えますが、設計者が想定していない使い方をされる可能性が高いです。このように、フロントエンドでは重複コードを許容した方が良い場面も少なくありません。コンポーネントの呼び出し可能範囲が明確になるようにしましょう。

おわりに

モジュール分割がどうやってソフトウェアの内部品質に寄与するのか、その一部を理解できた気がします。プロジェクトごとに様々なケースがありますが、「要はバランス」で終わらせない考え抜いた設計ができるようになりたいと思いました。

株式会社FLAT テックブログ

Discussion