What is the Repository pattern and how is it used in Swift?

What is the Repository pattern and how is it used in Swift?

ยท

7 min read

If you have any experience or exposure to the Android development world, you must have heard Google promoting the usage of the "Repository pattern" in conjunction with MVVM. But the Repository pattern is much more than just a Google recommendation. It has its place well cemented in the world of Clean code and it is almost a direct representation of some of the SOLID principles. Let's break down what it really is.

What is a 'Repository'?

In the English language, a 'Repository' is defined as "a place, room, or container where something is deposited or stored".

And in the programming world, that is exactly what it is. A container where something is stored. Now, this 'something' could literally be anything. It can be raw data, it can be numbers, words, custom datatypes.

This pattern was articulated rather well by Martin Fowler in his book Patterns of Enterprise Application Architecture (abbreviated as PEAA).

He states that the Repository pattern "Mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects."

The key terms to understand here are:

  • data mapping layer
  • domain layer
  • collection-like interface

1. Data-mapping layer

The data-mapping layer is simple. It is the layer that maps 'data' into 'objects'. You can think of it like the part of the app that converts data returned from the CoreData datastore into object-representations of that data that can be used by the app.

Here it is important to understand that these sources of data should be assumed to be flaky and will be susceptible to change without warning.

2. Domain layer

Simply put, the 'domain layer' is that layer of an application which encapsulates the functioning of a particular domain. There can be several domains within an application, each with its own layer. Taking the example of a meal-ordering app, the part of the application that you use to order a meal can be one independent domain within the app, the part you use to save and configure your payment methods can be another independent domain within the app.

It is important to understand that this domain layer, is likely to define its own data types to represent the objects it would like to use according to its requirements.

Like so,

struct Restaurant {
    var id: String
    var name: String
    var address: String
    var rating: Int
}

private extension APIRestaurant {
    func asRestaurant() -> Restaurant {
        func percentageToRating(_ percent: String) -> Int { return 5 }
        let rating: Int = percentageToRating(restaurant_scorePercentage)

        return Restaurant(id: restaurant_id, name: restaurant_name, address: restaurant_address, rating: rating)
    }
}

3. Collection-like interface:

To protect the codebase (and your own sanity as a developer), it is necessary to make sure that changes at the source of your data do not require you to make multiple changes in multiple locations of your codebase. And even more so, if your code is not thoroughly and well-tested.

A solution to that is to provide an interface at the boundary of your domain that facilitates the exchange from data-mapping layer to domain layer. The implementation of this interface will remain agnostic of the domain layer.

In simpler terms, the domain layer asketh and the Repository giveth. The domain layer does not need to (and has no business to) know how the Repository interface is being implemented.

One of the nuances here to understand is that the Repository is defined to be a "collection-like" interface. Which means that it is expected to have all or some of the attributes of a collection, like being able to request all elements, one single element, adding/updating/removing an element from that collection.

Keeping that in mind, here is how the aforementioned interface can be declared.

protocol Repository {
    associatedtype T

    func create() -> T
    func getAll() -> [T]?
    func get(objectWith id: String) -> T?
    func update(objectWith id: String)
    func delete(objectWith id: String)
}

What next?

Next we will see how this actually benefits the codebase with a slightly more realistic example.

We have a RestaurantManager which is responsible for managing a list of all the restaurants that you have visited/favourited in the past. Now this RestaurantManager has decided to save these restaurants in app's UserDefaults because at the moment the use case does not demand that these restaurants persist over several initialisations of the app. This is our 'domain' and we will implement it like so,

class RestaurantManager<T: Repository> {
    var restaurantRepository: T

    init(repository: T) {
        self.restaurantRepository = repository
    }

    func whichRepository() -> String {
        return "\(T.self)"
    }
}

class UserDefaultsRestaurantRepository: Repository {
    typealias T = Restaurant

    func create() -> Restaurant {
        let newRestaurant = Restaurant(id: UUID().uuidString, name: "New Restaurant", address: "Same street as my house", rating: 4)
        let key = "restaurant_\(newRestaurant.id)"
        UserDefaults.standard.set(newRestaurant, forKey: key)
        return newRestaurant
    }

    func getAll() -> [Restaurant]? {
        guard let restaurants = UserDefaults.standard.object(forKey: "restuarants") as? [Restaurant] else { return [] }
        return restaurants
    }

    func get(objectWith id: String) -> Restaurant? {
        let key = "restaurant_\(id)"
        guard let restaurant = UserDefaults.standard.object(forKey: key) as? Restaurant else { return nil }
        return restaurant
    }

    func update(objectWith id: String) {
        let key = "restaurant_\(id)"
        guard var restaurantToBeUpdated = UserDefaults.standard.object(forKey: key) as? Restaurant else { return }
        restaurantToBeUpdated.rating = 2
        UserDefaults.standard.set(restaurantToBeUpdated, forKey: key)
    }

    func delete(objectWith id: String) {
        let key = "restaurant_\(id)"
        UserDefaults.standard.removeObject(forKey: key)
    }
}

and invoke it like so,

let userDefaultsRepo = UserDefaultsRestaurantRepository()
let manager = RestaurantManager(repository: userDefaultsRepo)
print(manager.whichRepository())

Here, the repository acts as an interface between the UserDefaults implementation of the storage logic, and the Restaurant domain.

A new requirement emerges...

Just a week after releasing your app to the store, you hope to take the next week off to celebrate your success but your happiness is short-lived. Your customer changed his mind and now wants his app to save his favourite restaurant across app launches.

So now you have to implement that solution for the customer.

But because you've been smart while designing your solution, all you have to now do is change the low-level implementation of your storage logic and ask your RestaurantManager to now use the new Repository, and you will have achieved what the customer asked for without having to change a thing within your domain. Like so,

class CoreDataRestaurantRepository: Repository {
    typealias T = Restaurant

    func create() -> Restaurant {
        let newRestaurant = Restaurant(id: UUID().uuidString, name: "New Restaurant", address: "Same street as my house", rating: 4)
        //logic to add newRestaurant to core data and persist it goes here
        return newRestaurant
    }

    func getAll() -> [Restaurant]? {
        //logic to fetch all restaurant objects from core data goes here
        return []
    }

    func get(objectWith id: String) -> Restaurant? {
        //logic to fetch any one restaurant from core data goes here
        return Restaurant(id: UUID().uuidString, name: "New Restaurant", address: "Same street as my house", rating: 4)
    }

    func update(objectWith id: String) {
        //logic to update any one restaurant and persist to core data goes here
    }

    func delete(objectWith id: String) {
        //logic to delete any one restaurant from core data goes here
    }
}

let coreDateRepo = CoreDataRestaurantRepository()
let manager = RestaurantManager(repository: coreDateRepo)
print(manager.whichRepository())

Adherence to the SOLID Principles:

  • Single Responsibility: The Repository performs one and one duty only. Here, it is an interface for your domain layer to access the data stores.

  • Open Closed Principle:

    • The Repository is open to extension. You can now add any new implementation of your data store so long as it conforms to the Repository interface.
    • The Repository is closed for modification. Adding new behaviour to the repository did not require you to make any changes to the Repository itself.
  • Liskov Substitution principle: Every concrete implementation of the Repository interface can be substituted with each other and injected in the RestaurantManager with confidence that it will not break the system.

  • Dependency inversion principle: The implementation of the RestaurantManager does not depend on any lower level implementational details (like CoreDataRestaurantRepository or UserDefaultsRestaurantRepository). It only depends on an abstraction, which is the Repository protocol in our example.

AAAAAND we are done!

I hope you have liked and understood the content. All feedback is appreciated :)

Don't forget to Get Swifty ๐Ÿ‘‰๐Ÿป๐Ÿ˜Ž๐Ÿ‘‰๐Ÿป with me and the iOS Community on Twitter and LinkedIn! Tschรผss!

ย