The service-layer debate: fat models versus services
The best-practices module said "fat models, thin views" and then admitted the slogan runs out when logic spans several models or external systems. That is where the service layer enters — and it is the most genuinely debated architecture question in the Django community. There is no single right answer, which is exactly why you need to understand the trade-off rather than follow a rule. This lesson lays out both sides honestly and gives you a way to decide.
The question
Where does a business operation that touches several models and maybe external systems live? "Complete an appointment": set its status, compute the fee, record a payment, charge the card via a gateway, send an SMS. Three positions compete:
- Fat models — put it on the most relevant model as a method (
appointment.complete()). - Service layer — put it in a separate function or module (
services.complete_appointment(appt)) that orchestrates the models. - In the view — the beginner default, already rejected: untestable, unreusable, and mixing HTTP with business logic.
Position 3 is settled (do not). The real debate is between 1 and 2, and both are defensible.
The case for fat models (the Django-idiomatic default)
Django's design leans towards fat models: the ORM is Active Record (data and behaviour together), and putting behaviour on the object it concerns is idiomatic. For most operations this is simplest and best:
class Appointment(models.Model):
def complete(self):
with transaction.atomic():
self.status = self.Status.DONE
self.fee = self._fee_for_patient()
self.save()
Payment.objects.create(appointment=self, amount=self.fee)
Advantages: it is discoverable (behaviour lives with the data), idiomatic (Django expects it), and needs no extra layer. For a clinic app of moderate size, fat models handle the large majority of operations cleanly. Fat models are the right default — reach for a service layer only when a model method starts to strain.
The case for a service layer
Fat models strain in specific, recognisable ways, and that is when a service layer earns its place:
# services.py — a plain function orchestrating an operation
def complete_appointment(appointment, *, charge_card=True):
with transaction.atomic():
appointment.status = Appointment.Status.DONE
appointment.fee = calculate_fee(appointment)
appointment.save()
payment = Payment.objects.create(appointment=appointment, amount=appointment.fee)
if charge_card:
payment_gateway.charge(payment) # external system — NOT on the model
sms.send_receipt(appointment.patient) # external system
return payment
The service layer is justified when an operation:
- Spans several models such that no single model is the obvious home (which model owns "complete appointment + payment + SMS"?).
- Coordinates external systems — a payment gateway, an SMS provider, a lab API. Putting these on a model couples your data model to third-party services and makes it hard to test.
- Represents a process, not an object's behaviour — a workflow with several steps that does not belong to any one entity.
- Would make a model a "god object" — a model accreting dozens of methods for operations only loosely about it.
A service is just a function (Python does not need a Service class — that is the Java transliteration
the previous lesson warned against). It keeps the model focused on being an appointment and the process of
completing one in a clearly-named function that views, commands, tasks and tests all call.
The honest debate, and the trap
Here is the genuinely contested part, stated fairly:
- The "always use a service layer" camp (influential in some Django circles) argues that business logic should never be on models or in views — always in services — for consistency and testability. It produces a very clear structure in large teams.
- The "fat models, services only when needed" camp (closer to idiomatic Django) argues that a service layer everywhere is over-engineering for most projects: it adds a layer of indirection and anaemic models (models reduced to bare fields with no behaviour), which throws away what the ORM gives you.
The trap to avoid is cargo-culting either extreme. Putting every one-line operation in a service is ceremony; refusing a service for a genuinely cross-cutting, external-system-touching process makes a model a coupled mess. The mature position: fat models by default; a service function when an operation genuinely outgrows a single model or reaches an external system. Let the code tell you — when a model method starts importing a payment gateway or coordinating three models, that is the signal to extract a service.
How to decide, in practice
A short procedure for any operation:
- Is it the behaviour of one object, using only your own models? → a model method (fat model).
- Does it touch an external system, or coordinate several models as a process? → a service function.
- Is it just query logic (selecting rows)? → a manager/queryset method (next lesson), not either of the above.
- Whichever you choose, keep it out of the view — the view calls it.
For Nidaan: patient.age() is a model method; appointment.complete() might start as a model method and
become services.complete_appointment() once it charges a card and sends an SMS;
Appointment.objects.booked() is a manager method. The point is not a dogma but a fit — put each piece of
logic where it naturally belongs, default to the model, and extract a service when the model is no longer
the honest home.
Check your work
The question. Where a multi-model/external-system operation lives — fat model, service layer, or (never) the view.
Fat models (default). Behaviour of one object, on that object; idiomatic to Django's Active Record ORM; handles most operations — the right default.
Service layer (when needed). A plain function orchestrating an operation that spans several models,
touches external systems, is a process rather than an object's behaviour, or would make a model a god
object. It is a function, not a Service class.
The honest debate. "Always services" (consistency, but over-engineering + anaemic models for most apps) versus "fat models, services when needed" (idiomatic). Avoid cargo-culting either extreme.
How to decide. One object + own models → model method; external system / multi-model process → service function; selecting rows → manager method; always keep it out of the view.
Practice
- Take
appointment.complete()as a model method; then extend it to charge a card and send an SMS, and argue at what point it should become a service function. - Write the service-function version and note it is a plain function, not a class — reason about why a
Serviceclass would be un-Pythonic. - Classify five Nidaan operations as model method / service function / manager method, and justify each.
- Find (or imagine) a model that has become a god object; identify which methods should move to services.
- Argue both sides of the "always use a service layer" debate in a paragraph each, then state your position for a moderate-sized clinic app.
- Take an operation currently in a view and decide, using the four-step procedure, where it should go.
Official documentation
- Django — Model methods — The fat-model approach.
- Django — Design philosophies (loose coupling) — Why Django favours cohesion and loose coupling.
- Two Scoops of Django (service-layer discussion) — A well-known treatment of where business logic belongs.
Next: custom managers as the repository pattern.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship