コンテンツにスキップ

クリーンアーキテクチャ設計書

項目
モジュール/サービス名 Timeline Service (recerdo-timeline)
作成者 Akira
作成日 2026-04-13
ステータス ドラフト
バージョン 1.0

1. 概要

1.1 目的

Timeline Service はRecuerdo プラットフォームにおいて、すべてのユーザー活動を時系列でアグリゲートし、個人フィード・組織フィードとして提供する、いわば"メモリレイヤー"である。ユーザーや組織が過去の活動(イベント作成、アルバム追加、友人参加)を振り返ることができる中核サービス。

1.2 ビジネスコンテキスト

  • Recuerdo はノスタルジアと再接続をテーマとするソーシャルメモリプラットフォーム
  • Timeline は個人履歴&組織記録の両面:「私は誰とどんな時を過ごしたか」「我々の組織はどう成長したか」
  • プライベート(個人のみ)、フレンドリー(親友)、パブリック(公開)の3段階の可視性管理
  • イベント駆動アーキテクチャのコンシューマ:他すべてのサービスから通知を受け取る

1.3 アーキテクチャ原則

  • イベント駆動型:QueuePort メッセージをリスニング、TimelineItem を非同期作成
  • アクセス制御:可視性ルール(PRIVATE/FRIENDS/PUBLIC)をドメイン層で保護
  • スケーラビリティとハイブリッド Fan-out:月次パーティショニング(MySQL)+ Redis ソートセット FIFOキャッシュ。Fan-out on Write を既定とするが、フォロワー数 > 500 の場合は Fan-out on Read に動的フォールバックする。
  • 縮退運転 (Graceful Degradation):高負荷やSLOエラーバジェット枯渇時は、読み取りをキャッシュのフォールバックに切り替え、重い書き込み処理は Flipt フィーチャーフラグを用いて Fan-out on Read 化・または遅延・停止させる。
  • 冪等性とTransactional Outbox:全書き込み系API及びイベントリスニングで Idempotency-Key を用いた重複排除(Redisに24h保持)を行う。また、永続化と他サービスへのイベント通知における不整合を避けるため、一貫して Transactional Outbox を使用する。
  • イミュータビリティ:TimelineItem は削除されない、表示/非表示フラグで制御

2. レイヤーアーキテクチャ

2.1 アーキテクチャ図 (ASCII concentric circles)

┌─────────────────────────────────────────────────────┐
│  フレームワーク&ドライバ層                          │
│  (Web: Gin, DB: MySQL (MariaDB互換),         │
│   Queue: QueuePort consumer - Beta: Redis+BullMQ / Prod: OCI Queue) │
└────────────┬──────────────────────────────────────┘
┌────────────▼──────────────────────────────────────┐
│  インターフェースアダプタ層                        │
│  (HTTP Handler, Repository Impl,                  │
│   QueueMessageConsumer, Presenter)                │
└────────────┬──────────────────────────────────────┘
┌────────────▼──────────────────────────────────────┐
│  ユースケース層 (アプリケーション)                │
│  (CreateTimelineItem, GetUserTimeline,            │
│   GetOrgTimeline, HideTimelineItem)               │
└────────────┬──────────────────────────────────────┘
┌────────────▼──────────────────────────────────────┐
│  エンティティ層 (ドメイン)                        │
│  (TimelineItem, TimelinePayload,                  │
│   Visibility ルール、値オブジェクト)              │
└─────────────────────────────────────────────────────┘

2.2 依存性ルール

  • 内向き依存のみ:Adapter → UseCase → Domain
  • QueuePort メッセージはホストサービス扱い:外側(インフラ)から内側(ドメイン)への入力
  • 可視性チェック:Permission Service 呼び出しはアダプタ層の責務

3. エンティティ層(ドメイン)

3.1 ドメインモデル

エンティティ 説明
TimelineItem 時系列で記録される単一の活動:イベント作成、メディア追加、友人参加など
FeedCursor ページネーション状態:last_seen_id + occurred_at タプル

3.2 値オブジェクト

値オブジェクト 許可される値 不変性
TimelineItemType EVENT_CREATED, ALBUM_CREATED, MEDIA_ADDED, FRIEND_JOINED, EVENT_INVITATION_ACCEPTED, HIGHLIGHT_VIDEO_READY, MEMORY_SHARED イミュータブル(列挙値)
Visibility PUBLIC, FRIENDS, PRIVATE イミュータブル、ドメインルール適用
FeedCursor (last_seen_id: string, occurred_at: timestamp) イミュータブル
TimelinePayload item_type に応じて異なる JSON 構造 イミュータブル(MySQL/MariaDB JSON 型)

3.3 ドメインルール / 不変条件

  • Visibility ルール:PRIVATE は owner のみ、FRIENDS は接続ユーザーのみ、PUBLIC は全員
  • Immutability:TimelineItem は作成後変更不可(hidden フラグでのみ制御)
  • Occurred At Immutability:occurred_at は作成時に固定、イベント発生時刻を記録
  • Organization Timeline Filter:org timeline は FRIENDS/PUBLIC のみ表示、PRIVATE は除外
  • Hidden Flag:item は delete されず、hidden=true で表示除外
  • Payload Validation:TimelinePayload は item_type に応じた構造を強制(スキーマ検証)

3.4 ドメインイベント

イベント トリガー ペイロード 購読者
TimelineItemCreated CreateTimelineItem 成功 item_id, user_id, org_id, item_type, visibility Notification Svc (ユーザーフィード更新通知)
TimelineItemHidden HideTimelineItem 成功 item_id, hidden_by Notification Svc (UI 更新通知)

3.5 エンティティ定義 (Go pseudocode)

package domain

import (
    "database/sql"
    "encoding/json"
    "time"
)

// TimelineItemType は Timeline 上の活動の種類
type TimelineItemType string

const (
    TimelineItemTypeEventCreated            TimelineItemType = "EVENT_CREATED"
    TimelineItemTypeAlbumCreated            TimelineItemType = "ALBUM_CREATED"
    TimelineItemTypeMediaAdded              TimelineItemType = "MEDIA_ADDED"
    TimelineItemTypeFriendJoined            TimelineItemType = "FRIEND_JOINED"
    TimelineItemTypeEventInvitationAccepted TimelineItemType = "EVENT_INVITATION_ACCEPTED"
    TimelineItemTypeHighlightVideoReady     TimelineItemType = "HIGHLIGHT_VIDEO_READY"
    TimelineItemTypeMemoryShared            TimelineItemType = "MEMORY_SHARED"
)

// Visibility 可視性レベル
type Visibility string

const (
    VisibilityPrivate  Visibility = "PRIVATE"   // 所有者のみ
    VisibilityFriends  Visibility = "FRIENDS"   // 接続ユーザー
    VisibilityPublic   Visibility = "PUBLIC"    // すべてのメンバー
)

// TimelinePayload item_type に応じた可変構造(MySQL/MariaDB JSON 型にシリアライズ)
type TimelinePayload struct {
    data map[string]interface{}
}

func NewTimelinePayload(itemType TimelineItemType, data map[string]interface{}) (*TimelinePayload, error) {
    if !isValidPayloadForType(itemType, data) {
        return nil, fmt.Errorf("invalid payload for item type %s", itemType)
    }
    return &TimelinePayload{data: data}, nil
}

func (p *TimelinePayload) MarshalJSON() ([]byte, error) {
    return json.Marshal(p.data)
}

func (p *TimelinePayload) UnmarshalJSON(b []byte) error {
    return json.Unmarshal(b, &p.data)
}

// FeedCursor カーソルベースのページネーション
type FeedCursor struct {
    LastSeenID string    // last_seen timeline_item id
    OccurredAt time.Time // last occurred_at timestamp
}

func NewFeedCursor(lastSeenID string, occurredAt time.Time) FeedCursor {
    return FeedCursor{
        LastSeenID: lastSeenID,
        OccurredAt: occurredAt,
    }
}

// Encode カーソルをコードに変換(API レスポンスで使用)
func (fc FeedCursor) Encode() string {
    // Base64 エンコード: "{lastSeenID}:{occurredAt.Unix()}"
    data := fmt.Sprintf("%s:%d", fc.LastSeenID, fc.OccurredAt.Unix())
    return base64.StdEncoding.EncodeToString([]byte(data))
}

// TimelineItem ドメインエンティティ
type TimelineItem struct {
    ID        string              // ULID
    UserID    *string             // 個人活動の場合のみ; NULL なら org_id が主体
    OrgID     *string             // 組織活動の場合のみ
    EventID   *string             // イベント関連活動の場合のみ
    ItemType  TimelineItemType
    Payload   *TimelinePayload    // JSON
    OccurredAt time.Time          // イベント発生時刻(イミュータブル)
    Visibility Visibility
    Hidden    bool                // soft delete フラグ
    HiddenBy  *string             // who hid it
    HiddenAt  *time.Time
    CreatedAt time.Time
    domainEvents []interface{}
}

// NewTimelineItem ファクトリメソッド
func NewTimelineItem(
    userID *string,
    orgID *string,
    eventID *string,
    itemType TimelineItemType,
    payload *TimelinePayload,
    occurredAt time.Time,
    visibility Visibility,
) (*TimelineItem, error) {
    // バリデーション:user_id か org_id いずれか必須
    if (userID == nil || *userID == "") && (orgID == nil || *orgID == "") {
        return nil, fmt.Errorf("either user_id or org_id required")
    }

    if payload == nil {
        return nil, fmt.Errorf("payload required")
    }

    item := &TimelineItem{
        ID:         generateULID(),
        UserID:     userID,
        OrgID:      orgID,
        EventID:    eventID,
        ItemType:   itemType,
        Payload:    payload,
        OccurredAt: occurredAt,
        Visibility: visibility,
        Hidden:     false,
        CreatedAt:  time.Now(),
    }

    item.recordEvent(&TimelineItemCreatedEvent{
        ItemID:    item.ID,
        UserID:    userID,
        OrgID:     orgID,
        ItemType:  itemType,
        Visibility: visibility,
        CreatedAt: time.Now(),
    })

    return item, nil
}

// Hide soft delete
func (ti *TimelineItem) Hide(hiddenBy string) error {
    if ti.Hidden {
        return fmt.Errorf("already hidden")
    }
    ti.Hidden = true
    ti.HiddenBy = &hiddenBy
    now := time.Now()
    ti.HiddenAt = &now

    ti.recordEvent(&TimelineItemHiddenEvent{
        ItemID:   ti.ID,
        HiddenBy: hiddenBy,
        HiddenAt: now,
    })

    return nil
}

// IsVisibleTo 可視性チェック(アクセス制御)
func (ti *TimelineItem) IsVisibleTo(viewerID string, isConnected bool) bool {
    if ti.Hidden {
        return false
    }

    // 所有者には常に見える
    if ti.UserID != nil && *ti.UserID == viewerID {
        return true
    }

    switch ti.Visibility {
    case VisibilityPrivate:
        return ti.UserID != nil && *ti.UserID == viewerID
    case VisibilityFriends:
        return isConnected
    case VisibilityPublic:
        return true
    default:
        return false
    }
}

// DomainEvents ドメインイベント取得&クリア
func (ti *TimelineItem) DomainEvents() []interface{} {
    events := ti.domainEvents
    ti.domainEvents = []interface{}{}
    return events
}

func (ti *TimelineItem) recordEvent(event interface{}) {
    ti.domainEvents = append(ti.domainEvents, event)
}

// ドメインイベント
type TimelineItemCreatedEvent struct {
    ItemID     string
    UserID     *string
    OrgID      *string
    ItemType   TimelineItemType
    Visibility Visibility
    CreatedAt  time.Time
}

type TimelineItemHiddenEvent struct {
    ItemID   string
    HiddenBy string
    HiddenAt time.Time
}

// ペイロード構造の例(item_type ごと)
func EventCreatedPayload(eventID, eventTitle, createdBy string) map[string]interface{} {
    return map[string]interface{}{
        "event_id":  eventID,
        "title":     eventTitle,
        "created_by": createdBy,
    }
}

func AlbumCreatedPayload(albumID, albumName, eventID string) map[string]interface{} {
    return map[string]interface{}{
        "album_id":  albumID,
        "name":      albumName,
        "event_id":  eventID,
    }
}

4. ユースケース層(アプリケーション)

4.1 ユースケース一覧

ユースケース 説明 アクター 主成功シナリオ
CreateTimelineItem QueuePort メッセージから TimelineItem 作成(非同期) Queue Consumer メッセージパース、ドメイン構築、DB 保存、キャッシュ更新
GetUserTimeline ユーザーの個人フィード取得 Org Member カーソルベースページング、可視性フィルタ、キャッシュ活用
GetOrgTimeline 組織フィード取得 Org Member PUBLIC/FRIENDS のみ、個人 PRIVATE 除外、ページング
HideTimelineItem アイテムを非表示(soft delete) Item Owner or Org Admin Hidden=true 設定、キャッシュ無効化
GetUserFeed ユーザーのカスタマイズされたフィード(友人活動含む) Org Member グラフベース可視性チェック、マージソート(時系列)

4.2 ユースケース詳細 (CreateTimelineItem - main use case)

Actor: Queue Consumer (非同期ワーカー)

Pre-conditions: - QueuePort キューに JSON メッセージあり - メッセージスキーマ有効

Main Flow: 1. QueuePort メッセージ受信(例:EventCreatedEvent) 2. JSON をドメインイベント型にパース 3. TimelineItemType を決定(EventCreated → EVENT_CREATED) 4. TimelinePayload を構築(バリデーション含む) 5. Visibility を決定(EVENT_CREATED の場合は FRIENDS) 6. TimelineItem.NewTimelineItem() でドメインエンティティ作成 7. TimelineRepository.Save() で DB 保存 8. FeedCache を無効化(affected user/org) 9. DomainEvents を取得、Notification Service 呼び出し

Post-conditions: - TimelineItem が DB に保存(月次パーティション) - ユーザーフィード Redis キャッシュ無効化 - TimelineItemCreatedEvent が Notification Service に送信

Errors: - メッセージパース失敗:ErrInvalidMessage - ペイロード検証失敗:ErrInvalidPayload - DB 保存失敗:ErrPersistenceFailed(リトライ対象)

4.3 入出力DTO (Go struct pseudocode)

package application

// CreateTimelineItemRequest QueuePort メッセージペイロード
type CreateTimelineItemRequest struct {
    EventType   string                 `json:"event_type"` // EventCreated, AlbumCreated など
    UserID      *string                `json:"user_id,omitempty"`
    OrgID       *string                `json:"org_id,omitempty"`
    EventID     *string                `json:"event_id,omitempty"`
    Payload     map[string]interface{} `json:"payload"`
    OccurredAt  time.Time              `json:"occurred_at"`
    Visibility  string                 `json:"visibility"` // PUBLIC, FRIENDS, PRIVATE
}

// CreateTimelineItemResponse
type CreateTimelineItemResponse struct {
    ItemID    string    `json:"item_id"`
    ItemType  string    `json:"item_type"`
    CreatedAt time.Time `json:"created_at"`
}

// GetUserTimelineRequest
type GetUserTimelineRequest struct {
    UserID string  `json:"user_id"`
    Limit  int     `json:"limit"`       // default: 20, max: 100
    Cursor *string `json:"cursor,omitempty"` // base64 encoded
}

// GetUserTimelineResponse
type GetUserTimelineResponse struct {
    Items      []TimelineItemDTO `json:"items"`
    NextCursor *string           `json:"next_cursor,omitempty"`
    Total      int64             `json:"total"`
}

// TimelineItemDTO プレゼンテーション用
type TimelineItemDTO struct {
    ID         string                 `json:"id"`
    ItemType   string                 `json:"item_type"`
    Payload    map[string]interface{} `json:"payload"`
    OccurredAt time.Time              `json:"occurred_at"`
    Visibility string                 `json:"visibility"`
    CreatedAt  time.Time              `json:"created_at"`
}

// GetOrgTimelineRequest
type GetOrgTimelineRequest struct {
    OrgID  string  `json:"org_id"`
    Limit  int     `json:"limit"`
    Cursor *string `json:"cursor,omitempty"`
}

// HideTimelineItemRequest
type HideTimelineItemRequest struct {
    ItemID  string `json:"item_id"`
    HiddenBy string `json:"hidden_by"` // user_id
}

// GetUserFeedRequest(拡張フィード:友人活動含む)
type GetUserFeedRequest struct {
    UserID string `json:"user_id"`
    Limit  int    `json:"limit"`
    Offset int    `json:"offset"`
}

// GetUserFeedResponse
type GetUserFeedResponse struct {
    Items []TimelineItemDTO `json:"items"` // マージ済み、時系列順
    Total int64             `json:"total"`
}

4.4 リポジトリインターフェース(ポート)

package application

import "context"

// TimelineItemRepository Timeline Item 永続化のポート
type TimelineItemRepository interface {
    // Save TimelineItem を保存
    Save(ctx context.Context, item *domain.TimelineItem) error

    // FindByID ID で検索
    FindByID(ctx context.Context, itemID string) (*domain.TimelineItem, error)

    // ListByUser ユーザーの timeline(ページング)
    ListByUser(ctx context.Context, userID string, limit int, cursor *domain.FeedCursor) ([]*domain.TimelineItem, *domain.FeedCursor, error)

    // ListByOrg 組織の timeline(ページング)
    ListByOrg(ctx context.Context, orgID string, limit int, cursor *domain.FeedCursor) ([]*domain.TimelineItem, *domain.FeedCursor, error)

    // ListByEvent イベント関連アイテム
    ListByEvent(ctx context.Context, eventID string) ([]*domain.TimelineItem, error)

    // Update アイテム更新(Hidden フラグなど)
    Update(ctx context.Context, item *domain.TimelineItem) error

    // CountByUser ユーザーのアイテム総数
    CountByUser(ctx context.Context, userID string) (int64, error)
}

// FeedCacheRepository フィードキャッシュのポート(Redis)
type FeedCacheRepository interface {
    // GetUserFeed ユーザーフィード取得(Redis sorted set)
    GetUserFeed(ctx context.Context, userID string, start, stop int) ([]string, error)

    // InvalidateUserFeed ユーザーフィードキャッシュ無効化
    InvalidateUserFeed(ctx context.Context, userID string) error

    // InvalidateOrgFeed 組織フィードキャッシュ無効化
    InvalidateOrgFeed(ctx context.Context, orgID string) error
}

4.5 外部サービスインターフェース(ポート)

package application

// PermissionService アクセス制御のポート(gRPC)
type PermissionService interface {
    // IsUserConnected ユーザー間の接続状態チェック
    IsUserConnected(ctx context.Context, userID1, userID2 string) (bool, error)

    // CanViewItem ユーザーがアイテムを見られるか
    CanViewItem(ctx context.Context, viewerID string, item *domain.TimelineItem) (bool, error)
}

// NotificationService 通知サービスのポート
type NotificationService interface {
    // NotifyFeedUpdate フィード更新通知
    NotifyFeedUpdate(ctx context.Context, userID, itemID string) error
}

// QueueMessageConsumer キューコンシューマのポート (Beta: Redis+BullMQ/asynq / Prod: OCI Queue)
type QueueMessageConsumer interface {
    // Listen QueuePort メッセージリスニング開始
    Listen(ctx context.Context, handler func(ctx context.Context, message interface{}) error) error
}

5. インターフェースアダプタ層

5.1 コントローラ / ハンドラ

ハンドラ HTTP Method Path 入力 出力 責務
GetUserTimelineHandler GET /api/timelines/users/{id} Query params GetUserTimelineResponse ページング、可視性フィルタ、キャッシュ確認
GetOrgTimelineHandler GET /api/timelines/orgs/{id} Query params GetUserTimelineResponse 組織フィルタ、PRIVATE 除外
HideTimelineItemHandler DELETE /api/timeline-items/{id} - StatusResponse 権限チェック、Hidden 設定
GetUserFeedHandler GET /api/feeds/users/{id} Query params GetUserFeedResponse 友人グラフ結合、統合ソート
QueueMessageConsumerHandler (background) (async) Queue message CreateTimelineItemResponse メッセージパース、ドメイン構築

5.2 プレゼンター / レスポンスマッパー

package adapter

// TimelinePresenter ドメインモデル → HTTP レスポンス
type TimelinePresenter struct {
    cache *feedCacheProvider
}

// PresentTimelineItemDTO TimelineItem → DTO
func (p *TimelinePresenter) PresentTimelineItemDTO(item *domain.TimelineItem) *TimelineItemDTO {
    return &TimelineItemDTO{
        ID:         item.ID,
        ItemType:   string(item.ItemType),
        Payload:    item.Payload.Data(),
        OccurredAt: item.OccurredAt,
        Visibility: string(item.Visibility),
        CreatedAt:  item.CreatedAt,
    }
}

// PresentUserTimelineResponse ユーザーフィード レスポンス
func (p *TimelinePresenter) PresentUserTimelineResponse(
    items []*domain.TimelineItem,
    nextCursor *domain.FeedCursor,
    total int64,
) *GetUserTimelineResponse {
    dtos := make([]TimelineItemDTO, len(items))
    for i, item := range items {
        dtos[i] = *p.PresentTimelineItemDTO(item)
    }

    var cursorStr *string
    if nextCursor != nil {
        encoded := nextCursor.Encode()
        cursorStr = &encoded
    }

    return &GetUserTimelineResponse{
        Items:      dtos,
        NextCursor: cursorStr,
        Total:      total,
    }
}

5.3 リポジトリ実装(アダプタ)

リポジトリ実装 対象 技術 キャッシング戦略
MySQLTimelineItemRepository TimelineItem database/sql + sqlc + 月次パーティショニング リスト結果→Redis sorted set (TTL 10min)
RedisTimelineItemCache Timeline Item キャッシュ Redis Sorted Set スコア=occurred_at.Unix(), メンバー=item_id

5.4 外部サービスアダプタ

アダプタ 外部サービス 実装 エラーハンドリング
PermissionServiceClient Permission Service gRPC タイムアウト 2sec、default false
RedisBullMQConsumerAdapter Redis+BullMQ/asynq (Beta) github.com/hibiken/asynq リトライ 3回、DLQ へ送信
OCIQueueConsumerAdapter OCI Queue (Prod) github.com/oracle/oci-go-sdk/v65/queue リトライ 3回、DLQ へ送信

5.5 マッパー

package adapter

// TimelineItemMapper DB ↔ ドメインエンティティ
type TimelineItemMapper struct{}

// ToEntity SQL 結果 → ドメイン TimelineItem
func (m *TimelineItemMapper) ToEntity(row *TimelineItemRow) (*domain.TimelineItem, error) {
    payload, err := domain.NewTimelinePayload(
        domain.TimelineItemType(row.ItemType),
        row.PayloadJSON,
    )
    if err != nil {
        return nil, err
    }

    return &domain.TimelineItem{
        ID:         row.ID,
        UserID:     row.UserID,
        OrgID:      row.OrgID,
        EventID:    row.EventID,
        ItemType:   domain.TimelineItemType(row.ItemType),
        Payload:    payload,
        OccurredAt: row.OccurredAt,
        Visibility: domain.Visibility(row.Visibility),
        Hidden:     row.Hidden,
        HiddenBy:   row.HiddenBy,
        HiddenAt:   row.HiddenAt,
        CreatedAt:  row.CreatedAt,
    }, nil
}

// ToPersistence ドメイン TimelineItem → DB 挿入用
func (m *TimelineItemMapper) ToPersistence(item *domain.TimelineItem) *TimelineItemRow {
    payloadJSON, _ := json.Marshal(item.Payload)
    return &TimelineItemRow{
        ID:          item.ID,
        UserID:      item.UserID,
        OrgID:       item.OrgID,
        EventID:     item.EventID,
        ItemType:    string(item.ItemType),
        PayloadJSON: payloadJSON,
        OccurredAt:  item.OccurredAt,
        Visibility:  string(item.Visibility),
        Hidden:      item.Hidden,
        HiddenBy:    item.HiddenBy,
        HiddenAt:    item.HiddenAt,
        CreatedAt:   item.CreatedAt,
    }
}

// QueueMessageMapper QueuePort JSON → CreateTimelineItemRequest
func QueueMessageToRequest(message []byte) (*CreateTimelineItemRequest, error) {
    var req CreateTimelineItemRequest
    err := json.Unmarshal(message, &req)
    return &req, err
}

6. フレームワーク&ドライバ層(インフラストラクチャ)

6.1 Webフレームワーク

  • フレームワーク: Gin v1.10
  • ポート: 8002
  • ベースパス: /api
  • ミドルウェア: CORS, Auth Token 検証, Request ID, ロギング, Panic Recovery

6.2 データベース (MySQL 8.0 / MariaDB 10.11、月次 RANGE パーティション)

MySQL 8.0 / MariaDB 10.11 互換スキーマ(PostgreSQL 固有の JSONB / TIMESTAMPTZ / BIGSERIAL / PARTITION OF / gen_random_uuid() は未使用。JSON 型・DATETIME(6)BIGINT AUTO_INCREMENTPARTITION BY RANGE を利用)。

-- timeline_items テーブル(base)
-- MariaDB RANGE パーティショニング: YEAR*100 + MONTH を式として使用
CREATE TABLE IF NOT EXISTS timeline_items (
    id CHAR(26) NOT NULL,                      -- ULID
    user_id CHAR(36),
    org_id CHAR(36),
    event_id CHAR(36),
    item_type VARCHAR(50) NOT NULL,
    payload JSON NOT NULL,
    occurred_at DATETIME(6) NOT NULL,
    visibility VARCHAR(20) NOT NULL DEFAULT 'PUBLIC',
    hidden BOOLEAN NOT NULL DEFAULT FALSE,
    hidden_by CHAR(36),
    hidden_at DATETIME(6) NULL,
    created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    PRIMARY KEY (id, created_at),              -- パーティションキーを PK に含める
    CONSTRAINT chk_item_type CHECK (item_type IN (
        'EVENT_CREATED', 'ALBUM_CREATED', 'MEDIA_ADDED',
        'FRIEND_JOINED', 'EVENT_INVITATION_ACCEPTED',
        'HIGHLIGHT_VIDEO_READY', 'MEMORY_SHARED'
    )),
    CONSTRAINT chk_visibility CHECK (visibility IN ('PRIVATE', 'FRIENDS', 'PUBLIC')),
    CONSTRAINT chk_user_or_org CHECK (
        (user_id IS NOT NULL AND org_id IS NULL) OR
        (user_id IS NULL AND org_id IS NOT NULL)
    )
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) (
    PARTITION p202604 VALUES LESS THAN (202605),
    PARTITION p202605 VALUES LESS THAN (202606),
    PARTITION p202606 VALUES LESS THAN (202607),
    PARTITION pmax    VALUES LESS THAN (MAXVALUE)
);

-- 外部キーは別テーブル(users / organizations / events)に対して
-- MariaDB のパーティションテーブルには制約があるため、アプリ層で整合性を担保する
-- (users/orgs/events 側から ON DELETE CASCADE 相当の論理削除を QueuePort イベントで実施)

-- インデックス
CREATE INDEX idx_timeline_items_user_id ON timeline_items(user_id, hidden, occurred_at DESC);
CREATE INDEX idx_timeline_items_org_id ON timeline_items(org_id, hidden, occurred_at DESC);
CREATE INDEX idx_timeline_items_event_id ON timeline_items(event_id, occurred_at DESC);
CREATE INDEX idx_timeline_items_occurred_at ON timeline_items(occurred_at DESC);
CREATE INDEX idx_timeline_items_visibility ON timeline_items(visibility);

-- timeline_item_read_status テーブル(最後に見た位置)
CREATE TABLE IF NOT EXISTS timeline_item_read_status (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,      -- BIGSERIAL の代替
    user_id CHAR(36) NOT NULL,
    last_read_item_id CHAR(26),
    last_read_at DATETIME(6) NULL,
    updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
        ON UPDATE CURRENT_TIMESTAMP(6),
    UNIQUE KEY uq_read_status_user (user_id),
    CONSTRAINT fk_read_status_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE INDEX idx_timeline_item_read_status_user_id ON timeline_item_read_status(user_id);

6.3 メッセージブローカー

  • 入力: QueuePort キューから複数サービスのイベント受信
  • Events Service: EventCreated, EventArchived, EventInvitationAccepted
  • Album Service: AlbumCreated, MediaAdded
  • Auth Service: UserCreated, UserJoinedOrg
  • Messaging Service: MemoryShared
  • 処理: 非同期 Consumer (background worker)
  • エラー: リトライ 3回 → DLQ

6.4 外部ライブラリ&SDK

ライブラリ 用途 バージョン
github.com/gin-gonic/gin Web フレームワーク v1.10
github.com/go-sql-driver/mysql MySQL / MariaDB ドライバ v1.8
github.com/hibiken/asynq Redis + BullMQ/asynq (Beta QueuePort) v0.24
github.com/oracle/oci-go-sdk/v65 OCI Queue SDK (Prod QueuePort) v65.0
github.com/redis/go-redis/v9 Redis ソートセット v9.3
github.com/oklog/ulid/v2 ULID 生成 v2.1
google.golang.org/grpc Permission Service v1.57
encoding/json JSON パース stdlib

6.5 依存性注入 (uber-go/fx code example)

package infra

import (
    "go.uber.org/fx"
    "github.com/gin-gonic/gin"
    _ "github.com/go-sql-driver/mysql"
    "database/sql"
)

// Module Timeline Service fx Module
func Module() fx.Option {
    return fx.Module("timeline-service",
        // インフラプロバイダ
        fx.Provide(
            provideMySQLDB,
            provideRedisClient,
            provideQueueAdapter, // QueuePort (Beta: Redis+BullMQ / Prod: OCI Queue)
            provideGinEngine,
        ),
        // リポジトリプロバイダ
        fx.Provide(
            func(db *sql.DB) adapter.TimelineItemRepository {
                return adapter.NewMySQLTimelineItemRepository(db)
            },
            func(redis *redis.Client) adapter.FeedCacheRepository {
                return adapter.NewRedisTimelineItemCache(redis)
            },
        ),
        // 外部サービスアダプタ
        fx.Provide(
            func(grpcConn *grpc.ClientConn) application.PermissionService {
                return adapter.NewPermissionServiceClient(grpcConn)
            },
            func(q application.QueuePort) application.QueueMessageConsumer {
                // Beta: RedisBullMQConsumerAdapter / Prod: OCIQueueConsumerAdapter を q から構築
                return adapter.NewQueueMessageConsumerAdapter(q)
            },
        ),
        // ユースケース
        fx.Provide(
            func(
                itemRepo adapter.TimelineItemRepository,
                feedCache adapter.FeedCacheRepository,
                permSvc application.PermissionService,
            ) application.CreateTimelineItemUseCase {
                return application.NewCreateTimelineItemUseCase(itemRepo, feedCache, permSvc)
            },
            func(
                itemRepo adapter.TimelineItemRepository,
                feedCache adapter.FeedCacheRepository,
                permSvc application.PermissionService,
            ) application.GetUserTimelineUseCase {
                return application.NewGetUserTimelineUseCase(itemRepo, feedCache, permSvc)
            },
            // その他...
        ),
        // ハンドラ登録
        fx.Invoke(registerHandlers),
    )
}

func provideMySQLDB(cfg *config.DatabaseConfig) (*sql.DB, error) {
    // go-sql-driver/mysql DSN (MySQL 8.x / MariaDB 10.6+ 互換)
    connStr := fmt.Sprintf(
        "%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4&tls=preferred",
        cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.Database,
    )
    return sql.Open("mysql", connStr)
}

// provideQueueAdapter は QueuePort の実装を選択する
func provideQueueAdapter(cfg *config.QueueConfig) (application.QueuePort, error) {
    switch cfg.Provider {
    case "redis-bullmq":
        return adapter.NewRedisBullMQAdapter(cfg.RedisAddr), nil
    case "oci-queue":
        return adapter.NewOCIQueueAdapter(cfg.OCIQueueID)
    default:
        return nil, fmt.Errorf("unknown queue provider: %s", cfg.Provider)
    }
}

func provideRedisClient(cfg *config.RedisConfig) *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr: cfg.Address,
    })
}

func registerHandlers(
    engine *gin.Engine,
    createItemUC application.CreateTimelineItemUseCase,
    getUserTimelineUC application.GetUserTimelineUseCase,
    hideItemUC application.HideTimelineItemUseCase,
) {
    api := engine.Group("/api")
    {
        timelines := api.Group("/timelines")
        {
            timelines.GET("/users/:id", func(c *gin.Context) {
                handler := adapter.NewGetUserTimelineHandler(getUserTimelineUC)
                handler.Handle(c)
            })
        }
        items := api.Group("/timeline-items")
        {
            items.DELETE("/:id", func(c *gin.Context) {
                handler := adapter.NewHideTimelineItemHandler(hideItemUC)
                handler.Handle(c)
            })
        }
    }
}

7. ディレクトリ構成

recerdo-timeline/
├── cmd/
│   ├── main.go                 # アプリケーション起動
│   └── consumer/
│       └── main.go             # Queue Consumer ワーカー
├── internal/
│   ├── domain/
│   │   ├── timeline_item.go    # TimelineItem エンティティ
│   │   ├── value_objects.go    # TimelineItemType, Visibility, FeedCursor
│   │   ├── feed_cursor.go      # ページネーション
│   │   └── events.go           # ドメインイベント
│   ├── application/
│   │   ├── dto.go              # DTO 定義
│   │   ├── ports.go            # インターフェース
│   │   ├── create_timeline_item.go
│   │   ├── get_user_timeline.go
│   │   ├── get_org_timeline.go
│   │   ├── hide_timeline_item.go
│   │   └── get_user_feed.go
│   ├── adapter/
│   │   ├── http/
│   │   │   ├── get_user_timeline_handler.go
│   │   │   ├── get_org_timeline_handler.go
│   │   │   ├── hide_timeline_item_handler.go
│   │   │   └── get_user_feed_handler.go
│   │   ├── persistence/
│   │   │   ├── MySQL_timeline_item_repo.go
│   │   │   └── redis_timeline_item_cache.go
│   │   ├── external/
│   │   │   ├── sqs_message_consumer.go
│   │   │   └── permission_service_client.go
│   │   ├── consumer/
│   │   │   └── queue_message_handler.go # Queue Consumer logic (Beta: Redis+BullMQ / Prod: OCI Queue)
│   │   ├── presenter.go
│   │   └── mapper.go
│   └── infra/
│       ├── config.go
│       ├── database.go
│       ├── redis.go
│       ├── sqs.go
│       ├── fx_module.go
│       └── migrations/
│           └── 001_create_timeline_items.sql
├── test/
│   ├── integration/
│   │   ├── create_timeline_item_test.go
│   │   └── get_user_timeline_test.go
│   └── unit/
│       ├── domain/
│       │   └── timeline_item_test.go
│       └── application/
│           └── create_timeline_item_usecase_test.go
├── go.mod
├── go.sum
├── Dockerfile
└── README.md

8. 依存性ルールと境界

8.1 許可される依存関係

レイヤー 依存可能な対象
フレームワーク&ドライバ層 すべて下位 Queue Consumer → UseCase → Domain
インターフェースアダプタ層 ユースケース以下 Handler → UseCase → Domain
ユースケース層 ドメイン層のみ GetUserTimeline → domain.TimelineItem
ドメイン層 なし 自己完結

8.2 境界の横断

  • ポート経由:ユースケース → リポジトリポート
  • DTO 経由:QueuePort メッセージ → DTO → ユースケース → ドメイン
  • イベント駆動:ドメインイベント → 他サービス(疎結合)

8.3 ルールの強制

  • コンパイル時:Go 型チェック
  • 実行時:linter (golangci-lint depguard)
  • レビュー時:コードレビュー

9. テスト戦略

9.1 テストピラミッド

テストタイプ 割合 対象 ツール
ユニットテスト 70% ドメイン、ユースケース(Mock) testing + testify
統合テスト 20% Handler + UseCase + Repo testcontainers-go
エンドツーエンド 10% 全フロー(QueuePort 含む) docker-compose, API テスト

9.2 テスト例 (Go test code)

package domain_test

import (
    "testing"
    "time"
    "github.com/stretchr/testify/assert"
    "timeline-svc/internal/domain"
)

func TestNewTimelineItem_Success(t *testing.T) {
    // Arrange
    userID := "user-123"
    itemType := domain.TimelineItemTypeEventCreated
    payload, _ := domain.NewTimelinePayload(
        itemType,
        map[string]interface{}{"event_id": "evt-456", "title": "Party"},
    )

    // Act
    item, err := domain.NewTimelineItem(
        &userID,
        nil, // no org
        nil, // no event
        itemType,
        payload,
        time.Now(),
        domain.VisibilityPublic,
    )

    // Assert
    assert.NoError(t, err)
    assert.Equal(t, &userID, item.UserID)
    assert.False(t, item.Hidden)
    assert.Len(t, item.DomainEvents(), 1)
}

func TestTimelineItem_IsVisibleTo(t *testing.T) {
    userID := "user-123"
    otherUserID := "user-999"
    payload, _ := domain.NewTimelinePayload(
        domain.TimelineItemTypeEventCreated,
        map[string]interface{}{},
    )

    tests := []struct {
        name        string
        visibility  domain.Visibility
        viewerID    string
        isConnected bool
        expected    bool
    }{
        {
            name:        "Private visible to owner",
            visibility:  domain.VisibilityPrivate,
            viewerID:    userID,
            isConnected: false,
            expected:    true,
        },
        {
            name:        "Private not visible to others",
            visibility:  domain.VisibilityPrivate,
            viewerID:    otherUserID,
            isConnected: false,
            expected:    false,
        },
        {
            name:        "Friends visible to connected",
            visibility:  domain.VisibilityFriends,
            viewerID:    otherUserID,
            isConnected: true,
            expected:    true,
        },
        {
            name:        "Public visible to all",
            visibility:  domain.VisibilityPublic,
            viewerID:    "anyone",
            isConnected: false,
            expected:    true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            item, _ := domain.NewTimelineItem(
                &userID, nil, nil,
                domain.TimelineItemTypeEventCreated,
                payload,
                time.Now(),
                tt.visibility,
            )

            result := item.IsVisibleTo(tt.viewerID, tt.isConnected)
            assert.Equal(t, tt.expected, result)
        })
    }
}

// 統合テスト
package application_test

import (
    "context"
    "testing"
    "github.com/stretchr/testify/assert"
    "timeline-svc/internal/application"
)

func TestCreateTimelineItemUseCase_Integration(t *testing.T) {
    ctx := context.Background()

    // testcontainers で MySQL セットアップ
    db, cleanup := setupTestDB(t)
    defer cleanup()

    itemRepo := adapter.NewMySQLTimelineItemRepository(db)
    cacheRepo := &mockFeedCacheRepository{}
    permSvc := &mockPermissionService{connected: true}

    uc := application.NewCreateTimelineItemUseCase(itemRepo, cacheRepo, permSvc)

    // Act
    resp, err := uc.Execute(ctx, &application.CreateTimelineItemRequest{
        EventType:  "EVENT_CREATED",
        UserID:     strPtr("user-123"),
        Payload:    map[string]interface{}{"event_id": "evt-456"},
        OccurredAt: time.Now(),
        Visibility: "PUBLIC",
    })

    // Assert
    assert.NoError(t, err)
    assert.NotEmpty(t, resp.ItemID)
    assert.Equal(t, 1, cacheRepo.invalidateCount)
}

// Mock 実装
type mockFeedCacheRepository struct {
    invalidateCount int
}

func (m *mockFeedCacheRepository) GetUserFeed(ctx context.Context, userID string, start, stop int) ([]string, error) {
    return []string{}, nil
}

func (m *mockFeedCacheRepository) InvalidateUserFeed(ctx context.Context, userID string) error {
    m.invalidateCount++
    return nil
}

func (m *mockFeedCacheRepository) InvalidateOrgFeed(ctx context.Context, orgID string) error {
    return nil
}

10. エラーハンドリング

10.1 ドメインエラー

package domain

var (
    ErrInvalidVisibility     = errors.New("invalid visibility")
    ErrInvalidItemType       = errors.New("invalid item type")
    ErrInvalidPayload        = errors.New("payload invalid for item type")
    ErrUserAndOrgBothSet     = errors.New("either user or org required, not both")
    ErrAlreadyHidden         = errors.New("item already hidden")
)

10.2 アプリケーションエラー

package application

var (
    ErrTimelineItemNotFound      = errors.New("timeline item not found")
    ErrUnauthorizedToHide        = errors.New("not authorized to hide item")
    ErrPersistenceFailed         = errors.New("failed to persist item")
    ErrInvalidMessage            = errors.New("invalid Queue message")
    ErrCursorInvalid             = errors.New("invalid cursor")
)

10.3 エラー変換 (HTTP mapping table)

エラー HTTP ステータス レスポンス
ErrTimelineItemNotFound 404 Not Found {"error": "not_found"}
ErrUnauthorizedToHide 403 Forbidden {"error": "unauthorized"}
ErrCursorInvalid 400 Bad Request {"error": "invalid_cursor"}
ErrPersistenceFailed 500 Internal Server Error {"error": "internal_error"}

11. 横断的関心事

11.1 ロギング

  • ライブラリ: go.uber.org/zap
  • レベル: DEBUG, INFO, WARN, ERROR
  • ログポイント: QueuePort メッセージ受信、ドメイン操作、DB アクセス、可視性チェック結果
  • フォーマット: JSON

11.2 認証・認可

  • 認証: JWT トークン
  • 認可: Permission Service 経由で接続状態・権限チェック
  • ポリシー: ユーザーは自身のアイテムのみ非表示化可能、Org Admin は任意アイテム非表示化可

11.3 バリデーション

  • 入力: HTTP request JSON スキーマバリデーション
  • ドメイン: TimelineItemType、Visibility、Payload 構造検証
  • QueuePort: メッセージスキーマバリデーション (Beta: Redis+BullMQ / Prod: OCI Queue)

11.4 キャッシング

  • : Redis Sorted Set(スコア=occurred_at.Unix())
  • キー: timeline:user:{userID}, timeline:org:{orgID}
  • TTL: 10分
  • 無効化: 新規アイテム作成時、アイテム非表示化時に明示削除

12. マイグレーション計画

12.1 現状

  • モノリシック内の activity log(単純なテーブル)
  • 時系列ソート・ページング未実装
  • キャッシング戦略なし

12.2 目標状態

  • 独立した Timeline Service
  • カーソルベースページング、月次パーティショニング
  • Redis Sorted Set キャッシュ
  • 複数サービスからのイベント統合

12.3 マイグレーション手順

フェーズ 実施内容 期間 依存関係
1. インフラ準備 MySQL パーティション作成、Redis setup 1週間 なし
2. コア実装 ドメイン層、ユースケース、リポジトリ 2週間 フェーズ1
3. HTTP + キャッシュ Handler、Presenter、Redis キャッシュ 1週間 フェーズ2
4. Queue Consumer QueuePort メッセージハンドラ、ワーカー実装 1週間 フェーズ3
5. テスト 統合・E2E テスト 1週間 フェーズ4
6. デプロイ・データマイグレーション 本番へのロールアウト、既存データ移行 1週間 フェーズ5

13. 未決事項と決定事項

項目 現在の決定 状態 備考
パーティショニング戦略 月次(RANGE by year, month) 決定済み 年4回の archive + テーブル削除
Soft Delete vs Hard Delete Soft Delete(hidden フラグ) 決定済み 監査証跡保持
カーソルエンコーディング Base64(lastSeenID:occurredAt) 決定済み API 外部公開用
友人フィード更新頻度 リアルタイム(QueuePort 駆動) 決定済み eventual consistency 許容
キャッシュの一貫性 Eventually Consistent 決定済み 10分 TTL で十分
グラフDB 導入 未検討 保留中 友人グラフが複雑化したら検討

14. 参考資料

  • Clean Architecture: Robert C. Martin, "Clean Architecture"
  • Event-Driven Architecture: Sam Newman, "Building Event-Driven Microservices"
  • MySQL 8.x Partitioning: https://dev.mysql.com/doc/refman/8.0/en/partitioning.html
  • MariaDB Partitioning: https://mariadb.com/kb/en/partitioning-overview/
  • OCI MySQL HeatWave: https://docs.oracle.com/en-us/iaas/mysql-database/
  • OCI Queue Service: https://docs.oracle.com/en-us/iaas/Content/queue/
  • asynq (Redis queue): https://github.com/hibiken/asynq
  • Redis Sorted Set: https://redis.io/docs/data-types/sorted-sets/
  • Cursor-Based Pagination: https://medium.com/swlh/pagination-in-graphql
  • Gin Framework: https://github.com/gin-gonic/gin

最終更新: 2026-04-19 ポリシー適用