Guides implementation of Dependency Injection containers using Python Protocols. Does NOT handle: architecture (use architecture-builder), interface philosophy (use protocol-design), testing DI (use testing-patterns), scaffolding (use project-scaffold). Use when designing service wiring, managing component lifecycle, or decoupling modules. Recognizes: "di-container", "dependency injection", "container pattern", "service wiring", "inversion of control", "IoC container", "lazy initialization", "protocol-based DI"
Resources
1Install
npx skillscat add 101mare/skill-library/di-container Install via the SkillsCat registry.
SKILL.md
DI Container Pattern
Dependency Injection containers with Python Protocols for decoupled, testable applications.
When to Use
- Application has 5+ services that depend on each other
- Services need lifecycle management (startup/shutdown)
- You want to swap implementations (e.g., different backends)
- Testing requires replacing real services with mocks
When NOT to Use
- Small scripts or single-module apps
- Only 2-3 services with simple wiring
- No need for lifecycle management
Container Skeleton
from typing import Protocol
# 1. Define interfaces
class LlmClient(Protocol):
def complete(self, prompt: str) -> str: ...
class Storage(Protocol):
def save(self, key: str, data: bytes) -> None: ...
def load(self, key: str) -> bytes | None: ...
# 2. Container with lazy properties
class Container:
def __init__(self, config: AppConfig):
self._config = config
self._llm_client: LlmClient | None = None
self._storage: Storage | None = None
self._classifier: Classifier | None = None
@property
def llm_client(self) -> LlmClient:
if self._llm_client is None:
self._llm_client = self._create_llm_client()
return self._llm_client
@property
def storage(self) -> Storage:
if self._storage is None:
self._storage = self._create_storage()
return self._storage
@property
def classifier(self) -> Classifier:
if self._classifier is None:
self._classifier = Classifier(
llm=self.llm_client,
config=self._config,
)
return self._classifier
def _create_llm_client(self) -> LlmClient:
"""Factory: select implementation based on config."""
match self._config.provider.type:
case "ollama":
from .providers.ollama import OllamaClient
return OllamaClient(self._config.provider.ollama)
case "openai":
from .providers.openai import OpenAIClient
return OpenAIClient(self._config.provider.openai)
case _:
raise ConfigError(f"Unknown provider: {self._config.provider.type}")
def _create_storage(self) -> Storage:
from .storage.filesystem import FileStorage
return FileStorage(self._config.storage.path)Provider Factory Pattern
# providers/__init__.py
def create_provider(config: ProviderConfig) -> ModelProvider:
"""Factory function for provider selection."""
match config.type:
case "ollama":
from .ollama.provider import OllamaProvider
return OllamaProvider(config.ollama)
case "vllm":
from .vllm.provider import VllmProvider
return VllmProvider(config.vllm)
case _:
raise ConfigError(f"Unknown provider: {config.type}")Key principles:
- Import provider-specific code inside the match case (lazy import)
- Service code never imports provider implementations directly
- Only the container/factory knows about concrete types
Lifecycle Management
class Container:
def startup(self) -> None:
"""Initialize services that need explicit startup."""
logger.info("Container starting up")
self._provider = create_provider(self._config.provider)
self._provider.startup()
logger.info("Container ready")
def shutdown(self) -> None:
"""Clean up resources."""
logger.info("Container shutting down")
if self._provider:
self._provider.shutdown()
if self._storage:
self._storage.close()
logger.info("Container shutdown complete")
def __enter__(self) -> "Container":
self.startup()
return self
def __exit__(self, *exc) -> None:
self.shutdown()Usage:
config = load_config()
with Container(config) as container:
result = container.classifier.classify(document)Testing with DI
from unittest.mock import Mock
def test_classifier_with_mock():
# Create mock that satisfies Protocol
mock_llm = Mock(spec=LlmClient)
mock_llm.complete.return_value = '{"category": "invoice"}'
# Inject directly -- no container needed for unit tests
classifier = Classifier(llm=mock_llm, config=test_config)
result = classifier.classify("some document")
assert result.category == "invoice"
mock_llm.complete.assert_called_once()
def test_with_container():
# For integration tests, use the real container
config = load_config("test_config.yaml")
with Container(config) as container:
result = container.classifier.classify("test doc")
assert result is not NoneAnti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Service Locator | container.get("service_name") -- no type safety |
Use typed properties |
| Global container | global_container.service -- hidden dependency |
Pass container or services explicitly |
| Over-abstraction | Protocol for a single implementation | Only abstract when 2+ implementations exist |
| Eager initialization | All services created at startup | Use lazy properties |
| Circular dependencies | Service A needs B, B needs A | Extract shared logic to C |
| Container in business logic | def process(container) |
Inject specific services, not the container |
Checklist
- Interfaces defined as Protocols (not concrete classes)
- Container uses lazy properties
- Provider selection via factory (match/case or dict)
- Provider-specific imports only inside factory
- Lifecycle methods (startup/shutdown)
- Context manager support (enter/exit)
- Services accept Protocols, not concrete types
- Unit tests inject mocks directly (no container)