Repository Pattern + Composite Pattern
Why we need Composite Pattern in the Repository Pattern ?
For most typical use cases, when you are dealing with a single data source or repository, using the Repository Pattern is straightforward and sufficient. In this scenario, you would have a single repository responsible for handling the interactions between your application and the data source, whether it’s a database, an API, or a local cache. The repository pattern abstracts these operations, allowing you to interact with the data source in a clean and decoupled manner.
However, when your application needs to interact with multiple data sources — such as combining data from a local cache, remote APIs, or even multiple services — you encounter a more complex challenge. In these situations, a single repository is no longer enough. You need a way to aggregate, manage, and interact with these multiple repositories in a unified way.
This is where patterns like the Composite Pattern or a Repository Aggregator come into play. They provide a solution by introducing a higher-level component that treats all repositories uniformly. This component is responsible for deciding which data source to query or how to merge the results from multiple sources. By abstracting these complexities, the composite repository allows the rest of your application to interact with the data in a consistent manner, without worrying about where the data comes from or how it is retrieved. This promotes a clean separation of concerns and keeps your codebase modular and easier to maintain.
What is Repository Pattern ?
The Repository Pattern is a design pattern used in software development to separate the logic that retrieves, stores, and manages data from the rest of the application. It acts as an intermediary between the application’s business logic and the underlying data sources, like databases or APIs. The main purpose of the repository pattern is to create a clear separation of concerns, making your code more modular, maintainable, and testable.
protocol ISeriesRepository {
associatedtype T
func get(by id: Int) -> AnyPublisher<T, Error>
func getAll() -> AnyPublisher<[T], Error>
func add(with data: [T]) -> AnyPublisher<Bool, Error>
func update(with data: T) -> AnyPublisher<Bool, Error>
func delete(by id: Int) -> AnyPublisher<Bool, Error>
}
This protocol defines common operations for repositories, such as get
, getAll
, add
, update
, and delete
.
In this case, both LocalSeriesDAO
and RemoteSeriesDAO
act as repositories for local and remote data sources, respectively.
final class LocalSeriesDAO: ISeriesRepository {
func get(by _: Int) -> AnyPublisher<SeriesEntity, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
// Other methods...
}
class RemoteSeriesDAO: ISeriesRepository {
func get(by _: Int) -> AnyPublisher<SeriesResponse, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
// Other methods...
}
LocalSeriesDAO
and RemoteSeriesDAO
are concrete classes implementing the ISeriesRepository
protocol, where LocalSeriesDAO
represents a local database or cache, and RemoteSeriesDAO
interacts with a remote API.
What is Composite Pattern ?
The Composite Pattern is a structural design pattern used to treat individual objects and compositions of objects uniformly. It allows you to build a tree-like structure of objects, where both individual objects and groups of objects (composites) can be treated the same way. This pattern is especially useful when dealing with hierarchies of objects.
final class SeriesRepository: ISeriesRepository {
private let repositories: [any ISeriesRepository]
init(repositories: [any ISeriesRepository]) {
self.repositories = repositories
}
The Composite Pattern is applied in the SeriesRepository
class. Instead of using just one repository, this class manages an array of repositories (repositories: [any ISeriesRepository]
). This enables the handling of both local and remote data sources within the same interface.
The key idea here is that SeriesRepository
can treat both LocalSeriesDAO
and RemoteSeriesDAO
uniformly, leveraging both as needed. This makes it easy to merge, switch, or fall back between local and remote repositories.
Combining Local and Remote Results
In the get
and getAll
methods, the SeriesRepository
uses both the local and remote repositories, combining their results using Combine's Publishers.Merge
operator. This allows the app to first check the local cache, and if necessary, fetch the data from the remote API.
get(by id: Int)
Method
This method attempts to get a SeriesDTO
by ID from both local and remote repositories, merging the results. The first()
method ensures that it stops after receiving a value from either source.
protocol ISeriesRepository {
associatedtype T
func get(by id: Int) -> AnyPublisher<T, Error>
func getAll() -> AnyPublisher<[T], Error>
func add(with data: [T]) -> AnyPublisher<Bool, Error>
func update(with data: T) -> AnyPublisher<Bool, Error>
func delete(by id: Int) -> AnyPublisher<Bool, Error>
}
protocol ILocalSeriesDAO: ISeriesRepository where T == SeriesEntity {}
protocol IRemoteSeriesDAO: ISeriesRepository where T == SeriesResponse {}
protocol ISeriesDAO: ISeriesRepository where T == SeriesDTO {}
final class LocalSeriesDAO: ILocalSeriesDAO {
func get(by _: Int) -> AnyPublisher<SeriesEntity, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
func getAll() -> AnyPublisher<[SeriesEntity], Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
func add(with _: [SeriesEntity]) -> AnyPublisher<Bool, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
func update(with _: SeriesEntity) -> AnyPublisher<Bool, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
func delete(by _: Int) -> AnyPublisher<Bool, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
}
class RemoteSeriesDAO: IRemoteSeriesDAO {
func get(by _: Int) -> AnyPublisher<SeriesResponse, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
func getAll() -> AnyPublisher<[SeriesResponse], Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
func add(with _: [SeriesResponse]) -> AnyPublisher<Bool, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
func update(with _: SeriesResponse) -> AnyPublisher<Bool, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
func delete(by _: Int) -> AnyPublisher<Bool, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
}
final class SeriesRepository: ISeriesDAO {
private let localDAO: any ILocalSeriesDAO
private let remoteDAO: any IRemoteSeriesDAO
// Use protocols instead of concrete implementations
init(localDAO: any ILocalSeriesDAO, remoteDAO: any IRemoteSeriesDAO) {
self.localDAO = localDAO
self.remoteDAO = remoteDAO
}
func get(by id: Int) -> AnyPublisher<SeriesDTO, Error> {
let localPublisher = localDAO
.get(by: id)
.map(SeriesDTOAdapter.toDTO)
.eraseToAnyPublisher()
let remotePublisher = remoteDAO
.get(by: id)
.map(SeriesDTOAdapter.toDTO)
.eraseToAnyPublisher()
return Publishers.Merge(localPublisher, remotePublisher)
.first()
.eraseToAnyPublisher()
}
func getAll() -> AnyPublisher<[SeriesDTO], Error> {
let localPublisher = localDAO
.getAll()
.tryMap { $0.map(SeriesDTOAdapter.toDTO) }
.eraseToAnyPublisher()
let remotePublisher = remoteDAO
.getAll()
.flatMap { [weak self] data in
guard let self = self else {
return Just([SeriesDTO]())
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
let mappedData = data.map(SeriesEntityAdapter.toEntity)
return self.localDAO.add(with: mappedData)
.tryMap { $0 ? data.map(SeriesDTOAdapter.toDTO) : [] }
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
return Publishers.Merge(localPublisher, remotePublisher)
.eraseToAnyPublisher()
}
func add(with data: [SeriesDTO]) -> AnyPublisher<Bool, Error> {
let localPublisher = localDAO
.add(with: data.map(SeriesEntityAdapter.toEntity))
.eraseToAnyPublisher()
let remotePublisher = remoteDAO
.add(with: data.map(SeriesResponseAdapter.toEntity))
.eraseToAnyPublisher()
return Publishers.Merge(localPublisher, remotePublisher)
.collect()
.map { $0.allSatisfy { $0 == true } }
.eraseToAnyPublisher()
}
func update(with _: SeriesDTO) -> AnyPublisher<Bool, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
func delete(by _: Int) -> AnyPublisher<Bool, Error> {
return Fail(error: NSError(domain: "", code: 404))
.eraseToAnyPublisher()
}
}