The Container Pattern¶
The Container Pattern is the heart of dependency injection frameworks. This guide explains how containers work, their benefits, and how InjectQ implements them.
๐๏ธ What is a Container?¶
A Dependency Injection Container (or DI Container) is an object that:
- Knows about all your services and their dependencies
- Creates service instances when needed
- Injects dependencies automatically
- Manages service lifetimes (scopes)
๐ฆ Container Responsibilities¶
1. Service Registration¶
The container needs to know what services exist and how to create them:
from injectq import injectq
# Register services
injectq.bind(Database, PostgreSQLDatabase)
injectq.bind(Cache, RedisCache)
injectq.bind(UserService, UserService)
2. Dependency Resolution¶
When a service is requested, the container:
- Looks up the service registration
- Analyzes the service's dependencies
- Recursively resolves all dependencies
- Creates the service instance
- Returns the fully configured instance
# Container resolves this automatically
@inject
def process_data(service: UserService):
# Container creates:
# 1. Database instance
# 2. Cache instance
# 3. UserService instance with Database and Cache injected
pass
3. Lifetime Management¶
The container manages when services are created and destroyed:
# Singleton - one instance for entire app
@singleton
class Database:
pass
# Transient - new instance every time
@transient
class RequestHandler:
pass
๐ง How InjectQ's Container Works¶
Core Components¶
InjectQ's container consists of several key components:
class InjectQ:
def __init__(self):
self._registry = ServiceRegistry() # Service registrations
self._resolver = DependencyResolver() # Dependency resolution
self._scope_manager = ScopeManager() # Lifetime management
Service Registry¶
The registry stores information about all registered services:
# Internal registry structure
{
Database: {
"implementation": PostgreSQLDatabase,
"scope": "singleton",
"factory": None
},
UserService: {
"implementation": UserService,
"scope": "singleton",
"factory": None
}
}
Dependency Resolver¶
The resolver analyzes dependencies and builds the dependency graph:
# For UserService(Database, Cache)
# Resolver determines:
# UserService depends on Database and Cache
# Database depends on DatabaseConfig
# Cache depends on CacheConfig
Scope Manager¶
The scope manager controls service lifetimes:
# Different scopes for different lifetimes
injectq.bind(AppConfig, scope=Scope.APP) # Application lifetime
injectq.bind(RequestContext, scope=Scope.REQUEST) # Per request
injectq.bind(TempData, scope=Scope.TRANSIENT) # Always new
๐ฏ Container Patterns¶
1. Singleton Container (Default)¶
One global container for the entire application (recommended pattern):
from injectq import injectq
# Global convenience container
container = injectq
# Register services
container.bind(Database, PostgreSQLDatabase)
container.bind(UserService, UserService)
# Use anywhere in the app
@inject
def handler(service: UserService):
pass
Pros: - Simple to use - Services available everywhere - Easy to set up
Cons: - Global state - Harder to test in isolation - Can lead to tight coupling
2. Composed Containers¶
Multiple containers that can inherit from each other:
# Base container with common services
base_container = InjectQ()
base_container.bind(Database, PostgreSQLDatabase)
# Web-specific container
web_container = InjectQ(modules=[WebModule()])
web_container.bind(WebConfig, WebConfig)
# API-specific container
api_container = InjectQ(modules=[ApiModule()])
api_container.bind(ApiConfig, ApiConfig)
3. Scoped Containers¶
Containers that create child scopes:
# Main container
container = InjectQ()
# Create a request scope
async with container.scope("request"):
# Services in this scope are isolated
request_service = container.get(RequestService)
๐ Container Configuration Patterns¶
1. Dict-like Configuration¶
Simple key-value bindings:
# Simple values
injectq[str] = "postgresql://localhost/db"
injectq[int] = 42
injectq[bool] = True
# Complex objects
injectq["config"] = AppConfig(host="localhost", port=8080)
2. Type-based Configuration¶
Bind interfaces to implementations:
# Interface to implementation
container.bind(IDatabase, PostgreSQLDatabase)
container.bind(ICache, RedisCache)
container.bind(IUserRepository, UserRepository)
3. Factory-based Configuration¶
Use factories for complex creation logic:
def create_database(config: DatabaseConfig) -> IDatabase:
if config.driver == "postgres":
return PostgreSQLDatabase(config)
elif config.driver == "mysql":
return MySQLDatabase(config)
else:
return SQLiteDatabase(config)
container.bind_factory(IDatabase, create_database)
4. Module-based Configuration¶
Organize configuration with modules:
from injectq import Module
class DatabaseModule(Module):
def configure(self, binder):
binder.bind(IDatabase, PostgreSQLDatabase)
binder.bind(DatabaseConfig, DatabaseConfig)
class ServiceModule(Module):
def configure(self, binder):
binder.bind(IUserService, UserService)
binder.bind(IOrderService, OrderService)
# Compose modules
container = InjectQ([DatabaseModule(), ServiceModule()])
๐ Container Lifecycle¶
1. Registration Phase¶
Set up all service bindings:
container = InjectQ()
# Register all services
container.bind(Database, PostgreSQLDatabase)
container.bind(Cache, RedisCache)
container.bind(UserService, UserService)
# Validate configuration
container.validate()
2. Resolution Phase¶
Resolve services as needed:
# First resolution - creates instances
user_service = container.get(UserService)
# Subsequent resolutions - returns cached instances (for singletons)
another_service = container.get(UserService)
assert user_service is another_service # True for singletons
3. Cleanup Phase¶
Clean up resources when the application shuts down:
# Manual cleanup
container.clear()
# Or use context manager
with InjectQ() as container:
# Use container
pass
# Automatic cleanup
๐ Advanced Container Features¶
1. Lazy Resolution¶
Services are created only when first accessed:
container.bind(ExpensiveService, ExpensiveService)
# Service not created yet
print("Container ready")
# Service created here
service = container.get(ExpensiveService)
2. Circular Dependency Detection¶
Container detects and prevents circular dependencies:
class A:
def __init__(self, b: B):
self.b = b
class B:
def __init__(self, a: A): # Circular dependency!
self.a = a
container.bind(A, A)
container.bind(B, B)
# This will raise CircularDependencyError
container.validate()
3. Conditional Registration¶
Register services based on conditions:
if environment == "production":
container.bind(IDatabase, PostgreSQLDatabase)
else:
container.bind(IDatabase, SQLiteDatabase)
4. Named Bindings¶
Multiple implementations of the same interface:
# Register multiple caches
container.bind(Cache, RedisCache, name="redis")
container.bind(Cache, MemoryCache, name="memory")
# Resolve by name
redis_cache = container.get(Cache, name="redis")
memory_cache = container.get(Cache, name="memory")
๐งช Testing with Containers¶
1. Test Containers¶
Create isolated containers for testing:
from injectq.testing import test_container
def test_user_service():
with test_container() as container:
# Set up test dependencies
container.bind(IDatabase, MockDatabase)
container.bind(ICache, MockCache)
# Test the service
service = container.get(UserService)
result = service.get_user(1)
assert result is not None
2. Dependency Overrides¶
Temporarily override dependencies:
from injectq.testing import override_dependency
def test_with_override():
mock_db = MockDatabase()
with override_dependency(IDatabase, mock_db):
service = container.get(UserService)
# service now uses mock_db
result = service.get_user(1)
assert result.name == "Mock User"
๐ Performance Considerations¶
1. Compilation¶
Pre-compile dependency graphs for better performance:
# Compile for production
container.compile()
# Now resolutions are faster
service = container.get(UserService) # Optimized resolution
2. Caching¶
Container caches resolved instances based on scope:
# Singleton services are cached
db1 = container.get(Database)
db2 = container.get(Database)
assert db1 is db2 # Same instance
3. Lazy Loading¶
Services are created only when needed:
# No instances created yet
container.bind(HeavyService, HeavyService)
# Instance created here
service = container.get(HeavyService)
๐ Container Benefits¶
1. Automatic Dependency Resolution¶
No manual wiring of dependencies:
# Manual (error-prone)
def create_service():
config = DatabaseConfig()
db = Database(config)
cache = Cache()
logger = Logger()
return UserService(db, cache, logger)
# Container (automatic)
@inject
def use_service(service: UserService):
pass
2. Centralized Configuration¶
All service configuration in one place:
container = InjectQ()
# All configuration here
container.bind(Database, PostgreSQLDatabase)
container.bind(Cache, RedisCache)
container.bind_all_from_module(MyModule)
3. Lifetime Management¶
Automatic management of service lifetimes:
# Container handles creation and cleanup
@singleton
class Database:
def __init__(self):
# Set up connection
def close(self):
# Cleanup connection
4. Testability¶
Easy to replace dependencies for testing:
# Production
container.bind(IDatabase, PostgreSQLDatabase)
# Testing
with override_dependency(IDatabase, MockDatabase):
# Test with mock
pass
๐จ Common Container Mistakes¶
1. Over-using the Global Container¶
# โ Global container everywhere
from injectq import injectq
class MyClass:
def __init__(self):
self.service = injectq.get(UserService) # Hidden dependency
2. Ignoring Scopes¶
3. Circular Dependencies¶
# โ Circular dependency
class A:
def __init__(self, b: B):
self.b = b
class B:
def __init__(self, a: A):
self.a = a
๐ Best Practices¶
1. Use Modules for Organization¶
# โ
Organize with modules
class DatabaseModule(Module):
def configure(self, binder):
binder.bind(IDatabase, PostgreSQLDatabase)
container = InjectQ([DatabaseModule()])
2. Validate Early¶
3. Use Appropriate Scopes¶
# โ
Correct scope usage
@singleton
class Database: # Shared across app
pass
@scoped("request")
class RequestContext: # Per request
pass
@transient
class CommandHandler: # New each time
pass
4. Handle Cleanup¶
# โ
Proper cleanup
@resource
def database_connection():
conn = create_connection()
try:
yield conn
finally:
conn.close()
๐ฏ Summary¶
The Container Pattern provides:
- Automatic dependency resolution - No manual wiring
- Centralized configuration - All setup in one place
- Lifetime management - Automatic creation/cleanup
- Testability - Easy dependency replacement
- Performance - Caching and optimization
- Maintainability - Clear separation of concerns
InjectQ's container is designed to be: - Simple - Easy to get started - Powerful - Advanced features when needed - Fast - Optimized for performance - Testable - Built-in testing support
Ready to explore service lifetimes?