Dependency Injection¶
Dependency injection (DI) is how a handler gets ready-made objects — a database
pool, settings, the current user — without creating them in every file. You add
a parameter to the handler, EndoCore fills it in. It works like FastAPI's:
Depends(...) plus app-level providers — same idea, small implementation
(endocore/core/di.py, no separate DI framework underneath).
from endocore import Request, Response, Depends
async def db():
return get_pool() # a dependency (sync or async)
async def current_user(request: Request, pool = Depends(db)):
token = request.headers.get("authorization")
return await pool.user_from(token)
async def handler(request: Request, user = Depends(current_user)):
return Response.json({"user": user})
Dependencies can depend on other dependencies (nested, to any depth), and are
cached per request — a dependency used twice in the same request's
resolution tree runs once. The cache key is the dependency function object
itself: two different Depends(db) markers pointing at the same db
function share one cached result; Depends(db) and Depends(other_db) never
do, even if other_db happens to return an identical-looking object.
How a handler is dispatched at all¶
Before any of this runs, the dispatcher checks whether your handler is the
simplest possible shape: exactly one parameter, named request, with no
default value. If so, it skips the whole DI machinery below and calls
handler(request) directly — this "trivial handler" fast path exists purely
for the common case's sake, and its check result is cached per-function, so
it costs nothing on repeat calls. The moment you add a second parameter, a
default, or Depends(...), the handler goes through full resolution instead.
Resolution order — precisely¶
For each handler parameter, in this exact order (this is the literal
control flow of di._resolve, not a paraphrase — the order matters, because
more than one rule can technically apply to the same parameter):
- The parameter's default is
Depends(fn)→ resolvefn(recursively, through the same rules) and cache the result. This is checked before anything else — including before therequest/websocketspecial-casing below. In practice this never collides (nobody writesrequest = Depends(...)), but it's worth knowing which rule actually wins if you ever do something unusual. - The parameter is named
request, or annotatedRequest→ the currentRequest(orNoneinside a WebSocket handler — see below). - The parameter is named
websocket, or annotatedWebSocket→ the currentWebSocket(orNoneinside an HTTP handler). - The name matches a captured path param (
[id]→ a parameter literally namedid) → that string value. This works for both HTTP and WebSocket handlers — whichever ofrequest/websocketis active suppliespath_params. - An app-level provider matches — checked by annotation (type) first, then by parameter name if no type match exists. See below.
- The annotation is a pydantic
BaseModelsubclass → validated from the JSON request body. HTTP only — this rule is skipped entirely for WebSocket handlers (there's no request body to validate against), so aBaseModel-annotated parameter on aSocket.pyhandler falls through to the next rule instead. - The parameter has its own plain default value (not
Depends(...)) → used as-is. - None of the above matched →
DIErroris raised, naming the parameter and the function — this is a boot-time-adjacent failure in practice, since handlers are imported eagerly; a genuinely unresolvable parameter usually surfaces the first time the route is hit, not at import time (DI runs per-request, not at import), so cover new handlers with at least one test request rather than trustingendo checkalone to catch this class of bug.
App-level providers¶
Register singletons/services once; inject them by name or type:
# providers.py
from Services.db import make_pool
from Services.settings import Settings, get_settings
providers = {
"db": make_pool, # inject a param named `db`
Settings: get_settings, # inject a param annotated `Settings`
}
Providers are singletons by default: the factory function itself is
the cache key (app._singletons[factory]), built once on first use and
reused for the life of the process — not per-request like Depends(...)
results. The providers.py dict form is always singleton (there's no
per-entry option there); for a factory that should run fresh on every
resolution, register it at runtime instead —
app.provide("db", make_pool, singleton=False), e.g. from hooks.py or an
extension's setup(app). A provider factory can itself take
parameters resolved through the same DI rules (including Depends(...) and
other providers) — providers and Depends share one resolver, they're not
two separate systems.
If a provider is registered under both a name and a type that could
apply to the same parameter (unusual, but possible), type wins — that's
rule 5 above, and it's a property of ProviderRegistry.get(), not of
declaration order in providers.py.
Pydantic bodies¶
With endocore[pydantic] installed, a parameter annotated with a BaseModel
is validated from the JSON body — 422 with per-field errors on failure,
and its schema shows up in /docs:
from pydantic import BaseModel
class UserIn(BaseModel):
name: str
age: int
async def handler(request, data: UserIn): # POST body -> validated UserIn
return Response.json({"name": data.name}, status=201)
Both pydantic v1 (.parse_obj) and v2 (.model_validate) are supported —
whichever is installed is detected automatically, no configuration needed.
A validation failure raises UnprocessableEntity (422) with detail set to
a list of {"field": "age", "message": "..."} entries, one per failing
field — the same shape Exceptions documents for every other
HTTP error, so a client-side error handler doesn't need a special case for
body-validation failures specifically.
Performance¶
Signatures and resolved type hints are cached per function (inspect.signature
and typing.get_type_hints are not cheap to call on every request), and the
"is this a trivial handler" check above is a cached boolean too. In practice
DI adds negligible overhead even for handlers with several nested
dependencies — the cost that matters is whatever your dependency functions
actually do (a DB query, an HTTP call), not the resolution machinery
around them.