Skip to content

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:

  1. Knows about all your services and their dependencies
  2. Creates service instances when needed
  3. Injects dependencies automatically
  4. 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, singleton

container = InjectQ.get_instance()

# Option 1: Use decorators (recommended)
@singleton
class Database:
    pass

@singleton
class Cache:
    pass

@singleton
class UserService:
    pass

# Option 2: Manual binding
container.bind(Database, Database)
container.bind(Cache, Cache)
container.bind(UserService, UserService)

2. Dependency Resolution

When a service is requested, the container:

  1. Looks up the service registration
  2. Analyzes the service's dependencies
  3. Recursively resolves all dependencies
  4. Creates the service instance
  5. Returns the fully configured instance
from injectq import inject, singleton

@singleton
class Database:
    pass

@singleton
class Cache:
    pass

@singleton
class UserService:
    @inject
    def __init__(self, db: Database, cache: Cache):
        self.db = db
        self.cache = cache

# 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(RequestContext, scope=ScopeType.REQUEST)  # Per request
injectq.bind(TempData, scope=ScopeType.TRANSIENT)      # Always new
injectq.bind(AppConfig, scope=ScopeType.SINGLETON)     # One for app

๐ŸŽฏ Container Patterns

1. Singleton Container (Default)

One global container for the entire application (recommended pattern):

from injectq import InjectQ, inject, singleton

# Get the global container instance
container = InjectQ.get_instance()

# Register services using decorators or manual binding
@singleton
class Database:
    pass

@singleton
class UserService:
    @inject
    def __init__(self, db: Database):
        self.db = db

# Use anywhere in the app
@inject
def handler(service: UserService):
    pass

Pros: - Simple to use - Services available everywhere - Easy to set up - Decorators auto-register

Cons: - Global state - Harder to test in isolation - Can lead to tight coupling

2. Composed Containers

Multiple containers for different contexts:

from injectq import InjectQ, Module, singleton

@singleton
class Database:
    pass

class WebModule(Module):
    def configure(self, binder):
        binder.bind("web_config", {"port": 8080})

class ApiModule(Module):
    def configure(self, binder):
        binder.bind("api_config", {"version": "v1"})

# Base container with common services
base_container = InjectQ()
base_container.bind(Database, Database)

# Web-specific container
web_container = InjectQ(modules=[WebModule()])
web_container.bind(Database, Database)

# API-specific container
api_container = InjectQ(modules=[ApiModule()])
api_container.bind(Database, Database)

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:

from injectq import InjectQ

container = InjectQ.get_instance()

# Simple values
container["db_url"] = "postgresql://localhost/db"
container["port"] = 8080
container["debug"] = True

# Complex objects
class AppConfig:
    def __init__(self, host: str, port: int):
        self.host = host
        self.port = port

container["config"] = AppConfig(host="localhost", port=8080)

2. Type-based Configuration

Bind types to implementations:

from injectq import InjectQ, singleton
from abc import ABC, abstractmethod

# Define interface
class Database(ABC):
    @abstractmethod
    def query(self, sql: str):
        pass

# Implementation
@singleton
class PostgreSQLDatabase(Database):
    def query(self, sql: str):
        return f"PostgreSQL: {sql}"

container = InjectQ.get_instance()

# Bind interface to implementation
container.bind(Database, PostgreSQLDatabase)

3. Factory-based Configuration

Use factories for complex creation logic. InjectQ supports both DI-based factories (no parameters) and parameterized factories (with arguments).

Regular Factory (Dependency Injection)

Create factories that are resolved automatically using DI:

from injectq import InjectQ, inject, singleton

container = InjectQ.get_instance()

@singleton
class DatabaseConfig:
    def __init__(self):
        self.driver = "postgres"

class Database:
    def __init__(self, config: DatabaseConfig):
        self.config = config

# Factory with DI - dependencies are injected
@inject
def create_database(config: DatabaseConfig) -> Database:
    """Factory with DI - dependencies are injected."""
    if config.driver == "postgres":
        return Database(config)
    else:
        return Database(config)

# Bind the factory - DatabaseConfig is automatically injected
container.bind_factory(Database, create_database)

# Get the instance - factory is called automatically
db = container[Database]

Parameterized Factory

Create factories that accept arguments:

# Factory that accepts parameters
def create_connection_pool(db_name: str, max_conn: int = 10):
    """Factory with parameters - no DI needed."""
    return ConnectionPool(db_name, max_conn=max_conn)

# Bind the parameterized factory
container.bind_factory("db_pool", create_connection_pool)

# Method 1: Get the factory function and call it
factory = container.get_factory("db_pool")
users_pool = factory("users_db", max_conn=20)

# Method 2: Use call_factory shorthand
orders_pool = container.call_factory("db_pool", "orders_db", max_conn=15)

# Method 3: Chain the calls
logs_pool = container.get_factory("db_pool")("logs_db")

Combining DI and Parameterized Factories

Mix both patterns in the same container:

from injectq import InjectQ, singleton, inject

container = InjectQ.get_instance()

@singleton
class LogConfig:
    def __init__(self):
        self.level = "INFO"

class Logger:
    def __init__(self, config: LogConfig):
        self.config = config

@singleton
class Database:
    pass

# DI factory
@inject
def create_logger(config: LogConfig) -> Logger:
    """Factory with DI - dependencies injected."""
    return Logger(config)

# Parameterized factory
def get_user_from_db(user_id: int):
    """Factory with parameters - custom arguments."""
    db = container[Database]  # Can still use DI
    return {"user_id": user_id, "db": db}

# Bind both
container.bind_factory(Logger, create_logger)        # DI factory
container.bind_factory("get_user", get_user_from_db) # Parameterized

# Use both
logger = container[Logger]                    # No args needed
user = container.call_factory("get_user", 42) # Pass args

๐Ÿ†• Hybrid Factories with invoke()

The new invoke() method combines DI with manual arguments automatically:

from injectq import InjectQ, singleton, inject

container = InjectQ.get_instance()

@singleton
class Database:
    pass

@singleton
class Cache:
    pass

class UserService:
    def __init__(self, db: Database, cache: Cache, user_id: str):
        self.db = db
        self.cache = cache
        self.user_id = user_id

# Factory that needs BOTH DI dependencies and manual arguments
def create_user_service(db: Database, cache: Cache, user_id: str) -> UserService:
    """Hybrid factory - some deps injected, some provided manually."""
    return UserService(db, cache, user_id)

container.bind_factory("user_service", create_user_service)

# โŒ Old way - verbose manual resolution
db = container[Database]
cache = container[Cache]
service = container.call_factory("user_service", db, cache, "user123")

# โœ… New way - automatic DI + manual args
service = container.invoke("user_service", user_id="user123")
# Database and Cache are auto-injected, only provide user_id!

# Also works with async
async def async_factory(db: Database, batch_size: int) -> dict:
    return {"db": db, "batch_size": batch_size}

container.bind_factory("async_service", async_factory)
result = await container.ainvoke("async_service", batch_size=100)

When to use invoke(): - Factory needs some dependencies from DI + some runtime arguments - You want cleaner code without manual resolution - Mix configuration from container with user input

Learn more in Factory Methods.

Real-World Example: Multiple Database Connections

from injectq import InjectQ

container = InjectQ.get_instance()

class DatabasePool:
    """Connection pool for a database."""
    def __init__(self, db_name: str, max_connections: int = 10):
        self.db_name = db_name
        self.max_connections = max_connections
        self.connections = []

# Create a parameterized factory
def create_db_pool(db_name: str, max_conn: int = 10) -> DatabasePool:
    return DatabasePool(db_name, max_conn=max_conn)

# Bind the factory
container.bind_factory("db_pool", create_db_pool)

# Create multiple pools with different parameters
users_db = container.call_factory("db_pool", "users_db", max_conn=20)
orders_db = container.call_factory("db_pool", "orders_db", max_conn=15)
logs_db = container.call_factory("db_pool", "logs_db")  # Uses default max_conn=10

# Each pool is independent
assert users_db is not orders_db
assert users_db.db_name == "users_db"
assert orders_db.max_connections == 15

4. Module-based Configuration

Organize configuration with modules:

from injectq import Module, InjectQ, singleton
from abc import ABC, abstractmethod

# Define interface
class Database(ABC):
    @abstractmethod
    def query(self, sql: str):
        pass

# Implementation
@singleton
class PostgreSQLDatabase(Database):
    def query(self, sql: str):
        return f"PostgreSQL: {sql}"

@singleton
class DatabaseConfig:
    def __init__(self):
        self.connection_string = "postgresql://localhost/db"

@singleton
class UserService:
    pass

@singleton  
class OrderService:
    pass

class DatabaseModule(Module):
    def configure(self, binder):
        binder.bind(Database, PostgreSQLDatabase)
        binder.bind(DatabaseConfig, DatabaseConfig)

class ServiceModule(Module):
    def configure(self, binder):
        binder.bind(UserService, UserService)
        binder.bind(OrderService, OrderService)

# Compose modules
container = InjectQ(modules=[DatabaseModule(), ServiceModule()])

๐Ÿ”„ Container Lifecycle

1. Registration Phase

Set up all service bindings:

from injectq import InjectQ, singleton

container = InjectQ()

# Register all services using decorators
@singleton
class Database:
    pass

@singleton
class Cache:
    pass

@singleton
class UserService:
    pass

# Or manual binding
container.bind(Database, Database)
container.bind(Cache, Cache)
container.bind(UserService, UserService)

# Validate configuration (optional but recommended)
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:

from injectq import InjectQ

container = InjectQ()

# Manual cleanup
container.clear()

# Or use context manager
with container.context():
    # Use container
    pass
# Automatic cleanup when exiting context

๐Ÿš€ Advanced Container Features

1. Lazy Resolution

Services are created only when first accessed:

from injectq import InjectQ, singleton

container = InjectQ.get_instance()

@singleton
class ExpensiveService:
    def __init__(self):
        print("ExpensiveService initialized")

# Service not created yet
print("Container ready")

# Service created here
service = container[ExpensiveService]  # Prints: ExpensiveService initialized

2. Circular Dependency Detection

Container detects and prevents circular dependencies:

from injectq import InjectQ, inject, singleton

@singleton
class A:
    @inject
    def __init__(self, b: "B"):
        self.b = b

@singleton
class B:
    @inject
    def __init__(self, a: A):  # Circular dependency!
        self.a = a

container = InjectQ.get_instance()

# This will raise CircularDependencyError when trying to resolve
try:
    container.validate()
except Exception as e:
    print(f"Circular dependency detected: {e}")

3. Conditional Registration

Register services based on conditions:

from injectq import InjectQ, singleton
from abc import ABC, abstractmethod

class Database(ABC):
    @abstractmethod
    def query(self, sql: str):
        pass

@singleton
class PostgreSQLDatabase(Database):
    def query(self, sql: str):
        return f"PostgreSQL: {sql}"

@singleton
class SQLiteDatabase(Database):
    def query(self, sql: str):
        return f"SQLite: {sql}"

container = InjectQ.get_instance()
environment = "production"  # or "development"

if environment == "production":
    container.bind(Database, PostgreSQLDatabase)
else:
    container.bind(Database, SQLiteDatabase)

๐Ÿงช Testing with Containers

1. Test Containers

Create isolated containers for testing:

from injectq.testing import test_container
from injectq import singleton, inject
from abc import ABC, abstractmethod

class Database(ABC):
    @abstractmethod
    def get_user(self, user_id: int):
        pass

class MockDatabase(Database):
    def get_user(self, user_id: int):
        return {"id": user_id, "name": "Mock User"}

@singleton
class UserService:
    @inject
    def __init__(self, db: Database):
        self.db = db

    def get_user(self, user_id: int):
        return self.db.get_user(user_id)

def test_user_service():
    with test_container() as container:
        # Set up test dependencies
        container.bind(Database, MockDatabase)
        container.bind(UserService, UserService)

        # Test the service
        service = container[UserService]
        result = service.get_user(1)
        assert result is not None
        assert result["name"] == "Mock User"

2. Dependency Overrides

Temporarily override dependencies:

from injectq.testing import override_dependency
from injectq import InjectQ, singleton, inject
from abc import ABC, abstractmethod

class Database(ABC):
    @abstractmethod
    def get_user(self, user_id: int):
        pass

@singleton
class RealDatabase(Database):
    def get_user(self, user_id: int):
        return {"id": user_id, "name": "Real User"}

class MockDatabase(Database):
    def get_user(self, user_id: int):
        return {"id": user_id, "name": "Mock User"}

@singleton
class UserService:
    @inject
    def __init__(self, db: Database):
        self.db = db

    def get_user(self, user_id: int):
        return self.db.get_user(user_id)

def test_with_override():
    container = InjectQ.get_instance()
    mock_db = MockDatabase()

    with override_dependency(Database, mock_db):
        service = container[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:

from injectq import InjectQ, singleton

@singleton
class Database:
    pass

container = InjectQ.get_instance()

# Singleton services are cached
db1 = container[Database]
db2 = container[Database]
assert db1 is db2  # Same instance

3. Lazy Loading

Services are created only when needed:

from injectq import InjectQ, singleton

@singleton
class HeavyService:
    def __init__(self):
        print("HeavyService initialized")

container = InjectQ.get_instance()

# No instances created yet
print("Container ready")

# Instance created here
service = container[HeavyService]  # Prints: HeavyService initialized

๐ŸŽ‰ Container Benefits

1. Automatic Dependency Resolution

No manual wiring of dependencies:

from injectq import inject, singleton

# โŒ Manual (error-prone)
class DatabaseConfig:
    pass

class Database:
    def __init__(self, config: DatabaseConfig):
        self.config = config

class Cache:
    pass

class Logger:
    pass

class UserService:
    def __init__(self, db: Database, cache: Cache, logger: Logger):
        self.db = db
        self.cache = cache
        self.logger = logger

def create_service():
    config = DatabaseConfig()
    db = Database(config)
    cache = Cache()
    logger = Logger()
    return UserService(db, cache, logger)

# โœ… Container (automatic)
@singleton
class DatabaseConfig:
    pass

@singleton
class Database:
    @inject
    def __init__(self, config: DatabaseConfig):
        self.config = config

@singleton
class Cache:
    pass

@singleton
class Logger:
    pass

@singleton
class UserService:
    @inject
    def __init__(self, db: Database, cache: Cache, logger: Logger):
        self.db = db
        self.cache = cache
        self.logger = logger

@inject
def use_service(service: UserService):
    pass

2. Centralized Configuration

All service configuration in one place:

from injectq import InjectQ, Module, singleton
from abc import ABC, abstractmethod

# Define services
class Database(ABC):
    @abstractmethod
    def query(self, sql: str):
        pass

@singleton
class PostgreSQLDatabase(Database):
    def query(self, sql: str):
        return f"PostgreSQL: {sql}"

@singleton
class RedisCache:
    pass

# Option 1: Module-based configuration
class AppModule(Module):
    def configure(self, binder):
        binder.bind(Database, PostgreSQLDatabase)
        binder.bind(RedisCache, RedisCache)

container = InjectQ(modules=[AppModule()])

# Option 2: Direct configuration
container = InjectQ.get_instance()
container.bind(Database, PostgreSQLDatabase)
container.bind(RedisCache, RedisCache)

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:

from injectq import InjectQ, singleton
from injectq.testing import override_dependency
from abc import ABC, abstractmethod

# Production
class Database(ABC):
    @abstractmethod
    def query(self, sql: str):
        pass

@singleton
class PostgreSQLDatabase(Database):
    def query(self, sql: str):
        return f"PostgreSQL: {sql}"

container = InjectQ.get_instance()
container.bind(Database, PostgreSQLDatabase)

# Testing
class MockDatabase(Database):
    def query(self, sql: str):
        return "Mock result"

with override_dependency(Database, MockDatabase()):
    # Test with mock
    pass

๐Ÿšจ Common Container Mistakes

1. Over-using the Global Container

from injectq import InjectQ, inject, singleton

# โŒ Global container everywhere - hidden dependency
@singleton
class MyClass:
    def __init__(self):
        container = InjectQ.get_instance()
        self.service = container[UserService]  # Hidden dependency

# โœ… Explicit dependency injection
@singleton
class MyClass:
    @inject
    def __init__(self, service: UserService):
        self.service = service  # Clear dependency

2. Ignoring Scopes

# โŒ Wrong scope usage
@singleton
class RequestData:  # Should be scoped or transient
    pass

3. Circular Dependencies

from injectq import singleton, inject

# โŒ Circular dependency
@singleton
class A:
    @inject
    def __init__(self, b: "B"):
        self.b = b

@singleton
class B:
    @inject
    def __init__(self, a: A):
        self.a = a

# โœ… Break the cycle with a factory or interface
@singleton
class A:
    def set_b(self, b: "B"):
        self.b = b

@singleton
class B:
    @inject
    def __init__(self, a: A):
        self.a = a
        a.set_b(self)

๐Ÿ† Best Practices

1. Use Modules for Organization

from injectq import Module, InjectQ, singleton
from abc import ABC, abstractmethod

# Define interfaces and implementations
class Database(ABC):
    @abstractmethod
    def query(self, sql: str):
        pass

@singleton
class PostgreSQLDatabase(Database):
    def query(self, sql: str):
        return f"PostgreSQL: {sql}"

# โœ… Organize with modules
class DatabaseModule(Module):
    def configure(self, binder):
        binder.bind(Database, PostgreSQLDatabase)

container = InjectQ(modules=[DatabaseModule()])

2. Validate Early

from injectq import InjectQ

# โœ… Validate configuration
container = InjectQ()
# ... register services ...
container.validate()  # Check for errors 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?