Registration
Register types, configure retain policies, and handle resolution errors.
Concrete vs Protocol
You can register either a concrete type or cast the return value to a protocol. Always prefer protocols — they give you testability and hide implementation details from consumers.
container.register(with: .singleton) { _ in
NetworkService()
}
// Consume — with @Injectable
@Injectable
final class ViewModel {
let networkService: NetworkService
} container.register(with: .singleton) { _ in
NetworkService() as NetworkServiceProtocol
}
// Consume — the property's type drives resolution
@Injectable
final class ViewModel {
let networkService: NetworkServiceProtocol
} Why protocols
- Abstractions — consumers depend on interfaces, not implementations
- Testability — swap real implementations for mocks without changing call sites
- Module boundaries — modules expose protocols, not concrete types
Nested Dependencies
Use the container parameter in the builder closure to resolve other dependencies. Registration order does not matter — dependencies are resolved lazily at access time.
container.register(with: .singleton) { _ in
NetworkService() as NetworkServiceProtocol
}
container.register(with: .singleton) { container in
let networkService: NetworkServiceProtocol = try container.resolve()
return UserRepository(networkService: networkService) as UserRepositoryProtocol
} Type Matching
Important: The type you resolve must match the type you registered. If you register as a protocol, every consumer must resolve as that protocol.
// Registered as protocol
container.register(with: .singleton) { _ in
NetworkService() as NetworkServiceProtocol
}
// Will crash at runtime — registered type is NetworkServiceProtocol, not NetworkService
@Injectable
final class ViewModel {
let networkService: NetworkService
} @MainActor Registration
Some dependencies have @MainActor-isolated initializers (UIKit observers, view-related services). Use the mainActorBuilder variant to register them. The instance is created eagerly at registration time, then stored for future resolves.
// For @MainActor-isolated dependencies — instance is created eagerly at registration time.
// Requires T: Sendable since the instance crosses isolation boundaries at resolve time.
@MainActor
func setupContainer() {
container.register(with: .singleton, mainActorBuilder: { _ in
ProtectedDataObserver() as ProtectedDataObserving
})
} Resolve by Explicit Type
When type inference is insufficient (e.g., you have Any in hand), use resolveWithType(_:).
// Resolve by explicit type instead of type inference
let service: TaskService = try container.resolveWithType(TaskService.self) Resolve All Conforming Instances
Retrieve every cached instance that conforms to a given protocol. Only returns singletons and live weak references — unresolved factories and transient registrations are excluded.
// Returns all previously-resolved cached instances conforming to the given protocol.
// Only returns singletons and live weak references — unresolved factories and transient
// registrations are excluded.
let allServices = container.resolveAll(conforming: Serviceable.self)
for service in allServices {
service.teardown()
} Resetting the Container
Clear cached singleton and weak instances while preserving factory registrations. Ideal for logout flows where you want to tear down user-scoped state without re-registering everything.
// Clears cached singleton and weak instances but preserves factory registrations.
// On next resolve, factories run again to create fresh instances.
// Pass types to 'preserving' to keep specific instances alive across the reset.
func handleLogout() {
// Keep analytics alive, reset everything else
container.reset(preserving: [AnalyticsServiceProtocol.self])
} Error Handling
ForgeInject uses ForgeContainerError to communicate resolution failures. The macros crash with fatalError on unresolved types — this is intentional so bugs surface immediately during development rather than silently failing in production.
missingFactoryMethod
Thrown when you try to resolve a type that was never registered. This usually means you forgot to add it to your ForgeRegisterProtocol struct.
typeMismatch
Thrown when a factory exists for the key but its return value cannot be cast to the requested type. Happens when you register a concrete type but resolve as a protocol, or vice versa.
Catching Errors
When calling container.resolve() directly (outside the macros), both error cases can be pattern-matched:
// When calling resolve() directly, errors are thrown and can be caught.
do {
let service: TaskServiceProtocol = try container.resolve()
} catch let error as ForgeContainerError {
switch error {
case .missingFactoryMethod(let type):
print("No factory registered for \(type)")
case .typeMismatch(let type):
print("Factory exists but returned wrong type for \(type)")
}
} Tip: Inside macros, resolution failures become fatalError. Use direct try/catch only when calling container.resolve() yourself — for example, when implementing conditional resolution or retry logic.
Retain Policies
Control how long resolved instances live with three retain policies. Always specify the policy explicitly — the parameter-less register(builder:) convenience defaults to .transient.
.transient
A new instance is created every time the dependency is resolved. Use for stateful, short-lived objects like form validators or formatters.
// New instance every time the dependency is resolved
container.register(with: .transient) { _ in
FormValidator()
} .singleton
One shared instance is created on first resolve and retained for the container's lifetime. Use for app-wide services — networking, databases, analytics.
// Single shared instance for the app's lifetime
container.register(with: .singleton) { _ in
DatabaseService() as DatabaseServiceProtocol
} .weak
A shared instance is returned while at least one consumer holds a strong reference. Once all references are released, the next resolve creates a fresh instance. Use for caches or flow-scoped coordinators.
// Shared while at least one consumer holds a reference.
// Recreated after all references are released.
container.register(with: .weak) { _ in
ImageCache() as ImageCacheProtocol
} Quick Reference
| Scenario | Policy |
|---|---|
| App-wide service (networking, database, analytics) | .singleton |
| Stateless utility or per-use object (form validator, formatter) | .transient |
| Shared cache that can be freed under memory pressure | .weak |
| Flow coordinator shared across a navigation stack | .weak |
| Per-screen view model | .transient |
Mixing Policies
Different registrations can use different policies. Pick the right one for each dependency.
struct AppDependencies: ForgeRegisterProtocol {
func registerDependencies(in container: ForgeContainerProtocol) {
// Singleton — lives forever
container.register(with: .singleton) { _ in
NetworkService() as NetworkServiceProtocol
}
// Transient — new instance each time
container.register(with: .transient) { _ in
FormValidator() as FormValidatorProtocol
}
// Weak — shared while held
container.register(with: .weak) { _ in
ImageCache() as ImageCacheProtocol
}
}
} Best Practices
- Register as protocols — always cast with
as SomeProtocolin the builder closure. - Set
ForgeContainer.sharedonce at app launch, before any@Injectabletype is constructed. - Use
.singletonfor services,.transientfor view models — view models should not share state across screens. - Don't register
@MainActorclasses withoutmainActorBuilder— otherwise you'll race the main actor during construction. - Builder closures are
@Sendable— don't capture mutable state. UseMutexorOSAllocatedUnfairLockif you need mutable state inside a builder.