Deep Dive: Dependency Injection in Nexios
Nexios is a modern, high-performance Python web framework that stands out for its clean architecture, async-first design, and developer-friendly features. One of its most powerful features is its robust Dependency Injection (DI) system. In this post, we’ll explore how Nexios’s DI works, why it matters, and how you can leverage it to write cleaner, more maintainable, and testable code.
What is Dependency Injection?
Dependency Injection is a design pattern that allows you to declare external resources, services, or logic ("dependencies") that your route handlers or other dependencies need. Instead of manually wiring up these dependencies, Nexios will automatically resolve and inject them for you.
Real-world analogy:
Think of DI as a restaurant: the chef (your route handler) doesn't need to know where the ingredients (dependencies) come from, just that they're provided when needed. This separation lets the chef focus on cooking, not sourcing.
Benefits:
- Separation of concerns
- Reusability
- Testability
- Cleaner, more readable code
- Easier refactoring and scaling
Why not just use globals or singletons?
Globals make code harder to test and reason about, and singletons can introduce hidden state. DI makes dependencies explicit and swappable.
Quick Start: Basic Dependency
from nexios import NexiosApp, Depend
app = NexiosApp()
def get_settings():
return {"debug": True, "version": "1.0.0"}
@app.get("/config")
async def show_config(request, response, settings: dict = Depend(get_settings)):
return settings
- Use
Depend()
to mark a parameter as a dependency. - Dependencies can be sync or async functions, or even classes.
- Nexios will resolve dependencies recursively, even if they depend on other dependencies.
Chaining & Sub-Dependencies
Dependencies can depend on other dependencies, forming a tree. This enables powerful composition and reuse:
async def get_db_config():
return {"host": "localhost", "port": 5432}
async def get_db_connection(config: dict = Depend(get_db_config)):
return Database(**config)
@app.get("/users")
async def list_users(req, res, db: Database = Depend(get_db_connection)):
return await db.query("SELECT * FROM users")
- Each dependency can itself declare dependencies, and Nexios will resolve the entire tree for you.
- This makes it easy to build complex, layered services.
Resource Management with Yield (Generators)
For resources that need cleanup (e.g., DB sessions), use generator or async generator dependencies. Nexios will handle cleanup automatically:
# Synchronous generator dependency
def get_resource():
resource = acquire()
try:
yield resource
finally:
release(resource)
# Async generator dependency
async def get_async_resource():
resource = await acquire_async()
try:
yield resource
finally:
await release_async(resource)
@app.get("/resource")
async def use_resource(req, res, r=Depend(get_resource)):
...
@app.get("/async-resource")
async def use_async_resource(req, res, r=Depend(get_async_resource)):
...
- Cleanup code in the
finally
block is always executed after the request, even if an exception occurs. - Both sync and async generator dependencies are supported.
- This pattern is ideal for DB sessions, file handles, or network connections.
App-level and Router-level Dependencies
Nexios supports dependencies that apply to all routes in the app or in a router. This is useful for things like authentication, database sessions, or any logic you want to share across multiple routes.
-
App-level dependencies: Set with the
dependencies
argument onNexiosApp
. These run for every request to the app. -
Router-level dependencies: Set with the
dependencies
argument onRouter
. These run for every request to routes registered on that router.
Example: App-level Dependency
from nexios import NexiosApp, Depend
def global_dep():
# This will run for every request
return "global-value"
app = NexiosApp(dependencies=[Depend(global_dep)])
@app.get("/foo")
async def foo(req, res, value=Depend(global_dep)):
return res.text(value)
Example: Router-level Dependency
from nexios import Router, Depend
def router_dep():
return "router-value"
router = Router(prefix="/api", dependencies=[Depend(router_dep)])
@router.get("/bar")
async def bar(req, res, value=Depend(router_dep)):
return res.text(value)
Combining App, Router, and Route Dependencies
All levels are resolved and injected in order:
app = NexiosApp(dependencies=[Depend(global_dep)])
router = Router(prefix="/api", dependencies=[Depend(router_dep)])
@router.get("/combo")
async def combo(req, res, g=Depend(global_dep), r=Depend(router_dep), custom=Depend(lambda: "custom")):
return {"app": g, "router": r, "custom": custom}
app.mount_router(router)
- This allows for powerful composition and separation of concerns.
Using Classes as Dependencies
Classes can act as dependencies through their __call__
method. This is useful for stateful services, like authentication or caching.
class AuthService:
def __init__(self, secret_key: str):
self.secret_key = secret_key
async def __call__(self, context=Context()):
token = context.request.headers.get("Authorization")
return await self.verify_token(token)
async def verify_token(self, token):
# ... verify logic ...
return User(token)
auth = AuthService(secret_key="my-secret")
@app.get("/protected")
async def protected_route(req, res, user = Depend(auth)):
return {"message": f"Welcome {user.name}"}
- Classes can maintain state/configuration and encapsulate logic.
- You can inject configuration (like secret keys) at instantiation.
Context-Aware Dependencies
Dependencies can access request context, which includes the request, user, and more. This is useful for logging, tracing, or user-specific logic.
from nexios.dependencies import Context
@app.get("/context-demo")
async def context_demo(req, res, context: Context = None):
return {"path": context.request.url.path}
You can also use context=Context()
as a parameter, and Nexios will inject the current context automatically.
Deep Context Propagation
Nexios supports context propagation through deeply nested dependencies:
async def dep_a(context=Context()):
return f"A: {context.request.url.path}"
async def dep_b(a=Depend(dep_a), context=Context()):
return f"B: {a}, {context.request.url.path}"
@app.get("/deep-context")
async def deep_context(req, res, b=Depend(dep_b)):
return {"result": b}
- Context is available at any depth in the dependency tree.
- This enables advanced use cases like per-request logging, tracing, or user-specific logic.
Real-World Use Cases
Authentication & Authorization
class AuthService:
def __init__(self, secret):
self.secret = secret
async def __call__(self, context=Context()):
token = context.request.headers.get("Authorization")
return self.verify_token(token)
@app.get("/dashboard")
async def dashboard(req, res, user=Depend(AuthService("s3cr3t"))):
return {"message": f"Welcome {user.name}"}
Database Transactions
def get_db():
db = connect_db()
try:
yield db
finally:
db.close()
@app.get("/items")
async def items(req, res, db=Depend(get_db)):
return await db.fetch_items()
Third-Party Integrations
class EmailClient:
def __init__(self, api_key):
self.api_key = api_key
async def __call__(self):
return self
@app.post("/send-email")
async def send_email(req, res, email_client=Depend(EmailClient("my-key"))):
await email_client.send(...)
return {"status": "sent"}
Testing Dependencies
You can override dependencies for testing, making it easy to swap real services for mocks or fakes:
async def get_test_db():
return TestDatabase()
app.dependency_overrides[get_db] = get_test_db
- This is invaluable for unit and integration testing.
- You can override any dependency, including those used by sub-dependencies.
Best Practices
- Single Responsibility: Each dependency should have one clear purpose.
- Type Hints: Use type hints for better IDE support and error detection.
-
Resource Management: Use
yield
for resources that need cleanup. - Error Handling: Handle dependency errors gracefully (raise HTTPException, return error responses, etc.).
- Documentation: Document what each dependency provides.
- Testing: Design dependencies to be easily testable and overridable.
- Performance: Avoid expensive operations in dependencies unless necessary; use caching if needed.
- Use Context: Leverage context for request/user-specific logic.
- Avoid Circular Dependencies: Structure dependencies to avoid cycles.
Advanced Patterns
-
Dependency Caching: Use
functools.lru_cache
or similar for expensive dependencies that don't change per request. - Custom Scopes: Implement your own scoping rules for advanced use cases (e.g., per-session, singleton).
- Pydantic Models: Use Pydantic for validation in dependencies, ensuring robust data handling.
- Error Handling in Dependencies: Raise custom exceptions or return error responses as needed.
- Dependency Trees: Visualize complex dependency graphs for debugging and optimization.
How Does It Work? (Under the Hood)
Nexios’s DI system is implemented in nexios/dependencies.py
. Here’s a high-level overview:
- Depend class: Used to mark a parameter as a dependency. Stores a reference to the dependency provider (function/class).
- inject_dependencies decorator: Wraps route handlers, inspects their parameters, and resolves/injects dependencies recursively. Handles context injection and cleanup for generator dependencies.
- Context: A special object that carries request/user info and is available throughout the dependency tree.
-
App/Router-level dependencies: Managed by passing a list of
Depend
objects to the app or router. These are injected into every route as needed. - Cleanup: Generator/async generator dependencies are properly closed after the request, ensuring resources are released.
Step-by-step resolution:
- When a request comes in, Nexios inspects the route handler's parameters.
- For each parameter marked with
Depend
, it recursively resolves its dependencies, building a tree. - If a dependency is a generator, Nexios ensures the cleanup code runs after the response is sent.
- Context objects are passed down, so even deeply nested dependencies can access request/user info.
- This process is highly optimized and supports both sync and async dependencies.
Performance considerations:
- Nexios caches dependency resolution logic for each route, minimizing overhead.
- You can use memoization or caching for expensive dependencies.
Comparison with Other Frameworks:
- Nexios DI is inspired by FastAPI but is more flexible in some areas (e.g., class-based dependencies, context propagation).
- Unlike Flask, which requires manual wiring or third-party libraries, Nexios has DI built-in.
FAQ
Q: Can I inject dependencies into background tasks?
A: Yes! Nexios allows you to use the same DI system for background tasks, ensuring resources like DB connections or caches are properly managed and cleaned up.
Q: How do I debug dependency errors?
A: Nexios provides clear error messages when dependencies fail to resolve. Use type hints and docstrings to clarify what each dependency expects.
Q: Can I use DI with WebSockets or background jobs?
A: Yes, the same DI system can be used for WebSocket handlers and background jobs, with context propagation as needed.
Conclusion
Nexios’s DI system is one of its most powerful features, enabling you to write modular, maintainable, and testable web applications. Whether you’re building a simple API or a complex backend, leveraging DI will help you keep your codebase clean and scalable.
For more, see the official documentation and explore the codebase for advanced patterns!
Top comments (0)