T-CREATOR

<div />

スケーラブルなTypeScriptアプリを設計する モジュール分割の正解と判断基準

2026年1月20日
スケーラブルなTypeScriptアプリを設計する モジュール分割の正解と判断基準

TypeScript プロジェクトの規模が拡大するにつれ、「どこでモジュールを区切るか」「依存関係をどう整理するか」という設計判断に悩む場面が増えてきます。本記事では、静的型付けの恩恵を最大限に活かしながら、規模が増えても破綻しないモジュール分割の境界の切り方と依存方向の整え方を、実務経験に基づいて解説します。

モジュール分割の代表的な戦略比較

分割戦略境界の切り方依存方向型安全との相性適した規模
機能別分割(Vertical)ユーザー機能単位機能内で完結◎ 型が機能に閉じる中〜大規模
レイヤー別分割(Horizontal)技術的関心事上位→下位の一方向○ 境界にインターフェース必要小〜中規模
ドメイン駆動設計(DDD)ビジネス境界コンテキスト間は疎結合◎ 型がドメインモデルを表現大規模・複雑
ハイブリッド機能 + レイヤー外部→内部の依存逆転◎ 設計次第で最適化可能中〜大規模

それぞれの詳細と判断基準は後述します。

検証環境

  • OS: macOS Sequoia 15.3
  • Node.js: 24.13.0 LTS (Krypton)
  • TypeScript: 5.9.3
  • 主要パッケージ:
    • eslint: 9.19.0
    • eslint-plugin-import: 2.31.0
    • madge: 8.0.0
  • 検証日: 2026 年 01 月 20 日

モジュール分割がスケーラビリティを左右する理由

TypeScript の静的型付けは、コンパイル時にエラーを検出できる型安全な開発体験を提供します。しかし、モジュール境界が曖昧なまま開発を進めると、型の恩恵を十分に活かせなくなります。

モジュール分割とは、コードベースを意味のある単位に区切り、各モジュールの責務と依存関係を明確にする設計手法です。適切な分割により、以下の効果が得られます。

  • 変更の影響範囲が予測可能になる: あるモジュールの修正が他のモジュールに波及しにくくなる
  • チーム並行開発が可能になる: モジュール単位で担当を分けられる
  • テストが書きやすくなる: モジュール単位で独立してテストできる
  • 型による設計意図の表現: TypeScript の型システムでモジュール間の契約を明示できる

つまずきやすい点: 「とりあえずディレクトリを分けた」だけでは、モジュール分割とは呼べません。重要なのは、境界の切り方と依存方向の設計です。

実際に試したところ、50 ファイル程度の小規模プロジェクトでは分割の恩恵を感じにくいものの、100 ファイルを超えたあたりから、分割の有無で保守性に明確な差が出てきました。

分割の判断を誤ると発生する問題

モジュール分割における判断ミスは、プロジェクトの成長とともに深刻な問題へと発展します。

循環参照による型推論の破綻

モジュール A が B を、B が A を参照する循環参照が発生すると、TypeScript の型推論が正しく機能しなくなることがあります。

typescript// ❌ 循環参照の例
// user.service.ts
import { OrderService } from "./order.service";

// order.service.ts
import { UserService } from "./user.service";

検証の結果、循環参照が存在する状態でビルドすると、型エラーが出ないにもかかわらず実行時に undefined になるケースを確認しました。

境界が曖昧なことによる責務の肥大化

一つのモジュールに複数の関心事を詰め込むと、いわゆる「神モジュール」が生まれます。

typescript// ❌ 責務が多すぎるモジュール
// utils.ts(実際に業務で見かけた例)
export function formatDate() {
  /* ... */
}
export function validateEmail() {
  /* ... */
}
export function calculateTax() {
  /* ... */
}
export function fetchUserData() {
  /* ... */
}
export function parseCSV() {
  /* ... */
}

このような utils モジュールは、プロジェクトの成長とともに肥大化し、最終的に何でも屋になります。業務で問題になったのは、utils の変更が予期せぬ箇所に影響を与え、リグレッションテストの範囲が特定できなくなったことでした。

依存方向の逆転による型安全の喪失

上位モジュール(ビジネスロジック)が下位モジュール(インフラ)の具体的な実装に依存すると、型による抽象化の恩恵が失われます。

mermaidflowchart TB
  subgraph bad["❌ 依存方向が逆転"]
    bizA["ビジネスロジック"] --> infraA["具体的なDB実装"]
  end
  subgraph good["✅ 依存性逆転"]
    bizB["ビジネスロジック"] --> interfaceB["インターフェース"]
    infraB["具体的なDB実装"] -.-> interfaceB
  end

上図は依存方向の違いを示しています。左側は具体的な実装に依存しており、右側はインターフェースを介して依存性を逆転しています。

境界の切り方と依存方向の整理

この章では、モジュール分割における具体的な設計判断と、それぞれの戦略がどのような場面に適しているかを解説します。

機能別分割(Vertical Slicing)の境界設計

機能別分割は、ユーザーが認識する機能単位でモジュールを区切る戦略です。

bashsrc/
├── features/
│   ├── auth/           # 認証機能
│   │   ├── types.ts    # この機能固有の型定義
│   │   ├── service.ts  # ビジネスロジック
│   │   ├── repository.ts
│   │   └── index.ts    # 公開API
│   ├── products/       # 商品機能
│   └── orders/         # 注文機能
└── shared/             # 共通モジュール
    ├── types/
    └── utils/

tsconfig.json で paths を設定すると、インポートパスを簡潔にできます。

json{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@features/*": ["features/*"],
      "@shared/*": ["shared/*"]
    },
    "strict": true,
    "strictNullChecks": true
  }
}

つまずきやすい点: paths を設定しただけでは、バンドラーやランタイムが解決できません。ts-node や webpack、vite など、使用するツールに応じた追加設定が必要です。

採用した理由: 機能追加や修正が特定のディレクトリに閉じるため、チーム開発で担当範囲が明確になります。型定義も機能内に閉じるため、型安全を保ちやすい構造です。

レイヤー別分割(Horizontal Slicing)の依存ルール

レイヤー別分割は、技術的な関心事ごとにモジュールを区切る戦略です。

bashsrc/
├── controllers/    # HTTPリクエスト処理
├── services/       # ビジネスロジック
├── repositories/   # データアクセス
└── models/         # ドメインモデル

この構造での依存方向は、上位レイヤーから下位レイヤーへの一方向に限定します。

mermaidflowchart TB
  ctrl["controllers"] --> svc["services"]
  svc --> repo["repositories"]
  repo --> mdl["models"]
  ctrl -.->|"参照のみ"| mdl

上図はレイヤー間の依存方向を示しています。矢印の方向にのみ依存が許可され、逆方向の依存は禁止です。

採用しなかった理由: 小規模プロジェクトでは有効ですが、機能が増えると一つの変更が複数レイヤーにまたがり、修正箇所が分散しました。

依存性逆転による型安全な境界

TypeScript の interface を活用した依存性逆転は、型安全なモジュール分割の要です。

typescript// domain/user.repository.interface.ts
export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

// application/user.service.ts
export class UserService {
  constructor(private readonly userRepository: IUserRepository) {}

  async getUser(id: string): Promise<User | null> {
    return this.userRepository.findById(id);
  }
}

// infrastructure/user.repository.ts
export class UserRepository implements IUserRepository {
  async findById(id: string): Promise<User | null> {
    // 具体的なDB操作
  }
  async save(user: User): Promise<void> {
    // 具体的なDB操作
  }
}

この設計により、UserService は IUserRepository という型にのみ依存し、具体的な実装を知りません。テスト時にはモックを注入でき、インフラ層の変更がビジネスロジックに影響しません。

tsconfig.json による境界の強制

tsconfig.json の設定で、モジュール境界を強制できます。

json{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUncheckedIndexedAccess": true,
    "moduleResolution": "bundler",
    "isolatedModules": true
  }
}

isolatedModules を有効にすると、各ファイルが独立してトランスパイル可能であることが強制され、暗黙的な依存関係を防げます。

実際のプロジェクトにおける分割の適用

この章では、具体的なコード例を通じて、モジュール分割の実装方法を示します。

バレルファイル(index.ts)による公開 API の制御

バレルファイルは、モジュールの公開 API を明示的に定義するためのファイルです。

typescript// features/auth/index.ts
// 外部に公開する型とサービスのみをエクスポート
export type { UserCredentials, AuthResult } from "./types";
export { AuthService } from "./service";

// 内部実装は公開しない
// repository.ts の詳細は隠蔽される
typescript// 利用側
import { AuthService, UserCredentials } from "@features/auth";
// ✅ 公開APIのみインポート可能

つまずきやすい点: バレルファイルで export * from '.​/​...' を多用すると、バンドルサイズが肥大化します。必要なものだけを選択的にエクスポートしてください。

循環参照を検知するツール設定

eslint-plugin-import の no-cycle ルールで循環参照を検知できます。

javascript// eslint.config.js (ESLint v9 flat config)
import importPlugin from "eslint-plugin-import";

export default [
  {
    plugins: {
      import: importPlugin,
    },
    rules: {
      "import/no-cycle": ["error", { maxDepth: 3 }],
      "import/no-internal-modules": [
        "error",
        {
          allow: ["@features/*/index"],
        },
      ],
    },
  },
];

また、madge を使うと依存関係をグラフとして可視化できます。

bashnpx madge --circular --extensions ts src/

実際に試したところ、30 モジュール程度のプロジェクトで 5 件の循環参照が検出され、すべてインターフェースの抽出で解消できました。

境界コンテキストを型で表現する

DDD の境界コンテキストを TypeScript の型で表現する例を示します。

typescript// contexts/order/types.ts
// 注文コンテキストにおけるユーザーの表現
export type OrderCustomer = {
  readonly customerId: string;
  readonly name: string;
  readonly shippingAddress: Address;
};

// contexts/user/types.ts
// ユーザーコンテキストにおけるユーザーの表現
export type User = {
  readonly id: string;
  readonly email: string;
  readonly profile: UserProfile;
  readonly preferences: UserPreferences;
};

同じ「ユーザー」でも、コンテキストによって必要な情報が異なります。型を分けることで、各コンテキストの関心事が明確になり、型安全が保たれます。

mermaidflowchart LR
  subgraph userCtx["ユーザーコンテキスト"]
    userType["User 型"]
  end
  subgraph orderCtx["注文コンテキスト"]
    custType["OrderCustomer 型"]
  end
  userType -->|"必要な情報のみ変換"| custType

上図は、コンテキスト間で型を変換する様子を示しています。全ての情報を共有するのではなく、必要な情報のみを変換して渡します。

モジュール間の通信パターン

モジュール間の通信は、直接参照ではなくイベントや共有インターフェースを介して行うと、結合度を下げられます。

typescript// shared/events/types.ts
export type DomainEvent<T = unknown> = {
  readonly type: string;
  readonly payload: T;
  readonly timestamp: Date;
};

// shared/events/bus.ts
type EventHandler<T> = (event: DomainEvent<T>) => void;

const handlers = new Map<string, EventHandler<unknown>[]>();

export function publish<T>(event: DomainEvent<T>): void {
  const eventHandlers = handlers.get(event.type) ?? [];
  eventHandlers.forEach((handler) => handler(event));
}

export function subscribe<T>(
  eventType: string,
  handler: EventHandler<T>,
): () => void {
  const existing = handlers.get(eventType) ?? [];
  handlers.set(eventType, [...existing, handler as EventHandler<unknown>]);
  return () => {
    const current = handlers.get(eventType) ?? [];
    handlers.set(
      eventType,
      current.filter((h) => h !== handler),
    );
  };
}
typescript// features/orders/service.ts
import { publish } from "@shared/events/bus";

export class OrderService {
  async createOrder(data: CreateOrderInput): Promise<Order> {
    const order = await this.orderRepository.save(data);

    publish({
      type: "order.created",
      payload: { orderId: order.id },
      timestamp: new Date(),
    });

    return order;
  }
}

この設計により、注文モジュールは通知モジュールの存在を知る必要がなくなります。

モジュール分割戦略の詳細比較と判断基準

ここまでの内容を踏まえ、各戦略の詳細な比較と、どのような状況でどの戦略を選ぶべきかを整理します。

観点機能別分割レイヤー別分割DDDハイブリッド
境界の明確さ◎ 機能単位で明確○ 技術層で明確◎ ビジネス境界で明確◎ 設計次第
依存管理○ 機能間の依存に注意◎ 一方向で管理しやすい◎ コンテキスト間は疎◎ 両方の利点
型安全の活用◎ 機能内で完結○ 層間にIF必要◎ 型がモデルを表現◎ 柔軟に設計可能
学習コスト
リファクタリング耐性○ 機能内は容易△ 層をまたぐと困難◎ 境界が明確◎ 設計次第
チーム分割◎ 機能単位で可能△ 層単位は難しい◎ コンテキスト単位◎ 柔軟

判断基準のフローチャート

mermaidflowchart TD
  start["プロジェクト規模は?"] --> small["50ファイル未満"]
  start --> medium["50〜200ファイル"]
  start --> large["200ファイル以上"]

  small --> layer["レイヤー別分割"]
  medium --> q1["ビジネスロジックは複雑?"]
  large --> q2["チーム人数は?"]

  q1 -->|"いいえ"| feature["機能別分割"]
  q1 -->|"はい"| hybrid["ハイブリッド"]

  q2 -->|"〜5人"| hybrid
  q2 -->|"5人以上"| ddd["DDD + 機能別"]

上図は、プロジェクトの特性に応じた分割戦略の選択フローを示しています。

戦略選択の実務的な判断

機能別分割が向いているケース:

  • 機能追加が頻繁に行われるプロダクト
  • チームを機能単位で分けている組織
  • 型定義を機能内に閉じたい場合

レイヤー別分割が向いているケース:

  • 技術スタックの変更が見込まれる場合
  • インフラ層のテストを分離したい場合
  • 小規模で単純な CRUD アプリケーション

DDD が向いているケース:

  • ビジネスルールが複雑で、ドメインエキスパートとの協働が必要な場合
  • 複数のサブシステムが異なるモデルを持つ場合
  • 長期的に成長し続けるプロダクト

ハイブリッドが向いているケース:

  • 上記の特性を複数持つプロジェクト
  • 既存のレイヤー構造を維持しつつ、機能単位の独立性も確保したい場合

まとめ

モジュール分割に「唯一の正解」はありませんが、以下の原則は普遍的に適用できます。

  1. 境界は責務で切る: ディレクトリを分けるだけでなく、各モジュールが何に責任を持つかを明確にする
  2. 依存は一方向に保つ: 循環参照を防ぎ、上位から下位への依存のみを許可する
  3. 型で契約を明示する: TypeScript の interface を活用し、モジュール間の契約を型安全に定義する
  4. tsconfig.json で強制する: strict モードと paths 設定で、設計意図をコンパイラに強制させる

静的型付けの恩恵を最大限に活かすには、型システムとモジュール設計を連携させることが重要です。規模が増えても破綻しない設計は、最初から完璧を目指すのではなく、上記の原則に従いながら段階的にリファクタリングを重ねることで実現できます。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;