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:
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.