1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

カプセル化と関心の分離について

Posted at

ミノ駆動本改訂版

 改定前のミノ駆動本も買ったのですが、改訂版も買って読ませていただきました。前回の版に比べると、カプセル化関心の分離についてが大きく加筆されていると感じました。

 そこで、カプセル化と関心の分離について、自分なりの理解をChatGPTと壁打ちしながらカプセル化と関心の分離についてまとめてみました。

image.png

カプセル化

 カプセル化とは、データとそれに対する操作(メソッド)を一つにまとめ、外部からの直接的なアクセスを制限する仕組みのことです。完全性の保証を意識することで変更に強い設計の基本となります。

カプセル化の目的

ドメインモデルの 一貫性を守り、アプリケーション層や UI 層からの誤った操作を防ぐこと

カプセル化の手法

エンティティ や 値オブジェクト の 不変条件をクラス内に閉じ込める

  • 公開 API(メソッド / プロパティ)を最小限に
  • 内部状態を直接変更させず、必ずメソッド経由で操作させる

カプセル化の効果

  • ドメイン知識がモデルに集約 → 読みやすく保守しやすい
  • ユビキタス言語で表現されたメソッド名 → ビジネス担当者との会話もズレにくい

 カプセル化は単なるデータ隠蔽以上の意味を持ちます。ドメインオブジェクトが自身の一貫性を維持し、ビジネスルールに従った状態変更のみを許可することが核心です。

サンプルコード

 生成AIによるサンプルコード

カプセル化できていないモデル

from dataclasses import dataclass
from datetime import date
from typing import List

# 値オブジェクトを使わず、ドメイン知識が散らばる
@dataclass
class OrderItem:
    name: str
    unit_price: int
    quantity: int

@dataclass
class Order:                       # ← エンティティ
    id: str
    ordered_items: List[OrderItem]
    status: str            # "NEW", "PAID", "CANCELLED" などを直接持つ
    issued_on: date
    total_amount: int      # ← 外部から直接代入できてしまう ⚠️

# --- 別層のコードがドメインを壊す典型例 ---
order = Order(id="A-001",
              ordered_items=[OrderItem("Book", 1200, 2)],
              status="NEW",
              issued_on=date.today(),
              total_amount=0)            # ← バグ!初期値のまま

# UI 層で直接変更。整合性チェックなし
order.total_amount = 1_000_000           # ← 不正価格に改ざん
order.status = "PAID"                    # ← 在庫引当や請求処理を飛ばして状態遷移

問題点
  • 一貫性が守れない
    • total_amount を外部で自由に書き換えられる
       
  • 状態遷移ロジックの散逸
    • 支払い済み (PAID) にする手続き(在庫確保・請求生成など)が UI 側に漏れ、重複実装や漏れが起きる
       
  • テスト困難
    • ビジネスルールが散らばり、単体テストで網羅しづらい

カプセル化できてるモデル

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from typing import List

# --- 値オブジェクト ---
@dataclass(frozen=True)
class Money:
    amount: int

    def __post_init__(self) -> None:
        if self.amount < 0:
            raise ValueError("金額は負の値にできません")

    def __add__(self, other: "Money") -> "Money":
        return Money(self.amount + other.amount)

@dataclass(frozen=True)
class OrderItem:
    name: str
    unit_price: Money
    quantity: int

    @property
    def subtotal(self) -> Money:
        return Money(self.unit_price.amount * self.quantity)

# --- エンティティ ---
@dataclass
class Order:
    id: str
    issued_on: date
    _items: List[OrderItem] = field(default_factory=list, init=False)
    _status: str = field(default="NEW", init=False)
    _total: Money = field(default=Money(0), init=False)

    # 公開読み取り専用プロパティ
    @property
    def status(self) -> str:
        return self._status

    @property
    def total(self) -> Money:
        return self._total

    # ドメイン固有操作を “振る舞い” として提供
    def add_item(self, item: OrderItem) -> None:
        if self._status != "NEW":
            raise ValueError("確定後は明細を追加できません")
        self._items.append(item)
        self._recalculate_total()

    def pay(self, payment_service: "PaymentService") -> None:
        if self._status != "NEW":
            raise ValueError("支払い済みまたはキャンセル済みです")
        payment_service.charge(self.id, self._total)
        self._status = "PAID"

    # --- 内部ロジック(非公開) ---
    def _recalculate_total(self) -> None:
        self._total = Money(sum(item.subtotal.amount for item in self._items))

改善点
  • 値オブジェクトで整合性を担保
    • Money が負の値を禁止 → 金額整合性を自動で保持
       
  • 状態遷移をメソッドに封じ込め
    • pay() が支払い手続きを一手に引き受け、UI 側は呼ぶだけ
       
  • 集約の一貫性
    • add_item() で必ず _recalculate_total() が呼ばれ、合計金額が自動更新
       
  • 外部からの直接操作を禁止
    • _items, _status, _total は非公開属性 → 破壊的な代入を防止
       
  • ユビキタス言語
    • メソッド名・クラス名がドメイン用語 (Order, PaymentService, Money) で統一
       

関心の分離

 関心の分離とは、プログラムを目的や責任ごとに分割し、それぞれの部分が特定の関心事のみを扱うようにすることです。

 カプセル化が関心を一つにまとめることで内部を守る仕組みで、関心の分離は関心事に分割することで外部と切り離す仕組みになります。

関心の分離の目的

変更容易性・再利用性・テスト容易性・並行開発の向上

関心の分離の手法

“関心”(目的・責務)が互いに重ならないよう、モジュール・レイヤ・オブジェクトを分割し、公開インターフェースのみ経由で連携させる

関心の分離の効果

ドメイン知識がモデルに集約し、置き換え・テスト・移植が容易となり、DDD などレイヤードアーキテクチャの基盤になる

コードの流用により関心が混合する典型シナリオ

「とりあえず動くコード」をコピペしたら、レイヤが雪だるま式に混ざってしまった例

背景

  • 元々の 社内管理ツール(モノリシック Flask アプリ)には

    1. 受注登録
    2. 在庫更新
    3. 請求書 PDF 生成 & メール送信
      を 1 本のエンドポイントで処理する関数 register_order() があった。
  • 新規プロジェクトで FastAPI + DDD を採用する際、納期が迫っていたため開発者が 丸ごと流用

  • 「テーブル名やフィールドだけ直せば動くだろう」と思い、そのまま貼り付けて修正。


サンプルコード

 生成AIによるサンプルコード

混在コード”の実態

# 旧システムから流用した register_order(FastAPI ハンドラ内に直書き)
def register_order(req: OrderRequest):           # ← UI / I/O 層
    # --- ドメインロジック ---
    if req.quantity <= 0:
        raise HTTPException(400, "quantity must be > 0")
    total = req.quantity * req.unit_price        # ドメイン計算
    
    # --- インフラ (DB) ---
    with SessionLocal() as db:
        db.execute(
            "INSERT INTO orders(id, qty, total) VALUES(:id, :q, :t)",
            {"id": req.id, "q": req.quantity, "t": total}
        )
        db.execute(
            "UPDATE stock SET qty = qty - :q WHERE sku=:sku",
            {"sku": req.sku, "q": req.quantity}
        )
    
    # --- アプリケーション (ユースケース) ---
    pdf = create_invoice_pdf(req.id, total)      # 外部ライブラリ
    
    # --- インフラ (メール) ---
    smtp.sendmail(
        to=req.customer_email,
        attachment=pdf,
        subject=f"Invoice for Order {req.id}"
    )
    
    return {"status": "ok"}

何が問題か?

concern 具体的コード 変更 結果
UI / I/O FastAPI の例外 (HTTPException) リクエスト仕様が変わる ドメイン層を巻き添えに変更
ドメイン 価格計算ロジック 課税方式変更 DB・メール処理まで改修必要
DB インフラ INSERT / UPDATE スキーマ変更 画面・メール機能まで影響
外部サービス PDF 生成, SMTP サービス移行 コード全体が修正対象
ユースケース 在庫を減らすかどうかの条件分岐 ビジネスフロー改訂 レガシー SQL と密結合

正しい分離へのリファクタリング

presentation/        FastAPI ハンドラ (I/O)
application/         ユースケースサービス
domain/              エンティティ・集約・値オブジェクト
infrastructure/
    ├─ db/           SQLAlchemy Repository
    ├─ pdf/          InvoicePDFAdapter
    └─ mail/         MailerAdapter

1. ドメイン層(不変・副作用ゼロ)

# domain/models.py
@dataclass(frozen=True)
class Order:
    id: str
    sku: str
    quantity: int
    unit_price: int
    
    @property
    def total(self) -> int:
        return self.quantity * self.unit_price

2. インターフェースの抽象化

# domain/contracts.py
class OrderRepository(ABC):
    @abstractmethod
    def save(self, order: Order) -> None: ...
    @abstractmethod
    def decrement_stock(self, sku: str, qty: int) -> None: ...

class InvoicePDF(ABC):
    @abstractmethod
    def generate(self, order: Order) -> bytes: ...

class Mailer(ABC):
    @abstractmethod
    def send(self, *, to: str, subject: str, attachment: bytes) -> None: ...

3. アプリケーションサービス(ユースケース)

# application/services.py
class RegisterOrderService:
    def __init__(
        self,
        repo: OrderRepository,
        pdf: InvoicePDF,
        mailer: Mailer,
    ):
        self.repo = repo
        self.pdf = pdf
        self.mailer = mailer

    def execute(self, dto: OrderDTO) -> None:
        order = Order(**dto.dict())

        self.repo.save(order)
        self.repo.decrement_stock(order.sku, order.quantity)

        invoice = self.pdf.generate(order)
        self.mailer.send(
            to=dto.customer_email,
            subject=f"Invoice {order.id}",
            attachment=invoice,
        )

4. Infrastructure 実装(一部は省略)

# infrastructure/db/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session

from dotenv import load_dotenv
import os

load_dotenv()  # .env から DB_URL を読み込む
engine = create_engine(os.getenv("DB_URL", "sqlite:///./dev.db"), future=True, echo=False)

SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)

# infrastructure/db/models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import Integer, String

class Base(DeclarativeBase):
    pass

class OrderTable(Base):
    __tablename__ = "orders"

    id: Mapped[str] = mapped_column(String(36), primary_key=True)
    sku: Mapped[str] = mapped_column(String(50))
    qty: Mapped[int] = mapped_column(Integer)
    unit_price: Mapped[int] = mapped_column(Integer)
    total: Mapped[int] = mapped_column(Integer)

class StockTable(Base):
    __tablename__ = "stock"

    sku: Mapped[str] = mapped_column(String(50), primary_key=True)
    qty: Mapped[int] = mapped_column(Integer)

以下は省略

  • ReportLabInvoicePDF
  • SMTPMailer

5. Presentation(FastAPI)

@router.post("/orders")
def register(req: OrderRequest, svc: RegisterOrderService = Depends(get_service)):
    svc.execute(req)
    return {"status": "ok"}

Before / After 比較

観点 Before(コピペ関数) After(レイヤ分離)
テスト容易性 DB・SMTP が無いと失敗。E2E 一択 ドメイン・ユースケースは Fake 実装でユニットテスト可
変更影響範囲 スキーマ改修で UI〜メールまで修正 DB 変更は SqlAlchemyOrderRepository だけ
責務の数/関数 5 つ以上 1(I/O) + 1(ユースケース)
再利用性 ほぼゼロ ユースケースを CLI・Batch から呼び出し可
並行開発 1 ファイルをロックしがち 各レイヤを別の開発者が担当可能

実務での注意ポイント

  1. “コピペ前リファクタ”

    • 流用元がレガシーなら、まず関心を分割してから 移植する
    • 既存仕様をドキュメント化 → テスト追加 → 分離 → 移植
       
  2. “スモールステップ分割”

    • いきなり 4 レイヤ完全分離は重い
    • インターフェースと実装の部分 だけでも先に分離すると効果大
       
  3. “テスト駆動でガード”

    • ユースケース・ドメイン層に 副作用ゼロ のピュアロジックを閉じ込め
    • 失敗した分離ポイントはテストが先に赤くなるので早期検知

まとめ

  • カプセル化は “内部を壊させない守り”、関心の分離は “外部と疎結合にする攻め”
  • 両者を徹底すると「コピペ → 関心混在 → 変更地獄」の負の連鎖を断ち切り、保守しやすく拡張しやすいドメイン駆動設計が実現できる
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?