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.
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),
Nonefor update / delete flows — the in-memory instance is rendered instead, matching DRF'sUpdateAPIViewshape 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.
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— requiredSelectorKinddiscriminator (LISTvsRETRIEVE). Drives dispatch:RETRIEVEmaterializes a QuerySet via.first()and raisesNotFoundonNone,LISTreturns the (optionally shaped) selector result. Also drives the fail-fast cross-check that the spec is mounted on a compatible view — aLISTspec onSelectorRetrieveView(oraction_specs["retrieve"]) raisesImproperlyConfiguredatas_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 byget_queryset()(list) orget_object()(retrieve).Nonemeans "use the configuredqueryset/ default DRF behaviour".allow_none—RETRIEVE-only knob for theNone/ missing-object case.False(the default) raisesNotFound.Trueexpresses a nullable-resource contract: the retrieve view / viewset mixin renders200with a JSONnullbody, skipping the output serializer — for singleton-style resources that legitimately may not exist yet. Ignored on nested specs:output_selector_speckeeps its authoritative-None→ 204 contract, andinstance_selector_specalways 404s (a mutation against a missing row is not a nullable read).output_serializer— a DRFSerializersubclass used byget_serializer_class()for this action.Nonefalls back to DRF's standardserializer_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 ofview/request(or**kwargs) and receives only what it asks for. See the extra-kwargs recipe.permission_classes— overrides the view's class-levelpermission_classesfor the action the spec backs.None(the default) inherits;[]means "no permissions" explicitly. Ignored when the spec is nested underServiceSpec.output_selector_spec(the surrounding mutation's permissions apply). See the permissions recipe.output_serializer_context— callable returning extra keys for the response serializer'scontext=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 insidedispatch_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-ownedmetadata.
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 intransaction.atomic()(defaultsTrue).success_status— override the HTTP status (defaults to201for create,200for update,204for 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 answer201when it created a row and200when 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.
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
ServiceSpecand itsoutput_selector_specare independent objects with independent metadata. Same forPolymorphicServiceSpec, which has nometadatafield of its own — declare it on the variants (a permission class on a polymorphic action should therefore hang off each variant'spermission_classes, since under the defaultpermission_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_strategydecides howget_permissionstreats the variants (DRF runs permissions before the body is parsed):"union"(the default) requires the union of every variant'spermission_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
requestsimply 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:
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_statusand 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 anoutput_selector_specre-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 reload — refresh_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-fetch — output_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. PATCHresolvesaction_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'spermission_classesguard PATCH too. A dedicated"partial_update"entry can carry its own serializer / service /partialoverride.- Absent entries on a write action make that action return
405 Method Not Allowed. - A wrong-type entry (e.g.
SelectorSpeconcreate) raisesImproperlyConfiguredat 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
Servicebase class. A service is a function. - It does not invent a queryset filtering DSL. Use DRF's
filter_backendsfor list endpoints; for a retrieve selector — which overridesget_object(), the method DRF runsfilter_queryset()from, so the backends stop applying exactly as they would for any hand-written override — or for a transport-neutral declaration, pointSelectorSpec.filter_setat adjango-filterFilterSet— see filter a selector withfilter_set. - It does not own the input format. Use any DRF
Serializer(includingModelSerializer) or a bare@dataclass. - It does not decide your project layout. The
startserviceappscaffold 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.