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.
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.
I always lead with this in interviews because it separates people who have read the Spring docs from people who have debugged a BeanCurrentlyInCreationException at 2 AM. The cycle detection algorithm is the heart of a DI container.
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 all its write-heavy work (scan, validate, instantiate) during the
ctx.start()phase. After startup,getBean()is a pure read operation: aConcurrentHashMaplookup with no writes, no allocations, and no contention. The ratio is effectively infinite reads to zero writes at runtime. This means the entire design optimizes for two things: fast startup (write phase) and zero-cost lookups (read phase).
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): ...
# Container API: boot and retrieve beans
ctx = ApplicationContext(base_package="com.example")
ctx.start() # scan -> validate -> instantiate singletons
# Singleton returns cached instance: O(1) hash map lookup
orders = ctx.get_bean(OrderService)
# Multiple implementations: resolve by qualifier name
stripe_gw = ctx.get_bean(PaymentGateway, qualifier="stripe")
paypal_gw = ctx.get_bean(PaymentGateway, qualifier="paypal")
ctx.close() # trigger @PreDestroy hooks on all singletons
// No HTTP API: the DI framework is entirely process-internal.
// The "client" is always application code within the same JVM process.
// This HTTP block illustrates what a health check endpoint might expose:
GET /actuator/beans
Response: 200 OK
{
"beans": [
{ "name": "orderService", "scope": "singleton", "type": "com.example.OrderService" },
{ "name": "shoppingCart", "scope": "prototype", "type": "com.example.ShoppingCart" }
]
}
The ctx.start() call is the only synchronous initialization phase. After it returns successfully, all singletons are created, all cycles have been checked, and getBean() is a pure hash map read with no allocation.
I always emphasize the two-phase model in interviews: the write-heavy startup phase where correctness matters (cycle detection, ordering) and the read-only runtime phase where performance matters (O(1) lookups). Conflating the two leads to confused designs.
High-Level Design
1. Naive approach: manual object construction
Without a container, application code constructs all its dependencies explicitly. Each call site is responsible for knowing the full construction tree, wiring collaborators together, and maintaining the correct initialization order.
// Naive: caller owns the entire construction tree
db_config = load_config("db")
smtp_config = load_config("smtp")
repo = PostgresUserRepository(db_config)
email = SmtpEmailService(smtp_config)
orders = OrderService(repo, email) // caller must know this order
The coupling problem:
Changing UserRepository to a different implementation requires updating every call site that constructs it. Testing requires replacing constructor arguments at every construction point, and swapping implementations means modifying every call site. At 50 classes this becomes unmaintainable.
I have seen production codebases with 200-line initialization methods that construct every service in order. One constructor signature change cascades to dozens of files. This pain is exactly what motivates the container.
2. Evolved approach: IoC container manages construction
The container inverts control. Application code annotates what it provides and what it needs; the container builds the full wiring graph and drives construction.
Three-phase startup:
Phase 1 - Scan and Register: The container scans the target package for @Component classes, reads their @Inject points, and builds a BeanDefinition for each class in the BeanRegistry.
Phase 2 - Validate: The container builds a directed dependency graph (edge A to B means A depends on B) and runs topological sort. Any cycle is a startup failure with the cycle path included in the error.
Phase 3 - Instantiate: The container walks the topological initialization order and creates singleton instances, injecting already-created dependencies. After all singletons are created, start() returns.
3. Bean lookup at runtime
Once start() completes, singleton beans are in the cache and getBean() is a single hash map lookup. Prototype beans are created fresh on every getBean() call by re-running the instantiation logic without caching.
4. Lazy and optional dependencies
Injecting Provider<T> instead of T defers bean creation until the caller needs it. The container injects a thin wrapper at startup that closes over the scope handler; the actual bean is created inside its scope when .get() is called on the wrapper.
This solves two problems: a singleton that needs a request-scoped collaborator (inject Provider<ShoppingCart> instead of ShoppingCart directly), and an optional dependency where the binding may not exist (check the provider before calling .get()). I always mention this pattern in interviews because it shows you understand the scope mismatch problem that trips up most Spring developers.
Potential Deep Dives
1. How does the container discover and register beans?
The container must find all classes that declare themselves as managed beans without requiring the application to register them one by one.
2. How do you detect circular dependencies at startup?
A circular dependency means A depends on B and B depends on A (directly or through a chain). Attempting to instantiate either first causes infinite recursion without detection.
3. How do you implement the three standard bean scopes?
Bean scope determines how many instances exist for a type and who manages their lifecycle. Singleton, Prototype, and Request scopes require different storage and cleanup semantics.
4. Reflection-based vs code-generation-based injection
The underlying mechanism the container uses to create beans and set their dependencies determines startup speed, runtime overhead, and type safety at the framework level.
Final Architecture
The Classpath Scanner populates the BeanRegistry with a BeanDefinition for every @Component class. The Dependency Validator builds the directed dependency graph and runs Kahn's topological sort: any cycle causes a startup failure with the full cycle path in the error message. The Instantiation Engine walks the initialization order and creates singleton instances, either via reflection or generated factories, storing each in the Singleton Cache.
Request-scoped beans are stored in a ThreadLocal map bound to the current HTTP request thread by the hosting framework. Lifecycle hooks (@PostConstruct, @PreDestroy) are called by the engine at creation time and by ctx.close() at shutdown, ensuring connections and file handles are released cleanly.
Interview Cheat Sheet
- Inversion of control means the container owns object creation: application code declares what it needs via
@Injectand@Component, and the container resolves the full dependency graph at startup without callers knowing how objects are made. - Bean discovery via classpath scanning: the container scans the target package for
@Componentclasses, reads their injection points from bytecode metadata, and registers aBeanDefinitionfor each, eliminating manual registration for every class. - Detect circular dependencies at startup using Kahn's topological sort on the dependency graph: if the sort cannot process all nodes (any remain with non-zero in-degree), a cycle exists; report the cycle path and fail fast before any request is served.
- Constructor injection is preferred over field injection: dependencies are explicit, the object cannot be created without them, and there is no need for
setAccessible(true)to write private fields. - For field injection cycles, Spring inserts a proxy of the partially created bean to break the cycle; for constructor injection cycles, no proxy can help and the container must throw an error, which is the correct behavior since constructor cycles are structural design problems.
- Singleton scope uses a
ConcurrentHashMap<Class, Object>populated once at startup:getBean()after start is an O(1) read with no instantiation allocation. - Prototype scope creates a new instance on every
getBean()call with no caching: the container holds no reference after injection, so@PreDestroyis never called and the caller is responsible for cleanup. - Request scope uses a
ThreadLocal<Map<Class, Object>>bound by the hosting framework:begin_request()binds a fresh map,end_request()destroys all request-scoped beans and unbinds the map. - Reflection-based injection (Spring) uses
setAccessible(true)and reflective constructor calls: flexible and dynamic but 2-5x slower than direct calls and incompatible with Java module encapsulation without--add-opensflags. - Code-generation injection (Dagger) runs an annotation processor at compile time that generates plain Java factory classes: zero reflection at runtime, direct constructor calls, and compile-time detection of missing bindings.
- The topological sort produces the singleton initialization order directly: dependencies come first in the output sequence, so every bean's collaborators already exist before the bean itself is instantiated.
- Support
Provider<T>andLazy<T>wrappers for lazy dependencies: inject the wrapper at startup, and the container creates the actual bean only when.get()is called on the wrapper. - For thread safety on the singleton cache, initialize singletons eagerly at
ctx.start()(not lazily at firstgetBean()): this moves the initialization work to startup and makesgetBean()at runtime a pure read requiring no synchronization beyond theConcurrentHashMap.