Dependency Injection
Learn how to build a lightweight IoC container: bean registration, lifecycle management, circular dependency detection, and the tradeoffs between reflection-based and code-generation-based injection.
TL;DR
An IoC container turns class metadata into a dependency graph. It scans or receives bean definitions, validates bindings and qualifiers, detects cycles before serving requests, instantiates eager singletons in dependency order, and delegates prototype or request-scoped creation to scope handlers. The runtime hot path is a scope-aware lookup; lifecycle hooks and a durable startup error make resource ownership explicit. Reflection is flexible and dynamic, while code generation moves binding errors and construction cost to build time.
Scope and assumptions
- The design is an in-process container for one application runtime. It supports
@Componentor explicit registration,@Inject, qualifiers, Singleton, Prototype, and Request scopes. - The baseline numbers are illustrative targets: 1,000 beans resolved in under 2 seconds, lookup around 1 microsecond, and 10,000 definitions within 50 MB of registry overhead. Measure these on the target JVM and classpath rather than treating them as guarantees.
- Singleton beans are eagerly initialized during
ctx.start(). If lazy singleton creation is added, the cache needs a concurrency-safe creation path and startup no longer validates every construction failure. - Request scope is bound by the hosting framework. A
ThreadLocalimplementation requires explicit context propagation for asynchronous work and must be cleared at the end of every request. - A prototype bean is not automatically destroyed after injection in this design; callers or an explicit ownership extension must release resources it owns.
Numbers, reflection speedups, and scan times below are illustrative engineering estimates, not universal JVM properties.
What is a dependency injection framework?
A dependency injection framework (also called an IoC container) takes ownership of object creation: callers declare what they need through annotations or configuration, and the container creates, wires, and manages the lifecycle of all objects. The question tests your knowledge of framework internals, specifically how the container discovers beans, detects cycles, and supports different object lifetimes without application code changing how objects are constructed.
Functional Requirements
Core Requirements
- Application code can declare a class as a managed bean using an annotation (
@Component) or explicit registration. The container creates instances of managed beans on startup. - Application code can declare constructor, field, or method dependencies using
@Inject. The container resolves them by finding a registered bean of the matching type. - The container detects circular dependencies at startup and fails with a clear, actionable error before any requests are served.
- The container supports at minimum Singleton (one per container) and Prototype (new per injection) scopes. An HTTP-aware extension supports Request scope (one per HTTP request via thread-local).
Below the Line (out of scope)
- Auto-proxying for cross-cutting concerns (AOP, transactions)
- Conditional bean registration (
@ConditionalOnProperty) - Container hierarchy and parent containers
- Hot reload of bean definitions without restart
The hardest part in scope: Detecting all forms of circular dependencies at startup before any user-visible request is served, and doing it in a way that reports the exact cycle path rather than an opaque stack overflow.
The cycle detection algorithm is the heart of a DI container: it distinguishes a clear startup error from an opaque recursive failure and defines whether any request can observe a partially wired graph.
Auto-proxying is below the line because it requires wrapping every bean in a dynamic proxy at creation time, which doubles instantiation overhead and requires tracking proxy-bean pairs separately from the singleton cache. To add it, implement a BeanPostProcessor interface that the instantiation engine calls after creating each bean, allowing post-processors to wrap the instance in a proxy before returning it.
Conditional registration is below the line because it requires evaluating conditions against the runtime environment during the scan phase, before the dependency graph is built. To add it, execute condition evaluators in a pre-validation pass and remove pending BeanDefinition entries that fail their condition before building the graph.
Hot reload is out of scope because reloading mid-flight requires draining in-flight requests, destroying old singletons, rebuilding the dependency graph, and re-instantiating affected beans. To add a limited version, support a reload() method that closes the context, re-scans with the same base package, and reopens it.
Non-Functional Requirements
Core Requirements
- Startup time: Resolve a dependency graph of 1,000 beans in under 2 seconds using reflection-based injection.
- Runtime overhead: Singleton bean lookup (
getBean) completes in under 1 microsecond (a hash map lookup). - Memory: The bean registry holds complete metadata for 10,000 registered beans without exceeding 50 MB overhead.
- Correctness: Circular dependency detection catches all cycles deterministically at startup with zero false negatives, and reports the full cycle path.
- Thread safety: The singleton scope is thread-safe. Multiple threads calling
getBean()concurrently must never observe two different instances for the same type.
Below the Line
- Sub-100ms startup time (requires code-generation approach)
- Distributed container spanning multiple JVMs
- Bean version management or A/B bean routing
Read/write ratio analysis: This is not a request-serving system, so read/write ratio does not apply in the traditional sense. The container performs its write-heavy work (scan, validate, and eager singleton instantiation) during the
ctx.start()phase. After startup, singletongetBean()is a pureConcurrentHashMapread with no instantiation allocation; prototype and request-scoped lookups intentionally create or retrieve scoped objects. The design therefore optimizes separately for fast startup and low-cost singleton lookups.
30-Second Answer / Outline
- Discover
@Componentclasses or accept explicit registrations and normalize them intoBeanDefinitionrecords. - Build a directed graph from each bean to its dependencies, validate qualifiers and scopes, and run a topological sort.
- Fail startup with the exact cycle path if the graph is cyclic; otherwise instantiate eager singletons in dependency order.
- Store singleton instances in a thread-safe cache, create prototype instances per request, and bind request-scoped instances to the hosting request context.
- Run lifecycle hooks at the right scope boundary and keep reflection or generated factories behind the same instantiation interface.
5-Minute Explanation
Application code declares providers and injection points; it does not construct the full object graph at every call site. At ctx.start(), the scanner reads annotations, the BeanRegistry records types, scopes, qualifiers, and injection points, and the validator resolves each dependency to a definition. Kahn's topological sort both detects cycles and produces an order in which dependencies can be created first.
The Instantiation Engine walks that order, calls constructors or generated factories, injects fields or methods where supported, runs @PostConstruct, and stores singleton instances. getBean() then reads the singleton cache. A prototype skips the cache and receives a fresh object; a request-scoped bean is cached in the active request context and cleaned up at request end. Provider<T> or Lazy<T> is the escape hatch for optional, deferred, or scope-mismatched dependencies.
Reflection keeps discovery and wiring dynamic but defers many errors to startup and adds reflective overhead. Annotation processing generates direct factory calls and catches missing bindings at compile time, at the cost of build tooling and less runtime flexibility. Both strategies can implement the same lifecycle and scope contracts.
45-Minute Interview Approach
This is a discussion plan for the design question, not a claim that the article should take 45 minutes to read.
- 0-5 minutes β Clarify the contract: Confirm language/runtime, annotation versus explicit registration, supported injection points, scopes, eager versus lazy creation, and whether HTTP request context is in scope.
- 5-10 minutes β Estimate the workload: Use the illustrative bean counts and separate startup scanning/instantiation from steady-state
getBean()reads. - 10-16 minutes β Draw the lifecycle: Show Scanner β BeanRegistry β Validator β Instantiation Engine β Singleton Cache, and walk through
ctx.start(). - 16-23 minutes β Validate the graph: Explain type/qualifier resolution, topological ordering, missing bindings, constructor versus field injection, and cycle-path errors.
- 23-30 minutes β Add scopes: Compare Singleton, Prototype, and Request storage and destruction semantics; cover
Provider<T>for lazy or scope-mismatched dependencies. - 30-36 minutes β Cover concurrency and lifecycle: Discuss singleton publication,
@PostConstruct,@PreDestroy, shutdown ordering, and request-context cleanup. - 36-41 minutes β Compare implementation strategies: Contrast reflection with compile-time code generation and explain when the startup/runtime trade-off matters.
- 41-45 minutes β Close with pitfalls: Mention async request context propagation, prototype resource ownership, qualifiers, testability, and the out-of-scope AOP/container hierarchy extensions.
Core Entities
- BeanDefinition: Metadata for one managed bean. Contains the class type, scope (SINGLETON / PROTOTYPE / REQUEST), injection points (constructor params, annotated fields), lifecycle hooks (
@PostConstruct,@PreDestroy), and qualifier names. - BeanRegistry: The in-memory map from
(type, qualifier)toBeanDefinition. Populated during the scan or explicit registration phase, before any injection occurs. - Singleton Cache: A
ConcurrentHashMap<Class, Object>holding one fully initialized instance per singleton-scoped type. Populated duringctx.start()and read-only at runtime. - ApplicationContext (the container): The public API surface. Exposes
getBean(Class),getBeansOfType(Class), and lifecycle methods (start(),close()).
Schema and data structure decisions are expanded in the deep dives. These four entities anchor every phase of the container lifecycle: scan populates the BeanRegistry, validation reads BeanDefinitions, instantiation writes to the Singleton Cache, and the ApplicationContext orchestrates the entire flow.
API Design
Two surfaces exist: user-facing annotations (how application code declares beans and dependencies) and the container API (how application code boots the framework and retrieves beans).
# Annotation API: declare a managed bean with its dependencies
@Component
class OrderService:
# Constructor injection preferred: dependencies are explicit and final
@Inject
def __init__(self, users: UserRepository, email: EmailService):
self.users = users
self.email = email
@Component
@Scope("prototype") // new instance on every getBean() call
class ShoppingCart:
@Inject
def __init__(self, pricing: PricingService): ...
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.