Django でクリーンアーキテクチャを導入する(全体設計編)

概要

Django は「Fat Model / Fat View」になりやすいフレームワークです。ORM が強力で、ビジネスロジックをどこにでも書けてしまうからです。小規模なうちは問題ありませんが、ドメインが複雑化すると「この計算ロジックはどこにあるのか」「テストのたびに DB を立ち上げないといけない」といった痛みが表面化します。

本記事は、Django にクリーンアーキテクチャ(およびその発想の元にある DDD の実装パターン)を導入するときの全体設計を扱う考察記事です。個別レイヤの実装詳細ではなく、「どこで層を切り、どこで妥協するか」という設計判断に焦点を当てます。

なぜ Django でクリーンアーキテクチャなのか

クリーンアーキテクチャの本質は「依存の方向を内側(ビジネスルール)に向けて固定する」ことです。フレームワーク・DB・Web は最も外側の詳細であり、ビジネスルールはそれらを知ってはいけません。

Django との相性が悪いと言われる最大の理由は ActiveRecord 型の ORM です。models.Model を継承した瞬間に、ドメインモデルが DB スキーマと Web リクエストの都合に縛られます。この結合をどう扱うかが設計の肝になります。

現実的な選択肢は 2 つあります。

  1. Django Model をそのままドメインとして扱い、層だけ薄く切る(現実解)
  2. Django Model を永続化専用にし、純粋なドメインオブジェクトを別に持つ(理想寄り)

どちらが正解ということはありません。チームの規模とドメインの複雑さで選びます。本記事では両方を扱いつつ、段階的に理想へ寄せる設計を推奨します。

レイヤ構成

依存は必ず内向き(下から上への矢印は存在しません)。

┌─────────────────────────────────────────────┐
│  Presentation (Django View / DRF / CLI)      │  ← フレームワーク依存
├─────────────────────────────────────────────┤
│  Application (UseCase / Service)             │  ← ユースケースの手順
├─────────────────────────────────────────────┤
│  Domain (Entity / ValueObject / Repository IF)│  ← ビジネスルール(純粋)
├─────────────────────────────────────────────┤
│  Infrastructure (Repository 実装 / ORM)       │  ← 詳細。Domain の IF を実装
└─────────────────────────────────────────────┘

ポイントは Repository のインターフェースを Domain 層に置き、実装を Infrastructure 層に置くこと(依存性逆転)です。Application は「Repository という抽象」にしか依存せず、それが Django ORM で動いているか、外部 API で動いているかを知りません。

ディレクトリ設計

Django の「アプリ=機能単位」の思想を活かしつつ、アプリ内部を層で分割します。

myproject/
├── config/                  # settings, urls, wsgi
└── apps/
    └── billing/
        ├── domain/
        │   ├── entities.py        # ドメインエンティティ(Django非依存)
        │   ├── value_objects.py
        │   └── repositories.py    # Repository の抽象(Protocol / ABC)
        ├── application/
        │   └── use_cases.py
        ├── infrastructure/
        │   ├── models.py          # Django Model(永続化専用)
        │   ├── orm_repositories.py
        │   └── mappers.py         # Model ⇄ Entity 変換
        └── presentation/
            ├── views.py
            └── urls.py

Domain 層:フレームワークを知らないコア

エンティティは Django に一切依存しません。dataclass で十分です。

# apps/billing/domain/value_objects.py
from dataclasses import dataclass
from decimal import Decimal


@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str = "JPY"

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

    def add(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("通貨が一致しません")
        return Money(self.amount + other.amount, self.currency)
# apps/billing/domain/entities.py
from dataclasses import dataclass, field
from decimal import Decimal
from .value_objects import Money


@dataclass
class Invoice:
    id: int | None
    customer_id: int
    lines: list[Money] = field(default_factory=list)
    issued: bool = False

    def issue(self) -> None:
        if self.issued:
            raise ValueError("すでに発行済みです")
        if not self.lines:
            raise ValueError("明細のない請求書は発行できません")
        self.issued = True

このコードは DB もリクエストも import していません。だから pytest で DB なしに一瞬でテストできます。これがクリーンアーキテクチャで得られる最大の実利です。

Repository の抽象は Domain 層に置きます。Python なら typing.Protocol が軽くて良いです。

# apps/billing/domain/repositories.py
from typing import Protocol
from .entities import Invoice


class InvoiceRepository(Protocol):
    def get(self, invoice_id: int) -> Invoice | None: ...
    def save(self, invoice: Invoice) -> Invoice: ...

Application 層:ユースケースの手順書

ユースケースは「何をするか」を宣言的に書く場所であり、ビジネスルール(=どう判断するか)はエンティティに委ねます。トランザクション境界はここで引きます。

# apps/billing/application/use_cases.py
from dataclasses import dataclass
from ..domain.repositories import InvoiceRepository


@dataclass
class IssueInvoiceUseCase:
    repo: InvoiceRepository

    def execute(self, invoice_id: int) -> None:
        invoice = self.repo.get(invoice_id)
        if invoice is None:
            raise LookupError("請求書が見つかりません")
        invoice.issue()          # ← ルールはエンティティ側
        self.repo.save(invoice)

UseCase は具象の InvoiceRepository を知りません。Protocol を満たす何かを受け取るだけです。テストではインメモリ実装を渡せば十分です。

Infrastructure 層:Django ORM を閉じ込める

ここで初めて models.Model が登場します。Django Model は永続化のためのテーブル定義と割り切ります。

# apps/billing/infrastructure/models.py
from django.db import models


class InvoiceRecord(models.Model):
    customer_id = models.IntegerField()
    issued = models.BooleanField(default=False)

    class Meta:
        db_table = "invoice"

Model と Entity の変換は Mapper に集約します。ここを View やロジックに散らかすと結局結合が漏れます。

# apps/billing/infrastructure/orm_repositories.py
from ..domain.entities import Invoice
from .models import InvoiceRecord
from .mappers import to_entity


class DjangoInvoiceRepository:
    def get(self, invoice_id: int) -> Invoice | None:
        record = InvoiceRecord.objects.filter(id=invoice_id).first()
        return to_entity(record) if record else None

    def save(self, invoice: Invoice) -> Invoice:
        record, _ = InvoiceRecord.objects.update_or_create(
            id=invoice.id,
            defaults={"customer_id": invoice.customer_id, "issued": invoice.issued},
        )
        return to_entity(record)

DjangoInvoiceRepositoryInvoiceRepository プロトコルを構造的に満たしているので、UseCase にそのまま注入できます。

Presentation 層:組み立てだけを担う

View は「入力を受け取り、UseCase を組み立て、結果を返す」だけの薄い層にします。

# apps/billing/presentation/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from django.db import transaction

from ..application.use_cases import IssueInvoiceUseCase
from ..infrastructure.orm_repositories import DjangoInvoiceRepository


class IssueInvoiceView(APIView):
    def post(self, request, invoice_id: int):
        use_case = IssueInvoiceUseCase(repo=DjangoInvoiceRepository())
        with transaction.atomic():
            use_case.execute(invoice_id)
        return Response({"status": "issued"}, status=200)

依存の組み立て(DI)を View で直書きしている点は妥協です。規模が大きくなったら DI コンテナ(punqdependency-injector 等)に寄せます。

設定調整:models を infrastructure に置く

Django は app/models.py を自動検出します。infrastructure/models.py に置く場合は明示 import が必要です。

# apps/billing/models.py  ← 互換のための再エクスポート
from .infrastructure.models import InvoiceRecord  # noqa

こうしておけば makemigrations / migrate は通常どおり動きます。

テスト戦略

層を切る最大の見返りはテストピラミッドが素直に組めることです。

  • Domain テスト:DB なし。エンティティのルールを純粋にテスト(大量・高速)
  • Application テスト:インメモリ Repository を注入。ユースケースの手順を検証
  • Infrastructure テスト:実 DB(pytest-django)で Repository の SQL を検証(少数)
  • E2E/View テスト:エンドポイント疎通の担保(最小限)
class InMemoryInvoiceRepo:
    def __init__(self): self._store = {}
    def get(self, i): return self._store.get(i)
    def save(self, inv): self._store[inv.id] = inv; return inv


def test_issue_invoice():
    repo = InMemoryInvoiceRepo()
    repo.save(Invoice(id=1, customer_id=10, lines=[Money(Decimal("100"))]))
    IssueInvoiceUseCase(repo).execute(1)
    assert repo.get(1).issued is True

アンチパターンと落とし穴

  • Entity に Django の QuerySet を持たせる — 結合が漏れる。Repository の内側で完結させる
  • Mapper を省略して Model をそのまま Application へ渡す — DB スキーマ変更がユースケースまで波及する
  • 層を切ることが目的化する — CRUD 画面まで 4 層にすると保守コストだけ増える
  • 循環 importdomaininfrastructure を import した瞬間に設計が壊れる。import の向きは import-linter 等で機械検証すると安全

段階導入の指針

全部を最初からやると挫折します。推奨する順序は次のとおりです。

  1. まず Application 層(UseCase/Service)だけ切る。 View から生ロジックを追い出す
  2. 次に Repository 抽象を導入。 DB アクセスを UseCase から隠す
  3. 最後に Entity を Django Model から分離。 コストが高いので、ドメインが本当に複雑な機能アプリだけに適用する

全アプリを純粋 Entity にする必要はありません。CRUD で済む管理画面は素の Django、複雑なドメインは分離という割り切りがむしろ健全です。クリーンアーキテクチャは目的ではなく、変更容易性を得るための手段です。

まとめ

  • クリーンアーキテクチャの核心は「依存の方向を内側に固定する」ことであり、Django では ORM の扱いが最大の論点
  • Repository 抽象を Domain に、実装を Infrastructure に置く「依存性逆転」が背骨になる
  • 純粋 Entity 化はコストが高いので、複雑なドメインだけに段階適用するのが現実解
  • 得られる最大の実利は「DB を立てずに高速でルールをテストできる」こと
  • 層を切ること自体を目的にしない。変更容易性という見返りが割に合う機能にだけ適用する

次回(実装編)では、DI コンテナの導入、トランザクション境界の詳細、集約をまたぐ整合性の扱いを掘り下げる予定です。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください