A DI container resolves a request for a root type by walking its declared dependency graph depth-first, instantiating each node the first time it is needed:
resolve(node, stack):
if node in stack: # gray node re-entered
fail "circular dependency"
if node.scope == SINGLETON and cache.has(node):
return cache.get(node) # reuse, no new instance
stack.push(node)
args = [resolve(dep, stack) for dep in node.deps]
instance = new node.type(args)
if node.scope == SINGLETON: cache.set(node, instance)
stack.pop(node)
return instance
- Singleton scope (green ring) — the container builds the instance once and every future dependent receives the same cached object. Notice
Logger is depended on by four different services but, as a singleton, is only instantiated once.
- Transient scope (no ring) — a brand-new instance is created on every resolution, even if the same type was just built a moment ago.
- Circular dependency detection — the resolver keeps a "currently resolving" stack (the gray set in DFS graph coloring), shown live in the Call stack panel. If resolution revisits a node still on that stack, the graph has a cycle and the container fails fast instead of recursing forever — exactly the runtime crash Dagger/Hilt and Swinject catch at compile-time or first resolution.
- Toggle scope — click any non-root node then "Toggle scope" to flip it between Singleton and Transient and re-resolve to see the instance count change.
- Layout — switch between the dependency-order Layered view and a Circular view; both render the same graph and edges, just arranged differently.
This is the mechanism underneath every real mobile DI container: Dagger/Hilt on Android generate this exact resolution graph at compile time; Swinject and Koin walk it at runtime, same algorithm.