Factory

repository·main·Indexed 25 days ago

https://github.com/hmlongco/factory

A modern, container-based dependency injection framework for Swift and SwiftUI. Factory is designed to be lightweight and compile-time safe, supporting architectural patterns like MVVM, VIPER, and Clean Architecture. It provides features such as the @Injected property wrapper, object scopes (singleton, cached, shared), and support for Swift concurrency, including @MainActor and @globalActor isolation. The framework includes FactoryKit for core functionality and FactoryTesting for integration with Swift Testing and XCTest.

Tokens
24.4K
Snippets
98
Records
118
Agent score
83%

What's inside Factory

  1. Overview of Factory Dependency Injection

    main
    Factory is a modern, container-based dependency injection framework for Swift and SwiftUI. It is designed to be adaptable, performant, and compile-time safe. Key features include support for containers, scopes, passed parameters, contexts, decorators, and SwiftUI Previews, all while maintaining a lightweight footprint (under 800 lines of executable code).
  2. Define a Custom Container Trait

    main

    If you use a custom container that conforms to SharedContainer, you can create a custom trait for it.

    1. Attach @TaskLocal to the shared instance of your custom container.
    2. Extend Trait to define your custom trait using the container's $shared property.
    3. Use the custom trait in your @Test or @Suite macros.
    // 1. Setup custom container
    public final class CustomContainer: SharedContainer {
      @TaskLocal public static var shared = CustomContainer()
      public let manager = ContainerManager()
    }
    
    // 2. Define the trait
    extension Trait where Self == ContainerTrait<CustomContainer> {
        static var customContainer: ContainerTrait<CustomContainer> {
            .init(shared: CustomContainer.$shared, container: .init())
        }
    }
    
    // 3. Use it
    @Test(.customContainer)
    func testA() async {
      // ...
    }
  3. Register and resolve @globalActor-isolated types

    main

    A custom @globalActor behaves like the @MainActor. Because a @globalActor-isolated class has an isolated initializer, you must annotate the factory property with that specific global actor to allow the registration closure to run on the correct executor.

    Use a plain actor when you want an isolated instance you interact with via await. Use a @globalActor when you want a type isolated to a single shared executor.

    @globalActor
    actor BackgroundActor {
        static let shared = BackgroundActor()
    }
    
    @BackgroundActor
    final class DataManager {
        init() { /* isolated to @BackgroundActor */ }
        func fetch() -> Data { ... }
    }
    
    extension Container {
        @BackgroundActor
        var dataManager: Factory<DataManager> {
            self { DataManager() }
        }
    }
  4. Configure Scopes and Decorators

    main

    Scopes in Factory 2.0 are applied using modifier syntax on the factory definition. Common modifiers include .singleton and .shared. You can also use .decorator to add logic when a factory is resolved.

    extension Container {
        var singletonService: Factory<ServiceType> {
            self { MyService() }.singleton
        }
        var decoratedSharedService: Factory<MyServiceType> {
            self { MyService() }
                .shared
                .decorator { print("DECORATING \($0.id)") }
        }
    }
  5. Register and resolve plain actors

    main

    Ordinary actors are the simplest case for Factory. Because an actor's initializer is nonisolated, it can be built synchronously from any context. You do not need any special annotations on the factory registration or special resolution methods. The isolation only applies when accessing the actor's state, which the compiler handles via await.

    actor OrdinaryActor {
        private var count = 0
        func increment() -> Int {
            count += 1
            return count
        }
    }
    
    extension Container {
        var ordinaryActor: Factory<OrdinaryActor> {
            self { OrdinaryActor() }
        }
    }
    
    // Resolution
    let actor = Container.shared.ordinaryActor()
    let count = await actor.increment()
  6. Re-export FactoryKit via a Services module

    main

    To avoid importing FactoryKit in every single module, you can create a dedicated Services module that acts as an intermediary.

    In Swift 5.9+, use public import FactoryKit in an umbrella file within your Services module. This re-exports all Factory symbols (like Factory, Container, and @Injected) to any module that imports Services.

    For Swift 5.8 and earlier, use the @_exported import FactoryKit attribute.

    // Services/DependencyContainer.swift
    public import FactoryKit
    
    extension Container {
        public var accountLoader: Factory<AccountLoading?> { promised() }
    }
  7. Reset Container registrations and scopes

    main

    You can reset factories and caches on a specific container. Note that resetting a container only affects that specific instance.

    • reset(): Resets everything in that container.
    • reset(options: .registration): Restores original factories but leaves caches intact.
    • reset(options: .scope): Resets all scope caches but leaves registrations intact.
    • reset(scope: .cached): Resets a specific scope cache.
    // Reset everything based in that container.
    Container.shared.manager.reset()
    
    // Reset all registrations, restoring original factories but leaving caches intact
    Container.shared.manager.reset(options: .registration)
    
    // Reset all scope caches, leaving registrations intact
    Container.shared.manager.reset(options: .scope)
    
    // Reset a specific scope cache while leaving the others intact
    Container.shared.manager.reset(scope: .cached)
  8. Resolve a Factory

    main

    You can obtain an instance of a dependency (resolve it) using several methods:

    1. Direct Call: Call the factory as a function on a container instance (e.g., Container.shared.service()).
    2. @Injected Property Wrapper: Use the @Injected property wrapper with a KeyPath to the dependency. By default, this looks in Container.shared.
    3. Custom Container Injection: Specify a custom container in the @Injected wrapper.

    Important for iOS 17+: When using the @Observable macro, you must use @ObservationIgnored on @Injected properties to avoid property wrapper collisions with the @Observable backing store.

    // Method 1: Direct call on shared container
    class ContentViewModel: ObservableObject {
        private let myService = Container.shared.service()
    }
    
    // Method 2: Using @Injected (defaults to Container.shared)
    class ContentViewModel: ObservableObject {
        @Injected(\.myService) private var myService
    }
    
    // Method 2b: Using @Injected with a custom container
    @Injected(\MyCustomContainer.service) var service: ServiceType
    
    // Method 3: Using @Injected with @Observable (iOS 17+)
    @Observable class ContentViewModel {
        @ObservationIgnored
        @Injected(\.myService) private var myService
    }
  9. Register and resolve @MainActor-isolated types

    main

    When a type is isolated to the @MainActor (like many ViewModels), its initializer is also @MainActor-isolated. To register this in Factory, you must annotate the factory property with @MainActor. This ensures the factory closure runs on the main actor.

    Most users resolve these from other @MainActor contexts (like SwiftUI views or @Injected properties in @MainActor classes), which works seamlessly. However, resolving from a non-isolated context (like a detached task) requires special handling to avoid runtime traps.

    @MainActor
    class ContentViewModel {
        init() { /* isolated to @MainActor */ }
        func load() async { ... }
    }
    
    extension Container {
        @MainActor
        var contentViewModel: Factory<ContentViewModel> {
            self { ContentViewModel() }
        }
    }