Clean Architecture: Repositories
What is a use repository?
The repository is responsible for abstracting the data layer from the rest of the application and providing a way to perform data operations without knowing how the data is stored or retrieved.
The repository typically defines methods such as create, read, update, and delete (CRUD) operations, as well as other methods for querying and manipulating data. The repository is implemented in the data layer, which provides the specific implementation of how the data is stored and retrieved.
The repository is a key component as it allows the application to be decoupled from the data storage implementation. This makes the application more testable, maintainable, and scalable, as changes to the data storage implementation can be made without affecting the rest of the application.
Here's an example of a repository:
1protocol UserRepository { 2 func getUserById(id: Int) async -> User? 3 func createUser(user: User) async -> Bool 4 func updateUser(user: User) async -> Bool 5 func deleteUser(user: User) async -> Bool 6} 7 8class UserDatabaseRepository: UserRepository { 9 private let database: Database 10 11 init(database: Database) { 12 self.database = database 13 } 14 15 func getUserById(id: Int) async -> User? { 16 return await database.getUserById(id: id) 17 } 18 19 func createUser(user: User) async -> Bool { 20 return await database.createUser(user: user) 21 } 22 23 func updateUser(user: User) async -> Bool { 24 return await database.updateUser(user: user) 25 } 26 27 func deleteUser(user: User) async -> Bool { 28 return await database.deleteUser(user: user) 29 } 30} 31 32
The example is in Swift, but you can use the same basic concept in other programming languages too
It looks like the repository is just proxing data source calls?
The purpose of the repository class is to abstract away the details of the data source and provide a higher-level, domain-specific interface for the application to interact with the data. By doing this, we can achieve several benefits:
-
Decoupling the application from the data source: The repository allows the application to interact with the data source through a generic interface, which means that the application is not directly coupled to the specific details of the data source. This makes it easier to swap out the data source or change the implementation details without affecting the application.
-
Encapsulating the domain-specific logic: The repository provides a domain-specific interface for the application to interact with the data. This means that the domain-specific logic is encapsulated within the repository, which makes the application easier to understand and maintain. The repository can also enforce domain-specific constraints and business rules, such as validation, authorization, or concurrency control.
-
Improving testability: The repository can be easily mocked or stubbed in unit tests, which makes it easier to test the application's business logic without requiring a real data source. This can also speed up the testing process and reduce the need for expensive or complex testing infrastructure.
So, while the same calls could be made directly to the data source, using a repository provides a layer of abstraction that makes the application more modular, testable, and maintainable.
Let's incorporate some validation in the repository to illustrate:
1protocol UserRepository { 2 func getUserById(id: Int) async -> User? 3 func createUser(user: User) async throws 4 func updateUser(user: User) async throws 5 func deleteUser(user: User) async -> Bool 6} 7 8class UserDatabaseRepository: UserRepository { 9 private let database: Database 10 11 init(database: Database) { 12 self.database = database 13 } 14 15 func getUserById(id: Int) async -> User? { 16 return await database.getUserById(id: id) 17 } 18 19 func createUser(user: User) async throws { 20 // Validate the user 21 if user.name.isEmpty { 22 throw ValidationError("Name cannot be empty") 23 } 24 if user.email.isEmpty { 25 throw ValidationError("Email cannot be empty") 26 } 27 28 // Save the user to the database 29 let created = await database.createUser(user: user) 30 if !created { 31 throw RepositoryError("User could not be created") 32 } 33 } 34 35 func updateUser(user: User) async throws { 36 // Validate the user 37 if user.name.isEmpty { 38 throw ValidationError("Name cannot be empty") 39 } 40 if user.email.isEmpty { 41 throw ValidationError("Email cannot be empty") 42 } 43 44 // Update the user in the database 45 let updated = await database.updateUser(user: user) 46 if !updated { 47 throw RepositoryError("User could not be updated") 48 } 49 } 50 51 func deleteUser(user: User) async -> Bool { 52 return await database.deleteUser(user: user) 53 } 54} 55 56struct ValidationError: Error { 57 let message: String 58} 59 60struct RepositoryError: Error { 61 let message: String 62} 63 64
Conclusion
The main purpose of the repository is to provide a layer of abstraction that separates the use case from the underlying data source, so that changes to the data source can be made without affecting the use case.
The repository should not define the business logic or rules, as this would couple the data source with the business logic and make it harder to change one without affecting the other.