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[[ServiceView, Request], ExtraT] | None = None
permission_classes: Sequence[type[BasePermission]] | None = None
output_serializer_context: Callable[[ServiceView, Request], 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
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. Receives the view (typed as the narrowServiceViewProtocol) and the currentRequest. 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.
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 | None = None
partial: bool | None = None
input_serializer: type | None = None
input_data: Callable[..., Mapping[str, Any]] | None = None
input_serializer_context: Callable[[ServiceView, Request], Mapping[str, Any]] | None = None
instance_selector_spec: SelectorSpec[Any, Any] | None = None
output_selector_spec: SelectorSpec[Any, Any] | None = None
kwargs: Callable[[ServiceView, Request], ExtraT] | None = None
permission_classes: Sequence[type[BasePermission]] | 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).partial— override the transport-derived partial-validation flag.None(the default) inherits what the verb implies (Falsefor PUT/POST,Truefor PATCH);True/Falseforces 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.input_serializer— a DRFSerializersubclass, a bare@dataclass(auto-wrapped inDataclassSerializer), orNonefor side-effect-only services.input_data— callable returning a mapping merged on top ofrequest.databefore theinput_serializervalidates 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 declareinstanceas a keyword parameter to receive the resolved mutation target (Noneon create) — passed only when declared — so pre-validation input mutation that depends on the current row has a home.instance_selector_spec— nestedSelectorSpec(kind=SelectorKind.RETRIEVE) resolving the instance an update / destroy / detail action targets, embedding the lookup in the spec instead of the view'squeryset/get_object()chain. The selector pool is{request, user}+ the URL kwargs + the selector extras chain, soselector=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-styleserializer(instance, data=..., partial=...)), the service pool (instance), and object-level permission checks (check_object_permissions).None/ missing →404. Queryset shaping applies; the nested spec'spermission_classes,output_serializer, andoutput_serializer_contextare ignored.None(the default) keeps theget_object()chain. See Standalone update without a queryset.input_serializer_context— callable returning extra keys for the input serializer'scontext=dict. Sits at the most-specific layer of the serializer-context resolution chain.output_selector_spec— nestedSelectorSpec(kind=SelectorKind.RETRIEVE) carrying the response serializer, the optional re-fetchselector, the outputoutput_serializer_contexthook, and the queryset-shaping fields.None(the default) renders the service's return value directly. The nested spec'spermission_classesandkwargsare ignored — the surrounding mutation's permissions and kwargs chain apply.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-levelpermission_classesfor the action the spec backs.None(the default) inherits;[]means "no permissions" explicitly. See the permissions recipe.
Generic parameters InputT / ResultT / ExtraT default to Any, so
ServiceSpec(service=fn) keeps working unparameterized.
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.
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 |
| 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.
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,
),
)
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_backends. - 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.