Fastapi Rules
ASecurityTeam conventions for structuring, documenting, versioning and testing a FastAPI web API: domain packages, thin routes, services and domain rules, ports, OpenAPI generated from code, response models and error responses, Problem Details errors with 409 versus 422 and a type per error, the 405 Allow header, operation ids, docs exposure, Spectral and oasdiff in CI, Schemathesis, dependency overrides. Use when writing, fixing or reviewing a FastAPI route, service, model or API test, or when starti...
- 2 stars
- 0 votes
- 0 copies
- 0 views
- Added November 11, 2026
Works with
Security analysis
100/100Pro scans all 2 files and shows the line behind each finding
npx -y skills add JanSzewczyk/claude-plugins --skill fastapi-rules --agent claude-codeAre you the author of Fastapi Rules?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/janszewczyk-fastapi-rules)---
name: fastapi-rules
description: "Team conventions for structuring, documenting, versioning and testing a FastAPI web API: domain packages, thin routes, services and domain rules, ports, OpenAPI generated from code, response models and error responses, Problem Details errors with 409 versus 422 and a type per error, the 405 Allow header, operation ids, docs exposure, Spectral and oasdiff in CI, Schemathesis, dependency overrides. Use when writing, fixing or reviewing a FastAPI route, service, model or API test, or when starting a new FastAPI service (also load python-rules, python-project-rules and pytest-rules for it). Background knowledge that agents preload; not a command."
user-invocable: false
---
<!-- generated by knowledge.mjs from 35 note(s) (sha256 a0aab15de489); edit the notes in knowledge/, then run build -->
# FastAPI rules
Apply these rules to every FastAPI endpoint, service, Pydantic model and API test you write or review. The first section is always in force; open a rule from the table when the task touches it. A new service also needs `python-rules` (logging at the entry point, typing), `python-project-rules` (tooling, CI) and `pytest-rules` (tests): load all three before you write code.
## Always apply (critical, high)
### Give every endpoint a response model and list its real error statuses in responses
_high · python, fastapi_
**Rule.** Declare `response_model` (or a return annotation) on every endpoint and list every error status it can return in `responses`, with the error model. Include the statuses FastAPI raises itself: 422 for validation (a malformed JSON body is a 422 `json_invalid` too). List 400 only for a case you have seen the app answer with 400; a declared status the app never sends misleads clients.
**Why.** Without a response model nothing filters the output, so an ORM object leaks columns such as password hashes, and undeclared errors are missing from the spec, so generated clients cannot handle them.
**Correct:**
```python
@router.get(
"/{user_id}",
response_model=UserResponse,
responses={404: PROBLEM, 422: PROBLEM}, # PROBLEM: model plus the problem+json media type
)
async def get_user(user_id: int) -> UserResponse: ...
```
### Declare an ASGI server in the project's runtime dependencies
_high · python, fastapi, uv_
**Rule.** List the server that runs the app in `[project].dependencies`: `uvicorn[standard]`, or `fastapi[standard]` when you start it with `fastapi run`. Do not leave it to a dev group or to a tool installed outside the project.
**Why.** A service whose tests pass through `TestClient` never imports the server, so a missing server shows up only when someone runs the start command.
**Correct:**
```toml
[project]
dependencies = ["fastapi", "uvicorn[standard]"]
```
**Exceptions.** `fastapi[standard]` already pulls in `uvicorn[standard]`, so it satisfies the rule on its own.
### Generate the OpenAPI document from the FastAPI app and never edit it by hand
_high · python, fastapi_
**Rule.** Treat the FastAPI app and its Pydantic models as the only source of the OpenAPI document. Export it in CI with `app.openapi()` and never edit the exported file.
**Why.** A hand-edited spec drifts from the code, so clients and docs generated from it call endpoints that no longer behave that way.
**Correct:**
```bash
python -c "import json; from myapp.main import app; print(json.dumps(app.openapi()))" > openapi.json
```
### Return only loc, msg and type for each validation error
_high · python, fastapi_
**Rule.** In the `RequestValidationError` handler build the `errors` list from `loc`, `msg` and `type` of each item, and return neither `input`, `ctx` nor `exc.body`.
**Why.** `exc.errors()` carries the value the client submitted, so a handler that serialises it whole sends a rejected password or token back in the response body; `ctx` is dropped too because it holds the constraint values and objects of the failed check, which are not for clients.
**Correct:**
```python
errors=[{"loc": list(e["loc"]), "msg": e["msg"], "type": e["type"]} for e in exc.errors()]
```
**Exceptions.** A custom validator that puts the value in its message (`raise ValueError(f"{v} not allowed")`) leaks it through `msg`; word such messages without the value.
### Add structure to one module at a time, when it hurts
_high · any_
**Rule.** Begin every module with a handler, a service and models. Add a port, a fake and a richer domain model to a module only when a concrete pain shows: the same rule copied in several places, a rule test that needs a database, or business terms missing from the code. Make the step in that one module, not across the application.
**Why.** Hexagonal layers and a rich domain model on a module that is mostly CRUD add files and indirection that repay nothing; the Cosmic Python authors say a thin wrapper over a database probably does not need a domain model or a repository. Fowler advises learning the boundaries inside a well-modularised monolith before splitting further, so the step is taken per module, on evidence.
### Keep a handler to parsing, one service call and error mapping
_high · any_
**Rule.** A handler parses the request, calls one service and maps the result and its errors to a response. A service fetches objects, calls the domain and saves, and knows nothing of HTTP.
**Why.** A rule written in a handler is copied into the next handler and cannot be tested without HTTP. Cosmic Python's service layer keeps the orchestration out of the entrypoint so the same use case serves any caller.
**Exceptions.** A module that only creates, reads, updates and deletes rows has no rules to place; there the service stays a few lines.
### Let imports point toward the domain, never away from it
_high · any_
**Rule.** Let handlers depend on services, services on the domain and on the ports it declares, and adapters (ORM mappings, HTTP clients, queues) on the domain. The domain code imports no web framework, no ORM and no adapter. Only the composition root, the place that wires the application together, imports adapters.
**Why.** When a business rule imports the ORM or the web framework, it cannot be tested or changed without the database or the HTTP stack, and replacing either rewrites the rules. Cosmic Python puts it as: the ORM imports the model, not the model the ORM.
**Exceptions.** A module that only does CRUD has no domain code to protect: its service may use the ORM models and the session directly until the pain described in add-structure-to-one-module-when-it-hurts shows.
### Put a rule about what is allowed in the domain, not in a handler or service
_high · any_
**Rule.** Let the domain decide what is allowed: an invariant belongs in the domain object it protects. A service only loads the object, asks it, and saves it.
**Why.** A rule written in a service over field-only models gives an anemic domain model, which Fowler calls an anti-pattern: all the costs of a domain model without any of its benefits.
**Exceptions.** A module with no invariants needs no domain object; keep a plain model there.
### Split the codebase by domain first and by layer inside each domain
_high · any_
**Rule.** Make the top-level packages business areas (`orders`, `users`, `billing`) and put the layers (handlers, services, models) inside each one. Group by file type at the top only in a small service or a prototype.
**Why.** Grouping by file type scatters one feature over several folders, so every change edits all of them and the project tangles as it grows. Fowler advises the same for larger systems: divide the top level into domain modules first, and apply the layers inside each module.
**Exceptions.** A service with a handful of endpoints may stay flat; split it by domain when finding the code of one feature takes more than a moment.
### Keep a FastAPI route to parsing, one service call and error mapping
_high · python, fastapi_
**Rule.** Write a route so that it receives validated input, calls one service method and leaves the service's exceptions to the domain's registered exception handler, which turns them into HTTP responses. A loop, an `if` about a business rule or a second database call in a route belongs in the service.
**Why.** A rule that lives in a route is copied into the next route and can only be tested through HTTP.
**Correct:**
```python
@router.post(
"/orders/{order_id}/confirm",
status_code=204,
responses={404: PROBLEM, 409: PROBLEM}, # PROBLEM: see the Problem Details note
)
def confirm(order_id: UUID, service: OrderService = Depends(get_order_service)):
service.confirm(order_id) # OrderNotFound and EmptyOrder are mapped by the domain's handler
```
### Organize a FastAPI project as one package per domain
_high · python, fastapi_
**Rule.** Create one package per domain under `src/`, each with `router.py`, `schemas.py` (Pydantic), `models.py` (database) and `service.py`; add `dependencies.py` and `exceptions.py` when needed. A trivial domain may start with fewer files. `main.py` creates the app and includes the routers.
**Why.** With top-level `routers/`, `schemas/` and `models/`, adding a field to an order edits three folders, and nothing marks which files belong together.
**Correct:**
```text
src/
├── orders/ router.py schemas.py models.py service.py
├── users/ router.py schemas.py models.py service.py
└── main.py
```
**Exceptions.** The official FastAPI tutorial keeps a flat `routers/` package for a small app; that is a starting point, not the target for an API that grows.
### Structure a Python API as domain modules, with hexagonal layers only where they pay
_high · python, fastapi_
**Decision.** Build the API as domain modules: one package per business area, each with a router, schemas, models and a thin service. When one module's rules grow complex enough that testing them needs a database, give that module hexagonal layers: a domain model, ports and adapters. Do it per module, never for the whole application.
## Read when the task touches it
| Impact | Rule | Applies to |
| --- | --- | --- |
| medium | [Compare the spec with the main branch using oasdiff and fail on breaking changes](references/rules.md#compare-the-spec-with-the-main-branch-using-oasdiff-and-fail-on-breaking-changes) — oasdiff breaking reports removed fields and tightened types between two spec versions. | python, oasdiff |
| medium | [Accept only ISO strings for date and aware datetime inputs](references/rules.md#accept-only-iso-strings-for-date-and-aware-datetime-inputs) — Pydantic's lax mode reads 0 as 1970-01-01; a BeforeValidator makes the code match the spec's format. | python, fastapi |
| medium | [Answer a request that the current state forbids with 409, not 422](references/rules.md#answer-a-request-that-the-current-state-forbids-with-409-not-422) — 422 is for input that is invalid on its own; a limit reached, an empty stock or a date before a stored one is 409. | python, fastapi |
| medium | [Turn off slash redirects so an unknown path answers 404 Problem Details](references/rules.md#turn-off-slash-redirects-so-an-unknown-path-answers-404-problem-details) — FastAPI redirects /events/1/ to /events/1 with a bodyless 307; redirect_slashes=False makes it a 404 problem. | python, fastapi |
| medium | [Answer an unhandled exception with a generic 500 problem and no exception text](references/rules.md#answer-an-unhandled-exception-with-a-generic-500-problem-and-no-exception-text) — A catch-all Exception handler returns a fixed Problem Details 500; the server's error log keeps the traceback. | python, fastapi |
| medium | [Turn off the built-in docs and openapi.json endpoints on a public API](references/rules.md#turn-off-the-built-in-docs-and-openapijson-endpoints-on-a-public-api) — A public API publishes a static reference; /docs, /redoc and /openapi.json stay only behind network authentication. | python, fastapi |
| medium | [Mark an endpoint deprecated and send Deprecation and Sunset headers before removing it](references/rules.md#mark-an-endpoint-deprecated-and-send-deprecation-and-sunset-headers-before-removing-it) — deprecated=True shows in the spec; the headers tell machine clients when it goes away. | python, fastapi |
| medium | [Give each domain error its own Problem Details type](references/rules.md#give-each-domain-error-its-own-problem-details-type) — A stable type URI per error lets a client branch on it; about:blank on every 409 forces it to parse detail. | python, fastapi |
| medium | [List every method of the path in the Allow header of a 405](references/rules.md#list-every-method-of-the-path-in-the-allow-header-of-a-405) — Starlette reports only the first matching route's methods, so the handler reads them from the OpenAPI paths. | python, fastapi |
| medium | [Map a domain's exceptions to error responses in one registered handler](references/rules.md#map-a-domains-exceptions-to-error-responses-in-one-registered-handler) — One exception handler per domain turns its exceptions into Problem Details; routes do not repeat the mapping. | python, fastapi |
| medium | [Name request and response schemas by their role, not by In and Out or a DTO suffix](references/rules.md#name-request-and-response-schemas-by-their-role-not-by-in-and-out-or-a-dto-suffix) — ItemCreate, ItemUpdate, ItemResponse: the name says the role in the operation, with one style per project. | python, fastapi |
| medium | [Replace dependencies with app.dependency_overrides in API tests](references/rules.md#replace-dependencies-with-appdependency_overrides-in-api-tests) — Overrides swap the injected dependency for the test; patching import paths ties the test to module layout. | python, fastapi, pytest |
| medium | [Return every API error in one Problem Details model](references/rules.md#return-every-api-error-in-one-problem-details-model) — One RFC 9457 shape (type, title, status, detail) for all endpoints, declared in responses. | python, fastapi |
| medium | [Set operation_id explicitly so generated SDK method names stay stable](references/rules.md#set-operation_id-explicitly-so-generated-sdk-method-names-stay-stable) — The default id is built from the function and path, so a rename changes every client method name. | python, fastapi |
| medium | [Run Schemathesis against the OpenAPI document and fix the code, not the test](references/rules.md#run-schemathesis-against-the-openapi-document-and-fix-the-code-not-the-test) — It generates requests from the spec and finds 5xx errors, schema mismatches and undeclared statuses. | python, schemathesis |
| medium | [Refer to another module's objects by id, not by its model](references/rules.md#refer-to-another-modules-objects-by-id-not-by-its-model) — Each module owns its model; across a boundary pass identifiers and translate the data. | any |
| medium | [Make a service depend on a repository Protocol and test its rules with a fake](references/rules.md#make-a-service-depend-on-a-repository-protocol-and-test-its-rules-with-a-fake) — A typing.Protocol in the domain, a SQLAlchemy adapter, and a dict-backed fake for rule tests. | python |
| medium | [Build adapters in dependency functions and inject them with Depends](references/rules.md#build-adapters-in-dependency-functions-and-inject-them-with-depends) — A domain's dependencies.py creates its repositories and services; the rest receives them as arguments. | python, fastapi |
| medium | [Hold another domain's id in a field instead of importing its model](references/rules.md#hold-another-domains-id-in-a-field-instead-of-importing-its-model) — orders stores customer_id; it asks the users service for the customer when it needs one. | python |
| medium | [Put a business rule in a method of the domain object](references/rules.md#put-a-business-rule-in-a-method-of-the-domain-object) — order.confirm() checks the invariant; the service loads the object, calls it and saves. | python |
| low | [Describe every field and parameter of a public model and add an example](references/rules.md#describe-every-field-and-parameter-of-a-public-model-and-add-an-example) — Field(description=..., examples=[...]) and Path/Query(description=...) reach the spec and the generated docs. | python, fastapi |
| low | [Install httpx2 for the FastAPI test client](references/rules.md#install-httpx2-for-the-fastapi-test-client) — Starlette's TestClient prefers httpx2 and warns when it falls back to httpx; list httpx2 in the test group. | python, fastapi, uv |
| low | [Lint the OpenAPI document with Spectral in CI and fail on warnings](references/rules.md#lint-the-openapi-document-with-spectral-in-ci-and-fail-on-warnings) — Spectral checks one spec against the spectral:oas ruleset plus team rules for naming and descriptions. | python, spectral |
Files in this skill
- SKILL.md
- references/rules.md
Attribution
Related skills
View all in development →Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.