設計パターン総合演習
悪い設計のコードをリファクタリングして、学んだ原則・パターンを実践しよう
演習の概要
この演習では、設計上の問題を多数含む「タスク管理システム」のコードを 段階的にリファクタリングします。コードスメルを特定し、SOLID原則やデザインパターンを適用して改善しましょう。
演習の進め方
- まず「問題のあるコード」を読んで、コードスメルを見つける
- どの原則・パターンが適用できるかを考える
- 段階的にリファクタリングを行う
- 最終的な改善版と比較して理解を深める
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地獄を解消する
- 完璧な設計はない。プロジェクトの規模に応じた「適切な」設計を選ぶことが大切