EXERCISE 90分

ストーリー

高橋アーキテクト
個々のパターンを知っていても、実際のプロジェクトでは複数のパターンを組み合わせて使う場面がほとんどだ
高橋アーキテクト
今回は、通知システムの設計を通じて、複数のパターンを組み合わせる経験を積もう

ミッション概要

ミッションテーマ対象パターン目安時間
Mission 1外部サービス統合Adapter15分
Mission 2機能拡張可能なロガーDecorator15分
Mission 3イベント駆動通知Observer15分
Mission 4通知アルゴリズム切り替えStrategy15分
Mission 5操作の記録と取り消しCommand15分
Mission 6統合:通知システム複数パターン15分

Mission 1: 外部サービス統合(15分)

要件

以下の2つの外部通知サービスを、統一インターフェースで使えるようにAdapterを実装してください。

// 外部サービス1:Twilio SMS API(変更不可)
class TwilioSmsApi {
  sendSms(phoneNumber: string, body: string, from: string): { sid: string } {
    console.log(`Twilio: ${from} -> ${phoneNumber}: ${body}`);
    return { sid: `SM${Date.now()}` };
  }
}

// 外部サービス2:SendGrid Email API(変更不可)
class SendGridApi {
  send(msg: { to: string; from: string; subject: string; html: string }): Promise<{ statusCode: number }> {
    console.log(`SendGrid: ${msg.from} -> ${msg.to}: ${msg.subject}`);
    return Promise.resolve({ statusCode: 202 });
  }
}

// 統一インターフェース
interface MessageSender {
  send(to: string, content: string): Promise<boolean>;
}
解答
class TwilioAdapter implements MessageSender {
  constructor(
    private twilioApi: TwilioSmsApi,
    private fromNumber: string
  ) {}

  async send(to: string, content: string): Promise<boolean> {
    try {
      const result = this.twilioApi.sendSms(to, content, this.fromNumber);
      return result.sid !== undefined;
    } catch {
      return false;
    }
  }
}

class SendGridAdapter implements MessageSender {
  constructor(
    private sendGridApi: SendGridApi,
    private fromEmail: string
  ) {}

  async send(to: string, content: string): Promise<boolean> {
    try {
      const result = await this.sendGridApi.send({
        to,
        from: this.fromEmail,
        subject: 'Notification',
        html: `<p>${content}</p>`,
      });
      return result.statusCode === 202;
    } catch {
      return false;
    }
  }
}

// 使い方
const smsSender: MessageSender = new TwilioAdapter(new TwilioSmsApi(), '+81-90-1234-5678');
const emailSender: MessageSender = new SendGridAdapter(new SendGridApi(), 'noreply@example.com');

await smsSender.send('+81-90-9999-0000', 'Hello via SMS');
await emailSender.send('user@example.com', 'Hello via Email');

Mission 2: 機能拡張可能なロガー(15分)

要件

基本のロガーに、タイムスタンプ追加、ログレベルフィルタリング、JSON出力の機能をDecoratorで追加してください。

interface AppLogger {
  log(level: string, message: string): void;
}

class ConsoleAppLogger implements AppLogger {
  log(level: string, message: string): void {
    console.log(`[${level}] ${message}`);
  }
}

期待される動作

const logger = new JsonFormatLogger(
  new TimestampLogger(
    new LevelFilterLogger(
      new ConsoleAppLogger(),
      'warn' // warn以上のみ出力
    )
  )
);
解答
const LOG_LEVELS: Record<string, number> = {
  debug: 0, info: 1, warn: 2, error: 3,
};

class TimestampLogger implements AppLogger {
  constructor(private wrapped: AppLogger) {}

  log(level: string, message: string): void {
    const timestamp = new Date().toISOString();
    this.wrapped.log(level, `${timestamp} ${message}`);
  }
}

class LevelFilterLogger implements AppLogger {
  private minLevel: number;

  constructor(private wrapped: AppLogger, minLevelName: string) {
    this.minLevel = LOG_LEVELS[minLevelName] ?? 0;
  }

  log(level: string, message: string): void {
    const currentLevel = LOG_LEVELS[level] ?? 0;
    if (currentLevel >= this.minLevel) {
      this.wrapped.log(level, message);
    }
  }
}

class JsonFormatLogger implements AppLogger {
  constructor(private wrapped: AppLogger) {}

  log(level: string, message: string): void {
    const json = JSON.stringify({ level, message });
    this.wrapped.log(level, json);
  }
}

// 組み合わせて使う
const logger = new JsonFormatLogger(
  new TimestampLogger(
    new LevelFilterLogger(
      new ConsoleAppLogger(),
      'warn'
    )
  )
);

logger.log('info', 'This will be filtered out');
logger.log('warn', 'This will be shown');
logger.log('error', 'This too');

Mission 3: イベント駆動通知(15分)

要件

ユーザーアクション(登録、ログイン、購入)が発生したときに、複数のハンドラーに通知するObserverパターンを実装してください。

期待される動作

const eventBus = new EventBus();
eventBus.on('user:registered', (data) => sendWelcomeEmail(data));
eventBus.on('user:registered', (data) => trackAnalytics(data));
eventBus.on('user:purchase', (data) => updateInventory(data));

eventBus.emit('user:registered', { userId: '123', email: 'test@example.com' });
解答
type EventData = Record<string, unknown>;
type EventHandler = (data: EventData) => void;

class EventBus {
  private handlers: Map<string, EventHandler[]> = new Map();

  on(eventName: string, handler: EventHandler): void {
    const list = this.handlers.get(eventName) ?? [];
    list.push(handler);
    this.handlers.set(eventName, list);
  }

  off(eventName: string, handler: EventHandler): void {
    const list = this.handlers.get(eventName) ?? [];
    this.handlers.set(eventName, list.filter(h => h !== handler));
  }

  once(eventName: string, handler: EventHandler): void {
    const wrappedHandler: EventHandler = (data) => {
      handler(data);
      this.off(eventName, wrappedHandler);
    };
    this.on(eventName, wrappedHandler);
  }

  emit(eventName: string, data: EventData): void {
    const list = this.handlers.get(eventName) ?? [];
    for (const handler of list) {
      try {
        handler(data);
      } catch (error) {
        console.error(`Error in handler for ${eventName}:`, error);
      }
    }
  }

  listenerCount(eventName: string): number {
    return (this.handlers.get(eventName) ?? []).length;
  }
}

// 使い方
const eventBus = new EventBus();

eventBus.on('user:registered', (data) => {
  console.log(`Welcome email sent to ${data.email}`);
});

eventBus.on('user:registered', (data) => {
  console.log(`Analytics: New user ${data.userId}`);
});

eventBus.on('user:purchase', (data) => {
  console.log(`Inventory updated for order ${data.orderId}`);
});

eventBus.emit('user:registered', { userId: '123', email: 'test@example.com' });
eventBus.emit('user:purchase', { userId: '123', orderId: 'ORD-456' });

Mission 4: 通知アルゴリズム切り替え(15分)

要件

通知の優先度判定を Strategy パターンで切り替え可能にしてください。ビジネス時間内は全通知を送信し、夜間は重要度の高い通知のみ送信するようにしてください。

interface Notification {
  title: string;
  message: string;
  priority: 'low' | 'medium' | 'high' | 'critical';
}
解答
interface NotificationFilterStrategy {
  shouldSend(notification: Notification): boolean;
}

class AllNotificationsStrategy implements NotificationFilterStrategy {
  shouldSend(_notification: Notification): boolean {
    return true;
  }
}

class HighPriorityOnlyStrategy implements NotificationFilterStrategy {
  shouldSend(notification: Notification): boolean {
    return notification.priority === 'high' || notification.priority === 'critical';
  }
}

class CriticalOnlyStrategy implements NotificationFilterStrategy {
  shouldSend(notification: Notification): boolean {
    return notification.priority === 'critical';
  }
}

class NotificationDispatcher {
  private strategy: NotificationFilterStrategy;

  constructor(strategy: NotificationFilterStrategy) {
    this.strategy = strategy;
  }

  setStrategy(strategy: NotificationFilterStrategy): void {
    this.strategy = strategy;
  }

  dispatch(notification: Notification, sender: MessageSender, to: string): void {
    if (this.strategy.shouldSend(notification)) {
      sender.send(to, `[${notification.priority}] ${notification.title}: ${notification.message}`);
    } else {
      console.log(`Filtered out: ${notification.title} (${notification.priority})`);
    }
  }
}

// 時間帯に応じて切り替え
const dispatcher = new NotificationDispatcher(new AllNotificationsStrategy());

const hour = new Date().getHours();
if (hour < 9 || hour >= 22) {
  dispatcher.setStrategy(new HighPriorityOnlyStrategy());
}

Mission 5: 操作の記録と取り消し(15分)

要件

通知設定の変更操作をCommandパターンで実装し、Undo/Redoを可能にしてください。

interface NotificationPreferences {
  email: boolean;
  sms: boolean;
  push: boolean;
  frequency: 'realtime' | 'daily' | 'weekly';
}
解答
interface PreferenceCommand {
  execute(): void;
  undo(): void;
  describe(): string;
}

class ToggleChannelCommand implements PreferenceCommand {
  private previousValue: boolean;

  constructor(
    private prefs: NotificationPreferences,
    private channel: 'email' | 'sms' | 'push'
  ) {
    this.previousValue = prefs[channel];
  }

  execute(): void {
    this.prefs[this.channel] = !this.prefs[this.channel];
  }

  undo(): void {
    this.prefs[this.channel] = this.previousValue;
  }

  describe(): string {
    return `Toggle ${this.channel}: ${this.previousValue} -> ${!this.previousValue}`;
  }
}

class ChangeFrequencyCommand implements PreferenceCommand {
  private previousFrequency: NotificationPreferences['frequency'];

  constructor(
    private prefs: NotificationPreferences,
    private newFrequency: NotificationPreferences['frequency']
  ) {
    this.previousFrequency = prefs.frequency;
  }

  execute(): void {
    this.prefs.frequency = this.newFrequency;
  }

  undo(): void {
    this.prefs.frequency = this.previousFrequency;
  }

  describe(): string {
    return `Change frequency: ${this.previousFrequency} -> ${this.newFrequency}`;
  }
}

class PreferenceManager {
  private history: PreferenceCommand[] = [];
  private undone: PreferenceCommand[] = [];

  execute(cmd: PreferenceCommand): void {
    cmd.execute();
    this.history.push(cmd);
    this.undone = [];
  }

  undo(): boolean {
    const cmd = this.history.pop();
    if (!cmd) return false;
    cmd.undo();
    this.undone.push(cmd);
    return true;
  }

  redo(): boolean {
    const cmd = this.undone.pop();
    if (!cmd) return false;
    cmd.execute();
    this.history.push(cmd);
    return true;
  }
}

// 使い方
const prefs: NotificationPreferences = {
  email: true, sms: false, push: true, frequency: 'realtime',
};

const manager = new PreferenceManager();
manager.execute(new ToggleChannelCommand(prefs, 'sms'));     // sms: true
manager.execute(new ChangeFrequencyCommand(prefs, 'daily')); // frequency: daily
manager.undo(); // frequency: realtime に戻る
manager.undo(); // sms: false に戻る

Mission 6: 統合:通知システム(15分)

要件

Mission 1-5 のパターンを組み合わせて、通知システムの全体像を設計してください。以下の要件を満たすクラス構成を考えてください。

  • 外部サービスは Adapter で統合
  • ロガーは Decorator で機能拡張
  • ユーザーアクションは Observer で通知
  • 通知フィルタリングは Strategy で切り替え
  • 設定変更は Command で記録
解答
// === 全体の組み立て ===

// 1. Adapter: 外部サービスの統合
const smsService: MessageSender = new TwilioAdapter(new TwilioSmsApi(), '+81-90-0000-0000');
const emailService: MessageSender = new SendGridAdapter(new SendGridApi(), 'noreply@example.com');

// 2. Decorator: ロガーの機能拡張
const logger: AppLogger = new TimestampLogger(
  new LevelFilterLogger(new ConsoleAppLogger(), 'info')
);

// 3. Strategy: 通知フィルタリング
const dispatcher = new NotificationDispatcher(new AllNotificationsStrategy());

// 4. Observer: イベント駆動
const eventBus = new EventBus();

eventBus.on('user:registered', (data) => {
  logger.log('info', `New user registered: ${data.userId}`);
  dispatcher.dispatch(
    { title: 'Welcome', message: 'Thanks for joining!', priority: 'medium' },
    emailService,
    data.email as string
  );
});

eventBus.on('order:placed', (data) => {
  logger.log('info', `Order placed: ${data.orderId}`);
  dispatcher.dispatch(
    { title: 'Order Confirmation', message: `Order #${data.orderId}`, priority: 'high' },
    smsService,
    data.phone as string
  );
});

// 5. Command: 設定変更の記録
const prefs: NotificationPreferences = {
  email: true, sms: true, push: true, frequency: 'realtime',
};
const prefManager = new PreferenceManager();

// 実行例
eventBus.emit('user:registered', { userId: 'U001', email: 'user@example.com' });
eventBus.emit('order:placed', { orderId: 'ORD-001', phone: '+81-90-1234-5678' });

prefManager.execute(new ChangeFrequencyCommand(prefs, 'daily'));

達成度チェック

ミッションテーマ完了
Mission 1Adapter で外部サービス統合
Mission 2Decorator でロガー拡張
Mission 3Observer でイベント駆動
Mission 4Strategy で通知フィルタリング
Mission 5Command で設定変更記録
Mission 6全パターンの統合設計

まとめ

ポイント内容
パターンの組み合わせ実際のシステムでは複数パターンが共存する
Adapter + Strategy外部サービスの統合と切り替え
Observer + Commandイベント駆動と操作の記録
Decorator既存機能への横断的な拡張

チェックリスト

  • 各パターンを独立して実装できた
  • 複数パターンの組み合わせを設計できた
  • 実際のシステムでの適用場面をイメージできた

次のステップへ

次はチェックポイントクイズです。構造パターンと振る舞いパターンの理解度を確認しましょう。


推定読了時間: 90分