Boundaries and visibility
A bundle groups registrations; a boundary controls access to them. Call create_boundary() on a container or scope
builder to obtain a BoundaryBuilder. Apply ordinary bundles or register components directly on that retained handle;
its registrations are private by default. Expose makes one local component visible to root composition; Use admits
one root or exposed component into another boundary.
Boundaries do not create child containers, runtime namespaces, proxies, or plugin loaders. A boundary changes candidate visibility and may project a different public service type, name, and complete tag set. The source plan keeps its implementation, decorators, pre-configurations, lifespan, registration identity, runtime instance, cache, and cleanup owner.
from dataclasses import dataclass
from typing import Protocol
from clean_ioc import ContainerBuilder, Expose, Use
@dataclass(frozen=True)
class Settings:
stripe_key: str
class PaymentGateway(Protocol): ...
class StripeSdk:
def __init__(self, settings: Settings): ...
class StripeGateway(PaymentGateway):
def __init__(self, sdk: StripeSdk): ...
class PlaceOrder:
def __init__(self, gateway: PaymentGateway): ...
def payments_bundle(builder):
builder.register(StripeSdk, lifespan="singleton")
builder.register(PaymentGateway, StripeGateway, lifespan="singleton")
def orders_bundle(builder):
builder.register(PlaceOrder)
builder.mark_entrypoint(PlaceOrder)
builder = ContainerBuilder()
builder.register(Settings, instance=Settings("secret"))
payments = builder.create_boundary(
name="payments",
uses=(Use.root(Settings),),
exposes=(Expose(PaymentGateway),),
)
orders = builder.create_boundary(
name="orders",
uses=(Use("payments", PaymentGateway),),
exposes=(Expose(PlaceOrder),),
)
orders.apply_bundle(orders_bundle) # contribution order across boundaries is immaterial
payments.apply_bundle(payments_bundle)
container = builder.build()
container.resolve(PlaceOrder) # exposed
container.resolve(PaymentGateway) # exposed
# container.resolve(StripeSdk) # private: raises CannotResolveError
The payments component may use Settings only because it declares Use.root(Settings). Orders may use the gateway
only because payments exposes it and orders names that exposure in Use. Root composition sees every exposure without
declaring a use. A boundary cannot expose a component that it obtained through Use; register and expose a local
adapter when publishing a different contract.
Incremental composition and lifecycle
Keep the handle to contribute more bundles before the parent builds:
builder = ContainerBuilder()
builder.register(Settings, instance=Settings("secret"))
payments = builder.create_boundary(
"payments", uses=(Use.root(Settings),), exposes=(Expose(PaymentGateway),)
)
payments.apply_bundle(payments_bundle)
# Later contributions use the same ordinary ComponentBuilder operations.
payments.add_validation_rule(lambda context: ())
component_id = payments.get_component_id(PaymentGateway)
if component_id is not None:
payments.patch_component(PaymentGateway, component_id, lifespan="scoped")
container = builder.build()
A convenience bundle may create a boundary and retain or return its handle through its own API. Possessing that
handle grants explicit access to configure the subsystem; applying a bundle to the parent never redirects it into a
boundary. ComponentBuilder includes create_boundary() so boundary-owning bundles can use the shared protocol.
Nested boundaries are not supported: calling create_boundary() on a boundary handle raises ValueError.
Registrations, decorators, pre-configurations, discovery rules, and validation rules are snapshotted during parent
compilation. A successful parent build freezes every owned boundary handle, including handles retained by bundles.
Further mutations raise BuilderAlreadyBuiltError. A boundary has no separate build() or resolution API, and runtime
containers and scopes remain immutable.
A failed build leaves both parent and boundary composition editable. Register missing dependencies, patch components,
or replace visibility declarations and retry the parent build. The uses and exposes properties accept iterables and
store tuples; for example, payments.exposes = (*payments.exposes, Expose(StripeSdk)) explicitly changes the public
contract. Names are fixed when a boundary is created, and duplicate names never reopen an existing boundary.
has_component(), get_component_id(), and get_component_ids() on the boundary handle preview its local registrations
and explicitly admitted components with the parent's current composition and build arguments. These queries do not
freeze the builder, but still require valid visibility contracts and compilable queried dependencies. Parent queries
cannot see unexposed private registrations, and parent patch operations cannot change them.
Bundle application has the same semantics on every builder: if a bundle raises, earlier operations remain in the composition. Repair that retained state before retrying. Successfully applied run-once bundles retain their claims; neither a later bundle exception nor a failed compilation rolls their registrations back.
The former Boundary(root_bundle=...) declaration and install_boundary() API have been removed. Migrate by creating
the boundary, then calling boundary.apply_bundle(previous_root_bundle); additional bundles can use that same handle.
Named and tagged components
Boundary declarations select exactly one original component. Use the normal component filters to select a named or tagged definition, then use the normal argument-selection API in the consumer:
from clean_ioc import Expose, Tag, Use, select
from clean_ioc import component_filters as cf
def payments_bundle(builder):
builder.register(StripeSdk)
builder.register(
PaymentGateway,
StripeGateway,
name="stripe",
tags=(Tag("region", "global"),),
)
class Checkout:
def __init__(self, gateway: PaymentGateway):
self.gateway = gateway
def checkout_bundle(builder):
builder.register(
Checkout,
arguments={"gateway": select(cf.with_name("stripe"))},
)
builder = ContainerBuilder()
builder.register(Settings, instance=Settings("secret"))
payments = builder.create_boundary(
"payments",
uses=(Use.root(Settings),),
exposes=(Expose(PaymentGateway, filter=cf.with_name("stripe")),),
)
checkout = builder.create_boundary(
"checkout",
uses=(Use("payments", PaymentGateway, filter=cf.with_name("stripe")),),
exposes=(Expose(Checkout),),
)
payments.apply_bundle(payments_bundle)
checkout.apply_bundle(checkout_bundle)
container = builder.build()
The component is still named "stripe"; exposure does not make it the unnamed default. Tags remain available to
filters and graph tooling. Declare one Expose per collection member that should be public and one Use per member
that a consumer should admit.
An exposure may instead define a complete public identity with BoundaryAlias. The exposure filter sees the local
service type, name, and tags; root composition and consuming boundaries see the alias service type, name, and tags:
from clean_ioc import BoundaryAlias
class PublicPaymentGateway(Protocol): ...
builder = ContainerBuilder()
builder.register(Settings, instance=Settings("secret"))
payments = builder.create_boundary(
"payments",
uses=(Use.root(Settings),),
exposes=(
Expose(
PaymentGateway,
filter=cf.with_name("stripe"),
alias=BoundaryAlias(
PublicPaymentGateway,
name="primary",
tags=(Tag("audience", "public"),),
),
),
),
)
class AliasedCheckout:
def __init__(self, gateway: PublicPaymentGateway):
self.gateway = gateway
def aliased_checkout_bundle(builder):
builder.register(
AliasedCheckout,
arguments={"gateway": select(cf.with_name("primary"))},
)
checkout = builder.create_boundary(
"checkout",
uses=(Use("payments", PublicPaymentGateway, filter=cf.with_name("primary")),),
exposes=(Expose(AliasedCheckout),),
)
payments.apply_bundle(payments_bundle)
checkout.apply_bundle(aliased_checkout_bundle)
container = builder.build()
gateway = container.resolve(PublicPaymentGateway, filter=cf.with_name("primary"))
Selection happens on the appropriate side of the contract. Expose.service_type and Expose.filter select exactly
one local component using its source service type, name, and tags. A consuming Use must name the public service type,
and Use.filter runs against the public name and tags. Root resolution uses that same public view. Neither a Use
filter nor a root filter can select an aliased exposure by its source name or source tags.
This is a boundary contract, not another registration or a proxy. Internal dependencies, exposure filters, and
registration policies still see PaymentGateway, "stripe", and the original tags. External filters and graph
contracts see PublicPaymentGateway, "primary", and only the alias tags. The alias therefore does not duplicate
singleton state or change lifespan, caching, instance identity, or cleanup ownership. Omitting alias preserves the
local service type, name, and tags. A boundary may publish multiple distinct aliases for the same source registration;
exact duplicate public identities are rejected.
Entry points, providers, and policies
mark_entrypoint() remains a tooling declaration, not an access grant. A marker inside a boundary must select one
local component that the boundary also exposes. Root composition may mark a root registration or an exposure.
Provider[T] and AsyncProvider[T] freeze T using the visibility of the component that injects the provider, so
deferred execution cannot widen access. Raw Scope and ResolutionContext injection remain deliberate runtime escape
hatches; use typed providers when architecture segregation matters.
Aliases follow the same rule through eager collections and typed providers. Root or a boundary that has admitted
PublicPaymentGateway may request a collection, Provider[PublicPaymentGateway], or
AsyncProvider[PublicPaymentGateway]; matching and filtering use the public identities, while invocation activates the
original source plans. Requesting PaymentGateway externally does not bypass the alias. Inside payments, collections
and providers continue to select the source identity.
register_provider_map(...) is also available inside boundary bundles. Its entries use the map declaration's visibility;
exposing Mapping[K, Provider[T]] publishes that map without publishing or changing the ownership of its private target
registrations. The map service itself may be exposed under a BoundaryAlias; root and consuming Use declarations then
select the map by its public alias, but its entries retain the source map's already-compiled private target plans. Use
may admit an exposed map into another boundary. See
lazy provider maps.
Validation rules installed at root receive the complete graph. A rule installed by a boundary bundle receives that
boundary's local roots and outgoing boundary edges; ValidationContext.boundary and GraphVisit.boundary identify the
relevant defining area.
Decorators, scope slots, and overlays
Decorators, pre-configurations, and registration discovery inside a boundary apply only to that boundary's local registrations. They do not implicitly alter a component across a boundary.
Private boundary scope slots are not supported because runtime Scope.provide() has no boundary qualifier. Declare a
root slot and admit it with Use.root(...) instead.
ScopeBuilder.create_boundary() can add a new overlay-owned boundary and that boundary can use parent exposures. An
overlay cannot reopen, patch, or reuse the name of a parent boundary. A parent boundary never sees a boundary added by
a child overlay, and parent-owned singleton plans and cleanup ownership stay frozen.
Parent exposures keep their public aliases in an overlay. An overlay-owned boundary names that public identity in
Use, and overlay root resolution also uses the public identity. The overlay cannot use the source identity to bypass
the contract or re-alias the parent exposure; it must register and expose a local adapter to publish another contract.
Expose, Use, and Use.root accept native or backported type aliases. Boundary matching uses canonical service keys,
so an alias changes neither visibility nor ownership and cannot expose a private component indirectly.
Diagnostics and review
Boundary names use ^[a-z][a-z0-9_-]*$; root is reserved. Creation rejects invalid, duplicate, or inherited boundary
names. Build-time errors distinguish use cycles, missing and ambiguous exposure/use selection, private dependencies, re-exports, entry-point violations,
private slots, cross-boundary decoration, and prohibited overlay access.
Graph text and Mermaid output label defining boundaries and boundary edges. Manifests record the deterministic boundary
contract, resolved exposures and uses, component areas, and cross-boundary sources without serializing bundle objects,
filters, configured values, build inputs, owner tokens, or runtime identities. Each exposure records its public identity
as service, name, and tags, and its selected local identity as source_service, source_name, and source_tags.
The boundaries, boundary, and source_boundary fields describe visibility.
Semantic diffs compare the whole recorded exposure. Changing a source field or a public alias field therefore reports
the old contract as boundary-exposure-removed with high risk and the new contract as boundary-exposure-added with
medium risk. The diff does not classify source and public edits separately. Boundary additions, removals, uses, and
component moves have their own classifications. Tooling formats remain unversioned in this release candidate;
regenerate saved graphs and baselines when the format changes. Schema versioning is planned for a future release.