ストーリー
高橋アーキテクト が最後の演習課題を出しました。
ミッション概要
| 項目 | 内容 |
|---|---|
| 題材 | SNSタイムラインシステム |
| 目標 | CQRSアーキテクチャの設計 |
| 所要時間 | 90分 |
| 成果物 | Write/Read Model設計、プロジェクション、テスト戦略 |
ミッション 1: Write Modelの設計(20分)
以下の機能のWrite Model(コマンドモデル)を設計してください。
書き込み機能:
- 投稿を作成する
- 投稿にいいねする
- 投稿にコメントする
- ユーザーをフォローする
設計すべきこと:
- テーブル設計(正規化)
- コマンドの定義(Command Interface)
- ビジネスルールのバリデーション
ヒントと模範回答
// テーブル設計(正規化)
// 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に反映するプロジェクションを設計してください。
設計すべきこと:
- 各イベントに対するプロジェクションロジック
- タイムラインの更新戦略(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. 投稿 → タイムライン表示の遅延対策
// オプティミスティック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分