Skip to content

Services

Protocols

Each Protocol is parameterised by input, instance (where applicable), and result. **extras is typed Any, so the framework's kwargs pool flows through without the service having to declare each key. Strict-typed extras live on the user's function signature via **extras: Unpack[YourKw] — see Typing services and selectors for the full pattern.

CreateService

Bases: Protocol[InputT, ResultT]

Structural shape for a create-action service callable.

data carries the validated input from the framework. **extras absorbs whatever else the framework's kwargs pool delivers — request, user, and the ServiceSpec.kwargs / get_service_kwargs returns — without the service having to declare each key. The Protocol types **extras as Any so services on every major type checker (ty, mypy, pyright) conform.

Strict-typed extras stay possible on your own function signature: declare your extras as a TypedDict with NotRequired keys and annotate **extras: Unpack[YourKw]. Inside the function body, extras["foo"] is then typed by YourKw. The Protocol no longer carries a third type argument for the kwargs shape — that cross-check only ever worked under one minor version of one type checker (ty 0.0.32) and is not portable.

UpdateService

Bases: Protocol[InputT, InstanceT, ResultT]

Structural shape for an update-action service callable.

Receives the resolved instance plus the validated data. Returning None instructs the framework to render the in-memory instance (mirroring DRF's UpdateAPIView shape).

See :class:CreateService for the extras-typing notes.

DeleteService

Bases: Protocol[InputT, InstanceT, ResultT]

Structural shape for a delete-action service callable.

Receives the resolved instance. Most delete services return None; if you need a response body, return a value and configure ServiceSpec.output_selector_spec with an output_serializer (and optionally a re-fetch selector).

For delete with payload — when the spec carries an input_serializer — bind InputT to your input dataclass and declare data on the service. data is optional in the Protocol (default :data:Ellipsis) so services that don't read a body can still match the shape by binding InputT to :class:~rest_framework_services.types.no_input.NoInput.

See :class:CreateService for the extras-typing notes.

Default model service factories

create_model

create_model

create_model(
    model: type[ModelT],
    *,
    field_map: dict[str, str] | None = None,
    exclude_fields: list[str] | None = None,
    m2m: Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None = None,
    children: Mapping[str, ChildSpec] | None = None,
) -> Callable[..., ModelT]

Return a service callable that builds model from validated input.

Equivalent to writing the canonical glue stub by hand::

def create_author(*, data: AuthorIn, **_: Any) -> Author:
    return create_from_input(Author, data).instance

field_map and exclude_fields are forwarded to :func:~rest_framework_services.mutations.create_from_input.

m2m accepts either a static mapping (passed straight through) or a callable that receives the validated data and returns the mapping — the common case where M2M values live on the input dataclass / dict itself::

create_model(
    Post,
    m2m=lambda data: {"tags": data.tags},
)

children is forwarded to :func:~rest_framework_services.mutations.create_from_input to write reverse-FK child collections declaratively (no hand-written service)::

create_model(
    Author,
    children={"books": ChildSpec(model=Book, fk="author")},
)

The returned closure accepts **kwargs so the framework's kwargs pool (request, user, URL kwargs, ServiceSpec.kwargs returns) is absorbed without the service caring — matching the unified :class:~rest_framework_services.services.CreateService Protocol's default ExtraT (open extras).

update_model

update_model

update_model(
    model: type[ModelT],
    *,
    field_map: dict[str, str] | None = None,
    exclude_fields: list[str] | None = None,
    m2m: Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None = None,
    update_fields: bool | list[str] = True,
    children: Mapping[str, ChildSpec] | None = None,
) -> Callable[..., ModelT]

Return a service callable that updates the resolved instance in place.

Equivalent to::

def update_author(*, instance: Author, data: AuthorIn, **_: Any) -> Author:
    return update_from_input(instance, data).instance

model is accepted for symmetry with :func:create_model / :func:delete_model and to bind ModelT for the type checker; the instance itself comes from the view's get_object(). field_map, exclude_fields, m2m, update_fields, and children are forwarded to :func:~rest_framework_services.mutations.update_from_input. m2m accepts either a static mapping or a callable receiving the validated data (see :func:create_model for the common shape); children reconciles reverse-FK collections from data[relation] per its :class:~rest_framework_services.ChildSpec.

delete_model

delete_model

delete_model(
    model: type[ModelT],
    *,
    soft_delete: Callable[[ModelT], None] | None = None,
    children: Mapping[str, ChildSpec] | None = None,
) -> Callable[..., None]

Return a service callable that deletes the resolved instance.

Equivalent to::

def delete_author(*, instance: Author, **_: Any) -> None:
    instance.delete()

model is accepted for symmetry / type binding; the instance comes from the view's get_object().

soft_delete is an optional hook called instead of instance.delete() — covers the common archive case::

def _archive(instance: Author) -> None:
    instance.is_archived = True
    instance.save(update_fields=["is_archived"])

delete_model(Author, soft_delete=_archive)

children declares reverse-FK collections to remove before the parent goes (grandchildren first), each nullable-FK child unlinked and the rest deleted — mirroring on_delete=SET_NULL / CASCADE. Use it to cascade explicitly when the FK can't (a PROTECT relation, or a soft_delete that Django won't cascade through). The ChildSpec's write-only fields (match_key / mode / field_map / m2m) are ignored here; only model / fk / children apply.

acreate_model

acreate_model

acreate_model(
    model: type[ModelT],
    *,
    field_map: dict[str, str] | None = None,
    exclude_fields: list[str] | None = None,
    m2m: Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None = None,
    children: Mapping[str, ChildSpec] | None = None,
) -> Callable[..., Awaitable[ModelT]]

Async sibling of :func:~rest_framework_services.services.create_model.

Returns an async def closure that wraps :func:~rest_framework_services.mutations.acreate_from_input. The framework's :func:~rest_framework_services.is_async.is_async detection routes it through the async dispatch path automatically. children is forwarded for declarative reverse-FK writes (see :func:~rest_framework_services.services.create_model).

aupdate_model

aupdate_model

aupdate_model(
    model: type[ModelT],
    *,
    field_map: dict[str, str] | None = None,
    exclude_fields: list[str] | None = None,
    m2m: Mapping[str, Any] | Callable[[Any], Mapping[str, Any]] | None = None,
    update_fields: bool | list[str] = True,
    children: Mapping[str, ChildSpec] | None = None,
) -> Callable[..., Awaitable[ModelT]]

Async sibling of :func:~rest_framework_services.services.update_model.

adelete_model

adelete_model

adelete_model(
    model: type[ModelT],
    *,
    soft_delete: Callable[[ModelT], Awaitable[None]] | None = None,
    children: Mapping[str, ChildSpec] | None = None,
) -> Callable[..., Awaitable[None]]

Async sibling of :func:~rest_framework_services.services.delete_model.

Calls await instance.adelete() by default (Django 4.1+; the package floor is 4.2, so this is always available). soft_delete is an optional async hook called instead of adelete. children removes the declared reverse-FK collections first (see :func:~rest_framework_services.services.delete_model).

delete_collection

delete_collection

delete_collection(
    model: type[ModelT], *, soft_delete: Callable[[Any], None] | None = None
) -> Callable[..., None]

Return a service that deletes the resolved collection (bulk).

Pairs with :attr:ServiceSpec.collection_selector_spec, which seeds the target set into the pool as collection. Equivalent to::

def delete_books(*, collection, **_: Any) -> None:
    collection.delete()

The default calls collection.delete() — a single queryset bulk delete. An empty collection is a no-op (deletes nothing), so the action is idempotent. model is accepted for symmetry / type binding (like :func:delete_model); the set itself comes from the spec.

soft_delete is an optional hook called with the collection instead of delete() — e.g. lambda qs: qs.update(is_archived=True).

adelete_collection

adelete_collection

adelete_collection(
    model: type[ModelT], *, soft_delete: Callable[[Any], Awaitable[None]] | None = None
) -> Callable[..., Awaitable[None]]

Async sibling of :func:~rest_framework_services.services.delete_collection.

Calls await collection.adelete() by default (Django 4.1+; the package floor is 4.2). soft_delete is an optional async hook called with the collection instead.

Decorators

implements

implements(proto: type[F]) -> Callable[[F], F]

Identity decorator: assert fn structurally matches proto.

proto is a parameterised service or selector :class:~typing.Protocol::

@implements(CreateService[AuthorIn, Author])
def create_author(
    *,
    data: AuthorIn,
    **extras: Any,
) -> Author: ...

Strict-typed extras stay on your function: declare a TypedDict with NotRequired keys (so the function still conforms to a Protocol whose caller may not supply those keys) and annotate **extras: Unpack[YourKw]. The Protocol itself does not carry an extras-shape parameter — see :class:CreateService for the rationale.

Drift between the decorated function and proto is reported at the decorator line by ty. mypy refuses type[Protocol] arguments (the type-abstract rule); mypy users either silence that with # type: ignore[type-abstract] or keep using the legacy _: CreateService[...] = create_author shim alongside the def.

Returns the function unchanged at runtime.

Helpers

call_service

call_service

call_service(
    service: Callable[..., ResultT],
    *,
    request: Request,
    data: Any = UNSET,
    instance: Any = UNSET,
    map_errors: bool = False,
    **extras: Any,
) -> ResultT

Invoke service with the framework's kwargs pool.

request is required — the helper is HTTP-scoped by design. user is derived from request.user (None if the request bypassed authentication middleware), matching the framework's own pool construction.

data and instance are passed through when not UNSET; omitting them mirrors the create / list call shape. Anything else goes into **extras and merges into the pool — the framework's standard signature filter (:func:resolve_callable_kwargs) decides which keys actually reach the service.

map_errors mirrors what the mutation view layer does with a :class:~rest_framework_services.exceptions.service_error.ServiceError. By default (False) a raised ServiceError propagates unchanged, so a caller that forgets to handle it lets the error surface as a 500. Set True and a ServiceError is translated to the same DRF exception the framework raises on the normal view path — ServiceValidationErrorValidationError (400), any other ServiceError → 422 — so DRF's exception handler renders it as a proper response. Only meaningful because call_service is HTTP-scoped; a non-HTTP caller wants the raw ServiceError (hence the False default).

Async services are bridged transparently via async_to_sync; sync services are called inline.

acall_service

acall_service async

acall_service(
    service: Callable[..., ResultT] | Callable[..., Awaitable[ResultT]],
    *,
    request: Request,
    data: Any = UNSET,
    instance: Any = UNSET,
    map_errors: bool = False,
    **extras: Any,
) -> ResultT

Invoke service from async code with the framework's kwargs pool.

Same contract as :func:call_service, including map_errors: when True a raised ServiceError is translated to the matching DRF exception (validation → 400, else 422); False (the default) propagates it raw. Async services are awaited directly; sync services are called inline (no thread hop) — caller is responsible for any sync-side I/O safety.