What belongs in a service function — and what does not
coreadvancedA **service function** is one named business operation — `place_order`, `cancel_subscription`, `refund_payment` — that touches several models, owns its transaction, and can be called from a view, a management command, a Celery task or a test. It exists when an operation is more than "save this form". Django's section closes with the caveat that matters: know when it improves clarity, and when it is unnecessary abstraction.
Think of it as
The test for whether an operation deserves a service function is whether it has a *name* in the business. "Place an order" is a thing the company does: it validates stock, transitions a state, writes an invoice row, records who did it, and schedules a confirmation email. "Update a user's display name" is not — it is a field assignment wearing a business hat. The first belongs in a service; the second belongs in a form or a serializer, and wrapping it in `update_user_display_name(user, name)` adds a file, an import and a test for no gain, which is exactly the unnecessary abstraction the roadmap warns about. What a service function is *for* becomes clear once you ask what the alternatives cost. Put a multi-model workflow in a view and it is reachable only over HTTP: the admin cannot run it, a management command has to duplicate it, a Celery task has to reimplement it, and the test needs a request. Put it in `Model.save()` and it fires on every write, including bulk loads, fixtures and the admin — where it is usually wrong — and you cannot pass it the extra context it needs, like who is acting. Put it in a signal and it becomes invisible: it runs inside someone else's transaction, at a time you did not choose, and the traceback points at `save()`. A service function is none of those things — a plain function, with an explicit signature, in a place you can find. Three conventions make them consistent, and they are worth adopting wholesale. Keyword-only arguments, because `place_order(user, items)` and `place_order(items, user)` are both plausible and only one is right, and because adding a parameter later cannot then break a positional caller. No `request` in the signature: take the actor as a `user` argument instead, so the function is callable from a task or a command where no request exists. And raise domain exceptions rather than returning HTTP responses — `InsufficientStock` rather than `HttpResponseBadRequest` — so the caller decides how to present the failure and the same function serves an HTML view, a JSON API and a command. The last piece is where things stay. Data access stays on managers and querysets, because that is Django's own place for it and a service that writes raw filters everywhere has just moved the query mess to a new file. Field-level validation stays on forms and serializers, because they exist to turn untrusted input into trusted values. Invariants that must hold for every row stay in the database as constraints. The service is the layer above all of those: it decides what happens, in what order, and inside which transaction.
What we're doing: Move one multi-model workflow out of a view so the same operation serves an API, a management command and the admin.
- 2–4
- A domain exception rather than an HTTP response. This single choice is what lets the same function serve an API that returns 409, a command that exits non-zero, and a task that retries.
- 11–13
- Query logic stays on the manager. The service decides *what happens*; how to find the rows is Django's job and belongs where every other queryset lives.
- 17–21
- One transaction covering the whole workflow. This is the property the roadmap's own example points at, and it is the main reason a service exists rather than three view functions.
- 23–25
- `on_commit` for the side effect. Scheduling inside the transaction can hand a worker an order id that is not visible yet — or, on rollback, one that never existed.
- 31–41
- The view does two things only: validate input, and translate a domain exception into a status code. Everything a reviewer would call "business logic" is one function call away.
Why this works: One operation, three callers, one transaction, and no duplication — while validation stays in the serializer and query logic stays on the manager.
Putting the workflow in `Model.save()`
Wrong
Better
What you see: Loading a fixture sends real emails. A support agent editing a delivery address in the admin raises a second invoice. A data-repair script triggers a thousand confirmations.
Why: `save()` means "persist this object", and every part of Django calls it: the admin, ModelForms, fixtures, data migrations, bulk-loading scripts and your own repair commands. Attaching a business workflow to it makes that workflow fire in all of those contexts, most of which are not the business operation at all — and there is no way to say "save, but this time do not invoice", because `save()` has no place to take an actor or an intent. Worse, the side effects run inside whatever transaction the caller happens to have open, so a rollback leaves an email already sent. Keeping the workflow in a named function means it runs exactly when someone asks for that operation, with the arguments it actually needs, and `save()` goes back to meaning persistence.
- Whole: def place_order(*, user, items, idempotency_key=None) -> Order:
- def place_order — A business operation, named as one: The name is the test. If the company would say "we place an order", it is a service function; if the name is `update_x_field`, it is a form doing paperwork.
- *, — Keyword-only, always: Callers must write `place_order(user=…, items=…)`. Positional arguments to a business operation are ambiguous at the call site and become breaking changes the moment a parameter is added.
- user — The actor — not `request`: Taking a user rather than a request is what makes the function callable from a Celery task, a management command and a test. It is also what lets the operation record who did it.
- items — The inputs, already validated: Forms and serializers turn untrusted input into trusted values; the service receives the result. Re-validating field formats here duplicates work the framework already did well.
- idempotency_key=None — Optional, because the callers differ: An API caller can supply a key so a retried request cannot place two orders; an internal command has no such need. Defaulting to `None` keeps one function serving both.
- -> Order — Returns a domain object, not a response: The caller decides presentation: a view renders it, an API serialises it, a command prints an id. Returning `JsonResponse` here would tie the operation to HTTP.
Where the same logic could live, and what each choice costs
Together
When a service function is clarity, and when it is ceremony
Together
Remember: A service function is one named business operation that touches several models and owns its transaction — the test is whether the business has a name for it. Keep it callable from anywhere: keyword-only arguments, a `user` rather than a `request`, domain exceptions rather than HTTP responses, and a domain object returned. Leave data access on managers and querysets, field validation on forms and serializers, and invariants in database constraints. And take the section's own caveat seriously: a `services.py` full of one-line pass-throughs is the ceremony it warns about, not the pattern.
See also: transaction boundaries and external calls · layered clean and hexagonal · manager and queryset pairing · side effects and when to avoid signals

