( Dependency injection )

Real dependency injection for Python web apps

Austial's container resolves constructor type hints recursively. You declare what a class needs in __init__, and the container builds the graph. No tokens, no glue code for the common case.

Inject by type hint

Mark a class with @Injectable(), register it in a module's providers, and ask for it in a constructor. When the controller is created, the container inspects the service's own constructor, resolves its dependencies first, and so on down the graph.

from austial import Controller, Injectable


@Injectable()
class CatsService:
    def __init__(self) -> None:
        self._cats: list[Cat] = []


@Controller("cats")
class CatsController:
    def __init__(self, cats_service: CatsService):
        self.cats_service = cats_service

Scopes

Providers are singletons by default: the first resolution builds and caches the instance, and every later resolution returns the same object. Use Scope.TRANSIENT when you want a fresh instance each time it is resolved.

Austial does not implement a request scope yet. The docs say so plainly, so you do not go looking for it.

Scopes
ScopeBehaviour
Scope.DEFAULTSingleton, one instance for the lifetime of the application
Scope.TRANSIENTA new instance is built every time it is resolved

Custom providers

When a plain class is not enough, a provider can be a dict with a provide key. Bind a token to a fixed value, to another class for swapping implementations per environment, or to the result of a factory function with its own injected dependencies.

providers=[
    {"provide": "API_KEY", "useValue": "super-secret"},
    {"provide": CacheService, "useClass": RedisCacheService},
    {
        "provide": "DATABASE_URL",
        "useFactory": lambda config: config.get("DATABASE_URL", "sqlite+aiosqlite:///./app.db"),
        "inject": [ConfigService],
    },
]

How it differs from FastAPI's Depends()

Depends() resolves functions per request, wired at each route. Austial's container resolves classes through their constructors once, at the application level, so a service is declared in one place and injected wherever its type is requested. The testing module can then build a real graph from any subset of providers.

Global modules and module visibility

A module marked @Global() makes its exports available everywhere without every consumer listing it in imports. austial.config uses this for ConfigModule, and the security, tenancy and jobs modules are global too.

One honest detail: today the container is application-wide and flat. Every module's providers register into one container regardless of imports and exports, so those lists are used for scanning but are not yet enforced as a visibility boundary. Write them correctly anyway, so nothing changes in your code if strict visibility lands.

from austial import Global, Module


@Global()
@Module(providers=[ConfigService], exports=[ConfigService])
class ConfigModule:
    pass

Dynamic modules for configurable providers

A dynamic module computes its imports, controllers and providers at runtime from a factory method. This is how ConfigModule.for_root(), OrmModule.for_root() and the other framework modules take configuration, and it is the pattern to follow for your own reusable modules. The async variants accept a factory with injected dependencies, so a database URL can be read from ConfigService.

OrmModule.for_root_async(
    use_factory=lambda config: {
        "type_": "postgres",
        "url": config.get("DATABASE_URL"),
    },
    inject=[ConfigService],
    entities=[Cat],
)

Frequently asked questions

Does Austial need decorators on every dependency?

No. For the common case, an @Injectable() class registered as a provider and a type-hinted constructor parameter are enough.

Can I swap an implementation in tests?

Yes. The testing module builds a real, fully wired application from a set of controllers and providers, so you choose which implementations are in the graph.

What happens with circular dependencies?

The container detects the cycle during resolution and raises a CircularDependencyError that describes it, instead of recursing forever. The preferred fix is to redesign, for example by moving shared logic into a third provider.

Go deeper in the docs

( Keep reading )

( Click. Copy. Build. )

Seeing “command not found”? Run uv tool update-shell, then open a new terminal. Details