DEV Community

Cover image for Deep Dive: Dependency Injection in Nexios
Dunamix
Dunamix

Posted on

Deep Dive: Dependency Injection in Nexios

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
Enter fullscreen mode Exit fullscreen mode
  • 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")
Enter fullscreen mode Exit fullscreen mode
  • 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)):
    ...
Enter fullscreen mode Exit fullscreen mode
  • 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 on NexiosApp. These run for every request to the app.
  • Router-level dependencies: Set with the dependencies argument on Router. 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
  • 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}"}
Enter fullscreen mode Exit fullscreen mode
  • 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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode
  • 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}"}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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:

  1. When a request comes in, Nexios inspects the route handler's parameters.
  2. For each parameter marked with Depend, it recursively resolves its dependencies, building a tree.
  3. If a dependency is a generator, Nexios ensures the cleanup code runs after the response is sent.
  4. Context objects are passed down, so even deeply nested dependencies can access request/user info.
  5. 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)