Skip to content

Providers

Introduction

Providers are the core of waku dependency injection system. The idea behind a provider is that it can be injected as a dependency into other provider constructors, allowing objects to form various relationships with each other.

waku responsibility is to "wire up" all the providers using the DI framework and manage their lifecycle. This way you can focus on writing your application logic.

Dependency Injection

waku is designed to be modular and extensible. To support this principle, it provides a flexible dependency injection (DI) system that integrates seamlessly with various DI frameworks. waku itself acts as an IoC container, allowing you to register and resolve dependencies using the modules system.

Note

waku uses the Dishka IoC container under the hood. All provider lifecycles and dependency resolution are handled by Dishka.

What is Dependency Injection?

Dependency Injection (DI) is a design pattern that addresses the issue of tightly coupled code by decoupling the creation and management of dependencies from the classes that rely on them. In traditional approaches, classes directly instantiate their dependencies, resulting in rigid, hard-to-maintain code. DI solves this problem by enabling dependencies to be supplied externally, typically through mechanisms like constructor or setter injection.

By shifting the responsibility of dependency management outside the class, DI promotes loose coupling, allowing classes to focus on their core functionality rather than how dependencies are created. This separation enhances maintainability, testability, and flexibility, as dependencies can be easily swapped or modified without altering the class's code. Ultimately, DI improves system design by reducing interdependencies and making code more modular and scalable.

Manual DI Example
from abc import ABC, abstractmethod


# Use an interface to define contract for clients
# This allows us injecting different implementations
class IClient(ABC):
    @abstractmethod
    def request(self, url: str) -> str:
        pass


# Regular implementation
class RealClient(IClient):
    def request(self, url: str) -> str:
        # Some HTTP requesting logic
        return f'"{url}" call result'


# Implementation for tests
class MockClient(IClient):
    def __init__(self, return_data: str) -> None:
        self._return_data = return_data

    def request(self, url: str) -> str:
        # Mocked behavior for testing
        return f'{self._return_data} from "{url}"'


class Service:
    # Accepts any IClient implementation
    def __init__(self, client: IClient) -> None:
        self._client = client

    def do_something(self) -> str:
        return self._client.request('https://example.com')


# Usage in regular code
real_client = RealClient()
service = Service(real_client)
print(service.do_something())  # Output: "https://example.com" call result

# Usage in tests
mocked_client = MockClient('mocked data')
service = Service(mocked_client)
print(service.do_something())  # Output: mocked data from "https://example.com"

Here, a MockClient is injected into Service, making it easy to test Service in isolation without relying on a real client implementation.

What is IoC-container?

An IoC container is a framework that automates object creation and dependency management based on the Inversion of Control (IoC) principle. It centralizes the configuration and instantiation of components, reducing tight coupling and simplifying code maintenance. By handling dependency resolution, an IoC container promotes modular, testable, and scalable application design.

With the power of an IoC container, you can leverage all the benefits of DI without manually managing dependencies.

Providers

Provider is an object that holds dependency metadata, such as its type, lifetime scope and factory.

In waku, there are five types of providers, one for each scope:

Each provider (except Contextual) takes two arguments:

  • source: type or callable that returns or yields an instance of the dependency.
  • provided_type: type of the dependency. If not provided, it will be inferred from the factory function's return type.

Scopes

waku supports four different lifetime scopes for providers, inspired by the service lifetimes from .NET Core's DI system.

Transient

Dependencies defined with the Transient provider are created each time they're requested.

from waku import WakuFactory, module
from waku.di import transient


@module(providers=[transient(list)])
class AppModule:
    pass


async def main() -> None:
    application = WakuFactory(AppModule).create()
    async with application:
        async with application.container() as request_container:
            obj_1 = await request_container.get(list)
            obj_2 = await request_container.get(list)
            assert obj_1 is not obj_2

        # Providers are disposed at this point

Scoped

Dependencies defined with the Scoped provider are created once per dependency provider context entry and disposed when the context exits.

from waku import WakuFactory, module
from waku.di import scoped


@module(providers=[scoped(list)])
class AppModule:
    pass


async def main() -> None:
    application = WakuFactory(AppModule).create()
    async with application:
        async with application.container() as request_container:
            obj_1 = await request_container.get(list)
            obj_2 = await request_container.get(list)
            assert obj_1 is obj_2

        # Providers are disposed at this point

Singleton

Dependencies defined with the Singleton provider are created the first time they're requested and disposed when the application lifecycle ends.

from waku import WakuFactory, module
from waku.di import singleton


@module(providers=[singleton(list)])
class AppModule:
    pass


async def main() -> None:
    application = WakuFactory(AppModule).create()
    async with application:
        async with application.container() as request_container:
            obj_1 = await request_container.get(list)

        async with application.container():
            obj_2 = await request_container.get(list)

        assert obj_1 is obj_2

    # Providers are disposed at this point

Object

Dependencies defined with the Object provider behave like Singleton, but you must provide the implementation instance directly to the provider and manage its lifecycle manually, outside the IoC container.

from waku import WakuFactory, module
from waku.di import object_

some_object = (1, 2, 3)


@module(
    providers=[
        object_(some_object, provided_type=tuple),
    ],
)
class AppModule:
    pass


async def main() -> None:
    application = WakuFactory(AppModule).create()
    async with application, application.container() as request_container:
        obj = await request_container.get(tuple)
        assert obj is some_object

    # Providers are not disposed at this point automatically
    # because they are not part of the application container lifecycle

Contextual

The Contextual provider enables you to inject external objects that originate outside the DI container directly into your dependency graph. This is particularly valuable for framework-specific objects like HTTP requests, database transactions, or event data that have their own lifecycle managed externally.

When to use Contextual providers:

  • Framework integration: Inject HTTP request objects, user sessions, or authentication contexts
  • Event-driven scenarios: Process queue messages, webhooks, or callback data
  • External resources: Integrate database transactions, file handles, or network connections managed by external systems
  • Per-request data: Handle any data that varies per request/operation and originates from outside your application

How it works:

  1. Declare the contextual dependency using the contextual provider in your module
  2. Use the dependency in other providers just like any regular dependency
  3. Provide the actual value when entering the container scope using the context= parameter

The contextual provider accepts two arguments:

  • provided_type: The type of the dependency to be injected
  • scope: The scope where the context is available (defaults to Scope.REQUEST)
from waku import WakuFactory, module
from waku.di import contextual, Scope

some_object = (1, 2, 3)


@module(
    providers=[
        contextual(provided_type=tuple, scope=Scope.REQUEST),
    ],
)
class AppModule:
    pass


async def main() -> None:
    application = WakuFactory(AppModule).create()
    async with (
        application,
        application.container(
            context={tuple: some_object},
        ) as request_container,
    ):
        obj = await request_container.get(tuple)
        assert obj is some_object

    # Providers are not disposed at this point automatically
    # because they are not part of the application container lifecycle

Slightly more realistic example:

Consider building a web application with FastAPI where you need to inject the current request into your service layer. Here's how you can accomplish this:

from fastapi import FastAPI, Request
from waku import WakuFactory, module
from waku.di import contextual, scoped, Scope


class UserService:
    """Service that uses the current HTTP request for user-specific operations."""

    def __init__(self, request: Request) -> None:
        self.request = request

    def get_user_info(self) -> dict[str, str]:
        """Extract user information from the request headers."""
        return {
            'user_id': self.request.headers.get('user-id', 'anonymous'),
            'session_id': self.request.headers.get('session-id', 'none'),
            'user_agent': self.request.headers.get('user-agent', 'unknown'),
        }


@module(
    providers=[
        contextual(provided_type=Request, scope=Scope.REQUEST),
        scoped(UserService),
    ],
)
class WebModule:
    pass


# FastAPI application setup
app = FastAPI()
application = WakuFactory(WebModule).create()


@app.get('/user-info')
async def get_user_info(request: Request) -> dict[str, str]:
    """Endpoint that uses contextual dependency injection."""
    async with (
        application,
        application.container(
            context={Request: request},
        ) as request_container,
    ):
        # UserService automatically receives the current HTTP request
        user_service = await request_container.get(UserService)
        return user_service.get_user_info()


# Example usage:
# curl -H "user-id: john123" -H "session-id: abc456" http://localhost:8000/user-info

Important

In this example, the contextual provider and waku itself are used to manually inject the current request into the UserService. However, in real-world applications, you should use the Dishka FastAPI integration to inject the request automatically.

This pattern is essential for integrating with web frameworks, message brokers, and other external systems where objects have lifecycles managed outside your application.

Where and how to inject dependencies?

To inject dependencies with waku, you need to:

  1. Register them as providers with the desired scope in modules.
  2. Identify your application entrypoints and decorate them with the @inject decorator for your framework. Consult the Dishka integrations section for your framework to find out how to do this.
  3. Add dependencies as arguments to your entrypoint signature using the Injected type hint.

Next steps

For advanced features and customization options, refer to the Dishka documentation.