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]

The structural shape of a service that takes validated input and no target.

data is what the spec's Validator returned, under the pool's reserved data seed. **extras absorbs whatever else the keyword pool delivers - user, a registered seed, the spec's kwargs - without the service declaring each key. It is typed Any so a service conforms under every type checker.

Nothing reads the Protocol at run time: dispatch binds a service from the keywords it declares whatever it is annotated with, so conforming is a claim checked by a type checker, through implements.

Strictly typed extras stay possible on the service's own signature: declare them as a TypedDict whose keys are all NotRequired and annotate **extras: Unpack[YourExtras], so extras["tenant"] is typed in the body. NotRequired because the Protocol's caller may not supply them. The Protocol carries no type argument for the extras, since that cross-check is not portable across type checkers.

UpdateService

Bases: Protocol[InputT, InstanceT, ResultT]

The structural shape of a service that acts on a resolved row with validated input.

instance is the row the spec's instance_selector_spec resolved, under the pool's reserved instance seed, and data what the spec's Validator returned.

See CreateService for **extras and strictly typed extras.

DeleteService

Bases: Protocol[InputT, InstanceT, ResultT]

The structural shape of a service that acts on a resolved row, input optional.

instance is the row the spec's instance_selector_spec resolved. Most deletes return None.

data has a default in the Protocol, so a service that reads no input conforms without declaring it: bind InputT to NoInput and leave data out. A delete that does take input - a reason, a confirmation - binds InputT to its input type and declares data with a default of its own, data: ReasonInput | None = None: declared without one it does not conform, since the Protocol's caller may leave data out.

See CreateService for **extras and strictly typed extras.

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

A service that builds a row of model from its validated data, and returns the row.

The service a create operation would otherwise write by hand:

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

field_map, exclude_fields and relations are handed to create_from_input as they are, so one relations map writes every nested kind, a reverse foreign key's ChildSpec among them. m2m is a static mapping, or a callable handed the validated data that returns one, which is the usual case when the rows to link arrive in the arguments:

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

The service returns the row, not the ChangeResult, since the row is what a create's output presents. It opens no transaction: dispatch runs a service inside one, which covers the row and every nested write. It takes **kwargs, so the rest of the pool - the user and any registered seed - is absorbed rather than refused. It names call_context, which a catch-all is never handed, and the same pool with the call's context beside it is handed on as the helper's context, so a row service a relation spec declares can see who is calling and, by naming call_context, the very dict the call's other stages share.

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

A service that writes the validated data onto the resolved row, and returns the row.

The service an update operation would otherwise write by hand:

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

model is not read: it is taken for symmetry with create_model and to bind the row's type for a type checker, since the row is the instance the spec's instance selector resolved. field_map, exclude_fields, update_fields and relations are handed to update_from_input as they are, and m2m is read as create_model reads it.

The service returns the row rather than the ChangeResult, opens no transaction of its own, absorbs the rest of the pool and hands it on as context with the call's context beside it, as create_model's does.

delete_model

delete_model

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

A service that deletes the resolved row, after the rows it owns, and returns None.

The service a delete operation would otherwise write by hand:

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

model is not read, as in update_model: the row is the instance the spec's instance selector resolved.

soft_delete is called with the row instead of instance.delete(), for a row that is retired rather than removed:

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

delete_model(Author, soft_delete=archive)

So no row is deleted and Django sends no pre_delete or post_delete for it.

relations is the map the writes take, and what it declares goes before the row, deepest first, through delete_relations: the rows the instance owns are disposed of, a many-to-many loses its membership, and a forward relation is left alone. It is the cascade a database will not run - a PROTECT foreign key, or a soft delete, which removes no row for Django to cascade from - and the one a declared delete_service sees row by row. A value that is not a relation spec is refused with ImproperlyConfigured here, when the service is built, rather than at its first call. With no map, or an empty one, nothing but the row is touched.

The service opens no transaction: dispatch runs it inside one, so a refused cascade leaves every row in place. It absorbs the rest of the pool and hands it on, with the call's context it names, as the cascade's context, so a row's delete_service can see who is calling and, by naming call_context, the very dict the call's other stages share.

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

The async twin of create_model.

An async def service over acreate_from_input, so adispatch awaits it rather than bridging it. Every row service its relations declare is an async def too, which the async helpers check.

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

The async twin of update_model.

An async def service over aupdate_from_input.

adelete_model

adelete_model

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

The async twin of delete_model.

Deletes with await instance.adelete(), after the cascade adelete_relations runs by the rule delete_model states. soft_delete is an async def called with the row in place of adelete().

delete_collection

delete_collection

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

A service that deletes the resolved collection in one bulk delete, and returns None.

Pairs with a spec's collection_selector_spec, whose rows dispatch seeds into the pool as collection. The service a bulk delete would otherwise write by hand:

def delete_books(*, collection: QuerySet[Book], **_: Any) -> None:
    collection.delete()

collection.delete() is Django's bulk delete, which runs each foreign key's on_delete and sends pre_delete and post_delete for every row, as a single row's delete does. An empty collection deletes nothing, so running the operation twice is harmless. model is not read, as in delete_model.

soft_delete is called with the collection instead of delete(), as in lambda rows: rows.update(is_archived=True). The service opens no transaction, since dispatch runs it inside one, and absorbs the rest of the pool.

adelete_collection

adelete_collection

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

The async twin of delete_collection.

Deletes with await collection.adelete(). soft_delete is an async def called with the collection in its place.

Decorators

implements

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

Return the decorated callable unchanged, typed as conforming to proto.

proto is a parameterised service or selector Protocol:

@implements(CreateService[NoteInput, Note])
def create_note(*, data: NoteInput, **extras: Any) -> Note: ...

A type checker reports a callable that does not conform - a keyword the Protocol passes and the callable does not take, a return of the wrong type - at the decorator line, as ty does.

It lives in services/ beside the service Protocols, and decorates a selector the same way, with ListSelector or RetrieveSelector. Nothing changes at run time: dispatch binds a callable from the keywords it declares, annotated or not.

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), and view is the view DRF recorded on the request it built, matching the seeds the framework's own dispatch hands a service. A plain HttpRequest records no view, and then view is a name like any other extras key.

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 view / data / instance when the helper seeded it) 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.