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.
Code Implementation
1 from abc import ABC, abstractmethod
2 from dataclasses import dataclass, field
3 from enum import Enum
4 from typing import Any, Type, Dict, List, Optional, Set
5 import inspect
6 import typing
7
8
9 # --------------- Scope ---------------
10
11 class Scope(Enum):
12 SINGLETON = "SINGLETON"
13 PROTOTYPE = "PROTOTYPE"
14
15
16 # --------------- Lifecycle Interface ---------------
17
18 class BeanLifecycle(ABC):
19 """Optional interface. Beans can implement this for init/destroy hooks."""
20
21 def post_init(self) -> None:
22 """Called right after the container constructs the bean."""
23 pass
24
25 def pre_destroy(self) -> None:
26 """Called when the container shuts down."""
27 pass
28
29
30 # --------------- Bean Definition ---------------
31
32 @dataclass
33 class BeanDefinition:
34 interface_type: Type
35 impl_class: Type
36 scope: Scope
37 dependencies: list[Type] = field(default_factory=list)
38
39 def __post_init__(self):
40 # Auto-detect constructor dependencies by inspecting the impl class
41 try:
42 hints = typing.get_type_hints(self.impl_class.__init__)
43 except Exception:
44 hints = {}
45 hints.pop('return', None)
46 self.dependencies = list(hints.values())
47
48
49 # --------------- Dependency Resolver ---------------
50
51 class DependencyResolver:
52 """
53 Resolves a dependency graph by walking constructor parameters.
54 Detects circular dependencies before constructing anything.
55 """
56
57 def __init__(self, container: "Container"):
58 self._container = container
59
60 def resolve(self, interface_type: Type, resolving: Optional[Set[Type]] = None) -> Any:
61 if resolving is None:
62 resolving = set()
63
64 if interface_type in resolving:
65 chain = " -> ".join(t.__name__ for t in resolving)
66 raise RuntimeError(
67 f"Circular dependency detected: {chain} -> {interface_type.__name__}"
68 )
69
70 bean_def = self._container._get_definition(interface_type)
71
72 # Singleton cache check
73 if bean_def.scope == Scope.SINGLETON:
74 cached = self._container._get_singleton(interface_type)
75 if cached is not None:
76 return cached
77
78 resolving.add(interface_type)
79
80 # Recursively resolve all constructor dependencies
81 resolved_deps = []
82 for dep_type in bean_def.dependencies:
83 resolved_deps.append(self.resolve(dep_type, resolving.copy()))
84
85 # Build the instance
86 instance = bean_def.impl_class(*resolved_deps)
87
88 # Lifecycle hook
89 if isinstance(instance, BeanLifecycle):
90 instance.post_init()
91
92 # Cache singletons
93 if bean_def.scope == Scope.SINGLETON:
94 self._container._set_singleton(interface_type, instance)
95
96 return instance
97
98
99 # --------------- Container ---------------
100
101 class Container:
102 """
103 Inversion of Control container. You register interface-to-implementation
104 mappings with a scope, then resolve any interface and the container builds
105 the full object graph for you.
106 """
107
108 def __init__(self):
109 self._registry: Dict[Type, BeanDefinition] = {}
110 self._singletons: Dict[Type, Any] = {}
111 self._resolver = DependencyResolver(self)
112
113 def register(
114 self,
115 interface_type: Type,
116 impl_class: Type,
117 scope: Scope = Scope.SINGLETON,
118 ) -> "Container":
119 """Register a mapping. Returns self for chaining."""
120 bean_def = BeanDefinition(
121 interface_type=interface_type,
122 impl_class=impl_class,
123 scope=scope,
124 )
125 self._registry[interface_type] = bean_def
126 print(f" Registered: {interface_type.__name__} -> {impl_class.__name__} [{scope.value}]")
127 return self
128
129 def resolve(self, interface_type: Type) -> Any:
130 """Resolve an interface to a fully constructed instance."""
131 instance = self._resolver.resolve(interface_type)
132 print(f" Resolved: {interface_type.__name__} -> {type(instance).__name__}")
133 return instance
134
135 def _get_definition(self, interface_type: Type) -> BeanDefinition:
136 if interface_type not in self._registry:
137 name = getattr(interface_type, "__name__", str(interface_type))
138 raise KeyError(f"No registration found for {name}")
139 return self._registry[interface_type]
140
141 def _get_singleton(self, interface_type: Type) -> Optional[Any]:
142 return self._singletons.get(interface_type)
143
144 def _set_singleton(self, interface_type: Type, instance: Any) -> None:
145 self._singletons[interface_type] = instance
146
147 def shutdown(self) -> None:
148 """Call pre_destroy on all singleton beans, then clear everything."""
149 for interface_type, instance in self._singletons.items():
150 if isinstance(instance, BeanLifecycle):
151 instance.pre_destroy()
152 print(f" Destroyed: {interface_type.__name__}")
153 self._singletons.clear()
154 self._registry.clear()
155 print(" Container shut down.")
156
157
158 # --------------- Example Domain Classes ---------------
159
160 class UserRepository(ABC):
161 @abstractmethod
162 def find_by_id(self, user_id: int) -> str: ...
163
164 class InMemoryUserRepository(UserRepository, BeanLifecycle):
165 def __init__(self):
166 self._store: Dict[int, str] = {}
167
168 def post_init(self) -> None:
169 self._store = {1: "Alice", 2: "Bob", 3: "Charlie"}
170 print(f" [InMemoryUserRepository] post_init: loaded {len(self._store)} users")
171
172 def pre_destroy(self) -> None:
173 print(f" [InMemoryUserRepository] pre_destroy: clearing store")
174 self._store.clear()
175
176 def find_by_id(self, user_id: int) -> str:
177 return self._store.get(user_id, "Unknown")
178
179
180 class NotificationService(ABC):
181 @abstractmethod
182 def send(self, user: str, message: str) -> None: ...
183
184 class ConsoleNotificationService(NotificationService):
185 def send(self, user: str, message: str) -> None:
186 print(f" [Notification] -> {user}: {message}")
187
188
189 class UserService:
190 """Depends on UserRepository and NotificationService via constructor."""
191
192 def __init__(self, repo: UserRepository, notifier: NotificationService):
193 self._repo = repo
194 self._notifier = notifier
195
196 def greet_user(self, user_id: int) -> str:
197 name = self._repo.find_by_id(user_id)
198 self._notifier.send(name, "Welcome back!")
199 return f"Greeted {name}"
200
201
202 # --------------- Demo ---------------
203
204 if __name__ == "__main__":
205
206 print("=== Register Beans ===")
207 container = Container()
208 container.register(UserRepository, InMemoryUserRepository, Scope.SINGLETON)
209 container.register(NotificationService, ConsoleNotificationService, Scope.SINGLETON)
210 container.register(UserService, UserService, Scope.SINGLETON)
211
212 print("\n=== Resolve UserService (auto-injects dependencies) ===")
213 user_service = container.resolve(UserService)
214 print(f" Result: {user_service.greet_user(1)}")
215 print(f" Result: {user_service.greet_user(2)}")
216
217 print("\n=== Singleton Verification ===")
218 us1 = container.resolve(UserService)
219 us2 = container.resolve(UserService)
220 print(f" Same instance? {us1 is us2}")
221
222 print("\n=== Prototype Scope ===")
223 container2 = Container()
224 container2.register(NotificationService, ConsoleNotificationService, Scope.PROTOTYPE)
225 n1 = container2.resolve(NotificationService)
226 n2 = container2.resolve(NotificationService)
227 print(f" Same instance? {n1 is n2}")
228
229 print("\n=== Circular Dependency Detection ===")
230
231 class ServiceA:
232 def __init__(self, b: "ServiceB"):
233 self.b = b
234
235 class ServiceB:
236 def __init__(self, a: ServiceA):
237 self.a = a
238
239 container3 = Container()
240 container3.register(ServiceA, ServiceA, Scope.SINGLETON)
241 container3.register(ServiceB, ServiceB, Scope.SINGLETON)
242 try:
243 container3.resolve(ServiceA)
244 except RuntimeError as e:
245 print(f" Caught: {e}")
246
247 print("\n=== Swap Implementation ===")
248 class PostgresUserRepository(UserRepository):
249 def find_by_id(self, user_id: int) -> str:
250 return f"PG-User-{user_id}"
251
252 container4 = Container()
253 container4.register(UserRepository, PostgresUserRepository, Scope.SINGLETON)
254 container4.register(NotificationService, ConsoleNotificationService, Scope.SINGLETON)
255 container4.register(UserService, UserService, Scope.SINGLETON)
256 svc = container4.resolve(UserService)
257 print(f" Result with Postgres: {svc.greet_user(42)}")
258
259 print("\n=== Shutdown ===")
260 container.shutdown()
261
262 print("\nAll operations completed successfully.")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.