Combine Repository

Publishing Changes to all the observers

In the last article (UI vs API models) we talked briefly about the Repository layer.

Now it’s time to look closer into this layer, and how we can use Combine publishers to help drive the state of our views and our apps.

We will continue using the same Example app from the previous post.

diagram

Repository

public final class PokemonRepository: ObservableObject {
    public var pokemonsPublisher = CurrentValueSubject<LoadingState<[Pokemon]>, Never>(.idle)
    private let dependencies: Dependencies
    private var inMemoryPokemons: [Pokemon] = []

    public convenience init() {
        self.init(dependencies: .default)
    }

    init(dependencies: Dependencies) {
        self.dependencies = dependencies
    }
}

public extension PokemonRepository {
    @MainActor
    func loadAllPokemons() async {
        pokemonsPublisher.send(.loading)

        do {
            let apiPokemons = try await dependencies.getAllPokemons()
            let pokemons = apiPokemons.map(\.uiModel)
            inMemoryPokemons = pokemons
            pokemonsPublisher.send(.success(inMemoryPokemons))
        } catch {
            pokemonsPublisher.send(.failure(error))
        }
    }
}

From this sample code, there are a few things to notice:

  1. We use a CurrentValueSubject to publish values to all the observers.
  2. We use the Dependencies approach to talk to the backend (or inject mocks in previews/tests).
  3. We store the inMemoryPokemons in this class (we will use them later).
  4. We provide a public initializer that uses the production service.
  5. We provide an internal initializer to inject dependencies in the tests/previews (via @testable import).
  6. We provide a public method to load all the pokemons. This method publishes a loading state, then perform some work via its dependency and then either publish a success or an error value (and it stores the array of pokemons inMemory).

Usage

From our SwiftUI view:

  • We access the repository via EnvironmentObject:
@EnvironmentObject private var repository: PokemonRepository
  • We can just use the onReceive modifier to observe changes in the publisher:
.onReceive(repository.pokemonsPublisher) { newState in
    model.updateStates(with: newState)
}

Model

The model is a @State property in the view. It is the one that drives the UI:

extension PokemonListView {
    struct Model {
        var pokemons: [Pokemon] = []
        var isLoading: Bool = false
        var error: Error? = nil
        var isDisplayingDeletionErrorToast: Bool = false
    }
}

extension PokemonListView.Model {
    mutating func updateStates(with newState: LoadingState<[Pokemon]>) {
        switch newState {
        case .idle:
            pokemons = []
            isLoading = false
            error = nil
        case .loading:
            isLoading = true
        case .success(let t):
            pokemons = t
            isLoading = false
            error = nil
        case .failure(let e):
            error = e
            if e as? PokemonRepository.Errors == .deletionError {
                isDisplayingDeletionErrorToast = true
            }
            isLoading = false
        }
    }
}

Optimistic approach

This is the basic usage, but what happens if we want for example, an optimistic approach for the delete function.

@MainActor
/// Removes the pokemon with the given id.
/// - Parameters:
///   - id: the `id` of the pokemon.
///   - optimisticDelete: if `true` the repository will send the updated list of pokemons automatically,
///   while deleting with the API in the background. If the API call fails, the repository will re-publish the entire list.
func removePokemon(
    id: String,
    optimisticDelete: Bool = false
) async {
    do {
        var mutablePokemons = inMemoryPokemons
        mutablePokemons.removeAll { pokemon in
            pokemon.id == id
        }
        if optimisticDelete {
            pokemonsPublisher.send(.success(mutablePokemons))
        } else {
            pokemonsPublisher.send(.loading)
        }

        // Delete the pokemon.
        try await dependencies.deletePokemon(id)
        // Refetch all the pokemons.
        await loadAllPokemons()
    } catch {
        pokemonsPublisher.send(.failure(Errors.deletionError))
        /// Re-Publish all the pokemons (without the deletion)
        pokemonsPublisher.send(.success(inMemoryPokemons))
    }
}

This is why we store the array of inMemoryPokemons in the repository. It makes it super easy to have optimistic features when dealing with the API.

Then from the view, we can use the optimistic approach, and then react to the Errors.deletionError if needed and display an alert.

Side note: We could use Toasts instead.

Wipe method

We should provide a way to wipe the repository in case for example, the user logs out, or you want to provide a way to clear all the data:

@MainActor
func wipe() {
    pokemonsPublisher.send(.idle)
    inMemoryPokemons = []
}

Testing

Both the Repository and the State Model should be covered with unit tests:

Testing the repository is quite simple, given we can inject whatever dependencies we want, so we can easily check the success and error states.

Testing the model state changes is also really simple, given we just need to call the updateStates method with different states and then make some assertions.


Next iteration

The next natural step, would be to add an in-disk cache for our repositories, but that would be part of a future article!

Combine Repository | manu.show
Tags: iOS testing
Share: Twitter LinkedIn