EXERCISE 90分

ストーリー

高橋アーキテクト が最後の演習課題を出しました。

高橋アーキテクト
SNS のタイムラインシステムをCQRSで設計してほしい。投稿の書き込みとタイムラインの読み取りでは、要求されるパフォーマンスもデータモデルもまったく違う。この演習で、CQRSと結果整合性の設計を実践しよう

ミッション概要

項目内容
題材SNSタイムラインシステム
目標CQRSアーキテクチャの設計
所要時間90分
成果物Write/Read Model設計、プロジェクション、テスト戦略

ミッション 1: Write Modelの設計(20分)

以下の機能のWrite Model(コマンドモデル)を設計してください。

書き込み機能:

  • 投稿を作成する
  • 投稿にいいねする
  • 投稿にコメントする
  • ユーザーをフォローする

設計すべきこと:

  1. テーブル設計(正規化)
  2. コマンドの定義(Command Interface)
  3. ビジネスルールのバリデーション
ヒントと模範回答
// テーブル設計(正規化)
// posts テーブル
interface PostEntity {
  id: string;
  authorId: string;
  content: string;
  mediaUrls: string[];
  status: "DRAFT" | "PUBLISHED" | "DELETED";
  createdAt: Date;
}

// likes テーブル
interface LikeEntity {
  postId: string;
  userId: string;
  createdAt: Date;
  // 複合主キー: (postId, userId) → 二重いいね防止
}

// comments テーブル
interface CommentEntity {
  id: string;
  postId: string;
  authorId: string;
  content: string;
  createdAt: Date;
}

// follows テーブル
interface FollowEntity {
  followerId: string;
  followeeId: string;
  createdAt: Date;
}

// コマンド定義
interface CreatePostCommand {
  type: "CreatePost";
  authorId: string;
  content: string;
  mediaUrls?: string[];
}

interface LikePostCommand {
  type: "LikePost";
  postId: string;
  userId: string;
}

interface CreateCommentCommand {
  type: "CreateComment";
  postId: string;
  authorId: string;
  content: string;
}

interface FollowUserCommand {
  type: "FollowUser";
  followerId: string;
  followeeId: string;
}

// バリデーション例
class CreatePostHandler {
  async execute(cmd: CreatePostCommand): Promise<void> {
    // ビジネスルール
    if (cmd.content.length > 280) throw new Error("投稿は280文字以内");
    if (cmd.content.trim().length === 0) throw new Error("空の投稿は不可");

    const user = await this.userRepo.findById(cmd.authorId);
    if (user.isSuspended) throw new Error("停止中のユーザーは投稿不可");

    await this.postRepo.save({ ...cmd, status: "PUBLISHED" });
    await this.eventBus.publish("post.created", { ... });
  }
}

ミッション 2: Read Modelの設計(20分)

以下の読み取り要件に対するRead Model(クエリモデル)を設計してください。

読み取り要件:

  • ユーザーのタイムライン(フォローしている人の投稿を時系列で)
  • 投稿の詳細(いいね数、コメント一覧付き)
  • ユーザーのプロフィール(投稿数、フォロワー数付き)
ヒントと模範回答
// Read Model 1: タイムライン
// 非正規化、JOINなし、高速読み取り
interface TimelineEntry {
  timelineOwnerId: string;
  postId: string;
  authorId: string;
  authorName: string;
  authorAvatarUrl: string;
  content: string;
  mediaUrls: string[];
  likeCount: number;
  commentCount: number;
  isLikedByViewer: boolean;
  createdAt: string;
}
// ストレージ: Redis Sorted Set (score = timestamp)
// キー: timeline:{userId}
// → O(1)で最新N件を取得

// Read Model 2: 投稿詳細
interface PostDetailReadModel {
  postId: string;
  authorId: string;
  authorName: string;
  authorAvatarUrl: string;
  content: string;
  mediaUrls: string[];
  likeCount: number;
  recentLikers: Array<{ userId: string; name: string }>;
  comments: Array<{
    commentId: string;
    authorId: string;
    authorName: string;
    content: string;
    createdAt: string;
  }>;
  createdAt: string;
}
// ストレージ: DynamoDB or Elasticsearch

// Read Model 3: ユーザープロフィール
interface UserProfileReadModel {
  userId: string;
  name: string;
  bio: string;
  avatarUrl: string;
  postCount: number;
  followerCount: number;
  followingCount: number;
  recentPosts: Array<{
    postId: string;
    content: string;
    likeCount: number;
    createdAt: string;
  }>;
}
// ストレージ: DynamoDB

ミッション 3: プロジェクションの設計(20分)

Write Modelの変更をRead Modelに反映するプロジェクションを設計してください。

設計すべきこと:

  1. 各イベントに対するプロジェクションロジック
  2. タイムラインの更新戦略(Fan-out on Write vs Fan-out on Read)
ヒントと模範回答
// プロジェクション
class TimelineProjection {
  // post.created → フォロワー全員のタイムラインに追加
  async handlePostCreated(event: PostCreatedEvent): Promise<void> {
    const author = await this.userReadStore.get(event.data.authorId);
    const followers = await this.followStore.getFollowers(event.data.authorId);

    const entry: TimelineEntry = {
      postId: event.data.postId,
      authorId: event.data.authorId,
      authorName: author.name,
      authorAvatarUrl: author.avatarUrl,
      content: event.data.content,
      mediaUrls: event.data.mediaUrls,
      likeCount: 0,
      commentCount: 0,
      isLikedByViewer: false,
      createdAt: event.metadata.timestamp,
    };

    // Fan-out on Write: フォロワー全員のタイムラインに追加
    for (const followerId of followers) {
      await this.redis.zadd(
        `timeline:${followerId}`,
        Date.parse(event.metadata.timestamp),
        JSON.stringify({ ...entry, timelineOwnerId: followerId })
      );
    }

    // 投稿者のプロフィールも更新
    await this.userProfileStore.incrementPostCount(event.data.authorId);
  }

  // post.liked → いいね数を更新
  async handlePostLiked(event: PostLikedEvent): Promise<void> {
    await this.postDetailStore.incrementLikeCount(event.data.postId);
    // タイムラインのlikeCountも非同期で更新(遅延許容)
  }
}

// Fan-out on Write vs Fan-out on Read
// Fan-out on Write(推奨: フォロワー数が少ない場合)
//   投稿時にフォロワー全員のタイムラインにコピー
//   → 読み取りは高速(自分のタイムラインを読むだけ)
//   → 書き込みコストが高い(有名人のフォロワーが100万人など)

// Fan-out on Read(フォロワー数が非常に多い場合)
//   読み取り時にフォロー中ユーザーの投稿をマージ
//   → 書き込みは軽量
//   → 読み取りコストが高い

// ハイブリッド(Twitter/X方式)
//   一般ユーザー: Fan-out on Write
//   有名人(フォロワー10万人以上): Fan-out on Read

ミッション 4: 結果整合性の戦略(15分)

以下の場面での結果整合性の扱い方を設計してください。

  1. 投稿後にタイムラインに表示されるまでの遅延
  2. いいね数のカウント遅延
  3. フォロワー数の更新遅延
ヒントと模範回答
// 1. 投稿 → タイムライン表示の遅延対策
// オプティミスティックUI: 投稿者の画面には即座に表示
function handlePostSubmit(content: string) {
  // UI即座に更新
  timeline.prepend({ content, status: "posting..." });
  // API呼び出し
  api.createPost({ content }).then(result => {
    timeline.update(result.postId, { status: "posted" });
  });
}

// 2. いいね数の遅延対策
// クライアント側でカウントをインクリメント
function handleLike(postId: string) {
  ui.incrementLikeCount(postId);      // 即座に+1
  ui.setLiked(postId, true);
  api.likePost(postId).catch(() => {
    ui.decrementLikeCount(postId);    // 失敗時は戻す
    ui.setLiked(postId, false);
  });
}

// 3. フォロワー数の遅延
// 数秒の遅延は許容(SNSでは一般的)
// 表示: "約1.2万フォロワー" のように概数で表示
// → 正確な数値ではなく概算で良い場面

ミッション 5: テスト戦略(15分)

このCQRSシステムのテスト戦略を設計してください。

ヒントと模範回答
// 1. Contract Test: PostService → TimelineProjection
describe("Post Created Event Contract", () => {
  it("should produce valid event schema", async () => {
    const event = await postService.createPost(testData);
    expect(event.type).toBe("post.created");
    expect(event.data.postId).toBeDefined();
    expect(event.data.content).toBeDefined();
  });
});

// 2. Projection Test: イベントからRead Modelへの変換
describe("Timeline Projection", () => {
  it("should add entry to followers timelines", async () => {
    await projection.handlePostCreated(mockEvent);
    const timeline = await redis.zrange("timeline:follower-1", 0, -1);
    expect(timeline).toHaveLength(1);
  });
});

// 3. Chaos Test: プロジェクション障害時の復旧
const chaosTest = {
  experiment: "プロジェクションサービスを10分間停止",
  hypothesis: "復旧後にイベントが再処理され、Read Modelが整合する",
  verify: "Kafkaのconsumer lagがゼロに戻る",
};

// 4. 結果整合性テスト: 書き込み後の読み取り遅延を測定
describe("Eventual Consistency SLA", () => {
  it("should reflect post in timeline within 3 seconds", async () => {
    await postService.createPost(testData);
    const start = Date.now();
    let found = false;
    while (Date.now() - start < 3000) {
      const timeline = await timelineQuery.get(followerId);
      if (timeline.find(e => e.postId === testData.postId)) {
        found = true;
        break;
      }
      await sleep(100);
    }
    expect(found).toBe(true);
  });
});

達成チェックリスト

  • Write Model(正規化テーブル + コマンド)を設計できた
  • Read Model(非正規化、用途別のモデル)を設計できた
  • プロジェクションのロジックを設計できた
  • Fan-out on Write/Readの選択と理由を説明できた
  • 結果整合性のUX対策を設計できた
  • テスト戦略(Contract、Projection、Chaos)を策定できた

推定所要時間: 90分