Escape fat views and fat models with a pragmatic clean architecture for Django: a service layer for business operations, selectors for reads, clear domain/framework boundaries, transactions and validation in the right place, and business logic you can test without a request.
Django's defaults gently push business logic into two bad places: fat views that mix HTTP handling with domain rules, and fat models that become god-objects nobody dares touch. It works until the app grows, then every change ripples unpredictably and tests need a full request to exercise a rule. This tutorial covers a pragmatic clean architecture for Django — a service layer, selectors, clear boundaries, and honest testing — that keeps business logic findable and changeable without turning a web app into an enterprise cathedral.
Two anti-patterns dominate growing Django codebases. The fat view puts orchestration, validation, business rules, emails, and payment calls all inside a view function, so the logic is bound to HTTP and cannot be reused or tested without a request. The fat model reacts by dumping everything onto the model instead, until a single class has fifty methods spanning unrelated concerns and every save risks a surprise side effect. Both scatter business logic where it is hard to find and harder to change. The goal of clean architecture is a single, obvious place for domain logic that does not depend on the web framework.
Ignore the enterprise diagrams; for Django it comes down to one principle: separate the layers by responsibility and control the direction of dependencies. HTTP concerns live in views, domain logic lives in a service layer, and data access lives behind the ORM — and the domain logic does not reach up into HTTP. You are not adding ten abstraction layers; you are drawing a couple of clear lines so that "what the app does" is not tangled with "how it is delivered over the web".
The core move is a service layer: plain functions (or modules) that encapsulate a business operation — create an order, cancel a subscription, transfer funds. A service takes primitives or domain objects, does the work in a transaction, and returns a result. It does not know about requests, responses, or status codes.
# services.py
def place_order(*, user, cart, address):
with transaction.atomic():
order = Order.objects.create(user=user, address=address, total=cart.total())
order.items.bulk_create(cart.to_items(order))
charge = payments.charge(user, cart.total(), idempotency_key=f"order-{order.id}")
order.mark_paid(charge.id)
email.send_order_confirmation(order) # side effects after commit
return order
Now the operation has one home. A view calls it, a management command calls it, a Celery task calls it, a test calls it — all with the same guarantees, none duplicating the logic.
With services in place, the view shrinks to what it should be: parse and validate input, call one service, translate the result into a response. It is the HTTP adapter, nothing more.
def checkout(request):
form = CheckoutForm(request.POST)
if not form.is_valid():
return render(request, "checkout.html", {"form": form})
order = place_order(user=request.user, cart=request.cart, address=form.cleaned_data["address"])
return redirect("order_detail", pk=order.pk)
A view that is only orchestration is easy to read and rarely needs to change when business rules do — the rule change happens in the service, and every caller benefits at once.
Models should describe data and the small invariants intrinsic to a single record — a property, a simple derived value, a state-transition method that only touches its own fields. Cross-object workflows, orchestration, and rules that span several models belong in services, not on a model. The test for whether logic belongs on a model is simple: does it concern only this one object's own state? If it coordinates several objects or reaches into other systems, it is a service.
Mirror services with selectors for read logic — functions that encapsulate a query, especially a complex or reused one. This keeps views and templates from growing ad-hoc querysets and gives read logic the same findability as write logic.
# selectors.py
def active_orders_for(user):
return (Order.objects.filter(user=user, status="active")
.select_related("address").prefetch_related("items"))
Separating the command side (services that change state) from the query side (selectors that read it) is a lightweight nod to CQRS that pays off in clarity long before you need anything heavier.
The deeper idea is that your domain logic should not depend on the delivery mechanism. Views, serializers, and templates are the web boundary; services and selectors are the domain. The domain can be exercised without spinning up a request, which is exactly what makes it testable and reusable. You do not need to abstract the ORM away entirely — that is usually over-engineering in Django — but you do want business rules that a Celery task or a CLI can invoke identically to a web request.
Clean architecture is really a rule about which way dependencies point: outer layers (HTTP, tasks, CLI) depend on inner layers (services, domain), never the reverse. A service must never import a view or reach for request; if it needs the acting user or their locale, that is passed in as an argument. Keeping the arrows pointing inward is what lets you change the web layer — add an API alongside the HTML, swap a template for a SPA — without touching the domain at all.
Validation splits by kind. Input validation — is this a well-formed email, is this field required — belongs in forms and serializers at the boundary. Business validation — can this user actually place this order, is there enough stock — belongs in the service, because it is a domain rule that must hold no matter which caller invokes it. Putting business rules only in a form means a management command or API that bypasses the form also bypasses the rule, which is how invariants get violated in the paths you forgot about.
The service is the natural transaction boundary: a business operation should be atomic, so wrap it in transaction.atomic() and let it commit or roll back as a unit. Crucially, defer side effects that cannot be rolled back — emails, webhooks, external charges you have already confirmed — until after commit, using transaction.on_commit, so a late failure does not send a confirmation for an order that never saved. Owning the transaction is one of the service layer's biggest wins over scattered logic that half-commits.
The real payoff is tests. Because a service is a plain function with explicit inputs, you test business logic directly — no client, no URLs, no templates — which is faster and far more focused than driving everything through the view.
def test_place_order_charges_and_marks_paid(db):
order = place_order(user=user, cart=cart, address=addr)
assert order.status == "paid"
assert order.items.count() == 2
View tests then shrink to checking the HTTP adapter — right status, right redirect, form errors rendered — while the dense business-rule tests live against services where they are cheap to write and quick to run.
The two failure modes are opposite. Over-engineering imports enterprise patterns wholesale — repositories wrapping the ORM, interfaces for everything, a DTO for every model — adding indirection a Django app rarely needs and slowing everyone down. Under-doing it creates a service layer in name only, where services are anemic pass-throughs and the real logic still lives in views and models. Aim for the pragmatic middle: services and selectors as the home for real logic, thin views and models, and no abstraction you cannot justify by a concrete need.
A subtle source of coupling is passing ORM model instances everywhere, so that every layer — templates, serializers, external calls — depends on your database schema. For most Django apps this is fine and abstracting it away is over-engineering, but at the seams that matter you gain from a plain data transfer object: a small dataclass carrying exactly the fields a boundary needs, decoupled from the model. Returning a DTO from a service to an external integration means a schema change does not ripple into code that had no business knowing your columns. Use DTOs surgically at real boundaries, not as a blanket rule, and you get the decoupling without the ceremony.
Payments, email, search, and third-party APIs are the parts of a system most likely to change and hardest to test if called inline. Put each behind a small gateway — a thin module exposing what your domain needs (payments.charge(...)) and hiding the vendor SDK behind it. Services depend on the gateway, not the vendor, so swapping Stripe for another processor, or faking payments in tests, touches one file instead of scattering vendor calls through your business logic. This is the one place the "depend on abstractions" rule earns its keep even in a modest Django app.
# gateways/payments.py — the domain calls this, not stripe directly
def charge(user, amount, *, idempotency_key):
intent = stripe.PaymentIntent.create(
amount=amount, currency="eur", customer=user.stripe_customer_id,
idempotency_key=idempotency_key)
return Charge(id=intent.id, status=intent.status)
Services should raise meaningful domain exceptions — InsufficientStock, PaymentDeclined — not return ambiguous None or leak framework errors. The boundary then maps each domain exception to the right HTTP response or user message, keeping the translation between "what went wrong in the domain" and "what the user sees" in one predictable place. This gives you precise, testable error paths: a test asserts the service raises PaymentDeclined, and separately a view test asserts that exception renders the right message, without either concern bleeding into the other.
Structure follows the layers. A common Django-friendly layout gives each app a services.py (or a services/ package), a selectors.py, thin models.py and views.py, and a gateways/ package for external systems. The point is discoverability: a new developer knows business operations live in services, reads live in selectors, and the web layer is thin — so they can find and change a rule in seconds instead of grepping through fat views. Consistency across apps matters more than the exact filenames; pick a convention and hold to it.
You do not rewrite a working app to adopt this — you migrate it one workflow at a time. Find the messiest, most-duplicated piece of logic, extract it into a service with tests, and point every caller at it. Repeat where the pain is real. Over a few months the important logic accretes into services and selectors while the rest of the app keeps working untouched. Incremental extraction driven by actual pain is how clean architecture arrives in a real codebase — not as a big-bang rewrite, but as steady, testable simplification of the parts that hurt.
The short place_order sketch earlier shows the shape of a service, but it glosses over the parts that break in production: stock that two customers try to buy at once, a card that is declined after rows have been written, and a confirmation email that must never go out for an order that did not stick. This section builds the same operation out properly, layer by layer, so you can see how the ideas in this tutorial fit together in one realistic workflow.
Start with the vocabulary of failure. A small exception hierarchy per app gives every caller something precise to catch, and a common base class lets a boundary catch "any order problem" when it does not care which one.
# orders/exceptions.py
class OrderError(Exception):
"""Base class for business-rule failures raised by order services."""
class InsufficientStock(OrderError):
def __init__(self, sku, requested, available):
super().__init__(f"{sku}: requested {requested}, only {available} left")
self.sku = sku
self.requested = requested
self.available = available
class PaymentDeclined(OrderError):
pass
Carrying structured attributes (sku, available) rather than just a message matters: the HTML view can render a friendly sentence, the API can return machine-readable fields, and tests can assert on the exact values without parsing strings.
The naive version charges the card inside the same transaction that writes the order. That has two problems. First, it holds database row locks for the full duration of a network call to the payment provider, which under load turns a slow provider into a stalled database. Second, if the charge succeeds but the transaction later rolls back, you have taken money for an order that does not exist. The robust shape splits the work into a short reservation transaction, the external call outside any transaction, and a short confirmation transaction. In this version the payment gateway translates vendor-specific declines into the domain's PaymentDeclined, so the service never sees a vendor exception type.
# orders/services.py
from django.db import transaction
from django.db.models import F
from inventory.models import StockItem
from orders.exceptions import InsufficientStock, PaymentDeclined
from orders.gateways import email, payments
from orders.models import Order, OrderItem
def place_order(*, user, cart, address, payment_gateway=payments):
order = _reserve_order(user=user, cart=cart, address=address)
try:
charge = payment_gateway.charge(
user, order.total_cents, idempotency_key=f"order-{order.pk}"
)
except PaymentDeclined:
release_order(order_id=order.pk)
raise
with transaction.atomic():
order = Order.objects.select_for_update().get(pk=order.pk)
order.mark_paid(charge.id)
transaction.on_commit(lambda: email.send_order_confirmation(order.pk))
return order
def _reserve_order(*, user, cart, address):
lines = cart.lines() # [(sku, quantity, unit_price_cents), ...]
skus = sorted({sku for sku, _, _ in lines})
with transaction.atomic():
stock = {
item.sku: item
for item in StockItem.objects.select_for_update()
.filter(sku__in=skus)
.order_by("sku")
}
for sku, qty, _ in lines:
available = stock[sku].quantity if sku in stock else 0
if available < qty:
raise InsufficientStock(sku, qty, available)
order = Order.objects.create(
user=user,
address=address,
status=Order.Status.PENDING,
total_cents=sum(qty * price for _, qty, price in lines),
)
OrderItem.objects.bulk_create(
OrderItem(order=order, sku=sku, quantity=qty, unit_price_cents=price)
for sku, qty, price in lines
)
for sku, qty, _ in lines:
StockItem.objects.filter(sku=sku).update(quantity=F("quantity") - qty)
return order
def release_order(*, order_id):
with transaction.atomic():
order = Order.objects.select_for_update().get(pk=order_id)
if order.status != Order.Status.PENDING:
return order # already paid or released; releasing twice is a no-op
for item in order.items.all():
StockItem.objects.filter(sku=item.sku).update(
quantity=F("quantity") + item.quantity
)
order.status = Order.Status.FAILED
order.save(update_fields=["status"])
return order
A few details are doing real work here. The stock rows are locked in a deterministic order (order_by("sku")) so that two concurrent checkouts touching the same products cannot deadlock by acquiring locks in opposite orders. Stock is decremented with an F() expression so the database performs the arithmetic, not Python. The payment gateway is a keyword argument with a production default, which is the lightest possible form of dependency injection: production code never passes it, tests pass a fake. And release_order is idempotent — calling it twice, or calling it on an order a webhook has already marked paid, does nothing harmful. That property matters because retries, timeouts and duplicate webhook deliveries are normal in production, not exotic.
The split also creates a new state to reason about: an order can sit in PENDING if the process dies between the charge and the confirmation. That is not a flaw of the design, it is an honest model of reality — the payment provider really may have charged the card while your worker was being killed. Handle it with a reconciliation job or a provider webhook that looks up pending orders older than a few minutes and either confirms or releases them, reusing the same idempotency key so the provider never double-charges.
The layering gives you a natural split of test responsibilities. Each layer is tested for what it owns, and no test has to drive the whole stack to check a single rule.
| Layer | What to assert | Typical tools | Relative volume |
|---|---|---|---|
| Services | Business rules, state transitions, raised domain exceptions, side effects registered | Database-backed tests, fake gateways | Most tests live here |
| Selectors | Correct filtering, permission scoping, fixed query count | assertNumQueries | One or two per selector |
| Gateways | Correct translation to and from the vendor API | Mocked HTTP or the vendor's test mode | Few, focused |
| Views and API | Status codes, redirects, error mapping, permissions | Django test client, DRF APIClient | A handful per endpoint |
Because services receive the gateway as an argument, a tiny hand-written fake is usually clearer than patching. It records calls, can be told to fail, and does not break when you rename an internal import path — which is the usual way mock.patch-heavy tests rot.
# orders/tests/fakes.py
from dataclasses import dataclass, field
from orders.exceptions import PaymentDeclined
from orders.gateways.payments import Charge
@dataclass
class FakePayments:
decline: bool = False
calls: list = field(default_factory=list)
def charge(self, user, amount, *, idempotency_key):
self.calls.append((user.pk, amount, idempotency_key))
if self.decline:
raise PaymentDeclined(idempotency_key)
return Charge(id=f"ch_test_{len(self.calls)}", status="succeeded")
# orders/tests/test_services.py
from django.core import mail
from django.test import TestCase
class PlaceOrderTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.user, cls.address = make_customer()
StockItem.objects.create(sku="WIDGET", quantity=10)
def test_declined_payment_releases_stock_and_fails_order(self):
cart = make_cart(("WIDGET", 2, 1500))
with self.assertRaises(PaymentDeclined):
place_order(user=self.user, cart=cart, address=self.address,
payment_gateway=FakePayments(decline=True))
self.assertEqual(StockItem.objects.get(sku="WIDGET").quantity, 10)
self.assertEqual(Order.objects.get().status, Order.Status.FAILED)
def test_insufficient_stock_writes_nothing(self):
cart = make_cart(("WIDGET", 11, 1500))
fake = FakePayments()
with self.assertRaises(InsufficientStock) as ctx:
place_order(user=self.user, cart=cart, address=self.address,
payment_gateway=fake)
self.assertEqual(ctx.exception.available, 10)
self.assertFalse(Order.objects.exists())
self.assertEqual(fake.calls, [])
def test_confirmation_email_sent_only_after_commit(self):
cart = make_cart(("WIDGET", 1, 1500))
with self.captureOnCommitCallbacks(execute=True) as callbacks:
place_order(user=self.user, cart=cart, address=self.address,
payment_gateway=FakePayments())
self.assertEqual(len(callbacks), 1)
self.assertEqual(len(mail.outbox), 1)
The last test deserves a note. Django's TestCase wraps each test in a transaction that is rolled back, not committed, so on_commit callbacks never fire on their own. captureOnCommitCallbacks(execute=True) collects them and runs them when the block exits, letting you assert both that the side effect was deferred and what it did. (If you use pytest-django, the django_capture_on_commit_callbacks fixture provides the same behaviour.) Without it, you either miss the side effect entirely or reach for TransactionTestCase, which is much slower because it truncates tables between tests.
Most problems teams hit after adopting a service layer fall into a small number of recognisable patterns:
| Symptom | Likely cause | Fix |
|---|---|---|
| Customers receive confirmation emails for orders that do not exist | Side effect sent inside a transaction that later rolled back, often because of ATOMIC_REQUESTS | Register the side effect with transaction.on_commit |
| Service test passes, but the email test sees an empty outbox | on_commit callbacks never fire inside TestCase | Wrap the call in captureOnCommitCallbacks(execute=True) |
ImportError from a circular import between two apps' services | Service A imports service B at module level and vice versa | Import the module rather than names, move the shared rule into one app, or import inside the function as a last resort |
| Intermittent deadlocks under load | Rows locked with select_for_update in inconsistent order across services | Always lock in a deterministic order (for example by primary key) and keep transactions short |
| Pages slow down as data grows | A selector lost its select_related/prefetch_related, or a caller chained a filter that bypassed it | Add an assertNumQueries test for the selector |
Services have become one-line pass-throughs to Model.objects.create | Layer adopted mechanically for every model | Let simple CRUD stay in forms or generic views; reserve services for operations with rules |
A small app or prototype does not need this — Django's defaults are productive and the ceremony would slow you down. Reach for a service layer when logic starts duplicating across views, tasks, and commands; when views grow past comfortable reading; or when tests require elaborate request setup to check a simple rule. Introduce it incrementally, extracting the messiest workflow into a service first and growing the pattern where it earns its place, rather than rewriting a working app into layers it may never need.