Modules and dependency injection

Why Bustan resolves dependencies from a module graph rather than a global registry.

Most Python web frameworks resolve dependencies per-handler: a route declares what it needs and the framework supplies it. That works, and for a small service it is hard to beat.

It stops working when you want to know what depends on what without reading every handler.

The module graph is the answer#

A provider belongs to a module. A module states what it imports and what it exports. Those two lists are the entire visibility rule:

  • A provider is visible inside its own module
  • A provider is visible elsewhere only if it is exported and the other module imports the owner

So the dependency structure is a graph you can read off the declarations. Since 0.0.2 you can also print it:

$ bustan graph
AppModule
├── TaskModule
│   ├── provides  TaskService
│   └── exports   TaskService
└── ReportModule
    ├── imports   TaskModule
    └── provides  ReportService

Why private by default#

Everything-global is more convenient for about three weeks. After that any provider is reachable from anywhere, the graph is complete, and "what breaks if I change this" has no answer short of running everything.

Private-by-default keeps the graph sparse, and adding an edge becomes a deliberate act visible in a diff.

Resolution is constructor-based, and eager#

The container reads constructor annotations. @Injectable() marks a class the container may build, so a missing registration fails at startup rather than mysteriously at request time.

The graph is walked once at startup, providers are constructed in dependency order, and cycles are errors:

BustanResolutionError: circular dependency
  TaskService → ReportService → TaskService

Eager resolution is the point. A framework that resolves lazily turns a structural mistake into an intermittent one.

Scopes, and the rule that surprises people#

0.0.2 adds request scope. With it comes a constraint the container enforces:

A request-scoped provider cannot be injected into a singleton.

If it could, the singleton would capture the first request's instance and hold it forever — a bug that shows up as one user seeing another's data, weeks later, under load. Rejecting it at startup is unkind and correct.

What this costs#

Ceremony. A three-endpoint service is more code in Bustan than in a bare router, and if that is what you are building, the bare router is the better tool.

The trade pays off when a second person joins or a module has to move. Structure is a cost paid early against an unbounded cost later — and if the codebase never gets there, you simply paid.