Core Concepts¶
The Context (TinyDiCtx)¶
The TinyDiCtx is the heart of the dependency injection system. It holds:
value_map: Direct values to inject by parameter namefn_map: Function substitutions (for testing/decoupling)validator: Optional type validator (e.g., Pydantic)
Internal state (managed automatically):
_cache: Cached dependency results_cleanup_stack: Generators awaiting cleanup
Using the Context¶
from tiny_fastapi_di import empty_di_ctx
# Call a function with injected values
result = await empty_di_ctx.call_fn(my_function, request_id=123)
# Or create a derived context for multiple calls
test_ctx = empty_di_ctx.with_maps(fn_map={real_db: mock_db})
result = await test_ctx.call_fn(my_function, request_id=123)
with_maps() always returns a new context. The original is not modified.
Resolution Order¶
When resolving a parameter, tiny-fastapi-di checks in this order:
Depends()- If the default is aDepends, call the dependencyvalue_map- If the parameter name exists invalue_map, use that value- Default value - If the parameter has a default, use it
- Error - Raise
TypeErrorwith actionable guidance
async def example(
request_id: int, # Required - must be in value_map
db = Depends(get_db), # Resolved via Depends
timeout: int = 30, # Uses default if not in value_map
):
...
Caching¶
By default, dependencies are cached within a single call_fn invocation:
call_count = 0
def get_expensive_resource():
global call_count
call_count += 1
return "resource"
async def fn_a(r = Depends(get_expensive_resource)):
return r
async def fn_b(r = Depends(get_expensive_resource)):
return r
async def main(a = Depends(fn_a), b = Depends(fn_b)):
return (a, b)
# get_expensive_resource is only called ONCE
await empty_di_ctx.call_fn(main)
assert call_count == 1
Disable caching with use_cache=False:
async def main(
a = Depends(get_resource, use_cache=False),
b = Depends(get_resource, use_cache=False),
):
# get_resource called twice
...
Circular Dependency Detection¶
tiny-fastapi-di detects circular dependencies immediately:
def fn_a(b = Depends(fn_b)):
return b
def fn_b(a = Depends(fn_a)):
return a
await empty_di_ctx.call_fn(fn_a)
# Raises: RecursionError: Circular dependency detected: fn_a() is already being resolved.
# Check the dependency chain for cycles.
FastAPI does not detect circular dependencies (it will stack overflow). tiny-fastapi-di catches this early with a clear error message.
Decoupling with fn_map¶
The fn_map feature maps callables to their implementations. This enables:
- Framework/plugin separation: Plugin code sees only what it needs
- Testing: Swap real implementations for mocks
- Configuration: Different implementations for different environments
Example: Decoupled Plugin Code¶
from dataclasses import dataclass
from typing import Annotated
from tiny_fastapi_di import Depends, empty_di_ctx
# Plugin code only knows about this dataclass - no framework details
@dataclass
class RequestContext:
user_id: int
permissions: list[str]
request_path: str
# Plugin function - clean, framework-agnostic
async def handle_request(ctx: Annotated[RequestContext, Depends()]):
if "admin" in ctx.permissions:
return f"Admin {ctx.user_id} accessing {ctx.request_path}"
return f"User {ctx.user_id} accessing {ctx.request_path}"
# Framework code - knows how to build RequestContext
def get_request_context(request, auth_service):
user = auth_service.get_user(request.headers["Authorization"])
return RequestContext(
user_id=user.id,
permissions=user.permissions,
request_path=request.path,
)
# Plugin code runs without knowing where RequestContext comes from
result = await empty_di_ctx.call_fn(
handle_request,
fn_map={RequestContext: get_request_context},
request=http_request,
auth_service=auth,
)
The plugin code (handle_request) only sees RequestContext. It doesn't know about HTTP requests, authentication services, or any framework details.
fn_map Key Identity¶
The fn_map uses callable identity (not equality) for lookup. This works reliably for:
- Named functions (
def my_func(): ...) - Classes (
class MyService: ...)
Be careful with:
- Lambdas: Each
lambdacreates a new object. You can't map a lambda defined elsewhere. - Bound methods:
obj.methodcreates a new object each time.
If you need to map these, assign them to a variable first: