How compilation works
Clean IoC 2 separates deciding how to construct objects from constructing them. ContainerBuilder.build() resolves
the composition decisions and stores their results as reusable execution plans. Later, resolve() follows those plans
and maintains instance caches and cleanup state.
The compiler's output is an in-memory _PlanSet: executable dependency plans, an inspectable component graph, root
lookup tables, and diagnostic metadata. It contains live Python references to types, callables, and configured values.
It is not generated source code, bytecode, or a graph JSON file loaded back into the runtime.
This guide explains the implementation for readers who want to understand or maintain the compiler, particularly those familiar with V1. Underscore-prefixed types and methods are private implementation details and may change during the V2 release candidate. Use the build boundary and compiler tooling guides for the public API.
From V1 resolution to V2 compilation
In V1, resolution combined candidate lookup, contextual selection, dependency-node creation, object activation,
decoration, and caching. _Registration.build() created a DependencyNode, found applicable pre-configurations,
resolved arguments, activated the object, found decorators, and cached the resulting node.
V2 computes the wiring decisions at build time and preserves them as executable steps:
V1
register → resolve → select registrations → construct dependency nodes
→ activate objects → decorate → cache
V2
register → build → select and validate wiring → freeze graph and steps
│
resolve → follow steps → activate and cache
The original activation abstractions remain useful. V2 still uses internal registration, dependency, and activator
machinery from _legacy.py. It does not run the V1 resolver that constructs runtime dependency nodes.
Compilation does not eagerly construct application objects. Constructors, factories, generators, and context managers
run during activation. Explicit composition callbacks do run during build: filters, derive(...) functions,
provider-map key functions, decorator-template source filters and factories, and build-mode validation rules.
The build pipeline
The root build follows this call chain:
ContainerBuilder.build()
├─ _layer()
│ ├─ snapshot registration storage
│ └─ materialize queued registration discovery
├─ _Blueprint(layers, boundaries)
└─ _compile_with_report(blueprint, build_args)
├─ normalize type aliases
├─ prepare boundary visibility
├─ expand decorator templates from closed, visible source registrations
│ └─ compile undecorated sources, filter them, and call selected factories
├─ _Compiler(...).compile()
│ ├─ match generated templates to eligible target occurrences
│ ├─ recursively compile root candidates
│ ├─ compile provider roots and boundary-local roots
│ ├─ freeze component records
│ └─ return _PlanSet
└─ _finalize_plan(plan)
├─ validate entry points and analyze reachability
├─ construct the public CompiledGraph
├─ materialize ownership reporting
├─ run build-mode validation rules
└─ attach BuildReport
Container(plan, root_owner_token)
The builder is marked built only after successful compilation and runtime construction. If build fails, the builder can be repaired and retried. After success, its composition is frozen and it cannot be built again.
Input: registrations, layers, and the blueprint
Registration data already has structure
_BuilderBase uses an internal legacy.Container as its composition store. A public register() call translates the
V2 arguments and lifespan into that store's registration API, then records additional V2 metadata.
Consequently, compilation starts with familiar internal objects: _Registry, _Registration, Dependency, dependency
settings, and activator classes. Much of the basic signature interpretation has already happened during registration.
Generic specialization, decorators, and pre-configurations can require further signature processing during build.
The compiler does not inspect arbitrary application function bodies to discover what objects they might construct. It compiles the dependencies expressed through supported registration and annotation mechanisms.
A layer adds composition and ownership context
_Layer contains registration storage alongside:
- A declaring owner token.
- Registration predicates and declaration origins.
- Factory specializations and structural-pattern definitions.
- Decorator and pre-configuration definitions.
- Scope slots, entry points, and validation rules.
- Provider-map definitions.
For a root builder, _layer() snapshots the registry containers and materializes queued subclass/generic discovery.
Explicit registrations precede convention-discovered registrations. This snapshot does not deep-copy arbitrary
application values.
_Blueprint combines layers with boundary declarations. A normal root build usually has one ordinary layer. An overlay
has its own layer before inherited layers. Live boundary builders are snapshotted at this point, including all bundles
applied since their creation. Boundary-local definitions have separate visibility rules. Successful build freezes the
parent and its boundary handles together; failed compilation leaves them editable.
The blueprint answers: which definitions are visible for this requested type, from this composition area, and in what precedence order? The compiler then determines the concrete plan those definitions produce at each occurrence.
Boundary preparation resolves Expose and Use contracts before normal root compilation. Dependencies of an exposed
registration compile with its defining area's visibility. A consumer cannot acquire extra access merely by calling it
from another boundary. See boundaries.
The recursive unit: a component and an execution step
The central operation returns two linked representations:
Component describes the occurrence for filtering, validation, and inspection. _Step executes that occurrence later.
The following is a structural sketch, not standalone executable code:
compile_registration(registration, parent, requested_type):
validate argument names
if this is an anchored parent singleton:
return cloned component occurrence, existing parent step
check for a registration cycle
check lifespan against retaining ancestors
allocate component and mutable draft
push registration and ownership frames
try:
dependencies = compile arguments
declared_requests = compile declared runtime-resolution requests
bind those requests into supported context dependencies
configurations = compile pre-configurations
decorators = compile decorator pipeline
step = lifespan-specific step containing:
registration and component
dependency steps
configurations and decorators
declaring owner and cleanup descriptor
transitive synchronous capability
return component, step
finally:
pop frames
The component draft exists before recursive dependency compilation begins, so a child's compilation can inspect its parent. The finished parent step is constructed after its children, so it can hold direct references to their steps.
Occurrence-sensitive expansion
If Database, Repository, and Handler are visible registered roots, and Handler → Repository → Database, the
compiler produces occurrences resembling:
The three appearances of Database can have separate component records and separate execution-step objects. Their
parents, argument names, ancestors, derived values, applicable filters, and cleanup context can differ.
Compilation therefore expands dependency occurrences instead of creating one global step per service type. There is targeted reuse for specialized registrations, shared initializers, and anchored parent singleton steps.
A component's id identifies its registration; occurrence_id identifies a particular use. Distinct occurrences do
not automatically imply distinct cached instances: caching uses the registration identity and the lifespan's owner.
Candidate compilation and selection
For an ordinary dependency, the selection process is:
- Find definitions visible in the current composition area.
- Choose the applicable definition tier: exact registrations, matching structural patterns, or open-generic fallback.
- Specialize candidate registrations where required.
- Compile each candidate's subtree.
- Evaluate its registration-level
whenpredicate. - Apply the dependency's selection filter.
- Select the first matching ordinary component, or retain matching collection members.
Candidate compilation precedes contextual filtering. _compile_candidates() calls _compile_registration() before
evaluating when. This gives the predicate a compiled candidate subtree to inspect.
It also means a false predicate cannot hide a structurally invalid candidate:
from clean_ioc import ContainerBuilder, ContainerBuildError
class Missing:
pass
class Disabled:
def __init__(self, missing: Missing):
self.missing = missing
builder = ContainerBuilder()
builder.register(Disabled, when=lambda component: False)
try:
builder.build()
except ContainerBuildError as error:
assert any(issue.code == "missing-component" for issue in error.report.errors)
else:
raise AssertionError("The invalid candidate should fail before its predicate runs")
For an injected single dependency, the compiler compares the declared parent_precedence of candidates that passed
both registration and consumer filters. A stable maximum pass preserves candidate order. Higher integers win; the
default is zero, and conditional predicates grant no automatic bonus. Remaining ties pass through the consumer's
select(..., prefer=chain) stages, then each candidate's registration prefer=chain stages. Each stage evaluates all
survivors once and retains its matches only if at least one matches; stages stop when the tie resolves. Only a final
tie produces an ambiguous-selection warning and selects the first candidate. Typed providers require one unique
final survivor and report ambiguity if the chains leave a tie. Collections, maps, parentless roots and factory
resolution requests do not use these comparisons or invoke preference callbacks. Incomparable
structural patterns retain their stricter error rules. Filters rejecting
the selected definition tier do not cause a search through lower-precedence tiers.
Boundary-crossing predicates are evaluated from the defining side. The actual graph retains the consumer relationship,
but an external consumer cannot make a source registration eligible simply by becoming its contextual parent. Parent
precedence is likewise neutral when that parent is hidden: use 0 for comparison with local candidates, regardless of
the source's declared value. This ties a local 0, beats -1 and loses to 1. Registration preference chains are skipped entirely.
Consumer preferences use the public candidate view. Explanations capture numeric precedence and boolean stage results,
neutral reasons, elimination stages and compact ranges of unreached stages. Lower-precedence candidates, preference
losers and final tied alternatives count as eligible but not selected in the census. Reached callback failures record
the phase/stage and partial work without a fabricated winner. Reports never replay predicates, and equivalent
preference behavior does not alter structural fingerprints when graph wiring is unchanged.
Composition filters are not repeated during runtime dependency activation. A filter supplied directly to resolve()
still executes to choose among already-compiled root plans.
Exceptionally large parent_precedence values remain Python integers in CandidateDecision. For JSON safety, values
with more than 2,000 binary digits serialize as {"integer_hex": "0x..."} (or "-0x..."), preserving their exact value
without changing Python's process-wide decimal conversion limit. Smaller values serialize as ordinary JSON integers.
Compiling arguments into operations
V1's Dependency.resolve() interpreted settings, selected registrations, handled special dependencies, and resolved
the result. V2's _compile_dependency() chooses an executable operation during build.
| Argument meaning | Compiled operation |
|---|---|
| Fixed value or Python default | _ValueStep(value) |
Concrete result of derive(...) |
_ValueStep(computed_value) |
| Injected service | Direct reference to the selected registration step |
| Collection | _CollectionStep(collection_type, member_steps, ...) |
| Declared scope slot | _ProvidedStep(service_type, name) |
| Runtime scope or resolution context | _ScopeStep(requested_type, ...) |
| Typed deferred dependency | _ProviderStep(mode, target_step, ...) |
Each argument becomes a _CompiledDependency containing its parameter name and step. Activation uses those names to
build the keyword-argument dictionary expected by the constructor or factory.
A _ValueStep(30) simply returns 30 at runtime. A dependency step follows a direct reference to the selected
component's construction plan. A _ProvidedStep retrieves the declared slot from the resolving scope: its selection
is frozen, while its value is deliberately supplied later.
inject() and select(...) force dependency injection even when the parameter has a Python default. build_arg(...)
and generic_arg(...) lower through explicit build-time derivation. A derivation can also return INJECT, in which
case compilation continues through dependency selection rather than creating a value step.
For ordinary service injection, the compiler first attempts component selection, then a matching declared scope slot. If neither is available, it reports a missing or visibility-related dependency error. See argument policies.
Building and freezing the component graph
_ComponentGraph stores occurrence records indexed by occurrence_id. A public Component is a small handle with a
reference to that graph and its occurrence ID.
During compilation, its record is a mutable _ComponentDraft. Recursive compilation fills fields such as
dependency_ids, pre_configuration_ids, and decorator_ids. On success, graph.freeze() converts the drafts into
frozen _ComponentRecord objects and clears the draft store.
Existing Component handles continue to work because they access their records through the graph. Records hold type
information, generic mappings, relationship IDs, names/tags, activation kinds, ownership metadata, and boundaries.
A component seen by a callback during compilation reflects that stage of construction. Its ancestors may still be under construction. The final public graph is frozen and complete for the compiled relationships.
The graph is a description of possible construction, not a record of which objects have actually been activated. Runtime instance caches do not live in these component records.
Worked example: steps and caches
This complete example records construction events and uses only public APIs:
from clean_ioc import ContainerBuilder
created: list[str] = []
class Database:
def __init__(self):
created.append("Database")
class Repository:
def __init__(self, database: Database, timeout: int):
created.append("Repository")
self.database = database
self.timeout = timeout
class Handler:
def __init__(self, repository: Repository):
created.append("Handler")
self.repository = repository
builder = ContainerBuilder()
builder.register(Database, lifespan="singleton")
builder.register(Repository, lifespan="scoped", arguments={"timeout": 30})
builder.register(Handler)
with builder.build() as container:
assert created == []
with container.new_scope() as scope:
first = scope.resolve(Handler)
second = scope.resolve(Handler)
assert first is not second
assert first.repository is second.repository
assert first.repository.database is container.resolve(Database)
assert first.repository.timeout == 30
assert created == ["Database", "Repository", "Handler", "Handler"]
The relevant executable plans have this shape. Occurrence labels below are illustrative, not stable public IDs:
Root: Database
_SingletonRegistrationStep [Database occurrence A]
Root: Repository
_ScopedRegistrationStep [Repository occurrence B]
database → _SingletonRegistrationStep [Database occurrence C]
timeout → _ValueStep(30)
Root: Handler
_PerResolutionRegistrationStep [Handler occurrence D]
repository → _ScopedRegistrationStep [Repository occurrence E]
database → _SingletonRegistrationStep [Database occurrence F]
timeout → _ValueStep(30)
The Database steps can be different Python objects with the same registration ID. They consult the same declaring owner's singleton cache. Separate Repository occurrences likewise share when resolved against the same effective scoped cache. Each top-level Handler resolution gets a fresh per-resolution cache.
The result is two handlers, one repository, and one database, despite the expanded static graph.
Executing a registration step
_RegistrationStep holds the registration, component, compiled dependencies, pre-configurations, decorators, declaring
owner token, cleanup descriptor, and sync_supported flag.
Its activation follows the same broad order as V1:
run the compiled pre-configurations
resolve each compiled argument step
call the original implementation through its activator
apply the compiled decorators from core to outside
return the resulting object
The lifespan-specific subclass supplies the cache strategy:
| Step class | Runtime cache behaviour |
|---|---|
_TransientRegistrationStep |
Activate on each executed dependency edge |
_PerResolutionRegistrationStep |
Use context.resolution_cache[registration.id] |
_ScopedRegistrationStep |
Use the scope's scoped cache/inheritance lookup and first-activation coordinator |
_SingletonRegistrationStep |
Find the declaring owner, then use its singleton cache and coordinator |
Execution remains recursive. The recursion follows preselected step references rather than repeatedly interpreting
registrations and constructing dependency nodes. Normal root singleton/scoped cache hits also have direct shortcuts
in Scope.resolve() and resolve_async().
Lifespan checks and cleanup ownership
The compiler maintains _stack for active registrations and cycle detection, and _frames for richer path context:
lifespan, declaring owner, component kind, and occurrence.
For SingletonA → TransientB → ScopedC, SingletonA's frame is still active when ScopedC is compiled. That is how the
compiler detects the transitive captive dependency. A transient does not erase retaining ancestors.
Compilation distinguishes cache ownership from cleanup ownership. A cleanup-bearing transient retained by a singleton has no cache of its own, but its finalizer belongs to the retaining singleton's declaring owner.
The public graph records ownership categories and reasons. The step stores an executable _CleanupOwnerDescriptor,
including an owner token when necessary. _ActivationContext adapts the existing activator interface: its
add_finalizer() uses that descriptor rather than the lifespan supplied by the activator.
Per-resolution cache clearing and resource cleanup are separate events. A per-resolution resource normally closes
with the resolving scope, not as soon as the top-level resolve() returns. Supplied values are not automatically
finalized as container-created resources. See lifespans and cleanup ownership.
Sync capability, providers, and maps
A registration step's sync_supported combines its own activator capability with the capabilities of every compiled
dependency, pre-configuration, and decorator. Synchronous resolution can therefore reject an async-only root plan before
starting that root's activation. Component.requires_async describes local activation metadata; it is not the same
field as the execution step's transitive capability.
Typed providers preserve a direct reference to the compiled target step. Acquiring a provider does not execute its
target; calling it starts a new resolution context in its bound scope. Acquiring an AsyncProvider[T] can be
synchronous even though invoking the target requires await.
A provider frame creates a deferred boundary for eager-retention analysis. The target is still compiled and validated, and additional checks prevent a singleton from retaining a provider whose target reaches request/scope state. A singleton-captured provider is bound to the singleton's declaring scope owner.
Provider maps compile their selected targets and evaluate their key functions during build. The resulting map step stores frozen key-to-index information and provider targets. Runtime map acquisition creates scope-bound handles; looking up a key and calling its handle activates the selected target without repeating key calculation or selection. See special dependency types.
ProviderMapGroup maps make the candidate set explicit before specialization and target compilation: only visible
registrations whose contributes metadata contains that exact group identity are considered. Contributions are
composition metadata, not a resolution filter. The existing component_filter then runs on the compiled selected
targets, and all ordinary cycle, ownership, async, boundary, and key validation remains in force.
Decorators and shared pre-configurations
Decorator applicability is evaluated against the completed undecorated core subtree before decorator dependencies
are added. Selected definitions become _CompiledDecorator objects with their own dependency steps, activators,
cleanup descriptors, and sync capability.
Runtime decoration proceeds from core to outside. Component inspection presents the pipeline outside-to-inside. Decorator dependencies participate in the same cycle, lifetime, and async analysis as constructor dependencies.
Pre-configurations compile once per definition and are shared across applicable targets. Their dependency paths are validated as singleton-owned paths even if a transient component triggers them. They remain lazy: the first applicable activation runs the initializer, and concurrent callers join its in-flight attempt.
The wiring is fixed, but _CompiledPreConfiguration references mutable completion/coordination state. Default failures
can be retried by a later resolution. See pre-configurations for optional-failure semantics.
Generic specialization
For a structural factory template Serializer[list[T]] requiring Serializer[T], a request for
Serializer[list[Order]] binds the pattern variable to Order and compiles a Serializer[Order] dependency.
The original factory remains the callable invoked at runtime. Specialization rewrites dependency descriptions and closed registration metadata. Closed generic constructors similarly receive substituted dependency annotations while preserving the original class.
Factory specializations are memoized by registration identity and requested runtime type key. Their specialized IDs give different closed services separate lifespan caches. This memoization does not eliminate parent-specific occurrence compilation: one specialized registration may still appear under several parents.
Service generic mappings and factory-pattern bindings can differ. For example, a service parameter can map to
list[Order] while the pattern's variable maps to Order.
Open templates do not enumerate an unlimited set of roots. _Compiler.compile() skips unresolved open templates as
direct roots, compiles known closed requests, and incorporates supported discovered pattern requests. An entry-point
marker focuses an available request; it does not cause arbitrary unseen specializations to exist. Runtime resolution
does not compile new types. See generics for exact registration and pattern rules.
What is in the final plan?
Each _RootPlan pairs a Component with a _Step. _PlanSet collects these roots and the surrounding metadata:
| Field | Purpose |
|---|---|
roots |
Service key to ordered eligible root candidates |
default_roots |
Service key to the preselected default root |
default_root_groups |
Unnamed root candidates for default group selection |
provider_roots |
Precompiled deferred root plans |
graph |
Internal occurrence-record store |
compiled_graph |
Public graph inspection and tooling interface |
blueprint |
Retained composition structure for overlays and validation |
build_args |
Immutable mapping of inputs associated with the build |
build_report / compiler_issues |
Final findings and compilation diagnostics |
root_candidates / occurrence_explanations |
Recorded selection evidence |
architecture_roots / area_root_candidates |
Architectural roots and boundary-local candidate evidence |
RootPlan.component lets tooling inspect wiring; RootPlan.step lets the runtime execute it. Private boundary roots
can participate in architectural validation without becoming publicly resolvable roots.
The JSON manifest is a deterministic, redacted projection for inspection and comparison. It does not serialize the
executable _PlanSet. Configured values, build-input keys/values, runtime identities, and provenance are excluded from
default semantic manifests as described in compiler tooling.
"Immutable" describes supported composition and runtime wiring. It does not imply that every reachable Python object is deeply immutable: fixed values can be mutable application objects, internal mappings are not all deeply frozen, and caches and pre-configuration coordination state necessarily change during execution.
Occurrence metadata clones, including eager provider targets and anchored parent components, reuse the source's captured implementation type, including any source-inspection enrichment. Fresh occurrences still perform their normal reflection and validation. Cloning skips repeated introspection whose result would be discarded, so custom signature or annotation getters do not receive those incidental reads or raise errors from them. A composition callback that changes introspection state after capture can therefore remove an incidental clone-time failure; the captured type remains authoritative for that clone. Registered filters, derivations, templates and rules still run normally. Keep callbacks pure and avoid depending on a particular count of reflective reads.
Finalization and failed builds
After recursive compilation, _finalize_plan() validates entry-point selections, computes reachability warnings,
constructs the public CompiledGraph, materializes ownership reporting, and runs custom rules with mode="build".
It returns the plan with its BuildReport, or raises when the report contains errors.
Rules with mode="validation" run only during explicit validation. That pass retains stored build findings without
rerunning build-mode rules. Entry-point markers influence default rendering and reachability warnings; they do not
reduce whole-composition validation to only the marked roots.
The main recursive descent generally raises on its first structural failure. When no complete report is already
available, _error_report() attempts roots independently with fresh compiler instances and aggregates their failures.
Errors before normal graph compilation, such as invalid boundary contracts, can instead produce an immediate report.
Diagnostic retries can execute composition callbacks again. "Runs at build time" does not mean "runs exactly once". Filters and derivations should be pure. Explanation rendering reads captured decisions and does not rerun them.
Successful compilation proves the supported structural constraints, not the eventual success of user activation. A factory can still fail to connect to a database, and objects created inside arbitrary application code remain outside the statically declared dependency graph.
Overlay compilation and anchored steps
ScopeBuilder creates a blueprint with its layer before the parent's layers, and passes the parent's singleton steps,
initializer plans, and owner tokens to the compiler.
When an inherited singleton is encountered, the compiler clones its component representation into the overlay graph but retains the parent's executable step. The graph can describe its relationship to a new consumer while execution keeps the original dependencies, build arguments, decorators, and cleanup owner.
An overlay cannot introduce a previously uncompiled specialization of a parent-owned singleton. It must provide an appropriate overlay-owned definition instead. Inherited pre-configurations are anchored similarly.
The built overlay starts a fresh scoped-cache boundary and owns its newly declared singletons. Ordinary new_scope()
reuses its parent's _PlanSet and performs no compilation. See scopes and scope builders.
Source reading map
The following symbols connect the public build boundary to the executable output:
| Source | Symbols to follow |
|---|---|
container.py |
ContainerBuilder.build(), _BuilderBase._layer(), _Blueprint, _compile_with_report() |
container.py |
_Compiler.compile(), _compile_candidates(), _compile_registration(), _compile_dependency() |
components.py |
_ComponentDraft, _ComponentRecord, _ComponentGraph, Component |
container.py |
_RootPlan, _PlanSet, _RegistrationStep, _RuntimeResolutionContext, Scope.resolve() |
tooling.py |
CompiledGraph, CompilationExplanation, BuildReport, GraphManifest |
_legacy.py |
Registration/dependency representation and activators reused by the compiled implementation |