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 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 Ellipsis) so services that don't read a body can still match the shape by binding InputT to NoInput.

See 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,
    relations: Mapping[str, RelationSpec] | 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 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},
)

relations (and its reverse-FK alias children) is forwarded to create_from_input to write nested relations declaratively (no hand-written service):

create_model(
    Author,
    relations={"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 CreateService Protocol's default ExtraT (open extras). That same pool is handed on as the helper's context=, so a per-child service declared on a ChildSpec can see who is calling.

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,
    relations: Mapping[str, RelationSpec] | 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 create_model / 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 update_from_input. m2m accepts either a static mapping or a callable receiving the validated data (see create_model for the common shape); children reconciles reverse-FK collections from data[relation] per its ChildSpec.

The rest of the framework's kwargs pool is handed on as the helper's context=, so a per-child service declared on a ChildSpec can see who is calling (see create_model).

delete_model

delete_model

delete_model(
    model: type[ModelT],
    *,
    soft_delete: Callable[[ModelT], None] | None = None,
    children: Mapping[str, ChildSpec] | None = None,
    relations: Mapping[str, RelationSpec] | 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)

relations (and its reverse-FK alias children) declares what to remove before the parent goes, deepest first. Use it to cascade explicitly when the database can't: a PROTECT relation, or a soft_delete Django never cascades through because no row is deleted.

The same map the write path takes, and the same one rule applies to every kind: the cascade removes the rows the parent owns and leaves the rows it merely points at alone. A reverse-FK collection, a reverse one-to-one and a generic relation are the parent's rows and go, nullable links unlinked and the rest deleted; a many-to-many loses only its membership, since the targets are shared; a forward relation is left untouched, because the column holding it goes with the parent. The specs' write-only fields (match_key / mode / field_map / m2m) are ignored here.

The rest of the framework's kwargs pool is handed on as delete_relations's context=, so a per-row service declared on a spec can see who is calling (see create_model).

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,
    relations: Mapping[str, RelationSpec] | None = None,
) -> Callable[..., Awaitable[ModelT]]

Async sibling of create_model.

Returns an async def closure that wraps acreate_from_input. The framework's is_async detection routes it through the async dispatch path automatically. children is forwarded for declarative reverse-FK writes, and the rest of the kwargs pool as context= (see 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,
    relations: Mapping[str, RelationSpec] | None = None,
) -> Callable[..., Awaitable[ModelT]]

Async sibling of 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,
    relations: Mapping[str, RelationSpec] | None = None,
) -> Callable[..., Awaitable[None]]

Async sibling of 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. relations (and its reverse-FK alias children) removes what the parent owns first, by the one rule delete_model states, with the rest of the kwargs pool handed on as context=.

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 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 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 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 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 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.

For a view, middleware, or custom action delegating to a service wired to a different action: the helper builds the pool the framework would build, filters it against the service's signature, and dispatches sync-or-async so the caller need not know which — async services are bridged through async_to_sync, sync services called inline. Outside HTTP scope (Celery tasks, management commands) call the service directly with whatever kwargs you have; this helper is not the right tool there.

Parameters:

Name Type Description Default
service Callable[..., ResultT]

The service callable, sync or async.

required
request Request

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.

required
data Any

Passed into the pool when not UNSET; omitting it along with instance mirrors the create / list call shape.

UNSET
instance Any

Passed into the pool when not UNSET.

UNSET
map_errors bool

Translate a raised ServiceError into the DRF exception the normal view path raises for it (ServiceValidationError → 400, any other → 422) so DRF's handler renders it as a proper response. Left False it propagates unchanged and an unhandled one surfaces as a 500.

False
**extras Any

Merged into the pool; the signature filter (resolve_callable_kwargs) decides which keys reach the service. A seed this helper derives is never overridden here: an extras key named user (or data / instance when the matching argument was passed) is dropped, so spreading a serializer's validated_data cannot hand the service a client-supplied principal in place of request.user. Keys the helper does not seed reach the service unchanged.

{}

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 call_service, map_errors included. Async services are awaited directly; a sync service is called inline with no thread hop, so the caller owns any sync-side I/O safety.

**extras never overrides a seed this helper derives, exactly as in the sync twin: a user key spread in from client-supplied data is dropped rather than replacing request.user.