Skip to content

Concepts

Three building blocks. Everything else composes them.

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 This library

Services

A service is any callable. The library does not define a Service base class. It dispatches the callable, hands it the kwargs it asks for, and maps any framework-agnostic exception it raises to a DRF response.

def create_author(*, data, user):
    return Author.objects.create(name=data.name, created_by=user)

A service can return:

  • the freshly mutated model instance (DRF's typical pattern),
  • the matching output dataclass (when the API surface diverges from the model),
  • None for update / delete flows — the in-memory instance is rendered instead, matching DRF's UpdateAPIView shape without you wiring it up.

Selectors

A selector is a callable used by read-side flows. It overrides get_queryset() for list and get_object() for retrieve. Filter backends, pagination, and serialization stay vanilla DRF.

def list_authors(*, request):
    return Author.objects.filter(account=request.user.account)

Selectors go into action_specs wrapped in a SelectorSpec whose kind declares whether the action returns many objects or a single one:

action_specs = {
    "list": SelectorSpec(kind=SelectorKind.LIST, selector=list_authors),
    "retrieve": SelectorSpec(kind=SelectorKind.RETRIEVE, selector=get_author),
}

SelectorSpec

SelectorSpec is a frozen dataclass (keyword-only fields) bundling everything a read action needs:

@dataclass(frozen=True, kw_only=True)
class SelectorSpec(Generic[ResultT, ExtraT]):
    kind: SelectorKind  # required
    selector: Callable[..., ResultT] | None = None
    allow_none: bool = False
    output_serializer: type[Serializer] | None = None
    kwargs: Callable[..., ExtraT] | None = None
    permission_classes: Sequence[type[BasePermission]] | None = None
    output_serializer_context: Callable[..., Mapping[str, Any]] | None = None
    select_related: Sequence[str] | None = None
    prefetch_related: Sequence[str | Prefetch] | None = None
    annotations: Mapping[str, Any] | None = None
    extend_queryset: Callable[[QuerySet, ServiceView, Request], QuerySet] | None = None
    metadata: Mapping[str, Any] | None = None
  • kind — required SelectorKind discriminator (LIST vs RETRIEVE). Drives dispatch: RETRIEVE materializes a QuerySet via .first() and raises NotFound on None, LIST returns the (optionally shaped) selector result. Also drives the fail-fast cross-check that the spec is mounted on a compatible view — a LIST spec on SelectorRetrieveView (or action_specs["retrieve"]) raises ImproperlyConfigured at as_view() time. Making the kind explicit also lets a spec be reused outside a request (management command, cron job, non-DRF caller) without the semantics living implicitly in the call site.
  • selector — the callable invoked by get_queryset() (list) or get_object() (retrieve). None means "use the configured queryset / default DRF behaviour".
  • allow_noneRETRIEVE-only knob for the None / missing-object case. False (the default) raises NotFound. True expresses a nullable-resource contract: the retrieve view / viewset mixin renders 200 with a JSON null body, skipping the output serializer — for singleton-style resources that legitimately may not exist yet. Ignored on nested specs: output_selector_spec keeps its authoritative-None → 204 contract, and instance_selector_spec always 404s (a mutation against a missing row is not a nullable read).
  • output_serializer — a DRF Serializer subclass used by get_serializer_class() for this action. None falls back to DRF's standard serializer_class.
  • kwargs — callable returning extra kwargs to merge into the pool the selector receives. The most-specific level of the kwargs resolution chain; co-located with the selector it feeds. Invoked through the framework's keyword pool — it declares any subset of view / request (or **kwargs) and receives only what it asks for. See the extra-kwargs recipe.
  • permission_classes — overrides the view's class-level permission_classes for the action the spec backs. None (the default) inherits; [] means "no permissions" explicitly. Ignored when the spec is nested under ServiceSpec.output_selector_spec (the surrounding mutation's permissions apply). See the permissions recipe.
  • output_serializer_context — callable returning extra keys for the response serializer's context= dict. Sits at the most-specific layer of the serializer-context resolution chain.
  • select_related / prefetch_related / annotations / extend_queryset — declarative + dynamic queryset shaping applied to the selector's return value inside dispatch_selector_for_spec. See the queryset-shaping recipe.
  • metadata — a mapping the framework carries and does not read, apart from the reserved "json_schema" key. Your own per-operation facts, attached to the spec that describes the operation. See Consumer-owned metadata.

Generic parameters ResultT / ExtraT default to Any, so SelectorSpec(kind=..., selector=fn) keeps working unparameterized.

ServiceSpec

ServiceSpec is a frozen dataclass bundling everything a write action needs. The entire output pipeline (response serializer, optional post-mutation re-fetch, queryset shaping) lives in a single nested output_selector_spec: SelectorSpec | None:

@dataclass(frozen=True)
class ServiceSpec(Generic[InputT, ResultT, ExtraT]):
    service: Callable[..., ResultT]
    atomic: bool = True
    success_status: int | Callable[..., int] | None = None
    idempotent: bool | None = None
    partial: bool | None = None
    input_serializer: type | None = None
    input_data: Callable[..., Mapping[str, Any]] | None = None
    input_serializer_context: Callable[..., Mapping[str, Any]] | None = None
    instance_selector_spec: SelectorSpec[Any, Any] | None = None
    output_selector_spec: SelectorSpec[Any, Any] | None = None
    kwargs: Callable[..., ExtraT] | None = None
    permission_classes: Sequence[type[BasePermission]] | None = None
    response_finalizer: Callable[..., Response | None] | None = None
    metadata: Mapping[str, Any] | None = None
  • service — the callable to invoke.
  • atomic — wrap the service call in transaction.atomic() (defaults True).
  • success_status — override the HTTP status (defaults to 201 for create, 200 for update, 204 for delete). May also be a callable resolved through the keyword pool (result / instance / request / view) returning the status — the callable keys on the service's return value, so an upsert can answer 201 when it created a row and 200 when it found one:
def _upsert(*, data):
    author, created = Author.objects.get_or_create(name=data.name)
    return UpsertResult(author=author, created=created)


ServiceSpec(
    service=_upsert,
    input_serializer=AuthorIn,
    success_status=lambda *, result: 201 if result.created else 200,
    output_selector_spec=SelectorSpec(
        kind=SelectorKind.RETRIEVE,
        selector=lambda *, result: Author.objects.filter(pk=result.author.pk),
        output_serializer=AuthorSerializer,
    ),
)

OpenAPI documents the action default for the callable case (it can't be resolved statically). - idempotent — whether repeating the call with the same arguments leaves the same state as making it once. Nothing in this package reads it: idempotency is a property of the service you write, not something a dispatcher can arrange. It is here so the fact is stated once, beside the operation it is true of, and every consumer reads the same answer — a retry policy, a queue's redelivery handling, an agent tool annotation. Before, a consumer running two transports off one registry had to repeat the claim per transport, in each transport's own vocabulary.

None (the default) means undeclared, and that is not the same as False. A consumer that publishes the signal has to tell "nothing was said" from "the author said no" — defaulting to False would have every spec ever written start claiming it is not idempotent.

ServiceSpec(service=set_project_status, input_serializer=StatusIn, idempotent=True)

atomic answers a different question: it says one call is all-or-nothing, not that a second call is a no-op. SelectorSpec has no such field — a read is idempotent by construction, so the signal would say nothing there. - partial — override the transport-derived partial-validation flag. None (the default) inherits what the verb implies (False for PUT/POST, True for PATCH); True/False forces it. Applied once at the central dispatch point, so it works uniformly across viewset mixins, standalone views, @service_action — and create dispatch. See PATCH that validates like PUT. - many — validate the request body as a list and hand the validated list to the service (which loops itself); the result list is rendered the same way. The serializer(many=True) sibling of partial, mutually exclusive with collection_selector_spec. See Bulk mutations. - input_serializer — a DRF Serializer subclass, a bare @dataclass (auto-wrapped in DataclassSerializer), or None for side-effect-only services. - input_data — callable returning a mapping merged on top of request.data before the input_serializer validates it. Useful for lifting URL kwargs (e.g. parent IDs from nested routes) into fields the serializer can cross-validate. Server-provided keys win on conflict. May additionally declare instance as a keyword parameter to receive the resolved mutation target (None on create) — passed only when declared — so pre-validation input mutation that depends on the current row has a home. - instance_selector_spec — nested SelectorSpec (kind=SelectorKind.RETRIEVE) resolving the instance an update / destroy / detail action targets, embedding the lookup in the spec instead of the view's queryset / get_object() chain. The selector pool is {request, user} + the URL kwargs + the selector extras chain, so selector=lambda *, pk: Project.objects.filter(pk=pk) resolves the row from the route. Resolution happens before input validation; the resolved instance feeds the input serializer (DRF-style serializer(instance, data=..., partial=...)), the service pool (instance), and object-level permission checks (check_object_permissions). None / missing → 404. Queryset shaping applies; the nested spec's output_serializer and output_serializer_context are ignored, and its permission_classes / preconditions are rejected at as_view() — only the dispatching spec's permissions are ever checked, so a nested list would guard nothing. None (the default) keeps the get_object() chain. See Standalone update without a queryset. - collection_selector_spec — the bulk twin of instance_selector_spec: a kind=SelectorKind.LIST nested spec that resolves a set (scoped by the selector + filter_set) and seeds it into the service pool as collection for an instance-less bulk delete / update. Mutually exclusive with many. See Bulk mutations. - input_serializer_context — callable returning extra keys for the input serializer's context= dict. Sits at the most-specific layer of the serializer-context resolution chain. - output_selector_spec — nested SelectorSpec (kind=SelectorKind.RETRIEVE) carrying the response serializer, the optional re-fetch selector, the output output_serializer_context hook, and the queryset-shaping fields. None (the default) renders the service's return value directly. The nested spec's kwargs is ignored — the surrounding mutation's kwargs chain applies — and its permission_classes / preconditions are rejected at as_view(). - kwargs — callable returning extra kwargs to merge into the pool the service receives. The most-specific level of the kwargs resolution chain; co-located with the service it feeds. - permission_classes — overrides the view's class-level permission_classes for the action the spec backs. None (the default) inherits; [] means "no permissions" explicitly. See the permissions recipe. - response_finalizer — a post-serialization HTTP hook for cookie / header side effects (or swapping the response). Runs on the 2xx path only, after the output serializer builds the Response and before it is returned; error paths bypass it. Resolved through the keyword pool (response / result / request / view / instance / data), it returns a Response to replace the built one, or None to keep it:

def _set_session_cookie(*, response, result):
    response.set_cookie("session", result.token, httponly=True)
    return response


ServiceSpec(service=_login, input_serializer=LoginIn, response_finalizer=_set_session_cookie)

result is the service's return value, so services stay DRF-free — return domain flags on the result DTO and let the finalizer translate them into transport effects. HTTP-only: skipped on the transport-neutral path (dispatch_spec / call_service / MCP). - metadata — a mapping the framework carries and does not read, apart from the reserved "json_schema" key. See Consumer-owned metadata.

Generic parameters InputT / ResultT / ExtraT default to Any, so ServiceSpec(service=fn) keeps working unparameterized.

Consumer-owned metadata

Both specs carry a metadata: Mapping[str, Any] | None field. The framework reads exactly one key out of it, "json_schema" (see a spec-level title and description), and that key is reserved. Every other key is carried and never read — no defaulting, no per-key validation, no effect on the generated JSON Schema or OpenAPI. Validation at construction is shape-only: a non-mapping raises ImproperlyConfigured there, and the reserved key is checked when a schema is generated, so declaring metadata costs nothing on a spec that generates none.

It exists so a project can attach its own per-operation facts to the spec that describes the operation, and read them back from its own code:

class SameTenant(BasePermission):
    def has_permission(self, request, view):
        spec = view.action_specs[view.action]
        scope = (spec.metadata or {}).get("scope")
        return scope != "tenant" or request.user.tenant_id is not None


ServiceSpec(
    service=refund_order,
    input_serializer=RefundIn,
    permission_classes=[SameTenant],
    metadata={"scope": "tenant"},
)

The alternative — a {spec_name: declaration} side table — works, but a rename then means two edits in two files, with a parity test standing in for what the type system could have enforced. Putting the declaration on the spec removes the second file.

Why the spec and not the registry entry: a DRF permission class receives (request, view), and the view resolves its spec from action_specs. It holds the spec object but knows no registry and no name, so a registry-side field would be unreachable from the place that needs it. On the spec, both sides reach it — spec.metadata from a view, entry.spec.metadata from a registry consumer. Note the division of labour with RegisteredSpec.tags: tags carry boolean-ish labels every transport interprets ("read", "admin"); metadata carries structured facts only your code interprets.

Three things it deliberately does not do:

  • It never merges or inherits. A ServiceSpec and its output_selector_spec are independent objects with independent metadata. Same for PolymorphicServiceSpec, which has no metadata field of its own — declare it on the variants (a permission class on a polymorphic action should therefore hang off each variant's permission_classes, since under the default permission_strategy="union" permissions run before a variant is chosen).
  • It is not copied or frozen. The spec is frozen; the mapping you pass is not, and it is stored as given. Pass something you don't mutate.
  • It assigns no meaning to a key it has not reserved. "json_schema" is reserved and is the only one; a key the library later wants is announced in the changelog and refused loudly rather than quietly reinterpreted. Everything else stays yours.

Polymorphic actions

PolymorphicServiceSpec expresses a single action that accepts several mutually exclusive payload shapes — each with its own input serializer and service. A discriminator inspects the request and picks a variant key; the chosen variant then dispatches exactly like a plain ServiceSpec. Usable anywhere a ServiceSpec is (an action_specs entry or @service_action).

def _pick(*, data):
    if "email" in data:
        return "email"
    if "token" in data:
        return "token"
    raise ServiceValidationError({"detail": "provide an email or token"})


action_specs = {
    "create": PolymorphicServiceSpec(
        discriminator=_pick,  # pool: {request, data, user, view}
        specs={
            "email": ServiceSpec(service=register_by_email, input_serializer=EmailIn),
            "token": ServiceSpec(service=register_by_token, input_serializer=TokenIn),
        },
    ),
}
  • The discriminator is resolved once per request; the chosen variant's serializer context, kwargs, and output pipeline all apply.
  • A rejected payload is the discriminator's to raise on (ServiceValidationError → 400).
  • permission_strategy decides how get_permissions treats the variants (DRF runs permissions before the body is parsed): "union" (the default) requires the union of every variant's permission_classes — the conservative choice; "discriminate" reads the body early and applies only the chosen variant's; "require_identical" validates that all variants declare the same classes.
  • OpenAPI renders the request body as the union of the variant input serializers.

PATCH that validates like PUT

partial composes with the "partial_update" action key (which resolves first and falls back to "update" — uniformly at dispatch, permission resolution, and serializer resolution):

action_specs = {
    "partial_update": ServiceSpec(
        service=set_project_status,
        input_serializer=ProjectStatusInput,  # one required field
        partial=False,  # required stays required under PATCH
    ),
}

Defining only "partial_update" gives a PATCH-only update endpoint — PUT returns 405. On the standalone ServiceUpdateView both verbs share one spec, so a forced partial applies to PUT and PATCH; set http_method_names = ["patch"] for the PATCH-only standalone equivalent.

Standalone update without a queryset

With instance_selector_spec, a standalone mutation view needs no queryset / lookup_field — the spec is self-contained:

class SetProjectStatusView(ServiceUpdateView):
    spec = ServiceSpec(
        service=set_project_status,
        input_serializer=ProjectStatusInput,
        instance_selector_spec=SelectorSpec(
            kind=SelectorKind.RETRIEVE,
            selector=lambda *, pk: Project.objects.filter(pk=pk),
        ),
        output_selector_spec=SelectorSpec(
            kind=SelectorKind.RETRIEVE, output_serializer=ProjectSerializer
        ),
    )

The same field works on viewset update / partial_update / destroy entries and @service_action(detail=True) actions, where it takes precedence over an action_specs["retrieve"] selector and the DRF default lookup.

Bulk mutations

Two mutually-exclusive ServiceSpec fields cover what a single-instance spec can't. Both run through the same validate → dispatch → render flow — and the same transport-neutral dispatch_spec — so the rules hold on and off HTTP.

many=True — a list body in, a list out. The input_serializer validates the payload as a list and the service receives that validated list, looping itself:

class BulkCreateBooksView(ServiceCreateView):
    spec = ServiceSpec(
        service=bulk_create_books,  # (*, data: list[BookIn]) -> list[Book]
        input_serializer=BookIn,
        many=True,
        output_selector_spec=SelectorSpec(
            kind=SelectorKind.RETRIEVE, output_serializer=BookSerializer
        ),
    )

collection_selector_spec — operate on a filtered set with no pk in the URL. Its kind=SelectorKind.LIST selector (scoped by filter_set) resolves the target set and seeds it into the service as collection for a bulk delete / update; an empty set is a harmless no-op:

class BulkDeleteBooksView(ServiceDeleteView):
    spec = ServiceSpec(
        service=delete_collection(Book),  # collection.delete()
        collection_selector_spec=SelectorSpec(
            kind=SelectorKind.LIST, selector=published_books, filter_set=BookFilterSet
        ),
    )

To render the affected set instead of a summary, give that collection target an output_selector_spec whose kind is SelectorKind.LIST — it re-fetches and renders the rows as a list. Full walkthrough in the bulk & collection mutations recipe.

Dispatch

The view inspects the service / selector signature with inspect.signature and passes only the arguments the callable declares from a known pool:

Kwarg Source
data serializer.validated_data (a dataclass instance for DataclassSerializer, a dict for plain Serializer / ModelSerializer)
serializer the bound, validated input serializer (update flows construct it instance-aware) — declare it to call .save() from the service when persistence lives on the serializer (nested-write patterns)
instance spec.instance_selector_spec when set, else self.get_object() (update / destroy only)
request self.request
user self.request.user
progress a ProgressReporter — see below
URL kwargs self.kwargs (list / retrieve selectors and instance_selector_spec lookups — pk, parent IDs from nested routes, etc.)
extras self.get_service_kwargs() / self.get_selector_kwargs(), plus per-action and per-spec hooks

view is intentionally not in the pool — services and selectors are plain business logic and shouldn't reach back into the calling view. When a callable needs view state (URL kwargs, action name, etc.), pipe it through ServiceSpec.kwargs / SelectorSpec.kwargs (which receive a narrow ServiceView) or get_<action>_*_kwargs instead. See Pass extra kwargs.

If the callable declares **kwargs, the entire pool is forwarded. The implementation lives in rest_framework_services.views.utils.resolve_callable_kwargs.

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(account=request.user.account)

This matters because:

  • You don't have to declare a fixed signature. Add a kwarg when you need it; remove it when you don't.
  • Optional kwargs cost nothing. A service that doesn't declare request simply doesn't get it.
  • Custom kwargs are first-class. Override get_service_kwargs() / get_selector_kwargs() to add anything else (a tenant, a feature flag, a clock for tests). See the extra-kwargs recipe.

Reporting progress

A long-running service declares progress and calls it:

def export_invoices(*, data, progress):
    rows = list(build_rows(data))
    for index, row in enumerate(rows):
        write(row)
        progress(index + 1, total=len(rows), message="writing rows")

Reporting is always safe and never required. Every transport seeds a reporter, and the ones with nowhere to send progress seed a no-op — so the service above runs unchanged over HTTP, off-HTTP, and in tests. That default is the whole reason the reporter is a pool seed rather than an argument only some callers know how to pass: the service is written once, and whether anyone is listening is the transport's business.

progress must increase across calls within one dispatch. Omit total rather than guessing it — a receiver renders an indeterminate bar for a missing total and a wrong percentage for a wrong one.

Structured detail: meta

message is prose, for a person watching. Anything a machine on the far end should read goes in meta:

progress(
    processed,
    total=row_count,
    message=f"importing {path.name}",
    meta={
        "com.example/stage": "import",
        "com.example/file": path.name,
        "com.example/failed": failures,
    },
)

Without this slot the structure ends up in message anyway — stringified by the service and parsed back out at the sink, which is a wire format invented by accident inside a field documented as being for humans.

Each receiver decides what to do with meta: a websocket consumer forwards it into the frame its UI renders; a receiver with nowhere to put it drops it. So never encode anything the operation's correctness depends on — it is telemetry, not a channel.

Namespace the keys if the far end might be MCP

An MCP progress notification carries the structure under the protocol's _meta, whose key-naming rules reserve unprefixed names and anything under a modelcontextprotocol / mcp prefix. {"com.example/stage": …} is portable; {"stage": …} is not.

Supplying a reporter

Nothing about this is tied to any one transport. There are two ways in, depending on where the dispatch starts:

From a caller that drives dispatch itself — a Celery task, a management command, an alternate transport — pass it directly:

@shared_task
def export_task(job_id, params):
    job = ExportJob.objects.get(pk=job_id)

    def report(progress, *, total=None, message=None, meta=None):
        job.update(progress=progress, total=total, note=message, detail=dict(meta or {}))

    return dispatch_spec(export_spec, user=job.user, params=params, progress=report)

From an HTTP view, through the get_service_kwargs() / get_selector_kwargs() hook the view already uses for tenants and clocks:

class ExportViewSet(ServiceViewSet):
    action_specs = {"create": ServiceSpec(service=export_invoices)}

    def get_service_kwargs(self):
        return {"progress": self.push_to_websocket}

Extras merge over the seeds, so a server-authored progress replaces the no-op. Client input named progress cannot — it is a reserved pool seed and is stripped from the spread. That asymmetry is what makes the hook safe.

The reporter is sync, even when your sink is not

A reporter is called from inside domain code, which is written once for both transports and is therefore never async def. Bridge an async sink at the reporter rather than pushing async into the service:

def report(progress, *, total=None, message=None, meta=None):
    async_to_sync(channel_layer.group_send)(group, {...})

Only the primary callable receives the reporter. Target resolution and the output re-fetch get the no-op, because there is nothing to report from a lookup — and a live reporter there would let the output selector emit progress after the service finished, which to a watching client reads as the work having restarted.

Result rendering

What a mutation responds with is decided by three inputs: what the service returns, whether the spec carries an output pipeline, and whether success_status is set explicitly. The full matrix:

Service returns output_selector_spec Response
a value with output_serializer (no selector) serialized value at success_status (default 200/201)
a value with selector selector re-fetches (shaping applied, QuerySet materialized via .first()); result serialized at success_status
a value None the raw value at success_status — only useful for JSON-native returns (dicts, lists)
None with output_serializer (no selector) update flows render the in-memory instance through the serializer at success_status (DRF UpdateAPIView shape); destroy never resurrects the deleted instance — empty body
None with selector that returns None the selector's None is authoritative → empty body at 204 (always, even with a custom success_status)
None None empty body at the explicitly-set spec.success_status, else 204

Two consequences worth knowing:

  • A destroy (or any no-output mutation) can carry a custom success_status and still send an empty body.
  • Stale fetch-time annotations: when the service mutates in place and returns None, the update fallback renders the instance as it was looked up — annotations and shaping from the instance lookup reflect pre-mutation state. The supported pattern for "respond with the post-mutation truth" is an output_selector_spec re-fetch:
# Before — counter annotated at lookup time is stale in the response:
ServiceSpec(service=add_item, instance_selector_spec=_with_item_count)

# After — re-fetch renders post-mutation state:
ServiceSpec(
    service=add_item,
    instance_selector_spec=_by_pk,
    output_selector_spec=SelectorSpec(
        kind=SelectorKind.RETRIEVE,
        selector=lambda *, result: Checklist.objects.filter(pk=result.pk),
        annotations={"item_count": Count("items")},
        output_serializer=ChecklistSerializer,
    ),
)

Reloading and re-fetching are not the same tool

That re-fetch is often reached for as "the way to get fresh data", and a refresh_from_db() inside the service is often reached for instead. They fix different kinds of staleness, and neither is a superset of the other. Which one a response needs depends on who computed the value it is missing:

What is stale on the in-memory instance Who computed it What fixes it
Columns the write assigned; auto_now timestamps; the related row a one-row relation resolved; a prefetched collection the write changed your code, before the save nothing — the mutation helpers already settle these
A value the database produced: an F() expression, a GeneratedField, a database-side default, a trigger the database, during the save a reloadrefresh_from_db(), or a re-fetch
A value the query produced: an annotations= counter, select_related / prefetch_related shaping the queryset that fetched the row only a re-fetchoutput_selector_spec

The bottom row is the one that catches people: refresh_from_db() reloads concrete columns and cannot restore an annotation, so a stale counter survives it untouched. Only running the query again, with the shaping re-applied, produces it — which is what output_selector_spec exists for.

The top row is worth knowing for the opposite reason. Assigning views=F("views") + 1 leaves instance.views holding the expression object, not the number, so a serializer rendering that instance needs the reload; assigning a plain value, or letting auto_now fire, does not. Nor do the relations the mutation helpers write — a one-row relation is pointed at the row that was written, and a prefetched collection is dropped when the write changed which rows belong to it. See What the returned instance reads. A blanket refresh_from_db() there is not merely redundant: it discards that agreement along with every other cached relation, so the next read pays a query to learn what the instance already knew.

Views

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

Mutation views are configured by setting spec to a ServiceSpec. Selector views are configured by setting spec to a SelectorSpec.

Viewsets

ServiceViewSet is a router-compatible viewset composed of per-action mixins. A single action_specs mapping wires everything:

_author_detail = SelectorSpec(
    kind=SelectorKind.RETRIEVE,
    output_serializer=AuthorDetailSerializer,
)


class AuthorViewSet(ServiceViewSet):
    queryset = Author.objects.all()
    action_specs = {
        "list": SelectorSpec(
            kind=SelectorKind.LIST,
            selector=list_authors,
            output_serializer=AuthorListItemSerializer,
        ),
        "retrieve": SelectorSpec(
            kind=SelectorKind.RETRIEVE,
            selector=get_author,
            output_serializer=AuthorDetailSerializer,
        ),
        "create": ServiceSpec(
            service=create_author,
            input_serializer=CreateAuthorInput,
            output_selector_spec=_author_detail,
        ),
        "update": ServiceSpec(
            service=update_author,
            input_serializer=UpdateAuthorInput,
            output_selector_spec=_author_detail,
        ),
        "destroy": ServiceSpec(service=delete_author),
    }
  • Read-side actions take a SelectorSpec.
  • Write-side actions take a ServiceSpec.
  • PATCH resolves action_specs["partial_update"] first and falls back to "update" — the same chain applies at dispatch, permission resolution, and serializer resolution, so an "update"-keyed spec's permission_classes guard PATCH too. A dedicated "partial_update" entry can carry its own serializer / service / partial override.
  • Absent entries on a write action make that action return 405 Method Not Allowed.
  • A wrong-type entry (e.g. SelectorSpec on create) raises ImproperlyConfigured at request time.

SelectorViewSet is a pre-built read-only composition (list + retrieve only).

Per-action mixins (ServiceCreateMixin, ServiceUpdateMixin, ServiceDestroyMixin, SelectorListMixin, SelectorRetrieveMixin) are exported so you can compose only the actions you need — see the compose-viewset recipe.

ActionSerializerResolver

Resolves get_serializer_class() from the active action's action_specs entry (following the same "partial_update""update" fallback chain as dispatch):

spec = resolve_action_spec_entry(action_specs, self.action)
if isinstance(spec, SelectorSpec) and spec.output_serializer:
    return spec.output_serializer
if (
    isinstance(spec, ServiceSpec)
    and spec.output_selector_spec
    and spec.output_selector_spec.output_serializer
):
    return spec.output_selector_spec.output_serializer
# falls back to serializer_class

Works for both SelectorSpec (reads spec.output_serializer) and ServiceSpec (reads spec.output_selector_spec.output_serializer) entries. Falls back to DRF's standard serializer_class when the action has no spec or no response serializer is set. Already included in ServiceViewSet and SelectorViewSet; add it to any custom composition that needs per-action serializers.

@service_action

Custom viewset actions wrapped in the same plumbing as the standard mutation flow. See the service-action recipe.

What this library deliberately does not do

  • It does not define a Service base class. A service is a function.
  • It does not invent a queryset filtering DSL. Use DRF's filter_backends for list endpoints; for a retrieve selector — which overrides get_object(), the method DRF runs filter_queryset() from, so the backends stop applying exactly as they would for any hand-written override — or for a transport-neutral declaration, point SelectorSpec.filter_set at a django-filter FilterSet — see filter a selector with filter_set.
  • It does not own the input format. Use any DRF Serializer (including ModelSerializer) or a bare @dataclass.
  • It does not decide your project layout. The startserviceapp scaffold is a starting point, not a contract.
  • It does not insist every endpoint be a spec. Constant / no-logic endpoints (an enum map, a static config payload) are fine as plain DRF views — there is no service or selector to declare, so wrapping them buys nothing.