Skip to main content

Dependency Injection Container Design and Implementation

Comprehensive low-level design for a production-ready dependency injection container with singleton, prototype, and lifecycle management capabilities.

A dependency injection container is a core component in modern backend systems that manages object creation, lifecycle, and dependencies while promoting loose coupling and testability in enterprise applications.

1. Problem Statement & Requirements

Clarifying questions

  1. What is the expected scale of the DI container? Will it handle hundreds of thousands of components or just a few dozen?
  2. What are the consistency and availability requirements? Should it support high availability with zero downtime or can it tolerate brief outages?
  3. What types of components are expected to be managed? Are we dealing with simple POJOs, complex services with lifecycle events, or both?
  4. What is the expected read/write ratio for component instantiation? Is it mostly read (instantiation) or a mix of creation and updates?
  5. What are the latency sensitivity requirements? Should component instantiation happen in sub-millisecond time or is a few hundred milliseconds acceptable?
  6. What authentication and authorization model is expected? Should the DI container enforce access control or assume a trusted environment?
  7. Are there external integrations or dependencies that need to be handled? Should the DI container support integration with external services like config servers or service discovery?

Functional requirements

  • Support registration and retrieval of components by type
  • Support component lifecycle management (initialization, destruction)
  • Support dependency injection with constructor and setter injection
  • Support singleton and prototype scope for components
  • Support component configuration via properties or annotations
  • Support circular dependency detection and resolution (via lazy initialization)
  • Support component factory registration for complex instantiation logic
  • Support component lifecycle callbacks (e.g., @PostConstruct, @PreDestroy)
  • Support component metadata and introspection capabilities
  • Support component dependency graph visualization (optional)

Non-functional requirements

  • Latency targets:
    • p50: <1ms for component retrieval
    • p95: <5ms for component retrieval
    • p99: <10ms for component retrieval
  • Throughput:
    • 50,000 component instantiations/second under load
  • Availability:
    • 99.9% uptime (5-minute SLA)
  • Memory usage:
    • <10MB memory overhead for 10,000 registered components

Constraints and assumptions

  • The DI container will be used in a single-threaded environment for component registration (initialization phase)
  • Components are assumed to be Java classes with public constructors or factory methods
  • The container will not support AOP or dynamic proxy generation (focus on core DI logic)
  • Component instantiation will be synchronous and blocking
  • No external dependencies or third-party libraries (e.g., Spring, Guice) will be used
  • Components are assumed to be thread-safe for concurrent access (unless explicitly marked as prototype)
  • The container will not handle cross-cutting concerns like transaction management or security

Out of scope

  • AOP and dynamic proxy generation
  • Integration with external configuration management systems (e.g., Consul, Config Server)
  • Support for multiple container instances or hierarchical containers
  • Component versioning and backward compatibility handling
  • Integration with web frameworks or microservices orchestration tools
  • Support for asynchronous component instantiation or lazy loading beyond basic circular dependency handling
  • Runtime modification of component registration (e.g., hot-swapping)
  • Support for multiple injection strategies (e.g., field injection) beyond constructor and setter injection

2. API Design

API Style Selection

gRPC with Protocol Buffers is selected for the Dependency Injection container API design. This choice provides:

  • Strong typing and schema validation through protobuf definitions
  • Efficient binary serialization for high-performance scenarios
  • Built-in support for streaming and bidirectional communication
  • First-class support for versioning and evolution of contracts
  • Native language bindings for multiple platforms (Java, Go, Python, C++)
  • Better tooling ecosystem (protoc-gen-openapi, grpcurl, etc.)

Endpoint Definitions

MethodPathDescriptionAuth
Register/di.v1.Container/RegisterRegister a new dependency bindingNone
Resolve/di.v1.Container/ResolveResolve a dependency by typeNone
ResolveAll/di.v1.Container/ResolveAllResolve all bindings for a given typeNone
GetBinding/di.v1.Container/GetBindingRetrieve a specific binding configurationNone
ListBindings/di.v1.Container/ListBindingsList all registered bindingsNone
Clear/di.v1.Container/ClearClear all registered bindingsNone

Request and Response Schemas

RegisterRequest

{
  "binding": {
    "type": "com.example.UserService",
    "implementation": "com.example.UserServiceImpl",
    "lifecycle": "SINGLETON",
    "scope": "DEFAULT"
  }
}

RegisterResponse

{
  "success": true,
  "bindingId": "uuid-12345"
}

ResolveRequest

{
  "type": "com.example.UserService",
  "options": {
    "allowUnregistered": false,
    "resolveAsInterface": true
  }
}

ResolveResponse

{
  "resolvedObject": {
    "type": "com.example.UserService",
    "instanceId": "uuid-67890",
    "implementationType": "com.example.UserServiceImpl"
  },
  "resolvedAsInterface": true
}

ResolveAllRequest

{
  "type": "com.example.UserService",
  "options": {
    "includeInherited": true
  }
}

ResolveAllResponse

{
  "resolvedObjects": [
    {
      "type": "com.example.UserService",
      "instanceId": "uuid-67890",
      "implementationType": "com.example.UserServiceImpl"
    }
  ]
}

HTTP Status Codes and Error Response Format

Status CodeDescriptionError Type
200Success-
400Invalid request parametersValidationError
401Authentication requiredUnauthorizedError
403Insufficient permissionsForbiddenError
404Resource not foundNotFoundError
429Rate limit exceededRateLimitExceededError
500Internal server errorInternalServerError

Standardized error response format:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid type specification",
    "details": {
      "field": "type",
      "reason": "Type cannot be empty"
    },
    "timestamp": "2023-10-27T10:30:00Z"
  }
}

API Versioning Strategy

Version v1 is used for the initial release with semantic versioning:

  • Path-based versioning: /di.v1.Container/...
  • Protocol buffer versioning via package naming
  • Backward compatibility maintained through optional fields and default values

Pagination Design

Cursor-based pagination is selected because:

  • Provides consistent results even when data changes during pagination
  • Handles large datasets efficiently without offset calculation overhead
  • Supports stable ordering with timestamp-based cursors

Authentication and Authorization

  • No authentication required for DI container operations as they are typically internal to the application
  • Authorization is handled at the application level through DI configuration scopes and lifecycle management

Rate Limiting

Rate limiting per endpoint:

EndpointLimitPeriod
Register1000 req/min1 minute
Resolve5000 req/min1 minute
ResolveAll1000 req/min1 minute
GetBinding2000 req/min1 minute
ListBindings1000 req/min1 minute

Implementation strategy:

  • Token bucket algorithm with sliding window
  • Per-service instance rate limiting (not per-user)
  • Distributed rate limiting using Redis for multi-instance deployments

Idempotency Design

Idempotency key strategy:

  • For Register endpoint, use a combination of binding type and implementation class name as the idempotency key
  • Store in Redis with TTL of 5 minutes to prevent stale keys from accumulating
  • For duplicate requests, return the same binding ID and skip registration
// Example idempotency check in Register method
public RegisterResponse register(RegisterRequest request) {
    String idempotencyKey = generateIdempotencyKey(request);
    
    if (idempotencyCache.exists(idempotencyKey)) {
        return RegisterResponse.builder()
                .success(true)
                .bindingId(idempotencyCache.get(idempotencyKey))
                .build();
    }
    
    // Proceed with registration logic...
    String bindingId = registerBinding(request);
    idempotencyCache.put(idempotencyKey, bindingId, Duration.ofMinutes(5));
    
    return RegisterResponse.builder()
            .success(true)
            .bindingId(bindingId)
            .build();
}

3. Data Model Design

Entity-Relationship diagram (Mermaid)

erDiagram
    BEAN_DEFINITION {
        string id PK
        string name
        string implementationClass
        string scope
        boolean isSingleton
        boolean isLazy
        array dependencies
        timestamp createdAt
        timestamp updatedAt
    }

    BEAN_INSTANCE {
        string id PK
        string beanDefinitionId FK
        string instance
        timestamp createdAt
        timestamp updatedAt
    }

    BEAN_DEPENDENCY {
        string id PK
        string beanDefinitionId FK
        string dependencyBeanId FK
        string injectionType
        timestamp createdAt
    }

    BEAN_DEFINITION ||--o{ BEAN_INSTANCE : "has"
    BEAN_DEFINITION ||--o{ BEAN_DEPENDENCY : "has"

Table / Collection schema definitions with field types, constraints, indices

-- BeanDefinition table stores metadata about each bean definition
CREATE TABLE bean_definitions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL UNIQUE,
    implementation_class TEXT NOT NULL,
    scope VARCHAR(50) NOT NULL DEFAULT 'singleton',
    is_singleton BOOLEAN NOT NULL DEFAULT TRUE,
    is_lazy BOOLEAN NOT NULL DEFAULT FALSE,
    dependencies JSONB DEFAULT '[]',
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- BeanInstance table stores actual bean instances (for singleton scope)
CREATE TABLE bean_instances (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    bean_definition_id UUID NOT NULL REFERENCES bean_definitions(id) ON DELETE CASCADE,
    instance JSONB NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- BeanDependency table stores dependency relationships between beans
CREATE TABLE bean_dependencies (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    bean_definition_id UUID NOT NULL REFERENCES bean_definitions(id) ON DELETE CASCADE,
    dependency_bean_id UUID NOT NULL REFERENCES bean_definitions(id) ON DELETE CASCADE,
    injection_type VARCHAR(50) NOT NULL DEFAULT 'constructor',
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- Indexes for performance optimization
CREATE INDEX idx_bean_definitions_name ON bean_definitions(name);
CREATE INDEX idx_bean_definitions_scope ON bean_definitions(scope);
CREATE INDEX idx_bean_instances_definition_id ON bean_instances(bean_definition_id);
CREATE INDEX idx_bean_dependencies_bean_id ON bean_dependencies(bean_definition_id);
CREATE INDEX idx_bean_dependencies_dependency_id ON bean_dependencies(dependency_bean_id);

Primary key strategy (UUID vs. auto-increment vs. ULID — with justification)

Using UUID as primary key for all tables because:

  1. Distributed system compatibility: UUIDs are globally unique, making them ideal for distributed systems where multiple DI containers might be running in different environments or microservices.

  2. No coordination required: Unlike auto-increment, UUIDs don't require coordination between systems or database clusters to generate unique identifiers.

  3. Scalability: UUIDs support horizontal scaling without worrying about ID conflicts or database sequence contention.

  4. Security: UUIDs don't reveal information about the creation order or system, providing better security properties.

  5. Database independence: UUIDs work consistently across different database systems (PostgreSQL, MySQL, etc.) without requiring specific extensions or configurations.

Index design (composite, partial, covering indices — with justification per index)

  1. idx_bean_definitions_name:

    • Purpose: Fast lookup by bean name (common operation in DI containers)
    • Justification: Bean names are frequently used to retrieve definitions
  2. idx_bean_definitions_scope:

    • Purpose: Filter beans by scope for lazy loading or singleton management
    • Justification: Performance optimization for scope-based queries
  3. idx_bean_instances_definition_id:

    • Purpose: Fast retrieval of bean instances by definition ID
    • Justification: Critical for singleton instance lookup and lifecycle management
  4. idx_bean_dependencies_bean_id:

    • Purpose: Find all dependencies for a given bean
    • Justification: Required for dependency resolution and graph traversal
  5. idx_bean_dependencies_dependency_id:

    • Purpose: Find all beans that depend on a specific bean
    • Justification: Needed for lifecycle management and cleanup operations

Data partitioning / sharding strategy

For a DI container, data partitioning is not typically required at the database level since:

  1. Small dataset size: DI container metadata is generally small (hundreds to thousands of beans)
  2. In-memory caching: Most operations are in-memory, not database-backed
  3. Single logical unit: The DI container is a single logical unit that manages its own beans

However, if scaling to very large systems (e.g., hundreds of thousands of beans), we could consider:

  • Sharding by scope: Separate tables for singleton vs prototype beans
  • Sharding by namespace: Group beans by package or namespace prefix if using namespacing

Soft delete vs. hard delete decision with justification

Soft delete: Using soft deletes for bean definitions and dependencies to support:

  1. Audit trail capability: Track when beans were removed or modified
  2. Recovery capability: Allow recovery of deleted bean definitions if needed
  3. Historical tracking: Support for versioned dependency graphs and configuration history

However, bean instances are not soft-deleted since they represent runtime state that should be cleaned up when beans are destroyed.

Audit trail design (who, what, when)

Audit logging is handled through the timestamp fields (created_at, updated_at) in each table. For more detailed audit capabilities:

-- Enhanced audit table (optional)
CREATE TABLE bean_audit_log (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    operation_type VARCHAR(20) NOT NULL, -- CREATE, UPDATE, DELETE
    bean_definition_id UUID REFERENCES bean_definitions(id),
    changed_fields JSONB,
    user_id VARCHAR(255), -- if authentication is available
    timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

Data archival strategy and retention policy

Archival is not typically needed for DI container metadata since:

  1. Low storage cost: Metadata is minimal in size
  2. High access frequency: Bean definitions are accessed frequently during application startup and runtime
  3. No long-term retention requirements: Configuration is not typically retained for extended periods

If archiving is desired:

  • Archive beans that haven't been accessed in 6+ months
  • Retention policy: Keep last 3 years of bean definitions for debugging and recovery purposes

4. Class Design & Service Layer

UML Class Diagram

classDiagram
    class DIContainer {
        -Map~String, BeanDefinition~ beanDefinitions
        -Map~String, Object~ singletonBeans
        -Map~String, BeanFactory~ beanFactories
        +registerBean(String, BeanDefinition)
        +getBean(String)
        +initialize()
    }
    
    class BeanDefinition {
        -String name
        -Class~T~ type
        -String scope
        -List~String~ dependencies
        -Map~String, Object~ properties
        +createInstance()
    }
    
    class BeanFactory {
        -String name
        -Class~T~ type
        -Function~DIContainer, T~ factoryMethod
        +createBean(DIContainer)
    }
    
    class BeanPostProcessor {
        +postProcessBeforeInitialization(Object, String)
        +postProcessAfterInitialization(Object, String)
    }
    
    class BeanLifecycleAware {
        +afterPropertiesSet()
        +destroy()
    }
    
    class SingletonBeanRegistry {
        -Map~String, Object~ singletonBeans
        +registerSingleton(String, Object)
        +getSingleton(String)
    }
    
    class BeanDefinitionRegistry {
        -Map~String, BeanDefinition~ beanDefinitions
        +registerBeanDefinition(String, BeanDefinition)
        +getBeanDefinition(String)
    }
    
    DIContainer --> BeanDefinitionRegistry
    DIContainer --> SingletonBeanRegistry
    DIContainer --> BeanFactory
    DIContainer --> BeanPostProcessor
    DIContainer --> BeanLifecycleAware
    
    BeanDefinition *-- DIContainer : uses
    BeanFactory *-- DIContainer : uses
    
    class ApplicationContext {
        -DIContainer container
        +getBean(String)
        +refresh()
        +close()
    }
    
    ApplicationContext --> DIContainer : delegates to
    
    class ConfigurableApplicationContext {
        -List~BeanFactoryPostProcessor~ beanFactoryPostProcessors
        +refresh()
    }
    
    ApplicationContext <|-- ConfigurableApplicationContext
    
    class BeanFactoryPostProcessor {
        +postProcessBeanFactory(ConfigurableListableBeanFactory)
    }
    
    class ListableBeanFactory {
        +getBeansOfType(Class~T~)
        +getBeanNamesForType(Class~T~)
    }
    
    ListableBeanFactory <|-- DIContainer

State Machine Diagram for Bean Lifecycle

stateDiagram-v2
    [*] --> Instantiated
    Instantiated --> Initialized : afterPropertiesSet()
    Initialized --> Destroyed : destroy()
    Destroyed --> [*]

Service Class Structure and Responsibilities

The DI container service layer is organized into the following key components:

  1. DIContainer - Main container interface and implementation that orchestrates bean lifecycle management
  2. BeanDefinitionRegistry - Manages registration and retrieval of bean definitions
  3. SingletonBeanRegistry - Manages singleton instances
  4. BeanFactory - Factory pattern implementation for creating beans with custom logic
  5. BeanPostProcessor - Extension point for modifying beans after instantiation but before initialization

Interface / Abstract Class Design

public interface BeanDefinitionRegistry {
    void registerBeanDefinition(String beanName, BeanDefinition beanDefinition);
    BeanDefinition getBeanDefinition(String beanName);
    boolean containsBeanDefinition(String beanName);
}

public interface SingletonBeanRegistry {
    void registerSingleton(String beanName, Object singletonObject);
    Object getSingleton(String beanName);
    boolean containsSingleton(String beanName);
}

public interface BeanFactory {
    Object getBean(String beanName);
    <T> T getBean(Class<T> requiredType);
    boolean isSingleton(String beanName);
    boolean isPrototype(String beanName);
}

public interface BeanPostProcessor {
    Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException;
    Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException;
}

public interface BeanFactoryPostProcessor {
    void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException;
}

public interface BeanLifecycleAware {
    void afterPropertiesSet() throws Exception;
    void destroy() throws Exception;
}

Business Logic Encapsulation Patterns

The DI container implements the Factory Pattern for creating beans and the Strategy Pattern for handling different bean scopes.

public class BeanDefinition {
    private final String name;
    private final Class<?> type;
    private final String scope;
    private final List<String> dependencies;
    private final Map<String, Object> properties;
    
    public BeanDefinition(String name, Class<?> type, String scope, 
                         List<String> dependencies, Map<String, Object> properties) {
        this.name = name;
        this.type = type;
        this.scope = scope != null ? scope : "singleton";
        this.dependencies = dependencies != null ? new ArrayList<>(dependencies) : new ArrayList<>();
        this.properties = properties != null ? new HashMap<>(properties) : new HashMap<>();
    }
    
    public Object createInstance(DIContainer container) throws Exception {
        // Handle constructor injection
        Constructor<?> constructor = findMatchingConstructor(type, container);
        Object[] args = resolveDependencies(constructor.getParameterTypes(), container);
        
        Object instance = constructor.newInstance(args);
        
        // Handle property injection
        injectProperties(instance, properties);
        
        return instance;
    }
    
    private Constructor<?> findMatchingConstructor(Class<?> type, DIContainer container) {
        // Implementation to find the most appropriate constructor
        return type.getConstructors()[0]; // Simplified for example
    }
    
    private Object[] resolveDependencies(Class<?>[] paramTypes, DIContainer container) {
        Object[] args = new Object[paramTypes.length];
        for (int i = 0; i < paramTypes.length; i++) {
            args[i] = container.getBean(paramTypes[i]);
        }
        return args;
    }
    
    private void injectProperties(Object instance, Map<String, Object> properties) {
        // Implementation for property injection
    }
}

Critical-Path Method Implementation

public class DIContainer implements BeanFactory, BeanDefinitionRegistry, SingletonBeanRegistry {
    private final Map<String, BeanDefinition> beanDefinitions = new ConcurrentHashMap<>();
    private final Map<String, Object> singletonBeans = new ConcurrentHashMap<>();
    private final List<BeanPostProcessor> beanPostProcessors = new ArrayList<>();
    private final Map<String, BeanFactory> beanFactories = new ConcurrentHashMap<>();
    
    public synchronized Object getBean(String beanName) {
        // Step 1: Check if it's a singleton and already created
        Object bean = singletonBeans.get(beanName);
        if (bean != null) {
            return bean;
        }
        
        // Step 2: Check if it's a custom factory bean
        BeanFactory factory = beanFactories.get(beanName);
        if (factory != null) {
            return factory.createBean(this);
        }
        
        // Step 3: Create bean from definition
        BeanDefinition definition = beanDefinitions.get(beanName);
        if (definition == null) {
            throw new NoSuchBeanDefinitionException("No bean named '" + beanName + "' is defined");
        }
        
        try {
            // Step 4: Create instance with dependency injection
            Object instance = createBeanInstance(definition);
            
            // Step 5: Apply post-processors before initialization
            for (BeanPostProcessor processor : beanPostProcessors) {
                instance = processor.postProcessBeforeInitialization(instance, beanName);
            }
            
            // Step 6: Initialize if needed
            if (instance instanceof BeanLifecycleAware) {
                ((BeanLifecycleAware) instance).afterPropertiesSet();
            }
            
            // Step 7: Apply post-processors after initialization
            for (BeanPostProcessor processor : beanPostProcessors) {
                instance = processor.postProcessAfterInitialization(instance, beanName);
            }
            
            // Step 8: Register as singleton if scope is singleton
            if ("singleton".equalsIgnoreCase(definition.getScope())) {
                singletonBeans.put(beanName, instance);
            }
            
            return instance;
        } catch (Exception e) {
            throw new BeanCreationException("Failed to create bean '" + beanName + "'", e);
        }
    }
    
    private Object createBeanInstance(BeanDefinition definition) throws Exception {
        Class<?> type = definition.getType();
        Constructor<?> constructor = findBestConstructor(type, definition);
        
        // Resolve dependencies for constructor
        Object[] constructorArgs = resolveConstructorArguments(constructor, definition);
        
        // Create instance
        Object instance = constructor.newInstance(constructorArgs);
        
        return instance;
    }
    
    private Constructor<?> findBestConstructor(Class<?> type, BeanDefinition definition) {
        // Implementation to select the most appropriate constructor based on dependencies
        Constructor<?>[] constructors = type.getConstructors();
        return constructors.length > 0 ? constructors[0] : type.getDeclaredConstructor();
    }
    
    private Object[] resolveConstructorArguments(Constructor<?> constructor, BeanDefinition definition) {
        Class<?>[] paramTypes = constructor.getParameterTypes();
        Object[] args = new Object[paramTypes.length];
        
        for (int i = 0; i < paramTypes.length; i++) {
            String dependencyName = findBeanNameForType(paramTypes[i]);
            if (dependencyName != null) {
                args[i] = getBean(dependencyName);
            } else {
                // Handle primitive types or use default value
                args[i] = resolvePrimitiveValue(paramTypes[i]);
            }
        }
        
        return args;
    }
    
    private String findBeanNameForType(Class<?> type) {
        // Implementation to find bean name for a given type
        return null; // Simplified for example
    }
    
    private Object resolvePrimitiveValue(Class<?> type) {
        // Implementation to handle primitive types
        return null; // Simplified for example
    }
    
    public void registerBean(String beanName, BeanDefinition beanDefinition) {
        beanDefinitions.put(beanName, beanDefinition);
    }
    
    public void registerBeanFactory(String beanName, BeanFactory factory) {
        beanFactories.put(beanName, factory);
    }
    
    public void addBeanPostProcessor(BeanPostProcessor processor) {
        beanPostProcessors.add(processor);
    }
    
    public void initialize() {
        // Initialize all singleton beans in the right order
        Set<String> processedBeans = new HashSet<>();
        
        while (!processedBeans.containsAll(beanDefinitions.keySet())) {
            for (String beanName : beanDefinitions.keySet()) {
                if (!processedBeans.contains(beanName)) {
                    try {
                        getBean(beanName);
                        processedBeans.add(beanName);
                    } catch (Exception e) {
                        // Handle circular dependencies or other issues
                        throw new IllegalStateException("Failed to initialize bean: " + beanName, e);
                    }
                }
            }
        }
    }
}

Transaction Boundary Design

In the DI container context, transaction boundaries are not typically relevant since it's a configuration and instantiation service. However, when beans themselves perform database operations or other transactional work, the container should:

  1. Ensure proper lifecycle management of transactional beans
  2. Support declarative transaction management through annotations (e.g., @Transactional)
  3. Handle bean scope correctly for transactional contexts

Service Interface Contracts

All interfaces are designed to be minimal and focused:

  • BeanFactory - Core contract for bean retrieval
  • BeanDefinitionRegistry - Registration and lookup of bean definitions
  • SingletonBeanRegistry - Singleton instance management
  • BeanPostProcessor - Extension point for bean modification
  • BeanFactoryPostProcessor - Pre-processing of bean definitions

Saga / Orchestration Pattern

The DI container does not implement saga patterns as it's a configuration service, not an orchestration layer. However, it provides the foundation for:

  1. Bean lifecycle management that can support distributed transaction patterns
  2. Event-driven bean initialization through BeanPostProcessor implementations
  3. Integration with external systems that may require saga coordination

Domain Object / Value Object Design

The DI container uses the following domain objects:

  1. BeanDefinition - Immutable configuration object describing how to create a bean
  2. BeanFactory - Factory function for creating beans with custom logic
  3. ObjectReference - Reference to a bean instance (not explicitly shown but implied)
  4. BeanMetadata - Metadata about beans including scope, dependencies, and lifecycle information

The design follows value object principles where immutable configuration objects are used to define bean creation behavior, while mutable singleton instances are managed by the container.

5. Concurrency & Thread Safety

Concurrency Model

The DI container operates in a multi-threaded environment where multiple threads may simultaneously request object instantiation, register components, or perform configuration operations. The concurrency model leverages:

  • Java's java.util.concurrent package for thread-safe collections and synchronization primitives
  • Read-Write locks for component registry operations to allow concurrent reads while ensuring exclusive writes
  • Thread-local storage for context-specific state during object resolution

Race Condition Prevention Strategy

Race conditions can occur during:

  1. Component registration - Multiple threads registering the same component type
  2. Object instantiation - Concurrent resolution of circular dependencies or singleton instances
  3. Configuration updates - Simultaneous modification of component metadata

Prevention strategies:

  • Use ConcurrentHashMap for storing registered components
  • Implement double-checked locking pattern for singleton instances
  • Use ReentrantReadWriteLock to synchronize registry modifications while allowing concurrent reads
  • Atomic operations for component metadata updates

Optimistic vs. Pessimistic Locking Selection

Optimistic locking is selected for the following reasons:

  • Low contention in typical usage patterns (most DI operations are read-heavy)
  • Better performance under concurrent access with minimal lock overhead
  • Simpler implementation and debugging compared to complex locking mechanisms

Deadlock Prevention Strategy

Deadlock prevention is critical for:

  1. Circular dependency resolution - Prevents infinite waits during object graph construction
  2. Component initialization order - Ensures proper ordering without deadlocks

Implementation:

  • Track component resolution depth to detect cycles and throw CircularDependencyException
  • Use a global dependency graph with topological sorting to determine safe initialization order
  • Implement timeout-based acquisition of read locks to prevent indefinite blocking

Idempotency Implementation for Concurrent Duplicate Requests

For concurrent duplicate registration attempts:

public class DependencyInjectionContainer {
    private final Map<Class<?>, ComponentDefinition> registry = new ConcurrentHashMap<>();
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    
    public <T> void register(Class<T> interfaceType, Class<? extends T> implementation) {
        // Idempotent registration - if already registered, no-op
        if (registry.containsKey(interfaceType)) {
            return;
        }
        
        // Use computeIfAbsent for atomic registration
        registry.computeIfAbsent(interfaceType, key -> new ComponentDefinition<>(implementation));
    }
}

The container ensures that duplicate registration attempts are idempotent by checking if a component is already registered before attempting to register it. This prevents race conditions where multiple threads might try to register the same component simultaneously.

Thread Safety Patterns in Component Resolution

  1. Thread-safe singleton instantiation using double-checked locking:
private volatile Object instance;

public synchronized Object getInstance(Class<?> type) {
    if (instance == null) {
        synchronized (this) {
            if (instance == null) {
                instance = createInstance(type);
            }
        }
    }
    return instance;
}
  1. Concurrent component graph traversal using thread-safe collections and atomic operations for dependency tracking

  2. Immutable configuration objects to prevent concurrent modification during resolution

Synchronization Primitives Used

  • ConcurrentHashMap for thread-safe registry operations
  • ReentrantReadWriteLock for registry modification synchronization
  • AtomicReference for atomic component state updates
  • CountDownLatch for component initialization coordination in multi-threaded environments

Performance Considerations

  • Minimize lock contention by using read-write locks where appropriate
  • Avoid holding locks for extended periods during object instantiation
  • Use thread-local caches for frequently accessed component metadata to reduce lock contention
  • Implement lazy initialization patterns where appropriate to defer expensive operations until needed

The concurrency model is designed to handle high-concurrency scenarios while maintaining thread safety, with minimal performance impact through careful use of concurrent collections and lock-free patterns where possible.

6. Sequence Diagrams for Key Flows

Happy Path Sequence Diagram

sequenceDiagram
    participant Client
    participant DIContainer
    participant ServiceA
    participant ServiceB
    
    Client->>DIContainer: getBean(ServiceA.class)
    DIContainer->>DIContainer: Check if ServiceA is singleton
    DIContainer->>ServiceA: new ServiceA()
    DIContainer->>DIContainer: Inject dependencies
    DIContainer->>ServiceB: getBean(ServiceB.class)
    ServiceB->>ServiceB: Constructor injection
    DIContainer->>DIContainer: Store singleton instance
    DIContainer-->>Client: Return ServiceA instance

Error / Edge Case Sequence Diagram

sequenceDiagram
    participant Client
    participant DIContainer
    participant ServiceA
    
    Client->>DIContainer: getBean(ServiceA.class)
    DIContainer->>DIContainer: Check if ServiceA is registered
    DIContainer-->>Client: Return BeanNotFoundException
    Client->>DIContainer: getBean(ServiceA.class)
    DIContainer->>DIContainer: Check if ServiceA has circular dependency
    DIContainer-->>Client: Return CircularDependencyException
    Client->>DIContainer: getBean(ServiceA.class)
    DIContainer->>ServiceA: new ServiceA()
    DIContainer->>DIContainer: Handle constructor exception
    DIContainer-->>Client: Return InstantiationException

Async Flow Sequence Diagram

sequenceDiagram
    participant Client
    participant DIContainer
    participant ServiceA
    participant AsyncEventPublisher
    
    Client->>DIContainer: registerBean(ServiceA.class)
    DIContainer->>DIContainer: Store bean definition
    DIContainer->>AsyncEventPublisher: publishBeanRegisteredEvent
    AsyncEventPublisher->>AsyncEventPublisher: Queue event for processing
    DIContainer-->>Client: Return success response
    AsyncEventPublisher->>AsyncEventPublisher: Process queued event
    AsyncEventPublisher->>AsyncEventPublisher: Update internal registry

7. Caching Design

Cache Strategy Selection

For a dependency injection container, I'll use cache-aside pattern as the primary caching strategy. This approach gives us fine-grained control over when and how objects are cached, allowing for proper lifecycle management and lazy initialization of dependencies.

Cache Key Design

Cache keys will follow a structured namespace pattern:

{cacheNamespace}:{componentType}:{componentName}:{version}

Example key format:

di:service:UserService:v1
di:factory:DatabaseFactory:v2

TTL Strategy

Data TypeTTLReason
Service instances1 hourServices are typically long-lived, but we want to allow for configuration updates
Factory instances30 minutesFactories may be more volatile and need shorter refresh
Component metadata24 hoursMetadata about components is relatively static
Configuration objects5 minutesConfiguration changes should be picked up quickly

Cache Eviction Policy

Implement LRU (Least Recently Used) eviction policy with a secondary TTL-based cleanup for stale entries. This combination ensures:

  • Memory is efficiently managed by removing least-used items
  • Stale data doesn't persist indefinitely
  • Hot components remain in cache

Cache Stampede Prevention

To prevent thundering herd problems:

  1. Probabilistic early expiry: Set cache TTL to 2x the actual expiration time, but use a random offset (5-15%)
  2. Distributed locks: For critical component creation, use Redis distributed locks with 500ms timeout
  3. Cache warming: Pre-warm hot components during startup or low-traffic periods

Distributed Cache Architecture

Using Redis cluster with:

  • 3 master nodes for high availability
  • 3 replica nodes per master for read scaling
  • Redis Sentinel for automatic failover
  • Cache partitioning by component type to avoid hot keys

Cache Invalidation Strategy

Implement event-driven invalidation:

  1. When component configuration changes, publish component.updated event
  2. When service dependencies change, invalidate related cache entries
  3. Use Redis Pub/Sub for real-time invalidation notifications
// Cache invalidation example
public class ComponentCacheInvalidator {
    private final RedisTemplate<String, Object> redisTemplate;
    
    public void invalidateComponent(String componentType, String componentName) {
        String key = buildCacheKey(componentType, componentName);
        redisTemplate.delete(key);
        
        // Notify other instances in cluster
        redisTemplate.convertAndSend("cache.invalidations", 
            new CacheInvalidationEvent(componentType, componentName));
    }
}

Cache Read-Through Pattern

Implement read-through for service instantiation:

public class CachedServiceResolver {
    private final RedisTemplate<String, Object> redisTemplate;
    private final ServiceFactory serviceFactory;
    
    public Object resolveService(String componentType, String componentName) {
        String cacheKey = buildCacheKey(componentType, componentName);
        
        // Try cache first
        Object cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }
        
        // Cache miss - acquire distributed lock
        try (DistributedLock lock = acquireLock(componentType, componentName)) {
            // Double-check after lock acquisition
            cached = redisTemplate.opsForValue().get(cacheKey);
            if (cached != null) {
                return cached;
            }
            
            // Create and cache the service
            Object service = serviceFactory.createService(componentType, componentName);
            redisTemplate.opsForValue().set(cacheKey, service, 1, TimeUnit.HOURS);
            
            return service;
        }
    }
}

Cache Hit/Miss Metrics

Track cache performance with:

  • di.cache.hits (counter)
  • di.cache.misses (counter)
  • di.cache.hit_ratio (gauge)
  • di.cache.evictions (counter)

Cache Warm-up Strategy

Implement startup warm-up for critical services:

@Component
public class CacheWarmupService {
    private final ComponentRegistry componentRegistry;
    private final RedisTemplate<String, Object> redisTemplate;
    
    @EventListener
    public void onApplicationReady(ApplicationReadyEvent event) {
        // Warm up frequently accessed components
        componentRegistry.getHotComponents().forEach(component -> {
            try {
                String key = buildCacheKey(component.getType(), component.getName());
                Object service = componentRegistry.resolve(component);
                redisTemplate.opsForValue().set(key, service, 1, TimeUnit.HOURS);
            } catch (Exception e) {
                // Log but don't fail startup
                log.warn("Failed to warm up component: {}", component.getName(), e);
            }
        });
    }
}

8. Repository / Data Access Layer

Repository Pattern Implementation

The repository pattern is implemented to abstract data access logic and provide a clean separation between domain objects and persistence concerns. Each repository interface defines the contract for data operations on a specific entity.

public interface UserRepository {
    User findById(UUID id);
    List<User> findAll();
    User save(User user);
    void delete(UUID id);
    Optional<User> findByEmail(String email);
}

public interface OrderRepository {
    Order findById(UUID id);
    List<Order> findByUserId(UUID userId);
    Order save(Order order);
    void delete(UUID id);
}

ORM vs Raw Query Decisions

For this DI container design, we'll use raw SQL queries for performance-critical operations and ORM for complex object mapping. The trade-off is between developer productivity (ORM) and performance control (raw SQL). Raw queries are used for high-frequency operations like dependency resolution, while ORM is used for complex object hydration.

Read vs Write Separation

We implement read-write separation using a CQRS-like approach for better scalability:

public class DependencyRepository {
    private final DataSource dataSource;
    private final ReadDataSource readDataSource;
    
    public User findUserById(UUID id) {
        // Use read-only connection
        try (Connection conn = readDataSource.getConnection()) {
            return executeReadQuery(conn, "SELECT * FROM users WHERE id = ?", id);
        }
    }
    
    public User saveUser(User user) {
        // Use write connection
        try (Connection conn = dataSource.getConnection()) {
            return executeWriteQuery(conn, "INSERT INTO users VALUES (?, ?, ?)", user);
        }
    }
}

Connection Pool Configuration

Connection pools are configured with optimal sizing based on concurrent request patterns:

@Configuration
public class DatabaseConfig {
    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/di_container");
        config.setMaximumPoolSize(20);
        config.setMinimumIdle(5);
        config.setConnectionTimeout(30000);
        config.setIdleTimeout(600000);
        config.setMaxLifetime(1800000);
        config.setLeakDetectionThreshold(60000);
        return new HikariDataSource(config);
    }
}

N+1 Query Prevention Strategy

We implement eager loading and batch operations to prevent N+1 queries:

public class DependencyRepository {
    public List<Dependency> findDependenciesWithResolvers(List<UUID> dependencyIds) {
        // Single query to fetch all dependencies
        String sql = """
            SELECT d.*, r.name as resolver_name 
            FROM dependencies d 
            LEFT JOIN resolvers r ON d.resolver_id = r.id 
            WHERE d.id IN (%s)
            """;
        
        // Batch fetch related data in single queries
        List<Dependency> dependencies = executeBatchQuery(sql, dependencyIds);
        
        // Fetch all resolver instances in one query
        Set<UUID> resolverIds = dependencies.stream()
            .map(Dependency::getResolverId)
            .filter(Objects::nonNull)
            .collect(Collectors.toSet());
            
        Map<UUID, String> resolverMap = fetchResolverNames(resolverIds);
        
        // Populate resolver names in dependencies
        dependencies.forEach(dep -> {
            if (dep.getResolverId() != null) {
                dep.setResolverName(resolverMap.get(dep.getResolverId()));
            }
        });
        
        return dependencies;
    }
}

Batch Operation Design

Batch operations are implemented for bulk data processing:

public class BatchDependencyRepository {
    public void saveAll(List<Dependency> dependencies) {
        String sql = "INSERT INTO dependencies (id, name, resolver_id, created_at) VALUES (?, ?, ?, ?)";
        
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement(sql)) {
            
            for (Dependency dep : dependencies) {
                stmt.setObject(1, dep.getId());
                stmt.setString(2, dep.getName());
                stmt.setObject(3, dep.getResolverId());
                stmt.setTimestamp(4, Timestamp.valueOf(dep.getCreatedAt()));
                stmt.addBatch();
            }
            
            stmt.executeBatch();
        } catch (SQLException e) {
            throw new DataAccessException("Failed to batch save dependencies", e);
        }
    }
}

Transaction Management

Transaction boundaries are carefully defined to ensure data consistency:

@Service
public class DependencyService {
    @Transactional
    public void registerDependencies(List<Dependency> dependencies) {
        // All operations within same transaction
        dependencyRepository.saveAll(dependencies);
        eventPublisher.publish(new DependenciesRegisteredEvent(dependencies));
    }
}

Repository Interface Contracts

All repositories follow a consistent interface contract:

public interface Repository<T, ID> {
    T findById(ID id);
    List<T> findAll();
    T save(T entity);
    void delete(ID id);
    boolean existsById(ID id);
}

Data Access Layer Design Considerations

The repository layer is designed to:

  • Isolate persistence logic from business logic
  • Support multiple data sources (read/write separation)
  • Handle connection pooling efficiently
  • Prevent N+1 query problems through batch operations
  • Support transaction boundaries for consistency
  • Provide clean abstraction for different data access strategies

9. Asynchronous Processing & Messaging

Event-driven boundaries in this feature — what triggers async vs. what stays synchronous, and why

In a dependency injection (DI) container, most operations are inherently synchronous since they involve resolving dependencies and instantiating objects. However, the DI container can benefit from asynchronous processing in specific scenarios:

  1. Post-resolution lifecycle events: After a bean is instantiated and all its dependencies are injected, we may want to trigger asynchronous initialization or configuration tasks (e.g., database connection pooling, background task scheduling). These should be handled asynchronously to avoid blocking the main resolution thread.

  2. Event-driven bean registration: When new beans are dynamically registered (e.g., via annotations or configuration files), we may want to notify other components asynchronously to update their internal state without blocking the registration process.

  3. Background cleanup or monitoring: Periodic cleanup of unused beans or monitoring of bean lifecycle can be done asynchronously to avoid impacting performance during normal operation.

  4. Batch processing of configuration updates: If multiple beans are updated or reconfigured, we can batch these operations asynchronously to reduce overhead.

The decision to use async processing is based on whether the operation can be safely decoupled from the main flow without affecting correctness or responsiveness.

Message schema design with a sample payload (Avro / Protobuf / JSON Schema — with versioning strategy)

For the DI container, we'll use JSON Schema for simplicity and readability in this design. The schema will support versioning via a version field.

{
  "type": "object",
  "title": "BeanLifecycleEvent",
  "version": "1.0",
  "properties": {
    "eventId": { "type": "string" },
    "eventType": { "type": "string", "enum": ["BEAN_REGISTERED", "BEAN_INITIALIZED", "BEAN_DESTROYED"] },
    "beanName": { "type": "string" },
    "beanType": { "type": "string" },
    "timestamp": { "type": "string", "format": "date-time" },
    "metadata": {
      "type": "object",
      "properties": {
        "source": { "type": "string" },
        "context": { "type": "object" }
      }
    }
  },
  "required": ["eventId", "eventType", "beanName", "beanType", "timestamp"]
}

Versioning strategy:

  • Version 1.0: Initial schema with basic event fields.
  • Future versions will support backward compatibility through field deprecation and new optional fields.

Dead letter queue strategy and poison message handling

For asynchronous processing in the DI container, we implement a dead letter queue (DLQ) pattern:

  1. Poison Message Detection: If a message fails to be processed more than 3 times (configurable), it is moved to a DLQ.
  2. DLQ Handling: Messages in the DLQ are logged with full context and can be manually retried or investigated by system administrators.
  3. Retry Policy: Messages are retried with exponential backoff (1s, 2s, 4s, 8s) before being sent to DLQ.

At-least-once vs. exactly-once delivery — decision with justification for this feature

We choose at-least-once delivery for the DI container's asynchronous processing because:

  1. Idempotency is achievable: Bean lifecycle events can be made idempotent by checking if the event has already been processed.
  2. Simpler to implement: Exactly-once delivery requires complex coordination and is overkill for this use case.
  3. Performance: At-least-once delivery provides better throughput and simpler error handling.

Retry policy for consumers (max attempts, backoff formula, non-retryable error classification)

Retry policy configuration:

  • Max Attempts: 3 retries
  • Backoff Formula: Exponential backoff with jitter (1s, 2s, 4s)
  • Jitter: ±20% randomization to prevent thundering herd
  • Non-retryable Errors:
    • Invalid bean configuration (e.g., circular dependency)
    • Bean instantiation failure due to missing dependencies
    • Invalid event payload structure

Outbox pattern for reliable event publishing — include or explicitly justify omission

We do not use the outbox pattern in this DI container implementation because:

  1. The DI container is primarily a synchronous component.
  2. Events are published within the same JVM or tightly coupled services.
  3. The system is designed to be lightweight and avoid additional complexity.

If the DI container were to publish to an external message broker (e.g., Kafka or RabbitMQ), then the outbox pattern would be a better fit.

Sequence diagram for all async flows (Mermaid)

sequenceDiagram
    participant DIContainer
    participant EventPublisher
    participant DLQHandler

    DIContainer->>EventPublisher: Publish BeanLifecycleEvent (BEAN_REGISTERED)
    EventPublisher->>EventPublisher: Process event asynchronously
    EventPublisher-->>DIContainer: Acknowledge success

    loop Retry up to 3 times
        EventPublisher->>EventPublisher: Attempt processing
        alt Processing fails
            EventPublisher->>EventPublisher: Log failure
            EventPublisher->>DLQHandler: Move to DLQ if retries exhausted
        else
            EventPublisher-->>DIContainer: Success
        end
    end

10. Error Handling Design

Error Classification Hierarchy

The DI container error handling follows a three-tier classification system:

  1. Infrastructure Errors - System-level issues (connection failures, resource exhaustion)
  2. Domain Errors - Business logic violations (circular dependencies, missing bindings)
  3. Validation Errors - Input parameter issues (invalid configuration, malformed definitions)
public abstract class ContainerException extends RuntimeException {
    public ContainerException(String message) { super(message); }
    public ContainerException(String message, Throwable cause) { super(message, cause); }
}

public class InfrastructureException extends ContainerException {
    public InfrastructureException(String message, Throwable cause) { super(message, cause); }
}

public class DomainException extends ContainerException {
    public DomainException(String message) { super(message); }
}

public class ValidationException extends ContainerException {
    public ValidationException(String message) { super(message); }
}

Error Propagation Strategy

Errors are handled at the appropriate layer with minimal propagation:

  • Validation errors are caught and re-thrown as ValidationException at the API boundary
  • Domain errors are handled within service methods and re-thrown as DomainException
  • Infrastructure errors are wrapped in InfrastructureException and may trigger circuit breaker logic

Retry Strategy with Exponential Backoff and Jitter

For transient infrastructure issues, retry logic is implemented:

public class RetryableOperation {
    private static final int MAX_RETRIES = 3;
    private static final long BASE_DELAY_MS = 100;
    private static final double JITTER_FACTOR = 0.5;
    
    public <T> T executeWithRetry(Supplier<T> operation) {
        int attempts = 0;
        while (attempts < MAX_RETRIES) {
            try {
                return operation.get();
            } catch (InfrastructureException e) {
                if (attempts >= MAX_RETRIES - 1) throw e;
                
                long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempts) 
                    * (1 + JITTER_FACTOR * (Math.random() - 0.5)));
                
                try {
                    Thread.sleep(delay);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    throw new InfrastructureException("Retry interrupted", ie);
                }
                attempts++;
            }
        }
        throw new IllegalStateException("Unexpected execution path");
    }
}

Circuit Breaker Design

For external dependencies or retryable operations, a circuit breaker pattern is implemented:

public class CircuitBreaker {
    private final int failureThreshold;
    private final long timeoutMs;
    private final AtomicInteger failureCount = new AtomicInteger(0);
    private final AtomicLong lastFailureTime = new AtomicLong(0);
    private volatile CircuitState state = CircuitState.CLOSED;
    
    enum CircuitState {
        CLOSED, OPEN, HALF_OPEN
    }
    
    public boolean canExecute() {
        switch (state) {
            case CLOSED:
                return true;
            case HALF_OPEN:
                return lastFailureTime.get() < System.currentTimeMillis() - timeoutMs;
            case OPEN:
                if (System.currentTimeMillis() - lastFailureTime.get() > timeoutMs) {
                    state = CircuitState.HALF_OPEN;
                    return true;
                }
                return false;
            default:
                return false;
        }
    }
    
    public void recordSuccess() {
        failureCount.set(0);
        state = CircuitState.CLOSED;
    }
    
    public void recordFailure() {
        int failures = failureCount.incrementAndGet();
        lastFailureTime.set(System.currentTimeMillis());
        
        if (failures >= failureThreshold) {
            state = CircuitState.OPEN;
        }
    }
}

Fallback Behavior Design

Fallbacks are implemented for critical operations:

public class FallbackHandler {
    public <T> T executeWithFallback(Supplier<T> operation, Supplier<T> fallback) {
        try {
            return operation.get();
        } catch (InfrastructureException e) {
            // Log the failure for monitoring
            return fallback.get();
        }
    }
}

Error Logging Format and Alerting Thresholds

Structured logging includes trace context and error classification:

public class ErrorLogger {
    private static final Logger logger = LoggerFactory.getLogger(ErrorLogger.class);
    
    public void logError(String operation, String errorCode, Throwable error, 
                        Map<String, Object> context) {
        Map<String, Object> logContext = new HashMap<>(context);
        logContext.put("operation", operation);
        logContext.put("error_code", errorCode);
        logContext.put("error_message", error.getMessage());
        logContext.put("stack_trace", error.getStackTrace());
        
        logger.error("DI Container Error: {}", logContext);
    }
    
    public static final int ALERT_THRESHOLD_CRITICAL = 5; // per minute
    public static final int ALERT_THRESHOLD_WARNING = 2;  // per minute
}

Alerting thresholds:

  • Critical: >5 errors/minute → On-call escalation
  • Warning: >2 errors/minute → Alert team via Slack/Monitoring dashboard

Error Handling in Container Lifecycle

public class ContainerLifecycle {
    private final List<ContainerErrorHandler> errorHandlers = new ArrayList<>();
    
    public void handleInitializationError(String component, Exception error) {
        ContainerException containerError = new DomainException(
            "Failed to initialize component: " + component, error);
        
        errorHandlers.forEach(handler -> handler.handleError(containerError));
        
        // Alert monitoring system
        if (shouldAlert(error)) {
            alertSystem(containerError);
        }
    }
    
    private boolean shouldAlert(ContainerException error) {
        // Implementation for determining alert eligibility
        return true;
    }
    
    private void alertSystem(ContainerException error) {
        // Integration with monitoring system (e.g., PagerDuty, Slack)
    }
}

11. Security Design

Input Validation and Sanitization Strategy

All inputs must undergo strict validation using a combination of:

  • Whitelist-based validation: Only allow predefined character sets, patterns, and ranges
  • Length constraints: Prevent buffer overflow attacks through maximum length limits
  • Type checking: Ensure inputs match expected data types (string, integer, boolean)
  • Library-based sanitization: Use established libraries like Apache Commons Validator for email, URL, and phone number validation
public class InputValidator {
    private static final Pattern EMAIL_PATTERN = 
        Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$");
    
    public static boolean isValidEmail(String email) {
        return email != null && EMAIL_PATTERN.matcher(email).matches();
    }
    
    public static boolean isValidString(String input, int maxLength) {
        return input != null && input.length() <= maxLength;
    }
}

SQL Injection Prevention

All database queries must use parameterized/prepared statements exclusively. No dynamic query construction using string concatenation or template interpolation.

// ❌ Vulnerable - DO NOT USE
String query = "SELECT * FROM users WHERE email = '" + email + "'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(query);

// ✅ Secure - USE THIS INSTEAD
String query = "SELECT * FROM users WHERE email = ?";
PreparedStatement stmt = connection.prepareStatement(query);
stmt.setString(1, email);
ResultSet rs = stmt.executeQuery();

Authentication Middleware Design

Authentication middleware will intercept all requests and validate the presence and validity of authentication tokens (JWT or OAuth2). The middleware will:

  • Extract token from Authorization header
  • Validate token signature and expiration
  • Check token against known valid tokens (via cache or database)
  • Set authenticated user context in request scope
@Component
public class AuthenticationMiddleware {
    private final JwtService jwtService;
    private final TokenCache tokenCache;

    public void validateAndSetUserContext(HttpServletRequest request) {
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            throw new SecurityException("Missing or invalid token");
        }

        String token = authHeader.substring(7);
        if (!jwtService.validateToken(token)) {
            throw new SecurityException("Invalid token");
        }

        String userId = jwtService.extractUserId(token);
        UserContext context = new UserContext(userId, extractPermissions(token));
        request.setAttribute("userContext", context);
    }
}

Authorization Enforcement

RBAC (Role-Based Access Control) will be implemented at the service layer:

  • Roles and permissions are defined in a centralized permission system
  • Each endpoint has a required role or permission level
  • Service methods check user permissions before executing business logic
@PreAuthorize("hasRole('ADMIN') or hasPermission(#userId, 'USER_READ')")
public User getUser(String userId) {
    return userRepository.findById(userId);
}

Sensitive Data Encryption

At Rest: AES-256-GCM encryption using AWS KMS or similar managed key service In Transit: TLS 1.3 with HSTS header enforcement

@Service
public class DataEncryptionService {
    private final AesGcmCryptoService aesService;
    
    public String encrypt(String plaintext) {
        return aesService.encrypt(plaintext, getEncryptionKey());
    }
    
    public String decrypt(String ciphertext) {
        return aesService.decrypt(ciphertext, getEncryptionKey());
    }
}

PII Handling and Masking Strategy

All Personally Identifiable Information (PII) will be:

  • Stored encrypted at rest
  • Masked in logs and non-sensitive contexts (e.g., show only last 4 digits of SSN)
  • Deleted per retention policy (typically 7 years for financial data)
public class PiiMasker {
    public static String maskSsn(String ssn) {
        return ssn != null ? ssn.replaceAll("(?<=.{3}).(?=.{4})", "*") : null;
    }
    
    public static String maskEmail(String email) {
        if (email == null || !email.contains("@")) return null;
        String[] parts = email.split("@");
        return parts[0].substring(0, 1) + "****" + "@" + parts[1];
    }
}

Secrets Management

All secrets (API keys, database passwords, encryption keys) will be managed through:

  • AWS Secrets Manager or equivalent cloud-native secrets manager
  • Rotation policies (keys rotated every 90 days)
  • Least privilege access control (only services that need secrets can access them)
@Component
public class SecretManager {
    private final AWSSecretsManager secretsManager;
    
    public String getSecret(String secretName) {
        GetSecretValueRequest request = GetSecretValueRequest.builder()
            .secretId(secretName)
            .build();
        return secretsManager.getSecretValue(request).secretString();
    }
}

Audit Logging Design

All security-relevant events will be logged with:

  • Timestamp and user context
  • Event type (login, access denied, token refresh)
  • IP address and session ID
  • Outcome (success/failure)
@Component
public class SecurityAuditLogger {
    private final AuditEventRepository auditEventRepo;
    
    public void logSecurityEvent(String eventType, String userId, 
                                String ipAddress, boolean success) {
        AuditEvent event = new AuditEvent(
            UUID.randomUUID().toString(),
            userId,
            eventType,
            ipAddress,
            success,
            Instant.now()
        );
        auditEventRepo.save(event);
    }
}

Log Format Example:

{
  "timestamp": "2023-10-15T14:30:45.123Z",
  "user_id": "user-123",
  "event_type": "LOGIN_SUCCESS",
  "ip_address": "192.168.1.100",
  "session_id": "sess-abc123",
  "success": true,
  "trace_id": "trace-xyz789"
}

Retention Policy: Security logs retained for 3 years, then archived to cold storage.

12. Testing Strategy

Unit Testing Scope and Coverage Targets (%)

  • Service Layer: 90% code coverage with focus on business logic and edge cases
  • Repository Layer: 85% coverage including all query paths and error conditions
  • Core Container Logic: 95% coverage with emphasis on circular dependency detection, lazy vs eager instantiation, and scope handling
  • Integration Points: 80% coverage for cross-component interactions (e.g., lifecycle callbacks, event handling)
  • Error Path Testing: 100% of error conditions must be covered with explicit test cases
  • Test Coverage Tools: JaCoCo for Java projects, with thresholds enforced via CI pipeline

Integration Testing Strategy

  • Test Containers: Use Testcontainers for database and external service integration tests (e.g., Redis, Kafka)
  • Embedded Databases: H2/SQLite for fast in-memory database tests during development and CI
  • WireMock: For external HTTP service stubbing (e.g., third-party API calls, authentication services)
  • Test Data Isolation: Each integration test runs in isolated database schema or with clean state setup
  • End-to-End Test Scenarios: Validate complete container initialization and component resolution flows

Contract Testing

  • Tool: Pact (consumer-driven contract testing) for external service contracts
  • Scope: All components that interact with external systems or APIs (e.g., configuration providers, event publishers)
  • Versioning Strategy: Contracts versioned alongside service versions with backward compatibility checks
  • CI Pipeline Integration: Contract tests run in CI pipeline as part of pre-deployment validation

Performance Testing Tool and Key Scenarios

  • Tool: JMeter for load testing, Gatling for high-concurrency scenarios
  • Key Scenarios:
    • Container instantiation under concurrent access (100+ threads)
    • Component resolution performance with complex dependency graphs
    • Memory usage during container lifecycle operations (GC pressure, object retention)
    • High-throughput component creation and destruction cycles
  • Metrics Tracked: Mean response time, 95th percentile latency, throughput (req/sec), memory allocation rate

Test Data Management Strategy

  • Fixtures: YAML/JSON-based fixtures for predefined test configurations and component definitions
  • Factories: Factory classes for generating realistic but minimal test data (e.g., mock component configurations)
  • Seeding: Automated test data setup using database migrations or direct schema loading for integration tests
  • Test Data Isolation: Each test suite runs with clean, isolated test data to prevent cross-contamination
  • Data Generation Libraries: Use libraries like Faker for generating realistic but non-sensitive test data where applicable
// Example: Unit test for component registration and resolution
@Test
public void testComponentResolutionWithDependencies() {
    // Given
    DIContainer container = new DIContainer();
    container.register(ComponentA.class, ComponentA.class);
    container.register(ComponentB.class, ComponentB.class);
    
    // When
    ComponentA resolved = container.resolve(ComponentA.class);
    
    // Then
    assertNotNull(resolved);
    assertNotNull(resolved.getComponentB());
}

13. Observability Design

Structured Logging Schema

The logging system will use structured JSON format to ensure consistent parsing and analysis across services. The schema includes:

{
  "timestamp": "2023-10-15T14:30:45.123Z",
  "level": "INFO",
  "service": "dependency-injection-container",
  "trace_id": "12345678-1234-1234-1234-123456789012",
  "span_id": "abcdef12-3456-7890-abcd-ef1234567890",
  "user_id": "user-123",
  "entity_id": "container-abc",
  "operation": "resolveDependency",
  "message": "Successfully resolved dependency for type: com.example.ServiceA",
  "duration_ms": 15,
  "error_code": null,
  "error_details": null,
  "tags": {
    "dependency_type": "com.example.ServiceA",
    "resolution_strategy": "singleton"
  }
}

PII masking rules:

  • User IDs are masked in logs when not needed for debugging
  • All sensitive fields (passwords, tokens) are completely redacted from logs

Distributed Tracing Instrumentation

Using OpenTelemetry for distributed tracing with custom spans:

Spans to instrument:

  1. di.container.resolve - for dependency resolution operations
  2. di.container.register - for service registration operations
  3. di.container.lifecycle - for container lifecycle events

Attributes captured:

Span span = tracer.spanBuilder("di.container.resolve")
    .setAttribute("dependency.type", serviceType.getName())
    .setAttribute("resolution.strategy", strategy.name())
    .setAttribute("container.id", containerId)
    .setAttribute("is.singleton", isSingleton)
    .startSpan();

Metrics Design

RED Method Metrics:

Metric NameTypeLabelsDescription
di.container.resolution.countCountertype, strategy, statusCount of dependency resolution attempts
di.container.resolution.durationHistogramtype, strategyDuration of dependency resolution in ms
di.container.registration.countCountertype, strategyCount of service registrations
di.container.active.instancesGaugetypeCurrent active instances per type
di.container.errors.totalCountererror.type, operationTotal error counts

Sample metric recording:

public class DependencyMetrics {
    private final Counter resolutionCounter = 
        OpenTelemetry.get().getMeter("di.container")
            .counterBuilder("resolution.count")
            .setDescription("Dependency resolution attempts")
            .build();
    
    private final Histogram resolutionDuration = 
        OpenTelemetry.get().getMeter("di.container")
            .histogramBuilder("resolution.duration")
            .setDescription("Duration of dependency resolution in ms")
            .build();
    
    public void recordResolution(String type, long durationMs, boolean success) {
        resolutionCounter.add(1, 
            AttributeKey.stringKey("type").string(type),
            AttributeKey.booleanKey("success").booleanValue(success)
        );
        
        resolutionDuration.record(durationMs, 
            AttributeKey.stringKey("type").string(type)
        );
    }
}

Health Check Endpoint Design

Endpoints:

  1. /health - Liveness check (container running)
  2. /ready - Readiness check (dependencies available)
  3. /live - Health status (container + dependencies)

Response format:

{
  "status": "healthy",
  "timestamp": "2023-10-15T14:30:45.123Z",
  "checks": {
    "container": {
      "status": "healthy",
      "details": "Container initialized and ready"
    },
    "dependencies": {
      "status": "healthy",
      "details": "All dependencies resolved successfully"
    }
  },
  "uptime": "PT12H34M56S"
}

Alerting Thresholds

Critical alerts (require immediate on-call response):

  • di.container.errors.total > 100 per minute (5 min window)
  • di.container.resolution.duration.p99 > 1000ms (5 min window)
  • di.container.resolution.count > 10000 per minute (5 min window) with >5% failure rate

Warning alerts (require investigation):

  • di.container.resolution.duration.p95 > 500ms (5 min window)
  • di.container.errors.total > 10 per minute (5 min window)
  • di.container.resolution.count > 1000 per minute (5 min window) with >2% failure rate

On-call escalation path:

  1. First alert → Slack notification to engineering team
  2. Second alert within 30 min → SMS to primary on-call engineer
  3. Third alert within 1 hour → Escalate to engineering manager

14. Open Questions, Risks & Trade-offs

Decisions requiring product / engineering alignment

  1. Container scope and lifecycle management - Should the DI container be application-wide or scoped to modules/components? This affects how dependencies are shared and cleaned up during application shutdown.

  2. Service registration strategy - Should services be auto-discovered via annotations or explicitly registered? Auto-discovery provides convenience but can lead to performance overhead during container initialization.

  3. Component instantiation strategy - Should the container support lazy vs. eager instantiation of dependencies? Eager instantiation improves startup time but increases memory usage and startup latency.

Known trade-offs and justifications

  1. Performance vs. flexibility trade-off - Using reflection-based service discovery provides maximum flexibility but introduces runtime overhead. Alternative approaches like code generation or compile-time metadata would improve performance but reduce flexibility in service registration and configuration.

  2. Memory overhead vs. caching efficiency - Caching resolved dependencies improves performance for repeated lookups but increases memory consumption. The cache size and eviction strategy must balance between performance gains and memory usage.

  3. Thread safety vs. complexity trade-off - Supporting concurrent access to the DI container increases implementation complexity and potential race conditions, but is required for multi-threaded applications. The trade-off is acceptable given the typical usage patterns in enterprise applications.

  4. Type safety vs. runtime flexibility - Strong type checking during dependency resolution provides compile-time guarantees but limits runtime flexibility for dynamic service registration and configuration.

Risks and mitigation strategies

  1. Circular dependency detection risk - Risk of infinite loops during dependency resolution without proper cycle detection mechanisms. Mitigation: Implement a topological sort or cycle detection algorithm during container initialization to detect and fail fast on circular dependencies.

  2. Memory leak risk - Risk of holding references to objects that should be garbage collected, particularly in long-running applications. Mitigation: Implement proper cleanup mechanisms and lifecycle management for scoped services.

  3. Performance degradation risk - Risk of significant startup time increases due to service discovery and dependency resolution overhead. Mitigation: Provide caching mechanisms, lazy initialization options, and performance profiling tools to help users optimize their service registration.

  4. Runtime error propagation risk - Risk of runtime errors during dependency resolution that could crash the application. Mitigation: Implement comprehensive error handling with detailed error messages and graceful degradation where possible.

Technical debt acknowledged

  1. Reflection-based implementation complexity - The use of reflection for service discovery and instantiation introduces complexity in error handling, debugging, and performance optimization that could be reduced with compile-time code generation.

  2. Limited support for complex generic types - Current implementation may not handle complex generic type scenarios properly, requiring future refactoring to support advanced generic patterns.

  3. Limited integration with existing frameworks - The container may not integrate seamlessly with Spring or other DI frameworks, requiring additional effort for migration scenarios.

  4. Testing complexity - The container's behavior under various concurrent conditions and edge cases requires extensive test coverage that may not be fully implemented in the initial design, creating technical debt for future maintenance.

Was this article helpful?

Topics in this article

More from Backend LLD

  • Backend LLD

    Decorator-Based Feature Extension Design Pattern

    Comprehensive low-level design for implementing extensible backend systems using decorator patterns with runtime feature composition and configuration management.

  • Backend LLD

    Rule Evaluation Engine Design and Implementation

    Comprehensive low-level design for a scalable rule evaluation engine supporting complex business logic with performance optimization and extensibility.

Stay in the Loop

Get curated DSA patterns, coding problems, frontend and backend LLD/HLD, and AI/ML System Design insights delivered straight to your inbox. No spam, just focused interview and engineering content.