こんにちは、かつコーチです。
Djangoでアプリを育てていくと、いつの間にかviews.pyが数百行になり、条件分岐だらけで読めなくなる瞬間が来ます。
原因の多くは、ビジネスロジックの置き場所が決まっていないことです。
この記事では、Django設計の定番指針であるFat Model・Skinny Viewの考え方と、その限界、実務での落としどころを解説します。
Fat Model・Skinny Viewとは何を指すのか
ViewはHTTPの窓口に徹する、ロジックはModelに寄せる
Fat Model・Skinny Viewとは、「Viewはリクエストを受けてレスポンスを返す薄い窓口に留め、業務ロジックはModel側に厚く持たせる」という設計方針です。
DjangoのViewは本来、HTTPリクエストの受け取り・バリデーション呼び出し・テンプレートやレスポンスの選択という司令塔の役割に特化させるべきものです。
計算処理やドメイン固有のルール(例:注文が確定できる条件、在庫を引き当てる処理)まで書き始めると、Viewは一気に肥大化します。
なぜこの指針が広まったのか
RailsコミュニティのFat Model・Skinny Controllerという考え方がDjangoにも輸入され、定着しました。
背景にあるのは「同じロジックを複数のViewから呼びたい」というニーズです。
Web画面のViewと、管理コマンド、DRFのAPIビューが同じ業務ルールを共有する場面は珍しくありません。
ロジックがViewに閉じ込められていると、そのたびにコピペが発生し、修正漏れの温床になります。
Viewに書きがちなロジックを移動する
❌ Before:Viewに直接書かれた業務ロジック
# views.py
def confirm_order(request, order_id):
order = get_object_or_404(Order, pk=order_id)
if order.status != "pending":
messages.error(request, "この注文は確定できません")
return redirect("order_detail", order_id=order.id)
total = sum(item.price * item.quantity for item in order.items.all())
if total < 1000:
messages.error(request, "1000円未満の注文は確定できません")
return redirect("order_detail", order_id=order.id)
order.status = "confirmed"
order.confirmed_at = timezone.now()
order.save()
return redirect("order_detail", order_id=order.id)
「確定できる条件」「合計金額の計算」まで全てViewに書かれており、APIビューや管理画面から同じ処理を使い回せません。
✅ After:判断とロジックをModelに移動する
# models.py
class Order(models.Model):
status = models.CharField(max_length=20, default="pending")
confirmed_at = models.DateTimeField(null=True, blank=True)
@property
def total_price(self):
return sum(item.price * item.quantity for item in self.items.all())
def can_confirm(self):
return self.status == "pending" and self.total_price >= 1000
def confirm(self):
if not self.can_confirm():
raise ValueError("この注文は確定できません")
self.status = "confirmed"
self.confirmed_at = timezone.now()
self.save(update_fields=["status", "confirmed_at"])
# views.py
def confirm_order(request, order_id):
order = get_object_or_404(Order, pk=order_id)
try:
order.confirm()
except ValueError as e:
messages.error(request, str(e))
return redirect("order_detail", order_id=order.id)
Viewは「呼ぶだけ」になり、can_confirm()やconfirm()はAPIビューからも管理コマンドからも同じ形で呼び出せます。
Fat Modelの実践パターン
カスタムマネージャー・QuerySetでクエリロジックを閉じ込める
「確定待ちの注文一覧」のような検索条件も、Viewにfilter()を直書きせず、モデル側に閉じ込めます。
class OrderQuerySet(models.QuerySet):
def pending(self):
return self.filter(status="pending")
def high_value(self, threshold=10000):
return self.filter(total_price__gte=threshold)
class Order(models.Model):
objects = OrderQuerySet.as_manager()
# ...
Order.objects.pending().high_value()
同じ検索条件を複数箇所で使い回すたびに、filter()の条件式が微妙にずれるバグを防げます。
モデルメソッド・propertyでドメインロジックを表現する
「送料無料になるか」「キャンセル可能か」といったドメイン固有の判定も、プロパティやメソッドとして表現すると、Viewやテンプレートから自然に呼び出せます。
@property
def is_free_shipping(self):
return self.total_price >= 5000
テンプレート側でも{% if order.is_free_shipping %}のように、ロジックを意識せず条件分岐だけ書けます。
Fat Modelの落とし穴とService層という選択肢
モデルが肥大化しすぎる問題
筆者は以前、決済・在庫引き当て・通知送信まで全部Orderモデルのメソッドに詰め込み、1つのモデルファイルが1000行を超えた経験があります。
こうなると今度は「Modelを開けば全部わかる」はずが「Modelを開いても目的の処理が見つからない」状態になり、本末転倒です。
Fat Modelはあくまで「単一モデルの責務に閉じたロジック」を置く場所であり、複数モデルをまたぐ処理・外部サービス呼び出しまで詰め込む場所ではありません。
Service層・Formオブジェクトという第三の置き場所
複数モデルをまたぐ処理や、外部APIとの連携が絡む処理は、services.pyのようなService層に切り出すのが実務的な落としどころです。
# services.py
def confirm_order_and_notify(order: Order, user) -> None:
order.confirm()
InventoryService.reserve(order)
send_order_confirmation_email(user, order)
Viewからはこの関数1つを呼ぶだけで済み、Modelは「自分自身の状態管理」に責務を絞れます。
フォームのバリデーションが複雑になる場合は、forms.Formを「入力値の変換・検証専用オブジェクト」として活用する設計も併用されます。
まとめ
この記事のポイント
- Fat Model・Skinny Viewは「Viewを薄く、業務ロジックをModelに寄せる」設計指針
- カスタムマネージャー・QuerySet・モデルメソッド・propertyでロジックを一箇所に集約する
- Modelに何でも詰め込みすぎると今度はModelが肥大化する
- 複数モデルをまたぐ処理・外部連携はService層に切り出すのが実務的
次に読むべき記事
Fat Modelの延長として、APIを設計する際のシリアライザの責務分担を次回解説します。
→ 次の記事:Django REST Frameworkでシリアライザとアーキテクチャを設計する