Service layer vs fat models in Django
Article.
Django’s own answer to “where does business logic go?” is the model: “fat models, thin views”, Active Record style. A large part of the community moved it into service functions instead (the HackSoft Django Styleguide), and a third camp keeps it on custom QuerySet and Manager methods. The senior answer is not a camp. It is knowing which entry points run which code, because each placement has paths that skip it.
Where does business logic live in your Django app, and what runs it after
QuerySet.update()? Every result below is from Django 6.1.2 and DRF 3.18.3,
with an Order whose clean() and save() both reject a negative total.
What each entry point actually runs
total = -5, rejected in both clean() and save() save clean post_save
Order(...).save() 1 0 0 rejected
Order.objects.create(...) 1 0 0 rejected
ModelForm 0 1 0 rejected
DRF ModelSerializer 1 0 0 rejected
QuerySet.update(total=-5) 0 0 0 accepted
bulk_create([...]) 0 0 0 accepted
orders with a negative total in the table: 2clean()runs only through forms (and the admin, which uses forms).save()does not call it, and DRF serializers do not either; the serializer above was stopped bysave(), not byclean().save()and signals are skipped byupdate(),bulk_create(),bulk_update()and raw SQL. The update rewrote an existing valid order to -5, and the bulk insert added another.
So “the model enforces it” holds only for the paths that happen to call the method the rule lives in.
Invariants belong in the database
class Order(models.Model):
total = models.IntegerField()
class Meta:
constraints = [
models.CheckConstraint(condition=models.Q(total__gte=0),
name="order_total_gte_0"),
]QuerySet.update IntegrityError: CHECK constraint failed: order_total_gte_0
bulk_create IntegrityError: CHECK constraint failed: order_total_gte_0Every path ends in SQL, so a constraint is the one rule no code path can skip.
CheckConstraint, UniqueConstraint (including conditional and
expression-based) and foreign keys cover single-row and uniqueness rules;
cross-row rules need an exclusion constraint, a trigger, or a transaction
with locking (Transactions and ACID).
Application code then handles the message to the user; the database holds
the rule.
A service function
# orders/services.py
@transaction.atomic
def place_order(*, customer: Customer, items: list[ItemIn]) -> Order:
stock = Stock.objects.select_for_update().filter(sku__in=[i.sku for i in items])
... # check and decrement stock
order = Order.objects.create(customer=customer, total=total)
transaction.on_commit(lambda: send_confirmation.delay(order.id))
return orderThe shape that works:
- Keyword-only arguments, plain values or model instances in, a model instance out.
- The transaction boundary is the service, not the view: the same function is called from a view, a Celery task, a management command and an admin action, and each gets the same atomicity.
- Read-modify-write uses
select_for_update()orF();atomicalone does not stop lost updates (Django and DRF: the request path). - Side effects go through
on_commit, never inline:
transaction rolled back ("payment declined"):
emails sent directly: 1 via on_commit: 0 orders saved: 0The direct call told a customer about an order that does not exist.
on_commit (or Celery’s delay_on_commit) runs only after the commit; for
“must not be lost even if the process dies right after commit”, use an outbox
(The transactional outbox).
Reads: selectors that return QuerySets
The HackSoft style pairs services (writes) with selectors (reads). The mistake is making a selector return a list:
selector returns list(...), view reads o.customer.name 51 queries
selector returns a QuerySet, view adds select_related 1 queryA list freezes the query: the caller can no longer add select_related,
prefetch_related, only() or pagination, so every serializer that touches
a relation pays an N+1
(N+1 in Django: select_related, prefetch_related, fetch modes).
Return QuerySets from selectors, or define the read methods on a custom
QuerySet, which is the third camp’s point: Order.objects.paid().for_customer(c)
composes and stays lazy.
Choosing a placement
| Logic | Put it in |
|---|---|
| single-row rule | database constraint, plus clean() for forms |
| derived instance value | model method or property |
| reusable query | custom QuerySet method |
| multi-model use case | service function |
| side effects | service, via on_commit |
| request/response shape | view and serializer only |
Fat models fail when one model’s methods start orchestrating others, calling
HTTP APIs, or enqueuing tasks; services fail when they become one-line
wrappers around Model.objects.create.
Use services where a use case spans models or needs a transaction, and leave simple CRUD to the model and the serializer.
Testing the layers
Services are what make the use case testable without HTTP: call the function, assert on the database and on what was scheduled.
@pytest.mark.django_db(transaction=True)
def test_confirmation_sent_after_commit(django_capture_on_commit_callbacks):
with django_capture_on_commit_callbacks(execute=True) as callbacks:
order = place_order(customer=customer, items=[ItemIn(sku="A1", qty=1)])
assert len(callbacks) == 1
assert Order.objects.get(pk=order.pk).total > 0captureOnCommitCallbacks (pytest-django’s django_capture_on_commit_callbacks)
is needed because TestCase wraps each test in a transaction that never
commits, so on_commit callbacks would otherwise never run. The same service
is then exercised through one view test, not re-tested at every entry point.
Where it breaks in production
A rule that only some paths enforce
A discount rule lives in a serializer’s validate(). The nightly Celery job
creates orders through Order.objects.create() and never runs it; a support
script uses update(). The rule is enforced for the website and nowhere else.
Move it to a service every path calls, and the hard part of it into a
constraint.
Signals as business logic
A post_save signal sends the confirmation email. bulk_create skips it, so
imported orders get no email; and inside a transaction that later rolls back
it sends one for an order that never existed. Signals hide control flow; call
the side effect explicitly from the service, through on_commit.
Repositories over the ORM
Wrapping the ORM in repository classes that return lists or plain objects
loses everything that makes the ORM efficient, composable QuerySets and
eager loading, and the N+1 above follows. Django’s ORM is Active Record: there
is no unit of work to abstract over, and transaction.atomic is a transaction
boundary, not one
(Repository and Unit of Work Patterns).
The trade not taken
The far end is a hexagonal core: persistence-ignorant domain classes,
repositories and mappers, Django only as an adapter. It pays off when the core
must run without Django or against several stores. In a Django project it
costs the admin, ModelForm, ModelSerializer and migration autodetection,
which all expect the model to be the domain object.
Most teams get the benefit they want, testable use cases with clear transaction boundaries, from service functions over ordinary models, with the invariants in the database (Clean Architecture).
Related
Interview angle 5
- “Where does business logic live in your Django app?” - use cases that
span models in service functions that own the transaction; single-row
rules as database constraints; reusable reads as QuerySet methods.
- Follow-up: “Why not in
save()?” -update()andbulk_create()skip it: both wrote negative totals thatsave()rejects. ACheckConstraintstopped both.
- Follow-up: “Why not in
- “Does DRF run
Model.clean()?” - no; only forms and the admin do. Measured: the serializer path ransave()and neverclean(). - “Your service sends an email inside
atomic, then the transaction rolls back. What happened?” - the email went out for an order that does not exist. Usetransaction.on_commit, or an outbox if it must survive a crash. - “Would you put a repository over the Django ORM?” - rarely: returning
lists loses QuerySet composability, 51 queries instead of 1 here. Return
QuerySets from selectors or custom QuerySet methods.
- Follow-up: “And if it must not be lost when the process dies right after
commit?” -
on_commitruns in the same process; a crash between commit and enqueue loses it. Write an outbox row in the same transaction.
- Follow-up: “And if it must not be lost when the process dies right after
commit?” -
- “Fat models or services?” - both, by kind of logic: the instance’s own derived data on the model, orchestration in services, invariants in the database.