In this article, we’ll explore how to fetch and combine data from multiple sources efficiently. Let’s get started!
Consider this scenario: you are building a social app with the ability to display a list of friends. To achieve this, we have three sources of friends’ data—memory, local database, and a network API. After careful consideration of optimizations and performance, we’ve established the following requirements:
- Load friends from memory first. If that fails, try loading from the local database, and finally, if that also fails, fetch the data from the network API.
- Upon successful retrieval from the local database, cache the result in memory.
- When a successful HTTP request fetches data, cache the result both in the local database and memory.
In this article, we’ll explore three different approaches to meet these requirements. I’ll be showcasing the code snippets required to understand each concept and technique used. For the presentation layer, we’ll be adopting the MVVM UI pattern. The approaches are:
- Concrete class implementations (bad)
- Protocol with design patterns (good)
- Swift Combine (good with fewer code)
1. Concrete class implementations
For local data storage, you have various options like Core Data, Codable, or other implementations. For handling HTTP requests, you can either choose a 3rd-party library or utilize the native HTTPURLSession. Here’s how you might attempt to implement the first requirement:
class FriendListViewModel{
var inMemoryFriends: [Friend]?
let coreDataStack: CoreDataStack
let apiService: APIService
///Tell the observer (e.g. view controller) to update data when friends updated
var onFriendsLoad: (([FriendViewModel]?) -> Void)?
init(coreDataStack: CoreDataStack, apiLoader: APIService) {
self.coreDataStack = coreDataStack
self.apiService = apiLoader
}
func loadFriends(){
func map(_ friends: [Friend]) -> [FriendViewModel]{
return friends.map({FriendViewModel($0)})
}
if let inMemoryFriends{
onFriendsLoad?(map(inMemoryFriends))
}else{
self.coreDataStack.loadFriends(completion: {[weak self] result in
switch result {
case .success(let friends):
self?.onFriendsLoad?(map(friends))
case .failure:
self?.apiService.loadFriends(completion: { result2 in
switch result2 {
case .success(let friends):
self?.onFriendsLoad?(map(friends))
case .failure(let error):
//Handle error
()
}
})
}
})
}
}
}
class Friend{
let id: String
//Other variables...
}
class FriendViewModel{
init(_ friend: Friend){
//Handle conversion from friend model to presentation FriendViewModel
}
}
While this approach might work, it’s plagued with several issues:
- The code becomes hard to read and maintain.
- Modifications to the code are cumbersome.
- It contains an arrow-shaped anti-pattern. Arrowhead anti-pattern is caused by many nested structures such as nested conditions, switch statements, and closures.
- It can lead to a massive view model class.
Additionally, it breaks some important SOLID principles:
- Single-Responsibility Principle (SRP): The FriendListViewModel handles the order of loading the friend list source, but it should only be responsible for translating models to presentation data.
- Open-Closed Principle (OCP): FriendListViewModel must be modified to change the loading order or add behaviors like caching, which violates the principle.
2. Protocol with design patterns
An alternative approach involves using the FriendsLoader protocol. Both the local loader and remote loader confirm to this protocol. The local loader depends on FriendsStore, which can represent various local stores like in-memory, Core Data, Realm, etc. Here’s how we can leverage design patterns with protocols:
protocol FriendsLoader{
func loadFriends(completion: @escaping (Result<[Friend],Error>) -> Void)
}
struct LocalFriendsLoader: FriendsLoader{
///A store can be either from database or in memory store. You can make e.g. CoreData, or Realm confirm to the store protocol.
let store: FriendsStore
func loadFriends(completion: @escaping (Result<[Friend],Error>) -> Void){
//Return friends from store if available
}
}
struct RemoteFriendsLoader: FriendsLoader{
let httpClient: HTTPClient
func loadFriends(completion: @escaping (Result<[Friend],Error>) -> Void){
//Request friends from httpClient
}
}
FriendListViewModel
can use FriendsLoader
like below
class FriendListViewModel{
///FriendListViewModel does not know implementation of FriendsLoader, it can be from local or remote source
let friendsLoader: FriendsLoader
///Tell view controller to update view when friends updated
var onFriendsLoad: (([FriendViewModel]) -> Void)?
init(friendsLoader: FriendsLoader) {
self.friendsLoader = friendsLoader
}
func load(){
friendsLoader.loadFriends {[weak self] result in
switch result {
case .success(let friends):
self?.onFriendsLoad?(friends.map({FriendViewModel($0)}))
case .failure(let error):
//Handle error
()
}
}
}
}
class FriendViewModel{
init(_ friend: Friend){
//Handle conversion from friend model to presentation FriendViewModel
}
}
This code is more readable and cleaner than directly using concrete implementations. The FriendListViewModel also has a clearer responsibility of loading friends and sending presentable FriendViewModels to the observer (view controller). But how do we determine the loading order? The FriendListViewModel doesn’t need to know the concrete implementation of the FriendsLoader. Instead, we inject the FriendsLoader dependency, which can be done at the SceneDelegate or composition root. In this case, we use the composite pattern, creating the FriendsLoaderWithFallbackComposite class, enabling the loader to fallback to a secondary loader if the primary one fails.
public class FriendsLoaderWithFallbackComposite: FriendsLoader {
private let primary: FriendsLoader
private let fallback: FriendsLoader
init(primary: FriendsLoader, fallback: FriendsLoader) {
self.primary = primary
self.fallback = fallback
}
func loadFriends(completion: @escaping (Result<[Friend],Error>) -> Void) {
primary.loadFriends { [weak self] result in
switch result {
case .success:
completion(result)
case .failure:
self?.fallback.loadFriends(completion: completion)
}
}
}
}
We can use the composite class as below,
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
lazy var inMemoryStore: FriendsStore = {
//Initialize in memory store
}
lazy var coreDataStore: FriendsStore = {
//Initialize core data store
}
lazy var httpClient: HTTPClient = {
//Initialize httpclient
}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let _ = (scene as? UIWindowScene) else { return }
configureWindow()
}
func configureWindow() {
let inMemoryLoader = LocalFriendsLoader(store: inMemoryStore)
let localLoader = LocalFriendsLoader(store: coreDataStore)
let remoteLoader = RemoteFriendsLoader(httpClient: httpClient)
let inMemoryWithLocalLoaderFallback = FriendsLoaderWithFallbackComposite(primary: inMemoryLoader, fallback: localLoader)
let loaderWithRemoteFallback = FriendsLoaderWithFallbackComposite(primary: inMemoryWithLocalLoaderFallback, fallback: remoteLoader)
//Inject loader to FriendListViewModel
let viewModel = FriendListViewModel(friendsLoader: loaderWithRemoteFallback)
//Inject view model to FriendListViewController
let viewController = FriendListViewController(viewModel: viewModel)
window?.rootViewController = viewController
}
}
For better readability, we can extract the method for the fallback approach.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
//Other code..
func configureWindow() {
let inMemoryLoader = LocalFriendsLoader(store: inMemoryStore)
let localLoader = LocalFriendsLoader(store: coreDataStore)
let remoteLoader = RemoteFriendsLoader(httpClient: httpClient)
//Inject loader to FriendListViewModel
let viewModel = FriendListViewModel(friendsLoader: inMemoryLoader
.fallback(localLoader)
.fallback(remoteLoader))
//Inject view model to FriendListViewController
let viewController = FriendListViewController(viewModel: viewModel)
window?.rootViewController = viewController
}
}
extension FriendsLoader{
func fallback(_ loader: FriendsLoader) -> FriendsLoader{
return FriendsLoaderWithFallbackComposite(primary: self, fallback: loader)
}
}
This composite pattern allows us to easily change the loader order—for example, loading from the remote source first instead of the local source.
For caching the results, we can employ the decorator pattern. However, we won’t delve into that here. Instead, we’ll explore caching using the Combine framework.
Using protocols with design patterns offers many advantages:
- Adherence to SOLID principles, ensuring better software architecture.
- Code becomes easier to reuse, modify, and maintain.
- Improved code readability.
- Enhanced testability as we can easily inject any FriendsLoader for testing purposes.
- Achieves better separation of concerns, leading to a cleaner and more organized codebase.
However, like any approach, there are some drawbacks to consider:
- Slightly increased code length due to the introduction of protocols and design pattern implementations.
- Usage of additional files for the protocol and pattern implementations.
3. Use Combine Framework
The Combine framework offers a declarative Swift API for processing values over time and presents more efficient and elegant solutions. We can achieve similar results as with design patterns but with less code. So far, we’ve only tackled the first requirement of loading friends. Now, let’s explore caching by having LocalFriendsLoader implement the FriendsCache protocol:
protocol FriendsCache {
func save(_ friends: [Friend], completion: @escaping (Result<Void, Error>) -> Void)
}
class LocalFriendsLoader: FriendsLoader, FriendsCache{
///A store can be either from database or in memory store. You can make e.g. CoreData, or Realm confirm to the store protocol.
let store: FriendsStore
func loadFriends(completion: @escaping (Result<[Friend],Error>) -> Void){
//Return friends from core data stack if available
}
func save(_ friends: [Friend], completion: @escaping (Result<Void, Error>) -> Void) {
//Save friends to cache
}
}
With the help of some extension methods for readability, we can now use Combine as follows:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
//Other code..
func configureWindow() {
let inMemoryLoader = LocalFriendsLoader(store: inMemoryStore)
let localLoader = LocalFriendsLoader(store: coreDataStore)
let remoteLoader = RemoteFriendsLoader(httpClient: httpClient)
//Make friends publisher by following requirements 1,2,3
let loadFriendsPublisher = inMemoryLoader
.loadPublisher()
.fallback(to: localLoader.loadPublisher)
.caching(to: inMemoryLoader)
.fallback(to: {
return remoteLoader
.loadPublisher()
.caching(to: localLoader)
.caching(to: inMemoryLoader)
})
let viewModel = FriendListViewModel(friendsPublisher: loadFriendsPublisher)
//Inject view model to FriendListViewController
let viewController = FriendListViewController(viewModel: viewModel)
window?.rootViewController = viewController
}
}
extension FriendsCache{
func saveIgnoringResult(_ friends: [Friend]) {
save(friends) { _ in}
}
}
extension FriendsLoader {
func loadPublisher() -> AnyPublisher<[Friend], Error> {
Deferred {
Future(self.loadFriends)
}
.eraseToAnyPublisher()
}
}
extension Publisher {
func caching(to cache: FriendsCache) -> AnyPublisher<Output, Failure> where Output == [Friend]{
handleEvents(receiveOutput: cache.saveIgnoringResult).eraseToAnyPublisher()
}
}
extension Publisher {
func fallback(to fallbackPublisher: @escaping () -> AnyPublisher<Output, Failure>) -> AnyPublisher<Output, Failure> {
self.catch { _ in fallbackPublisher() }.eraseToAnyPublisher()
}
}
Using Combine, we can achieve the same results as with design patterns. Moreover, we’ve successfully implemented all the requirements, including caching:
- Load friends from memory first, and if that fails, try the local database, finally falling back to the network API.
- Cache the local database result in memory upon success.
- Cache the HTTP request result in both the local database and memory upon success.
Advantages of using the Combine framework include adhering to SOLID principles, less code to write, easier code reuse, better maintainability, increased readability, easy to add side effects such as caching or merging result, and enhanced testability by injecting different FriendsLoaders for testing.
However, there are some drawbacks to consider:
- The learning curve associated with Combine.
- Dependency on the Combine framework.
4. Bonus Requirement: Adding websocket
In addition to the existing requirements, let’s explore a new scenario: updating friends’ data in real-time using Websockets. Here are the steps to achieve this:
- Introduce the FriendEmitter protocol, which allows us to make a websocket client conform to this protocol.
- Create a method to convert FriendEmitter into a publisher.
- Merge the existing publisher with the FriendEmitter publisher, enabling seamless integration of real-time updates.
- Cache the result of the FriendEmitter publisher to both the memory loader and local loader. This ensures that the updated friends’ value is readily available when the publisher is utilized again.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
//Other code...
lazy var friendEmitter: FriendEmitter = {
//Initialize friend emitter here
}
func configureWindow() {
let inMemoryLoader = LocalFriendsLoader(store: inMemoryStore)
let localLoader = LocalFriendsLoader(store: coreDataStore)
let remoteLoader = RemoteFriendsLoader(httpClient: httpClient)
//Make friends publisher by following requirements 1,2,3 and friends emitter protocol
let loadFriendsPublisher = inMemoryLoader
.loadPublisher()
.fallback(to: localLoader.loadPublisher)
.caching(to: inMemoryLoader)
.fallback(to: {
return remoteLoader
.loadPublisher()
.caching(to: localLoader)
.caching(to: inMemoryLoader)
})
.merge(with: friendEmitter
.loadPublisher(loader: inMemoryLoader.loadPublisher())
.caching(to: inMemoryLoader)
.caching(to: localLoader)
)
.eraseToAnyPublisher()
let viewModel = FriendListViewModel(friendsPublisher: loadFriendsPublisher)
//Inject view model to FriendListViewController
let viewController = FriendListViewController(viewModel: viewModel)
window?.rootViewController = viewController
}
}
protocol FriendEmitter: AnyObject{
var didEmitFriend: ((Friend) -> Void)? { get set }
}
We achieve this functionality with just few lines of code, isn’t it awesome? Leveraging the merge method from the Combine publisher, we seamlessly combine two publishers with matching outputs. While we’ve simplified the example above, it’s worth noting that the implementation of FriendEmitter falls beyond the scope of this blog post. However, in essence, you can utilize an adapter pattern class to pass the websocket value to the FriendEmitter protocol. Now, let’s explore how to create a publisher from FriendEmitter:
extension FriendEmitter{
func loadPublisher(loader: AnyPublisher<[Friend], Error>) -> AnyPublisher<[Friend], Error>{
let subject = PassthroughSubject<[Friend],Error>()
var cancellable: Cancellable?
//didEmitFriend closure is called by websocket or any other class
didEmitFriend = { friend in
//When a friend is updated, we load friend list again (should be from in memory loader for fast performance)
cancellable = loader.sink { completion in
} receiveValue: { friends in
//Here we replace the old friend data with new updated friend data, by checking its id.
var theFriends = friends
if let idx = theFriends.firstIndex(where: { aFriend in
return aFriend.id == friend.id
}){
theFriends[idx] = friend
//Send updated friend list to the publisher
subject.send(theFriends)
}
}
}
return subject.eraseToAnyPublisher()
}
}
These are the steps to create the FriendsEmitter publisher:
- Observe the emission of new friend data using
didEmitFriend
from FriendEmitter. - Upon receiving updated friend data, load the friend list from the friends publisher. Make sure to send an in-memory friends publisher to ensure instant results when loading friends.
- Once the friend list is retrieved, locate and replace the old friend with the new friend value.
- Send the updated friends using the PassthroughSubject.
By combining the previous publisher with the friend emitter publisher, all subscribers will be notified whenever a new friend value is updated. This enables you to seamlessly update friends’ data in real-time, without the need to modify the view controller or view model at all!
To further improve the implementation, consider the following:
- Based on the code above, the network API will only be called once because the local cache is not cleared. You can introduce a cache policy in the LocalFriendsLoader to handle when to clear the cache.
- Since websockets can send continuous data, saving to the local database will also be called frequently. To optimize this, you can add a delay when saving to the LocalFriendsLoader with the database store. You can achieve this functionality by utilizing cache policy too.
I hope you’ve gained valuable insights from this post. Feel free to leave any comments or feedback; your input is highly appreciated!
P/S: If you want to learn more about best practices when developing iOS apps, I recommended you take a look at iOS Lead Essentials program. The program teaches high level topics such as test driven development, clean design, modular design, SOLID principles and design patterns. As a fellow student in the program, I can say that it is worth every cent.