Skip to main content

A service-oriented layer for Django REST Framework: precise, controllable side effects for mutating endpoints.

Project description

djangorestframework-services

CI PyPI Python versions Django versions Docs Coverage Ruff License

A service / selector layer for Django REST Framework.

DRF's default mode for mutating endpoints is "the serializer is the business logic". That's fine for thin CRUD, but it falls apart the moment you need to compose with an external system, fan out side effects, or write logic that doesn't belong on a model. djangorestframework-services keeps DRF's routing, validation, and serialization for what they're good at, and gives you a precise, well-typed seam for the bits in the middle.

  • Services — plain callables. The library does not define a Service base class or prescribe a signature.
  • Selectors — plain callables that override get_queryset() / get_object(). Filter backends, pagination, and serialization stay vanilla DRF.
  • Mutation helperscreate_from_input, update_from_input, apply_input (and async siblings) with change tracking, no surprises.
  • Sync and async services and selectors, transparently dispatched.
  • Atomic by default, opt-out per spec.
  • Framework-agnostic exceptions — services don't import from DRF.
  • Typed end-to-end — generic ServiceSpec[InputT, ResultT] plus lenient and strict Protocols that catch signature drift at type-check time, with fail-fast validation at as_view().
  • 100% test coverage, type-checked, Python 3.10–3.14, Django 4.2–6.0.

📖 Full documentation: https://artui.github.io/djangorestframework-services/

pip install djangorestframework-services

Quick start

A POST /authors/ endpoint that creates an Author. Input is validated by a dataclass, the service is a plain function, and the response is shaped by another dataclass — both halves rendered through DataclassSerializer from djangorestframework-dataclasses, which the library already depends on.

from dataclasses import dataclass

from rest_framework_dataclasses.serializers import DataclassSerializer
from rest_framework_services import ServiceCreateView, ServiceSpec, create_from_input

from myapp.models import Author


# 1. Input — validated at the view boundary; the service receives a
#    typed instance.
@dataclass
class CreateAuthorInput:
    name: str
    bio: str = ""


# 2. Output — a dataclass that shapes the JSON response. The service
#    can return either the matching dataclass or a model instance with
#    the same attribute names; DataclassSerializer reads via getattr.
@dataclass
class AuthorOutput:
    id: int
    name: str
    bio: str


class AuthorOutputSerializer(DataclassSerializer):
    class Meta:
        dataclass = AuthorOutput


# 3. Service — a plain callable. No DRF imports; raises framework-
#    agnostic exceptions if it needs to.
def create_author(*, data: CreateAuthorInput) -> Author:
    result = create_from_input(Author, data)
    return result.instance


# 4. View — wires it all together.
class CreateAuthorView(ServiceCreateView):
    spec = ServiceSpec(
        service=create_author,
        input_serializer=CreateAuthorInput,
        output_serializer=AuthorOutputSerializer,
    )
# urls.py
from django.urls import path
from myapp.views import CreateAuthorView

urlpatterns = [path("authors/", CreateAuthorView.as_view())]

POST {"name": "Ada"}201 with {"id": 1, "name": "Ada", "bio": ""}.

Returning the output dataclass directly

If you want the service's return type to be the response shape — useful when the API surface diverges from your model (computed fields, hidden columns, denormalised joins) — have the service build and return the output dataclass:

def create_author(*, data: CreateAuthorInput) -> AuthorOutput:
    author = Author.objects.create(name=data.name, bio=data.bio)
    return AuthorOutput(id=author.id, name=author.name, bio=author.bio)

AuthorOutputSerializer renders it the same way; the view doesn't care.

Alternative: ModelSerializer

When the response mirrors the model exactly, DRF's ModelSerializer is the shorter path and gets the full DRF feature set (relations, nested serializers, etc.):

from rest_framework import serializers


class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = Author
        fields = ("id", "name", "bio")


class CreateAuthorView(ServiceCreateView):
    spec = ServiceSpec(
        service=create_author,
        input_serializer=CreateAuthorInput,
        output_serializer=AuthorSerializer,
    )

Both patterns are first-class — the library doesn't care which kind of DRF serializer you use for output. Pick DataclassSerializer when you want the API contract to live alongside the service signature; pick ModelSerializer when the response mirrors the model and you want DRF's relational machinery.


Mental model

There are three building blocks:

Block What it is Where it lives
Service A plain callable that performs a mutation Your code
Selector A plain callable that returns data to read Your code
View / Viewset DRF view that wires a service or selector to an HTTP method Provided by this library

Views inspect the service / selector signature with inspect.signature and pass only the arguments they declare from a known pool: data, instance, request, user, view, plus extras from get_service_kwargs() / get_selector_kwargs(). If a callable declares **kwargs, the entire pool is forwarded.

def create_author(*, data, user):       # the view passes only data + user
    return Author.objects.create(name=data.name, created_by=user)

def list_authors(*, request):           # request is in the pool
    return Author.objects.filter(...)

Mutation helpers

The library doesn't run your services for you, but it does ship the helpers that DRF's serializer.save() quietly performs — minus the surprises and plus a typed change record.

from rest_framework_services import update_from_input, UNSET

def update_author(*, instance, data):
    result = update_from_input(instance, data, exclude_fields=["created_by"])
    if result.get_field_change("email"):
        send_email_changed_notice(instance)
    return result.instance

What you get:

  • apply_input(instance, data) — set attributes in memory, no save.
  • create_from_input(Model, data) — build, save, optional M2M.
  • update_from_input(instance, data) — diff in-memory state vs. input, call save(update_fields=[...]) with only the fields that actually changed.
  • acreate_from_input / aupdate_from_input — async equivalents using Django 4.2+ asave() / aset().

All of them accept:

  • data — a dataclass, plain dict, or any object with __dict__.
  • field_map: dict[str, str] — translate input keys to model attribute names.
  • exclude_fields: list[str] — fields to drop from the input before applying.
  • m2m: dict[str, Any] — many-to-many assignments applied post-save (create/update only).

All of them return a ChangeResult:

@dataclass(frozen=True)
class ChangeResult:
    instance: Model
    created: bool
    changes: tuple[FieldChange, ...]

    @property
    def changed_fields(self) -> tuple[str, ...]: ...
    def get_field_change(self, field_name: str) -> FieldChange | None: ...
    def __bool__(self) -> bool: ...   # True iff any change

The UNSET sentinel distinguishes "field omitted from input" from "field explicitly set to None" — critical for correct PATCH semantics.


Typed services and selectors

Services and selectors are plain callables, but you can pin their shape to a Protocol so a type checker catches signature drift before request time.

CreateService, UpdateService, DeleteService, ListSelector, RetrieveSelector, and OutputSelector are lenient Protocols — they accept **kwargs: Any, so your callable can declare only the parameters it actually uses:

from rest_framework_services import CreateService, ListSelector


def create_author(*, data: CreateAuthorInput, user) -> Author: ...

def list_authors(*, request) -> QuerySet[Author]: ...


_: CreateService[CreateAuthorInput, Author] = create_author
_: ListSelector[Author] = list_authors

When you want the type checker to fail on any drift — including extras forwarded from a kwargs= provider — use the strict variants. They use PEP 692 Unpack[TypedDict] to pin every kwarg the callable receives:

from typing import TypedDict

from django.http import HttpRequest

from rest_framework_services import (
    ServiceSpec,
    ServiceViewSet,
    StrictCreateService,
    StrictListSelector,
)


class AuthorExtras(TypedDict):
    tenant_id: int


def _author_kwargs(view, request) -> AuthorExtras:
    return {"tenant_id": request.tenant.id}


def create_author(
    *,
    data: CreateAuthorInput,
    request: HttpRequest,
    tenant_id: int,                 # must match AuthorExtras
) -> Author: ...


def list_authors(
    *,
    request: HttpRequest,
    tenant_id: int,
) -> QuerySet[Author]: ...


_: StrictCreateService[CreateAuthorInput, Author, AuthorExtras] = create_author
_: StrictListSelector[Author, AuthorExtras] = list_authors


class AuthorViewSet(ServiceViewSet):
    action_specs = {
        "create": ServiceSpec(
            service=create_author,
            input_serializer=CreateAuthorInput,
            output_serializer=AuthorOutputSerializer,
            kwargs=_author_kwargs,
        ),
    }

Adding a parameter to the service without updating AuthorExtras is a type error. Removing a key from AuthorExtras without updating the service is a type error.

On top of static typing, as_view() walks every spec at URL-wiring time and raises ImproperlyConfigured for misconfigurations the checker can't see — a service requiring data with no input_serializer, an instance parameter on a create flow, or a required parameter no extras provider supplies.

See Typing services and selectors for the full Protocol catalogue and per-spec kwargs= resolution rules.


Views

Class Method Purpose
ServiceCreateView POST runs service to create
ServiceUpdateView PUT / PATCH runs service to update; instance from get_object()
ServiceDeleteView DELETE runs service to delete
SelectorListView GET uses selector (or queryset) for list
SelectorRetrieveView GET uses selector (or queryset + lookup_field) for retrieve

Mutation views are configured by setting a single spec class attribute to a ServiceSpec, which bundles service, input_serializer, output_serializer, output_selector, atomic, and success_status. Selector views configure selector and DRF's standard serializer_class.

@dataclass
class UpdateAuthorInput:
    name: str | None = None
    bio: str | None = None


class UpdateAuthorView(ServiceUpdateView):
    queryset = Author.objects.all()
    spec = ServiceSpec(
        service=update_author,
        input_serializer=UpdateAuthorInput,
        output_serializer=AuthorOutputSerializer,   # DataclassSerializer
    )

A ModelSerializer can be dropped in just as cleanly:

class UpdateAuthorView(ServiceUpdateView):
    queryset = Author.objects.all()
    spec = ServiceSpec(
        service=update_author,
        input_serializer=UpdateAuthorInput,
        output_serializer=AuthorSerializer,         # DRF ModelSerializer
    )

When a service returns None and the view has an instance in scope (update or delete), the in-memory instance is rendered — matching DRF's UpdateAPIView shape without you having to wire it up.


Viewsets

ServiceViewSet is a router-compatible viewset composed of per-action mixins. Each action picks its own serializer; below the read side uses two DataclassSerializer shapes (terse list, full detail) and the mutations reuse the detail one:

from dataclasses import dataclass

from rest_framework_dataclasses.serializers import DataclassSerializer
from rest_framework_services import ServiceSpec, ServiceViewSet


@dataclass
class AuthorListItem:
    id: int
    name: str


@dataclass
class AuthorDetail:
    id: int
    name: str
    bio: str


class AuthorListItemSerializer(DataclassSerializer):
    class Meta:
        dataclass = AuthorListItem


class AuthorDetailSerializer(DataclassSerializer):
    class Meta:
        dataclass = AuthorDetail


class AuthorViewSet(ServiceViewSet):
    queryset = Author.objects.all()
    serializer_classes = {
        "list": AuthorListItemSerializer,
        "retrieve": AuthorDetailSerializer,
    }
    service_specs = {
        "list": list_authors,
        "retrieve": get_author,
        "create": ServiceSpec(
            service=create_author,
            input_serializer=CreateAuthorInput,
            output_serializer=AuthorDetailSerializer,
        ),
        "update": ServiceSpec(
            service=update_author,
            input_serializer=UpdateAuthorInput,
            output_serializer=AuthorDetailSerializer,
        ),
        "destroy": ServiceSpec(service=delete_author),
    }

service_specs is a single action-keyed mapping — mirroring serializer_classes from MultiSerializerMixin. Read-side actions ("list", "retrieve") take a bare callable (the selector). Write-side actions take a ServiceSpec bundling everything that flow needs. An absent (or non-ServiceSpec) entry on a write action makes it return 405 Method Not Allowed.

Mix ModelSerializer and DataclassSerializer per action freely — the viewset doesn't distinguish between them.

Register it with a router as usual:

router = DefaultRouter()
router.register("authors", AuthorViewSet, basename="author")

Per-action mixins (ServiceCreateMixin, ServiceUpdateMixin, ServiceDestroyMixin, SelectorListMixin, SelectorRetrieveMixin, MultiSerializerMixin) are exported so you can compose only the actions you need:

class AuthorReadOnly(SelectorListMixin, SelectorRetrieveMixin, GenericViewSet):
    queryset = Author.objects.all()
    serializer_class = AuthorDetailSerializer   # or any DRF Serializer

SelectorViewSet is a pre-built read-only composition.

MultiSerializerMixin

Per-action serializer dispatch via a single mapping:

serializer_classes = {
    "list": ListSerializer,
    "retrieve": DetailSerializer,
    "my_custom_action": CustomSerializer,
}

get_serializer_class() consults the map first, falls back to serializer_class.

@service_action

Custom viewset actions wrapped in the same plumbing as the standard mutation flow:

from dataclasses import dataclass

from rest_framework_dataclasses.serializers import DataclassSerializer
from rest_framework_services import ServiceSpec, service_action


@dataclass
class ApproveInput:
    note: str = ""


@dataclass
class InvoiceDetail:
    id: int
    customer: str
    amount_cents: int
    status: str


class InvoiceDetailSerializer(DataclassSerializer):
    class Meta:
        dataclass = InvoiceDetail


class InvoiceViewSet(ServiceViewSet):
    @service_action(
        ServiceSpec(
            service=approve_invoice,
            input_serializer=ApproveInput,
            output_serializer=InvoiceDetailSerializer,
        ),
        detail=True,
        methods=["post"],
    )
    def approve(self, request, pk=None):
        """Approve an invoice."""

The decorated method body is not executed — the decorator supplies the handler. The body is there so the action has a docstring, a name (used by the router), and a place for @action-compatible metadata.


Errors

Services raise framework-agnostic exceptions. The view boundary translates them to DRF responses.

from rest_framework_services import ServiceError, ServiceValidationError

def withdraw(*, instance, data):
    if data.amount > instance.balance:
        raise ServiceValidationError({"amount": ["insufficient funds"]})
    if instance.locked:
        raise ServiceError("account is locked")
    instance.balance -= data.amount
    instance.save(update_fields=["balance"])
    return instance
Raised Becomes HTTP
ServiceValidationError rest_framework.exceptions.ValidationError 400
ServiceError rest_framework.exceptions.APIException 422

Atomic transactions

Every service call is wrapped in transaction.atomic() by default. Opt out on a view-by-view basis:

class ImportView(ServiceCreateView):
    service = run_import
    atomic = False   # the import service handles its own savepoints

Async services

Services and selectors can be async def. The dispatcher detects this via inspect.iscoroutinefunction and runs them via asgiref.sync.async_to_sync under sync views, or directly under async views. Atomic wrapping works for both.

async def fetch_remote(*, request):
    async with httpx.AsyncClient() as client:
        return await client.get("https://...").json()

class FetchView(ServiceCreateView):
    service = fetch_remote

startserviceapp

A management command that scaffolds a service-oriented Django app:

python manage.py startserviceapp billing

Produces:

billing/
├── __init__.py
├── apps.py
├── admin.py
├── urls.py
├── models/__init__.py
├── views/__init__.py
├── services/__init__.py
├── selectors/__init__.py
├── validators/__init__.py
├── serializers/__init__.py
├── utils/__init__.py
├── migrations/__init__.py
└── tests/__init__.py

Add "rest_framework_services" to INSTALLED_APPS to make the command discoverable.

A note on validators/

The validators/ package is a stylistic convention, not a library feature. The library doesn't import from it or look it up by name. It exists to give business-level validation a home of its own — rules like "a draft invoice can only be sent if the customer has a verified email" or "refunds beyond 30 days require manager approval". These belong neither in the model nor in the serializer.

The split this layout suggests:

Concern Lives in
Type validation, required fields, format checks DRF serializers (serializers/), including DataclassSerializer for service inputs
Business rules / cross-record invariants / external-state checks Functions in validators/, called from services
Side effects, persistence, orchestration services/

Use it, ignore it, or rename it — the scaffolding is a starting point, not a contract.


Examples

A minimal but runnable example project lives in examples/. It demonstrates the create / list / retrieve / update / destroy flows on a single resource and one custom action via @service_action.

cd examples
python manage.py migrate
python manage.py runserver

Compatibility

Axis Range
Python 3.10 – 3.14
Django 4.2, 5.0, 5.1, 5.2, 6.0
DRF ≥ 3.14

CI runs the full Python × Django matrix with 100% coverage gating.


Status

Pre-1.0. Public API is stable but may shift. See CHANGELOG.md for the full release history.


License

MIT.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

djangorestframework_services-0.6.1.tar.gz (179.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

djangorestframework_services-0.6.1-py3-none-any.whl (77.5 kB view details)

Uploaded Python 3

File details

Details for the file djangorestframework_services-0.6.1.tar.gz.

File metadata

File hashes

Hashes for djangorestframework_services-0.6.1.tar.gz
Algorithm Hash digest
SHA256 a814890ba5c1f6c10ad4ee9ab6d2360e33d0d18e408765707120d7d6add8888b
MD5 2c0004e72ccf7965d3f5d14b48275a01
BLAKE2b-256 0776cb24742a38b578c2b6be91ce331785ae9c2676e770925205341a2a35bece

See more details on using hashes here.

Provenance

The following attestation bundles were made for djangorestframework_services-0.6.1.tar.gz:

Publisher: release.yml on Artui/djangorestframework-services

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file djangorestframework_services-0.6.1-py3-none-any.whl.

File metadata

File hashes

Hashes for djangorestframework_services-0.6.1-py3-none-any.whl
Algorithm Hash digest
SHA256 22caa6985387259f6887c6b7d4bfd839f44d6d3a43bda9474f8de21ecc15302a
MD5 a137b79787232362ac0ff331e71e0808
BLAKE2b-256 88d7caa6b7dd08cc37fa1254850b590e95a83c7e92661c34517da43ede17bc2c

See more details on using hashes here.

Provenance

The following attestation bundles were made for djangorestframework_services-0.6.1-py3-none-any.whl:

Publisher: release.yml on Artui/djangorestframework-services

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page