5.3 KiB
ADR-008: MicroContainer for Dependency Injection
Status: Accepted
Date: 2025-01-11
Context:
With a modular architecture using multiple packages, I needed a dependency injection solution to wire up the application. The options included:
- Manual dependency injection: Pass dependencies through initializers (simple but verbose)
- SwiftUI @Environment: Good for view-level DI but limited to SwiftUI context
- Third-party DI frameworks: Swinject, Resolver, etc. (feature-rich but complex)
- MicroContainer: Lightweight DI container I built for this purpose
Key considerations:
- Simplicity: Needed straightforward registration and resolution
- Type safety: Compile-time safety where possible
- Factory pattern support: Factories are the primary integration pattern
- Lifecycle management: Support for singleton (.static) vs transient instances
- Minimal overhead: Lightweight solution without complex features
Decision:
I chose to build and use MicroContainer (exposed as DependencyContainer) as the dependency injection solution for the OMG application. It provides a simple, type-safe API for registering and resolving dependencies, tailored to the application's factory-based architecture.
Key Implementation Patterns:
- TritonEnvironment: Main app environment manages root
DependencyContainer - Registration: Dependencies registered with type, allocation strategy, and factory closure
- Resolution: Simple
container.resolve()with type inference - Static allocation: Singleton instances using
.staticallocation - Dependency chains: Container passed to factory closures for resolving dependencies
Example Usage:
// Main environment setup
struct TritonEnvironment: TritonEnvironmentProtocol {
private let container = DependencyContainer()
// Computed properties resolve from container
var authSessionService: any AuthSessionServiceProtocol { container.resolve() }
var statusAppFactory: StatusAppFactory { container.resolve() }
init() {
// Register core service with singleton allocation
container.register(
type: (any AuthSessionServiceProtocol).self,
allocation: .static
) { _ in
authSessionServiceFactory.makeAuthSessionService()
}
// Register with dependency resolution
container.register(
type: (any NetworkClientProtocol).self,
allocation: .static
) { container in
let authSessionService = container.resolve() as any AuthSessionServiceProtocol
return networkClient.makeOMGAPIClient(
authTokenProvider: {
await authSessionService.accessToken
}
)
}
// Register feature factory
container.register(
type: StatusAppFactory.self,
allocation: .static
) { container in
StatusAppFactory(
sessionService: container.resolve(),
authSessionService: container.resolve(),
networkClient: container.resolve()
)
}
}
}
Consequences:
Positive
- Simple API: Easy to understand registration and resolution
- Type safety: Type-based resolution with compiler support
- Lightweight: Minimal overhead and complexity
- Full control: Can evolve alongside the application's needs
- Factory-friendly: Natural fit for factory-based architecture
- Allocation control: Explicit
.staticfor singletons - Testability: Easy to register mock implementations for testing
Negative
- Maintenance responsibility: I maintain the library
- Limited features: No advanced DI features (property injection, circular dependency detection, etc.)
- Manual registration: Need to explicitly register all dependencies
- No automatic cleanup: Need to manage lifecycles manually
Neutral
- Learning curve: Need to understand container patterns
- Feature scope: Only implements features needed by this application
- Dependency management: External dependency, but under my control
Integration with Architecture:
- TritonEnvironment: Root environment in main app with
DependencyContainer - Registration pattern: Init methods register all dependencies with allocation strategy
- Resolution pattern: Computed properties use
container.resolve()for lazy access - Factory dependencies: Factories receive resolved dependencies via initializers
- Testing: Test environments can register mock implementations
Related Decisions:
- ADR-001: Modular Architecture with Swift Package Manager - Container wires together modular packages
- ADR-003: Feature-Based Package Organization - Each feature's AppFactory resolved from container
- ADR-010: AppFactory Pattern for Feature Integration - Factories use container for dependency resolution
Notes:
Building MicroContainer gave me a dependency injection solution that fits perfectly with the factory-based architecture. The DependencyContainer provides just enough DI functionality with explicit allocation control and type-safe resolution.