Dependency Injection Container
A mini Spring-like IoC container. Register classes with scopes, resolve dependency graphs automatically, and swap implementations without changing client code.
Key Abstractions
Central registry and resolver -- maps interfaces to implementations, drives resolution
Metadata record: concrete class, scope, and declared dependencies
Enum: Singleton (one shared instance) or Prototype (fresh instance per resolve)
Walks constructor parameters, topologically resolves the full dependency graph
Init/destroy hooks called after construction and before container shutdown
Class Diagram
How It Works
Think about what happens every time you write new UserService(new UserRepository(new DatabaseConnection(...))). You are manually wiring the entire object graph. Change one dependency and you are chasing constructor calls across the codebase. Now multiply that by dozens of services. It gets out of hand fast.
A Dependency Injection container flips this around. You tell the container "when someone asks for a UserRepository, give them an InMemoryUserRepository." Later, when you resolve UserService, the container sees that its constructor takes a UserRepository, looks up the registered implementation, builds it, and passes it in. You never call new on your service classes directly.
The container inspects constructor signatures at resolve time. In Python, inspect.signature reveals parameter type annotations. In Java, Constructor.getParameterTypes() does the same through reflection. The resolver walks these parameters recursively: if UserService needs a UserRepository, and UserRepository needs a DataSource, the container resolves DataSource first, then UserRepository, then UserService. It is topological resolution of a dependency graph, built on the fly.
Scoping decides how many instances exist. A Singleton bean gets created once and cached. Every subsequent resolve() call returns that same object. A Prototype bean is freshly constructed every time. You pick the scope per registration, and the container handles the rest.
Requirements
Functional
register(interface, implementation, scope)records a mapping in the containerresolve(interface)returns a fully constructed instance with all dependencies injected- Singleton scope caches one instance; Prototype scope creates a new one each call
- Constructor dependencies are detected automatically from type annotations or reflection
- Circular dependencies are detected and reported with a clear error message
Non-Functional
- Open for extension: adding new classes never requires modifying the container itself
- Swappable implementations: changing
InMemoryUserRepositorytoPostgresUserRepositoryis a one-line registration change - Lifecycle management: beans can hook into
post_initandpre_destroyfor setup and cleanup
Design Decisions
Why constructor injection instead of setter injection or field injection?
Constructor injection makes dependencies explicit. You look at the constructor and you know exactly what a class needs to work. There is no hidden state, no partially constructed objects. If a required dependency is missing, you find out at construction time, not when some method blows up later at runtime. Setter injection looks convenient, but it lets you create objects in an invalid state. Field injection (using reflection to jam values in) hides dependencies entirely. Spring moved toward constructor injection as the recommended approach for these reasons.
Why detect circular dependencies before building anything?
If ServiceA needs ServiceB and ServiceB needs ServiceA, naive recursive resolution loops forever until you hit a stack overflow. That crash gives you no useful information. By tracking a "currently resolving" set and checking it before each resolution step, you catch the cycle immediately and produce an error message like "ServiceA -> ServiceB -> ServiceA." Some frameworks solve this with lazy proxies instead of failing, but for an interview-level design, fail-fast with a good message is the right call.
Why key the registry on the interface type, not the concrete class?
The whole point of dependency injection is programming to interfaces. If you register InMemoryUserRepository under the key UserRepository, any class that depends on UserRepository gets the in-memory version. Swap the registration to PostgresUserRepository and everything that depends on UserRepository now gets Postgres, with zero code changes in the dependent classes. Keying on interfaces is what makes the container useful for testing, too. Swap in a mock for integration tests with a single line.
How do lifecycle hooks fit in?
Real applications need setup and teardown. A repository might preload a cache in post_init. A connection pool might close all connections in pre_destroy. The container calls post_init right after construction and pre_destroy during shutdown(). Making these optional (default no-op methods) means only beans that need lifecycle management pay for it. Spring's @PostConstruct and @PreDestroy follow exactly this pattern.
Interview Follow-ups
- "How would you add support for named beans?" Add an optional
nameparameter toregister()and key the registry on(type, name)tuples instead of just the type. At resolve time, if a constructor parameter has a@Named("primary")annotation, look up the matching registration. Without a name qualifier, fall back to the type-only lookup. Spring and Guice both support this for cases where you have multiple implementations of the same interface. - "How does lazy initialization work?" Instead of building the bean immediately on first
resolve(), return a proxy object. The proxy stores a reference to the container and the target type. On the first method call, it resolves the real bean, caches it internally, and delegates. In Python,__getattr__makes this straightforward. In Java,java.lang.reflect.Proxyor byte-code generation (CGLIB) creates the proxy. Lazy init is useful for expensive beans that might not get used in every request path. - "How would you support request-scoped beans in a web application?" Add a
REQUESTscope. Store request-scoped instances in a thread-local (or async-context-local) map keyed by the current request ID. At the end of the request, clear that map and callpre_destroyon every bean in it. Spring's@RequestScopeworks this way, backed by aRequestAttributesholder tied to the current thread. - "What happens if you need to inject configuration values, not just objects?" Introduce a
@Valueannotation or equivalent. During resolution, the container checks if a constructor parameter is annotated with@Value("db.host")and looks up the key in a configuration source (environment variables, a config file, a remote config service). You resolve primitives and strings from config, and complex types from the bean registry. Spring's@Valueand@ConfigurationPropertiessolve this problem.
45-Minute LLD Playbook
Each phase is what the interviewer expects you to do and say. Concrete steps, not topic hints. Diagrams are what you would sketch on the board.
- 15 min
Clarify the container scope and lock the resolution contract
GoalPin down what kinds of beans the container handles, what scopes exist, and how dependencies are detected. Then ask the diagram-vs-code question.
Do & Say- ASK·1Open with: Are we doing constructor injection only, or do we also need setter and field injection? I'll default to constructor injection because it makes dependencies explicit and forbids partially-constructed objects. Setter injection is a v2 if needed.
- SAY·2Lock the scopes: Singleton and Prototype for v1. Request scope and Session scope are common but they need a host (web framework). I'll mention how Request scope plugs in at the end.
- SAY·3Pin the resolution model: Register interface to implementation. Resolve takes the interface and the container walks the implementation's constructor parameter types recursively. Type annotations in Python via inspect, getParameterTypes in Java via reflection.
- SAY·4Commit to circular-dependency fail-fast: A and B referring to each other should produce a clear error like ServiceA -> ServiceB -> ServiceA, not a stack overflow. I detect this with a resolving set passed through recursion.
- ASK·5Ask the process question: Do you want the class diagram first, or code-first with the relationships in the structure? Either is fine, just want to know how to budget the rest. If diagram, the next phase costs 8 minutes. If not, 4 minutes of signatures.
Interviewer is grading: You pick constructor injection over field injection with a reason, not by default. You park Request scope and field injection as explicit v2 items rather than letting them creep into v1. You commit to fail-fast on cycles instead of suggesting lazy proxies as the primary fix.
- 25-10 min
Sketch the API and (optionally) the class diagram
GoalWrite the register and resolve signatures, name where each pattern sits, and decide what the BeanDefinition stores.
Do & Say- SAY·1List the abstractions: Container, BeanDefinition (interface_type, impl_class, scope, dependencies), Scope enum, DependencyResolver, BeanLifecycle interface, singleton cache map.
- WRITE·2Write Container signatures: register(interface_type, impl_class, scope=SINGLETON) -> Container (chainable), resolve(interface_type) -> Any, shutdown() -> None. Say: Register returns self so the wiring code reads top-to-bottom.
- WRITE·3Write BeanDefinition: it stores interface_type, impl_class, scope, and the auto-detected dependency list. In Python that's typing.get_type_hints(impl.__init__); in Java it's the longest-constructor's getParameterTypes. Say: I detect dependencies at registration time, not at resolve time, so the cost is paid once.
- WRITE·4Write resolve(interface_type, resolving=set()) -> instance. If interface_type in resolving, raise with chain joined by -> . Else recurse for each dep with resolving | {interface_type}, then call impl_class(*resolved_deps). Say: Singleton check runs before recursion so cached beans skip the walk.
- SAY·5If diagram requested, draw it. Container in the middle, BeanDefinition map on one side, singleton map on the other, DependencyResolver pointing to Container, BeanLifecycle dashed off with post_init/pre_destroy hooks. Name patterns at each cluster: Registry, Factory, Singleton, Prototype, Proxy.
- SAY·6If no diagram, verbalize: Container holds the registry and singleton cache. BeanDefinition is the factory recipe. DependencyResolver does the recursive walk. Scope decides whether the cache is consulted. BeanLifecycle is optional and detected via isinstance.
Interviewer is grading: register returns the container for fluent chaining. BeanDefinition computes its dependency list at registration time, not at every resolve. The resolving set is a method parameter so concurrent resolves on different threads don't collide. You name Registry as the reason lookup is O(1).
- 325 min
Code in this sequence (bottom-up)
GoalBuild Scope, BeanDefinition, the resolver, then the Container facade. Talk through the order so the interviewer hears why each step needs the previous one.
Do & Say- SAY·1Start with Scope as an Enum with SINGLETON and PROTOTYPE values. Say: Two scopes for v1. Request and Session scopes are subclasses or extensions of this enum, but I'm not designing them today. (~1 min)
- SAY·2Code BeanLifecycle next as an ABC (or interface in Java) with default no-op post_init and pre_destroy. Say: Default no-ops so beans only opt in by overriding. Spring's @PostConstruct and @PreDestroy follow exactly this shape. (~1 min)
- SAY·3Code BeanDefinition as a dataclass with interface_type, impl_class, scope, and dependencies (default empty list). In __post_init__, call typing.get_type_hints on impl_class.__init__, drop the return key, and store the values as the dependency list. Say: Detection runs once at registration. Resolve never re-introspects. (~3 min)
- SAY·4Code DependencyResolver. resolve(interface_type, resolving=None): init resolving to set, raise on cycle with arrow-joined chain. Look up BeanDefinition. If SINGLETON cached, return. Else add to resolving, recurse per dep with resolving.copy(), construct impl_class(*deps). Call post_init if BeanLifecycle. Cache if singleton. Say: resolving.copy() keeps sibling branches independent. (~7 min)
- SAY·5Code Container. _registry dict, _singletons dict, _resolver = DependencyResolver(self). register builds a BeanDefinition, stores, returns self for chaining. resolve delegates to the resolver. _get_definition raises KeyError if missing. Say: Container is the facade. The resolver is private machinery. (~5 min)
- SAY·6Code shutdown. Iterate _singletons items. If isinstance(instance, BeanLifecycle), call pre_destroy and print Destroyed. Then clear _singletons and _registry. Say: pre_destroy runs in registration order. In production, you'd want LIFO order so dependents shut down before their dependencies, but for an interview the simple iteration is fine. (~2 min)
- SAY·7Code a small example to exercise everything: UserRepository interface, InMemoryUserRepository implementing UserRepository and BeanLifecycle, NotificationService interface, ConsoleNotificationService, UserService whose constructor takes both. register all three, resolve UserService, call greet_user, then resolve UserService again and assert it is the same instance. (~4 min)
- SAY·8Mentally walk one circular dep before declaring done. ServiceA depends on ServiceB, ServiceB depends on ServiceA. resolve(ServiceA) adds A to resolving, recurses into B with {A}, adds B, recurses into A, sees A in {A,B}, raises RuntimeError('ServiceA -> ServiceB -> ServiceA). No stack overflow.' This is the self-check. (~2 min)
Interviewer is grading: BeanDefinition introspects in __post_init__, so resolve never reruns it. The resolving set is a parameter, not an instance field. resolving.copy() (or a new LinkedHashSet in Java) protects sibling branches. Singleton cache hit returns before recursing into dependencies. shutdown calls pre_destroy on BeanLifecycle instances only, not blindly on every cached object. You walk the circular-dependency case in your head as a self-check.
- 45 min
Trade-offs, extensions, and wrap-up
GoalDefend two structural decisions, volunteer one extension, close in one sentence.
Do & Say- SAY·1Trade-off one, constructor injection over field/setter: Field injection via reflection hides dependencies. The class looks free of deps but blows up at runtime without the container. Constructor injection puts deps in the public signature, so tests pass mocks directly without the container.
- SAY·2Trade-off two, fail-fast on cycles over lazy proxies: Spring uses lazy proxies for one bean, which hides the design smell. Fail-fast with a clear chain (ServiceA -> ServiceB -> ServiceA) forces a setter, method-level provider, or dependency refactor. For v1, fail-fast wins.
- SAY·3Extension to volunteer, named beans: Two implementations of UserRepository (primary Postgres, fallback in-memory) need a name qualifier. Register with an optional name, key the registry on (type, name) tuples, and resolve checks a @Named annotation on the constructor parameter. Both Spring and Guice support this exact mechanism.
- SAY·4Ready for the request-scope question: Add REQUEST to the Scope enum. Store request-scoped instances in a ContextVar (Python) or ThreadLocal (Java) keyed by request id. On request end, iterate and call pre_destroy. Resolver checks this storage before the singleton cache.
- SAY·5Close with one sentence: Registry maps interfaces to BeanDefinitions for O(1) lookup. BeanDefinition is the factory recipe. DependencyResolver walks the constructor graph with a resolving set to fail-fast on cycles. Singleton caches in the container, Prototype constructs fresh. BeanLifecycle hooks fire around construction and shutdown.
Interviewer is grading: Constructor injection is defended specifically by naming testability without the container. Fail-fast on cycles is defended by naming the lazy-proxy alternative honestly rather than pretending it doesn't exist. Request scope is named as the natural Scope enum extension. You can summarize all five patterns in one sentence under 20 seconds.
Code Implementation
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Type, Dict, List, Optional, Set
import inspect
import typing
# --------------- Scope ---------------
class Scope(Enum):
SINGLETON = "SINGLETON"
PROTOTYPE = "PROTOTYPE"
# --------------- Lifecycle Interface ---------------
class BeanLifecycle(ABC):
"""Optional interface. Beans can implement this for init/destroy hooks."""
def post_init(self) -> None:
"""Called right after the container constructs the bean."""
pass
def pre_destroy(self) -> None:
"""Called when the container shuts down."""
pass
# --------------- Bean Definition ---------------
@dataclass
class BeanDefinition:
interface_type: Type
impl_class: Type
scope: Scope
dependencies: list[Type] = field(default_factory=list)
def __post_init__(self):
# Auto-detect constructor dependencies by inspecting the impl class
try:
hints = typing.get_type_hints(self.impl_class.__init__)
except Exception:
hints = {}
hints.pop('return', None)
self.dependencies = list(hints.values())
# --------------- Dependency Resolver ---------------
class DependencyResolver:
"""
Resolves a dependency graph by walking constructor parameters.
Detects circular dependencies before constructing anything.
"""
def __init__(self, container: "Container"):
self._container = container
def resolve(self, interface_type: Type, resolving: Optional[Set[Type]] = None) -> Any:
if resolving is None:
resolving = set()
if interface_type in resolving:
chain = " -> ".join(t.__name__ for t in resolving)
raise RuntimeError(
f"Circular dependency detected: {chain} -> {interface_type.__name__}"
)
bean_def = self._container._get_definition(interface_type)
# Singleton cache check
if bean_def.scope == Scope.SINGLETON:
cached = self._container._get_singleton(interface_type)
if cached is not None:
return cached
resolving.add(interface_type)
# Recursively resolve all constructor dependencies
resolved_deps = []
for dep_type in bean_def.dependencies:
resolved_deps.append(self.resolve(dep_type, resolving.copy()))
# Build the instance
instance = bean_def.impl_class(*resolved_deps)
# Lifecycle hook
if isinstance(instance, BeanLifecycle):
instance.post_init()
# Cache singletons
if bean_def.scope == Scope.SINGLETON:
self._container._set_singleton(interface_type, instance)
return instance
# --------------- Container ---------------
class Container:
"""
Inversion of Control container. You register interface-to-implementation
mappings with a scope, then resolve any interface and the container builds
the full object graph for you.
"""
def __init__(self):
self._registry: Dict[Type, BeanDefinition] = {}
self._singletons: Dict[Type, Any] = {}
self._resolver = DependencyResolver(self)
def register(
self,
interface_type: Type,
impl_class: Type,
scope: Scope = Scope.SINGLETON,
) -> "Container":
"""Register a mapping. Returns self for chaining."""
bean_def = BeanDefinition(
interface_type=interface_type,
impl_class=impl_class,
scope=scope,
)
self._registry[interface_type] = bean_def
print(f" Registered: {interface_type.__name__} -> {impl_class.__name__} [{scope.value}]")
return self
def resolve(self, interface_type: Type) -> Any:
"""Resolve an interface to a fully constructed instance."""
instance = self._resolver.resolve(interface_type)
print(f" Resolved: {interface_type.__name__} -> {type(instance).__name__}")
return instance
def _get_definition(self, interface_type: Type) -> BeanDefinition:
if interface_type not in self._registry:
name = getattr(interface_type, "__name__", str(interface_type))
raise KeyError(f"No registration found for {name}")
return self._registry[interface_type]
def _get_singleton(self, interface_type: Type) -> Optional[Any]:
return self._singletons.get(interface_type)
def _set_singleton(self, interface_type: Type, instance: Any) -> None:
self._singletons[interface_type] = instance
def shutdown(self) -> None:
"""Call pre_destroy on all singleton beans, then clear everything."""
for interface_type, instance in self._singletons.items():
if isinstance(instance, BeanLifecycle):
instance.pre_destroy()
print(f" Destroyed: {interface_type.__name__}")
self._singletons.clear()
self._registry.clear()
print(" Container shut down.")
# --------------- Example Domain Classes ---------------
class UserRepository(ABC):
@abstractmethod
def find_by_id(self, user_id: int) -> str: ...
class InMemoryUserRepository(UserRepository, BeanLifecycle):
def __init__(self):
self._store: Dict[int, str] = {}
def post_init(self) -> None:
self._store = {1: "Alice", 2: "Bob", 3: "Charlie"}
print(f" [InMemoryUserRepository] post_init: loaded {len(self._store)} users")
def pre_destroy(self) -> None:
print(f" [InMemoryUserRepository] pre_destroy: clearing store")
self._store.clear()
def find_by_id(self, user_id: int) -> str:
return self._store.get(user_id, "Unknown")
class NotificationService(ABC):
@abstractmethod
def send(self, user: str, message: str) -> None: ...
class ConsoleNotificationService(NotificationService):
def send(self, user: str, message: str) -> None:
print(f" [Notification] -> {user}: {message}")
class UserService:
"""Depends on UserRepository and NotificationService via constructor."""
def __init__(self, repo: UserRepository, notifier: NotificationService):
self._repo = repo
self._notifier = notifier
def greet_user(self, user_id: int) -> str:
name = self._repo.find_by_id(user_id)
self._notifier.send(name, "Welcome back!")
return f"Greeted {name}"
# --------------- Demo ---------------
if __name__ == "__main__":
print("=== Register Beans ===")
container = Container()
container.register(UserRepository, InMemoryUserRepository, Scope.SINGLETON)
container.register(NotificationService, ConsoleNotificationService, Scope.SINGLETON)
container.register(UserService, UserService, Scope.SINGLETON)
print("\n=== Resolve UserService (auto-injects dependencies) ===")
user_service = container.resolve(UserService)
print(f" Result: {user_service.greet_user(1)}")
print(f" Result: {user_service.greet_user(2)}")
print("\n=== Singleton Verification ===")
us1 = container.resolve(UserService)
us2 = container.resolve(UserService)
print(f" Same instance? {us1 is us2}")
print("\n=== Prototype Scope ===")
container2 = Container()
container2.register(NotificationService, ConsoleNotificationService, Scope.PROTOTYPE)
n1 = container2.resolve(NotificationService)
n2 = container2.resolve(NotificationService)
print(f" Same instance? {n1 is n2}")
print("\n=== Circular Dependency Detection ===")
class ServiceA:
def __init__(self, b: "ServiceB"):
self.b = b
class ServiceB:
def __init__(self, a: ServiceA):
self.a = a
container3 = Container()
container3.register(ServiceA, ServiceA, Scope.SINGLETON)
container3.register(ServiceB, ServiceB, Scope.SINGLETON)
try:
container3.resolve(ServiceA)
except RuntimeError as e:
print(f" Caught: {e}")
print("\n=== Swap Implementation ===")
class PostgresUserRepository(UserRepository):
def find_by_id(self, user_id: int) -> str:
return f"PG-User-{user_id}"
container4 = Container()
container4.register(UserRepository, PostgresUserRepository, Scope.SINGLETON)
container4.register(NotificationService, ConsoleNotificationService, Scope.SINGLETON)
container4.register(UserService, UserService, Scope.SINGLETON)
svc = container4.resolve(UserService)
print(f" Result with Postgres: {svc.greet_user(42)}")
print("\n=== Shutdown ===")
container.shutdown()
print("\nAll operations completed successfully.")Interview Grading by Level
What an interviewer at each level expects to see in your answer. Use this to calibrate, not to perform.
Junior Engineer (L3)
Can register and resolve flat dependencies, but the recursive walk, cycle detection, and singleton cache stay vague.
- Knows that DI inverts the 'new ServiceX(new ServiceY(...))' wiring.
- Writes a Map from Class to instance for the singleton case.
- Detects constructor parameter types via reflection or type hints when prompted.
- Adds the Scope enum with SINGLETON and PROTOTYPE when reminded that one instance is not always what you want.
- Acknowledges that calling new in business code defeats the container.
- Builds the instance on every resolve even for SINGLETON, losing identity.
- Recurses into dependencies without any cycle protection, hitting stack overflow on the A-B-A case.
- Keys the registry on impl_class instead of interface_type, so swapping implementations needs a code change everywhere.
- Forgets pre_destroy entirely or hardcodes a destroy method on every bean.
- Mixes resolve and construct logic into one method that is hard to test.
Mid-Level Engineer (L4)
Drives the container end-to-end with a recursive resolver, cycle detection, singleton caching, and a clean BeanLifecycle hook.
- Detects constructor dependencies in BeanDefinition at registration time, not at every resolve.
- Implements the resolving-set cycle detection with a clear error chain (A -> B -> A).
- Caches singletons in a per-container map and returns the cache hit before recursing.
- Treats BeanLifecycle as an optional opt-in via isinstance / instanceof, not a required base class.
- Defends constructor injection over setter and field injection on testability grounds.
- Keys the registry on interface_type so PostgresUserRepository swaps for InMemoryUserRepository with one line.
- Does not volunteer named beans as the natural extension when two implementations of the same interface exist.
- Misses the lazy-proxy alternative when defending fail-fast on cycles.
- Treats Request scope as out of scope rather than naming it as a Scope enum entry with ThreadLocal storage.
Senior Engineer (L5+)
Volunteers named beans, request scope, and configuration injection unprompted, and names each pattern as a defense against a specific failure mode.
- Volunteers named beans with (type, name) tuple registry keys and @Named-style annotations.
- Proposes Request scope as a Scope.REQUEST entry backed by ContextVar or ThreadLocal, with pre_destroy on request end.
- Suggests lazy initialization via __getattr__ proxies (Python) or java.lang.reflect.Proxy (Java) as the optimization for expensive rarely-used beans.
- Names @Value or equivalent configuration injection for primitive and string parameters resolved from environment or config source.
- Defends fail-fast cycle detection by naming Spring's lazy-proxy alternative honestly and explaining when each is appropriate.
- Frames each pattern as a defense against a specific failure: Registry against O(n) lookup, Factory (BeanDefinition) against tight coupling, Singleton cache against duplicate construction, Proxy against eager-init cost, Strategy (Scope) against scope sprawl in the resolver.
- Closes with a one-sentence summary that names Container, BeanDefinition, DependencyResolver, Scope, and BeanLifecycle in under 20 seconds.
Common Mistakes
- ✗Not detecting circular dependencies: the resolver recurses forever and blows the stack
- ✗Creating a new instance on every resolve call even for singleton scope, breaking identity guarantees and wasting memory
- ✗Tight coupling between the container internals and registered classes, so adding a new class forces changes inside the container
- ✗Not supporting interface-to-implementation binding: callers must ask for concrete classes, defeating the whole point of inversion of control
Key Points
- ✓The Registry pattern gives you O(1) lookup from an interface to its BeanDefinition, so the container never scans linearly
- ✓Singleton scope stores one instance in a cache and returns it on every resolve call. Prototype scope calls the factory fresh each time.
- ✓Constructor injection with automatic resolution means you declare what a class needs in its constructor, and the container figures out the build order by inspecting parameter types
- ✓Circular dependency detection runs before any object is created. If A needs B and B needs A, you get a clear error instead of infinite recursion.