The build boundary
ContainerBuilder.build() is the validation and compilation boundary. It first materializes queued subclass and generic discovery rules from the currently live Python classes. It then walks every visible root, specializes generic dependencies, constructs occurrence-specific component trees, evaluates filters, and freezes runtime instructions.
from clean_ioc import ContainerBuilder
builder = ContainerBuilder()
builder.register(Repository, SqlRepository)
builder.register(CreateOrder)
container = builder.build()
No user constructor, factory, generator, or context manager runs during this work. Functions passed explicitly to
derive(...) do run because their concrete values are compiled into the plan.
A successful build therefore establishes the runtime invariant: the Container contains one complete, immutable, structurally valid component plan. Later Python subclasses do not join or invalidate that plan.
For the implementation walkthrough, see How compilation works: blueprint inputs, candidate
selection, occurrence-specific graph construction, executable steps, and the final _PlanSet.
Strict failures
Build fails when a visible plan contains:
- a missing component or scope-slot declaration;
- a circular component path;
- a singleton that captures a scoped component;
- a singleton or scoped component that directly or transitively captures
per_resolutionstate; - an invalid decorator or pre-configuration dependency;
- a factory result annotation that is definitely incompatible with its registered service.
The automatic factory return annotation check reports
factory-return-type-mismatch. It inspects declarations without running factories or analysing their bodies;
missing or indeterminate return-type information does not fail this check.
from clean_ioc import ContainerBuildError
try:
container = builder.build()
except ContainerBuildError as error:
print(error.report.to_text())
The compiler aggregates failures from independent roots into a structured BuildReport. Each issue has a stable code, severity, message, and semantic path. A failed builder remains reusable after the complete report is produced.
A successful runtime exposes the same report as container.build_report. Mark public resolution requests with builder.mark_entrypoint(...) to add reachability warnings and focus graph renderers without weakening whole-container validation. See Compiler tooling for graph manifests, semantic diffs, and CI commands.
Custom graph rules
Use add_validation_rule() to enforce application or organization policy against the complete immutable graph. A rule
is synchronous, receives a per-pass ValidationContext, and returns or yields zero or more BuildIssue values. The
context's graph is the complete CompiledGraph; graph.walk() visits every occurrence with its root and complete
semantic path, including decorators, pre-configurations, collections, configured values, runtime contexts, and scope
slots.
The examples below introduce the core API. See the dedicated custom graph validation cookbook for registration-uniqueness, architecture, metadata, lifespan, decorator, build-argument, AST, bundle, overlay, testing, and CI recipes.
For standard checks, policy helpers and packs create ordinary rules for layers, dependencies, decorators, lifespans, runtime access, tags, and capabilities. A pack accepts a list of rules and per-rule build/validation modes.
This example prevents a domain-layer component from depending directly on infrastructure:
from collections.abc import Iterable
from clean_ioc import BuildIssue, ValidationContext
def enforce_architecture(context: ValidationContext) -> Iterable[BuildIssue]:
graph = context.graph
for visit in graph.walk():
if len(visit.components) < 2:
continue
parent, dependency = visit.components[-2:]
if (
parent.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)
visit.issue() creates an error by default and fills in the matching root and path. Pass
severity=IssueSeverity.warning for an advisory finding. Errors from build rules fail build(); their warnings
appear on the successful runtime's report. All findings participate in the strict-by-default clean-ioc check and
--ignore CODE policies. Prefer an application or organization prefix for custom codes.
Rules that are too expensive for application startup can be limited to validation mode:
The mode argument is typed by the public ValidationRuleMode = Literal["build", "validation"] alias and defaults to
"build". A validation-mode rule is frozen into the graph policy but skipped by build(). clean-ioc check ...
executes build and validate-only rules in their respective phases, which is suitable for CI. The default strict mode
makes every unsuppressed warning fatal. --ignore CODE can suppress warnings from either build or validate-only rules,
but never errors. Pass --no-strict to leave warnings informational without skipping any rules.
For programmatic tooling, request a fresh aggregate report from an already-built container or scope:
This retains the complete stored container.build_report, including build-rule warnings, and adds one fresh execution
of every rule registered with mode="validation". Build rules are not rerun. The method does not mutate the stored
report or raise for a validation error. Exact duplicate findings are removed within the new complete report. Calling it
again performs another pass of validate-only rules.
In a unit test, call report.assert_valid(). It passes valid reports, including warnings alone, and raises
AssertionError with SARIF findings when errors remain. The report automatically
uses the graph it was validated against for source locations and the graph fingerprint.
Inspecting implementation source
context.type_ast(type) lazily extracts and parses an inspectable Python class definition. Results are cached within
the validation context, so every selected rule in one pass shares one parse per concrete type. The returned TypeAst
includes the source filename, original first line, dedented source, and an ast.ClassDef whose line numbers match the
original file.
import ast
from clean_ioc import ValidationContext
def forbid_direct_environment_access(context: ValidationContext):
for visit in context.graph.walk():
inspected = context.type_ast(visit.component.implementation_type)
if inspected is None:
continue
for node in ast.walk(inspected.node):
if (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "os"
and node.func.attr == "getenv"
):
yield visit.issue(
"my-app-direct-environment-access",
f"Direct os.getenv() call at {inspected.filename}:{node.lineno}",
)
builder.add_validation_rule(forbid_direct_environment_access, mode="validation")
Source inspection returns None for built-in, extension, dynamically generated, and otherwise unavailable class
definitions. Each rule decides whether that should be ignored or reported. Treat the returned AST as read-only; copy it
before using a mutating ast.NodeTransformer. The context and its cache are not stored on the resulting container or
graph, and source data never enters manifests or fingerprints. Excluding a source-inspection rule from build mode also
defers its inspection and parsing cost until an explicit validation pass.
Build rules execute only after structural compilation produces a complete graph. They therefore do not run
during builder preview queries or when missing dependencies, cycles, or another structural failure prevent that graph
from existing. They do run alongside complete-graph findings such as a missing marked entry point. Validate-only rules
require a successfully built graph and run only when validation_report() or clean-ioc check is requested. A rule
that raises, returns a non-iterable value, or yields a malformed issue produces validation-rule-error; later rules
still run so the report remains useful.
Rules should be deterministic, side-effect-free, and safe to run again after a failed build. They may inspect
context.graph.build_args, but Clean IoC does not automatically copy those inputs into a report: do not include secrets
in a custom issue's code, message, root, or path.
Builder state after build
A failed build leaves the builder reusable:
try:
builder.build()
except ContainerBuildError:
builder.register(MissingDependency)
container = builder.build()
After a successful build, the builder rejects registration, decoration, pre-configuration, patching, slot declaration,
validation-rule registration, bundle application, and a second build() call. The resulting Container has no
mutation APIs.
Inspecting the static plan
Runtime containers expose their compiled root components:
for component in container.components:
print(component.service_type, component.implementation_type, component.lifespan)
for dependency in component.dependencies:
print(" ", dependency.argument, dependency.service_type)
Component objects are immutable views of static occurrences. A stable component ID identifies its registration; occurrence_id distinguishes the same registration under different parents.
container.graph adds a complete, read-only view that also models configured/default values, runtime contexts,
declared slots, decorators, and pre-configurations. It can render text or Mermaid and produce deterministic, redacted
JSON manifests.
Child composition
new_scope() never validates or compiles because it reuses its parent's frozen plan. new_scope_builder().build() is a
separate strict build boundary for a child overlay. It runs only discovery rules declared on that ScopeBuilder;
inherited root discovery is already frozen and is never rescanned. Custom validation rules are different: an overlay
inherits its parent's policy rules, applies them to the complete recompiled overlay graph, and then runs rules declared
on the ScopeBuilder.