第5回:大規模アプリケーションの構造化

はじめに

みなさん、こんにちは!前回はクラスとモジュールの分割技法について学びました。今日はさらに視野を広げて、大規模アプリケーション全体の構造化について考えていきます。

アプリケーションが大きくなると、単にクラスを分けるだけでは足りなくなります。全体をどう整理するか、どうやって秩序を保つかという「アーキテクチャ」の考え方が必要になってきます。

大きなアプリケーションは、まるで一つの町のようなものです。道路や建物をバラバラに作っていくと、やがて混乱した町になってしまいます。計画的に区画を分け、役割ごとに整理しておくことで、住みやすく、拡張しやすい町になるのです。

レイヤード・アーキテクチャの基本

レイヤーとは何か?

レイヤー(層)とは、特定の役割や責任を持った機能のグループのことです。アプリケーションを横に切った「層」として考えると分かりやすいでしょう。

一般的なレイヤード・アーキテクチャでは、次のような層に分けることが多いです:

  1. プレゼンテーション層(UI層):ユーザーとのやり取りを担当
  2. アプリケーション層(サービス層):業務ロジックやフロー制御を担当
  3. ドメイン層(ビジネスロジック層):核となる業務ルールを担当
  4. データアクセス層(インフラ層):データの保存と取得を担当

例えば、学校の成績管理システムを考えてみましょう:

  • UI層:成績入力画面、成績一覧表示画面
  • アプリケーション層:成績登録サービス、成績計算サービス
  • ドメイン層:生徒、科目、成績などの基本概念とそのルール
  • データアクセス層:データベースへの保存・取得処理

レイヤーの依存関係

重要なのは、レイヤー間の依存関係です。一般的には「上位レイヤーが下位レイヤーに依存する」というルールがあります。

UI層 → アプリケーション層 → ドメイン層 → データアクセス層

上の矢印は「依存している」という意味です。UI層はアプリケーション層に依存しますが、逆は許されません。これによって、下位レイヤーの変更が上位に影響しにくくなります。

例えば、データベースをMySQLからMongoDBに変えたい場合、データアクセス層だけを修正すれば良く、ビジネスロジックやUI層には影響しません。

クリーンアーキテクチャの考え方

より洗練された「クリーンアーキテクチャ」という考え方では、依存関係を逆転させて、中心にドメイン層(ビジネスルール)を置きます。

UI層 → アプリケーション層 → ドメイン層 ← データアクセス層

ここでは、データアクセス層もドメイン層に依存します(矢印が内側に向いている)。これはインターフェースを使って実現します。

たとえば:

// ドメイン層にあるインターフェース
class StudentRepository {
  findById(id) { throw new Error("実装してください"); }
  save(student) { throw new Error("実装してください"); }
}

// データアクセス層にある実装
class MySQLStudentRepository extends StudentRepository {
  findById(id) { /* MySQLからデータを取得 */ }
  save(student) { /* MySQLにデータを保存 */ }
}

// ドメイン層のサービス(データアクセス層を知らない)
class GradeService {
  constructor(studentRepository) {
    this.studentRepository = studentRepository;
  }
  
  updateGrade(studentId, subject, score) {
    const student = this.studentRepository.findById(studentId);
    student.setGrade(subject, score);
    this.studentRepository.save(student);
  }
}
Code language: JavaScript (javascript)

このようにすると、ドメイン層はデータの保存方法を気にせず、ビジネスロジックに集中できます。

モジュール間の依存関係管理

モジュールとは

モジュールはレイヤーをさらに細かく分けたもので、関連する機能のまとまりです。例えば:

  • 「ユーザー管理モジュール」
  • 「注文処理モジュール」
  • 「商品カタログモジュール」

これらは目的ごとに分けられた、比較的独立したコードの集まりです。

依存関係の管理原則

モジュール間の依存関係を管理する原則としては:

  1. 依存関係の明示化:どのモジュールがどのモジュールに依存しているか明確にする
  2. 循環依存の排除:AがBに依存し、BがAに依存するような関係を作らない
  3. 安定依存の原則:変更の少ない安定したモジュールに依存するようにする
  4. 抽象化:具体的な実装ではなく、インターフェースに依存する

循環依存の例と、その解決策を見てみましょう:

// 悪い例(循環依存)
// UserモジュールとOrderモジュールが互いに依存している
class User {
  constructor() {
    this.orders = [];
  }
  
  addOrder(order) {
    this.orders.push(order);
  }
}

class Order {
  constructor(user) {
    this.user = user;
    user.addOrder(this); // Userに依存
  }
}

// 良い例
// UserモジュールはOrderのIDだけを持つ
class User {
  constructor() {
    this.orderIds = [];
  }
  
  addOrderId(orderId) {
    this.orderIds.push(orderId);
  }
}

// OrderモジュールはUserのIDだけを持つ
class Order {
  constructor(userId) {
    this.userId = userId;
  }
}

// 関係を管理する別のサービス
class OrderService {
  createOrder(userId) {
    const order = new Order(userId);
    const orderId = saveOrder(order); // IDを取得
    
    const user = findUserById(userId);
    user.addOrderId(orderId);
    saveUser(user);
    
    return order;
  }
}
Code language: JavaScript (javascript)

このように、直接的な依存関係を間接的なものに変えることで、循環依存を解消できます。

パッケージ設計の原則

パッケージとは

パッケージはモジュールをさらにグループ化したもので、ファイルシステム上のディレクトリやフォルダに相当します。例えば:

src/
  ├── user/              // ユーザー関連のパッケージ
  │   ├── domain/        // ドメインモデル
  │   ├── application/   // アプリケーションサービス
  │   ├── infrastructure/  // インフラ(データアクセスなど)
  │   └── ui/            // ユーザーインターフェース
  │
  ├── order/             // 注文関連のパッケージ
  │   ├── domain/
  │   ├── application/
  │   └── ...
Code language: JavaScript (javascript)

パッケージ設計の原則

パッケージを設計する際には、次の原則が役立ちます:

  1. 共通閉包の原則:同じ理由で変更されるものは同じパッケージに
  2. 共通再利用の原則:一緒に再利用されるものは同じパッケージに
  3. 安定依存の原則:安定したパッケージに依存する
  4. 安定度メトリクス:パッケージの入ってくる依存と出ていく依存のバランス

特に重要なのは「高凝集・低結合」の原則です:

  • 高凝集:関連性の高いものをまとめる
  • 低結合:パッケージ間の依存関係を最小限にする

パッケージ構成のアプローチ

パッケージを構成する方法には大きく分けて2つあります:

  1. レイヤー優先:レイヤーでまず分け、その中で機能ごとに分ける
src/
  ├── presentation/
  │   ├── user/
  │   ├── order/
  │
  ├── application/
  │   ├── user/
  │   ├── order/
  │
  ├── domain/
  │   ├── user/
  │   ├── order/
  │
  └── infrastructure/
      ├── user/
      ├── order/
  1. 機能優先:機能で分け、その中でレイヤーに分ける(先ほど示した例)
src/
  ├── user/
  │   ├── domain/
  │   ├── application/
  │   ├── infrastructure/
  │   └── ui/
  │
  ├── order/
      ├── domain/
      └── ...

どちらが良いかは、プロジェクトの性質によって異なります。小規模なら機能優先、大規模で多くの開発者が関わるならレイヤー優先が適していることが多いです。

実習:簡単なアプリをレイヤーに分割する

では、実際に簡単なアプリケーションをレイヤーに分割してみましょう。

例として、「書籍管理アプリ」を考えます。このアプリでは:

  • 書籍の登録、検索、更新、削除ができる
  • 著者の管理もできる
  • 本の貸し出し状況を記録できる

分割前のコード例

// 分割前の巨大なクラス
class BookManager {
  constructor() {
    this.books = [];
    this.authors = [];
    this.loans = [];
  }
  
  // UIメソッド
  showBookList() { /* ... */ }
  showBookDetails(bookId) { /* ... */ }
  
  // アプリケーションロジック
  registerBook(title, authorName, isbn) { /* ... */ }
  updateBook(bookId, data) { /* ... */ }
  lendBook(bookId, userId) { /* ... */ }
  returnBook(bookId) { /* ... */ }
  
  // ドメインロジック
  isBookAvailable(bookId) { /* ... */ }
  calculateLateFee(loanId) { /* ... */ }
  
  // データアクセス
  saveBooks() { /* ... */ }
  loadBooks() { /* ... */ }
  findBookByIsbn(isbn) { /* ... */ }
}
Code language: JavaScript (javascript)

レイヤーに分割したコード

これをレイヤーに分けると:

// -------------------
// UI層
// -------------------
class BookListView {
  constructor(bookService) {
    this.bookService = bookService;
  }
  
  displayBookList() {
    const books = this.bookService.getAllBooks();
    // UIに表示する処理...
  }
  
  displayBookDetails(bookId) {
    const book = this.bookService.getBookById(bookId);
    // 詳細を表示する処理...
  }
}

// -------------------
// アプリケーション層
// -------------------
class BookService {
  constructor(bookRepository, authorRepository, loanRepository) {
    this.bookRepository = bookRepository;
    this.authorRepository = authorRepository;
    this.loanRepository = loanRepository;
  }
  
  registerBook(title, authorName, isbn) {
    // 著者を探すか作成する
    let author = this.authorRepository.findByName(authorName);
    if (!author) {
      author = new Author(authorName);
      this.authorRepository.save(author);
    }
    
    // 本を作成して保存
    const book = new Book(title, author, isbn);
    this.bookRepository.save(book);
    return book;
  }
  
  lendBook(bookId, userId) {
    const book = this.bookRepository.findById(bookId);
    
    if (!book.isAvailable()) {
      throw new Error("この本は貸出中です");
    }
    
    const loan = new Loan(book, userId, new Date());
    book.markAsLoaned();
    
    this.loanRepository.save(loan);
    this.bookRepository.save(book);
    
    return loan;
  }
  
  // その他のサービスメソッド...
}

// -------------------
// ドメイン層
// -------------------
class Book {
  constructor(title, author, isbn) {
    this.id = generateId();
    this.title = title;
    this.author = author;
    this.isbn = isbn;
    this.status = "available"; // 'available' または 'loaned'
  }
  
  isAvailable() {
    return this.status === "available";
  }
  
  markAsLoaned() {
    this.status = "loaned";
  }
  
  markAsReturned() {
    this.status = "available";
  }
}

class Author {
  constructor(name) {
    this.id = generateId();
    this.name = name;
    this.books = [];
  }
  
  addBook(book) {
    this.books.push(book);
  }
}

class Loan {
  constructor(book, userId, loanDate) {
    this.id = generateId();
    this.book = book;
    this.userId = userId;
    this.loanDate = loanDate;
    this.returnDate = null;
  }
  
  calculateLateFee(today) {
    if (this.returnDate) return 0;
    
    // 2週間を超える貸出には遅延料金が発生
    const twoWeeksInMilliseconds = 14 * 24 * 60 * 60 * 1000;
    const dueDate = new Date(this.loanDate.getTime() + twoWeeksInMilliseconds);
    
    if (today > dueDate) {
      const daysLate = Math.floor((today - dueDate) / (24 * 60 * 60 * 1000));
      return daysLate * 100; // 1日あたり100円の遅延料金
    }
    
    return 0;
  }
}

// -------------------
// データアクセス層
// -------------------
class BookRepository {
  constructor() {
    this.books = [];
  }
  
  findById(id) {
    return this.books.find(book => book.id === id);
  }
  
  findByIsbn(isbn) {
    return this.books.find(book => book.isbn === isbn);
  }
  
  save(book) {
    const index = this.books.findIndex(b => b.id === book.id);
    if (index >= 0) {
      this.books[index] = book;
    } else {
      this.books.push(book);
    }
    // 実際にはデータベースに保存する処理も入る
  }
  
  // その他のデータアクセスメソッド...
}

// AuthorRepositoryやLoanRepositoryも同様に実装
Code language: JavaScript (javascript)

このように分割することで:

  1. 各クラスの責任が明確になる
  2. テストがしやすくなる(各層を独立してテストできる)
  3. 変更の影響範囲が限定される
  4. チーム開発がしやすくなる(UIチーム、バックエンドチームなど)

まとめ

今日の講義では、大規模アプリケーションの構造化について学びました:

  1. レイヤード・アーキテクチャの基本:UIから始まり、データアクセスに至る階層構造
  2. モジュール間の依存関係管理:循環依存を避け、明確な依存方向を設定
  3. パッケージ設計の原則:高凝集・低結合を実現するパッケージ構成

適切なアーキテクチャは、最初は少し手間がかかるように感じるかもしれませんが、長期的には開発速度を維持し、バグを減らし、変更に強いコードを作るために欠かせません。

建物でも、最初に丈夫な土台と骨組みを作ることで、その後の拡張や修繕が楽になるのと同じです。

次回は「テストとリファクタリング」について学びます。実際にコードを安全に改善していくための技術を身につけていきましょう。

質問タイム

みなさん、今日の講義内容について質問はありますか?

質問1:山田さんからの質問

質問:「レイヤードアーキテクチャとクリーンアーキテクチャの違いをもう少し詳しく説明してもらえますか?どちらを選ぶべき状況も知りたいです。」

回答: 良い質問ですね、山田さん。両者の主な違いは「依存関係の方向」にあります。

レイヤードアーキテクチャでは、上から下への一方向の依存関係があります:

UI層 → アプリケーション層 → ドメイン層 → データアクセス層

これは理解しやすく、多くのフレームワークでもこの構造が採用されています。

一方、クリーンアーキテクチャでは、依存関係が中心(ドメイン層)に向かいます:

UI層 → アプリケーション層 → ドメイン層 ← データアクセス層

ドメイン層は外部の詳細(データベースやUIなど)を知りません。これを「依存性逆転の原則」と呼びます。

どちらを選ぶべきかについては:

  • レイヤードアーキテクチャ
    • 小〜中規模のプロジェクト
    • 開発速度を優先したい場合
    • チームがアーキテクチャに慣れていない場合
  • クリーンアーキテクチャ
    • 長期的なプロジェクト
    • テスト容易性が重要な場合
    • 技術的な詳細(DBなど)が将来変わる可能性がある場合

クリーンアーキテクチャは最初の構築に時間がかかりますが、長期的には柔軟性が高くなります。学習曲線も少し急ですが、一度理解すれば強力なアプローチです。

個人的には、中〜大規模のプロジェクトならクリーンアーキテクチャをおすすめします。小さく始めて、徐々に発展させていくのも良い方法ですよ。

質問2:木村さんからの質問

質問:「実際のプロジェクトでパッケージ構成を決めるとき、どういう基準で『レイヤー優先』か『機能優先』かを選べばいいですか?それぞれのメリット・デメリットが知りたいです。」

回答: 素晴らしい質問です、木村さん。パッケージ構成は開発体験に大きく影響します。

レイヤー優先(水平分割)の場合:

src/
  ├── presentation/  (すべての機能のUI)
  ├── application/   (すべての機能のサービス)
  ├── domain/        (すべての機能のドメインモデル)
  └── infrastructure/(すべての機能のデータアクセス)

メリット

  • 技術的な関心ごとでコードが整理される
  • 各レイヤーの責任が明確
  • 特定のレイヤー(UIなど)に特化したチームがいる場合に便利

デメリット

  • 一つの機能追加で複数のパッケージを修正する必要がある
  • ビジネス機能の全体像が見えにくい

機能優先(垂直分割)の場合:

src/
  ├── user/  (ユーザー関連の全レイヤー)
  ├── order/ (注文関連の全レイヤー)
  └── product/(商品関連の全レイヤー)

メリット

  • 一つの機能を追加する際の作業がまとまっている
  • ビジネスドメインの構造が見えやすい
  • 機能ごとにチームが分かれている場合に便利

デメリット

  • レイヤー間の一貫性を保つのが難しくなる可能性がある
  • レイヤーをまたぐ共通部品の作成が複雑になりがち

選択基準

  1. チーム構成
    • 技術別チーム(フロント/バック)→レイヤー優先
    • 機能別チーム→機能優先
  2. プロジェクト規模
    • 大規模→レイヤー優先が多い
    • 中小規模→機能優先が使いやすい
  3. ビジネスドメインの複雑さ
    • 複雑なドメインロジック→機能優先で関連性を保つ
    • シンプルなCRUD操作が多い→レイヤー優先でも問題ない

最近のマイクロサービスの流れでは、サービスごとに機能優先で分け、サービス内部ではさらにレイヤー分けするという「ハイブリッド」なアプローチも人気です。

質問3:高橋さんからの質問

質問:「実際の開発で、最初から完璧なアーキテクチャを設計するのは難しいと思います。途中からリファクタリングする場合、どのように段階的に進めれば良いでしょうか?」

回答: とても現実的な質問をありがとう、高橋さん。おっしゃるとおり、最初から完璧なアーキテクチャを設計するのは困難です。ほとんどの場合、進化的なアプローチが必要になります。

段階的リファクタリングのステップ

  1. 現状分析と目標設定
    • 現在のコードの依存関係を図にする
    • 目指すアーキテクチャを決める
    • 最も問題のある部分を特定する
  2. 境界の明確化から始める
    • まずはインターフェースを定義して、理想的な境界を作る
    • 既存コードはそのままに、新しいインターフェースを経由するようにする
  3. 一つの垂直スライスを完成させる
    • すべての機能を一度に変えようとせず、一つの機能(例:ユーザー登録)を 新しいアーキテクチャで実装する
    • これを「参照実装」として、パターンを確立する
  4. 段階的な移行
    • 新しい機能は新アーキテクチャで開発
    • 古いコードは触る機会があるたびに少しずつ新構造に移行
    • 「ストラングラーパターン」と呼ばれるアプローチ
  5. レガシーコードの隔離
    • 変更が難しい古いコードはアダプターで包み、新しい構造から利用できるようにする
    • 時間をかけて徐々に置き換えていく

実際の例として、私が関わったあるプロジェクトでは、古いPHPのモノリスアプリケーションを3年かけて段階的にクリーンアーキテクチャに移行しました。最初に認証機能だけを新構造に移し、それから機能ごとに移行していきました。

最も重要なのは「完璧を求めない」ということです。80%の改善を達成するのに20%の労力で済むなら、それを選ぶべきです。また、ビジネス価値を提供しながらリファクタリングするバランスも大切です。

「アーキテクチャとは旅であって、目的地ではない」という言葉もあります。常に進化し続けるものと考えると良いでしょう。