On the previous article, we already learn about how to create a UseCase. In this article, we are going to learn how we can composes use case and create new one that has different functionality, using Composite Reuse Principle.
Let’s visit the previous article example.
The previous example, we have a LoadTransactionsUseCase
protocol to get a general picture of a use case. This protocol, does not care wether the transactions is being loaded from Local or Remote data.
protocol LoadTransactionsUseCase {
func execute(completion: @escaping (Result<[Transaction], Error>) -> Void)
}
Then, comes the concrete type or implementation of this use case, named LoadTransactionsFromCacheUseCase
.
class LoadTransactionsFromCacheUseCase: LoadTransactionsUseCase {
private let store: TransactionCacheStore
init(store: TransactionCacheStore) {
self.store = store
}
func execute(completion: @escaping (Result<[Transaction], Error>) -> Void) {
store.loadTransactions { result in
switch result {
case .success(let transactions):
completion(.success(transactions))
case .failure(let error):
completion(.failure(error))
}
}
}
}
This class is an implementation of the LoadTransactionsUseCase
protocol, that communicates with the abstract store that we now it is using cache, or we can call it as local
data.
What if the user wants to load from a Remote first then load from cache? If we change directly in LoadTransactionsFromCacheUseCase
implementation class, we will break the class first, add logic, and retest again. Furthermore, the class’ name needs to change.
Composite Reuse Principle
We can minimize the changes by creating new types to accomodate the requirement without changing the old implementation class.
First, we create a remote implementation for this protocol. This concrete class is talking to the remote service, the network, to ask the transactions data.
class LoadTransactionsFromRemoteUseCase: LoadTransactionsUseCase {
private let service: TransactionService
init(service: TransactionService) {
self.service = service
}
func execute(completion: @escaping (Result<[Transaction], Error>) -> Void) {
service.loadTransactions { result in
switch result {
case .success(let transactions):
completion(.success(transactions))
case .failure(let error):
completion(.failure(error))
}
}
}
}
Then, we can take the benefit of the Composite Reuse Principle, to enable the load from network first then load from local functionality, by creating a new type. Let’s call it LoadTransactionsFromRemoteWithLocalFallbackUseCase
assumed that we have NetworkReachabilityManager
protocol that is implemented by a custom class or 3rd party framework.
protocol NetworkReachabilityManager {
func isOnline() -> Bool
}
Then, we can create a new type and inject all of the dependencies :
class LoadTransactionsFromRemoteWithLocalFallbackUseCase: LoadTransactionsUseCase {
private let remoteUseCase: LoadTransactionsUseCase
private let localUseCase: LoadTransactionsUseCase
private let networkReachailityManager: NetworkReachabilityManager
init(remoteUseCase: LoadTransactionsUseCase, localUseCase: LoadTransactionsUseCase, networkReachabilityManager: NetworkReachabilityManager) {
self.remoteUseCase = remoteUseCase
self.localUseCase = localUseCase
self.networkReachabilityManager = NetworkReachabilityManager
}
func execute(completion: @escaping (Result<[Transaction], Error>) -> Void) {
let isOnline = networkReachabilityManager.isOnline()
if isOnline {
// using `remoteUseCase` when online
// Of course we can implify the closure, but that is not the goal here.
remoteUseCase.execute { result in
switch result {
case .success(let transactions):
completion(.success(transactions))
case .failure(let error):
completion(.failure(error))
}
}
} else {
// using `localUseCase` when offline
// Of course we can implify the closure, but that is not the goal here.
localUseCase.execute { result in
switch result {
case .success(let transactions):
completion(.success(transactions))
case .failure(let error):
completion(.failure(error))
}
}
}
}
}
This new type conforms to the same abstraction, the LoadTransactionsUseCase
. This enables client to just replacing
the instantiation easily.
What if in the same app but different screen, we need to implement load transaction from local first, then remote?
We can achieve this by creating new type again, without breaking the current available types, especially if it is still being used by another client
.
LoadTransactionsFromLocalWithRemoteFallbackUseCase
class LoadTransactionsFromLocalWithRemoteFallbackUseCase: LoadTransactionsUseCase {
private let localUseCase: LoadTransactionsUseCase
private let remoteUseCase: LoadTransactionsUseCase
private let networkReachailityManager: NetworkReachabilityManager
init(localUseCase: LoadTransactionsUseCase, remoteUseCase: LoadTransactionsUseCase, networkReachabilityManager: NetworkReachabilityManager) {
self.localUseCase = localUseCase
self.remoteUseCase = remoteUseCase
self.networkReachabilityManager = NetworkReachabilityManager
}
func execute(completion: @escaping (Result<[Transaction], Error>) -> Void) {
let isOffline = !networkReachabilityManager.isOnline()
if isOffline {
// using `localUseCase` when offline
// Of course we can implify the closure, but that is not the goal here.
localUseCase.execute { result in
switch result {
case .success(let transactions):
completion(.success(transactions))
case .failure(let error):
completion(.failure(error))
}
}
} else {
// using `remoteUseCase` when online
// Of course we can implify the closure, but that is not the goal here.
remoteUseCase.execute { result in
switch result {
case .success(let transactions):
completion(.success(transactions))
case .failure(let error):
completion(.failure(error))
}
}
}
}
}
Client’s point of view
let remoteUseCase: LoadTransactionsUseCase = LoadTransactionsFromRemoteUseCase(service: URLSessionTransactionService())
let localUseCase: LoadTransactionsUseCase = LoadTransactionsFromLocalUseCase(cacheStore: CacheTransactionStore())
let loadTransactionsFromRemoteFirstUseCase: LoadTransactionsUseCase = LoadTransactionsFromRemoteWithLocalFallbackUseCase(remoteUseCase: remoteUseCase, localUseCase: localUseCase, networkReachabilityManager: getNetworkReachabilityManager()) // if we want to use remote first
let loadTransactionsFromLocalFirstUseCase: LoadTransactionsUseCase = LoadTransactionsFromLocalWithRemoteFallbackUseCase(localUseCase: localUseCase, remoteUseCase: remoteUseCase, networkReachabilityManager: getNetworkReachabilityManager()) // if we want to use local first
And of because of the using of the same protocol, we can easily replace the implementation from the client.
func createDefaultTransactionsUseCase() -> LoadTransactionsUseCase {
let remoteUseCase: LoadTransactionsUseCase = LoadTransactionsFromRemoteUseCase(service: URLSessionTransactionService())
let localUseCase: LoadTransactionsUseCase = LoadTransactionsFromLocalUseCase(cacheStore: CacheTransactionStore())
let loadTransactionsFromRemoteFirstUseCase: LoadTransactionsUseCase = LoadTransactionsFromRemoteWithLocalFallbackUseCase(remoteUseCase: remoteUseCase, localUseCase: localUseCase, networkReachabilityManager: getNetworkReachabilityManager()) // if we want to use remote first
return loadTransactionsFromRemoteFirstUseCase
}
// ...
let transactionsViewController = TransactionViewController(loadTransactionsUseCase: createDefaultTransactionsUseCase())
Conclusion
Creating a new functionality of a component can be achieved by modifying the component itself. But, we can also create new type that compose the old type, which mean to reuse it, to create another new behavior, using the composite reuse principle. In this case, we can breakdown a component into a smaller classes, test separately, easy to maintain, test, and more!