Custom graph validation
Clean IoC compiles the complete dependency graph before runtime. Custom validation rules turn that graph into an application-specific policy boundary: invalid architecture, missing conventions, and suspicious implementation details can be reported before any component is activated.
A rule is a synchronous callable that receives a ValidationContext and returns or yields BuildIssue values:
from collections.abc import Iterable
from clean_ioc import BuildIssue, ValidationContext
def validate_graph(context: ValidationContext) -> Iterable[BuildIssue]:
for visit in context.graph.walk():
if violates_policy(visit.component):
yield visit.issue(
"my-app-policy",
"The component violates an application policy",
)
builder.add_validation_rule(validate_graph)
visit.issue(...) creates an error by default and fills in the root and complete semantic path. The resulting finding
uses the same reports, JSON output, CLI exit codes, and warning suppression as Clean IoC's built-in findings.
Important
Clean IoC's built-in missing-component, circular-dependency, captive-dependency, lifespan, decorator, and
pre-configuration checks always run during build(). Setting mode="validation" on a custom rule never
disables those startup safety checks.
When rules run
Clean IoC has two rule execution modes. CLI strictness is a separate decision about warning severity; it does not select which rules run.
The public ValidationRuleMode type is Literal["build", "validation"]. Every custom rule belongs to exactly one mode,
selected when it is registered:
builder.add_validation_rule(required_at_startup) # mode="build" is the default
builder.add_validation_rule(expensive_policy, mode="validation")
The values are case-sensitive literal strings; boolean flags and any value other than "build" or "validation" raise
ValueError during registration.
| Rule | During build() |
During validation_report() |
|---|---|---|
| Built-in structural checks | Always | Prior findings retained; not rerun |
mode="build" custom rule (default) |
Runs | Prior findings retained; not rerun |
mode="validation" custom rule |
Skipped | Runs |
Build mode protects application startup
ContainerBuilder.build() runs built-in structural checks and every custom rule whose mode is "build". This is the
default so an application that never runs separate validation does not silently miss its custom
policies. An error from this subset raises ContainerBuildError; a warning is retained in container.build_report
without preventing startup.
Custom rules run only after structural compilation has produced a complete graph. For example, a missing dependency or dependency cycle can prevent that graph from existing and stop the build before custom rules run. Once the graph does exist, built-in complete-graph findings—such as an unreachable component or missing marked entry point—are aggregated with findings from build rules.
Use the default for inexpensive rules that must hold whenever the application starts:
Use mode="validation" when a rule is too expensive for startup or is intended only for deliberate test and CI
validation:
Validation mode runs validate-only rules
An already-built container or scope can run its validate-only rules and return a complete report:
Validation mode does not rerun build rules: the container already passed their error checks during build().
It runs every rule registered with mode="validation" and returns their errors in the report instead of raising.
Each call creates a new shared ValidationContext for those validate-only rules, so they must be deterministic,
side-effect-free, and safe to run repeatedly.
The complete report starts with every stored container.build_report finding, including built-in findings and warnings
from build rules, then adds the new validate-only results. Exact duplicate findings are removed across those two sources
within the new report, and the stored build report is never mutated.
Strictness controls warnings, not rule execution
For a successfully built target, clean-ioc check always runs validation mode. It is strict by default, so unsuppressed
errors and warnings produce a non-zero exit status. --no-strict uses the same stored build findings and runs the same
validate-only rules, but only errors produce a non-zero status. --ignore CODE may suppress a selected warning; errors
cannot be ignored.
An ignored warning is removed from the CLI's displayed report as well as from its exit-status decision. The underlying
container report is unchanged.
clean-ioc check my_app.composition:application_builder
clean-ioc check my_app.composition:application_builder --ignore my-app-missing-owner
clean-ioc check my_app.composition:application_builder --no-strict
Choose the execution mode by use case
| Use case | Registration | Reason |
|---|---|---|
| Fast invariant required by every application instance | Default | An error stops an unsafe container from starting |
| Cheap advisory visible at startup | Default rule yielding a warning | The warning is retained without failing build() |
| Expensive whole-graph or source-AST analysis | mode="validation" |
Keeps the startup path fast while retaining CI coverage |
| Policy meaningful only to architecture tests | mode="validation" |
Runs through validation_report() and clean-ioc check |
| Reusable library policy that consumers might not check separately | Default | Avoids depending on downstream CI configuration |
Recommended trade-off
Keep cheap graph-metadata checks in build mode so every application instance is protected, even when a separate
validation command is accidentally skipped. Prefer mode="validation" for rules that read source files,
parse ASTs, or perform other expensive whole-graph analysis, and make clean-ioc check or an equivalent
validation_report() test mandatory in CI.
A build rule is paid for on every application build but is not rerun by full validation. A validate-only rule is paid
for only during deliberate validation, but application startup alone will not enforce it. For a composition target,
clean-ioc check first builds the container and then runs validation, so build rules and validate-only rules each run
once. If an expensive rule protects runtime correctness rather than an architectural convention, keep it in build
mode or split it into a cheap startup invariant and a deeper validate-only rule.
What a rule can inspect
context.graph is the complete immutable CompiledGraph. Its walk() method traverses every compiled root in stable
depth-first order, even when entry-point markers focus graph rendering on a smaller public surface.
Each GraphVisit exposes:
| Field | Meaning |
|---|---|
root |
The compiled root occurrence and requested type |
component |
The current immutable Component occurrence |
components |
Every component from the root to the current occurrence |
root_name |
The diagnostic name of the requested root |
path |
The same semantic path used by built-in findings |
issue(...) |
A BuildIssue located at this occurrence |
The current component exposes its service and implementation types, stable registration ID, occurrence ID, name, tags, lifespan, kind, activation kind, decorator position, dependencies, decorators, pre-configurations, async requirement, and cleanup behavior.
One registration may occur beneath several roots or parents. Use graph.walk() directly when the path matters. For a
policy that should inspect each registration only once, deduplicate by component.id:
from collections.abc import Iterator
from clean_ioc import ComponentKind, GraphVisit, ValidationContext
def unique_registration_visits(context: ValidationContext) -> Iterator[GraphVisit]:
seen: set[str] = set()
for visit in context.graph.walk():
component = visit.component
if component.kind is not ComponentKind.registration or component.id in seen:
continue
seen.add(component.id)
yield visit
Recipe: enforce architecture boundaries
The most direct architecture rule examines each dependency edge. In a visit path, the final two components are the owner and the dependency used at that occurrence:
from collections.abc import Iterable
from clean_ioc import BuildIssue, ValidationContext
def forbid_domain_to_infrastructure(context: ValidationContext) -> Iterable[BuildIssue]:
for visit in context.graph.walk():
if len(visit.components) < 2:
continue
owner, dependency = visit.components[-2:]
owner_module = owner.implementation_type.__module__
dependency_module = dependency.implementation_type.__module__
if owner_module.startswith("my_app.domain") and dependency_module.startswith("my_app.infrastructure"):
yield visit.issue(
"my-app-domain-depends-on-infrastructure",
"Domain components cannot depend directly on infrastructure components",
)
Because the issue is created from the dependency visit, a report points to the exact root-to-dependency path that broke the boundary. The same registration can legitimately pass beneath one parent and fail beneath another.
Recipe: reject duplicate service names
Clean IoC permits multiple registrations for one service. An application can impose the stronger convention that each
(service_type, name) pair identifies only one registration. Tracking stable component IDs avoids treating repeated
occurrences of the same registration as duplicates:
from collections.abc import Iterable
from clean_ioc import BuildIssue, ComponentKind, ValidationContext
def require_unique_service_names(context: ValidationContext) -> Iterable[BuildIssue]:
registrations: dict[tuple[object, str | None], set[str]] = {}
for visit in context.graph.walk():
component = visit.component
if component.kind is not ComponentKind.registration:
continue
key = (component.service_type, component.name)
component_ids = registrations.setdefault(key, set())
if component.id in component_ids:
continue
if component_ids:
name = component.name if component.name is not None else "<default>"
yield visit.issue(
"my-app-duplicate-service-name",
f"More than one registration uses the {name!r} name for this service type",
)
component_ids.add(component.id)
The first registration is retained as the accepted definition; each later distinct registration reports its own path.
Clean IoC deduplicates exact duplicate BuildIssue values within each independently constructed report while preserving
the first occurrence.
Recipe: enforce lifespan and ownership metadata
Tags make organization-specific ownership visible to both filters and validation. This example requires database adapters to be singletons and warns when they have no owning-team tag:
from collections.abc import Iterable
from clean_ioc import BuildIssue, IssueSeverity, ValidationContext
def validate_database_adapters(context: ValidationContext) -> Iterable[BuildIssue]:
for visit in unique_registration_visits(context):
component = visit.component
if not component.implementation_type.__module__.startswith("my_app.infrastructure.database"):
continue
if component.lifespan != "singleton":
yield visit.issue(
"my-app-database-lifespan",
"Database adapters must use the singleton lifespan",
)
if not component.has_tag("owner"):
yield visit.issue(
"my-app-missing-owner",
"Database adapters should declare an owner tag",
severity=IssueSeverity.warning,
)
Register metadata at composition time:
from clean_ioc import Tag
builder.register(
OrderRepository,
SqlOrderRepository,
lifespan="singleton",
tags=(Tag("owner", "payments"),),
)
Recipe: require a policy decorator
A boundary tag can identify components that must have an authorization, audit, retry, or tracing decorator. Decorator metadata is available from the exact compiled pipeline, after all filters and build arguments have selected it:
from collections.abc import Iterable
from clean_ioc import BuildIssue, ValidationContext
def require_authorization(context: ValidationContext) -> Iterable[BuildIssue]:
for visit in unique_registration_visits(context):
component = visit.component
if not component.has_tag("boundary", "http"):
continue
if any(decorator.has_tag("policy", "authorization") for decorator in component.decorators):
continue
yield visit.issue(
"my-app-missing-authorization",
"HTTP boundary components must have an authorization decorator",
)
The policy is decoupled from a particular decorator class:
from clean_ioc import Tag
builder.register(CheckoutHandler, tags=(Tag("boundary", "http"),))
builder.register_decorator(
CheckoutHandler,
AuthorizeCheckout,
decorated_arg="handler",
tags=(Tag("policy", "authorization"),),
)
Recipe: flag risky cleanup policy
Component activation metadata can support operational conventions. This advisory rule highlights transient resources that manage cleanup, allowing a team to review whether a wider ownership boundary would be safer:
from collections.abc import Iterable
from clean_ioc import BuildIssue, IssueSeverity, ValidationContext
def warn_about_transient_resources(context: ValidationContext) -> Iterable[BuildIssue]:
for visit in unique_registration_visits(context):
component = visit.component
if component.lifespan == "transient" and component.manages_cleanup:
yield visit.issue(
"my-app-transient-resource",
"A transient component owns cleanup; confirm that this is intentional",
severity=IssueSeverity.warning,
)
Warnings remain visible in container.build_report without stopping application startup. The strict CLI treats an
unsuppressed warning as a failure, so teams can adopt advisory rules gradually.
Recipe: validate environment-specific composition
Rules can inspect immutable build arguments and the graph selected by them. This example prevents fake adapters in a production build without revealing the value of any build argument in the issue:
from collections.abc import Iterable
from clean_ioc import BuildIssue, ValidationContext
def forbid_production_fakes(context: ValidationContext) -> Iterable[BuildIssue]:
if context.graph.build_args.get("environment") != "production":
return
for visit in unique_registration_visits(context):
if visit.component.has_tag("test-double"):
yield visit.issue(
"my-app-production-test-double",
"Production composition contains a test double",
)
Build arguments are never copied into findings automatically. Rule authors should avoid putting credentials, tokens, or other input values in issue messages, roots, paths, or codes.
Recipe: inspect implementation ASTs
context.type_ast(type) lazily reads and parses an inspectable Python class. Results are cached once per concrete type
for the current validation pass and shared by all rules in that pass. Source is not attached to the compiled graph,
runtime, manifest, or fingerprint.
AST inspection performs filesystem reads and parsing, so it is normally best registered with
mode="validation" and enforced by the full validation suite in tests or CI. Register it for build mode only when
the policy must block every application startup and that guarantee is worth the added startup cost.
This validate-only rule finds direct os.getenv(...) calls inside component classes:
import ast
from collections.abc import Iterable
from clean_ioc import BuildIssue, ValidationContext
def forbid_direct_environment_access(context: ValidationContext) -> Iterable[BuildIssue]:
for visit in unique_registration_visits(context):
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"Use injected settings instead of os.getenv at {inspected.filename}:{node.lineno}",
)
builder.add_validation_rule(forbid_direct_environment_access, mode="validation")
type_ast() returns None for built-in, extension, dynamically generated, or otherwise unavailable types. Each rule
decides whether missing source should be accepted or reported. Treat the returned AST as read-only; copy it before
using a mutating ast.NodeTransformer.
Recipe: build reusable parameterized rules
A rule factory can package the same policy for several boundaries or applications:
from collections.abc import Iterable
from clean_ioc import BuildIssue, ValidationContext, ValidationRule
def forbid_module_dependency(
owner_prefix: str,
dependency_prefix: str,
*,
code: str,
) -> ValidationRule:
def validate(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(owner_prefix) and (
dependency.implementation_type.__module__.startswith(dependency_prefix)
):
yield visit.issue(
code,
f"{owner_prefix} cannot depend directly on {dependency_prefix}",
)
return validate
builder.add_validation_rule(
forbid_module_dependency(
"my_app.domain",
"my_app.infrastructure",
code="my-app-domain-infrastructure-boundary",
)
)
Custom codes must be non-empty strings. Built-in codes are not reserved, but an application or organization prefix
makes ownership and --ignore CODE policy clearer.
Package rules in bundles
Bundles can install policy beside reusable composition because add_validation_rule() is part of the shared
ComponentBuilder protocol:
from clean_ioc import ComponentBuilder
from clean_ioc.bundles import BaseBundle
class OrganizationPolicyBundle(BaseBundle):
def apply(self, builder: ComponentBuilder) -> None:
builder.add_validation_rule(require_unique_service_names)
builder.add_validation_rule(forbid_direct_environment_access, mode="validation")
Rules registered on a root builder are frozen into its policy. A ScopeBuilder inherits those rules and applies build
rules parent-first to the complete recompiled overlay graph, followed by build rules declared on the child builder.
Ordinary new_scope() calls reuse the prior graph and report without rerunning rules. Calling validation_report() on
that new scope runs the validate-only rules parent-first against the shared graph and retains the stored build findings.
Test the full suite without starting the application
Build the immutable plan, then request the complete validation report. The first call runs build rules; the second runs validate-only rules and combines their findings with the stored build report. Components are compiled but not activated:
def test_architecture_policy(application_builder):
container = application_builder.build()
report = container.validation_report()
report.assert_valid()
report.assert_valid() checks report.is_valid and raises AssertionError with a SARIF document when errors remain.
Container-created reports include captured registration sources and dependency paths automatically; see
SARIF reporting.
Warnings alone pass. To apply the CLI's default strict policy in a unit test, reject warnings too:
If a built-in or build rule reports an error, no container is returned. Test that earlier boundary through
ContainerBuildError.report:
import pytest
from clean_ioc import ContainerBuildError
def test_invalid_composition_is_rejected(invalid_builder):
with pytest.raises(ContainerBuildError) as raised:
invalid_builder.build()
assert "my-app-layer-boundary" in {issue.code for issue in raised.value.report.errors}
CI can use the same composition target as production without activating application components:
- name: Validate dependency graph and architecture
run: clean-ioc check my_app.composition:application_builder
The CLI target may be a builder, built container or scope, or a zero-argument factory returning any of them.
If the target cannot build, check reports that build failure; validate-only rules require a successfully built graph
and cannot run in that invocation.
Rule behavior and failure handling
Build rules run in declaration order after built-in complete-graph findings. Validate-only rules run in declaration order when a validation report is requested. Parent rules precede overlay rules within either phase. The complete report lists stored build findings before validate-only findings; exact duplicates are removed while the first occurrence retains its position.
Each yielded value is validated. If a rule raises, returns a non-iterable, returns an awaitable, or yields a malformed
issue, Clean IoC records a validation-rule-error, stops that rule, retains any issues it already yielded, and continues
with later rules. Asynchronous rules are rejected during registration.
Rules should be deterministic and side-effect-free. Structural compilation failures do not invoke custom rules because no honest complete graph exists. Failed builders remain reusable. Each explicit validation report performs a new pass of validate-only rules without rerunning build rules.