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
- What is the expected scale of the DI container? Will it handle hundreds of thousands of components or just a few dozen?
- What are the consistency and availability requirements? Should it support high availability with zero downtime or can it tolerate brief outages?
- What types of components are expected to be managed? Are we dealing with simple POJOs, complex services with lifecycle events, or both?
- What is the expected read/write ratio for component instantiation? Is it mostly read (instantiation) or a mix of creation and updates?
- What are the latency sensitivity requirements? Should component instantiation happen in sub-millisecond time or is a few hundred milliseconds acceptable?
- What authentication and authorization model is expected? Should the DI container enforce access control or assume a trusted environment?
- 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
| Method | Path | Description | Auth |
|---|---|---|---|
Register | /di.v1.Container/Register | Register a new dependency binding | None |
Resolve | /di.v1.Container/Resolve | Resolve a dependency by type | None |
ResolveAll | /di.v1.Container/ResolveAll | Resolve all bindings for a given type | None |
GetBinding | /di.v1.Container/GetBinding | Retrieve a specific binding configuration | None |
ListBindings | /di.v1.Container/ListBindings | List all registered bindings | None |
Clear | /di.v1.Container/Clear | Clear all registered bindings | None |
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 Code | Description | Error Type |
|---|---|---|
| 200 | Success | - |
| 400 | Invalid request parameters | ValidationError |
| 401 | Authentication required | UnauthorizedError |
| 403 | Insufficient permissions | ForbiddenError |
| 404 | Resource not found | NotFoundError |
| 429 | Rate limit exceeded | RateLimitExceededError |
| 500 | Internal server error | InternalServerError |
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:
| Endpoint | Limit | Period |
|---|---|---|
Register | 1000 req/min | 1 minute |
Resolve | 5000 req/min | 1 minute |
ResolveAll | 1000 req/min | 1 minute |
GetBinding | 2000 req/min | 1 minute |
ListBindings | 1000 req/min | 1 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
Registerendpoint, 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:
-
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.
-
No coordination required: Unlike auto-increment, UUIDs don't require coordination between systems or database clusters to generate unique identifiers.
-
Scalability: UUIDs support horizontal scaling without worrying about ID conflicts or database sequence contention.
-
Security: UUIDs don't reveal information about the creation order or system, providing better security properties.
-
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)
-
idx_bean_definitions_name:
- Purpose: Fast lookup by bean name (common operation in DI containers)
- Justification: Bean names are frequently used to retrieve definitions
-
idx_bean_definitions_scope:
- Purpose: Filter beans by scope for lazy loading or singleton management
- Justification: Performance optimization for scope-based queries
-
idx_bean_instances_definition_id:
- Purpose: Fast retrieval of bean instances by definition ID
- Justification: Critical for singleton instance lookup and lifecycle management
-
idx_bean_dependencies_bean_id:
- Purpose: Find all dependencies for a given bean
- Justification: Required for dependency resolution and graph traversal
-
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:
- Small dataset size: DI container metadata is generally small (hundreds to thousands of beans)
- In-memory caching: Most operations are in-memory, not database-backed
- 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:
- Audit trail capability: Track when beans were removed or modified
- Recovery capability: Allow recovery of deleted bean definitions if needed
- 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:
- Low storage cost: Metadata is minimal in size
- High access frequency: Bean definitions are accessed frequently during application startup and runtime
- 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:
- DIContainer - Main container interface and implementation that orchestrates bean lifecycle management
- BeanDefinitionRegistry - Manages registration and retrieval of bean definitions
- SingletonBeanRegistry - Manages singleton instances
- BeanFactory - Factory pattern implementation for creating beans with custom logic
- 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:
- Ensure proper lifecycle management of transactional beans
- Support declarative transaction management through annotations (e.g.,
@Transactional) - Handle bean scope correctly for transactional contexts
Service Interface Contracts
All interfaces are designed to be minimal and focused:
BeanFactory- Core contract for bean retrievalBeanDefinitionRegistry- Registration and lookup of bean definitionsSingletonBeanRegistry- Singleton instance managementBeanPostProcessor- Extension point for bean modificationBeanFactoryPostProcessor- 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:
- Bean lifecycle management that can support distributed transaction patterns
- Event-driven bean initialization through
BeanPostProcessorimplementations - Integration with external systems that may require saga coordination
Domain Object / Value Object Design
The DI container uses the following domain objects:
- BeanDefinition - Immutable configuration object describing how to create a bean
- BeanFactory - Factory function for creating beans with custom logic
- ObjectReference - Reference to a bean instance (not explicitly shown but implied)
- 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.concurrentpackage 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:
- Component registration - Multiple threads registering the same component type
- Object instantiation - Concurrent resolution of circular dependencies or singleton instances
- Configuration updates - Simultaneous modification of component metadata
Prevention strategies:
- Use
ConcurrentHashMapfor storing registered components - Implement double-checked locking pattern for singleton instances
- Use
ReentrantReadWriteLockto 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:
- Circular dependency resolution - Prevents infinite waits during object graph construction
- 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
- 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;
}
-
Concurrent component graph traversal using thread-safe collections and atomic operations for dependency tracking
-
Immutable configuration objects to prevent concurrent modification during resolution
Synchronization Primitives Used
ConcurrentHashMapfor thread-safe registry operationsReentrantReadWriteLockfor registry modification synchronizationAtomicReferencefor atomic component state updatesCountDownLatchfor 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 Type | TTL | Reason |
|---|---|---|
| Service instances | 1 hour | Services are typically long-lived, but we want to allow for configuration updates |
| Factory instances | 30 minutes | Factories may be more volatile and need shorter refresh |
| Component metadata | 24 hours | Metadata about components is relatively static |
| Configuration objects | 5 minutes | Configuration 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:
- Probabilistic early expiry: Set cache TTL to 2x the actual expiration time, but use a random offset (5-15%)
- Distributed locks: For critical component creation, use Redis distributed locks with 500ms timeout
- 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:
- When component configuration changes, publish
component.updatedevent - When service dependencies change, invalidate related cache entries
- 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:
-
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.
-
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.
-
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.
-
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:
- Poison Message Detection: If a message fails to be processed more than 3 times (configurable), it is moved to a DLQ.
- DLQ Handling: Messages in the DLQ are logged with full context and can be manually retried or investigated by system administrators.
- 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:
- Idempotency is achievable: Bean lifecycle events can be made idempotent by checking if the event has already been processed.
- Simpler to implement: Exactly-once delivery requires complex coordination and is overkill for this use case.
- 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:
- The DI container is primarily a synchronous component.
- Events are published within the same JVM or tightly coupled services.
- 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:
- Infrastructure Errors - System-level issues (connection failures, resource exhaustion)
- Domain Errors - Business logic violations (circular dependencies, missing bindings)
- 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
ValidationExceptionat the API boundary - Domain errors are handled within service methods and re-thrown as
DomainException - Infrastructure errors are wrapped in
InfrastructureExceptionand 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:
di.container.resolve- for dependency resolution operationsdi.container.register- for service registration operationsdi.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 Name | Type | Labels | Description |
|---|---|---|---|
di.container.resolution.count | Counter | type, strategy, status | Count of dependency resolution attempts |
di.container.resolution.duration | Histogram | type, strategy | Duration of dependency resolution in ms |
di.container.registration.count | Counter | type, strategy | Count of service registrations |
di.container.active.instances | Gauge | type | Current active instances per type |
di.container.errors.total | Counter | error.type, operation | Total 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:
/health- Liveness check (container running)/ready- Readiness check (dependencies available)/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:
- First alert → Slack notification to engineering team
- Second alert within 30 min → SMS to primary on-call engineer
- Third alert within 1 hour → Escalate to engineering manager
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
-
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.
-
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.
-
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
-
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.
-
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.
-
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.
-
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
-
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.
-
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.
-
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.
-
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
-
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.
-
Limited support for complex generic types - Current implementation may not handle complex generic type scenarios properly, requiring future refactoring to support advanced generic patterns.
-
Limited integration with existing frameworks - The container may not integrate seamlessly with Spring or other DI frameworks, requiring additional effort for migration scenarios.
-
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.