From Design Patterns to Universal Abstractions using Combine

Peña Fernando
5 min readFeb 16, 2023

--

Waterfall of events flowing on composition layers — Photo by Martin Sanchez en Unsplash

Hi again! This is the second section of this post, in the previous article I explained how to apply design patterns to add functionalities while avoiding component coupling. In this one I will explain how to replace Design Patterns with Universal Abstractions using Combine framework.

Why we would like to change our current implementation? Mmm… there is nothing wrong with Design patterns, they provide a general and reusable solution for common software design problems.

Actually we can leave it as it is, but I would like to show another solution that doesn’t require us to code, test and maintain those Adapters, Decorators or Repositories. We can replace all of them by using built-in Combine operators that provide the same behavior.

From UsersRepository protocol to Combine Publishers

Lets dive in, first we will replace extend our UsersRepository with a Combine Publisher that emits the same data.

extension UsersRepository {
func fetchPublisher() -> AnyPublisher<[User], Error> {
Deferred {
Future(self.fetchUsers)
}.eraseToAnyPublisher()
}
}

This method returns a Publisher that emits the same type of the UsersRepository, an array of Users or an Error.

  1. Future: We’re using Combine’s Future publisher to bridge between the callback world and the reactive one.
  2. Deferred: We wrapped the Future inside a Deferred so that the publisher starts emitting values once someone subscribes to it and not on creation.
  3. eraseToAnyPublisher: Type erasure so we don’t leak implementation details, publishers signatures can become huge and imposible to follow if we don’t erase them and use AnyPublisher instead.

We can now update our UsersViewModel to use the Publisher instead of the repository

class UsersViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading: Bool = false

private var cancellable: AnyCancellable?
private let usersPublisher: () -> AnyPublisher<[User], Error>

init(usersPublisher: @escaping () -> AnyPublisher<[User], Error>) {
self.usersPublisher = usersPublisher
}

func fetchUsers() {
isLoading = true
cancellable = usersPublisher()
.sink(receiveCompletion: { [weak self] _ in
self?.isLoading = false
}, receiveValue: { [weak self] users in
self?.users = users
})
}
}

We did change our presentation layer now, we can use an adapter here to keep our presentation layer untouched, but I also wanted to show how you can subscribe to a Publisher and handle the events.

  1. Create a cancellable var that will keep the subscription to the publisher
  2. Subscribe to the publisher events using sink method.

Finally we can update our composition

func makeUsersView() -> UsersView {
let viewModel = UsersViewModel(usersPublisher: makeUsersPublisher)
return UsersView(viewModel: viewModel)
}

func makeUsersPublisher() -> AnyPublisher<[User], Error> {
RandomUserAPIClient()
.fetchPublisher()
.eraseToAnyPublisher()
}

We are just loading the data from the ApiClient and displaying it, no authentication, logging or threading currently.

Dispatch on main queue

If we now run our App we get the following warnings

We removed the threading logic from the ViewModel, so now it’s updating the UI from a background thread and this could cause a crash at runtime, lets fix it:

func makeUsersPublisher() -> AnyPublisher<[User], Error> {
RandomUserAPIClient()
.fetchPublisher()
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}

If you look carefully we just added a new line in the composition: .receive(on: DispatchQueue.main) that’s all we need, no Decorator is needed now, it can be replaced by Combine’s receive(on:) built in operator.

Logging

Now we want to replace our previous LoggerDecorator with a Combine operator. There is a print(to:) that is really helpful while developing, but it doesn’t fulfill our requirement to print only the errors and have the ability to switch logging engine easily (Ex: use Crashlytics to log them). So we create our own custom operator that listen the errors and logs them.

extension Publisher {
func logErrors(to logger: LoggerProtocol) -> AnyPublisher<Output, Failure> {
self.catch { error -> Fail in
logger.log("ERROR: \(error)")
return Fail(error: error)
}.eraseToAnyPublisher()
}
}

Then we add it to our composition and that’s it, no LoggerDecorator needed and we keep the ability to easily replace our logging infrastructure.

func makeUsersPublisher() -> AnyPublisher<[User], Error> {
RandomUserAPIClient()
.fetchPublisher()
.logErrors(to: LocalLogger())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}

Authentication

Now we are going the add the authentication requirement before consuming the data from the service. For that we are going to bridge Swift concurrency (async/await) world to Combine publishers. There is a helper we can use that is recommended here and allows us to create a Publisher from an async/await method.

extension AuthenticatorProtocol {
func authenticatePublisher() -> AnyPublisher<Void, Error> {
Deferred {
Future {
try await self.authenticate()
}
}.eraseToAnyPublisher()
}
}

We can now update our composition and put back our authentication requirement without decorators.

func makeUsersPublisher() -> AnyPublisher<[User], Error> {
LABiometryAuthenticator()
.authenticatePublisher()
.flatMap { RandomUserAPIClient().fetchPublisher() }
.logErrors(to: LocalLogger())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}

This composition code is easy to follow, the events are chained. First you authenticate, then you call the service, log the error if any occurred and send the result on the main thread, easy-peasy.

Bonus

I mentioned earlier that we could remain our Presentation layer untouched if we wanted to by bridging Combine’s publishers to UsersRepository callback world using an Adapter. Let’s do that now:

class UsersRepositoryAdapter: UsersRepository {
private let usersPublisher: () -> AnyPublisher<[User], Error>
private var cancellable: AnyCancellable?

init(usersPublisher: @escaping () -> AnyPublisher<[User], Error>) {
self.usersPublisher = usersPublisher
}

func fetchUsers(completion completionHandler: @escaping Completion) {
cancellable = usersPublisher().sink(receiveCompletion: { completion in
switch completion {
case .failure(let error):
completionHandler(.failure(error))
case .finished:
break
}
}, receiveValue: { value in
completionHandler(.success(value))
})
}
}

With this Adapter we can now revert our ViewModel to the previous state.

We are now adapting the APIClient from async/await interface to a callback interface, then to a Publisher and back to a callback. We can simplify the first part of the flow by going directly from async/await to a Publisher.

extension RandomUserAPIClient {
func fetchPublisher() -> AnyPublisher<[User], Error> {
Deferred {
Future {
try await self.fetchRandomUsers()
}.map{ $0.map(User.init(dto:)) }
}.eraseToAnyPublisher()
}
}

We replaced our previous RandomUserAPIClientAdapter implementation with this extension. Now the only class that references the UsersRepository is the UsersViewModel (and the UsersRepositoryAdapter we are using to bridge it to Combine’s world).

We can even remove the UsersRepository now and change it to a closure that can be injected to the ViewModel and fetches the users.

The final project is here.

Conclusion

We’ve replaced the Decorators we’ve created previously with Universal Abstractions using Combine operators. This allows us to keep our components and layers decoupled and compose our system without the necessity to create, maintain and test our Design Pattern components.

We just need to bridge our components to the Combine world and then compose everything together on our composition chain.

--

--

Peña Fernando

iOS Developer with 10 years of experience that never stops training. I’m passionate for well structured and reusable code.