swift-dependencies

repository·main·Indexed 24 days ago

https://github.com/pointfreeco/swift-dependencies

A dependency management library for Swift inspired by SwiftUI's environment. It provides tools to register, propagate, and override dependencies—such as clocks, dates, and network clients—to improve code testability and predictability. The library features the @Dependency property wrapper, support for Live, Test, and Preview implementations via DependencyKey, and the @DependencyClient macro for struct-based dependency design.

Tokens
13.9K
Snippets
43
Records
65
Agent score
80%

What's inside swift-dependencies

  1. What is a single entry point system?

    main

    A system is considered a "single entry point" system if there is exactly one place to invoke all of its logic and behavior. This design pattern makes it easy to alter the execution context (the environment) in which the system runs.

    Common examples include:

    • SwiftUI Views: Logic is triggered by the single body property.
    • The Composable Architecture (TCA): Logic is triggered by the single reduce method.
    • Server Frameworks: Logic is triggered by a single request-to-response lifecycle.

    In contrast, systems like ObservableObject or UIKit are generally considered non-single entry point systems because they lack a single, unified invocation point for all behavior.

  2. Use DependencyKey properties for different environments

    main

    When implementing DependencyKey, you provide specific implementations for the following properties to ensure your app behaves correctly across different contexts:

    • liveValue: The production implementation of the dependency.
    • testValue: The implementation used during unit testing (often a mock or a stub).
    • previewValue: The implementation used within SwiftUI Previews to allow for rapid UI development without side effects.
  3. Design dependencies using structs with closures

    main

    A more powerful pattern involves using a struct where each property is a closure representing a dependency endpoint. This approach allows for greater flexibility, such as overriding only specific endpoints in tests or injecting only the specific functions a feature requires using the @Dependency property wrapper.

    struct AudioPlayerClient {
      var loop: (_ url: URL) async throws -> Void
      var play: (_ url: URL) async throws -> Void
      var setVolume: (_ volume: Float) async -> Void
      var stop: () async -> Void
    }
    
    extension AudioPlayerClient: DependencyKey {
      static var liveValue: Self { /* ... */ }
      static let previewValue = Self(/* ... */)
      static let testValue = Self(
        loop: unimplemented("AudioPlayerClient.loop"),
        play: unimplemented("AudioPlayerClient.play"),
        setVolume: unimplemented("AudioPlayerClient.setVolume"),
        stop: unimplemented("AudioPlayerClient.stop")
      )
    }
    
    extension DependencyValues {
      var audioPlayer: AudioPlayerClient {
        get { self[AudioPlayerClient.self] }
        set { self[AudioPlayerClient.self] = newValue }
      }
    }
  4. How @Dependency lifetimes and inheritance work

    main

    The @Dependency property wrapper captures the state of a dependency at the moment it is initialized. This mechanism is powered by Swift's @TaskLocal system.

    Key Behaviors:

    • Scoping: Dependencies can be overridden for a specific scope using withDependencies.
    • Inheritance: Dependencies are automatically inherited by new tasks created via Task { }, TaskGroup, or async let.
    • Escaping Boundaries: Dependencies are not automatically inherited across all escaping boundaries. For example, if you use DispatchQueue.main.asyncAfter, the dependency will reset to its original value inside that closure because it is not a structured concurrency context.

    Extending Lifetimes:

    To ensure a model uses the dependencies captured at the moment of its creation (even if its methods are called later outside the original scope), initialize the model within a withDependencies block.

    // Creating a model in a controlled environment
    let onboardingModel = withDependencies {
      $0.apiClient = .mock
    } operation: {
      FeatureModel()
    }
    // Even if FeatureModel.onAppear() is called later, it will use the .mock apiClient.
  5. Propagate overridden dependencies to child models

    main

    When a model creates a child model, the child will default to the application's global DependencyKey/liveValue unless you explicitly propagate the parent's dependencies.

    To ensure overridden dependencies (like mocks) flow down to child and grandchild models, you must wrap the child model's construction in withDependencies(from: self). Even if you aren't overriding any specific values, using from: self ensures the child inherits the exact dependency environment currently used by the parent.

    func tappedTodo(_ todo: Todo) {
      editTodo = withDependencies(from: self) {
        EditTodoModel(todo: todo)
      }
    }
  6. Understand dependency cascading rules

    main

    The library uses a cascading system to decide which dependency value is used at runtime based on the current context (Live, Preview, or Test). The hierarchy is determined by which values you implement in your DependencyKey and TestDependencyKey conformances:

    1. Test Context: By default, testValue calls previewValue. If previewValue is not implemented, it calls liveValue.
    2. Preview Context: By default, previewValue calls liveValue.
    3. Live Context: Uses liveValue.

    Warning: If you only implement liveValue (without providing previewValue or testValue), your tests and previews will use the live dependency, which may interact with the outside world. The library will intentionally fail a test if it detects a live dependency being used in a test context to prevent this accidental behavior.

  7. Design dependencies using protocols

    main

    The traditional way to design dependencies in Swift is to use a protocol to define the interface. You then create multiple conformances: a Live version for production, a Mock version for previews, and an Unimplemented version for tests that calls reportIssue when methods are invoked. These conformances are registered via a DependencyKey.

    protocol AudioPlayer {
      func loop(url: URL) async throws
      func play(url: URL) async throws
      func setVolume(_ volume: Float) async
      func stop() async
    }
    
    private enum AudioPlayerKey: DependencyKey {
      static let liveValue: any AudioPlayer = LiveAudioPlayer()
      static let previewValue: any AudioPlayer = MockAudioPlayer()
      static let testValue: any AudioPlayer = UnimplementedAudioPlayer()
    }
  8. Prevent test case leakage in Swift's native Testing framework

    main

    Because Swift's native Testing framework runs tests in parallel and in-process, stateful dependencies can cause 'leakage' where one test's changes affect another.

    To prevent this, create a 'base suite' that provides a fresh set of dependencies to every nested test or suite. You do this by defining a @Suite with the .dependencies trait and nesting all other tests inside it.

    1. Define a base suite: @Suite(.dependencies) struct BaseSuite {}
    2. Nest your actual test suites inside an extension of BaseSuite.
    @Suite(.dependencies) struct BaseSuite {}
    
    extension BaseSuite {
      @Suite struct FeatureTests {
        @Test func basics() {
          // ...  
        }
      }
    }
  9. Understand the three dependency implementations: Live, Test, and Preview

    main

    The swift-dependencies library allows you to provide different implementations of a dependency depending on the environment (device, test, or Xcode preview). This is achieved by conforming your dependency to DependencyKey and TestDependencyKey.

    • Live Value (DependencyKey/liveValue): The default implementation used when running on a device or simulator. It is intended for real-world interactions like network requests or file system access.
    • Test Value (TestDependencyKey/testValue): Used during unit tests. It should avoid real-world side effects. A best practice is to use an "unimplemented" version that triggers a test failure if accessed, ensuring you don't accidentally use live dependencies in tests.
    • Preview Value (TestDependencyKey/previewValue): Used in Xcode previews. It sits between live and test values; it avoids real-world side effects (like network calls) but typically returns mock data so the UI can be visualized immediately.

    If testValue or previewValue are not implemented, they will delegate to liveValue by default.

  10. Use Dependency key paths for improved ergonomics

    main

    You can register dependencies using key paths by extending DependencyValues with a property that wraps a DependencyKey. This provides several benefits:

    1. Autocomplete: Dependencies are discoverable via DependencyValues properties.
    2. Cleaner Syntax: You can use @Dependency(\.propertyName) instead of @Dependency(Type.self).
    3. Scoping: You can scope a @Dependency to a specific sub-property of a dependency.

    Example of registering a key path:

    extension DependencyValues {
      var apiClient: APIClient {
        get { self[APIClientKey.self] }
        set { self[APIClientKey.self] = newValue }
      }
    }
    // Using the key path in a model
    @Dependency(\.apiClient) var apiClient
    
    // Scoping to a sub-property
    @Dependency(\.apiClient.currentUser) var currentUser
    
    // Overriding in tests using the key path
    let model = withDependencies {
      $0.apiClient.fetchTodos = { _ in Todo(id: 1, title: "Get milk") }
    } operation: {
      TodosModel()
    }
  11. What are dependencies and why control them?

    main

    In an application, dependencies are types or functions that interact with outside systems you do not control. Examples include API clients, UUID or Date initializers, and clocks/timers.

    By controlling these dependencies, you can alter the execution context of a feature. This allows you to provide mock versions (e.g., a stubbed API client or an immediate clock) in Xcode previews and unit tests, ensuring they are fast, deterministic, and do not rely on real-world time or network availability.