「とりあえずClean Architecture」で疲弊した私が、Djangoの流儀と和解するまで
注意
記事内の機能要件やディレクトリ構成などは、実際のプロジェクトとは異なります。
あくまで構成例として参考にしてください。
はじめに
最近は AI のコーディングアシストもあって、冗長な構成のコードを書くコストはかなり下がりました。そのせいで、思考停止で 「とりあえず Clean Architecture で」 をやりがちだったな…と振り返っています。
この記事は、Clean Architecture を入れて 「全部抽象化したら、逆にしんどくなった」 体験談と、その落とし所のメモです。アーキテクチャはゴールではなく、解消したい痛みを閉じ込めるための手段。ここを見失わないための反省をまとめます。
背景と課題
Django をバックエンドに用いたアプリケーションのプロジェクトに、途中から参画したときの話です。私自身 Django を実務で触るのは初めてでした。
当時の実装は機能的には問題なく動いていましたが、開発を進める中で以下の2点が課題になっていると感じました。
- 外部サービス(インフラ)への依存がアプリケーションに露出していた
- 入口(View)に処理が集まり、変更の単位が大きくなっていた
これらを解消するために Clean Architecture の導入を検討しました。
ただ振り返ると、この時点で 「どこを分離すべきか」より先に、「Clean Architecture を適用すること」自体が目的になっていました。
プロジェクトの要件
今回のプロジェクトの要件は以下の通りです。
機能要件
- フロントエンドから YAML文字列 を受け取る(HTTPのペイロードはJSONで、例:
{"yaml": "..."}の形) -
yaml → IR(中間表現 / Intermediate Representation) → application 実行の流れで処理する
※ 本記事での IR は「最終処理に渡すまでの内部表現(内部契約としての中間データ構造)」を指します - 非同期実行(バックグラウンド処理、定期実行)を行う(Celery想定)
- 外部入力のYAMLは信用しない前提。パースは安全なローダ(safe load系)を使い、入力サイズ制限・バリデーション・エラー整形までやる
非機能要件
- Django の良さはそのまま使い倒したい(実装スピード・品質・管理画面など)
- インフラ(外部エンジン)の実装は今後変わる可能性が大きく、直接依存したくない
- YAML の将来的な構造変換にも耐えたい(互換性の維持・吸収)
実装の変遷
理想と現実の狭間で、① → ② → ③ の試行を経て、最終的に③の形に落ち着きました。
① Django Native(参画当初)
まずは標準的な Django / DRF の構成です。
ここでいう View は DRF のAPIView/ViewSet(HTTPの入口)を指します。
📁 ディレクトリ構成(クリックで展開)
project_root
├── config/ # Django設定
│ ├── settings.py # settings
│ ├── urls.py # URLルーティング
│ └── celery.py # Celery設定
└── apps/
└── runner/
├── migrations/ # マイグレーション
├── models.py # Django ORMモデル
├── api/ # HTTP入口(DRF)
│ ├── serializers.py # 入力検証
│ └── views.py # 受付→YAMLパース→IR生成→実行→レスポンス
├── workers/ # 非同期入口(Celery)
│ └── tasks.py # タスク定義
└── infrastructure/ # 外部I/O
└── engine_client.py # エンジンクライアント
実際に見えた課題
-
yaml → IR → 実行のロジックが View に集まり、Fat View 化していた - 外部エンジン呼び出しが View や Task に直結しており、差し替え時の影響範囲が見えづらい
- YAML 構造変更時の影響範囲が大きい
② Django × Clean Architecture(細かく抽象化)
①の課題を見て、「Clean Architecture を入れれば解決できるはず」と考えました。
いわゆる同心円の図に忠実に、かなり素直に抽象化を進めた構成です。
📁 ディレクトリ構成(クリックで展開)
project_root
├── config/ # Django設定
│ ├── settings.py # settings
│ ├── urls.py # URLルーティング
│ └── celery.py # Celery設定
├── apps/
│ └── runner/
│ ├── migrations/ # マイグレーション(Django都合)
│ ├── models.py # Django ORMモデル(フレームワーク依存)
│ ├── api/ # HTTP入口(DRF)
│ │ ├── serializers.py # 入力検証(DTO寄せになりがち)
│ │ └── views.py # Usecase呼び出しに寄せる
│ └── workers/ # 非同期入口(Celery)
│ └── tasks.py # Usecase呼び出しに寄せる
├── domain/ # ドメイン
│ ├── ir/
│ │ └── plan.py # IR定義
│ ├── rules.py # ドメインルール
│ └── errors.py # ドメイン例外
├── application/ # ユースケース
│ ├── usecases/
│ │ └── run_workflow.py # 実行ユースケース
│ └── dto/
│ ├── workflow_spec.py # 入力DTO
│ └── result.py # 出力DTO
├── ports/ # Port定義(Interface)
│ ├── engine.py # 外部エンジンPort
│ └── repository.py # 永続化Port(Repository Interface)
└── infrastructure/ # 外部I/O(具体実装)
├── codecs/
│ └── yaml_loader.py # YAML Codec(YAML→DTO/IR)
├── engine/
│ ├── vendor_adapter.py # Engine Adapter(Port実装)
│ └── vendor_client.py # エンジンクライアント(SDK呼び出し)
├── repositories/
│ ├── django_repo.py # Repository実装(Django ORM)
│ └── mappers/
│ └── mapper.py # Model ⇔ Entity/DTO 変換
└── wiring/
└── container.py # DI設定
振り返って分かったこと・思ったこと
- ✅: Repository や Port を介すことで、Django ORM や外部ライブラリへの直接依存をビジネスロジックから排除できた
- ✅: Repositoryのおかげで、ユニットテストは書きやすかった
- ❌: ORMまで厳密に抽象化すると、Model⇔Entityの整合や二重管理が増えて “Django流に素直に繋ぐ”旨味が薄れ、運用コストが前に出た
- ❌: DTO / IR / Model / Entity あたりの変換が増え、小さい修正でも触る場所が増えて変更コストが跳ねた
③ Django × 部分的に抽象化
②は理論的には間違ってないと思いましたが、「今回そこまで分ける必要あったっけ?」って疑いが強くなりました。
やりたかったのは完全な分離じゃなくて、**「変更が起きやすい箇所(外部接続・YAML定義)の影響を閉じ込めること」**だったはずだ、と立ち返りました。
あと現実的に、こういう前提もありました。
- 当分、DjangoからDBを変える判断はしなさそう(少なくともこのプロジェクトでは)
- CRUD中心で、ドメイン(状態遷移や例外ルール)が爆発しなさそう
それならRepositoryを維持するコストを背負うより、Djangoの生産性に寄せたほうがよくない?という結論に落ち着きました。
代わりに 業務の判断・手順はUsecaseに寄せ、ORMは入出力に寄せる。DB不要の分岐は IR / Pure な関数に切り出してテストしやすくすれば、Repositoryが無いデメリットも受けにくいのでは?と考えました。
📁 アーキテクチャ図(概念)(クリックで展開)
📁 ディレクトリ構成(クリックで展開)
project_root
├── config/ # Django設定
│ ├── settings.py # settings
│ ├── urls.py # URLルーティング
│ └── celery.py # Celery設定
└── apps/
└── runner/
├── migrations/ # マイグレーション
├── models.py # Django ORMモデル
├── api/ # HTTP入口(DRF)
│ ├── serializers.py # 入力検証
│ └── views.py # 受付(Codec→Usecase呼び出し)
├── workers/ # 非同期入口(Celery)
│ └── tasks.py # タスク定義(Usecase呼び出し)
├── interfaces/ # 入力変換(外部入力→IR)
│ └── codecs/
│ └── yaml_loader.py # YAML→IR 変換
├── application/ # アプリ層(業務手順)
│ ├── usecases.py # Usecase(Model直利用)
│ ├── ir.py # IR定義(内部契約)
│ └── errors.py # エラー定義(アプリ層)
├── ports/ # 外部I/O境界(Port)
│ └── engine.py # 外部エンジンPort
└── infrastructure/ # 外部I/O(Adapter)
├── engine_vendor.py # エンジンAdapter
└── engine_client.py # エンジンクライアント
設計のポイント
-
DBアクセスは割り切る(ただし“全部をUsecase経由”にはしない)
Django ORM を直接利用し、永続化層の抽象化(Repositoryパターン)は採用しない。Djangoの生産性を落としたくなかった。
今回は「Admin活用」「QuerySet/Managerの再利用」「集計や検索の最適化」が多く、Repositoryで抽象化するより Django ORMに寄せた方が変更コストが下がる と判断した。そのうえで、API(View)からの呼び出し経路を 2つに分ける。
- **薄い CRUD(特に読み取り・単純更新)**は、View から QuerySet/Manager を経由して Model を直接触る
→ Djangoの流儀(チェーンできるQuerySetやエコシステム)を捨てずに済む - **複雑な処理(状態遷移・複数モデルをまたぐ手順・外部I/O)**だけ、Usecase に寄せる
→ View の Fat 化を防ぎつつ、手順や判断を閉じ込める
ただし、Repositoryを置かないことのデメリットは受け入れました。
-
クエリ知識が散りやすい(どこに書くかがブレる)
- 対策:
Model.objects.*を View/Usecase に直書きせず、QuerySet/Manager に寄せる(薄いCRUDでも同じ)
- 対策:
-
DBをまたぐ分岐が増えるとテストが重くなる
- 対策: 「DB不要の分岐」は IR / Pureな関数に寄せる。DBが絡むところは統合テストで割り切る
-
Usecaseが肥大化しやすい
- 対策: “手順”の単位でUsecaseを分割する(巨大Usecaseを作らない)、例外・整形は共通化する
- **薄い CRUD(特に読み取り・単純更新)**は、View から QuerySet/Manager を経由して Model を直接触る
-
変化の激しい部分は守る
外部エンジンや YAML フォーマットは変わりやすいので、PortとCodecを挟んでアプリケーションから隔離する。 -
IR(内部契約)を防波堤にする
抽象化のためじゃなくて、外部入力(YAML)の変更をロジックに波及させないために IR を定義する。ここから先は IR だけ見る。
結果どうなったか
- YAMLの構造が変わっても、修正は
yaml_loader.py(正規化ルール)だけで完結するようになった
たとえばキーのリネームが入っても、Usecase/Model側には波及しない - 外部エンジンを差し替えるときも、
infrastructureにAdapterを追加するだけで済む(Portが維持できる範囲で) - Django の良さ(実装スピード・品質・管理画面など)を犠牲にせずに済んだ
まとめ
- 最初に「解消したい痛み」を言語化できてなかったのが反省点。何を解決したいのかをまずは言語化することが大事。
- 過去の成功体験で、手段のはずの Clean Architecture が目的化してた。アーキテクチャは問題を解決するためのツールでしかない。
- フレームワークには流儀や得意領域があるので、既存のアーキテクチャをそのまま当てはめるとデメリットが出ることもある。「どこを抽象化して、どこは乗っかるか」を言葉にして、必要な部分だけ柔軟に取り入れる。これも学びでした。
Discussion