Skip to content

Getting Started

Build a minimal waku app, then extend it into a multi-module project with configuration and multiple services.

Why modules?

A typical Python service — every dependency is a hardcoded import:

services.py
from db import get_session
from config import settings
from notifications import send_email


class UserService:
    def create_user(self, name: str) -> User:
        session = get_session()              # (1)!
        user = User(name=name)
        session.add(user)
        session.commit()
        if settings.SEND_WELCOME_EMAIL:      # (2)!
            send_email(user.email, '...')     # (3)!
        return user
  1. Direct import — how do you test this without a real database?
  2. Global config access — how do you swap settings per environment?
  3. Hidden cross-module dependency — nothing prevents notifications from importing UserService back.

Same functionality — dependencies are injected, boundaries are explicit:

users/services.py
class UserService:
    def __init__(self, session: AsyncSession, notifier: INotifier) -> None:
        self.session = session
        self.notifier = notifier

    async def create_user(self, name: str) -> User:
        user = User(name=name)
        self.session.add(user)
        await self.notifier.notify(user.email, '...')
        return user
users/module.py
@module(
    providers=[scoped(UserService)],               # (1)!
    imports=[DatabaseModule, NotificationModule],   # (2)!
    exports=[UserService],                         # (3)!
)
class UserModule:
    pass
  1. Providers are declared, not imported — swap the DB by changing one provider.
  2. Module imports make dependencies explicit — circular dependencies are caught at startup by validation.
  3. Exports control what other modules can access — the module's public API.

Creating Your First waku Application

Step 1: Create the Basic Structure

Create a new directory for your project and set up your files:

project/
├── app.py
└── services.py

Step 2: Define Your Services

Define a service in services.py:

services.py
1
2
3
class GreetingService:
    async def greet(self, name: str) -> str:
        return f'Hello, {name}!'

Step 3: Create Modules

Define the modules and bootstrap the app in app.py:

app.py
import asyncio

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

from services import GreetingService


@module(
    providers=[scoped(GreetingService)],  # (1)!
    exports=[GreetingService],  # (2)!
)
class GreetingModule:
    pass


@module(imports=[GreetingModule])
class AppModule:
    pass


def bootstrap() -> WakuApplication:  # (3)!
    return WakuFactory(AppModule).create()


async def main() -> None:
    application = bootstrap()

    async with application, application.container() as container:  # (4)!
        svc = await container.get(GreetingService)
        print(await svc.greet('waku'))


if __name__ == '__main__':
    asyncio.run(main())
  1. providers defines which providers this module creates and manages. scoped creates a new instance for each container context entrance.
  2. exports makes providers available to other modules that import this one. Without an export, a provider is only injectable within its own module.
  3. WakuFactory is the composition root — define your module tree once, reuse it across API server, CLI, and workers.
  4. This is for standalone scripts and demos. In real applications, your framework handles container scoping — see the FastAPI tab.
main.py
import contextlib
from collections.abc import AsyncIterator

import uvicorn
from dishka.integrations.fastapi import inject, setup_dishka
from fastapi import FastAPI

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


class GreetingService:
    async def greet(self, name: str) -> str:
        return f'Hello, {name}!'


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


@contextlib.asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    async with app.state.waku:  # (1)!
        yield


app = FastAPI(lifespan=lifespan)
waku_app = WakuFactory(AppModule).create()
app.state.waku = waku_app
setup_dishka(waku_app.container, app)  # (2)!


@app.get('/')
@inject  # (3)!
async def hello(greeting: Injected[GreetingService]) -> dict[str, str]:  # (4)!
    return {'message': await greeting.greet('waku')}


if __name__ == '__main__':
    uvicorn.run(app)
  1. Manages waku lifecycle (extension hooks, startup/shutdown) through FastAPI's lifespan. Dishka handles per-request container scoping automatically.
  2. setup_dishka connects the DI container to FastAPI — dependencies resolve automatically per request.
  3. @inject from Dishka's FastAPI integration enables automatic dependency resolution for this handler.
  4. Injected[Type] marks a parameter for injection. See Framework Integrations for other frameworks.

Step 4: Run Your Application

Run the application with:

python app.py

You should see the output:

Hello, waku!

Creating a More Realistic Application

Now add configuration, multiple modules, and cross-module dependencies.

Step 1: Set Up the Project Structure

Create a more complete project structure:

app/
├── __init__.py
├── __main__.py
├── application.py
├── modules/
│   ├── __init__.py
│   ├── greetings/
│   │   ├── __init__.py
│   │   ├── models.py
│   │   ├── services.py
│   │   └── module.py
│   └── users/
│       ├── __init__.py
│       ├── models.py
│       ├── services.py
│       └── module.py
└── settings.py

Step 2: Add Configuration Module

Define settings and a configuration module:

Tip

Consider using pydantic-settings or similar libraries for settings management in production applications.

app/settings.py
from dataclasses import dataclass
from typing import Literal

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

Environment = Literal['dev', 'prod']


@dataclass(kw_only=True)
class AppSettings:
    environment: Environment
    debug: bool


@module(is_global=True)
class ConfigModule:
    @classmethod
    def register(cls, env: Environment) -> DynamicModule:
        settings = AppSettings(
            environment=env,
            debug=env == 'dev',
        )
        return DynamicModule(
            parent_module=cls,
            providers=[object_(settings)],
        )

is_global=True makes this module's exports available to every module in the app without explicit imports. Without it, every module that needs settings would have to add imports=[ConfigModule]. Learn more: Global Modules.

Step 3: Create Modules

Greeting Module

app/modules/greetings/models.py
1
2
3
4
5
6
7
from dataclasses import dataclass


@dataclass
class Greeting:
    language: str
    template: str
app/modules/greetings/services.py
from app.settings import AppSettings
from app.modules.greetings.models import Greeting


class GreetingService:
    def __init__(self, settings: AppSettings) -> None:
        self.settings = settings
        self.greetings: dict[str, Greeting] = {
            'en': Greeting(language='en', template='Hello, {}!'),
            'es': Greeting(language='es', template='¡Hola, {}!'),
            'fr': Greeting(language='fr', template='Bonjour, {}!'),
        }

    def get_greeting(self, language: str = 'en') -> Greeting:
        greeting = self.greetings.get(language)
        if greeting is not None:
            return greeting
        if not self.settings.debug:
            msg = f'Unsupported language: {language!r}'
            raise ValueError(msg)
        return self.greetings['en']

    def greet(self, name: str, language: str = 'en') -> str:
        greeting = self.get_greeting(language)
        return greeting.template.format(name)

    def available_languages(self) -> list[str]:
        return list(self.greetings.keys())
app/modules/greetings/module.py
from waku import module
from waku.di import singleton

from app.modules.greetings.services import GreetingService


@module(
    providers=[singleton(GreetingService)],
    exports=[GreetingService],
)
class GreetingModule:
    pass

User Module

app/modules/users/models.py
1
2
3
4
5
6
7
8
from dataclasses import dataclass


@dataclass
class User:
    id: str
    name: str
    preferred_language: str = 'en'
app/modules/users/services.py
from app.modules.users.models import User


class UserService:
    def __init__(self) -> None:
        self.users: dict[str, User] = {
            '1': User(id='1', name='Alice', preferred_language='en'),
            '2': User(id='2', name='Bob', preferred_language='fr'),
            '3': User(id='3', name='Carlos', preferred_language='es'),
        }

    def get_user(self, user_id: str) -> User | None:
        return self.users.get(user_id)
app/modules/users/module.py
from waku import module
from waku.di import scoped

from app.modules.users.services import UserService


@module(
    providers=[scoped(UserService)],
    exports=[UserService],
)
class UserModule:
    pass

Step 4: Create the Application Module

Define the root module and bootstrap function:

app/application.py
from waku import WakuApplication, WakuFactory, module

from app.modules.greetings.module import GreetingModule
from app.modules.users.module import UserModule
from app.settings import ConfigModule


@module(
    # Import all top-level modules
    imports=[
        ConfigModule.register(env='dev'),
        GreetingModule,
        UserModule,
    ],
)
class AppModule:
    pass


def bootstrap_application() -> WakuApplication:
    return WakuFactory(AppModule).create()

ConfigModule.register(env='dev') is a dynamic module — it lets you pass parameters at import time, so the module controls how the value becomes a provider. The other modules are imported directly — they don't need parameters.

Tip

The bootstrap function is the composition root — one place to wire the entire module tree. Every entrypoint (API, CLI, worker) calls it.

Step 5: Create the Main Entrypoint

Tip

In production, you would use FastAPI, Litestar, or another framework instead of a standalone script. The double context manager (async with app, app.container()) disappears — your framework's integration handles container scoping per request.

app/__main__.py
import asyncio

from app.application import bootstrap_application
from app.modules.greetings.services import GreetingService
from app.modules.users.services import UserService


async def main() -> None:
    application = bootstrap_application()

    async with application, application.container() as container:
        user_service = await container.get(UserService)
        greeting_service = await container.get(GreetingService)

        for user_id in ['1', '2', '3', '4']:
            user = user_service.get_user(user_id)
            if not user:
                print(f'User {user_id} not found')
                continue
            print(greeting_service.greet(name=user.name, language=user.preferred_language))

        print(f'Available languages: {greeting_service.available_languages()}')


if __name__ == '__main__':
    asyncio.run(main())

Step 6: Run Your Application

python -m app

Expected output:

Hello, Alice!
Bonjour, Bob!
¡Hola, Carlos!
User 4 not found
Available languages: ['en', 'es', 'fr']

Next steps

  1. Understand the Module System in depth
  2. Explore Dependency Injection patterns
  3. Integrate with FastAPI, Litestar, or other frameworks
  4. Add Extensions for lifecycle hooks
  5. Use the Mediator (CQRS) for command/query separation
  6. Build event-driven systems with Event Sourcing

Further reading