This release is a pre-release and may not be stable for production use.
Clean IoC
Clean IoC is a typed dependency-injection container for Python 3.11+. Version 2 separates mutable composition from immutable runtime execution:
- Register components with
ContainerBuilder. - Call
build()to validate and compile every visible dependency plan. - Resolve from the immutable
Containeror a lightweightScope.
Constructors, factories, generators, and context managers do not run during the build. Explicit derive(...) argument
policies do run at build time because their concrete results become part of the frozen plan. At runtime, Clean IoC
executes the compiled activation instructions and maintains lifespan caches and cleanup state. It does not rebuild the
dependency graph during resolution.
The compiled graph is also an application policy surface. Custom validation rules can enforce architecture, registration conventions, required decorators, metadata, and even source-level AST rules before runtime.
For larger compositions, opt-in boundaries make bundle registrations private by default. Explicit Expose and Use
declarations turn cross-feature dependencies into a compiler-validated architecture contract without introducing
runtime child containers, proxies, or aliases. See the boundaries guide.
2.0 beta: the compiled API remains subject to breaking changes while the V2 surface is finalized. V1 is not shipped as a parallel public API.
pip install clean_ioc
pip install "clean_ioc[fastapi]" # optional FastAPI integration
Minimal example
Application code uses ordinary Python types:
from typing import Protocol
from clean_ioc import ContainerBuilder
class PaymentGateway(Protocol):
def charge(self, amount: int) -> str: ...
class StripeGateway:
def charge(self, amount: int) -> str:
return f"charged:{amount}"
class Checkout:
def __init__(self, gateway: PaymentGateway):
self.gateway = gateway
def place_order(self, amount: int) -> str:
return self.gateway.charge(amount)
builder = ContainerBuilder()
builder.register(PaymentGateway, StripeGateway, lifespan="singleton")
builder.register(Checkout)
container = builder.build()
checkout = container.resolve(Checkout)
assert checkout.place_order(2500) == "charged:2500"
Application types do not require Clean IoC base classes or decorators. The container remains at the composition root, and activation returns ordinary Python objects rather than generated proxies.
Build and runtime model
ContainerBuilder.build() performs registration discovery, contextual selection, structural validation, and activation-plan
compilation at an explicit application boundary.
| Build time | Runtime |
|---|---|
| Specialize generic types | Select a frozen root plan |
| Build occurrence-specific component trees | Execute precompiled activation steps |
Evaluate filters and explicit build_args |
Cache plain instances by lifespan |
| Detect missing, circular, and captive dependencies | Coordinate concurrent scoped/singleton builds |
| Freeze decorators, pre-configurations, and argument policies | Track only activation and teardown state |
Application-defined build arguments make environment-dependent composition explicit without turning those inputs into runtime services:
container = builder.build(
build_args={"environment": "production", "mode": "live"},
)
Derived argument policies and component filters can inspect the immutable mapping during compilation. The chosen wiring is frozen, while graph manifests and reports omit build-argument names and values.
build() raises ContainerBuildError if a graph is incomplete, a singleton captures scoped state, or a singleton or
scoped component captures per_resolution state. Lifespan checks are transitive, including dependencies reached through
transient components. A failed build leaves the builder reusable. A builder becomes immutable and single-use after a
successful build.
Make architecture executable
Clean IoC lets applications and libraries add their own validation rules to the build boundary. A rule receives the complete immutable graph—not just one constructor—and can report structured errors at the exact dependency path where an application-specific policy is broken.
This rule prevents domain code from depending directly on infrastructure code:
from collections.abc import Iterable
from clean_ioc import BuildIssue, ValidationContext
def enforce_architecture(context: ValidationContext) -> Iterable[BuildIssue]:
for visit in context.graph.walk():
if len(visit.components) < 2:
continue
owner, dependency = visit.components[-2:]
if (
owner.implementation_type.__module__.startswith("my_app.domain")
and dependency.implementation_type.__module__.startswith("my_app.infrastructure")
):
yield visit.issue(
"my-app-domain-depends-on-infrastructure",
"Domain components cannot depend directly on infrastructure components",
)
builder.add_validation_rule(enforce_architecture)
Build-mode custom errors fail build() alongside Clean IoC's built-in missing, circular, and captive-dependency checks.
Custom warnings flow into the same BuildReport. Rules can inspect service and implementation types, names, tags,
lifespans, decorators, scope slots, configured values, build arguments, and complete root-to-occurrence paths.
Expensive rules can be kept out of application startup:
builder.add_validation_rule(forbid_direct_environment_access, mode="validation")
This is recommended for source inspection, AST parsing, and other expensive analysis, provided the full validation suite is mandatory in tests or CI. Build rules protect every startup; validate-only rules trade that startup guarantee for lower application-build cost. A CLI check runs each set once: build rules while constructing the container, then validate-only rules while creating the complete report.
Validate-only rules still run under the CLI check, making source and architecture analysis practical in CI:
clean-ioc check my_app.composition:application_builder
Unit tests can run the same complete rule suite without activating application components:
container = builder.build()
report = container.validation_report()
assert report.is_valid, report.to_text()
Build rules are not rerun in this validation pass. Their stored findings remain in the complete report, followed by fresh findings from validate-only rules.
See the custom graph validation guide for recipes covering duplicate registrations, architecture layers, metadata and lifespan conventions, required decorators, AST inspection, environment-specific composition, reusable rule factories, bundles, overlays, warnings, and CI policy.
Graph inspection
Mark application entry points to focus graph output and reachability analysis:
builder.mark_entrypoint(Checkout)
container = builder.build()
print(container.build_report.to_text())
print(container.graph.to_mermaid())
container.graph.manifest().to_json()
container.graph.ownership_report().to_json()
container.graph.sharing_report().to_json()
clean-ioc check my_app.composition:application_builder
clean-ioc graph my_app.composition:application_builder --format json -o dependency-graph.json
clean-ioc ownership my_app.composition:application_builder --format json
clean-ioc sharing my_app.composition:application_builder --format json
clean-ioc diff my_app.composition:application_builder dependency-graph.json
clean-ioc explain my_app.composition:application_builder my_app.ports:PaymentGateway
Each target can be a builder, a built container or scope, or a zero-argument factory function returning one.
Build errors are aggregated across independent roots. Deterministic JSON manifests omit configured values and runtime
identities, allowing wiring changes to be reviewed without serializing secrets. Entry points focus the default graph and
enable warnings for unreachable registrations; every visible root is still compiled, validated, and resolvable.
Manifests record the compiled cache and cleanup owner for every occurrence. Tooling JSON formats are unversioned
during beta; regenerate saved graphs and baselines when the format changes. Schema versioning will begin after beta.
Cleanup-bearing transients retained by singletons are promoted to the singleton's declaring owner;
ownership reports explain that decision without exposing runtime tokens or values.
Sharing reports are static eligibility reports: they group occurrences by their compiled cache identity without
activating them or claiming factory results are distinct. They explain per-resolution, scope, singleton, transient,
and supplied-instance semantics, and identify occurrence plans that can compete to initialize one cache.
Expensive custom rules can be registered with mode="validation", keeping their graph or source-AST inspection out
of application startup while still running under clean-ioc check in CI. CLI strictness only controls whether warnings
produce a failing exit code; errors fail in strict and non-strict modes.
container.graph.explain(...) and clean-ioc explain show the recorded selected and rejected candidates, stable reason
codes, bundle paths, and best-effort declaration locations without adding provenance to manifests or fingerprints.
Component model
Component is the immutable, plan-backed model used for registrations, dependency occurrences, filters, and graph
inspection. It exposes the service, implementation, lifespan, name, tags, generic mapping, parent, dependencies,
decorators, and pre-configurations.
import clean_ioc.component_filters as cf
builder.register(PaymentGateway, StripeGateway, name="stripe")
component_id = builder.get_component_id(
PaymentGateway,
filter=cf.with_name("stripe"),
)
The same filter API applies to root selection, dependency selection, contextual registration, decorators, and pre-configuration:
builder.register(
PaymentGateway,
StripeGateway,
when=cf.parent(cf.has_tag("channel", "web")),
)
gateway = container.resolve(PaymentGateway, filter=cf.with_name("stripe"))
Composition, dependency, decorator, and pre-configuration filters run while the container or scope is built. Their
decisions are frozen and are not repeated during resolution. A filter passed directly to resolve(...) selects among
already-compiled root plans.
Pre-configurations are compiled as lazy singleton initializers. Their dependency paths are validated during build. Shared
targets run one definition in declaration order, and concurrent first resolutions join the same attempt. Optional
failures can be logged and suppressed with continue_on_failure=True; other failures remain retryable.
Scopes, provided values, and overlays
An ordinary scope reuses the compiled plan:
builder.declare_scope_slot(RequestContext)
builder.register(RequestHandler)
container = builder.build()
with container.new_scope() as scope:
scope.provide(RequestContext, current_request)
handler = scope.resolve(RequestHandler)
Slots represent values that are unavailable during root compilation, such as request or framework context. Only declared slots may be provided. Duplicate provisions are rejected, and provisions lock when resolution starts. Nested scopes inherit provided values and may override them before their first resolve.
Use ScopeBuilder when a child scope requires different registrations or decorators:
tenant_builder = container.new_scope_builder()
tenant_builder.register(PaymentGateway, TenantGateway)
with tenant_builder.build() as tenant_scope:
tenant_scope.resolve(Checkout)
Singletons introduced by a ScopeBuilder belong to its built scope and descendants. Existing root singletons remain
anchored to the root container and cannot be rewired by overlay dependencies or decorators. A built overlay starts a
new scoped cache boundary and is finalized when that scope exits. The root container is not mutated.
Lifespans and ownership
| Lifespan | Reuse boundary | Typical ownership |
|---|---|---|
transient |
Every dependency edge | Context-sensitive objects |
per_resolution |
One top-level resolve | Ordinary application services |
scoped |
One explicit scope | Request state, units of work, DB sessions |
singleton |
Owning container or compiled overlay scope | Settings, pools, long-lived clients |
Pass these as plain strings to lifespan=. The exported Lifespan name is a Literal type alias for annotations, not an enum.
Generator factories, context managers, and their async equivalents are finalized by their cache owner.
ASGI integration
The dependency-free ASGI extension owns the container for the application lifespan and one ordinary child scope for each complete HTTP request or WebSocket connection:
from clean_ioc.ext.asgi import CleanIocMiddleware, get_scope
async def application(asgi_scope, receive, send):
handler = await get_scope(asgi_scope).resolve_async(RequestHandler)
await handler(asgi_scope, receive, send)
app = CleanIocMiddleware(application, root_scope=container)
Routing remains application or framework code. See the
minimal health server, which implements /health/liveness, /health/readiness, and
/health/startup as example routes rather than extension behavior.
FastAPI integration
FastAPI remains responsible for HTTP parameters, validation, and security dependencies. Resolve is the route-level
equivalent of Depends for an application entry point compiled by Clean IoC:
from fastapi import FastAPI
from clean_ioc import ContainerBuilder
from clean_ioc.ext.fastapi import Resolve, install_fastapi
builder = ContainerBuilder()
builder.register(OrderRepository, SqlOrderRepository, lifespan="scoped")
builder.register(PlaceOrder)
container = builder.build()
app = FastAPI()
install_fastapi(app, container)
@app.post("/orders")
async def place_order(command: OrderRequest, handler: PlaceOrder = Resolve(PlaceOrder)):
return await handler(command)
Native FastAPI supports nested dependency chains and caches repeated dependency callables within a request. For
framework-independent application classes, those chains require provider functions at each layer. Clean IoC derives the
application graph from ordinary constructor annotations and keeps only Resolve(EntryPoint) at the route boundary.
| Requirement | FastAPI with Clean IoC |
|---|---|
| Route-level application dependency | service: Service = Resolve(Service) |
| Request-owned component | lifespan="scoped" |
| Application-owned component | lifespan="singleton" |
| Shared value within one resolution | lifespan="per_resolution" |
| Invalid component or lifespan graph | ContainerBuildError before activation |
The integration creates an ordinary child scope for each complete HTTP request or WebSocket connection. Streaming responses, background work, and cleanup remain inside that boundary. FastAPI route selections are checked against the compiled container during application startup.
Composition features
- Sync and async factories, generators, context managers, and deterministic cleanup.
- Named, tagged, parent-aware, and descendant-aware component filters.
- Z-indexed decorators with stable IDs, builder patch/removal, owned metadata, and build-time validation.
- Build-time generic discovery, generic factory specialization, open-generic fallback, and plan-driven decorator policies.
- Immutable build inputs with explicit
build_arg(...),generic_arg(...), andinject()argument policies. - Coordinated first activation across threads and event loops.
- Bundles targeting one shared
ComponentBuildercomposition protocol. - Synchronous custom graph rules with structured findings, path-aware traversal, lazy type-AST inspection, and separate build and validation execution.
- BenchBro experiments separating build cost, runtime latency, and Python allocations.
Project links
Release files for clean_ioc 2.0.0b14
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| clean_ioc-2.0.0b14.tar.gz | 142.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| clean_ioc-2.0.0b14-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 300.6 kB
Release files / clean_ioc-2.0.0b14.tar.gz
| Download URL | clean_ioc-2.0.0b14.tar.gz |
|---|---|
| Size | 142.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9128ff355a0b3b94bd7b77dc6c6045b4d6dabf269489700dad6eb0098a6f18b3
|
|
BLAKE2b-256 checksum How to use checksums |
0554aa3cb2ceb8f92d278d80016dab5eda1044ec47a12aaec9c10f43349ec05e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / clean_ioc-2.0.0b14-py3-none-any.whl
| Download URL | clean_ioc-2.0.0b14-py3-none-any.whl |
|---|---|
| Size | 158.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
efbd9638146d04e6c9150aaa4f048a68c844a6a4fac854460ae2167a198b8516
|
|
BLAKE2b-256 checksum How to use checksums |
1969981027ae1bfc17da03d339ee84ef2d8ab07eeaddf85f42514616698a1415
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.17 {"installer":{"name":"uv","version":"0.12.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|