django-modern-rest skill

SkillDev tools

Write the best DMR code possible with all the recommended best practices, avoiding common mistakes.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the django-modern-rest skill skill

What this skill tells your AI

The instructions your AI receives, as published by wemake-services/django-modern-rest in .agents/skills/dmr/SKILL.md and read by ahel’s review.

Here's a list of best practices to use for different parts of the application.

Installing django-modern-rest

Always prefer to install msgspec extra, because it provides the fastest json parsing / loading.

Always add django-stubs[compatible-mypy] to the dev dependencies, because django-modern-rest requires types for Django during type checking.

Defining controller

Do not use @validate, when @modify is enough

This code:

from http import HTTPStatus

import msgspec
from django.http import HttpResponse

from dmr import Body, Controller, ResponseSpec, validate
from dmr.plugins.msgspec import MsgspecSerializer


class UserModel(msgspec.Struct):
    email: str


class UserController(Controller[MsgspecSerializer]):
    @validate(  # <- describes unique return types from this endpoint
        ResponseSpec(
            UserModel,
            status_code=HTTPStatus.OK,
        ),
    )
    def post(self, parsed_body: Body[UserModel]) -> HttpResponse:
        # This response would have an explicit status code `200`:
        return self.to_response(
            parsed_body,
            status_code=HTTPStatus.OK,
        )

should be rewritten and simplified as:

from http import HTTPStatus

import msgspec

from dmr import Body, Controller, modify
from dmr.plugins.msgspec import MsgspecSerializer


class UserModel(msgspec.Struct):
    email: str


class UserController(Controller[MsgspecSerializer]):
    @modify(status_code=HTTPStatus.OK)
    def post(self, parsed_body: Body[UserModel]) -> UserModel:
        # This response would have an explicit status code `200`:
        return parsed_body

Because it does not use any of the validate features, like settings extra headers or cookies.

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/using-controller/index.html

Prefer implicit @modify over the explicit one

A code like:

from http import HTTPStatus

from dmr import Controller, modify
from dmr.plugins.msgspec import MsgspecSerializer


class UserController(Controller[MsgspecSerializer]):
    @modify(status_code=HTTPStatus.OK)
    def put(self) -> UserModel: ...

Should be rewritten as:

from http import HTTPStatus

from dmr import Controller
from dmr.plugins.msgspec import MsgspecSerializer


class UserController(Controller[MsgspecSerializer]):
    def put(self) -> UserModel: ...

Because no @modify features were actually used, since status_code was matching the default inferred one.

Prefer MsgspecSerializer

When defining simple models that do not require any complex logic that pydantic provides, prefer msgspec plugin over pydantic one. Because it can be 10x times faster.

This code:

from http import HTTPStatus

import pydantic

from dmr import Body, Controller
from dmr.plugins.pydantic import PydanticSerializer


class UserModel(pydantic.BaseModel):
    email: str


class UserController(Controller[PydanticSerializer]):
    def post(self, parsed_body: Body[UserModel]) -> UserModel:
        # This response would have an explicit status code `200`:
        return parsed_body

Should be rewritten as:

from http import HTTPStatus

import msgspec

from dmr import Body, Controller
from dmr.plugins.msgspec import MsgspecSerializer


class UserModel(msgspec.Struct):
    email: str


class UserController(Controller[MsgspecSerializer]):
    def post(self, parsed_body: Body[UserModel]) -> UserModel:
        # This response would have an explicit status code `200`:
        return parsed_body

Prefer PydanticFastSerializer

When no content negotiation is used, when working with json only, and when working with pydantic, it is better to rewrite code like:

from http import HTTPStatus

import pydantic

from dmr import Body, Controller, modify
from dmr.plugins.pydantic import PydanticSerializer


class UserModel(pydantic.BaseModel):
    email: str


class UserController(Controller[PydanticSerializer]):
    @modify(status_code=HTTPStatus.OK)
    def post(self, parsed_body: Body[UserModel]) -> UserModel:
        # This response would have an explicit status code `200`:
        return parsed_body

To be:

from http import HTTPStatus

import pydantic

from dmr import Body, Controller, modify
from dmr.plugins.pydantic import PydanticFastSerializer


class UserModel(pydantic.BaseModel):
    email: str


class UserController(Controller[PydanticFastSerializer]):
    @modify(status_code=HTTPStatus.OK)
    def post(self, parsed_body: Body[UserModel]) -> UserModel:
        # This response would have an explicit status code `200`:
        return parsed_body

Because PydanticFastSerializer is at least 3 times faster in this case.

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/deep-dive/public-api.html#dmr.plugins.pydantic.PydanticFastSerializer

Never return Django HttpResponse directly — use to_response, to_error, or APIError

Returning Django responses directly bypasses content negotiation, cookie and header management.

Wrong:

import json

from django.http import HttpResponse

from dmr import Controller
from dmr.plugins.msgspec import MsgspecSerializer


class UserController(Controller[MsgspecSerializer]):
    def get(self) -> HttpResponse:
        return HttpResponse(
            json.dumps({'email': 'user@example.com'}),
            content_type='application/json',
            headers={'X-API-Token': 'some-token'},
            status=200,
        )

Correct:

import msgspec

from django.http import HttpResponse

from dmr import Body, Controller, validate
from dmr.plugins.msgspec import MsgspecSerializer


class UserModel(msgspec.Struct):
    email: str


class UserController(Controller[MsgspecSerializer]):
    def get(self) -> HttpResponse:
        return self.to_response(
            {'email': 'user@example.com'},
            headers={'X-API-Token': 'some-token'},
        )

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/using-controller/index.html

Use APIError for error responses instead of manually building HttpResponse

APIError is automatically handled by the framework, formatted through format_error, and documented in OpenAPI schema.

Wrong:

import json
from http import HTTPStatus

from django.http import HttpResponse

from dmr import Controller
from dmr.plugins.msgspec import MsgspecSerializer


class UserController(Controller[MsgspecSerializer]):
    def get(self) -> HttpResponse:
        return HttpResponse(
            json.dumps({'detail': [{'msg': 'Not found'}]}),
            content_type='application/json',
            status=404,
        )

Correct:

from http import HTTPStatus

from dmr import APIError, Controller
from dmr.errors import ErrorType
from dmr.plugins.msgspec import MsgspecSerializer


class UserController(Controller[MsgspecSerializer]):
    def get(self) -> str:
        raise APIError(
            self.format_error(
                'Not found',
                error_type=ErrorType.user_msg,
            ),
            status_code=HTTPStatus.NOT_FOUND,
        )

You can also use self.to_error when using @validate endpoints.

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/error-handling.html

Redirects

Choose a redirect compatible with the endpoint style

Raise RedirectTo from @modify endpoints, because they cannot return HttpResponse subclasses. @validate endpoints support both raising RedirectTo and returning Django's HttpResponseRedirect equally. Returning HttpResponseRedirect from @validate is an exception to the general rule against returning Django responses directly.

Whichever redirect style you use, document the Location header in its ResponseSpec. For @modify, add the spec to extra_responses; for @validate, pass the spec directly:

from http import HTTPStatus
from typing import Final

from django.http import HttpResponse, HttpResponseRedirect

from dmr import (
    Controller,
    HeaderSpec,
    RedirectTo,
    ResponseSpec,
    modify,
    validate,
)
from dmr.plugins.msgspec import MsgspecSerializer

_REDIRECT_SPEC: Final = ResponseSpec(
    None,
    status_code=HTTPStatus.FOUND,
    headers={'Location': HeaderSpec()},
)


class UserController(Controller[MsgspecSerializer]):
    @modify(extra_responses=[_REDIRECT_SPEC])
    def get(self) -> str:
        raise RedirectTo(
            '/api/new/users/',
            status_code=HTTPStatus.FOUND,
        )

    @validate(_REDIRECT_SPEC)
    def post(self) -> HttpResponse:
        raise RedirectTo(
            '/api/new/users/',
            status_code=HTTPStatus.FOUND,
        )

    @validate(_REDIRECT_SPEC)
    def put(self) -> HttpResponseRedirect:
        return HttpResponseRedirect(
            '/api/new/users/',
            content_type='application/json',
        )

Select the redirect status code according to the Redirection 3xx section of the HTTP Semantics specification.

Validate user-provided URLs before passing them to RedirectTo

RedirectTo validates a URL's length and scheme, but does not check whether the destination host is trusted. Passing an untrusted URL directly can create an open redirect vulnerability.

Wrong:

from typing import Never

from dmr import RedirectTo


def redirect_to_next(next_url: str) -> Never:
    raise RedirectTo(next_url)

Correct:

from typing import Never

from django.http import HttpRequest
from django.utils.http import url_has_allowed_host_and_scheme

from dmr import RedirectTo


def redirect_to_next(request: HttpRequest, next_url: str) -> Never:
    url_is_safe = url_has_allowed_host_and_scheme(
        url=next_url,
        allowed_hosts={request.get_host()},
        require_https=request.is_secure(),
    )
    raise RedirectTo(next_url if url_is_safe else '/')

Limitations: validation is required for user-provided or otherwise untrusted redirect targets; hard-coded local URLs do not need this check.

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/using-controller/redirects.html

Routing

Use dmr.routing.path instead of django.urls.path

dmr.routing.path is a drop-in replacement that uses prefix-based pattern matching for 9-31% faster URL routing.

Wrong:

from django.urls import include, path

urlpatterns = [
    path('api/', include('myapp.urls')),
]

Correct:

from django.urls import include

from dmr.routing import path

urlpatterns = [
    path('api/', include('myapp.urls')),
]

Limitations: no API changes required — it is a full drop-in replacement for django.urls.path.

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/routing.html

Use build_404_handler for API-style 404 responses

build_404_handler returns JSON 404 responses for API prefixes while keeping Django HTML 404s for non-API paths.

Wrong:

from django.urls import include

from dmr.routing import Router, path
from myapp.views import UserController

router = Router(
    'api/',
    [
        path('user/', UserController.as_view(), name='users'),
    ],
)

urlpatterns = [
    router.to_urlpatterns(namespace='api'),
]
# No custom 404 handler — API gets HTML error pages

Correct:

from django.urls import include

from dmr.plugins.msgspec import MsgspecSerializer
from dmr.routing import Router, build_404_handler, path
from myapp.views import UserController

router = Router(
    'api/',
    [
        path('user/', UserController.as_view(), name='users'),
    ],
)

urlpatterns = [
    router.to_urlpatterns(namespace='api'),
]

handler404 = build_404_handler(router.prefix, serializer=MsgspecSerializer)

Limitations: overriding handler404 has no effect while DEBUG = True — this is Django's default behavior.

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/routing.html

Use build_500_handler for API-style 500 responses

build_500_handler returns JSON 500 responses for API prefixes while keeping Django HTML 500s for non-API paths.

Wrong:

from django.urls import include

from dmr.routing import Router, path
from myapp.views import UserController

router = Router(
    'api/',
    [
        path('user/', UserController.as_view(), name='users'),
    ],
)

urlpatterns = [
    router.to_urlpatterns(namespace='api'),
]
# No custom 500 handler — API gets HTML error pages

Correct:

from django.urls import include

from dmr.plugins.msgspec import MsgspecSerializer
from dmr.routing import Router, build_500_handler, path
from myapp.views import UserController

router = Router(
    'api/',
    [
        path('user/', UserController.as_view(), name='users'),
    ],
)

urlpatterns = [
    router.to_urlpatterns(namespace='api'),
]

handler500 = build_500_handler(router.prefix, serializer=MsgspecSerializer)

Limitations: overriding handler500 has no effect while DEBUG = True — this is Django's default behavior.

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/routing.html

Error handling

Match error handler sync/async type to the endpoint type

Async endpoints require async error handlers, and sync endpoints require sync error handlers — this is validated on endpoint creation.

Wrong:

from http import HTTPStatus

from django.http import HttpResponse

from dmr import Controller, modify
from dmr.endpoint import Endpoint
from dmr.plugins.msgspec import MsgspecSerializer
from dmr.serializer import BaseSerializer


def sync_error_handler(
    endpoint: Endpoint,
    controller: Controller[BaseSerializer],
    exc: Exception,
) -> HttpResponse:
    return controller.to_error(
        controller.format_error(str(exc)),
        status_code=HTTPStatus.BAD_REQUEST,
    )


class MyController(Controller[MsgspecSerializer]):
    @modify(error_handler=sync_error_handler)
    async def get(self) -> str:  # async endpoint with sync handler!
        return 'hello'

Correct:

from http import HTTPStatus

from django.http import HttpResponse

from dmr import Controller, modify
from dmr.endpoint import Endpoint
from dmr.plugins.msgspec import MsgspecSerializer
from dmr.serializer import BaseSerializer


async def async_error_handler(
    endpoint: Endpoint,
    controller: Controller[BaseSerializer],
    exc: Exception,
) -> HttpResponse:
    return controller.to_error(
        controller.format_error(str(exc)),
        status_code=HTTPStatus.BAD_REQUEST,
    )


class MyController(Controller[MsgspecSerializer]):
    @modify(error_handler=async_error_handler)
    async def get(self) -> str:
        return 'hello'

Limitations: the same rule applies to controller-level handle_error (sync) and handle_async_error (async) — don't define sync handlers for async controllers and vice versa.

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/error-handling.html

Don't catch APIError explicitly in error handlers

APIError has a built-in default handler that automatically converts it to an HttpResponse — you don't need to handle it manually.

Wrong:

from http import HTTPStatus

from django.http import HttpResponse
from typing_extensions import override

from dmr import APIError, Controller
from dmr.endpoint import Endpoint
from dmr.plugins.msgspec import MsgspecSerializer


class MyController(Controller[MsgspecSerializer]):
    @override
    def handle_error(
        self,
        endpoint: Endpoint,
        controller: Controller[MsgspecSerializer],
        exc: Exception,
    ) -> HttpResponse:
        if isinstance(exc, APIError):  # unnecessary!
            return self.to_error(
                exc.args[0],
                status_code=exc.status_code,
            )
        raise exc from None

Correct:

from http import HTTPStatus

from django.http import HttpResponse
from typing_extensions import override

from dmr import Controller
from dmr.endpoint import Endpoint
from dmr.plugins.msgspec import MsgspecSerializer


class MyController(Controller[MsgspecSerializer]):
    @override
    def handle_error(
        self,
        endpoint: Endpoint,
        controller: Controller[MsgspecSerializer],
        exc: Exception,
    ) -> HttpResponse:
        if isinstance(exc, SomeSpecificError):
            return self.to_error(
                self.format_error(str(exc)),
                status_code=HTTPStatus.BAD_REQUEST,
            )
        raise exc from None

Limitations: only catch specific errors you know how to handle — always re-raise unfamiliar errors to let the next handler level deal with them.

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/error-handling.html

Customize error messages with error_model and format_error on the controller

Defining a custom error_model and overriding format_error on the controller gives consistent error formatting across all endpoints and updates the OpenAPI schema automatically.

Wrong:

from http import HTTPStatus

from dmr import APIError, Controller
from dmr.plugins.msgspec import MsgspecSerializer


class MyController(Controller[MsgspecSerializer]):
    def post(self) -> str:
        raise APIError(
            {'errors': [{'message': 'test'}]},  # ad-hoc format
            status_code=HTTPStatus.BAD_REQUEST,
        )

Correct:

from http import HTTPStatus
from typing import Any

from typing_extensions import TypedDict, override

from dmr import APIError, Body, Controller, ResponseSpec, modify
from dmr.errors import ErrorType, format_error
from dmr.plugins.msgspec import MsgspecSerializer


class CustomErrorDetail(TypedDict):
    message: str


class CustomErrorModel(TypedDict):
    errors: list[CustomErrorDetail]


class MyController(Controller[MsgspecSerializer]):
    error_model = CustomErrorModel

    @override
    def format_error(
        self,
        error: str | Exception,
        *,
        loc: str | list[str | int] | None = None,
        error_type: str | ErrorType | None = None,
    ) -> Any:
        default = format_error(error, loc=loc, error_type=error_type)
        return {
            'errors': [
                {'message': detail['msg']} for detail in default['detail']
            ],
        }

    @modify(
        extra_responses=[
            ResponseSpec(
                return_type=CustomErrorModel,
                status_code=HTTPStatus.BAD_REQUEST,
            ),
        ],
    )
    def post(self, parsed_body: Body[dict[str, str]]) -> str:
        raise APIError(
            self.format_error('test msg'),
            status_code=HTTPStatus.BAD_REQUEST,
        )

Limitations: error_model and format_error are per-controller — you can't customize error format per-endpoint, only per-controller or globally.

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/error-handling.html

Do not handle errors in the endpoints body

We have a separate layer in the app specifically for error handling. Instead of handling errors in place, prefer to use the error handling methods, like handle_error and handle_async_error.

Wrong:

from http import HTTPStatus

from django.http import HttpResponse
from typing_extensions import override

from dmr import Controller
from dmr.endpoint import Endpoint
from dmr.plugins.msgspec import MsgspecSerializer

from myapp import SomeSpecificError, some_logic


class MyController(Controller[MsgspecSerializer]):
    def get(self) -> str:
        try:
            return some_logic()
        except SomeSpecificError:
            return self.to_error(
                self.format_error(str(exc)),
                status_code=HTTPStatus.BAD_REQUEST,
            )

Correct:

from http import HTTPStatus

from django.http import HttpResponse
from typing_extensions import override

from dmr import Controller
from dmr.endpoint import Endpoint
from dmr.plugins.msgspec import MsgspecSerializer

from myapp import SomeSpecificError, some_logic


class MyController(Controller[MsgspecSerializer]):
    def get(self) -> str:
        return some_logic()

    @override
    def handle_error(
        self,
        endpoint: Endpoint,
        controller: Controller[MsgspecSerializer],
        exc: Exception,
    ) -> HttpResponse:
        if isinstance(exc, SomeSpecificError):
            return self.to_error(
                self.format_error(str(exc)),
                status_code=HTTPStatus.BAD_REQUEST,
            )
        raise exc from None

If error is handled in most controllers, you can move it to a global handler.

Validation

Disable response validation in production, keep it on in development

Response validation catches schema mismatches during development, but adds overhead in production — disable it globally for deployed apps.

Wrong:

# settings.py — production with validation still on (slow):
DMR_SETTINGS = {}  # validate_responses defaults to True

Correct:

# settings.py — production:
from dmr.settings import Settings

DMR_SETTINGS = {
    Settings.validate_responses: False,
}

Limitations: only disable for production — fix schema errors during development instead of turning off validation.

Docs: https://django-modern-rest.readthedocs.io/en/latest/pages/validation.html

Do not disable response validation when retuning extra data

Instead of disabling response validation when retuning some extra data, add new ResponseSpec objects.

Wrong:

from http import HTTPStatus

from dmr import APIError, Body, Controller
from dmr.plugins.msgspec import MsgspecSerializer


class MyController(Controller[MsgspecSerializer]):
    validate_responses = False

    def post(self, parsed_body: Body[dict[str, str]]) -> str:
        if not parsed_body:
            raise APIError(
                self.format_error('empty body'),
                status_code=HTTPStatus.GONE,
            )
        return 'saved'

Correct:

from http import HTTPStatus

from dmr import APIError, Body, Controller, ResponseSpec, modify
from dmr.plugins.msgspec import MsgspecSerializer


class MyController(Controller[MsgspecSerializer]):
    @modify(
        extra_responses=[
            ResponseSpec(
                return_type=Controller.error_model,
                status_code=HTTPStatus.GONE,
            ),
        ],
    )
    def post(self, parsed_body: Body[dict[str, str]]) -> str:
        if not parsed_body:
            raise APIError(
                self.format_error('empty body'),
                status_code=HTTPStatus.GONE,
            )
        return 'saved'

Don't override HttpSpec validation unless implementing legacy APIs

HttpSpec validation is already disabled by default for problematic cases — overriding it should only be done for very specific legacy API compatibility reasons.

Wrong:

from http import HTTPStatus

from dmr import Controller, modify
from dmr.plugins.pydantic import PydanticSerializer
from dmr.settings import HttpSpec


class JobController(Controller[PydanticSerializer]):
    # Disabling just to avoid fixing the real issue:
    no_validate_http_spec = frozenset((HttpSpec.empty_response_body,))

    @modify(status_code=HTTPStatus.NO_CONTENT)
    def post(self) -> int:
        return 4

Correct:

from http import HTTPStatus

from dmr import Controller, modify
from dmr.plugins.pydantic import PydanticSerializer


class JobController(Controller[PydanticSerializer]):
    @modify(status_code=HTTPStatus.NO_CONTENT)
    def post(self) -> None:
        print('Job created')  # noqa: WPS421

Limitations: override no_validate_http_spec only when implementing old legacy APIs that cannot follow HTTP spec properly.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
1k
Forks
190
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
dmr
Source
github.com/wemake-services/django-modern-rest