Building Dynamic Data Sources with Swift Combine: The Lazy Publisher Pattern

Note: As a non-native speaker, I’m using AI for grammar and spelling checks, but the source code and the “unpolished” version of this post were generated by my own mushy human brain. It’s a bit sad that a disclaimer like this even needs to exist.

Introduction

In iOS and macOS development, we frequently deal with resource-intensive data sources. It’s best to observe them only when necessary, but managing their lifecycle and state is often a challenge. Additionally, they are notoriously difficult to mock for tests and UI previews. This post explores a Lazy/Dynamic Publisher pattern and provides a reusable base class to simplify creating your own dynamic publishers.

The Core Concept

The Lazy/Dynamic Publisher pattern reacts to subscriber availability by automatically starting and stopping its internal processes. It handles both subscriber reference counting and the management of internal data source lifecycles.

Why This Pattern?

  • Resource Efficiency: Internal data mechanisms remain idle when not in use. All subscribers share the same data source and receive identical events.
  • Cleaner ViewModels: Models are only responsible for subscribing and unsubscribing, keeping the business logic decoupled.
  • Ease of Mocking, Testing, and Previews: By wrapping Dynamic Publishers as AnyPublisher, you can easily swap them for mock data during testing or provide dynamic providers for SwiftUI previews.

Code

Note: This pattern is specifically useful for data streams, which is my primary goal; I’m intentionally ignoring the Demand mechanism of Swift Combine Publishers. Additionally, since I plan to use these publishers as the foundation for my UI data providers, I have chosen to make them @MainActor isolated.

@MainActor

open class DynamicPublisher<Output, Failure: Error>: NSObject, @MainActor Publisher {

    private let logTag = "DynamicPublisher<\(Output.self), \(Failure.self)>"

    public typealias Output = Output

    public typealias Failure = Failure

    private var subscriptions: [UUID: DynamicSubscription] = [:]

    public var subscriptionCount: Int { subscriptions.count }

    private(set) var isInitialized: Bool = false

    open func start() {}

    open func stop() {}

    public func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {

        let subscription = DynamicSubscription(publisher: self, subscriber: subscriber)

        addSubscription(subscription: subscription)

    }

    public func send(_ value: Output) {

        guard subscriptionCount > 0 else {

            return

        }

        subscriptions.forEach {(id, subscription) in

            _ = subscription.subscriber.receive(value)

        }

    }

    public func fail(with error: Failure) {

        Swift.print(logTag, "Failed with:", error)

        completion(with: .failure(error))

    }

    public func finish() {

        Swift.print(logTag, "Finish")

        completion(with: .finished)

    }

    public func completion(with completion: Subscribers.Completion<Failure>) {

        guard subscriptionCount > 0 else {

            return

        }

        subscriptions.forEach {(id, subscription) in

            subscription.subscriber.receive(completion: completion)

        }

        subscriptions.removeAll()

        invokeStop()

    }

    private func invokeStop() {

        guard isInitialized else { return }

        isInitialized = false

        Swift.print(logTag, "call stop")

        stop()

    }

    private func invokeStart() {

        guard !isInitialized else { return }

        isInitialized = true

        Swift.print(logTag, "call start")

        start()

    }

    public func clear() {

        Swift.print(logTag, "Clear")

        guard subscriptions.count > 0 else {

            return

        }

        subscriptions.forEach {(id, subscription) in

            cancelSubscription(subscription: subscription)

        }

    }

    fileprivate func addSubscription(subscription: DynamicSubscription) {

        Swift.print(logTag, "addSubscription")

        guard subscriptions[subscription.id] == nil else {

            return

        }

        subscriptions[subscription.id] = subscription

        if !isInitialized {

            invokeStart()

        }

        subscription.subscriber.receive(subscription: subscription)

    }

    fileprivate func cancelSubscription(subscription: DynamicSubscription) {

        Swift.print(logTag, "cancelSubscription")

        guard subscriptions[subscription.id] != nil else {

            return

        }

        subscriptions.removeValue(forKey: subscription.id)

        subscription.subscriber.receive(completion: .finished)

        if subscriptions.count == 0 {

            invokeStop()

        }

    }

    @MainActor

    fileprivate class DynamicSubscription: @MainActor Subscription, Identifiable {

        let id: UUID = .init()

        let publisher: DynamicPublisher<Output, Failure>

        let subscriber: any Subscriber<Output, Failure>

        init(publisher: DynamicPublisher<Output, Failure>, subscriber: any Subscriber<Output, Failure>) {

            self.publisher = publisher

            self.subscriber = subscriber

        }

        func request(_ demand: Subscribers.Demand) {}

        func cancel() {

            publisher.cancelSubscription(subscription: self)

        }

    }

}

Practical Example

Let’s see how this approach simplifies a common task: streaming location data.

Note: In this example, the publisher is not responsible for requesting location permissions; however, it will monitor them, react to changes, and fail “gracefully” if permission is not granted.

Publisher:

enum LocationDataProviderError: Error {

    case authorizedDenied

    case locationManagerError(error: any Error)

}

  

class LocationDataProvider: DynamicPublisher<CLLocation, LocationDataProviderError>, CLLocationManagerDelegate {

    static let shared: LocationDataProvider = .init()

    private let locationManager = CLLocationManager()

    override init() {

        super.init()

        locationManager.delegate = self

    }

    override func start() {

        if checkAuthorization() {

            locationManager.startUpdatingLocation()

        } else {

            fail(with: LocationDataProviderError.authorizedDenied)

        }

    }

    override func stop() {

        locationManager.stopUpdatingLocation()

    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {

        if let location = locations.first {

            send(location)

        }

    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {

        fail(with: LocationDataProviderError.locationManagerError(error: error))

    }

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {

        checkAuthorization()

    }

    @discardableResult

    private func checkAuthorization() -> Bool {

        return [.authorizedAlways, .authorizedWhenInUse].contains(locationManager.authorizationStatus)

    }

}

Usage:


// ... Model

init(locationDataProvider: AnyPublisher<CLLocation, LocationDataProviderError>) {

        self.locationDataProvider = locationDataProvider

        super.init()

        initialize()

    }

subscription = locationDataProvider.sink(

            receiveCompletion: {completion in

                debugPrint(self.logTag, "receiveCompletion: \(completion)")

                if case .failure(let error) = completion {

                    self.error = error

                    self.state = .failed

                } else {

                    self.state = .notSubscribed

                }

            },

            receiveValue: { location in

                self.location = location

            })

Mock:

class LocationDataProviderMock: DynamicPublisher<CLLocation, LocationDataProviderError> {

    static let shared = LocationDataProviderMock()

    private var task: Task<Void, Never>?

    override func start() {

        task = Task {

            while true {

                try? await Task.sleep(for: .seconds(1))

                if Task.isCancelled { return }

                send(CLLocation(latitude: CLLocationDegrees.random(in: -90...90), longitude: CLLocationDegrees.random(in: -180...180)))

            }

        }

    }

    override func stop() {

        task?.cancel()

    }

}

Preview:

#Preview {

    NavigationStack {

        LocationDataProviderTestView(

            model: LocationDataProviderTestViewModel(locationDataProvider: LocationDataProviderMock.shared.eraseToAnyPublisher())

        )

    }

}

Conclusion

Joining Swift Combine with the Dynamic/Lazy Pattern provides a clean decoupled abstraction layer between data sources and the UI. It significantly improves testability and previewability, and helps build more battery-efficient and resource-efficient solutions.

P.S. The full source code is available here: https://github.com/AndreiMaksimovich/SwiftDeveloperBlog/