Filter concepts by levelShowing all levels.

Django · Section 100

Service Layer

Level
advanced
Read
20 min
Concepts
2

The roadmap's own example is `create_order(*, user, items)` wrapped in `transaction.atomic()`, and the shape of that one function is most of what a service layer is. It exists for operations the business would name — placing an order, cancelling a subscription, refunding a payment — that touch several models and need one transaction, not for every field update wearing a business hat. The test is whether the operation has a name outside the code: "place an order" is one, "update a display name" is not, and wrapping the second in a service function is exactly the unnecessary abstraction the section's own closing line warns against. What a service function buys is reachability: a plain, keyword-only function taking a `user` rather than a `request` and raising domain exceptions rather than returning HTTP responses can be called from a view, a management command, a Celery task and a test alike — none of which is true of logic left in a view, in `Model.save()`, or in a signal, each of which either narrows where the operation can run or makes it fire somewhere it should not. Inside that function, two decisions belong to it and nowhere else. The first is the transaction boundary: which writes must be atomic together, drawn as tightly as the invariant actually requires — an order and its lines, a balance and its ledger entry — and no wider, because every row inside the block stays locked for the block's whole duration. The second is where a call to another system goes, and the rule is absolute: never inside `atomic()`. A transaction held open by a slow HTTP call reserves a database connection for as long as that call takes, which turns one slow payment provider into an outage for browsing and search as well as checkout — and it risks a worse bug, because a rollback after a real charge cannot un-charge it. `transaction.on_commit()` is the fix: write the intent as a row inside the transaction, commit, and only then make the call. After that commit there is no rollback, so the function has to decide the failure path on purpose — retry with an idempotency key generated once and reused on every attempt, hand the call to a background task, or give a genuinely possible partial state a name instead of pretending the two writes were atomic when the database could never make that promise across a network boundary. Both concepts return to the same caution the roadmap states outright: know when this improves clarity, and when it is ceremony a form or a queryset method already handled.

What is true here

  1. A service function is one named business operation, not every write.
  2. Keyword-only arguments, a user not a request, domain exceptions — reachable from anywhere.
  3. The transaction boundary is exactly the invariant, no wider.
  4. A call to another system never happens inside atomic().
  5. on_commit() plus an idempotency key is what makes the after-commit call safe.

What you will be able to do

  • Tell a genuine service operation from a one-line pass-through
  • Move a multi-model workflow out of a view without losing its transaction
  • Place an external call so it holds no database connection and cannot double-fire
  • Name a partial state explicitly instead of leaving it silently inconsistent
One service function, two decisions it alone can make

Named operation?

the business would say this sentence, or it belongs in a form

Draw the boundary

atomic() covers exactly the invariant — no wider

Commit

the local writes are now visible to every other reader

Call, after commit

on_commit() — no connection held for the network

Name the failure

retry with a key, queue it, or a visible partial state

  1. Named operation? — the business would say this sentence, or it belongs in a form
  2. Draw the boundary — atomic() covers exactly the invariant — no wider
  3. Commit — the local writes are now visible to every other reader
  4. Call, after commit — on_commit() — no connection held for the network
  5. Name the failure — retry with a key, queue it, or a visible partial state

What belongs in one, and what does not

The test for a real service operation, and the ceremony to skip.

What belongs in a service function — and what does not

coreadvanced

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

python
def place_order(*, user, items) -> Order:
    with transaction.atomic():
        ...

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.

orders/services.py + the three callerspython
# orders/services.py
class InsufficientStock(Exception):
    """A domain failure. NOT an HTTP response: the caller decides how to
    present it, which is what lets three different callers share this."""


def place_order(*, user, items, idempotency_key=None) -> Order:
    """Place an order: reserve stock, create the order, raise an invoice,
    record who did it, and schedule the confirmation."""

    # Query logic stays on the manager. A service that writes filters
    # everywhere has moved the mess rather than removed it.
    unavailable = Product.objects.unavailable_for(items)
    if unavailable:
        raise InsufficientStock(unavailable)

    with transaction.atomic():
        order = Order.objects.create_for(user=user, items=items)
        reserve_stock(order)                    # another service, same tx
        raise_invoice(order_id=order.id, amount_cents=order.total_cents)
        AuditEntry.objects.record("order.placed", actor=user, target=order)

    # After COMMIT, not inside: scheduling a task inside the transaction
    # can hand a worker an id the database has not committed yet.
    transaction.on_commit(lambda: send_confirmation.delay(order.id))
    return order


# --- caller 1: a DRF view. Turns the domain exception into a response.
class PlaceOrderView(APIView):
    def post(self, request):
        serializer = PlaceOrderSerializer(data=request.data)   # validation
        serializer.is_valid(raise_exception=True)
        try:
            order = place_order(
                user=request.user,
                items=serializer.validated_data["items"],
                idempotency_key=request.headers.get("Idempotency-Key"),
            )
        except InsufficientStock as exc:
            return Response({"detail": str(exc)}, status=409)
        return Response(OrderSerializer(order).data, status=201)


# --- caller 2: a management command. No request exists here at all.
class Command(BaseCommand):
    def handle(self, *args, **options):
        order = place_order(user=self.get_actor(options), items=self.load_items(options))
        self.stdout.write(self.style.SUCCESS(f"placed order {order.id}"))


# --- caller 3: a Celery task, retrying a queued order
@shared_task
def place_queued_order(user_id, items, key):
    place_order(user=User.objects.get(pk=user_id), items=items, idempotency_key=key)
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

python
class Order(models.Model):
    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        raise_invoice(self)            # runs on EVERY save
        send_confirmation.delay(self.id)   # including fixtures and the admin

Better

python
def place_order(*, user, items) -> Order:
    with transaction.atomic():
        order = Order.objects.create_for(user=user, items=items)
        raise_invoice(order_id=order.id, amount_cents=order.total_cents)
    transaction.on_commit(lambda: send_confirmation.delay(order.id))
    return order

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.

The signature the roadmap shows, part by part

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.

  • 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

Where the same logic could live, and what each choice costs
HomeReachable fromCost
the viewHTTP onlycommands and tasks duplicate it; tests need a request
`Model.save()`every write, alwaysfires on fixtures, bulk loads and the admin; no actor argument
a signalevery write, invisiblysomeone else's transaction, and a traceback pointing at `save()`
a serializerthat API endpointthe HTML view and the command cannot reach it
**a service function**anywhereone more module — and that is the whole cost

Together

python
# One operation, four callers, no duplication:
place_order(user=request.user, items=items)          # a view
place_order(user=command_user, items=items)          # a management command
place_order(user=task_actor, items=items)            # a Celery task

When a service function is clarity, and when it is ceremony

When a service function is clarity, and when it is ceremony
OperationService function?
touches several models in one transaction**yes** — this is the core case
has a name the business uses**yes** — "place an order", "cancel a subscription"
orchestrates an external system plus local writes**yes** — the ordering matters and is easy to get wrong
must run from a view *and* a command or task**yes** — that is what makes it reusable
sets one field from validated inputno — a form or a serializer already does this
is a single `create()` with no side effectsno — `Model.objects.create()` is already the operation
is a queryno — that belongs on a manager or a queryset

Together

python
# Ceremony, not clarity: a function whose entire body is one call.
def update_display_name(*, user, name):
    user.display_name = name
    user.save(update_fields=["display_name"])

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

Advertisement

The transaction, and the call outside it

Two decisions only the service function is positioned to make.

Where the transaction ends, and where the network call goes

coreadvanced

A service function decides two things nobody else is well placed to decide: **what one `atomic()` block covers**, and **where an external call sits relative to it**. Local writes that must succeed or fail together go inside the transaction. A call to another system — an HTTP request, a charge, an email send — goes *outside* it, because a transaction held open for the length of a network call is a database connection spent waiting on someone else's server.

Think of it as

Two questions decide a service function's shape, and both belong to it precisely because nothing else in the request has the full picture. The first is the transaction boundary: which writes must be atomic together? The answer is "however much has to be consistent from another reader's point of view" — an order and its line items, a balance and its ledger entry, a status and the audit row that explains it. Draw the boundary too narrow and a reader can observe the order placed with no lines; too wide and you have pulled in work that has nothing to do with the invariant, and every row that work touches stays locked for longer than it needs to. The second question is what to do with a call to another system, and it has one firm rule: never inside `atomic()`. A transaction held open by a slow HTTP call means the database connection is reserved for exactly as long as the call takes, and connections are the scarce, shared resource every other request needs — so a slow payment provider does not just slow the checkout, it can exhaust the pool for requests that have nothing to do with payment. The pattern that resolves this is `transaction.on_commit()`: write the fact that you *intend* to act — an outbox row, a status flag — inside the transaction, commit it, and only then make the call, scheduled to run after the commit succeeds. This also fixes a correctness bug that predates the performance one: making the call *before* committing means a rollback can leave you having charged a customer for an order that was never actually placed. The remaining question is what to do about failure after the commit, because now the local write has already happened and the external call might still fail. There is no transaction to roll back to, so the service has to decide explicitly: retry, with the idempotency key the operation should already carry; queue the call as a background task so a slow provider does not hold the request; or accept that a partial state is possible and give it a name — `Order.Status.PLACED_PENDING_INVOICE` — rather than pretending the two writes are atomic when the database cannot make that promise across a network boundary. What ties the two questions together is that the service function is the only place both are visible at once: a view sees the request, a model sees a single row, and only the function orchestrating the whole workflow can see which writes are one unit and which call is not part of that unit at all.

python
with transaction.atomic():
    ...                       # local writes only
transaction.on_commit(lambda: external_call.delay(...))

What we're doing: Charge a customer without holding a database connection for the call, and without risking a charge that a rollback later erases.

orders/services.pypython
def place_order(*, user, items) -> Order:
    with transaction.atomic():
        # Everything that must be consistent together: the order, its
        # lines, and the audit row explaining who placed it. Nothing
        # here can observe one without the other.
        order = Order.objects.create_for(user=user, items=items)
        AuditEntry.objects.record("order.placed", actor=user, target=order)

        # The INTENT to charge is written here, inside the transaction,
        # as a row rather than as a call. If anything above raises, this
        # row is rolled back with it — no charge was ever attempted.
        order.status = Order.Status.AWAITING_PAYMENT
        order.save(update_fields=["status"])

    # Scheduled for AFTER commit. No database connection is held while
    # this runs, and it cannot fire for an order a rollback erased.
    transaction.on_commit(lambda: charge_order.delay(order.id))
    return order


# orders/tasks.py — runs after the order is durably committed
@shared_task(bind=True, max_retries=3)
def charge_order(self, order_id):
    order = Order.objects.get(pk=order_id)
    try:
        charge_id = gateway.charge(
            amount_cents=order.total_cents,
            # Generated once, at order-creation time, and reused on
            # every retry — so a retried task cannot charge twice.
            idempotency_key=f"order:{order.id}",
        )
    except GatewayTimeout as exc:
        raise self.retry(exc=exc, countdown=2 ** self.request.retries)
    except GatewayDeclined:
        # No transaction to roll back to. The partial state is named
        # and visible, not silently left as AWAITING_PAYMENT forever.
        order.status = Order.Status.PAYMENT_FAILED
        order.save(update_fields=["status"])
        return

    with transaction.atomic():
        order.status = Order.Status.PAID
        order.charge_id = charge_id
        order.save(update_fields=["status", "charge_id"])
3–7
The boundary: order, lines and audit row. This is the invariant a reader must never see half-formed — nothing about payment belongs in this block.
9–13
The intent, written as a row inside the transaction. It is what makes the later call resumable: if the process crashes before the task runs, `AWAITING_PAYMENT` orders are the ones to re-drive.
17–17
The call is scheduled, not made. `on_commit` guarantees the order genuinely exists — for every other reader, not just this process — before anything reaches out to the network.
27–31
The idempotency key is generated once and reused on every retry of this task. Without that, `self.retry` on a timeout would risk a second, independent charge for the same order.
35–38
No transaction to roll back to here — the order write already committed. The failure becomes a named status rather than a silent gap between what the order says and what actually happened.

Why this works: No database connection is held for the length of the charge, a rollback can never leave a phantom charge, a crash mid-flow leaves a resumable state rather than a lost one, and a retried task cannot double-charge.

Calling the payment provider inside the transaction

Wrong

python
with transaction.atomic():
    order = Order.objects.create_for(user=user, items=items)
    charge_id = gateway.charge(amount_cents=order.total_cents)   # network call, INSIDE
    order.mark_paid(charge_id)

Better

python
with transaction.atomic():
    order = Order.objects.create_for(user=user, items=items)
transaction.on_commit(lambda: charge_order.delay(order.id))

What you see: Checkout gets slow exactly when the payment provider is slow, and during an outage the connection pool fills with requests stuck mid-checkout — taking down browsing and search along with payments.

Why: A transaction holds its database connection open for its entire duration, including the time spent waiting on anything inside it. A network call has no bound the database can see, so the connection is reserved for however long the provider takes — and under load, or during a provider incident, every checkout in flight holds one. There is a correctness problem hiding behind the performance one: if a later statement in the same block fails and the transaction rolls back, the charge already happened and cannot be undone by a `ROLLBACK`, so the customer is charged for an order that, as far as the database is concerned, never existed. Moving the call after commit fixes both at once — the connection is free during the call, and the call can only fire for a write that genuinely persisted.

Commit first, call second — and what would go wrong reversed
view
service
database
payment provider
  1. 1. place_order(user, items)
  2. 2. BEGIN; INSERT order, linesthe invariant that must be atomic
  3. 3. COMMIT
  4. 4. committed — the order now exists for every reader
  5. 5. on_commit: charge(order.id)no database connection held during this call
  6. 6. charge id, or a declined/timeout
  7. 7. if it failed: mark PLACED_PENDING_PAYMENTa named, visible partial state — never silent
  1. view → service: place_order(user, items)
  2. service → database: BEGIN; INSERT order, lines (the invariant that must be atomic)
  3. service → database: COMMIT
  4. database → service: committed — the order now exists for every reader
  5. service → payment provider: on_commit: charge(order.id) (no database connection held during this call)
  6. payment provider → service: charge id, or a declined/timeout
  7. service → database: if it failed: mark PLACED_PENDING_PAYMENT (a named, visible partial state — never silent)

Inside the transaction, or outside it — and why

Inside the transaction, or outside it — and why
OperationWhereWhy
the order and its line itemsinsidemust never be observed half-written
an audit row explaining the changeinsidethe same invariant: the change and its record are one fact
charging a cardoutside, after commita rollback after a real charge cannot un-charge it
sending a confirmation emailoutside, after commitholds no lock, and a rollback should not have sent it
publishing a domain eventinside, as an outbox rowthe row commits with the change; a relay publishes it after
a database query used to validate inputinsidepart of the same consistent read the writes depend on

Together

python
with transaction.atomic():
    order = Order.objects.create_for(user=user, items=items)
transaction.on_commit(lambda: charge_card.delay(order.id))   # after, not inside

What to do when the after-commit call fails

What to do when the after-commit call fails
StrategyUse it whenNeeds
retry inline, boundedthe call is fast and usually succeedsan idempotency key; see §90
hand off to a background taskthe call is slow or the provider is flakya queue, and a status the UI can poll
name the partial statethe two facts genuinely may disagree brieflya status value, not a silent inconsistency
dead-letter and alertretries are exhausteda replay path — see §90's DLQ concept

Together

python
order.status = Order.Status.PLACED_PENDING_INVOICE   # named, not hidden
order.save(update_fields=["status"])

Remember: Two decisions belong to the service function alone: what one `atomic()` block must cover, and what stays outside it. Local writes that must be consistent together go inside; a call to another system never does, because a transaction holds its database connection for as long as the call takes. Write the intent as a row inside the transaction, commit, then use `transaction.on_commit()` to make the call — that ordering is also what stops a rollback leaving a phantom charge. After commit there is no rollback, so decide the failure path explicitly: retry with an idempotency key, hand off to a background task, or name the partial state rather than hiding it.

See also: what belongs in a service function · on commit and durable · timeouts retries backoff and jitter · idempotency keys and http semantics

Advertisement