🎭

ブログのPostモデリングで考える単一責任原則の真髄

に公開

ブログのPostモデリングで考える単一責任原則の真髄

はじめに

ブログシステムにおける「下書き」と「公開」という状態管理は、一見単純に見えて実は奥深い設計課題です。単一のBlogPostエンティティにstatusフラグを持たせるだけでは、システムが成長するにつれて様々な問題が顕在化してきます。

本稿では、ロバート・C・マーティン(Uncle Bob)が提唱する単一責任原則(SRP: Single Responsibility Principle)の本質である「アクター」の概念を軸に、ブログの状態管理をどのようにモデリングすべきかを検討します。


SRPの本質:「変更理由」ではなく「アクター」

従来の誤解

SRPは「クラスは一つの責任だけを持つべき」と説明されることが多いですが、「責任」の解釈が曖昧なため、実際の設計に適用しづらいという問題がありました。

Of all the SOLID principles, the Single Responsibility Principle (SRP) might be the least well understood. That's likely because it has a particularly inappropriate name. It is too easy for programmers to hear the name and then assume that it means that every module should do just one thing.

— Robert C. Martin, "Clean Architecture" [1]

(訳:SOLID原則の中でも、単一責任原則(SRP)はおそらく最も誤解されている。その原因は、特に不適切な名前にある。プログラマーはその名前を聞くと、すべてのモジュールは一つのことだけをすべきだと思い込みやすい。)

「一つのことだけをやるべき」という解釈は、関数レベルでは正しいですが、クラスやモジュールレベルでは不十分です。

アクターによる再定義

Martin氏は『Clean Architecture』において、SRPをより明確に再定義しています。

A module should be responsible to one, and only one, actor.

— Robert C. Martin [2]

(訳:モジュールは、ただ一つのアクターに対してのみ責任を負うべきである。)

ここでいうアクター(actor)とは、システムに対して異なる要求や変更理由を持つステークホルダーのグループを指します。[3]

同じデータを扱っていても、異なるアクターが異なる理由でそのデータの扱い方を変更したいと考える場合、それらは分離されるべきです。なぜなら、あるアクターの要求による変更が、別のアクターの機能に意図せず影響を与えるリスクがあるからです。


具体例:給与計算システムの悲劇

SRPの「アクター」概念を理解するために、Martin氏が『Clean Architecture』で紹介している有名な例を見てみましょう。[4]

問題のある設計

給与計算アプリケーションに、以下のようなEmployeeクラスがあるとします。

public class Employee {
    public Money calculatePay();    // 給与計算
    public String reportHours();    // 労働時間レポート
    public void save();             // データベース保存
}

一見問題なさそうに見えますが、このクラスは3つの異なるアクターに対して責任を負っています。

  • calculatePay() → CFO(経理部門)が仕様を決定
  • reportHours() → COO(人事部門)が仕様を決定
  • save() → CTO(技術部門)が仕様を決定

悲劇の発生

開発者は重複コードを避けるため、calculatePay()とreportHours()で共通のregularHours()関数を作りました。

CFOの要求で修正したregularHours()関数が、COOが使うreportHours()にも影響を与えてしまいました。

Why did we separate these functions into different classes? The answer is obvious: we don't want to get the COO fired because we made a change requested by the CFO.

— Robert C. Martin [5]

(訳:なぜこれらの関数を異なるクラスに分離したのか?答えは明白だ。CFOの依頼で行った変更のせいで、COOがクビになるような事態は避けたいからだ。)

これがSRP違反の本質的な問題です。異なるアクターの要求が同じコードに混在していると、一方の変更が他方を壊すリスクがあるのです。

正しい設計

各クラスが単一のアクターにのみ責任を負うことで、変更の波及を防げます。


ブログシステムにおけるアクター分析

では、ブログシステムではどのようなアクターが存在するでしょうか?

三つの主要アクター

アクターごとのユースケース

アクター 主なユースケース
閲覧者 公開記事一覧の表示、記事詳細の閲覧、カテゴリ/タグ検索、RSS購読
編集者 下書き作成・編集、プレビュー表示、公開操作、履歴確認、取り下げ
管理者 全記事の状態一覧、強制非公開、公開承認ワークフロー、統計閲覧

単一クラスの問題:変更の波及

閲覧者向けの変更が編集者を壊す

給与計算システムと同じ構造の問題を、ブログシステムで見てみましょう。

単一のBlogPostクラスに全アクターの機能を詰め込んでいるとします。

class BlogPost {
  private formatText(text: string): string {
    return text;  // 共通のテキスト処理
  }

  // 閲覧者向け:公開記事の本文表示
  getDisplayContent(): string {
    return this.formatText(this.bodyMarkdown);
  }

  // 編集者向け:編集画面でのプレビュー
  getPreviewContent(): string {
    return this.formatText(this.bodyMarkdown);
  }
}

ある日、セキュリティチームから要求が来ました:

「公開記事にスクリプトタグが残っていてXSSの危険がある。閲覧者向けの表示をサニタイズしてほしい」

開発者は共通関数formatText()を修正します。両方のメソッドで使われているので、一箇所の変更で済むと考えました。

class BlogPost {
  private formatText(text: string): string {
    return sanitizeHtml(text);  // XSS対策を追加
  }
  // ...
}

しかし翌週、編集者から報告が来ます:

「プレビューでHTMLが正しく表示されなくなった。iframeの埋め込みが消える」

編集者はプレビューで実際のHTML(YouTube埋め込み等)を確認したいのに、セキュリティ対策で除去されてしまいました。

これは給与計算のregularHours()が壊れた話とまったく同じ構造です。

両方の要求は正当です:

  • セキュリティチーム(閲覧者のため):「公開ページでXSSを防ぎたい」✓
  • 編集者:「プレビューで実際の表示を確認したい」✓

問題は、異なるアクターの正当な要求が同じコードに結合していることにあります。

本質:暗黙的な結合

if地獄や条件分岐の増殖は「症状」であり、本質的な問題は異なるアクターの要求が暗黙的に結合していることです。

  • 閲覧者と編集者は同じ関数(formatText())を共有している
  • しかし求める処理内容が異なる(セキュリティ重視 vs 忠実な表示)
  • 単一クラスだと、どの変更がどのアクターに影響するか見えない

解決策:クラス層とDB層を分けて考える

前提:クラスとテーブルは1対1じゃない

アクター別にモデルを分離する前に、重要な前提を確認します。

なぜこの誤解が生まれるのか

多くの開発者が「1テーブル = 1クラス」という思い込みを持っています。これには歴史的な背景があります。

Active Recordパターンの影響

Ruby on RailsやLaravelなどのフレームワークで広く採用されているActive Recordパターンでは、データベースのテーブルとクラスが1対1で対応します。

// Active Record的な発想
class BlogPost extends Model {
  // usersテーブルと1対1対応
  // テーブルのカラム = クラスのプロパティ
}

このパターンは学習コストが低く、CRUDアプリケーションを素早く構築できる利点があります。しかし、ドメインの複雑性が増すと限界が見えてきます。

ORMの暗黙の前提

多くのORMは「テーブル定義からクラスを生成する」または「クラス定義からテーブルを生成する」という双方向のマッピングを前提としています。この便利さが、逆に「両者は1対1であるべき」という固定観念を強化してしまいます。

分離すべき理由

データベース層とアプリケーション層では、最適化の方向性が異なります。

層 最適化の方向性 例
データベース層 データの整合性、正規化、クエリ性能 重複排除、インデックス設計
アプリケーション層 ユースケースへの適合、型安全性、テスタビリティ アクター別の責務分離

この分離ができないと、アクター別のモデリングは実現できません。


推奨:単一テーブル + アクター別クラス

SRPはアプリケーション層の話であり、データベース設計とは独立した関心事です。

多くのブログシステムでは、以下のシンプルな構成で十分です:

データベース:単一テーブル + status

CREATE TABLE articles (
    id UUID PRIMARY KEY,
    title TEXT NOT NULL,
    body_markdown TEXT NOT NULL,
    author_id UUID NOT NULL REFERENCES users(id),
    status TEXT NOT NULL DEFAULT 'draft',  -- 'draft' | 'published' | 'archived'
    published_at TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

アプリケーション層:アクター別にクラス/型を分ける

// 閲覧者向け:公開記事の表示に必要な情報のみ
class PublishedArticle {
  constructor(
    public readonly id: string,
    public readonly title: string,
    private readonly bodyMarkdown: string,
    public readonly authorName: string,
    public readonly publishedAt: Date,
  ) {}

  // computed: HTMLはmarkdownから都度生成(またはキャッシュ)
  get bodyHtml(): string {
    return renderMarkdown(this.bodyMarkdown);
  }

  get readingTimeMinutes(): number {
    // 日本語は約500文字/分で読める想定
    const charCount = this.bodyMarkdown.replace(/\s/g, '').length;
    return Math.ceil(charCount / 500);
  }
}

// 編集者向け:編集操作に必要な情報
class ArticleDraft {
  constructor(
    public readonly id: string,
    public title: string,
    public bodyMarkdown: string,
    public status: 'draft' | 'published' | 'archived',
  ) {}

  publish(): void {
    if (!this.title || !this.bodyMarkdown) {
      throw new Error('タイトルと本文は必須です');
    }
    this.status = 'published';
  }
}

// 管理者向け:管理操作に必要な情報
class AdminArticle {
  constructor(
    public readonly id: string,
    public readonly title: string,
    public readonly status: string,
    public readonly authorId: string,
    public readonly createdAt: Date,
  ) {}

  // 管理者専用の操作
  canForceUnpublish(): boolean {
    return this.status === 'published';
  }
}

このアプローチのメリット:

  • データベース設計がシンプル
  • ORMとの相性が良い
  • SRPはアプリ層の型/クラスで実現できる

このアプローチで十分なケース:

  • 履歴管理が不要(または別途監査ログで対応)
  • 同時編集がない
  • 読み書きのパフォーマンス要件が同程度

より高度なアプローチ(必要な場合のみ)

シンプルなアプローチで問題が発生した場合のみ、以下を検討してください。

戦略 いつ必要か 概要
CQRS 読み取り性能が問題 読み取り専用モデルと書き込みモデルを完全分離。別々のDBやキャッシュ層を持つ
DDD集約 状態管理が複雑化 アクターごとに集約(トランザクション境界)を定義し、不変条件を厳密に管理
イベント駆動 サービス間連携が必要 ドメインイベントで疎結合に統合。マイクロサービス化の布石

For most systems CQRS adds risky complexity.

— Martin Fowler [6]

(訳:ほとんどのシステムにとって、CQRSはリスクのある複雑性を追加することになる。)

原則:問題が発生してから複雑化する。最初から高度なアーキテクチャを採用すると、開発速度が落ち、保守コストが増大します。


どう選ぶ?判断フローチャート

ポイント:シンプルな構成から始めて、問題が発生したら段階的に複雑化する。


まとめ

SRPの本質は「一つのことだけをやる」ではなく、一つのアクターにだけ責任を負うことです。

ブログシステムでは閲覧者・編集者・管理者という異なるアクターが存在し、単一クラスで全てに対応しようとすると変更が予期せぬ影響を与えます。

解決策:

  1. SRPはクラス設計の話 - DB設計とは独立した関心事
  2. DBは単一テーブルで十分 - 分離はアプリ層(クラス/型)で行う
  3. 問題が起きてから複雑化 - CQRS/DDD/イベント駆動は本当に必要な時だけ

参考文献

脚注
  1. Robert C. Martin, "Clean Architecture: A Craftsman's Guide to Software Structure and Design", Chapter 7 - O'Reilly ↩︎

  2. Robert C. Martin, "The Single Responsibility Principle" - Clean Coder Blog (2014) ↩︎

  3. Wikipedia, "Single-responsibility principle" - Wikipedia ↩︎

  4. Clean Architecture Notes - GitHub ↩︎

  5. Robert C. Martin, Clean Coder Blog - 原文 ↩︎

  6. Martin Fowler, "CQRS" - martinfowler.com ↩︎

Discussion