# Appverk Av Marketplace Django Web Patterns

> Django REST Framework Patterns (Pragmatic DDD)

- Skill: `tomevault-io/appverk-av-marketplace-django-web-patterns` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tomevault-io/appverk-av-marketplace-django-web-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tomevault-io/appverk-av-marketplace-django-web-patterns/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: tomevault-io (https://skillmd.com/u/tomevault-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tomevault-io/appverk-av-marketplace-django-web-patterns

---


# Django REST Framework Patterns (Pragmatic DDD)

<HARD-RULES>
These rules are NON-NEGOTIABLE. Violating any of them is a bug.

**Architecture:**

- NEVER put business logic in ViewSets/APIViews — ALWAYS delegate to services or model methods
- NEVER return raw `Response(data)` with manually constructed dicts — ALWAYS use Serializers
- NEVER use `@api_view` for complex endpoints — use `APIView` or `ViewSet` classes

**Views:**

- NEVER do manual authentication checks in views — ALWAYS use `permission_classes` and `authentication_classes`
- ALWAYS use `ModelViewSet` / `ReadOnlyModelViewSet` when providing full CRUD on a model
- ALWAYS use `@action` decorator for custom ViewSet actions (not extra URL patterns)
- ALWAYS declare explicit `status_code` on mutating `@action` decorators

**Serializers:**

- NEVER use `Meta.fields = "__all__"` — ALWAYS use an explicit field list
- NEVER put business logic in serializers — only validation and data transformation
- ALWAYS separate `<Entity>CreateSerializer`, `<Entity>UpdateSerializer`, `<Entity>ResponseSerializer` for non-trivial resources
- ALWAYS use `serializers.PrimaryKeyRelatedField` or nested serializer — NEVER raw ID integer fields
- ALWAYS override `create()`/`update()` in serializer when custom logic needed (not in ViewSet)

**URLs:**

- ALWAYS use `DefaultRouter` for ViewSet registration
- ALWAYS namespace URL patterns per app (`app_name = "orders"`)
- NEVER hardcode URLs — ALWAYS use `reverse()` or `reverse_lazy()`

**Permissions:**

- ALWAYS use custom permission classes (not inline checks in views)
- ALWAYS combine permissions with `&` / `|` operators for composite permissions

**Exception Handling:**

- NEVER use DRF's default exception handler for domain errors — ALWAYS use a custom `exception_handler` mapping domain exceptions to API responses
- Domain exceptions hierarchy: `DomainError` → `EntityNotFoundError`, `DomainValidationError`, `PermissionDeniedError`, `ConflictError` (prefixed to avoid shadowing Python built-in `PermissionError` and Django/DRF `ValidationError`)

**Settings & Middleware:**

- ALWAYS use `django-environ` or `pydantic-settings` for env config — NEVER hardcode secrets
- ALWAYS split settings: `base.py`, `local.py`, `production.py`, `test.py`
- NEVER remove Django security middleware (`SecurityMiddleware`, `CsrfViewMiddleware`)
- ALWAYS load `CORS_ALLOWED_ORIGINS` from environment (via `django-cors-headers`)

**Throttling & Filtering:**

- ALWAYS configure throttle rates in settings, not per-view
- ALWAYS use `django-filter` with `FilterSet` classes — NEVER manual `request.query_params` parsing for filtering

**Quality:**

- ALWAYS run the project's typecheck and test commands after any change
</HARD-RULES>

These are the Django REST Framework patterns for building APIs in AppVerk projects, following **Pragmatic DDD**. Django Models serve as rich domain models. All patterns target **Python 3.13+**, **Django 5.1+**, **Django REST Framework 3.15+**.

## Architecture Overview

```
Presentation (DRF ViewSets/APIViews, Serializers, Permissions, URLs, exception handlers)
    ↓ depends on
Application Layer (Services / Use Cases)
    ↓ depends on
Domain Layer (Django Models as rich domain models, Value Objects, Repository Protocols, Domain Exceptions)
    ↓
Infrastructure Layer (Managers, QuerySets, external integrations, Celery tasks)
```

**Key difference from Full DDD:** Django Models ARE the domain entities — there is no separate mapping layer. Business methods live directly on the Model. The infrastructure layer provides custom Managers and QuerySets that encapsulate query logic.

### Directory Layout

```
project/
    apps/
        orders/
            models.py          # Domain models (rich domain entities)
            serializers.py     # Request/response serializers
            views.py           # ViewSets and APIViews
            urls.py            # Router registration (app_name = "orders")
            permissions.py     # Custom permission classes
            filters.py         # FilterSet classes
            services.py        # Application layer (use cases)
            exceptions.py      # Domain exception hierarchy
            tasks.py           # Celery tasks
            tests/
                test_views.py
                test_models.py
                test_services.py
    config/
        settings/
            base.py
            local.py
            production.py
            test.py
        urls.py
        wsgi.py
        asgi.py
```

## ViewSet Structure

One ViewSet per domain resource. Custom actions use `@action`, not separate URL patterns.

```python
# apps/orders/views.py
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet

from apps.orders.filters import OrderFilter
from apps.orders.models import Order
from apps.orders.permissions import IsOrderOwner
from apps.orders.serializers import (
    OrderCreateSerializer,
    OrderResponseSerializer,
    OrderUpdateSerializer,
)
from apps.orders.services import OrderService


class OrderViewSet(ModelViewSet):
    permission_classes = [IsOrderOwner]
    filterset_class = OrderFilter

    def get_queryset(self):
        return Order.objects.for_user(self.request.user).select_related("product")

    def get_serializer_class(self):
        if self.action == "create":
            return OrderCreateSerializer
        if self.action in ("update", "partial_update"):
            return OrderUpdateSerializer
        return OrderResponseSerializer

    def perform_create(self, serializer: OrderCreateSerializer) -> None:
        # Delegate to service — never put logic directly here
        OrderService.create_order(
            user=self.request.user,
            validated_data=serializer.validated_data,
        )

    @action(detail=True, methods=["post"], url_path="cancel", status_code=status.HTTP_200_OK)
    def cancel(self, request: Request, pk: int | None = None) -> Response:
        order = self.get_object()
        OrderService.cancel_order(order=order, cancelled_by=request.user)
        return Response(OrderResponseSerializer(order).data)
```

Register with `DefaultRouter` in `urls.py`:

```python
# apps/orders/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter

from apps.orders.views import OrderViewSet

app_name = "orders"

router = DefaultRouter()
router.register("", OrderViewSet, basename="order")

urlpatterns = [path("orders/", include(router.urls))]
```

## Serializer Patterns

Separate serializers for create, update, and response. Nested serializers for related objects.

```python
# apps/orders/serializers.py
from rest_framework import serializers

from apps.orders.models import Order
from apps.products.models import Product
from apps.products.serializers import ProductSummarySerializer


class OrderCreateSerializer(serializers.ModelSerializer):
    product = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all())

    class Meta:
        model = Order
        fields = ["product", "quantity", "notes"]

    def validate_quantity(self, value: int) -> int:
        if value < 1:
            raise serializers.ValidationError("Quantity must be at least 1.")
        return value


class OrderUpdateSerializer(serializers.ModelSerializer):
    class Meta:
        model = Order
        fields = ["quantity", "notes"]


class OrderResponseSerializer(serializers.ModelSerializer):
    product = ProductSummarySerializer(read_only=True)
    status_display = serializers.CharField(source="get_status_display", read_only=True)

    class Meta:
        model = Order
        fields = [
            "id", "product", "quantity", "notes",
            "status", "status_display", "created_at", "updated_at",
        ]
        read_only_fields = fields
```

### Conventions

- `<Entity>CreateSerializer` — write path for creation; validate business constraints here.
- `<Entity>UpdateSerializer` — write path for updates; all fields optional if supporting `PATCH`.
- `<Entity>ResponseSerializer` — read path; all fields `read_only`. Use nested serializers for related objects.
- Override `create()` / `update()` in the serializer (not ViewSet's `perform_create`) when custom save logic is needed.

## Domain Exception Handling

Services raise **domain exceptions**. A custom DRF `exception_handler` maps them to HTTP responses. ViewSets never catch domain exceptions manually.

### Domain Exception Hierarchy

```python
# apps/orders/exceptions.py — no external dependencies
class DomainError(Exception):
    """Base for all domain exceptions."""


class EntityNotFoundError(DomainError):
    """Raised when a requested entity does not exist."""


class DomainValidationError(DomainError):
    """Raised when a domain business rule is violated."""


class PermissionDeniedError(DomainError):
    """Raised when the user lacks permission for the operation."""


class ConflictError(DomainError):
    """Raised when an operation conflicts with existing state (e.g. duplicate)."""
```

### Custom DRF Exception Handler

```python
# config/exception_handler.py
from rest_framework.exceptions import APIException
from rest_framework.views import exception_handler as drf_default_handler
from rest_framework.request import Request
from rest_framework.response import Response

from apps.orders.exceptions import (
    ConflictError,
    DomainError,
    DomainValidationError,
    EntityNotFoundError,
    PermissionDeniedError,
)

DOMAIN_EXCEPTION_MAP: dict[type[DomainError], int] = {
    EntityNotFoundError: 404,
    PermissionDeniedError: 403,
    DomainValidationError: 422,
    ConflictError: 409,
}


def custom_exception_handler(exc: Exception, context: dict) -> Response | None:
    for exc_class, status_code in DOMAIN_EXCEPTION_MAP.items():
        if isinstance(exc, exc_class):
            return Response(
                {"detail": str(exc), "error_code": type(exc).__name__},
                status=status_code,
            )
    # Fall back to DRF default for APIException, 404, etc.
    return drf_default_handler(exc, context)
```

Register in settings:

```python
# config/settings/base.py
REST_FRAMEWORK = {
    "EXCEPTION_HANDLER": "config.exception_handler.custom_exception_handler",
}
```

## Permissions

Custom permission classes encapsulate authorization logic. Combine with `&` / `|` for composite rules.

```python
# apps/orders/permissions.py
from rest_framework.permissions import BasePermission, IsAuthenticated
from rest_framework.request import Request
from rest_framework.views import APIView

from apps.orders.models import Order


class IsOrderOwner(BasePermission):
    """Allow access only to the owner of the order."""

    def has_permission(self, request: Request, view: APIView) -> bool:
        return bool(request.user and request.user.is_authenticated)

    def has_object_permission(self, request: Request, view: APIView, obj: Order) -> bool:
        return obj.user_id == request.user.pk


# Composite: authenticated AND (owner OR staff)
class IsOwnerOrStaff(BasePermission):
    def has_object_permission(self, request: Request, view: APIView, obj: Order) -> bool:
        return obj.user_id == request.user.pk or request.user.is_staff


# Usage in ViewSet:
# permission_classes = [IsAuthenticated & IsOrderOwner]
```

## Settings & Middleware

Split settings by environment. Load all secrets from environment variables via `django-environ`.

```python
# config/settings/base.py
import environ

env = environ.Env()

SECRET_KEY = env("SECRET_KEY")
DEBUG = env.bool("DEBUG", default=False)

DATABASES = {
    "default": env.db("DATABASE_URL"),
}

INSTALLED_APPS = [
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "rest_framework",
    "corsheaders",
    "django_filters",
    "apps.orders",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",  # NEVER remove
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",  # NEVER remove
    "django.contrib.auth.middleware.AuthenticationMiddleware",
]

CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS", default=[])

REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": env("THROTTLE_ANON_RATE", default="100/hour"),
        "user": env("THROTTLE_USER_RATE", default="1000/hour"),
    },
    "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
    "EXCEPTION_HANDLER": "config.exception_handler.custom_exception_handler",
}
```

Environment-specific files extend base:

```python
# config/settings/local.py
from config.settings.base import *  # noqa: F403

DEBUG = True
ALLOWED_HOSTS = ["*"]
```

```python
# config/settings/test.py
from config.settings.base import *  # noqa: F403

DATABASES = {"default": env.db("TEST_DATABASE_URL", default="sqlite://:memory:")}
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
```

## Throttling & Filtering

Use `django-filter` with `FilterSet` classes. Configure rates in settings — never per-view.

```python
# apps/orders/filters.py
import django_filters

from apps.orders.models import Order


class OrderFilter(django_filters.FilterSet):
    status = django_filters.ChoiceFilter(choices=Order.Status.choices)
    created_after = django_filters.DateTimeFilter(field_name="created_at", lookup_expr="gte")
    created_before = django_filters.DateTimeFilter(field_name="created_at", lookup_expr="lte")

    class Meta:
        model = Order
        fields = ["status", "created_after", "created_before"]
```

Attach to the ViewSet via `filterset_class = OrderFilter` — DRF applies it automatically.

## Testing DRF

Use `pytest-django` with `APIClient` for functional tests and `factory_boy` for test data.

```python
# apps/orders/tests/test_views.py
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient

from apps.orders.tests.factories import OrderFactory, UserFactory


@pytest.fixture
def api_client() -> APIClient:
    return APIClient()


@pytest.fixture
def auth_client(api_client: APIClient) -> APIClient:
    user = UserFactory()
    api_client.force_authenticate(user=user)
    api_client.user = user  # type: ignore[attr-defined]
    return api_client


@pytest.mark.django_db
class TestOrderViewSet:
    def test_list_returns_only_own_orders(self, auth_client: APIClient) -> None:
        own_order = OrderFactory(user=auth_client.user)
        OrderFactory()  # belongs to another user

        response = auth_client.get(reverse("orders:order-list"))

        assert response.status_code == status.HTTP_200_OK
        assert len(response.data["results"]) == 1
        assert response.data["results"][0]["id"] == own_order.pk

    def test_create_order_returns_201(self, auth_client: APIClient) -> None:
        product = ProductFactory()
        payload = {"product": product.pk, "quantity": 2}

        response = auth_client.post(reverse("orders:order-list"), data=payload)

        assert response.status_code == status.HTTP_201_CREATED
        assert response.data["quantity"] == 2

    def test_cancel_action_returns_200(self, auth_client: APIClient) -> None:
        order = OrderFactory(user=auth_client.user, status=Order.Status.PENDING)

        response = auth_client.post(reverse("orders:order-cancel", args=[order.pk]))

        assert response.status_code == status.HTTP_200_OK
        order.refresh_from_db()
        assert order.status == Order.Status.CANCELLED

    def test_unauthenticated_returns_403(self, api_client: APIClient) -> None:
        response = api_client.get(reverse("orders:order-list"))
        assert response.status_code == status.HTTP_403_FORBIDDEN
```

Use `RequestFactory` for unit-testing a single view in isolation (no middleware, no URL routing):

```python
from django.test import RequestFactory
from apps.orders.views import OrderViewSet
from apps.orders.tests.factories import UserFactory

def test_get_queryset_filters_by_user() -> None:
    user = UserFactory.build()
    request = RequestFactory().get("/")
    request.user = user
    view = OrderViewSet()
    view.request = request  # type: ignore[assignment]
    view.kwargs = {}
    qs = view.get_queryset()
    assert qs.query.where  # filter is applied
```

### Conventions

- Use `@pytest.mark.django_db` on test classes — `pytest-django` handles transactions and rollback.
- Use `APIClient.force_authenticate()` for auth — never create real tokens in unit tests.
- Use `factory_boy` `Factory` classes for all test data — never use `Model.objects.create()` directly in tests.
- Test the HTTP interface (status codes, response shape) for view tests; test domain logic in `test_models.py` / `test_services.py`.
- See the `tdd-workflow` skill for full testing rules and factory patterns.

---
> Source: [AppVerk/av-marketplace](https://github.com/AppVerk/av-marketplace) — distributed by [TomeVault](https://tomevault.io).
<!-- tomevault:4.0:skill_md:2026-06-16 -->

