Modules¶
In a typical Python project, any file can import from any other file. This works fine at first, but as the codebase grows, hidden dependencies pile up: services reach into each other's internals, circular imports appear, and nobody can tell what depends on what without reading every file.
Modules solve this by giving your code explicit boundaries. Each module
declares what it provides (providers), what it needs from other modules
(imports), and what it exposes to the outside (exports). Everything else
stays private. waku enforces these boundaries at startup — if a module tries
to use something it didn't import, you get an error, not a silent bug.
graph LR
subgraph ModuleA
direction TB
SA[ServiceA]
end
subgraph ModuleB
direction TB
SB[ServiceB]
end
subgraph AppModule [AppModule · root]
direction TB
end
AppModule -->|imports| ModuleA
AppModule -->|imports| ModuleB
ModuleB -->|imports| ModuleA
ModuleA -.->|exports ServiceA| ModuleB
Module¶
A module is a class annotated with the @module() decorator. This decorator attaches metadata
to the class, which waku uses to construct the application graph.
Group related code into a module — a users module holds the user service and repository, an orders module holds order processing logic. Each module is a self-contained unit with a clear responsibility, making the codebase easier to navigate and reason about as it grows.
Every waku application has at least one module: the root module, also known as the
composition root. This module serves as the starting point for
WakuFactory to build the entire application graph.
| Parameter | Description |
|---|---|
providers |
List of providers for dependency injection |
imports |
List of modules imported by this module |
exports |
List of types or modules exported by this module |
extensions |
List of module extensions for lifecycle hooks |
is_global |
Whether this module is global or not |
Modules encapsulate providers by default — you can only inject providers that are part of the current module or explicitly exported from imported modules. This is the key difference from plain Python imports: if you forget to export a provider, other modules simply can't use it. The exported providers serve as the module's public API.
Note
Encapsulation is enforced by validators, which you can disable at runtime if needed. However, disabling them entirely is not recommended, as they help maintain modularity.
Module Re-exporting¶
Sometimes you want to group several modules behind a single facade. Re-exporting lets a module expose another module's providers to its own consumers — without duplicating any registrations.
graph LR
subgraph UsersModule
US[UserService]
end
subgraph IAMModule [IAMModule · facade]
direction TB
end
subgraph FeatureModule
direction TB
end
IAMModule -->|imports| UsersModule
IAMModule -.->|re-exports| UsersModule
FeatureModule -->|imports| IAMModule
UsersModule -.->|UserService| FeatureModule
Warning
You can only re-export modules, not individual types imported from other modules. To expose an imported type, re-export the entire module that provides it.
Global Modules¶
Some providers are needed everywhere — database connections, configuration, logging.
Adding imports=[InfraModule] to every feature module is tedious and adds noise
without adding information. For these cases, you can mark a module as global.
A global module's exported providers become available to every module in the application
without explicit imports. Set is_global=True in the @module() decorator and register
the module once in the root module:
graph LR
subgraph InfraModule ["InfraModule · global"]
direction TB
DB[DatabaseModule]
end
subgraph AppModule [AppModule · root]
direction TB
end
subgraph UsersModule
direction TB
US[UserService]
end
subgraph OrdersModule
direction TB
OS[OrderService]
end
AppModule -->|imports| InfraModule
AppModule -->|imports| UsersModule
AppModule -->|imports| OrdersModule
InfraModule -.->|"AsyncSession (available everywhere)"| UsersModule
InfraModule -.->|"AsyncSession (available everywhere)"| OrdersModule
With InfraModule imported in the root module, any feature module can inject AsyncSession
without adding DatabaseModule to its own imports.
Note
The root module is always global.
When to use global modules?
Without is_global, every feature module that needs database access must
explicitly imports=[DatabaseModule]. A global module's exports become
available everywhere without explicit imports — like adding to Python's
builtins, but for DI providers.
Warning
Global modules reduce boilerplate but weaken encapsulation. Reserve them for truly cross-cutting infrastructure — database connections, configuration, logging. Feature modules should use explicit imports to keep their dependency graph visible.
Dynamic Module¶
Sometimes a module can't build its providers on its own — it needs a value from the outside: an environment name, a connection string, a list of entities to register. Dynamic modules solve this by accepting parameters at import time and turning them into providers internally.
graph LR
ENV["env='dev'"] -->|parameter| REG["ConfigModule.register()"]
REG -->|creates| DM[DynamicModule]
DM -->|provides| AS[AppSettings]
Then import the dynamic module by calling its register() method:
Why register() instead of passing config directly?
Dynamic modules let you parameterize a module at import time.
ConfigModule.register(env='dev') creates a module instance with that
specific configuration baked in — think of it as a factory method for
modules. The module controls how the config value becomes a provider,
keeping construction logic encapsulated.
You can also make a dynamic module global by setting is_global=True in the DynamicModule
constructor.
Tip
If you need to swap implementations based on a runtime condition (e.g., use Redis in production but in-memory in development), prefer conditional providers over dynamic modules. Dynamic modules are for parameterized construction — passing values into a module to build providers from them.
Note
While you can use any method name instead of register, we recommend sticking with register
for consistency. This mirrors the NestJS convention where forRoot() configures a module
globally and register() configures it per consumer.
Further reading¶
- Providers — provider types and scopes for dependency injection
- Lifecycle Hooks — module and application extension hooks
- Custom Extensions — writing your own module extensions
- Validation — encapsulation rules and how to configure them