コンテンツにスキップ

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

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

1. 概要

1.1 目的

Audit Service は、Recerdo アプリケーション全体における全システムアクション(ユーザ操作、管理者操作、サービス間通信)の不変監査証跡を提供します。GDPR コンプライアンス、セキュリティ監査、トラブルシューティングのための包括的なアクティビティログを維持し、過去のシステムイベントの復元不可能な記録を提供します。

1.2 ビジネスコンテキスト

  • 主要ユースケース: コンプライアンスレポート、管理者ダッシュボード、GDPR データ開示、セキュリティ監査
  • 規制要件: EU GDPR、データ保護法、監査証跡保持
  • イベント消費元: API Gateway(認証失敗、権限否定)、Auth Service(ログイン/ログアウト)、全マイクロサービス(データ変更)
  • イベント配信: QueuePort 非同期メッセージング(Beta: Redis+BullMQ/asynq、Prod: OCI Queue)
  • 消費者: 管理ダッシュボード、コンプライアンスレポート機能、ユーザ GDPR 開示
  • キー特性: APPEND-ONLY パターン、PII 匿名化、オブジェクトストレージ(Garage / OCI Object Storage)による長期アーカイバル

1.3 アーキテクチャ原則

  1. ドメイン駆動設計: ビジネスルール(不変性、監査証跡の完全性)をコア層に実装
  2. 依存性逆転: リポジトリ、キャッシュ、メッセージング参照はインターフェースで抽象化
  3. イベント駆動: QueuePort 経由のメッセージによる疎結合なイベント処理(SQS は使用しない)
  4. 責任の分離: エンティティ層はドメインルール、ユースケース層はビジネスロジック、適配層は外部 I/O
  5. テスト可能性: インターフェースベースで全レイヤをユニットテスト可能に

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

2.1 アーキテクチャ図

┌─────────────────────────────────────────────────────────────┐
│  External Systems (API, QueuePort, Object Storage, DB)       │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│    Framework & Drivers Layer (Infrastructure)               │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ HTTP Server | QueueConsumer | MySQL(MariaDB) | Redis | Garage/OCI OSS ││
│  └─────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│    Interface Adapters Layer (Controllers, Presenters)       │
│  ┌────────────┐ ┌────────────┐ ┌──────────────┐            │
│  │ HTTP       │ │ Queue      │ │ ObjStorage   │            │
│  │ Handlers   │ │ Consumers  │ │ Archival     │            │
│  └────────────┘ └────────────┘ └──────────────┘            │
│  ┌────────────┐ ┌────────────────────────────┐              │
│  │ Presenters │ │ Repository Implementations │              │
│  └────────────┘ └────────────────────────────┘              │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│    Application Layer (Use Cases)                            │
│  ┌──────────────────────────────────────────────────────────┐│
│  │ RecordAuditEntry │ QueryAuditLogs │ ExportAuditLogs     ││
│  │ ArchiveOldLogs   │ AnonymizeGDPRData                     ││
│  └──────────────────────────────────────────────────────────┘│
│  ┌──────────────────────────────────────────────────────────┐│
│  │ Ports: AuditRepository, ArchivalService, CacheService    ││
│  └──────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│    Entity Layer (Domain)                                    │
│  ┌────────────────────────────────────────────────────────┐ │
│  │ AuditEntry (immutable) | RetentionPolicy             │ │
│  │ ArchivalJob | GDPRAnonymization                       │ │
│  │ Value Objects: AuditAction, ResourceType, etc.       │ │
│  └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

2.2 依存性ルール

  • 内向き依存のみ: 外層は内層に依存、逆は不可
  • インターフェース境界: ユースケース層が Abstract Ports(リポジトリ、サービス)定義
  • DTO 翻訳: 適配層で外部格式を内部 Entity へ変換
  • エラー変換: ドメインエラーを HTTP レスポンスに変換(適配層)

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

3.1 ドメインモデル

モデル 説明 主要属性
AuditEntry 不変監査ログエントリ id (UUID char36), actor_id, actor_type, action, resource_type, resource_id, result, ip_address (VARCHAR(45)), user_agent, metadata (JSON), occurred_at (DATETIME(6))
RetentionPolicy イベント種別の保持期間定義 id, event_type, retention_days, created_at
ArchivalJob オブジェクトストレージ アーカイバル実行レコード id, batch_id, status (PENDING/RUNNING/SUCCESS/FAILED), archived_at, rows_count, s3_path
GDPRAnonymization GDPR 削除リクエスト追跡 id, user_id, request_at, anonymized_at, status (PENDING/COMPLETED/FAILED), error_message

3.2 値オブジェクト

値オブジェクト 許可値 バリデーション
AuditAction READ, WRITE, DELETE, LOGIN, LOGOUT, PERMISSION_CHECK, ADMIN_ACTION 定義済み定数のみ
ResourceType USER, ORG, MEDIA, ALBUM, EVENT, MESSAGE, PAYMENT, ROLE 定義済み定数のみ
ActorType USER, SERVICE, SYSTEM 定義済み定数のみ
AuditResult ALLOWED, DENIED, ERROR 定義済み定数のみ
IPAddress IPv4/IPv6 文字列 有効な IP アドレス形式

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

  • 不変性: AuditEntry は作成後、更新・削除禁止(APPEND-ONLY)
  • 完全な行為記録: 全データ変更操作は actor_id と occurred_at を含む必須エントリ
  • アーカイバル: created_at が 2 年以上前のエントリはオブジェクトストレージ (Garage / OCI OSS) へアーカイブ必須
  • GDPR 削除: user_id 削除リクエストは actor_id, email などの PII を HASH 値で置換(構造保持)
  • イベント分類: action と resource_type の組み合わせで監査重要度決定
  • タイムスタンプ正確性: occurred_at は UTC タイムゾーンで記録

3.4 ドメインイベント

イベント名 トリガ ペイロード
AuditEntryRecorded RecordAuditEntry 実行完了 audit_entry_id, actor_id, action, resource_id, occurred_at
AuditLogsArchived ArchiveOldLogs 完了 batch_id, rows_count, s3_path, archived_date
GDPRAnonymizationCompleted AnonymizeGDPRData 完了 user_id, anonymized_entry_count, completed_at
ArchivalJobFailed オブジェクトストレージ アーカイバル失敗 batch_id, error_reason, retry_count

3.5 エンティティ定義

// ドメイン層: エンティティ定義

// AuditEntry: 不変監査ログエントリ
type AuditEntry struct {
    ID           uuid.UUID
    ActorID      string         // ユーザ/サービス ID
    ActorType    ActorType      // USER, SERVICE, SYSTEM
    Action       AuditAction    // READ, WRITE, DELETE, ...
    ResourceType ResourceType   // USER, ORG, MEDIA, ...
    ResourceID   string         // 対象リソース ID
    Result       AuditResult    // ALLOWED, DENIED, ERROR
    IPAddress    string         // リクエスト元 IP
    UserAgent    string         // HTTP User-Agent
    Metadata     map[string]interface{} // 追加コンテキスト JSON
    OccurredAt   time.Time      // UTC タイムスタンプ
    CreatedAt    time.Time      // レコード作成時刻
}

// IsImmutable: エンティティ変更禁止検査
func (ae *AuditEntry) IsImmutable() bool {
    return ae.ID != uuid.Nil && !ae.CreatedAt.IsZero()
}

// RetentionPolicy: 保持期間ポリシー
type RetentionPolicy struct {
    ID           uuid.UUID
    EventType    string    // "login", "data_change", "admin_action"
    RetentionDays int      // 何日保持するか
    CreatedAt    time.Time
}

// ArchivalJob: アーカイバル実行ジョブ
type ArchivalJob struct {
    ID                uuid.UUID
    BatchID           string           // バッチ識別子
    Status            ArchivalStatus   // PENDING, RUNNING, SUCCESS, FAILED
    RowsCount         int
    ArchivePath       string           // 互換: オブジェクトストレージ上の論理パス(bucket/key)
    ObjectStoragePath string           // Beta: Garage 上のフルパス / Prod: OCI Object Storage 上のフルパス
    ArchivedAt        *time.Time
    CreatedAt         time.Time
    UpdatedAt         time.Time
}

// GDPRAnonymization: GDPR 削除追跡
type GDPRAnonymization struct {
    ID             uuid.UUID
    UserID         string       // 削除対象ユーザ
    RequestedAt    time.Time
    AnonymizedAt   *time.Time
    Status         string       // PENDING, COMPLETED, FAILED
    ErrorMessage   string
}

// Value Objects
type AuditAction string

const (
    ActionRead            AuditAction = "READ"
    ActionWrite           AuditAction = "WRITE"
    ActionDelete          AuditAction = "DELETE"
    ActionLogin           AuditAction = "LOGIN"
    ActionLogout          AuditAction = "LOGOUT"
    ActionPermissionCheck AuditAction = "PERMISSION_CHECK"
    ActionAdminAction     AuditAction = "ADMIN_ACTION"
)

type ResourceType string

const (
    ResourceUser    ResourceType = "USER"
    ResourceOrg     ResourceType = "ORG"
    ResourceMedia   ResourceType = "MEDIA"
    ResourceAlbum   ResourceType = "ALBUM"
    ResourceEvent   ResourceType = "EVENT"
    ResourceMessage ResourceType = "MESSAGE"
)

type ActorType string

const (
    ActorUser    ActorType = "USER"
    ActorService ActorType = "SERVICE"
    ActorSystem  ActorType = "SYSTEM"
)

type AuditResult string

const (
    ResultAllowed AuditResult = "ALLOWED"
    ResultDenied  AuditResult = "DENIED"
    ResultError   AuditResult = "ERROR"
)

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

4.1 ユースケース一覧

ユースケース アクタ 説明 主要入力
RecordAuditEntry System (QueuePort) Queue イベントから監査エントリを記録 AuditEvent (Queue payload JSON)
QueryAuditLogs Admin 期間・条件で監査ログをクエリ date_from, date_to, actor_id, action, resource_id
ExportAuditLogs Admin CSV 形式で監査ログをエクスポート filters (date_from, date_to, ...)
ArchiveOldLogs System (Scheduler) 2 年以上前のログをオブジェクトストレージへ移動 なし(定期実行)
AnonymizeGDPRData System (Event) GDPR 削除リクエスト → PII 匿名化 user_id, gdpr_request_id

4.2 ユースケース詳細

RecordAuditEntry (メインユースケース)

アクタ: System (QueuePort Consumer — Beta: Redis+BullMQ, Prod: OCI Queue)

前提条件: - QueuePort からメッセージ受信(JSON フォーマット) - メッセージスキーマ有効(actor_id, action, resource_type, resource_id 必須)

フロー: 1. QueuePort Consumer がメッセージを受信 2. JSON デシリアライズ → DTO 変換 3. バリデーション(null チェック、enum 値確認) 4. Domain Entity (AuditEntry) 構築 5. Repository.Save() で MySQL (MariaDB 互換) に INSERT 6. 挿入成功時、キャッシュをインバリデート(Redis) 7. DomainEvent (AuditEntryRecorded) 発行

事後条件: - AuditEntry が immutable で MySQL に保存 - Redis キャッシュが新規エントリで更新 - ドメインイベント発行完了

エラーケース: - スキーマバリデーション失敗 → DLQ へ移動 - DB コネクション失敗 → リトライ(QueuePort DLQ — Beta: asynq retry, Prod: OCI Queue DLQ) - JSON パース失敗 → ログ出力、スキップ

4.3 入出力DTO

// ユースケース層: 入出力DTO

// RecordAuditEntryInput
type RecordAuditEntryInput struct {
    ActorID      string `json:"actor_id" validate:"required"`
    ActorType    string `json:"actor_type" validate:"required,oneof=USER SERVICE SYSTEM"`
    Action       string `json:"action" validate:"required"`
    ResourceType string `json:"resource_type" validate:"required"`
    ResourceID   string `json:"resource_id" validate:"required"`
    Result       string `json:"result" validate:"required,oneof=ALLOWED DENIED ERROR"`
    IPAddress    string `json:"ip_address" validate:"required,ip"`
    UserAgent    string `json:"user_agent"`
    Metadata     map[string]interface{} `json:"metadata"`
    OccurredAt   string `json:"occurred_at" validate:"required,datetime=2006-01-02T15:04:05Z"`
}

// RecordAuditEntryOutput
type RecordAuditEntryOutput struct {
    AuditEntryID string `json:"audit_entry_id"`
    RecordedAt   string `json:"recorded_at"`
}

// QueryAuditLogsInput
type QueryAuditLogsInput struct {
    DateFrom   string `query:"date_from" validate:"datetime=2006-01-02T15:04:05Z"`
    DateTo     string `query:"date_to" validate:"datetime=2006-01-02T15:04:05Z"`
    ActorID    string `query:"actor_id"`
    Action     string `query:"action"`
    ResourceID string `query:"resource_id"`
    Limit      int    `query:"limit" validate:"max=1000"`
    Offset     int    `query:"offset"`
}

// AuditLogDTO
type AuditLogDTO struct {
    ID           string                 `json:"id"`
    ActorID      string                 `json:"actor_id"`
    ActorType    string                 `json:"actor_type"`
    Action       string                 `json:"action"`
    ResourceType string                 `json:"resource_type"`
    ResourceID   string                 `json:"resource_id"`
    Result       string                 `json:"result"`
    IPAddress    string                 `json:"ip_address"`
    Metadata     map[string]interface{} `json:"metadata"`
    OccurredAt   string                 `json:"occurred_at"`
}

// QueryAuditLogsOutput
type QueryAuditLogsOutput struct {
    Logs       []AuditLogDTO `json:"logs"`
    Total      int           `json:"total"`
    Limit      int           `json:"limit"`
    Offset     int           `json:"offset"`
}

// ExportAuditLogsInput
type ExportAuditLogsInput struct {
    DateFrom   string `json:"date_from" validate:"datetime=2006-01-02T15:04:05Z"`
    DateTo     string `json:"date_to" validate:"datetime=2006-01-02T15:04:05Z"`
    ActorID    string `json:"actor_id"`
    Format     string `json:"format" validate:"oneof=csv json"`
}

// ExportAuditLogsOutput
type ExportAuditLogsOutput struct {
    DownloadURL string `json:"download_url"`
    RowsCount   int    `json:"rows_count"`
}

// AnonymizeGDPRDataInput
type AnonymizeGDPRDataInput struct {
    UserID          string `validate:"required,uuid"`
    GDPRRequestID   string `validate:"required,uuid"`
}

// AnonymizeGDPRDataOutput
type AnonymizeGDPRDataOutput struct {
    AnonymizedCount int    `json:"anonymized_count"`
    Status          string `json:"status"`
    CompletedAt     string `json:"completed_at"`
}

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

// ユースケース層: ポートインターフェース

// AuditRepository: 監査ログ永続化ポート
type AuditRepository interface {
    // Save: 新規 AuditEntry を保存(不変)
    Save(ctx context.Context, entry *domain.AuditEntry) error

    // FindByID: ID で監査ログを取得
    FindByID(ctx context.Context, id uuid.UUID) (*domain.AuditEntry, error)

    // Query: 条件で監査ログをクエリ
    Query(ctx context.Context, filter *domain.AuditFilter) ([]*domain.AuditEntry, int, error)

    // CountByDateRange: 日付範囲のエントリ件数
    CountByDateRange(ctx context.Context, from, to time.Time) (int, error)

    // FindOlderThan: 指定日付より古いエントリを取得(アーカイバル用)
    FindOlderThan(ctx context.Context, cutoffDate time.Time, limit int) ([]*domain.AuditEntry, error)

    // DeleteByID: 物理削除(GDPR のみ、エントリ自体は保持、PII 匿名化)
    AnonymizePII(ctx context.Context, userID string) error
}

// RetentionPolicyRepository: 保持ポリシーリポジトリ
type RetentionPolicyRepository interface {
    FindAll(ctx context.Context) ([]*domain.RetentionPolicy, error)
    FindByEventType(ctx context.Context, eventType string) (*domain.RetentionPolicy, error)
    Save(ctx context.Context, policy *domain.RetentionPolicy) error
}

// ArchivalJobRepository: アーカイバルジョブリポジトリ
type ArchivalJobRepository interface {
    Save(ctx context.Context, job *domain.ArchivalJob) error
    Update(ctx context.Context, job *domain.ArchivalJob) error
    FindByID(ctx context.Context, id uuid.UUID) (*domain.ArchivalJob, error)
    FindByStatus(ctx context.Context, status string) ([]*domain.ArchivalJob, error)
}

// GDPRAnonymizationRepository: GDPR 追跡リポジトリ
type GDPRAnonymizationRepository interface {
    Save(ctx context.Context, req *domain.GDPRAnonymization) error
    Update(ctx context.Context, req *domain.GDPRAnonymization) error
    FindByID(ctx context.Context, id uuid.UUID) (*domain.GDPRAnonymization, error)
}

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

// ArchivalService: オブジェクトストレージ アーカイバルポート
// 実装:
//   - Beta: GarageObjectStorageArchivalAdapter (S3 互換 API)
//   - Prod: OCIObjectStorageArchivalAdapter
type ArchivalService interface {
    // ArchiveEntries: エントリリストをオブジェクトストレージへアーカイブ
    // archivePath はバケット内の論理パス(例: audits/2026/01/batch-xxxx.parquet)
    ArchiveEntries(ctx context.Context, entries []*domain.AuditEntry, archivePath string) (objectStoragePath string, err error)

    // DeleteFromDB: アーカイブ済みエントリを DB から削除
    DeleteFromDB(ctx context.Context, entryIDs []uuid.UUID) error
}

// CacheService: キャッシュ(Redis)ポート
type CacheService interface {
    Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error
    Get(ctx context.Context, key string) (interface{}, error)
    InvalidatePattern(ctx context.Context, pattern string) error
}

// NotificationService: 通知ポート(GDPR 完了通知)
type NotificationService interface {
    SendGDPRCompletionNotification(ctx context.Context, userID string, count int) error
}

// EventPublisher: ドメインイベント発行ポート
type EventPublisher interface {
    PublishAuditEntryRecorded(ctx context.Context, event *domain.AuditEntryRecordedEvent) error
    PublishArchivalCompleted(ctx context.Context, event *domain.ArchivalCompletedEvent) error
}

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

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

ハンドラ HTTP メソッド パス 説明
QueueEventHandler - (async) QueuePort メッセージ受信 → RecordAuditEntry 実行
GetAuditLogsHandler GET /api/audits 監査ログクエリ(管理者)
ExportAuditLogsHandler POST /api/audits/export CSV エクスポート(管理者)
ArchiveLogsScheduler - (cron) 定期アーカイバル実行(24h ごと)
GDPRAnonymizeScheduler - (cron) GDPR リクエスト処理(1h ごと)

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

// インターフェース適配層: プレゼンター

type AuditPresenter struct{}

// PresentQueryAuditLogs: QueryAuditLogsOutput → HTTP 200 JSON
func (p *AuditPresenter) PresentQueryAuditLogs(output *application.QueryAuditLogsOutput) *http.Response {
    return &http.Response{
        StatusCode: 200,
        Body:       json.Marshal(output),
    }
}

// PresentExportAuditLogs: ExportAuditLogsOutput → HTTP 200 JSON
func (p *AuditPresenter) PresentExportAuditLogs(output *application.ExportAuditLogsOutput) *http.Response {
    return &http.Response{
        StatusCode: 200,
        Body:       json.Marshal(output),
    }
}

// PresentError: Error → HTTP エラーレスポンス
func (p *AuditPresenter) PresentError(err error) *http.Response {
    statusCode, message := p.mapErrorToHTTP(err)
    return &http.Response{
        StatusCode: statusCode,
        Body:       json.Marshal(map[string]string{"error": message}),
    }
}

func (p *AuditPresenter) mapErrorToHTTP(err error) (int, string) {
    switch err.(type) {
    case *domain.ValidationError:
        return 400, "Invalid input"
    case *domain.NotFoundError:
        return 404, "Resource not found"
    case *domain.UnauthorizedError:
        return 403, "Unauthorized"
    default:
        return 500, "Internal server error"
    }
}

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

アダプタ 技術 説明
MySQLAuditRepository go-sql-driver/mysql MySQL/MariaDB 実装、月単位パーティション対応(MariaDB RANGE パーティション)
RedisArchivalJobRepository go-redis/v9 アーカイバルジョブ状態キャッシング
ObjectStorageArchivalAdapter Beta: AWS SDK v2 (Garage互換) / Prod: oci-go-sdk オブジェクトストレージ バケットへのアーカイブ実装
MySQLRetentionPolicyRepository go-sql-driver/mysql 保持ポリシー永続化

5.4 外部サービスアダプタ

アダプタ 実装 説明
ObjectStorageArchivalServiceImpl Garage/OCI OSS SDK オブジェクトストレージ バケットへの Parquet ファイルアップロード
RedisCache go-redis/v9 Redis キャッシュ実装
QueueEventPublisher QueuePort QueuePort (Beta: Redis/asynq, Prod: OCI Queue) へのイベント発行
PostfixSMTPNotificationAdapter net/smtp GDPR 完了メール送信 (Postfix+Dovecot+Rspamd / CoreServerV2)

5.5 マッパー

// マッパー: DTO ↔ Entity 相互変換

type AuditMapper struct{}

// MapQueueEventToInput: QueuePort メッセージ (JSON) → RecordAuditEntryInput
func (m *AuditMapper) MapQueueEventToInput(event *queue.Message) (*application.RecordAuditEntryInput, error) {
    var input application.RecordAuditEntryInput
    err := json.Unmarshal([]byte(event.Body), &input)
    if err != nil {
        return nil, fmt.Errorf("failed to unmarshal queue message: %w", err)
    }
    return &input, nil
}

// MapInputToEntity: RecordAuditEntryInput → AuditEntry
func (m *AuditMapper) MapInputToEntity(input *application.RecordAuditEntryInput) (*domain.AuditEntry, error) {
    occurredAt, err := time.Parse(time.RFC3339, input.OccurredAt)
    if err != nil {
        return nil, fmt.Errorf("invalid occurred_at: %w", err)
    }

    return &domain.AuditEntry{
        ID:           uuid.New(),
        ActorID:      input.ActorID,
        ActorType:    domain.ActorType(input.ActorType),
        Action:       domain.AuditAction(input.Action),
        ResourceType: domain.ResourceType(input.ResourceType),
        ResourceID:   input.ResourceID,
        Result:       domain.AuditResult(input.Result),
        IPAddress:    input.IPAddress,
        UserAgent:    input.UserAgent,
        Metadata:     input.Metadata,
        OccurredAt:   occurredAt,
        CreatedAt:    time.Now(),
    }, nil
}

// MapEntityToDTO: AuditEntry → AuditLogDTO
func (m *AuditMapper) MapEntityToDTO(entry *domain.AuditEntry) *application.AuditLogDTO {
    return &application.AuditLogDTO{
        ID:           entry.ID.String(),
        ActorID:      entry.ActorID,
        ActorType:    string(entry.ActorType),
        Action:       string(entry.Action),
        ResourceType: string(entry.ResourceType),
        ResourceID:   entry.ResourceID,
        Result:       string(entry.Result),
        IPAddress:    entry.IPAddress,
        Metadata:     entry.Metadata,
        OccurredAt:   entry.OccurredAt.Format(time.RFC3339),
    }
}

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

6.1 Webフレームワーク

  • Go 1.22+ + Echo v4 (HTTP サーバ、RESTful API)
  • Gin Gonic 代替案(ルーティング、ミドルウェア)
  • grpc-go (内部サービス間 RPC)

6.2 データベース

互換性方針: Beta は MySQL 8.x、Prod は OCI MySQL HeatWave。いずれも MariaDB 10.6+ 互換の SQL に限定。 PostgreSQL 固有記法(UUID 型、INETJSONBTIMESTAMPTZPARTITION OFUSING GINgen_random_uuid())は使用せず、 CHAR(36)(UUID 文字列)、VARCHAR(45)(IPv6 最大長)、JSONDATETIME(6)PARTITION BY RANGE (YEAR(...)*100 + MONTH(...))、 JSON カラム用の仮想カラム + インデックス、UUID() で代替する。

-- MySQL 8.x / MariaDB 10.6+ スキーマ(月単位 RANGE パーティション)

-- 監査エントリテーブル(親テーブル)
CREATE TABLE IF NOT EXISTS audit_entries (
    id CHAR(36) NOT NULL,
    actor_id VARCHAR(255) NOT NULL,
    actor_type VARCHAR(50) NOT NULL,
    action VARCHAR(50) NOT NULL,
    resource_type VARCHAR(50) NOT NULL,
    resource_id VARCHAR(255) NOT NULL,
    result VARCHAR(50) NOT NULL,
    ip_address VARCHAR(45) NOT NULL,  -- IPv6 最大長
    user_agent TEXT,
    metadata JSON,
    occurred_at DATETIME(6) NOT NULL,
    created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    -- CHECK 制約は MySQL 8 / MariaDB 10.2+ で有効
    CHECK (actor_type IN ('USER', 'SERVICE', 'SYSTEM')),
    CHECK (action IN ('READ', 'WRITE', 'DELETE', 'LOGIN', 'LOGOUT', 'PERMISSION_CHECK', 'ADMIN_ACTION')),
    CHECK (resource_type IN ('USER', 'ORG', 'MEDIA', 'ALBUM', 'EVENT', 'MESSAGE')),
    CHECK (result IN ('ALLOWED', 'DENIED', 'ERROR')),
    PRIMARY KEY (id, created_at)  -- パーティションキーを含める必要あり
)
PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) (
    PARTITION p2026_01 VALUES LESS THAN (202602),
    PARTITION p2026_02 VALUES LESS THAN (202603),
    PARTITION p2026_03 VALUES LESS THAN (202604),
    PARTITION pmax     VALUES LESS THAN MAXVALUE
);

-- インデックス
CREATE INDEX idx_audit_actor_id ON audit_entries (actor_id, created_at DESC);
CREATE INDEX idx_audit_resource ON audit_entries (resource_type, resource_id, created_at DESC);
CREATE INDEX idx_audit_action   ON audit_entries (action, created_at DESC);
CREATE INDEX idx_audit_occurred_at ON audit_entries (occurred_at DESC);
-- JSON 検索は仮想カラム + 通常インデックスで代替(USING GIN は非サポート)
ALTER TABLE audit_entries
    ADD COLUMN metadata_event_type VARCHAR(100)
        GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.event_type'))) VIRTUAL,
    ADD INDEX idx_audit_meta_event_type (metadata_event_type);

-- 保持ポリシーテーブル
CREATE TABLE IF NOT EXISTS retention_policies (
    id CHAR(36) PRIMARY KEY NOT NULL,
    event_type VARCHAR(100) UNIQUE NOT NULL,
    retention_days INT NOT NULL CHECK (retention_days > 0),
    created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
);

-- UUID() は MySQL/MariaDB 共通関数
INSERT INTO retention_policies (id, event_type, retention_days) VALUES
    (UUID(), 'LOGIN', 365),
    (UUID(), 'DATA_CHANGE', 2555),  -- 7 years
    (UUID(), 'ADMIN_ACTION', 2555),
    (UUID(), 'PERMISSION_CHECK', 90);

-- アーカイバルジョブテーブル
CREATE TABLE IF NOT EXISTS archival_jobs (
    id CHAR(36) PRIMARY KEY NOT NULL,
    batch_id VARCHAR(100) UNIQUE NOT NULL,
    status VARCHAR(50) NOT NULL,
    rows_count INT DEFAULT 0,
    archive_path TEXT,          -- 論理パス(bucket/key)
    object_storage_path TEXT,   -- Beta: Garage 上のフル URI / Prod: OCI Object Storage 上のフル URI
    archived_at DATETIME(6),
    created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    CHECK (status IN ('PENDING', 'RUNNING', 'SUCCESS', 'FAILED'))
);

CREATE INDEX idx_archival_status   ON archival_jobs (status, created_at DESC);
CREATE INDEX idx_archival_batch_id ON archival_jobs (batch_id);

-- GDPR 匿名化テーブル
CREATE TABLE IF NOT EXISTS gdpr_anonymizations (
    id CHAR(36) PRIMARY KEY NOT NULL,
    user_id VARCHAR(255) NOT NULL,
    requested_at DATETIME(6) NOT NULL,
    anonymized_at DATETIME(6),
    status VARCHAR(50) NOT NULL,
    error_message TEXT,
    created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    CHECK (status IN ('PENDING', 'COMPLETED', 'FAILED'))
);

CREATE INDEX idx_gdpr_user_id ON gdpr_anonymizations (user_id);
CREATE INDEX idx_gdpr_status  ON gdpr_anonymizations (status, created_at DESC);

6.3 メッセージブローカー

  • QueuePort: イベント非同期処理(AWS SQS は使用しない)
  • Beta: RedisBullMQAdapter(Redis + asynq / BullMQ、self-hosted)
  • Prod: OCIQueueAdapter(OCI Queue Service)
  • キュー名: recerdo-audit-events-queue
  • DLQ: recerdo-audit-events-dlq
  • メッセージ保有期間: 1 日
  • 可視性タイムアウト: 5 分

6.4 外部ライブラリ&SDK

ライブラリ 用途 バージョン
go-sql-driver/mysql MySQL/MariaDB 互換ドライバ v1.8+
go-redis/v9 Redis キャッシュ v9.3+
echo/v4 HTTP フレームワーク v4.10+
uber-go/fx DI コンテナ v1.20+
go-playground/validator バリデーション v10.16+
google/uuid UUID 生成 v1.5+

6.5 依存性注入

// インフラストラクチャ層: DI 設定 (uber-go/fx)

package infrastructure

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

// FX モジュール: 依存関係の登録
var AuditModule = fx.Module("audit",
    fx.Provide(
        // インフラストラクチャ提供者
        provideMySQLDB,                   // MySQL 8.x / MariaDB 互換
        provideRedis,
        provideObjectStorageArchiver,     // Beta: Garage / Prod: OCI OSS
        provideQueueAdapter,              // Beta: Redis+BullMQ / Prod: OCI Queue

        // リポジトリ実装
        repository.NewMySQLAuditRepository,
        repository.NewMySQLRetentionPolicyRepository,
        repository.NewMySQLArchivalJobRepository,

        // ユースケース
        usecase.NewRecordAuditEntryUsecase,
        usecase.NewQueryAuditLogsUsecase,
        usecase.NewArchiveOldLogsUsecase,
        usecase.NewAnonymizeGDPRDataUsecase,

        // ハンドラ
        handler.NewQueueEventHandler,     // QueuePort 経由でイベント消費
        handler.NewGetAuditLogsHandler,
        handler.NewExportAuditLogsHandler,

        // プレゼンター
        handler.NewAuditPresenter,
    ),
)

// provideMySQLDB: MySQL/MariaDB コネクションプール
func provideMySQLDB(cfg *config.Config) (*sql.DB, error) {
    db, err := sql.Open("mysql", cfg.DatabaseDSN)
    if err != nil {
        return nil, err
    }
    db.SetMaxOpenConns(cfg.DBMaxOpenConns)
    return db, nil
}

// provideRedis: Redis クライアント(Beta: self-hosted / Prod: OCI Cache with Redis)
func provideRedis(cfg *config.Config) *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr: cfg.RedisAddr,
    })
}

// provideObjectStorageArchiver: ArchivalService のアダプタ選択
//   storage.provider = "garage"   → GarageObjectStorageArchivalAdapter (S3互換APIクライアント)
//   storage.provider = "oci-oss"  → OCIObjectStorageArchivalAdapter
func provideObjectStorageArchiver(cfg *config.Config) application.ArchivalService {
    switch cfg.StorageProvider {
    case "garage":
        return adapter.NewGarageObjectStorageArchivalAdapter(cfg.StorageEndpoint, cfg.StorageBucket, cfg.StorageAccessKey, cfg.StorageSecretKey)
    case "oci-oss":
        return adapter.NewOCIObjectStorageArchivalAdapter(cfg.OCINamespace, cfg.OCIBucket, cfg.OCIRegion)
    default:
        panic("unsupported storage.provider: " + cfg.StorageProvider)
    }
}

// provideQueueAdapter: QueuePort アダプタ選択
//   queue.provider = "redis-bullmq" → Beta
//   queue.provider = "oci-queue"    → Prod
func provideQueueAdapter(cfg *config.Config, redis *redis.Client) application.QueuePort {
    switch cfg.QueueProvider {
    case "redis-bullmq":
        return adapter.NewRedisBullMQAdapter(redis)
    case "oci-queue":
        return adapter.NewOCIQueueAdapter(cfg.OCIQueueOCID, cfg.OCIRegion)
    default:
        panic("unsupported queue.provider: " + cfg.QueueProvider)
    }
}

// main.go での使用
func main() {
    app := fx.New(
        AuditModule,
        fx.Provide(config.LoadConfig),
        fx.Invoke(startServer),
    )
    app.Run()
}

func startServer(
    handler *handler.GetAuditLogsHandler,
    queueHandler *handler.QueueEventHandler,
) {
    e := echo.New()
    e.GET("/api/audits", handler.Handle)

    go queueHandler.StartConsumer()

    e.Start(":8080")
}

7. ディレクトリ構成

recerdo-audit/
├── cmd/
│   ├── main.go                 # エントリポイント
│   └── migrations/             # DB マイグレーション
│       ├── 001_create_audit_tables.sql
│       └── 002_create_partitions.sql
├── domain/
│   ├── audit_entry.go          # エンティティ定義
│   ├── retention_policy.go
│   ├── archival_job.go
│   ├── gdpr_anonymization.go
│   ├── value_objects.go        # 値オブジェクト
│   ├── errors.go               # ドメインエラー
│   └── events.go               # ドメインイベント定義
├── application/
│   ├── usecase/
│   │   ├── record_audit_entry.go
│   │   ├── query_audit_logs.go
│   │   ├── export_audit_logs.go
│   │   ├── archive_old_logs.go
│   │   └── anonymize_gdpr_data.go
│   ├── port/
│   │   ├── audit_repository.go
│   │   ├── archival_service.go
│   │   └── cache_service.go
│   ├── dto/
│   │   ├── record_audit_entry_dto.go
│   │   ├── query_audit_logs_dto.go
│   │   └── export_audit_logs_dto.go
│   └── mapper/
│       └── audit_mapper.go
├── adapter/
│   ├── handler/
│   │   ├── sqs_event_handler.go
│   │   ├── get_audit_logs_handler.go
│   │   ├── export_audit_logs_handler.go
│   │   └── presenter.go
│   ├── repository/
│   │   ├── MySQL_audit_repository.go
│   │   ├── MySQL_retention_policy_repository.go
│   │   └── MySQL_archival_job_repository.go
│   └── external/
│       ├── s3_archival_adapter.go
│       ├── redis_cache_adapter.go
│       ├── sqs_event_publisher.go
│       └── email_notification_adapter.go
├── infrastructure/
│   ├── config/
│   │   └── config.go
│   ├── database/
│   │   ├── connection.go
│   │   └── migrations.go
│   ├── cache/
│   │   └── redis.go
│   └── di/
│       └── module.go            # DI 初期化 (fx)
├── test/
│   ├── domain/
│   │   └── audit_entry_test.go
│   ├── application/
│   │   └── record_audit_entry_usecase_test.go
│   ├── adapter/
│   │   └── MySQL_audit_repository_test.go
│   ├── integration/
│   │   └── sqs_to_MySQL_test.go
│   └── fixtures/
│       ├── audit_entries.json
│       └── sample_events.json
├── go.mod
├── go.sum
├── Dockerfile
├── docker-compose.test.yml
├── README.md
└── .env.example

8. 依存性ルールと境界

8.1 許可される依存関係

From Layer To Layer 許可 説明
Framework Adapter ハンドラ実装、リポジトリ実装など
Adapter Application ユースケース呼び出し、DTO 変換
Application Domain エンティティ、値オブジェクト使用
Domain (外部) フレームワーク、ライブラリ非参照
Application Framework DI コンテナのみ許可(初期化時)
Adapter Adapter 各アダプタは独立、ポート経由で通信

8.2 境界の横断

  1. 入口 (Handler → UseCase):
  2. HTTP ハンドラが HTTP リクエスト → DTO に変換
  3. ユースケースインスタンス呼び出し

  4. 出口 (UseCase → Repository):

  5. ユースケースが Abstract Repository ポート参照
  6. 実装は DI コンテナで注入

  7. エラー処理:

  8. ドメインエラー → Application エラー → HTTP 400/403/500 に変換

8.3 ルールの強制

  • コンパイル時: Go 内部パッケージ (internal/domain, internal/application) でアクセス制限
  • 実行時: ポート (インターフェース) 経由で外部参照、実装クラスは非公開
  • テスト: Mock インターフェース実装で境界検証

9. テスト戦略

9.1 テストピラミッド

レベル カウント 説明 ツール
ユニット 40% ドメインモデル、値オブジェクト testing, testify/assert
統合 35% ユースケース + Mock リポジトリ testing, testify/mock
E2E 25% QueuePort → DB → キャッシュ全フロー docker-compose, testcontainers

9.2 テスト例

// domain/audit_entry_test.go: ドメインテスト
package domain_test

import (
    "testing"
    "time"
    "github.com/stretchr/testify/assert"
    "github.com/google/uuid"
    "recuerdo/audit/domain"
)

func TestAuditEntryImmutability(t *testing.T) {
    entry := &domain.AuditEntry{
        ID:        uuid.New(),
        ActorID:   "user-123",
        Action:    domain.ActionWrite,
        CreatedAt: time.Now(),
    }

    // エンティティは作成後 immutable
    assert.True(t, entry.IsImmutable())
}

func TestRecordAuditEntryValidation(t *testing.T) {
    tests := []struct {
        name    string
        input   *domain.AuditEntry
        wantErr bool
    }{
        {
            name: "valid audit entry",
            input: &domain.AuditEntry{
                ID:           uuid.New(),
                ActorID:      "user-123",
                ActorType:    domain.ActorUser,
                Action:       domain.ActionWrite,
                ResourceType: domain.ResourceMedia,
                ResourceID:   "media-456",
                Result:       domain.ResultAllowed,
                IPAddress:    "192.168.1.1",
                OccurredAt:   time.Now().UTC(),
                CreatedAt:    time.Now().UTC(),
            },
            wantErr: false,
        },
        {
            name: "missing actor_id",
            input: &domain.AuditEntry{
                ID:        uuid.New(),
                ActorID:   "",
                CreatedAt: time.Now(),
            },
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := tt.input.Validate()
            if tt.wantErr {
                assert.Error(t, err)
            } else {
                assert.NoError(t, err)
            }
        })
    }
}

// application/record_audit_entry_usecase_test.go: ユースケーステスト
package application_test

import (
    "context"
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
    "recuerdo/audit/application"
    "recuerdo/audit/domain"
)

type MockAuditRepository struct {
    mock.Mock
}

func (m *MockAuditRepository) Save(ctx context.Context, entry *domain.AuditEntry) error {
    args := m.Called(ctx, entry)
    return args.Error(0)
}

func TestRecordAuditEntryUsecase(t *testing.T) {
    mockRepo := new(MockAuditRepository)
    mockRepo.On("Save", mock.Anything, mock.MatchedBy(func(e *domain.AuditEntry) bool {
        return e.ActorID == "user-123" && e.Action == domain.ActionWrite
    })).Return(nil)

    usecase := application.NewRecordAuditEntryUsecase(mockRepo)

    input := &application.RecordAuditEntryInput{
        ActorID:      "user-123",
        ActorType:    "USER",
        Action:       "WRITE",
        ResourceType: "MEDIA",
        ResourceID:   "media-456",
        Result:       "ALLOWED",
        IPAddress:    "192.168.1.1",
        OccurredAt:   "2026-04-13T10:00:00Z",
    }

    output, err := usecase.Execute(context.Background(), input)

    assert.NoError(t, err)
    assert.NotNil(t, output)
    assert.NotEmpty(t, output.AuditEntryID)
    mockRepo.AssertExpectations(t)
}

// adapter/MySQL_audit_repository_test.go: 統合テスト
package adapter_test

import (
    "context"
    "testing"
    "testcontainers"
    "github.com/stretchr/testify/assert"
    "recuerdo/audit/adapter/repository"
    "recuerdo/audit/domain"
)

func TestMySQLAuditRepository_Save(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }

    // MySQL コンテナ起動
    MySQL := testcontainers.NewMySQLContainer(t)
    defer MySQL.Terminate(context.Background())

    pool := MySQL.Pool()
    repo := repository.NewMySQLAuditRepository(pool)

    entry := &domain.AuditEntry{
        ID:           uuid.New(),
        ActorID:      "user-123",
        ActorType:    domain.ActorUser,
        Action:       domain.ActionWrite,
        ResourceType: domain.ResourceMedia,
        ResourceID:   "media-456",
        Result:       domain.ResultAllowed,
        IPAddress:    "192.168.1.1",
        OccurredAt:   time.Now().UTC(),
        CreatedAt:    time.Now().UTC(),
    }

    err := repo.Save(context.Background(), entry)
    assert.NoError(t, err)

    // 検証: DB から取得
    retrieved, err := repo.FindByID(context.Background(), entry.ID)
    assert.NoError(t, err)
    assert.Equal(t, entry.ActorID, retrieved.ActorID)
}

10. エラーハンドリング

10.1 ドメインエラー

type ValidationError struct {
    Message string
}

type ImmutableViolationError struct {
    Message string
}

type NotFoundError struct {
    ResourceID string
}

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

  • InvalidInputError: DTO バリデーション失敗
  • RepositoryError: DB 永続化失敗
  • ArchivalError: オブジェクトストレージ (Garage / OCI OSS) アーカイバル失敗
  • CacheError: Redis キャッシュ失敗

10.3 エラー変換 (HTTP マッピング)

ドメイン / アプリケーションエラー HTTP ステータス HTTP メッセージ
ValidationError 400 "Invalid input: {details}"
ImmutableViolationError 409 "Cannot modify immutable record"
NotFoundError 404 "Audit entry not found"
UnauthorizedError 403 "Access denied"
RepositoryError 500 "Database error"
ArchivalError 500 "Archival service unavailable"

11. 横断的関心事

11.1 ロギング

  • 構造化ロギング: JSON フォーマット(Zap, Logrus)
  • レベル: INFO (API 呼び出し), ERROR (例外), DEBUG (DB クエリ)
  • トレース ID: QueuePort メッセージ ID を全ログに包含

11.2 認証・認可

  • 認証: JWT ベアラトークン(Authorization ヘッダ)
  • 認可: 管理者ロールチェック(GET /api/audits は Admin のみ)
  • ミドルウェア: Echo Middleware で認証検証

11.3 バリデーション

  • 入力DTO: go-playground/validator 使用
  • ドメイン: Entity.Validate() メソッド
  • ルール: 必須フィールド、enum 値、IP アドレス形式など

11.4 キャッシング

  • キャッシュ対象: QueryAuditLogs 結果(5 分 TTL)
  • 無効化: 新規 AuditEntry 記録時に pattern invalidate
  • 実装: Redis SETEX, GET, DEL コマンド

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

12.1 現状

  • 既存システム: in-memory ログ(再起動で消失)
  • スケーラビリティ: 1 サービスのみ
  • コンプライアンス: GDPR 対応なし

12.2 目標状態

  • MySQL APPEND-ONLY ログ
  • 複数マイクロサービス間の一元監査
  • GDPR 削除リクエスト自動化
  • オブジェクトストレージ (Garage / OCI OSS) 冷保存アーカイバル

12.3 マイグレーション手順

フェーズ 期間 作業 リスク
フェーズ 1 W1-2 MySQL テーブル作成、パーティション設定 テーブル設計誤り
フェーズ 2 W3-4 QueuePort Consumer 実装、RecordAuditEntry テスト メッセージ損失
フェーズ 3 W5-6 QueryAuditLogs API 実装、キャッシング キャッシュ一貫性
フェーズ 4 W7-8 オブジェクトストレージ アーカイバル、GDPR 匿名化実装 データ消失
フェーズ 5 W9-10 ロードテスト、本番デプロイ パフォーマンス低下

13. 未決事項と決定事項

項目 ステータス 決定 理由
メッセージフォーマット DECIDED JSON (QueuePort 共通) 汎用性、デシリアライズ容易
キャッシュ戦略 DECIDED Redis (5min TTL) QueryAuditLogs 頻出、低遅延必須
アーカイバル形式 PENDING Parquet vs CSV 圧縮率と分析ツール互換性で検討中
GDPR 保持期間 DECIDED 匿名化後 1 年保持 法的最小要件 + 分析用
Queue リトライ DECIDED DLQ + 3 回リトライ 一時的なエラーに対応
テスト DB DECIDED testcontainers CI/CD 環境での再現性

14. 参考資料

  • Clean Architecture (Uncle Bob): https://blog.cleancoder.com/
  • Domain-Driven Design (Eric Evans): https://www.domainlanguage.com/
  • MySQL パーティショニング: https://www.MySQL.org/docs/current/ddl-partitioning.html
  • Garage Object Storage: https://garagehq.deuxfleurs.fr/
  • OCI Object Storage Lifecycle: https://docs.oracle.com/iaas/Content/Object/Tasks/usinglifecyclepolicies.htm

最終更新: 2026-04-19 ポリシー適用 - GDPR コンプライアンス: https://www.gdpr.eu/ - uber-go/fx DI: https://pkg.go.dev/go.uber.org/fx