A lightweight, type-safe dependency injection container for Swift. Supports keep-alive (singleton), transient (fresh instance), and flow-scoped lifetimes with automatic dependency graph resolution and circular dependency detection.
Version: 0.1.2 Last updated (UTC): 2026-06-08T13:57:54Z
Release metadata lives in VERSION, LAST_UPDATED, and
CoolDependencyManagerMetadata.swift. Enable automatic patch-version and timestamp updates for
commits made on main:
./scripts/install-git-hooks.shAdd to your Package.swift:
dependencies: [
.package(url: "https://github.com/darkbringer1/CoolDependencyManager.git", branch: "main")
]Then add "CoolDependencyManager" to your target's dependencies:
.target(
name: "MyApp",
dependencies: [
.product(name: "CoolDependencyManager", package: "CoolDependencyManager"),
]
)Or in Xcode: File → Add Package Dependencies → paste the repository URL.
| Lifetime | Behavior | Use Case |
|---|---|---|
.keepAlive |
Created once, cached forever (singleton) | Managers, services, shared state |
.transient |
New instance on every get() call |
Stateful objects, request-scoped work |
.flow |
Cached within a DependencyScope, released when scope ends |
Multi-step flows (e.g., registration, checkout) |
DependencyContainer— The central registry. Stores factories, resolves dependencies, manages caches.DependencyKey<T>— Identifies a dependency by type and lifetime.DependencyFactory— Protocol for creating dependency instances. Concrete types:SimpleDependencyFactory,TypedDependencyFactory,TypedDependencyFactory2.DependencyScope— A named cache boundary for flow-scoped dependencies.DependencyGraph— Internal topological sort and cycle detection engine.
protocol NetworkServiceProtocol {
func fetch(url: URL) async throws -> Data
}
class NetworkService: NetworkServiceProtocol {
func fetch(url: URL) async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}
protocol AuthManagerProtocol {
var isLoggedIn: Bool { get }
}
class AuthManager: AuthManagerProtocol {
private let networkService: NetworkServiceProtocol
var isLoggedIn: Bool = false
init(networkService: NetworkServiceProtocol) {
self.networkService = networkService
}
}Extend DependencyContainer with computed properties that return DependencyKey values. This gives you type-safe KeyPath-based injection.
import CoolDependencyManager
extension DependencyContainer {
var networkService: DependencyKey<NetworkServiceProtocol> {
DependencyKey<NetworkServiceProtocol>(keepAlive: true) // singleton
}
var authManager: DependencyKey<AuthManagerProtocol> {
DependencyKey<AuthManagerProtocol>(keepAlive: true) // singleton
}
var sessionToken: DependencyKey<String> {
DependencyKey<String>(name: "sessionToken", lifetime: .transient) // fresh each time
}
}Register factories that tell the container how to create each dependency.
func registerDependencies() {
let container = DependencyContainer.shared
// Simple factory — no dependencies needed
let networkFactory = SimpleDependencyFactory(
key: container.networkService
) { _ in
NetworkService()
}
try? container.registerFactory(networkFactory)
// Typed factory — declares dependency on NetworkServiceProtocol
let authFactory = TypedDependencyFactory(
key: container.authManager,
dependency1: container.networkService
) { networkService in
AuthManager(networkService: networkService)
}
try? container.registerFactory(authFactory)
// Transient factory — new UUID string each time
let tokenFactory = SimpleDependencyFactory(
key: container.sessionToken
) { _ in
UUID().uuidString
}
try? container.registerFactory(tokenFactory)
}After registering all factories, build the container. This creates all keep-alive dependencies in topological order (respecting inter-dependencies). Transient dependencies are not created during build — they're created on-demand.
func setupApp() {
registerDependencies()
try? DependencyContainer.shared.buildAll()
}class ProfileViewModel {
@Dependency(\.authManager) var authManager
@Dependency(\.networkService) var networkService
func loadProfile() {
guard authManager.isLoggedIn else { return }
// use networkService...
}
}The Observation macro turns tracked stored properties into computed properties. Exclude dependency
wrappers from observation so @Dependency remains a property wrapper:
import Observation
@MainActor
@Observable
final class ProfileViewModel {
@ObservationIgnored
@Dependency(\.authManager)
var authManager
}@ObservationIgnored is appropriate because changing the wrapper itself is not observable state;
if the resolved service is observable, SwiftUI tracks that service when its properties are read.
For initializer injection, use DependencyContainer.shared.resolve(\.authManager). Use
tryResolve(_:) when the caller should handle resolution errors instead of failing fast.
let container = DependencyContainer.shared
let auth = try container.get(container.authManager)
let token = try container.get(container.sessionToken) // fresh each callScoped dependencies are cached for the duration of a DependencyScope. When the scope is released, all cached instances within it are discarded. This is ideal for multi-step flows like registration or checkout.
extension DependencyContainer {
var registrationData: DependencyKey<RegistrationData> {
DependencyKey<RegistrationData>(lifetime: .flow)
}
}let container = DependencyContainer.shared
// Create a scope
let scope = container.makeScope()
// Resolve within scope — same instance returned for same scope
let data1 = try container.get(container.registrationData, scope: scope)
let data2 = try container.get(container.registrationData, scope: scope)
// data1 === data2 ✅
// Different scope → different instance
let otherScope = container.makeScope()
let data3 = try container.get(container.registrationData, scope: otherScope)
// data1 !== data3 ✅
// Release scope when flow ends
container.releaseScope(scope.id)Push/pop scopes onto a stack so .flow dependencies resolve automatically without passing a scope around:
let container = DependencyContainer.shared
let scope = container.makeScope()
container.pushScope(scope)
// .flow dependencies now resolve against the pushed scope
let data = try container.get(container.registrationData)
container.popScope(scope)try container.withScope { scope in
let data = try container.get(container.registrationData)
// scope is auto-pushed and popped
}class RegistrationStepViewModel {
@ScopedDependency(.shared, scope: { myScope }, \.registrationData)
var registrationData: RegistrationData
}Use @ContainerDependency when you have multiple container instances (e.g., production vs. test):
class MyViewController {
@ContainerDependency(DependencyContainer.shared, \.networkService)
var networkService: NetworkServiceProtocol
}Dependencies resolved only on first access:
class ExpensiveFeatureViewModel {
@LazyDependency(DependencyContainer.shared, \.heavyService)
var heavyService: HeavyServiceProtocol
}For factories that need 2 resolved dependencies:
let factory = TypedDependencyFactory2(
key: container.checkoutService,
dependency1: container.networkService,
dependency2: container.authManager
) { network, auth in
CheckoutService(network: network, auth: auth)
}
try container.registerFactory(factory)Replace an existing factory (useful for feature flags or runtime swaps):
let newFactory = SimpleDependencyFactory(key: container.networkService) { _ in
MockNetworkService()
}
try container.registerFactory(newFactory, override: true)Use registerMock(_:_:) to bypass factories and inject a pre-built instance directly:
@Suite(.serialized)
@MainActor
struct MyServiceTests {
init() {
DependencyContainer.shared.reset()
}
@Test func fetchProfile() throws {
let container = DependencyContainer.shared
let mockNetwork = MockNetworkService()
container.registerMock(container.networkService, mockNetwork)
let viewModel = ProfileViewModel()
// viewModel.networkService is now mockNetwork
}
}reset() clears all factories, cached instances, scopes, and the dependency graph:
DependencyContainer.shared.reset()All resolution errors are thrown as DependencyContainerError:
| Error | Cause |
|---|---|
.productNotFound(name:) |
No factory registered for the requested key |
.factoryAlreadyRegistered(name:) |
Duplicate registration without override: true |
.circularDependencyDetected(cycles:) |
A → B → A dependency cycle found during build |
.dependencyNotRegistered(name:requiredBy:) |
A factory's declared dependency hasn't been registered |
.dependencyTypeMismatch(expected:found:) |
Resolved type doesn't match the expected type in a typed factory |
The container is @MainActor-isolated. All public APIs must be called from the main thread. The container conforms to @unchecked Sendable for storage in contexts that require Sendable, but access is only safe from @MainActor.
- Swift 6.0+
- iOS 15+ / macOS 12+
MIT