Backend / Web frameworks / Django / 14_service_layer_vs_fat_models.md

Service layer vs fat models in Django

Updated 5 interview angles 6 min read source
On this page10
  1. What each entry point actually runs
  2. Invariants belong in the database
  3. A service function
  4. Reads: selectors that return QuerySets
  5. Choosing a placement
  6. Testing the layers
  7. Where it breaks in production
  8. The trade not taken
  9. Related
  10. Interview angle

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

text
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: 2
  • clean() 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 by save(), not by clean().
  • save() and signals are skipped by update(), 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

python
class Order(models.Model):
    total = models.IntegerField()

    class Meta:
        constraints = [
            models.CheckConstraint(condition=models.Q(total__gte=0),
                                   name="order_total_gte_0"),
        ]
text
QuerySet.update   IntegrityError: CHECK constraint failed: order_total_gte_0
bulk_create       IntegrityError: CHECK constraint failed: order_total_gte_0

Every 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

python
# 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 order

The 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() or F(); atomic alone does not stop lost updates (Django and DRF: the request path).
  • Side effects go through on_commit, never inline:
text
transaction rolled back ("payment declined"):
  emails sent directly: 1   via on_commit: 0   orders saved: 0

The 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:

text
selector returns list(...), view reads o.customer.name    51 queries
selector returns a QuerySet, view adds select_related       1 query

A 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.

python
@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 > 0

captureOnCommitCallbacks (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).

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() and bulk_create() skip it: both wrote negative totals that save() rejects. A CheckConstraint stopped both.
  • “Does DRF run Model.clean()?” - no; only forms and the admin do. Measured: the serializer path ran save() and never clean().
  • “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. Use transaction.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_commit runs in the same process; a crash between commit and enqueue loses it. Write an outbox row in the same transaction.
  • “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.