<CodeLearn/>
設計パターン レッスン5

設計パターン総合演習

悪い設計のコードをリファクタリングして、学んだ原則・パターンを実践しよう

演習の概要

この演習では、設計上の問題を多数含む「タスク管理システム」のコードを 段階的にリファクタリングします。コードスメルを特定し、SOLID原則やデザインパターンを適用して改善しましょう。

演習の進め方

  1. まず「問題のあるコード」を読んで、コードスメルを見つける
  2. どの原則・パターンが適用できるかを考える
  3. 段階的にリファクタリングを行う
  4. 最終的な改善版と比較して理解を深める

Step 1: 問題のあるコード

以下のコードには多くの設計上の問題があります。どんなコードスメルが見つかるか考えてみましょう。

// 問題だらけのタスク管理システム
class TaskManager {
  tasks: any[] = [];
  nextId = 1;

  // タスクの作成・保存・通知・ログを全部やっている(SRP違反)
  addTask(title: string, assignee: string, type: string) {
    // マジックナンバー
    if (title.length > 100) {
      throw new Error("Too long");
    }

    const task: any = {
      id: this.nextId++,
      title,
      assignee,
      type,
      status: "todo",
      createdAt: new Date(),
    };

    this.tasks.push(task);

    // 通知ロジックが直接埋め込み(高結合)
    if (type === "bug") {
      console.log(`[EMAIL] ${assignee}さん: 緊急バグ「${title}」`);
      console.log(`[SLACK] #bugs: 新しいバグ「${title}」`);
    } else if (type === "feature") {
      console.log(`[EMAIL] ${assignee}さん: 新機能「${title}」`);
    } else if (type === "chore") {
      console.log(`[SLACK] #general: タスク「${title}」`);
    }

    // ログも直接書いている
    console.log(
      `[LOG] ${new Date().toISOString()} - Task created: ${title}`
    );

    return task;
  }

  // タスク完了処理もif/elseの嵐(OCP違反)
  completeTask(id: number) {
    const task = this.tasks.find((t) => t.id === id);
    if (!task) throw new Error("Not found");

    task.status = "done";
    task.completedAt = new Date();

    // ポイント計算がべた書き
    let points = 0;
    if (task.type === "bug") {
      points = 3;
    } else if (task.type === "feature") {
      points = 5;
    } else if (task.type === "chore") {
      points = 1;
    }

    console.log(`${task.assignee}に${points}ポイント付与`);

    // レポート生成も混在
    const report = `完了: ${task.title} (${task.type}) - ${points}pt`;
    console.log(report);

    return task;
  }

  // CSVエクスポート(関心の分離ができていない)
  exportToCSV(): string {
    let csv = "id,title,assignee,type,status\n";
    for (const t of this.tasks) {
      csv += `${t.id},${t.title},${t.assignee},${t.type},${t.status}\n`;
    }
    return csv;
  }

  // 検索機能(DRYの問題)
  findByAssignee(assignee: string) {
    const result = [];
    for (const t of this.tasks) {
      if (t.assignee === assignee) result.push(t);
    }
    return result;
  }

  findByType(type: string) {
    const result = [];
    for (const t of this.tasks) {
      if (t.type === type) result.push(t);
    }
    return result;
  }

  findByStatus(status: string) {
    const result = [];
    for (const t of this.tasks) {
      if (t.status === status) result.push(t);
    }
    return result;
  }
}

Step 2: コードスメルを特定する

上のコードに含まれる設計上の問題点をリストアップしましょう。

SRP違反(単一責任の原則)

TaskManagerがタスク管理、通知、ログ、CSV出力、ポイント計算など全てを担当している

OCP違反(開放閉鎖の原則)

新しいタスクタイプを追加するたびにif/elseを修正する必要がある

DIP違反(依存性逆転の原則)

通知やログの具体的な実装(console.log)に直接依存している

マジックナンバー / any型の多用

100、3、5、1などの数値リテラル。any型で型安全性がない

DRY違反

findByAssignee / findByType / findByStatus がほぼ同じロジック

Step 3: 型を定義してドメインモデルを整理する

まず、anyを排除し、 適切な型を定義します。これが全てのリファクタリングの土台になります。

// 型定義 - ドメインモデル
type TaskType = "bug" | "feature" | "chore";
type TaskStatus = "todo" | "in-progress" | "done";

interface Task {
  readonly id: number;
  readonly title: string;
  readonly assignee: string;
  readonly type: TaskType;
  status: TaskStatus;
  readonly createdAt: Date;
  completedAt?: Date;
}

// バリデーション用の定数(マジックナンバー排除)
const TASK_TITLE_MAX_LENGTH = 100;

// ポイント設定もオブジェクトで管理(OCP対応の準備)
const POINTS_BY_TYPE: Record<TaskType, number> = {
  bug: 3,
  feature: 5,
  chore: 1,
};

Step 4: 責務を分離する(SRP + DIP)

通知、ログ、エクスポートなどの責務をインターフェースで抽象化し、個別のクラスに分離します。

// 通知サービス - Strategyパターンで種類を切り替え可能
interface NotificationChannel {
  send(recipient: string, message: string): void;
}

class EmailChannel implements NotificationChannel {
  send(recipient: string, message: string): void {
    console.log(`[EMAIL] ${recipient}: ${message}`);
  }
}

class SlackChannel implements NotificationChannel {
  constructor(private defaultChannel: string = "general") {}
  send(recipient: string, message: string): void {
    console.log(`[SLACK] #${recipient || this.defaultChannel}: ${message}`);
  }
}

// 通知ルールをタスクタイプごとに定義(OCP対応)
interface NotificationRule {
  shouldNotify(task: Task): boolean;
  getChannels(): NotificationChannel[];
  formatMessage(task: Task): string;
}

class BugNotificationRule implements NotificationRule {
  private email = new EmailChannel();
  private slack = new SlackChannel("bugs");

  shouldNotify(task: Task): boolean {
    return task.type === "bug";
  }
  getChannels(): NotificationChannel[] {
    return [this.email, this.slack];
  }
  formatMessage(task: Task): string {
    return `緊急バグ「${task.title}」が報告されました`;
  }
}

class FeatureNotificationRule implements NotificationRule {
  private email = new EmailChannel();

  shouldNotify(task: Task): boolean {
    return task.type === "feature";
  }
  getChannels(): NotificationChannel[] {
    return [this.email];
  }
  formatMessage(task: Task): string {
    return `新機能「${task.title}」が追加されました`;
  }
}

// 通知サービス(ルールをまとめて管理)
class NotificationService {
  constructor(private rules: NotificationRule[]) {}

  notify(task: Task): void {
    for (const rule of this.rules) {
      if (rule.shouldNotify(task)) {
        const message = rule.formatMessage(task);
        for (const channel of rule.getChannels()) {
          channel.send(task.assignee, message);
        }
      }
    }
  }
}

// ロガー - インターフェースで抽象化
interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[LOG] ${new Date().toISOString()} - ${message}`);
  }
}

// エクスポーター - Strategyパターン
interface TaskExporter {
  export(tasks: Task[]): string;
}

class CSVExporter implements TaskExporter {
  export(tasks: Task[]): string {
    const header = "id,title,assignee,type,status";
    const rows = tasks.map(
      (t) => `${t.id},${t.title},${t.assignee},${t.type},${t.status}`
    );
    return [header, ...rows].join("\n");
  }
}

class JSONExporter implements TaskExporter {
  export(tasks: Task[]): string {
    return JSON.stringify(tasks, null, 2);
  }
}

Step 5: リファクタリング完成版

すべてを組み合わせた完成版です。TaskManagerは「タスクの管理」という単一責任だけを持ち、 通知・ログ・エクスポートは外部から注入されます。

// リファクタリング後のTaskManager
class TaskManager {
  private tasks: Task[] = [];
  private nextId = 1;

  constructor(
    private notifier: NotificationService,
    private logger: Logger
  ) {}

  addTask(title: string, assignee: string, type: TaskType): Task {
    if (title.length > TASK_TITLE_MAX_LENGTH) {
      throw new Error(
        `タイトルは${TASK_TITLE_MAX_LENGTH}文字以内です`
      );
    }

    const task: Task = {
      id: this.nextId++,
      title,
      assignee,
      type,
      status: "todo",
      createdAt: new Date(),
    };

    this.tasks.push(task);
    this.logger.log(`Task created: ${title}`);
    this.notifier.notify(task);

    return task;
  }

  completeTask(id: number): Task {
    const task = this.tasks.find((t) => t.id === id);
    if (!task) throw new Error("タスクが見つかりません");

    task.status = "done";
    task.completedAt = new Date();

    const points = POINTS_BY_TYPE[task.type];
    this.logger.log(
      `Task completed: ${task.title} (${points}pt → ${task.assignee})`
    );

    return task;
  }

  // DRY: 汎用フィルタメソッド
  findTasks(predicate: (task: Task) => boolean): Task[] {
    return this.tasks.filter(predicate);
  }

  // 便利メソッドは汎用フィルタを使って実装
  findByAssignee(assignee: string): Task[] {
    return this.findTasks((t) => t.assignee === assignee);
  }

  findByType(type: TaskType): Task[] {
    return this.findTasks((t) => t.type === type);
  }

  findByStatus(status: TaskStatus): Task[] {
    return this.findTasks((t) => t.status === status);
  }

  getAllTasks(): Task[] {
    return [...this.tasks];
  }
}

// 組み立て(Dependency Injection)
const notificationService = new NotificationService([
  new BugNotificationRule(),
  new FeatureNotificationRule(),
]);

const logger = new ConsoleLogger();
const manager = new TaskManager(notificationService, logger);

// 使い方
manager.addTask("ログイン画面が表示されない", "田中", "bug");
manager.addTask("ダッシュボード機能", "佐藤", "feature");
manager.completeTask(1);

// エクスポートは別のサービスとして使う
const csvExporter = new CSVExporter();
console.log(csvExporter.export(manager.getAllTasks()));

const jsonExporter = new JSONExporter();
console.log(jsonExporter.export(manager.findByStatus("done")));

Step 6: 改善のまとめ

SRP: 責務の分離

TaskManager、NotificationService、Logger、Exporterがそれぞれ単一の責務を持つ

OCP: 拡張に開いている

新しいタスクタイプ・通知ルール・エクスポート形式を既存コード修正なしで追加可能

DIP: 抽象への依存

TaskManagerはNotificationServiceやLoggerのインターフェースに依存。実装の差し替えが容易

DRY: 重複の排除

汎用フィルタメソッドでfindBy系の重複を解消

型安全性の向上

any型を排除し、TaskType、TaskStatus等の型でコンパイル時エラーを検出

テスト容易性

依存性注入により、モックを使ったユニットテストが簡単に書ける

まとめ

  • リファクタリングは小さなステップで段階的に行う
  • まずコードスメルを特定し、どの原則に違反しているか判断する
  • 型を定義してドメインモデルを明確にすることが第一歩
  • インターフェースで抽象化し、依存性注入で結合度を下げる
  • Strategy/Observerパターンでif/else地獄を解消する
  • 完璧な設計はない。プロジェクトの規模に応じた「適切な」設計を選ぶことが大切