The repository design pattern allows you to create an accessible data layer that’s easy to mock for tests. By using common design patterns, you’ll be able to create a consistent project structure, separate concerns, and increase the chances for the project to be easier to understand by outside contributors.
One of my favorite design patterns is the repository pattern. It’s a great way to centralize access to data layers like Core Data or MySQL databases while providing a testable layer. Let’s dive into the details.
What is the repository design pattern?
The repository design pattern acts as an in-between layer between an app’s business logic and data storage. It provides a structured way to read and write while abstracting the underlying details of the storage layer. You’ll interact with data without knowing whether it’s stored in memory, Core Data, User Defaults, or any other layer.
It creates a clear separation of concerns and makes testing much easier. For example, you could use an in-memory backing store for tests while using Core Data in production. Finally, your data layer becomes much more flexible as you can replace the data storage used without having to change the application code at the implementation level.
How to implement the repository design pattern
The repository design pattern starts by defining the interface using a protocol in Swift. For example, imagine having a database with users. We would define a protocol to create, retrieve, or delete a user:
protocol UserRepository {
/// Inserts a new user in the data store.
func create(_ user: User) async throws
/// Deletes an existing user if it exists.
func deleteUser(for id: UUID) async throws
/// Returns a user for the given ID if it exists.
func find(id: UUID) async throws -> User?
}
We must only communicate with a given UserRepository
type at the implementation level. In the following example, I’ve defined a view model that’s constructed using the UserRepository
protocol type:
final class UsersViewModel {
let repository: UserRepository
init(repository: UserRepository) {
self.repository = repository
}
func createUser(name: String) async throws {
let user = User(id: UUID(), name: name)
try await repository.create(user)
}
func delete(user: User) async throws {
try await repository.deleteUser(for: user.id)
}
func findUser(for id: UUID) async throws -> User? {
try await repository.find(id: id)
}
}
You’ll have no idea which data storage will be used, but you will have access to all user-related operations. Behind the scenes, this repository could be backed by an in-memory data storage:
Creating several repository implementations
After defining the repository protocol, you can create the implementation layers. I always prefer to start with the in-memory layer as it allows me to quickly test my application while also setting me up for writing any unit tests.
actor InMemoryUserRepository: UserRepository {
private var users: [User] = []
func create(_ user: User) async throws {
users.append(user)
}
func deleteUser(for id: UUID) async throws {
users.removeAll(where: { $0.id == id })
}
func find(id: UUID) async throws -> User? {
users.first(where: { $0.id == id })
}
}
I defined it as an actor in this case, as I want to prevent data races. If you’re new to actors, read my article Actors in Swift: how to use and prevent data races.
A secondary implementation could be used in production using another type of data layer. For example, you could decide to store all users in the User Defaults:
struct UserDefaultsUserRepository: UserRepository {
var userDefaults: UserDefaults = .standard
let encoder = JSONEncoder()
let decoder = JSONDecoder()
func create(_ user: User) async throws {
var users = try fetchUsers()
users.append(user)
try store(users: users)
}
func deleteUser(for id: UUID) async throws {
var users = try fetchUsers()
users.removeAll(where: { $0.id == id })
try store(users: users)
}
func find(id: UUID) async throws -> User? {
try fetchUsers().first(where: { $0.id == id })
}
private func fetchUsers() throws -> [User] {
guard let usersData = userDefaults.object(forKey: "users") as? Data else {
return []
}
return try decoder.decode([User].self, from: usersData)
}
private func store(users: [User]) throws {
let usersData = try encoder.encode(users)
userDefaults.set(usersData, forKey: "users")
}
}
The major benefit of using this design pattern is its flexibility at the implementation level. You can now initialize the UserViewModel
with either repository:
/// Using an in-memory store:
let viewModel = UsersViewModel(repository: InMemoryUserRepository())
/// Or use `UserDefaults`
let viewModel = UsersViewModel(repository: UserDefaultsUserRepository())
Conclusion
The repository design pattern is one of many that allows you to structure your code better. By using it, you’ll create flexibility for your project and be able to switch data layers without much effort at the implementation level.
If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.
Thanks!