EXERCISE 90分

ストーリー

高橋アーキテクト
理論は十分だ。ここからは手を動かそう
高橋アーキテクト
これから見せるコードには、SOLID原則に違反している箇所がいくつもある。1つずつ見つけて、原則に基づいてリファクタリングしてほしい

ミッション概要

ミッションテーマ対象原則目安時間
Mission 1神クラスを分割せよSRP15分
Mission 2条件分岐を拡張可能にせよOCP15分
Mission 3壊れた継承を直せLSP15分
Mission 4太ったインターフェースを整理せよISP15分
Mission 5具体への依存を断ち切れDIP15分
Mission 6総合:全原則を適用せよ全SOLID15分

Mission 1: 神クラスを分割せよ(15分)

要件

以下の OrderManager クラスは SRP に違反しています。適切に分割してください。

class OrderManager {
  createOrder(userId: string, items: CartItem[]): Order {
    // バリデーション
    if (items.length === 0) throw new Error('Cart is empty');
    for (const item of items) {
      if (item.quantity <= 0) throw new Error('Invalid quantity');
    }

    // 価格計算
    let total = 0;
    for (const item of items) {
      total += item.price * item.quantity;
      if (item.category === 'food') total -= item.price * item.quantity * 0.08;
    }

    // DB保存
    const order: Order = { id: generateId(), userId, items, total, status: 'pending' };
    db.query(`INSERT INTO orders VALUES (...)`, order);

    // メール送信
    const user = db.query(`SELECT * FROM users WHERE id = ?`, [userId]);
    smtp.send(user.email, 'Order Confirmed', `Your order #${order.id} has been placed.`);

    // ログ記録
    fs.appendFileSync('orders.log', `${new Date().toISOString()} Order ${order.id} created\n`);

    return order;
  }
}

期待される動作

  • 少なくとも4つのクラスに分割する
  • それぞれのクラスが1つの変更理由だけを持つ
  • 元の createOrder と同等の処理が実行される
解答
// 責任1: 注文のバリデーション
class OrderValidator {
  validate(items: CartItem[]): void {
    if (items.length === 0) throw new Error('Cart is empty');
    for (const item of items) {
      if (item.quantity <= 0) throw new Error('Invalid quantity');
    }
  }
}

// 責任2: 価格計算
class PriceCalculator {
  calculate(items: CartItem[]): number {
    return items.reduce((total, item) => {
      const subtotal = item.price * item.quantity;
      const discount = item.category === 'food' ? subtotal * 0.08 : 0;
      return total + subtotal - discount;
    }, 0);
  }
}

// 責任3: 注文の永続化
class OrderRepository {
  save(order: Order): void {
    db.query(`INSERT INTO orders VALUES (...)`, order);
  }
}

// 責任4: 通知
class OrderNotifier {
  constructor(private userRepository: UserRepository) {}

  notifyOrderCreated(order: Order): void {
    const user = this.userRepository.findById(order.userId);
    smtp.send(user.email, 'Order Confirmed', `Your order #${order.id} has been placed.`);
  }
}

// 責任5: ログ記録
class OrderLogger {
  logCreation(order: Order): void {
    fs.appendFileSync('orders.log', `${new Date().toISOString()} Order ${order.id} created\n`);
  }
}

// オーケストレーション
class OrderService {
  constructor(
    private validator: OrderValidator,
    private calculator: PriceCalculator,
    private repository: OrderRepository,
    private notifier: OrderNotifier,
    private logger: OrderLogger
  ) {}

  createOrder(userId: string, items: CartItem[]): Order {
    this.validator.validate(items);
    const total = this.calculator.calculate(items);
    const order: Order = { id: generateId(), userId, items, total, status: 'pending' };
    this.repository.save(order);
    this.notifier.notifyOrderCreated(order);
    this.logger.logCreation(order);
    return order;
  }
}

Mission 2: 条件分岐を拡張可能にせよ(15分)

要件

以下の通知システムはOCPに違反しています。新しい通知チャネルを追加しやすい設計にリファクタリングしてください。

class NotificationService {
  send(userId: string, message: string, channel: string): void {
    if (channel === 'email') {
      const user = this.getUser(userId);
      smtp.send(user.email, 'Notification', message);
    } else if (channel === 'sms') {
      const user = this.getUser(userId);
      smsGateway.send(user.phone, message);
    } else if (channel === 'slack') {
      slackApi.postMessage(userId, message);
    }
    // push通知を追加したい場合、ここを修正する必要がある...
  }
}

期待される動作

  • インターフェースを定義し、各チャネルを個別のクラスにする
  • 新しいチャネルの追加で既存コードの変更が不要
解答
interface NotificationChannel {
  send(userId: string, message: string): void;
}

class EmailChannel implements NotificationChannel {
  constructor(private userRepository: UserRepository) {}

  send(userId: string, message: string): void {
    const user = this.userRepository.findById(userId);
    smtp.send(user.email, 'Notification', message);
  }
}

class SmsChannel implements NotificationChannel {
  constructor(private userRepository: UserRepository) {}

  send(userId: string, message: string): void {
    const user = this.userRepository.findById(userId);
    smsGateway.send(user.phone, message);
  }
}

class SlackChannel implements NotificationChannel {
  send(userId: string, message: string): void {
    slackApi.postMessage(userId, message);
  }
}

// 新しいチャネルの追加:クラスを追加するだけ
class PushChannel implements NotificationChannel {
  send(userId: string, message: string): void {
    pushService.send(userId, { title: 'Notification', body: message });
  }
}

class NotificationService {
  private channels: Map<string, NotificationChannel> = new Map();

  registerChannel(name: string, channel: NotificationChannel): void {
    this.channels.set(name, channel);
  }

  send(userId: string, message: string, channelName: string): void {
    const channel = this.channels.get(channelName);
    if (!channel) throw new Error(`Unknown channel: ${channelName}`);
    channel.send(userId, message);
  }
}

Mission 3: 壊れた継承を直せ(15分)

要件

以下のコードはLSPに違反しています。ReadOnlyConfig を使ったときにエラーが発生しないよう設計を修正してください。

class Config {
  protected data: Map<string, string> = new Map();

  get(key: string): string | undefined {
    return this.data.get(key);
  }

  set(key: string, value: string): void {
    this.data.set(key, value);
  }

  delete(key: string): void {
    this.data.delete(key);
  }
}

class ReadOnlyConfig extends Config {
  set(key: string, value: string): void {
    throw new Error('Cannot modify read-only config');
  }

  delete(key: string): void {
    throw new Error('Cannot modify read-only config');
  }
}

期待される動作

  • ReadOnlyConfig の利用者が予期しないエラーに遭わない
  • 読み取り専用と読み書き可能の両方を安全に扱える
解答
interface ConfigReader {
  get(key: string): string | undefined;
  has(key: string): boolean;
}

interface ConfigWriter {
  set(key: string, value: string): void;
  delete(key: string): void;
}

class ReadOnlyConfig implements ConfigReader {
  constructor(private data: Map<string, string>) {}

  get(key: string): string | undefined {
    return this.data.get(key);
  }

  has(key: string): boolean {
    return this.data.has(key);
  }
}

class MutableConfig implements ConfigReader, ConfigWriter {
  private data: Map<string, string> = new Map();

  get(key: string): string | undefined {
    return this.data.get(key);
  }

  has(key: string): boolean {
    return this.data.has(key);
  }

  set(key: string, value: string): void {
    this.data.set(key, value);
  }

  delete(key: string): void {
    this.data.delete(key);
  }
}

// 読み取りだけ必要な場所
function displayConfig(config: ConfigReader): void {
  // set/delete は呼べない = エラーが起きない
  console.log(config.get('app.name'));
}

// 書き込みも必要な場所
function updateConfig(config: ConfigWriter, key: string, value: string): void {
  config.set(key, value);
}

Mission 4: 太ったインターフェースを整理せよ(15分)

要件

以下のインターフェースはISPに違反しています。適切に分離してください。

interface DataStore {
  read(id: string): any;
  write(id: string, data: any): void;
  delete(id: string): void;
  search(query: string): any[];
  backup(): void;
  restore(backupId: string): void;
  getStats(): StoreStats;
}

// 検索だけしたいクライアント
class SearchService {
  constructor(private store: DataStore) {} // 7メソッド中1つしか使わない
  find(query: string): any[] {
    return this.store.search(query);
  }
}

期待される動作

  • 役割ごとに3-4個のインターフェースに分離する
  • 各クライアントが必要なインターフェースだけに依存する
解答
interface DataReader {
  read(id: string): any;
}

interface DataWriter {
  write(id: string, data: any): void;
  delete(id: string): void;
}

interface DataSearcher {
  search(query: string): any[];
}

interface DataBackup {
  backup(): void;
  restore(backupId: string): void;
}

interface DataMonitor {
  getStats(): StoreStats;
}

// 全機能を持つ実装
class FullDataStore implements DataReader, DataWriter, DataSearcher, DataBackup, DataMonitor {
  read(id: string): any { /* ... */ }
  write(id: string, data: any): void { /* ... */ }
  delete(id: string): void { /* ... */ }
  search(query: string): any[] { return []; }
  backup(): void { /* ... */ }
  restore(backupId: string): void { /* ... */ }
  getStats(): StoreStats { return {} as StoreStats; }
}

// 各クライアントは必要なインターフェースだけに依存
class SearchService {
  constructor(private store: DataSearcher) {}
  find(query: string): any[] {
    return this.store.search(query);
  }
}

class BackupService {
  constructor(private store: DataBackup) {}
  performBackup(): void {
    this.store.backup();
  }
}

class CrudService {
  constructor(
    private reader: DataReader,
    private writer: DataWriter
  ) {}
  // ...
}

Mission 5: 具体への依存を断ち切れ(15分)

要件

以下のコードはDIPに違反しています。抽象に依存するように修正してください。

class StripePaymentGateway {
  charge(amount: number, cardToken: string): string {
    // Stripe固有のAPI呼び出し
    return `stripe-txn-${Date.now()}`;
  }
}

class OrderService {
  private gateway = new StripePaymentGateway(); // 具体クラスに直接依存

  placeOrder(order: Order, cardToken: string): string {
    const txnId = this.gateway.charge(order.total, cardToken);
    return txnId;
  }
}

期待される動作

  • 決済ゲートウェイのインターフェースを定義する
  • OrderService はインターフェースに依存する
  • テスト用のモック実装を作成する
解答
interface PaymentGateway {
  charge(amount: number, paymentToken: string): string;
}

class StripePaymentGateway implements PaymentGateway {
  charge(amount: number, paymentToken: string): string {
    // Stripe固有の実装
    return `stripe-txn-${Date.now()}`;
  }
}

class PayPalPaymentGateway implements PaymentGateway {
  charge(amount: number, paymentToken: string): string {
    // PayPal固有の実装
    return `paypal-txn-${Date.now()}`;
  }
}

// テスト用モック
class MockPaymentGateway implements PaymentGateway {
  public lastCharge: { amount: number; token: string } | null = null;

  charge(amount: number, paymentToken: string): string {
    this.lastCharge = { amount, token: paymentToken };
    return 'mock-txn-123';
  }
}

class OrderService {
  constructor(private gateway: PaymentGateway) {} // 抽象に依存

  placeOrder(order: Order, paymentToken: string): string {
    return this.gateway.charge(order.total, paymentToken);
  }
}

// 使い方
const service = new OrderService(new StripePaymentGateway());
// テスト時
const mockGateway = new MockPaymentGateway();
const testService = new OrderService(mockGateway);

Mission 6: 総合:全原則を適用せよ(15分)

要件

以下のコードに含まれるすべてのSOLID原則違反を特定し、修正してください。

class ReportSystem {
  generateSalesReport(startDate: Date, endDate: Date): string {
    // DB直接アクセス(DIP違反)
    const db = new PostgresDB();
    const sales = db.query(`SELECT * FROM sales WHERE date BETWEEN ...`);

    // 集計処理とフォーマットが混在(SRP違反)
    let total = 0;
    let report = 'Sales Report\n';
    for (const sale of sales) {
      total += sale.amount;
      if (sale.type === 'online') {
        report += `[Online] ${sale.amount}\n`;
      } else if (sale.type === 'store') {
        report += `[Store] ${sale.amount}\n`;
      }
      // 新しい種類を追加するたびに else if が増える(OCP違反)
    }
    report += `Total: ${total}`;
    return report;
  }
}
解答
// DIP: 抽象に依存
interface SalesRepository {
  findByDateRange(start: Date, end: Date): Sale[];
}

// OCP: 販売タイプごとのフォーマッター
interface SaleFormatter {
  readonly type: string;
  format(sale: Sale): string;
}

class OnlineSaleFormatter implements SaleFormatter {
  readonly type = 'online';
  format(sale: Sale): string { return `[Online] ${sale.amount}`; }
}

class StoreSaleFormatter implements SaleFormatter {
  readonly type = 'store';
  format(sale: Sale): string { return `[Store] ${sale.amount}`; }
}

// SRP: 集計ロジック
class SalesAggregator {
  calculateTotal(sales: Sale[]): number {
    return sales.reduce((sum, sale) => sum + sale.amount, 0);
  }
}

// SRP: レポート組み立て
class SalesReportGenerator {
  private formatters: Map<string, SaleFormatter> = new Map();

  constructor(
    private repository: SalesRepository,
    private aggregator: SalesAggregator,
    formatters: SaleFormatter[]
  ) {
    for (const f of formatters) {
      this.formatters.set(f.type, f);
    }
  }

  generate(startDate: Date, endDate: Date): string {
    const sales = this.repository.findByDateRange(startDate, endDate);
    const total = this.aggregator.calculateTotal(sales);

    const lines = sales.map(sale => {
      const formatter = this.formatters.get(sale.type);
      return formatter ? formatter.format(sale) : `[Unknown] ${sale.amount}`;
    });

    return `Sales Report\n${lines.join('\n')}\nTotal: ${total}`;
  }
}

達成度チェック

ミッションテーマ完了
Mission 1SRP — 神クラスの分割
Mission 2OCP — 条件分岐の拡張可能化
Mission 3LSP — 壊れた継承の修正
Mission 4ISP — 太ったインターフェースの分離
Mission 5DIP — 具体依存の排除
Mission 6全SOLID原則の総合適用

まとめ

ポイント内容
SRPクラスの変更理由を1つにする
OCPインターフェースで拡張ポイントを作る
LSP基底型と派生型の契約を守る
ISP必要な操作だけのインターフェースを定義する
DIP具体ではなく抽象に依存する

チェックリスト

  • 各原則の違反パターンを認識できるようになった
  • 各原則に基づくリファクタリングを実行できた
  • 複数の原則を組み合わせた設計ができた

次のステップへ

次はチェックポイントクイズです。SOLID原則の理解度を確認しましょう。


推定読了時間: 90分