Service Layer Patterns

Organize business logic in your Django modules using the services and selectors pattern. This approach, popularized by the HackSoft Django Styleguide, separates read and write operations.

Overview

In a typical Django project, business logic ends up scattered across views, serializers, model methods, signals, and management commands. This makes code hard to test and reuse.

The services/selectors pattern establishes a simple rule: all business logic lives in services (writes) or selectors (reads). Everything else (views, serializers, signals) becomes thin glue code that delegates to these functions.

The Core Principle

Services handle write operations:

  • Create, update, or delete data

  • Trigger side effects (emails, events, external APIs)

  • Enforce business rules on mutations

Selectors handle read operations:

  • Query and filter data

  • Apply access control to queries

  • Return data without side effects

# {project_slug}/users/services.py - Write operations
def user_create(*, email: str, name: str) -> User:
    """Create a new user with profile."""
    user = User(email=email)
    user.full_clean()  # Validate before save
    user.save()

    profile_create(user=user, name=name)
    send_welcome_email.delay(user_id=user.id)

    return user

# {project_slug}/users/selectors.py - Read operations
def user_list(*, fetched_by: User) -> QuerySet[User]:
    """Return users visible to the requesting user."""
    if fetched_by.is_staff:
        return User.objects.all()
    return User.objects.filter(is_active=True)

Where Business Logic Should NOT Live

The pattern is as much about where logic doesn’t go as where it does:

Not in views

Views handle HTTP concerns only: parsing requests, calling services/selectors, returning responses.

# BAD - business logic in view
class UserCreateView(APIView):
    def post(self, request):
        email = request.data["email"]
        if User.objects.filter(email=email).exists():
            raise ValidationError("Email taken")
        user = User.objects.create(email=email)
        Profile.objects.create(user=user)
        send_welcome_email.delay(user.id)
        return Response(UserSerializer(user).data)

# GOOD - view delegates to service
class UserCreateView(APIView):
    def post(self, request):
        serializer = UserCreateInputSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        user = user_create(**serializer.validated_data)

        return Response(UserSerializer(user).data)

Not in serializers

Serializers handle validation and transformation, not business logic.

# BAD - business logic in serializer
class UserSerializer(serializers.ModelSerializer):
    def create(self, validated_data):
        user = User.objects.create(**validated_data)
        Profile.objects.create(user=user)  # Side effect!
        return user

# GOOD - serializer only validates
class UserCreateInputSerializer(serializers.Serializer):
    email = serializers.EmailField()
    name = serializers.CharField(max_length=100)

Not in signals

Signals create hidden coupling. Use explicit service calls or domain events instead.

# BAD - hidden side effect in signal
@receiver(post_save, sender=User)
def create_profile_on_user_create(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)

# GOOD - explicit in service
def user_create(*, email: str, name: str) -> User:
    user = User.objects.create(email=email)
    profile_create(user=user, name=name)  # Explicit, testable
    return user

Not in model save()

Overriding save() for business logic makes models unpredictable and hard to test.

# BAD - side effects in save()
class User(models.Model):
    def save(self, *args, **kwargs):
        is_new = self.pk is None
        super().save(*args, **kwargs)
        if is_new:
            send_welcome_email.delay(self.id)  # Surprise!

# GOOD - model is just data
class User(models.Model):
    email = models.EmailField(unique=True)
    # No custom save() with side effects

Model Properties: The Exception

Model properties are acceptable for simple, non-relational computations:

class User(models.Model):
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)

    @property
    def full_name(self) -> str:
        """Simple computation from own fields - OK as property."""
        return f"{self.first_name} {self.last_name}"

    @property
    def is_adult(self) -> bool:
        """Simple business rule from own fields - OK as property."""
        return self.age >= 18

But move it to a selector if it:

  • Queries related objects

  • Has complex business rules

  • Needs to be reused across modules

Writing Services

Services should be explicit about their inputs and outputs.

Function Signature Pattern

Use keyword-only arguments (*) to force explicit parameter names at call sites:

# Forces callers to write: user_create(email="...", name="...")
# Instead of: user_create("...", "...")
def user_create(*, email: str, name: str) -> User:
    ...

Return DTOs for Cross-Module Communication

When services are called from other modules, return data transfer objects instead of model instances:

from dataclasses import dataclass

@dataclass(frozen=True)
class UserDTO:
    id: int
    email: str
    name: str

def user_get_by_id(user_id: int) -> UserDTO | None:
    """Public interface for other modules."""
    try:
        user = User.objects.get(id=user_id)
        return UserDTO(id=user.id, email=user.email, name=user.name)
    except User.DoesNotExist:
        return None

This prevents other modules from depending on your model internals.

Atomic Transactions

Wrap services that make multiple changes in transaction.atomic():

from django.db import transaction

@transaction.atomic
def order_create(*, user_id: int, items: list[dict]) -> Order:
    """Create order with items atomically."""
    order = Order.objects.create(user_id=user_id, status="pending")

    for item in items:
        OrderItem.objects.create(
            order=order,
            product_id=item["product_id"],
            quantity=item["quantity"],
        )

    return order

Writing Selectors

Selectors return querysets or model instances, never triggering side effects.

Filtering with Access Control

Selectors often need to filter based on who’s asking:

def order_list(*, fetched_by: User) -> QuerySet[Order]:
    """Return orders visible to the requesting user."""
    if fetched_by.is_staff:
        return Order.objects.all()
    return Order.objects.filter(user_id=fetched_by.id)

def order_get(*, order_id: int, fetched_by: User) -> Order:
    """Get a specific order if the user can access it."""
    queryset = order_list(fetched_by=fetched_by)
    return queryset.get(id=order_id)

Avoid N+1 Queries

Selectors should handle query optimization:

def order_list_with_items(*, fetched_by: User) -> QuerySet[Order]:
    """Return orders with items pre-fetched."""
    return (
        order_list(fetched_by=fetched_by)
        .prefetch_related("items", "items__product")
        .select_related("shipping_address")
    )

Module Structure

Organize services and selectors as modules grow:

Small Module

{project_slug}/users/
├── models.py
├── services.py      # All services in one file
├── selectors.py     # All selectors in one file
└── ...

Large Module

{project_slug}/orders/
├── models.py
├── services/
│   ├── __init__.py  # Re-export public functions
│   ├── order.py     # order_create, order_update, order_cancel
│   ├── payment.py   # payment_process, payment_refund
│   └── shipping.py  # shipping_calculate, shipping_create
├── selectors/
│   ├── __init__.py
│   ├── order.py
│   └── analytics.py
└── ...

Re-export public functions from __init__.py for a clean API:

# {project_slug}/orders/services/__init__.py
from .order import order_create, order_update, order_cancel
from .payment import payment_process, payment_refund

Common Pitfalls

Over-engineering small projects

For a simple CRUD app, services/selectors add overhead without benefit. Use this pattern when:

  • Multiple modules need to call the same business logic

  • Business rules are complex enough to warrant isolation

  • You want clear seams for future module extraction

N+1 queries across modules

Without foreign keys (see Module Boundary Enforcement), cross-module queries can multiply:

# BAD - N+1 queries
def order_list_with_users(fetched_by: User) -> list[dict]:
    orders = order_list(fetched_by=fetched_by)
    return [
        {
            "order": order,
            "user": user_get_by_id(order.user_id),  # Query per order!
        }
        for order in orders
    ]

# BETTER - batch fetch
def order_list_with_users(fetched_by: User) -> list[dict]:
    orders = list(order_list(fetched_by=fetched_by))
    user_ids = [o.user_id for o in orders]
    users = user_get_by_ids(user_ids)  # Single query
    users_by_id = {u.id: u for u in users}

    return [
        {"order": order, "user": users_by_id.get(order.user_id)}
        for order in orders
    ]

Unclear public interfaces

Document which services/selectors are part of the module’s public API:

# {project_slug}/users/services.py

# === PUBLIC API ===
# These functions can be called from other modules

def user_create(...): ...
def user_update(...): ...
def user_exists(user_id: int) -> bool: ...

# === INTERNAL ===
# These are implementation details, not for external use

def _validate_email_domain(email: str) -> bool: ...
def _send_verification_email(user: User) -> None: ...

Testing Services and Selectors

Services and selectors are easy to unit test because they’re plain functions:

import pytest
from {project_slug}.users.services import user_create
from {project_slug}.users.selectors import user_list

@pytest.mark.django_db
def test_user_create():
    user = user_create(email="test@example.com", name="Test User")

    assert user.email == "test@example.com"
    assert user.profile.name == "Test User"

@pytest.mark.django_db
def test_user_list_filters_inactive_for_non_staff(user_factory):
    staff = user_factory(is_staff=True)
    regular = user_factory(is_staff=False)
    inactive = user_factory(is_active=False)

    # Staff sees everyone
    assert inactive in user_list(fetched_by=staff)

    # Regular user doesn't see inactive
    assert inactive not in user_list(fetched_by=regular)

See Also