FI
ForgeInject

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.

Concrete (not recommended)
container.register(with: .singleton) { _ in
    NetworkService()
}

// Consume — with @Injectable
@Injectable
final class ViewModel {
    let networkService: NetworkService
}
Protocol (recommended)
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.

This will crash at runtime
// 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.

AppDependencies.swift
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 SomeProtocol in the builder closure.
  • Set ForgeContainer.shared once at app launch, before any @Injectable type is constructed.
  • Use .singleton for services, .transient for view models — view models should not share state across screens.
  • Don't register @MainActor classes without mainActorBuilder — otherwise you'll race the main actor during construction.
  • Builder closures are @Sendable — don't capture mutable state. Use Mutex or OSAllocatedUnfairLock if you need mutable state inside a builder.