avatarEmre Havan

Summary

The article discusses the correct use of PassthroughSubject in Combine for APIs, emphasizing the importance of exposing a publisher rather than the subject itself to prevent consumers from sending input.

Abstract

The article "Use PassthroughSubject the Right Way in Your APIs With Combine" addresses a common pitfall in API design when using Apple's Combine framework. It explains that while PassthroughSubject is a useful tool for notifying consumers about events, exposing the subject directly in public APIs can lead to unintended consequences, such as consumers being able to send values into the subject. The author illustrates this issue with a hypothetical workout tracker app where a notifier is designed to inform consumers about the number of exercises executed. The initial implementation uses a PassthroughSubject to broadcast updates, but this allows any consumer to also send values, potentially causing bugs. The solution proposed is to wrap the PassthroughSubject in an AnyPublisher, which restricts consumers to only observing updates without the ability to send values. This ensures that the notifier remains the sole source of truth. The article concludes by advocating for this design pattern to avoid common mistakes and maintain the integrity of the notifier's role in the API.

Opinions

  • Exposing a PassthroughSubject directly in public APIs is not recommended because it allows consumers to send input, which can

Use PassthroughSubject the Right Way in Your APIs With Combine

Photo by Daria Nepriakhina on Unsplash

There you are, trying to refactor the usage of that nasty notification center or implementing a new API where the consumers can observe certain events.

Even though everybody says the Combine is dead, you come across the PassthroughSubject and think, “Great! This looks like a nice way to notify my consumers”.

Well, yes, it is, but it is not a good idea to expose your subjects to your public APIs. In this article, we’ll dive into why and how to tackle this issue.

TLDR: The consumers can also send input from the exposed subject; that is why we need to expose a publisher instead of the subject directly.

Using the PassthroughSubject

Imagine we have a workout tracker app, and whenever a user executes an exercise, we would like to notify consumers about the current number of exercises the user executed in their session. Let's call this ExerciseExecutionCountNotifier and conform to ExerciseExecutionCountNotifying with it.

ExerciseExecutionCountNotifying will look like the following:

protocol ExerciseExecutionCountNotifying {
    var countSubject: PassthroughSubject<Int, Never> { get }
}

It exposes a subject for consumers to observe the count changes and uses Never for the failure case since we never want to emit any errors nor complete this subject for the lifecycle of this entity.

Now let’s implement the notifier and show how it can interact with this subject. (The full implementation of the actual notifier would include more code, of course, but let’s imagine whenever an exercise is executed, another method will trigger notifyConsumers(withCount: Int)

final class ExerciseExecutionCountNotifier: ExerciseExecutionCountNotifying {

    let countSubject = PassthroughSubject<Int, Never>()

    // Other parts of the notifier is omitted. We assume another method triggers
    // notifyConsumers method whenever an exercise is executed.
    private func notifyConsumers(withCount count: Int) {
        countSubject.send(count)
    }
}

So that’s it. It looks like we can easily send values to consumers with a subject. But before making the decision final, let’s see how it will be used by the consumers.

Now, imagine we show the number of exercises in this workout tracker app on the profile. So, we need to observe such changes from the profile view. Assuming we are using some sort of a view model including architecture, we can observe new values in the view model, as follows:

final class ProfileViewModel {

    private let exerciseExecutionCountNotifier: ExerciseExecutionCountNotifying
    private var executionCountCancellable: AnyCancellable?

    init(exerciseExecutionCountNotifier: ExerciseExecutionCountNotifying) {
        self.exerciseExecutionCountNotifier = exerciseExecutionCountNotifier
    }

    deinit {
        executionCountCancellable?.cancel()
    }

    private func observeExerciseExecutionCountChanges() {
        executionCountCancellable = exerciseExecutionCountNotifier.countSubject.sink { countValue in
            print(countValue)
            // update view with the countValue (out of scope for the article)
        }
    }
    
}

We can call the sink method on the injected count notifier’s subject, and assign it to an AnyCancellable to keep it alive. Then, it is very straightforward to get new values in the closure.

Great, let’s ship it then! Well, not so fast. Because we can do more than just calling sink on the new values with the subject. From the profile view model, we can also send values, 😱 like so:

exerciseExecutionCountNotifier.countSubject.send(10)

Now we have a problem. The notifier can’t be the source of truth anymore, and any consumer can also send values into its subject. That is not the intention of our API design, and it can cause potential bugs in the future as this notifier is used more and more.

The Solution

To make sure only the notifier can send values and others can only observe, we need to wrap our PassthroughSubject in a publisher so that the consumers can only listen for updates on this publisher and cannot send values anymore. Luckily, the solution won’t require too many changes. Let’s see how we can do it:

Now, the protocol will look like the following:

protocol ExerciseExecutionCountNotifying {
    var countPublisher: AnyPublisher<Int, Never> { get }
}

Now, we declare an AnyPublisher in our protocol, instead of PassthroughSubject (I have also renamed it as countPublisher)

The updated notifier implementation:

final class ExerciseExecutionCountNotifier: ExerciseExecutionCountNotifying {

    var countPublisher: AnyPublisher<Int, Never> {
        countSubject.eraseToAnyPublisher()
    }
    
    private let countSubject = PassthroughSubject<Int, Never>()

    private func notifyConsumers(withCount count: Int) {
        countSubject.send(count)
    }
}

Now, we set countSubject as private and implemented the protocol requirement countPublisher, where it returns the subject as any publisher by calling eraseToAnyPublisher(). We still send our values by calling send to our private subject.

In the consumer side, all we need to do is rename the countSubject to countPublisher, and it will continue to work just fine.

private func observeExerciseExecutionCountChanges() {
    executionCountCancellable = exerciseExecutionCountNotifier.countPublisher.sink { countValue in
        print(countValue)
        // update view with the countValue (out of scope for the article)
    }
}

That’s it, now, the consumers won’t be able to accidentally send values from the subject directly.

Final words

In this piece, we looked at how to design a notifier with Combine while avoiding an easy-to-make mistake by directly exposing a PassthroughSubject. We learned that wrapping our subjects inside an AnyPublisher makes sure, consumers cannot accidentally send values while observing the values our notifier emits.

I hope you found this article useful. Let me know what you think about it in the comments section. Also, do you use Combine for such notifier implementations? :)

Until next time 👋

Swift Programming
iOS Development
Combine Framework
Notification Center
Recommended from ReadMedium