Introduction to Services in Django

This article introduces services in Django and explains how they can separate business workflows from views, forms, and models. It covers service functions, service classes, transactions, business rules, permissions, external APIs, background tasks, testing, project organization, and when a service layer is useful or unnecessary.

Introduction to Services in Django

As a Django application grows, views can easily become overloaded.

A small view may begin like this:

python

1
2
def checkout(request):
    ...

Then more requirements are added:

  • validate a cart
  • calculate totals
  • reserve inventory
  • create an order
  • charge a payment
  • send confirmation email
  • record analytics
  • create an audit log

Soon the view contains a large amount of business logic.

This makes the code harder to:

  • read
  • test
  • reuse
  • maintain
  • debug
  • change safely

One common solution is to move application workflows into service functions or service classes.

A service layer is not a special Django feature. Django does not require one.

It is an application design pattern used to keep business operations separate from HTTP request handling.

A simple mental model is:

text

1
2
3
4
5
View
   ↓
Service
   ↓
Models / external systems

The view handles the request and response.

The service handles the business operation.

What Is a Service?

A service is a function or class that performs an application-level operation.

For example:

python

1
2
def create_order(*, user, cart):
    ...

The service might:

  1. validate the cart
  2. calculate the total
  3. create the order
  4. create order items
  5. update inventory
  6. trigger a confirmation email

The view does not need to know every implementation detail.

It only needs to call:

python

1
2
3
4
order = create_order(
    user=request.user,
    cart=cart,
)

This makes the main action easier to understand.

Services Are Not Built Into Django

Django provides built-in concepts such as:

  • models
  • views
  • forms
  • middleware
  • templates
  • signals

There is no built-in:

text

1
Service

base class that every Django application must use.

A service layer is simply an organizational pattern.

You may create:

text

1
services.py

or:

text

1
services/

inside an application.

For example:

text

1
2
3
4
5
6
7
8
orders/
├── admin.py
├── apps.py
├── forms.py
├── models.py
├── services.py
├── urls.py
└── views.py

For a larger app:

text

1
2
3
4
5
6
7
8
orders/
├── services/
│   ├── __init__.py
│   ├── checkout.py
│   ├── refunds.py
│   └── shipping.py
├── models.py
└── views.py

Why Use Services?

Services are most useful when an operation involves more than one simple model action.

Suppose a view contains:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def article_publish(request, pk):
    article = get_object_or_404(
        Article,
        pk=pk,
        author=request.user,
    )

    article.is_published = True
    article.published_at = timezone.now()
    article.save()

    Notification.objects.create(
        user=article.author,
        message="Article published.",
    )

    send_publish_email(article)

    log_activity(
        user=request.user,
        action="article_published",
        object_id=article.pk,
    )

    return redirect(
        "article-detail",
        pk=article.pk,
    )

The HTTP behavior is mixed with the publishing workflow.

A service can separate them.

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
def publish_article(*, article, user):
    article.is_published = True
    article.published_at = timezone.now()
    article.save()

    Notification.objects.create(
        user=article.author,
        message="Article published.",
    )

    send_publish_email(article)

    log_activity(
        user=user,
        action="article_published",
        object_id=article.pk,
    )

    return article

The view becomes:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def article_publish(request, pk):
    article = get_object_or_404(
        Article,
        pk=pk,
        author=request.user,
    )

    publish_article(
        article=article,
        user=request.user,
    )

    return redirect(
        "article-detail",
        pk=article.pk,
    )

The view now focuses on HTTP behavior.

Views and Services Have Different Responsibilities

A useful separation is:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
View responsibilities
    Read request data
    Check authentication
    Check permissions
    Validate forms
    Call application operations
    Return responses

Service responsibilities
    Perform business workflows
    Coordinate models
    Apply business rules
    Call external systems
    Manage transactions
    Return useful results

For example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def checkout(request):
    form = CheckoutForm(
        request.POST or None,
    )

    if (
        request.method == "POST"
        and form.is_valid()
    ):
        order = create_order(
            user=request.user,
            shipping_address=(
                form.cleaned_data[
                    "shipping_address"
                ]
            ),
        )

        return redirect(
            "order-detail",
            pk=order.pk,
        )

    return render(
        request,
        "orders/checkout.html",
        {"form": form},
    )

The view handles:

  • request method
  • form validation
  • redirect
  • template rendering

The service handles:

python

1
2
3
4
5
6
def create_order(
    *,
    user,
    shipping_address,
):
    ...

Service Functions

A service does not need to be a class.

A plain function is often enough.

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
from django.db import transaction

from .models import Order, OrderItem


@transaction.atomic
def create_order(*, user, cart):
    order = Order.objects.create(
        user=user,
        total=cart.total,
    )

    for item in cart.items.all():
        OrderItem.objects.create(
            order=order,
            product=item.product,
            quantity=item.quantity,
            price=item.product.price,
        )

    return order

This is simple, explicit, and easy to call.

For many Django applications, service functions are easier to understand than introducing service classes immediately.

Keyword-Only Arguments

Service functions often use keyword-only arguments.

Example:

python

1
2
3
4
5
6
7
def create_order(
    *,
    user,
    cart,
    shipping_address,
):
    ...

The * means callers must write:

python

1
2
3
4
5
create_order(
    user=user,
    cart=cart,
    shipping_address=address,
)

instead of:

python

1
2
3
4
5
create_order(
    user,
    cart,
    address,
)

This makes calls easier to read when a service takes several values.

Returning Results

A service should usually return something useful.

For example:

python

1
2
3
4
5
6
7
8
def create_article(*, author, title, content):
    article = Article.objects.create(
        author=author,
        title=title,
        content=content,
    )

    return article

The caller can then use:

python

1
2
3
4
5
article = create_article(
    author=request.user,
    title=form.cleaned_data["title"],
    content=form.cleaned_data["content"],
)

and redirect:

python

1
2
3
4
return redirect(
    "article-detail",
    pk=article.pk,
)

Returning useful values keeps services composable.

Services and Models

Services do not replace models.

Models should still represent application data and model-specific behavior.

For example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Order(models.Model):
    status = models.CharField(
        max_length=20,
    )

    def mark_as_paid(self):
        self.status = "paid"
        self.save(
            update_fields=["status"],
        )

A service might coordinate several model operations:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def complete_payment(
    *,
    order,
    payment_reference,
):
    Payment.objects.create(
        order=order,
        reference=payment_reference,
    )

    order.mark_as_paid()

    send_receipt(order)

The model owns behavior closely tied to its own state.

The service coordinates the wider workflow.

When Logic Belongs on the Model

Suppose an invoice can calculate its remaining amount:

python

1
2
3
4
5
6
class Invoice(models.Model):
    total = models.DecimalField(...)
    paid = models.DecimalField(...)

    def remaining_balance(self):
        return self.total - self.paid

This is naturally model behavior.

The logic depends only on the invoice itself.

A service would add little value:

python

1
2
def calculate_invoice_balance(invoice):
    return invoice.total - invoice.paid

That is unnecessary indirection.

Prefer model methods when behavior clearly belongs to one model instance.

When Logic Belongs in a Service

A service becomes useful when a workflow crosses boundaries.

For example:

text

1
2
3
4
5
6
7
8
9
Order
    +
Inventory
    +
Payment
    +
Email
    +
Audit log

A checkout service might coordinate all of them.

python

1
2
def checkout(*, user, cart, payment_method):
    ...

This operation does not belong naturally to just one model.

Services and Forms

Forms are responsible for validating input.

Services are responsible for performing business operations.

For example:

python

1
2
3
4
5
6
7
8
class TransferForm(forms.Form):
    recipient = forms.ModelChoiceField(
        queryset=Account.objects.all(),
    )

    amount = forms.DecimalField(
        min_value=1,
    )

The view:

python

1
2
3
4
5
6
7
8
if form.is_valid():
    transfer_funds(
        sender=request.user.account,
        recipient=(
            form.cleaned_data["recipient"]
        ),
        amount=form.cleaned_data["amount"],
    )

The form validates:

  • the recipient value
  • the decimal format
  • the minimum amount

The service can enforce business rules:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def transfer_funds(
    *,
    sender,
    recipient,
    amount,
):
    if sender.balance < amount:
        raise InsufficientFunds

    ...

This distinction is important.

A user can call application logic from somewhere other than a form.

Critical business rules should not exist only in browser-facing validation.

Services and Business Rules

A service is a good place for rules that define how an operation works.

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def cancel_order(*, order, user):
    if order.user_id != user.id:
        raise PermissionDenied

    if order.status == "shipped":
        raise OrderCannotBeCancelled

    order.status = "cancelled"
    order.save(
        update_fields=["status"],
    )

The service describes the business operation:

text

1
Cancel order

rather than the HTTP request:

text

1
POST /orders/42/cancel/

This distinction makes the same operation reusable from:

  • a web view
  • an admin action
  • a management command
  • a background task
  • an API endpoint

Services Improve Reuse

Without a service, two views may duplicate logic.

Web view:

python

1
2
3
4
5
6
def publish_article_view(request, pk):
    article.is_published = True
    article.published_at = timezone.now()
    article.save()

    send_publish_email(article)

API view:

python

1
2
3
4
5
6
def publish_article_api(request, pk):
    article.is_published = True
    article.published_at = timezone.now()
    article.save()

    send_publish_email(article)

This duplication can drift over time.

Instead:

python

1
2
3
4
5
6
def publish_article(*, article):
    article.is_published = True
    article.published_at = timezone.now()
    article.save()

    send_publish_email(article)

Both entry points call the same workflow.

Services and Transactions

Services are often a natural place for database transactions.

Suppose creating an order requires:

  1. creating the order
  2. creating order items
  3. decreasing inventory

If step three fails, the first two changes may need to roll back.

Use:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from django.db import transaction


@transaction.atomic
def create_order(*, user, cart):
    order = Order.objects.create(
        user=user,
    )

    for item in cart.items.select_related(
        "product"
    ):
        OrderItem.objects.create(
            order=order,
            product=item.product,
            quantity=item.quantity,
        )

        item.product.stock -= item.quantity
        item.product.save(
            update_fields=["stock"],
        )

    return order

Now the database operation is treated as one transaction.

If an exception occurs before completion, Django can roll back the database changes.

Transactions Do Not Roll Back Everything

A database transaction only controls database changes.

Suppose a service does this:

python

1
2
3
4
5
6
7
@transaction.atomic
def create_order(...):
    order = Order.objects.create(...)

    send_email(...)

    raise ValueError

The order may roll back.

The email cannot be “unsent.”

Likewise, transactions do not automatically undo:

  • file uploads
  • API requests
  • payment charges
  • messages sent to external queues

Be careful when combining database transactions with external side effects.

transaction.on_commit()

Sometimes an external action should occur only after a successful database commit.

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from django.db import transaction


@transaction.atomic
def publish_article(*, article):
    article.is_published = True
    article.save(
        update_fields=["is_published"],
    )

    transaction.on_commit(
        lambda: send_publish_email(
            article.pk
        )
    )

If the database transaction rolls back, the callback is not run.

This is useful for:

  • sending emails
  • dispatching background tasks
  • notifying external systems

when those actions depend on committed data.

Custom Service Exceptions

A service may need to report a business failure.

Instead of returning unclear values such as:

python

1
False

define an exception:

python

1
2
class InsufficientStock(Exception):
    pass

Service:

python

1
2
3
4
5
def create_order(*, user, product, quantity):
    if product.stock < quantity:
        raise InsufficientStock

    ...

The view can translate the business error into an HTTP response:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
try:
    order = create_order(
        user=request.user,
        product=product,
        quantity=quantity,
    )
except InsufficientStock:
    messages.error(
        request,
        "There is not enough stock.",
    )

    return redirect(
        "product-detail",
        pk=product.pk,
    )

This preserves the separation:

text

1
2
3
4
5
Service
    Reports business failure

View
    Decides how that failure appears over HTTP

Avoid Returning HttpResponse from Services

A service should usually not return:

python

1
HttpResponse

or:

python

1
redirect(...)

For example, avoid:

python

1
2
3
def create_order(request):
    ...
    return redirect("order-detail")

That makes the service dependent on the web layer.

Prefer:

python

1
2
3
def create_order(*, user, cart):
    ...
    return order

Then the view decides:

python

1
2
3
4
5
6
7
8
9
order = create_order(
    user=request.user,
    cart=cart,
)

return redirect(
    "order-detail",
    pk=order.pk,
)

This makes the service easier to reuse outside views.

Avoid Passing the Whole Request to Services

Avoid:

python

1
2
3
def create_order(request):
    user = request.user
    address = request.POST["address"]

The service now depends directly on Django’s HTTP request object.

Prefer explicit inputs:

python

1
2
3
4
5
6
def create_order(
    *,
    user,
    address,
):
    ...

Call it from the view:

python

1
2
3
4
5
6
create_order(
    user=request.user,
    address=(
        form.cleaned_data["address"]
    ),
)

Explicit parameters make dependencies easier to understand and test.

Services Should Receive Useful Domain Objects

A service can accept:

  • model instances
  • IDs
  • simple values
  • dataclasses
  • validated form values

For example:

python

1
2
3
4
5
6
7
def refund_order(
    *,
    order,
    amount,
    reason,
):
    ...

This is usually clearer than:

python

1
2
def refund_order(request):
    ...

The function signature documents what the operation actually needs.

Services and Permissions

There are two reasonable places for permission checks depending on the application.

A view can enforce access before calling the service:

python

1
2
3
4
5
6
7
8
9
article = get_object_or_404(
    Article,
    pk=pk,
    author=request.user,
)

publish_article(
    article=article,
)

Or a service may enforce an important business authorization rule:

python

1
2
3
4
5
6
7
8
9
def publish_article(
    *,
    article,
    user,
):
    if article.author_id != user.id:
        raise PermissionDenied

    ...

For critical operations reused from many entry points, enforcing the rule inside the service can reduce the chance that one caller forgets it.

The HTTP view can still perform earlier access checks for a better user experience.

Services and QuerySets

A service may query models directly.

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def deactivate_user(*, user_id):
    user = User.objects.get(
        pk=user_id,
    )

    user.is_active = False
    user.save(
        update_fields=["is_active"],
    )

    return user

Or the caller may retrieve the object first:

python

1
2
3
4
5
6
7
8
user = get_object_or_404(
    User,
    pk=pk,
)

deactivate_user(
    user=user,
)

Neither style is universally correct.

A useful guideline is to make ownership of the lookup clear.

If the view needs special HTTP behavior such as 404, the lookup often belongs in the view.

If retrieving the object is inherently part of the business operation, the service may perform it.

Services and Selectors

Some projects separate read logic from write workflows.

They may use:

text

1
2
3
4
5
services
    Write or business operations

selectors
    Read/query operations

Example selector:

python

1
2
3
4
5
6
7
8
9
def get_visible_articles(*, user):
    queryset = Article.objects.filter(
        is_published=True,
    )

    if user.is_staff:
        queryset = Article.objects.all()

    return queryset

Service:

python

1
2
def publish_article(*, article, user):
    ...

This separation is optional.

For many applications, custom model managers and querysets already provide a good place for reusable query logic.

Custom QuerySets Versus Services

Consider:

python

1
Article.objects.published()

A custom queryset method is a natural place for reusable database filtering.

python

1
2
3
4
5
class ArticleQuerySet(models.QuerySet):
    def published(self):
        return self.filter(
            is_published=True,
        )

A service function like:

python

1
2
3
4
def get_published_articles():
    return Article.objects.filter(
        is_published=True,
    )

may add little value unless the query represents a more complex application operation.

Use the abstraction that keeps the code clearest.

Service Classes

Sometimes a service class can be useful.

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class CheckoutService:
    def __init__(
        self,
        *,
        user,
        cart,
    ):
        self.user = user
        self.cart = cart

    def execute(self):
        ...

Call:

python

1
2
3
4
5
6
service = CheckoutService(
    user=request.user,
    cart=cart,
)

order = service.execute()

A class can be useful when:

  • several methods share state
  • the workflow has multiple meaningful stages
  • dependencies need to be injected
  • the object itself represents a workflow

However, do not create a class merely because the file is called services.py.

This:

python

1
2
3
4
class ArticleService:
    @staticmethod
    def publish(article):
        ...

may be less clear than:

python

1
2
def publish_article(*, article):
    ...

Start with functions unless a class provides a real advantage.

Avoid Generic Service Base Classes

This is usually unnecessary:

python

1
2
class BaseService:
    ...

followed by:

python

1
2
class CreateOrderService(BaseService):
    ...

Django does not require services to share a common base class.

A generic hierarchy can add complexity without improving the application.

Prefer concrete operations with clear names.

Name Services After Actions

Good service names describe business actions.

Examples:

python

1
2
3
4
5
6
7
create_order()
cancel_order()
publish_article()
register_customer()
refund_payment()
transfer_funds()
invite_team_member()

Less useful names include:

python

1
2
3
4
handle_data()
process()
do_action()
manage_order()

A reader should understand the operation from the function name.

Organizing services.py

For a small application:

text

1
2
3
4
articles/
├── models.py
├── services.py
└── views.py

services.py:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def publish_article(...):
    ...


def archive_article(...):
    ...


def duplicate_article(...):
    ...

This is often enough.

Organizing a Services Package

When services.py becomes too large:

text

1
2
3
4
5
6
7
8
orders/
├── services/
│   ├── __init__.py
│   ├── cancellation.py
│   ├── checkout.py
│   └── refunds.py
├── models.py
└── views.py

Imports can remain clear:

python

1
2
3
from orders.services.checkout import (
    create_order,
)

Do not split service files too early.

A single clear file is easier to navigate than many nearly empty files.

Services Across Django Apps

Suppose an order service needs:

text

1
2
3
orders.Order
products.Product
payments.Payment

It is normal for an application-level service to coordinate models from several apps when the workflow itself belongs to the orders domain.

Example:

python

1
2
def create_order(*, user, cart):
    ...

The most important question is:

text

1
Which application owns this business operation?

not:

text

1
Which model is touched first?

External APIs

Services are often a good place to coordinate external systems.

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def charge_order(
    *,
    order,
    payment_method,
):
    payment = payment_gateway.charge(
        amount=order.total,
        payment_method=payment_method,
    )

    Payment.objects.create(
        order=order,
        provider_id=payment.id,
        amount=order.total,
    )

    return payment

The view should not need to understand the payment provider’s implementation details.

Keep Third-Party Integrations Behind Boundaries

Instead of spreading:

python

1
stripe.PaymentIntent.create(...)

through views, models, and management commands, wrap external behavior behind application-specific functions.

For example:

python

1
2
3
4
5
6
def charge_payment(
    *,
    amount,
    payment_method,
):
    ...

Then the order service uses:

python

1
2
3
4
payment = charge_payment(
    amount=order.total,
    payment_method=payment_method,
)

This makes third-party systems easier to replace and mock in tests.

Services and Background Tasks

A service may dispatch slow work to a background queue.

For example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@transaction.atomic
def publish_article(*, article):
    article.is_published = True
    article.save(
        update_fields=["is_published"],
    )

    transaction.on_commit(
        lambda: send_publish_email.delay(
            article.pk
        )
    )

This keeps long-running tasks out of the HTTP request when appropriate.

The service still describes the business workflow.

Avoid Hiding Too Much

Services should make the application easier to understand.

They should not make ordinary operations mysterious.

For example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def create_comment(
    *,
    article,
    user,
    content,
):
    return Comment.objects.create(
        article=article,
        user=user,
        content=content,
    )

This may be useful if comment creation is a real application operation.

But if the function adds no behavior and has only one caller, this may simply add another layer to navigate.

Not every:

python

1
Model.objects.create(...)

needs a service.

Do Not Create Services for Every CRUD Operation

Avoid mechanically creating:

text

1
2
3
4
create_article()
get_article()
update_article()
delete_article()

just because CRUD exists.

Django already provides:

  • managers
  • querysets
  • forms
  • generic views
  • model methods

A service is valuable when it represents meaningful application behavior.

For example:

python

1
publish_article()

communicates more business meaning than:

python

1
update_article()

Services Should Not Become God Objects

Avoid a single class like:

python

1
2
class ApplicationService:
    ...

with hundreds of unrelated methods.

Likewise, avoid a huge:

text

1
services.py

that contains every workflow in the project.

Group services by application or business area.

For example:

text

1
2
3
orders/services/
payments/services/
accounts/services/

Keep Services Focused

A service should usually describe one meaningful operation.

Example:

python

1
2
def cancel_order(*, order, user):
    ...

not:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def handle_order(
    *,
    action,
    order,
    user,
    payment=None,
    address=None,
    refund=False,
):
    ...

Large functions controlled by flags are often harder to understand than several focused operations.

Services and Signals

Suppose an order should send an email after creation.

One approach is a post_save signal.

Another is explicit service logic:

python

1
2
3
4
5
6
def create_order(...):
    order = Order.objects.create(...)

    send_order_email(order)

    return order

The service makes the relationship explicit.

A reader can see immediately that creating an order triggers the email.

Signals may still be useful for loosely coupled notifications, but important business workflows are often easier to follow when coordinated explicitly by a service.

Services and Model save()

Avoid turning every model save into a hidden business workflow.

For example:

python

1
2
3
4
5
6
7
class Order(models.Model):
    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)

        charge_card(self)
        send_email(self)
        update_inventory(self)

This means a seemingly simple:

python

1
order.save()

may trigger several external effects.

A service makes the operation clearer:

python

1
2
3
complete_checkout(
    order=order,
)

Explicit operations are easier to reason about.

Testing Services

Services are usually straightforward to test because they receive explicit inputs and return useful results.

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from django.test import TestCase

from .services import create_order


class CreateOrderTests(TestCase):
    def test_creates_order_for_user(self):
        user = User.objects.create_user(
            username="alex",
        )

        cart = create_test_cart(
            user=user,
        )

        order = create_order(
            user=user,
            cart=cart,
        )

        self.assertEqual(
            order.user,
            user,
        )

The test does not need to simulate an HTTP request unless HTTP behavior is part of what is being tested.

Testing Business Rules

Suppose an order cannot be cancelled after shipment.

Service:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class OrderCannotBeCancelled(
    Exception
):
    pass


def cancel_order(*, order):
    if order.status == "shipped":
        raise OrderCannotBeCancelled

    order.status = "cancelled"
    order.save(
        update_fields=["status"],
    )

Test:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def test_shipped_order_cannot_be_cancelled(
    self,
):
    order = Order.objects.create(
        status="shipped",
    )

    with self.assertRaises(
        OrderCannotBeCancelled
    ):
        cancel_order(
            order=order,
        )

This test directly describes the business rule.

Mocking External Dependencies

Suppose a service calls a payment provider.

python

1
2
3
4
5
6
7
8
9
def refund_order(*, order):
    payment_gateway.refund(
        order.payment_reference
    )

    order.status = "refunded"
    order.save(
        update_fields=["status"],
    )

Test without calling the real provider:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from unittest.mock import patch


@patch(
    "orders.services.payment_gateway.refund"
)
def test_refund_calls_payment_provider(
    self,
    refund,
):
    order = Order.objects.create(
        payment_reference="pay_123",
        status="paid",
    )

    refund_order(
        order=order,
    )

    refund.assert_called_once_with(
        "pay_123"
    )

Service boundaries often make external integrations easier to replace during tests.

Integration Tests Still Matter

Testing a service in isolation is useful, but the application should also test that views call it correctly.

For example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def test_cancel_view_cancels_order(self):
    self.client.force_login(
        self.user
    )

    response = self.client.post(
        reverse(
            "order-cancel",
            kwargs={
                "pk": self.order.pk,
            },
        )
    )

    self.order.refresh_from_db()

    self.assertEqual(
        self.order.status,
        "cancelled",
    )

A healthy test suite can contain both:

text

1
2
3
4
5
Service tests
    Business behavior

View tests
    HTTP integration

A Complete Example

Consider a small order system.

Models:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
from django.conf import settings
from django.db import models


class Product(models.Model):
    name = models.CharField(
        max_length=200,
    )

    price = models.DecimalField(
        max_digits=10,
        decimal_places=2,
    )

    stock = models.PositiveIntegerField()


class Order(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    total = models.DecimalField(
        max_digits=10,
        decimal_places=2,
    )

    created_at = models.DateTimeField(
        auto_now_add=True,
    )


class OrderItem(models.Model):
    order = models.ForeignKey(
        Order,
        on_delete=models.CASCADE,
        related_name="items",
    )

    product = models.ForeignKey(
        Product,
        on_delete=models.PROTECT,
    )

    quantity = models.PositiveIntegerField()

    price = models.DecimalField(
        max_digits=10,
        decimal_places=2,
    )

Custom exception:

python

1
2
class InsufficientStock(Exception):
    pass

Service:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from decimal import Decimal

from django.db import transaction

from .models import Order, OrderItem


@transaction.atomic
def create_order(
    *,
    user,
    items,
):
    total = Decimal("0.00")

    for item in items:
        product = item["product"]
        quantity = item["quantity"]

        if product.stock < quantity:
            raise InsufficientStock(
                product.name
            )

        total += (
            product.price
            * quantity
        )

    order = Order.objects.create(
        user=user,
        total=total,
    )

    for item in items:
        product = item["product"]
        quantity = item["quantity"]

        OrderItem.objects.create(
            order=order,
            product=product,
            quantity=quantity,
            price=product.price,
        )

        product.stock -= quantity
        product.save(
            update_fields=["stock"],
        )

    transaction.on_commit(
        lambda: send_order_confirmation(
            order.pk
        )
    )

    return order

View:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
from django.contrib import messages
from django.contrib.auth.decorators import (
    login_required,
)
from django.shortcuts import (
    redirect,
    render,
)

from .forms import CheckoutForm
from .services import (
    InsufficientStock,
    create_order,
)


@login_required
def checkout(request):
    if request.method == "POST":
        form = CheckoutForm(
            request.POST,
        )

        if form.is_valid():
            try:
                order = create_order(
                    user=request.user,
                    items=(
                        form.cleaned_data[
                            "items"
                        ]
                    ),
                )
            except InsufficientStock:
                messages.error(
                    request,
                    (
                        "One or more products "
                        "are out of stock."
                    ),
                )
            else:
                return redirect(
                    "order-detail",
                    pk=order.pk,
                )
    else:
        form = CheckoutForm()

    return render(
        request,
        "orders/checkout.html",
        {
            "form": form,
        },
    )

The responsibilities are now clear.

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Form
    Validates request input

View
    Handles HTTP flow

Service
    Executes checkout

Models
    Store order data

Transaction
    Protects related database changes

Another Complete Example: User Registration

Registration often contains more than:

python

1
User.objects.create_user(...)

Suppose registration also creates a profile and sends a welcome email.

Service:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from django.contrib.auth import (
    get_user_model,
)
from django.db import transaction

from .models import Profile


User = get_user_model()


@transaction.atomic
def register_user(
    *,
    username,
    email,
    password,
):
    user = User.objects.create_user(
        username=username,
        email=email,
        password=password,
    )

    Profile.objects.create(
        user=user,
    )

    transaction.on_commit(
        lambda: send_welcome_email(
            user.pk
        )
    )

    return user

View:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def register(request):
    if request.method == "POST":
        form = RegistrationForm(
            request.POST,
        )

        if form.is_valid():
            user = register_user(
                username=(
                    form.cleaned_data[
                        "username"
                    ]
                ),
                email=(
                    form.cleaned_data[
                        "email"
                    ]
                ),
                password=(
                    form.cleaned_data[
                        "password"
                    ]
                ),
            )

            login(
                request,
                user,
            )

            return redirect(
                "dashboard"
            )
    else:
        form = RegistrationForm()

    return render(
        request,
        "accounts/register.html",
        {
            "form": form,
        },
    )

This registration operation can now also be reused from another entry point without duplicating the workflow.

Common Beginner Mistakes

Creating a Service Layer Too Early

Not every application needs services from the beginning.

This is perfectly reasonable:

python

1
2
def article_create(request):
    ...

if the operation is simple.

Introduce a service when it improves clarity or reuse.

Moving All Code Out of Views

A service layer does not mean views should contain no logic.

Views still need to handle:

  • request methods
  • forms
  • authentication
  • permissions
  • redirects
  • response rendering

The goal is separation, not empty views at all costs.

Moving Model Behavior Into Services

Avoid:

python

1
2
def get_article_title(article):
    return article.title

when:

python

1
article.title

already expresses the operation.

Similarly, model-specific behavior may belong on the model.

Passing request Everywhere

Avoid:

python

1
2
def publish_article(request, article):
    ...

Prefer:

python

1
2
3
4
5
6
def publish_article(
    *,
    user,
    article,
):
    ...

The service should receive the data it actually needs.

Returning HTTP Responses from Services

Avoid:

python

1
return redirect(...)

inside a business service.

Return application data or raise a meaningful exception.

Swallowing Errors

Avoid:

python

1
2
3
4
5
def create_order(...):
    try:
        ...
    except Exception:
        return None

This hides why the operation failed.

Catch only errors the service can meaningfully handle.

Creating Huge Service Classes

A service layer should reduce complexity, not create a new giant abstraction.

Prefer focused operations.

Duplicating Model Managers

Do not introduce a service function for every simple queryset.

Use custom managers and querysets where they naturally fit.

Using Services Only as Wrappers

This:

python

1
2
3
4
def create_article(**kwargs):
    return Article.objects.create(
        **kwargs
    )

may not provide enough value unless it establishes an important application boundary or is expected to gain real workflow behavior.

Mixing HTTP and Business Exceptions

A service should usually raise business-oriented exceptions.

For example:

python

1
InsufficientStock

rather than returning:

python

1
HttpResponseBadRequest(...)

The view can map the business failure to HTTP.

Forgetting Transactions

When a workflow performs several related writes, think about what should happen if one of them fails.

A transaction may be required.

Assuming Transactions Cover External Systems

Database rollback cannot undo an already-sent email or payment request.

Coordinate side effects deliberately.

When to Use a Service

A service is often useful when an operation:

  • touches several models
  • contains important business rules
  • is used from several entry points
  • needs a database transaction
  • calls an external system
  • has meaningful failure states
  • is difficult to test through a view
  • makes a view too large
  • represents a named business action

Examples:

text

1
2
3
4
5
6
7
8
Create order
Cancel subscription
Publish article
Refund payment
Transfer funds
Invite team member
Approve application
Register customer

When a Service May Be Unnecessary

A service may add little value for:

  • one simple model lookup
  • basic CRUD handled cleanly by a ModelForm
  • straightforward generic views
  • a small model method
  • a reusable queryset filter

For example:

python

1
2
3
4
article = get_object_or_404(
    Article,
    pk=pk,
)

does not normally require:

python

1
get_article_service(pk)

Use services because they clarify application behavior, not because every Django project must contain them.

For many Django projects, a useful default is:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
1. Start with a function.

2. Give it an action-oriented name.

3. Use explicit arguments.

4. Avoid passing request objects.

5. Keep HTTP responses in views.

6. Put business rules in the operation.

7. Use transactions when several
   database writes belong together.

8. Return useful domain objects.

9. Raise meaningful business exceptions.

10. Introduce classes only when they
    provide a clear advantage.

Example:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@transaction.atomic
def cancel_order(
    *,
    order,
    user,
):
    if order.user_id != user.id:
        raise PermissionDenied

    if order.status == "shipped":
        raise OrderCannotBeCancelled

    order.status = "cancelled"
    order.save(
        update_fields=["status"],
    )

    return order

A Simple Project Structure

For a small project:

text

1
2
3
4
5
6
7
8
9
orders/
├── admin.py
├── apps.py
├── forms.py
├── models.py
├── services.py
├── tests.py
├── urls.py
└── views.py

For a larger project:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
orders/
├── services/
│   ├── __init__.py
│   ├── cancellation.py
│   ├── checkout.py
│   └── refunds.py
├── tests/
│   ├── test_cancellation.py
│   ├── test_checkout.py
│   └── test_refunds.py
├── models.py
├── urls.py
└── views.py

Organize the code only as much as the application actually needs.

A useful way to think about a Django application is:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Request
   ↓
View
   ↓
Form / serializer
   ↓
Service
   ↓
Models and external systems
   ↓
Result
   ↓
View
   ↓
Response

Not every request needs every layer.

For example, a simple read-only page might be:

text

1
2
3
4
5
View
   ↓
QuerySet
   ↓
Template

A complex checkout might be:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
View
   ↓
Form
   ↓
Checkout service
   ├── Order model
   ├── Inventory
   ├── Payment provider
   └── Email task
   ↓
View
   ↓
Redirect

Use the layers that make the specific workflow easier to understand.

Mini Reference

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Service
    Application-level business operation

Service function
    Plain function that performs a workflow

Service class
    Class used when a workflow benefits
    from shared state or multiple methods

View
    Handles HTTP request and response logic

Form
    Validates browser-submitted data

Model
    Represents persistent application data

Custom QuerySet
    Encapsulates reusable query behavior

transaction.atomic()
    Groups related database writes

transaction.on_commit()
    Runs work after a successful commit

Business exception
    Represents an expected application
    failure such as insufficient stock

Basic service:

python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def publish_article(
    *,
    article,
    user,
):
    if article.author_id != user.id:
        raise PermissionDenied

    article.is_published = True
    article.save(
        update_fields=[
            "is_published",
        ]
    )

    return article

Basic view usage:

python

1
2
3
4
5
6
7
8
9
article = publish_article(
    article=article,
    user=request.user,
)

return redirect(
    "article-detail",
    pk=article.pk,
)

Transactional service:

python

1
2
3
@transaction.atomic
def create_order(...):
    ...

External action after commit:

python

1
2
3
transaction.on_commit(
    lambda: send_email(...)
)

Services in Django provide a way to separate application workflows from HTTP request handling.

They are not a required Django feature and should not be added mechanically to every project.

Their main purpose is to make important business operations explicit.

A useful division of responsibilities is:

text

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Views
    Handle HTTP

Forms
    Validate input

Models
    Represent data and model behavior

Services
    Coordinate business workflows

Services become especially valuable when an operation touches several models, contains important business rules, needs transactions, calls external systems, or must be reused from several entry points.

Start with simple functions such as:

python

1
2
3
create_order()
publish_article()
cancel_subscription()

Keep their inputs explicit, return useful results, and avoid coupling them to request or HttpResponse.

The goal is not to create more layers.

The goal is to make the code easier to understand.

When a developer opens a view and sees:

python

1
2
3
4
order = create_order(
    user=request.user,
    cart=cart,
)

the important business action is immediately visible.

That clarity is the main reason a service layer can be useful in a growing Django application.

Join the Newsletter

Practical insights on Django, backend systems, deployment, architecture, and real-world development — delivered without noise.

Get updates when new guides, learning paths, cheat sheets, and field notes are published.

No spam. Unsubscribe anytime.



There is no third-party involved so don't worry - we won't share your details with anyone.