Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

Clean IoC

CI PyPI Python License

Clean IoC is a typed dependency-injection container for Python 3.11+. Version 2 separates mutable composition from immutable runtime execution:

  1. Register components with ContainerBuilder.
  2. Call build() to compile the executable dependency plan; use a separate fresh builder’s analyse() for CI checks.
  3. Resolve from the immutable Container or a lightweight Scope.

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 or proxy registrations. BoundaryAlias may optionally project a complete public service type, name, and tag set while the source activation plan and runtime identity remain unchanged. See the boundaries guide.

2.0 release candidate: the compiled API is being finalized for 2.0.0. Please report compatibility issues before the stable release. V1 is not shipped as a parallel public API.

pip install --pre clean_ioc
pip install --pre "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() discovers registrations, binds dependencies and emits an ordinary execution runtime. It runs no validation rules or policy audits. Impossible wiring fails locally; actual runtime ownership, async, resolution and cleanup guards remain enforced.

Use a fresh builder's analyse() for a completed AnalysisResult containing an AnalysisReport, graph or partial analysis, coverage and rule outcomes. Every available rule finishes before return. Reports never replay callbacks. Tests may call result.create_runtime() to execute the already emitted plan without recompilation. Production separately uses a fresh composition factory's build().

Every admitted attempt consumes its builder, including failures, cancellation and budget refusal. Retry with a fresh builder. Runtimes retain no graph, report or composition handle. For future overlays, call retain_composition() before compiling and pass the external handle to ScopeBuilder(parent, composition=handle). Analysis captures this handle automatically. Ordinary scopes need no handle; composition handles have no cleanup lifecycle. See the migration guide.

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
Bind dependencies; analyse policy separately 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 a local ContainerBuildError when binding is impossible. analyse() additionally reports policy findings such as captive lifespans and incompatible annotations, with full or partial diagnostic evidence. Every admitted attempt consumes its builder. Retry with a fresh composition factory.

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)

Custom rules and built-in checks run only during analyse(). Register all policies with the same API:

builder.add_validation_rule(forbid_direct_environment_access)
with builder.analyse() as result:
    result.report.assert_valid()

Errors and incomplete coverage make the report invalid. Warnings remain available for CI policy. Analysis of one configuration does not validate every future input; use the same composition factory and inputs for a separate CI check:

clean-ioc check my_app.composition:application_builder

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.

Policy helpers and packs supply standard validation rules for layers, dependencies, decorators, lifespans, runtime access, tags, and capabilities. PolicyPack("architecture", [rule, expensive_rule]) groups ordinary callbacks for analysis.

Graph inspection

Mark application entry points to focus graph output and reachability analysis:

builder.register(Checkout, root_policy="entrypoint")
analysis = builder.analyse()

print(analysis.report.to_text())
print(analysis.graph.to_mermaid())
analysis.graph.manifest().to_json()
analysis.graph.ownership_report().to_json()
analysis.graph.sharing_report().to_json()

Use root_policy="dependency_only" for a helper that should be retained only when another root needs it. build() omits unused dependency-only registrations from execution. analyse() inspects and validates them without exposing runtime roots. mark_entrypoint() still marks collection and filtered requests.

clean-ioc check my_app.composition:application_builder
clean-ioc graph my_app.composition:application_builder --format json -o dependency-graph.json
clean-ioc check my_app.composition:application_builder --format sarif -o clean-ioc.sarif
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 diff my_app.composition:application_builder dependency-graph.json --fail-on high
clean-ioc matrix my_app.composition:deployment_matrix --format json
clean-ioc explain my_app.composition:application_builder my_app.ports:PaymentGateway

Each target can be a builder, an analysis-built container or scope, or a zero-argument factory returning one. The CLI builds builder targets in analysis mode. A run runtime cannot recover discarded inspection evidence; point the CLI at a fresh composition factory instead.

Graph-change policies classify wiring changes by meaning, risk, and affected entry point. Use analysis.graph.diff(baseline).evaluate(DiffPolicy(...)) in tests, or diff --classify, --fail-on, and --policy in CI. Risk thresholds, denied change kinds, and path allowances permit expected changes while flagging architectural drift.

Build-variant matrices check supported environments, tenants, or feature configurations from fresh builders. They aggregate completed analysis findings, compare each valid variant with a named reference, and support entry-point and graph-drift policies. Use BuildMatrix(...).check().assert_valid() in tests or the matrix command for text, JSON, and SARIF reports.

SARIF reporting exports validation findings with registration sources and dependency code flows for CI review. Structural failures also produce a report; text and JSON retain their existing shapes.

Analysis builds aggregate errors across independent roots; run builds preserve the original failure. 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 remain unversioned for this release candidate; regenerate saved graphs and baselines when the format changes. Schema versioning is planned for a future release. 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. All custom rules use add_validation_rule(rule) and run during analyse() or clean-ioc check, never production build. CLI strictness only controls whether warnings produce a failing exit code; errors fail in strict and non-strict modes. analysis.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. Retain composition explicitly before building the parent:

from clean_ioc import ScopeBuilder

builder = application_builder()
composition = builder.retain_composition()
with builder.build() as parent:
    tenant_builder = ScopeBuilder(parent, composition=composition)
    tenant_builder.register(PaymentGateway, TenantGateway)
    with tenant_builder.build() as tenant_scope:
        tenant_scope.resolve(Checkout)

The handle retains declaration values by identity, so keep it only when further composition is needed.

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 declaration default "auto" means "per_resolution" for ordinary registrations. scope="per_call" instead gives every service method invocation a fresh scoped implementation and dependency graph while callers keep the same service interface. With that scope policy, "auto" becomes "scoped"; other concrete lifespans are invalid. See Scopes. The exported Lifespan name is a Literal type alias for concrete lifespans, 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 analysis policy checks.
  • Build-time generic discovery, generic factory specialization, open-generic fallback, and plan-driven decorator policies.
  • Immutable build inputs with explicit build_arg(...), generic_arg(...), and inject() argument policies.
  • Coordinated first activation across threads and event loops.
  • Bundles targeting one shared ComponentBuilder composition protocol.
  • Synchronous custom graph rules with structured findings, path-aware traversal, lazy type-AST inspection, and separate production build and completed analysis.
  • BenchBro experiments separating build cost, runtime latency, and Python allocations.

Use ManagedProvider[T] with with handle() or AsyncManagedProvider[T] with async with handle() to acquire a frozen dependency plan in a fresh isolated scope and close its scoped resources at block exit. Handles can be retained by singleton consumers. Return materialized data while resources are open. See managed resource providers.

Declare singleton startup intent with WarmupPlan and WarmupTarget, then explicitly call container.warmup(name) or await warmup_async(name). Build remains lazy; startup reports aggregate initialization failures while preserving ordinary singleton caches and cleanup ownership. See declared warm-up plans.

Metadata

Release files for clean_ioc 2.0.0rc5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for clean_ioc 2.0.0rc5
File Size Uploaded
clean_ioc-2.0.0rc5.tar.gz 293.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for clean_ioc 2.0.0rc5
File Interpreter ABI Platform
clean_ioc-2.0.0rc5-py3-none-any.whl Python 3 none any Details

Total release size: 614.5 kB

Release files / clean_ioc-2.0.0rc5.tar.gz

Download URL clean_ioc-2.0.0rc5.tar.gz
Size 293.0 kB
Tags Source
SHA-256 checksum
How to use checksums
7b3be220171a2f26e30d6bbac490643d1a28dbb4bf50e5b074903e87c7fc62d9
BLAKE2b-256 checksum
How to use checksums
b723cf729023a23eb9709b9ffe2f7fc6a038f66b9eac5f1dd29f91641f455b99
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","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.0rc5-py3-none-any.whl

Download URL clean_ioc-2.0.0rc5-py3-none-any.whl
Size 321.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c4160211f1b0d6b19a3887b6e07d92f4934cd08b5eae709b593f190615ca8446
BLAKE2b-256 checksum
How to use checksums
f316ab4d49f0577d9bd794aa4552c1cf70db307f536abac52d963800a4f36fa3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","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 history Release notifications | RSS feed

This release

2.0.0rc5 This release

2 release files

1.24.0

2 release files

1.22.0

2 release files

1.19.0

2 release files

1.18.0

2 release files

1.17.0

2 release files

1.16.1

2 release files

1.16.0

2 release files

1.15.0

2 release files

1.13.0

2 release files

1.12.0

2 release files

1.11.0

2 release files

1.10.0

2 release files

1.9.0

2 release files

1.8.1

2 release files

1.8.0

2 release files

1.7.0

2 release files

1.6.0

2 release files

1.5.1

2 release files

1.5.0

2 release files

1.4.3

2 release files

1.4.2

2 release files

1.4.1

2 release files

1.4.0

2 release files

1.3.0

2 release files

1.2.0

2 release files

1.1.0

2 release files

1.0.0

2 release files

0.18.0

2 release files

0.16.1

2 release files

0.16.0

2 release files

0.14.0

2 release files

0.13.0

2 release files

0.11.0

2 release files

0.10.0

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

0.0.6

2 release files

0.0.5

2 release files

0.0.4

2 release files

0.0.3

2 release files

0.0.2

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page