Usage
1. Basic container
from doppy_di import ContainerBuilder
builder = ContainerBuilder()
builder.service("greet", lambda: "Hello")
builder.value("pi", 3.14)
container = builder.build()
assert container.get("greet") == "Hello"
assert container.get("pi") == 3.14
2. Singleton vs transient
Singleton: same object on every get.
Transient: new object on every get.
builder = ContainerBuilder()
builder.service("s", lambda: object(), lifetime="singleton")
builder.service("t", lambda: object(), lifetime="transient")
c = builder.build()
assert c.get("s") is c.get("s") # same
assert c.get("t") is not c.get("t") # different
3. Dependencies
Factories receive resolved dependencies as positional args.
builder = ContainerBuilder()
builder.value("greeting", "Hello")
builder.value("name", "World")
builder.service(
"message",
lambda greeting, name: f"{greeting}, {name}!",
deps=["greeting", "name"],
)
c = builder.build()
assert c.get("message") == "Hello, World!"
4. Scopes
Scope caches resolved values within a with block. On exit the cache is cleared.
builder = ContainerBuilder()
builder.service("x", lambda: object(), lifetime="transient")
c = builder.build()
with c.scope("req") as s:
a = s.get("x")
b = s.get("x")
assert a is b # cached within scope
Scopes can nest:
with c.scope("outer") as outer:
with c.scope("inner") as inner:
...
5. Aliases
An alias points to another key. Resolving an alias resolves the target.
builder = ContainerBuilder()
builder.value("pi", 3.14)
builder.alias("π", "pi")
c = builder.build()
assert c.get("π") == 3.14
6. Overrides
Temporarily replace one or more values for the duration of a with block.
Overrides stack: nested override() blocks are LIFO, the last one wins, and
exiting restores the original rules — even on exception.
builder = ContainerBuilder()
builder.value("x", 1)
builder.value("y", 2)
c = builder.build()
# single key
with c.override("x", 99):
assert c.get("x") == 99
# dict of keys, nested stack
with c.override({"x": 10, "y": 20}):
assert c.get("x") == 10
assert c.get("y") == 20
with c.override({"x": 30}):
assert c.get("x") == 30 # last wins
assert c.get("x") == 10
assert c.get("x") == 1
assert c.get("y") == 2
A callable override is treated as a factory and invoked on every resolution:
with c.override({"x": lambda: 42}):
assert c.get("x") == 42
Overrides are validated on entry. Overriding a singleton with a scoped
dependency or a resource with a plain value raises ValueError.
For pytest, wrap the container in a fixture:
@pytest.fixture
def container_with_overrides(container):
with container.override({"db": fake_db}):
yield container
7. Duplicate-key policies
Control behaviour when the same key is registered twice.
from doppy_di import DuplicateKeyPolicy
# FAIL — raise DuplicateKeyError
builder = ContainerBuilder(duplicate_policy=DuplicateKeyPolicy.FAIL)
builder.value("x", 1)
builder.value("x", 2) # raises DuplicateKeyError
# WARN — log warning, overwrite
builder = ContainerBuilder(duplicate_policy=DuplicateKeyPolicy.WARN)
# OVERWRITE (default) — silently replace
builder = ContainerBuilder()
8. Build validation
Pass validate=True to catch missing dependencies at build time.
builder = ContainerBuilder()
builder.service("a", lambda b: b.upper(), deps=["b"])
# raises ContainerBuildError: a -> b
c = builder.build(validate=True)
9. Cycle detection
Cycles are detected automatically when a rule is added.
builder = ContainerBuilder()
builder.service("a", lambda b: b, deps=["b"])
builder.service("b", lambda a: a, deps=["a"]) # raises CycleError
10. Compile / plan mode
Compile the dependency graph once into an immutable ExecutionPlan:
from doppy_di import CompilePolicy, ContainerBuilder, ExecutionPlan
builder = ContainerBuilder()
builder.value("a", 1)
builder.service("b", lambda a: a + 1, deps=["a"])
container = builder.build()
plan = container.compile()
assert plan.get("b") == 2
compile() validates the full graph up front: missing dependencies raise
MissingDependencyError, cycles raise DependencyCycleError. The plan is
immutable and resolves through the live container, so lifetimes, singleton
caches and scopes keep identical semantics.
Override policy
Compiler is opt-in policy.
# ALLOW_OVERRIDE (default): overrides still apply through the live container
builder = ContainerBuilder(compile_policy=CompilePolicy.ALLOW_OVERRIDE)
container = builder.build()
plan = container.compile()
with container.override("a", 10):
assert plan.get("b") == 11
# STRICT: after compile() the container rejects further overrides
strict = ContainerBuilder(compile_policy=CompilePolicy.STRICT).build()
strict.compile()
strict.override("x", 1) # raises RuntimeError
Serialization
Plans persist graph topology, rule metadata and resolved singletons to JSON:
data = plan.serialize()
restored = ExecutionPlan.deserialize(data)
assert restored.get("a") == 1
Factories are not serialized. After deserialization only registered singleton
values resolve; factory-backed keys raise ServiceNotFoundError. Use
module-level factory functions for true cross-process plan caching.
Zero overhead
compile() is fully opt-in. If it is never called, no plan is built and no
extra work happens at resolution time.
11. Observability / tracing
Set a tracer callback to observe every resolution. The callback receives
(key, duration, cache_hit, scope) after each successful get()/aget().
events = []
def tracer(key, duration, cache_hit, scope):
events.append((key, duration, cache_hit, scope))
builder = ContainerBuilder()
builder.value("a", 1)
container = builder.build()
container.set_tracer(tracer)
container.get("a")
container.get("a") # cache hit
assert events[0] == ("a", _, False, None) # miss
assert events[1] == ("a", _, True, None) # hit
Pass set_tracer(None) to disable tracing. When no tracer is set there is
no timing and no dispatch — zero overhead. Child containers inherit the
parent tracer. Scope resolutions report the scope name as the last argument.
OpenTelemetry
Install the optional extra and attach an adapter that emits spans:
pip install "doppy-di[otel]"
from doppy_di import ContainerBuilder
from doppy_di.ext.otel import otel_adapter
builder = ContainerBuilder()
builder.value("a", 1)
container = builder.build()
container.set_tracer(otel_adapter())
container.get("a") # emits doppy.resolve:'a' span
12. Async-first resolution
aget() resolves sync and async factories, sync and async resources, and
resolves independent dependency branches concurrently. Sync factories are
called directly with no await overhead.
import asyncio
async def make_db():
return "async-db"
builder = ContainerBuilder()
builder.service("db", make_db)
container = builder.build()
db = asyncio.run(container.aget("db"))
assert db == "async-db"
Async yield providers are finalized on scope exit and on cancellation:
async def make_session():
try:
yield Database()
finally:
await cleanup()
builder.service("session", make_session)
container = builder.build()
async with container.ascope("req") as scope:
session = await scope.aget("session")
# session finalized on scope exit
Mixed-graph rules
A sync factory depending on an async dependency raises
AsyncDependencyInSyncContextError when resolved via get(). A sync factory
returning an awaitable raises SyncFactoryReturningAwaitableError. A
cancelled aget() finalizes partially-created resources and raises
ResolutionCancelledError.
Parallel resolution
get_many() resolves independent keys concurrently:
a, b = await container.get_many(["a", "b"], parallel=True)
13. Provider facade
Declarative providers convert to rules on attribute assignment. Import from
doppy_di.providers; the package-level Factory protocol is untouched.
from doppy_di import Container, Scope
from doppy_di.providers import Factory, Singleton, Value, Resource
services = Container()
services.config = Value({"debug": True})
services.db = Resource(create_db, Scope.APP)
services.repo = Factory(UserRepository, db=services.db)
services.service = Singleton(UserService, repo=services.repo)
assert services.get("config") == {"debug": True}
Dependencies may reference providers before they are assigned; unbound placeholders resolve by name at rule registration. Assigning a class factory registers both the named key and a type key.
Provider classes
Factory— transient factory.Singleton— singleton factory.Scoped— factory cached per scope.Value— constant value.Resource— yield-based resource finalized on scope exit.Coroutine— async factory.Alias— points at another key.Selector— picks one provider at resolution time.ListOf— aggregates providers into a list.DictOf— aggregates named providers into a dict.
14. Config profiles and child containers
Derive environment-specific containers without mutating the base.
builder = ContainerBuilder()
builder.value("env", "base")
container = builder.build()
prod = container.with_profile("prod", {"env": "prod"})
assert container.get("env") == "base"
assert prod.get("env") == "prod"
child() layers rules over the parent; parent rules added later stay
visible.
child = container.child("worker")
child.value("role", "worker")
assert child.get("env") == "base" # parent rule visible
diff(other) returns a DiffReport of added/removed/changed keys:
report = container.diff(prod)
assert "env" in report.changed
export_config() serializes the effective configuration to JSON:
config = container.export_config()
# '{"env": "base"}'
15. Resolution policies
Policies control the order of dependency resolution. Opt-in; default behaviour unchanged when none specified.
from doppy_di import (
ResolutionChildrenFirstPolicy,
EagerPolicy,
ParallelPolicy,
)
# container-wide policy
container = builder.build(policy=ResolutionChildrenFirstPolicy())
# per-call policy
container.get("a", policy=EagerPolicy())
Built-in policies: DefaultResolutionPolicy, LazyPolicy,
ResolutionParentFirstPolicy, ResolutionChildrenFirstPolicy,
EagerPolicy, ParallelPolicy. Implement the ResolutionPolicy protocol
(order(graph, root)) for custom strategies.
Note: top-level ChildrenFirstPolicy/ParentFirstPolicy are devkit
nested-field ordering policies. Resolution policies use the
ResolutionChildrenFirstPolicy/ResolutionParentFirstPolicy aliases.
16. Graph introspection and CLI
Query the dependency graph programmatically:
g = container.graph()
g.nodes() # all registered keys
g.edges() # (key, dependency) pairs
g.dependencies_of("a") # direct deps
g.dependents_of("a") # direct dependents
g.to_mermaid() # mermaid
g.to_dot() # graphviz
g.to_json() # dict
g.to_text() # text tree
Inspect or lint container definitions from a file:
doppy-di graph container.py --format mermaid
doppy-di explain db --file container.py
doppy-di check container.py --root service --strict
graph supports mermaid, dot, json, text formats. explain shows
lifetime, scope, dependencies and dependents of a key. check reports
missing dependencies, cycles, duplicate registrations, unused registrations
(with --root), and lifetime violations.
Devkit extensions
ValidatingContainer
Wrap a container with resolution ordering and validation rules.
from doppy_di import (
ContainerBuilder,
UnorderedPolicy,
ValidatingContainer,
ValidationRunner,
)
builder = ContainerBuilder()
builder.value("x", 1)
base = builder.build()
wrapped = ValidatingContainer(
base, UnorderedPolicy(), ValidationRunner()
)
assert wrapped.get("x") == 1
LoggingContainer
Log every container operation.
from doppy_di import ContainerBuilder, LoggingContainer
events = []
def log(msg: str) -> None:
events.append(msg)
builder = ContainerBuilder()
builder.value("x", 1)
base = builder.build()
wrapped = LoggingContainer(base, log)
wrapped.get("x")
assert "get('x')" in events
Nested rules
Validate that a resolved object's attribute matches a separately resolved nested rule.
from doppy_di import (
ContainerBuilder,
NestedRules,
Rule,
RuleSet,
)
class Service:
def __init__(self):
self.repo = "db"
nested = NestedRules()
rule = Rule(("service", "repo"), lambda: "db")
rs = RuleSet()
nested.add_nested("service", "repo", rule, rs)
builder = ContainerBuilder()
builder.service("service", lambda: Service())
base = builder.build()
# validate_nested checks attribute == resolved nested value
nested.validate_nested("service", base)