Skip to main content

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

Project description

djangorestframework-services

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) — DRF-style attribute application with change tracking, no surprises.
  • Sync and async services and selectors, transparently dispatched.
  • Atomic by default, opt-out per view.
  • Framework-agnostic exceptions — services don't import from DRF.
  • 100% test coverage, type-checked, Python 3.10–3.14, Django 4.2–6.0.
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, 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):
    service = create_author
    input_dataclass = 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):
    service = create_author
    input_dataclass = 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.


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 configure: service, input_dataclass, output_serializer, output_selector, atomic, 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()
    service = update_author
    input_dataclass = UpdateAuthorInput
    output_serializer = AuthorOutputSerializer   # DataclassSerializer

A ModelSerializer can be dropped in just as cleanly:

class UpdateAuthorView(ServiceUpdateView):
    queryset = Author.objects.all()
    service = update_author
    input_dataclass = 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 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,
    }
    list_selector = list_authors
    retrieve_selector = get_author
    create_service = create_author
    create_input_dataclass = CreateAuthorInput
    create_output_serializer = AuthorDetailSerializer
    update_service = update_author
    update_input_dataclass = UpdateAuthorInput
    update_output_serializer = AuthorDetailSerializer
    destroy_service = delete_author

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 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(
        detail=True,
        methods=["post"],
        service=approve_invoice,
        input_dataclass=ApproveInput,
        output_serializer=InvoiceDetailSerializer,
    )
    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.1.0.tar.gz (113.5 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.1.0-py3-none-any.whl (48.1 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: djangorestframework_services-0.1.0.tar.gz
  • Upload date:
  • Size: 113.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.2 {"installer":{"name":"uv","version":"0.11.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for djangorestframework_services-0.1.0.tar.gz
Algorithm Hash digest
SHA256 662ba0416d430799236be03611029181df21c7f18dd89f782d422e8ff6b34d5e
MD5 8f7f7ea781fb6241bdf029d02a0372d9
BLAKE2b-256 2aca966de3c4e0e7cae319a72e91a2c3b97a974f4410e802ccc244251baed265

See more details on using hashes here.

File details

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

File metadata

  • Download URL: djangorestframework_services-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 48.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.2 {"installer":{"name":"uv","version":"0.11.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for djangorestframework_services-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7471412e201c0b7710f9fd43737ca6425883628b9a2074ca9766a72ef831dbbd
MD5 01885a47cc2d2343ea8ca79b7ea00bef
BLAKE2b-256 0fea8d5aa39b10c389f36b4e37b6cc5dfb61f8bfbf849ab749d9cb541d82f0a6

See more details on using hashes here.

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