lukecsmith.co.uk

Creating a new data driven app at Gumtree - 2

Luke, 09 Aug 2024

Introduction

Ive spent the last two years at Gumtree UK as principal engineer on the iOS team. In part 1 of this article I discussed the background to the rewrite of the Gumtree apps, and an overview of the architecture including a new mobile BFF. In this second part I'd like to discuss in more detail the architecure of the iOS app and how we built it to be scalable, clean and modern, in order to be a great platform for Gumtree to quickly iterate on in future.

iOS App - The Architecture

We implemented a kind of clean architecture separated into three core layers within the code. Each has a specific purpose, and is isolated from the others by way of protocols. We define this structure to our clean architecture specifically, because we want a clear vision of where we drawn lines of clean separation in the code. We do not cleanly separate everything: ive seen projects where each ViewModel has a protocol, for example. Our clean separations are done distinctly in these three areas:

iOS App Architecture

The broad overview is as follows: The UI Layer is built with a Model-View-ViewModel architecture. The layer below is a Repository layer aka Domain layer- heres where data is requested from the UI layer, and is supplied back to it. The domain layer acts as an important data source layer to make UI testing much easier, but it also serves as a bridge between UI and Data objects, converting between the two. Then below that is the Data layer, concerned only with doing network requests to get objects for the Domain layer. Each of these three layers is cleanly separated using protocols. The main benefit here is that in isolating a layer, we can help to future proof the app- for example if we wanted to change the way the Data layer gets its data, we could do that cleanly without affecting the code within the Domain or UI layers.

Its also easier to test all the layers in isolation: we can mock the Domain layer to test the Views in UIUnitTests or Previews, and we can mock the Data layer to check that business logic in the Domain does what it should - and so on.

The UI Layer

The UI layer consists of a Model-View-ViewModel architecture, built with SwiftUI. It has a specific main model to drive the views, called RowType (enum with associated types). Theres a variety of RowTypes: AdRow, ButtonRow, TitleRow, ListingRow, etc.

enum RowType: Hashable {
    case adRow(AdType)
    case buttonRow(ButtonType)
    case checkboxRow(Checkbox)

We isolate this RowType from the type thats downloaded, to ensure the clean separation of concerns between the layers, so theres a corresponding type in the Domain - Data layers called NetworkRowType, which conforms to Decodable:

enum NetworkRowType: Decodable {
    case adRow([AdType])
    case buttonRow([NetworkButton])
    case checkboxRow(Bool, [NetworkCheckbox], Bool)

So the UI layer simply displays data, with Views for that, and ViewModels drive the data in the Views as necessary.

struct ListingView: View {
    @ObservedObject var viewModel: ListingViewModel
    var body: some View {
        VStack(alignment: .leading, spacing: 2.0) {
            Text(viewModel.title)

And its view model contains the properties from the RowType enums Listing case:

final class ListingViewModel: ObservableObject, Hashable {
    let id: String
    let title: String
    
    init(listing: RowType.Listing) {
        self.id = listing.id
        self.title = listing.title

Rows - UI Building Blocks

The core of each main view on the tabs contains a ForEach that simply loops through the Rows in the data, and creates a corresponding View like the above for each one, using a ViewFactory to create each View (Ive simplified this code a bit so you can see the core of whats going on). These are displayed sequentially as follows:

struct HomePage: View {
    @StateObject var viewModel: HomePageViewModel
    @ObservedObject var router: HomePageRouter
    
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 12.0) {
                ForEach(rows, id: \.self) { row in
                    ViewFactory.view(for: row) {

The beauty of this is that this core system is most of whats required for every main screen in the app, so the code in each main screen is really simple. The only reason why we didnt actually encapsulate this into some kind of ScreenView for re-use was simply that theres individual layout requiremenents for different screens that need to be done at this level- spacings in the VStack for example.

So what weve created is a system of building blocks (Rows) with which we can simply build each screen in the app, and drive it with the data from the BFF.

Building Blocks

Domain Layer

At this level, the ViewModel for (in this case) the HomePage contains the repository that returns the data for the home page.

As the repository is defined by a protocol, we can easily mock this repository to supply any kind of test data, for unit testing the viewModel or UI testing the view, and for easier displaying of previews in XCode.

final class HomePageViewModel: ObservableObject {
    private let repository: HomeRepositoryProtocol
    
    func fetchFeed(page: String, size: String, location: Location) async {
        let result = await repository.fetchHomeFeed(page: page, size: size, location: location)

The repository itself is within the Domain layer, and a typical one looks like this:

struct HomeRepository: HomeRepositoryProtocol {
    private let dataProvider: AdsDataProviderProtocol

    init(dataProvider: AdsDataProviderProtocol = AdsDataProvider()) {
        self.dataProvider = dataProvider
    }

    func fetchHomeFeed(page: String, size: String, location: Location) async -> Result<HomeData, GTError> {
        let queryItems = HomePageFeedQueryItems(page: page, size: size, locationId: location.id, locationType: location.type.rawValue, distance: location.distance.toAPIValue())
        let result = await dataProvider.fetch(queryItems: queryItems)
        switch result {
        case .success(let response):
            return .success(HomeFeedNetworkObjectMapper.map(response: response))
        case .failure(let error):
            debugLog("Error attempting to fetch ads for home screen: \(String(describing: error))")
            return .failure(error)
        }
    }
}

The AdsDataProvider is the link to the Data layer, and returns Data specific objects- NetworkRowType. We convert from this type into the RowType that the View uses, using the HomeFeedNetworkObjectMapper. Here the conversion from NetworkRowType to RowType is pretty simple as we can initialise each RowType enum with its NetworkRowType counterpart:

struct HomeFeedNetworkObjectMapper {
    static func map(response: HomePageFeedResponse) -> HomeData {
        let landscapeData = response.landscapeData?.compactMap(RowType.init) ?? []
        let portraitData = response.portraitData.compactMap(RowType.init)
        

If we had any further work to do on the supplied data, it would happen in the repository. For example, if a selected item on the home page meant that some data had to be hidden, that would happen on the repository, and we could unit test it there.

Data Layer

The final piece is the Data layer itself, so taking the above mentioned AdsDataProvider as an example:

final class AdsDataProvider {
    private let apiClient: APIClientProtocol
    
    init(apiClient: APIClientProtocol = URLSession.apiClient()) {
        self.apiClient = apiClient
    }
    
    func fetch(queryItems: HomePageFeedQueryItems) async -> Result<HomePageFeedResponse, GTError> {
        let request = HomePageFeedRequest(queryItems: queryItems)
        let result = await apiClient.process(request)
        return result
    }
}

The HomePageFeedRequest type conforms to an APIRequest protocol, and supplies the APIClient with everything it needs for the call: the URL, the query items and header items, any kind of body if required, etc. APIRequesting has an associated type which specifies the return type that the APIClient attempts to parse the received data into. Here the associated type has been specified as HomePageFeedResponse:

struct HomePageFeedRequest: APIRequesting {
    var queryItems: [String: String]?
    typealias Response = HomePageFeedResponse
    let resourceName = NetworkEndpoint.homePageFeed.rawValue

And HomePageFeedResponse looks like:

struct HomePageFeedResponse: Decodable {
    let nextPage: String?
    let portraitData: [NetworkRowType]
    let landscapeData: [NetworkRowType]?

The APIClientProtocol is simple, just enabling a URLRequest type, to be processed (run as a network request)

protocol APIClientProtocol {
    func process<T: APIRequesting>(_ request: T) async -> Result<T.Response, GTError>
}

We created our own APIClient (rather than using a library like Alamofire) for simplicity and to help keep the app size small. Its built on URLSession, and simply processes requests and returns data using generics specified in the URLRequest objects. Heres what the process func looks like:

final class APIClient: APIClientProtocol {
    private let baseURL: String
    private let session: URLSessionDataRequestAsyncProtocol
    
    func process<T: APIRequesting>(_ request: T) async -> Result<T.Response, GTError> {
        do {
            let (data, response) = try await session.data(for: apiRequest)
            guard let httpResponse = response as? HTTPURLResponse else {
                recordError(url: apiRequest.url?.absoluteString, statusCode: -1001, error: "Unable to cast response as HTTPURLResponse")
                return .failure(.networkingError())
            }
            
            // check response status codes:
            guard 200...299 ~= httpResponse.statusCode else {
                recordError(url: apiRequest.url?.absoluteString, statusCode: httpResponse.statusCode, error: "")
                return .failure(.apiError(httpResponse.statusCode))
            }
            
            // decode if success:
            return decode(jsonDecoder: .decoderWithDateFormat(request.dateFormat), data: data, url: apiRequest.url?.absoluteString, statusCode: httpResponse.statusCode)
        } catch {
            return .failure(.networkingError())
        }
    }
}

Conclusion

As a team we feel the rebuild of the Gumtree apps with a BFF has been a success. Weve got pretty lightweight clients, and much of the heavy lifting in terms of data organisation is done a single time on the BFF, rather than in both Android and iOS clients. As a team weve become more broadly knowledgeable of systems beyond our own client side, and as a primarily an iOS developer, its been really interesting to work with Kotlin, and on server side code. Ultimately weve been able to create brand new apps with all the features we felt were missing before - great accessibility for example, a nice new simple design, smooth, quick operation, and remote control of the contents of most screens in both apps from the BFF. But most importantly we now have a technological base for Gumtree which is really easy and quick to work on. New features can be developed and deployed really quickly, and Gumtree can move at a speed it really wants to, competing with other quicker startups, no longer hindered by the weight of big old legacy codebases.

Tagged with: