Uplifting client code with a Swift Server

If you have worked with a large scale company, or enterprise system, no doubt that you have had to solve more problems then you would like on the client side. Often there is in-flexibility with how the existing backend systems work and you are left with no choice- but is there an alternative option?

What are the problems with this?

We might find that we have to make more requests than we would like on the client side, this can lead to slower load times. Often there can be sequential requests required that can't be executed in parallel. More often than not we have to communicate with various APIs, endpoints, or SDKs to get the information that we need. This is also required to be implemented manually across the various clients e.g. iOS, Android, Web. What if the client team could create a BFF (Backend for front end) to help solve this problem?

Demonstration

To demonstrate this we are going to simulate an audio streaming application and requesting the information we need to display the home feed. This is simulated below in the following UML diagram. We first need to make a call to the schedule service to find out what collection should be shown based on the current time of day, and once we have that, we can then go to the collection service and request the collection, and we can then filter, sort, and reduce it before passing it to a view model layer for presentation.

Diagram showing the communication between app and the various services

Given that we are talking directly to services we can't change we might get some sub-optimal JSON back and need to build complex models to decode it appropriately.

Here is some sample JSON that is returned

[
  {
    "metadata": {
      "identifiers": {
        "id": "ep001",
        "externalRef": "ext-123"
      },
      "timing": {
        "published": "2024-11-15T09:30:00Z",
        "durationSeconds": 1800
      },
      "tags": [
        {
          "code": "news",
          "label": "News"
        },
        {
          "code": "tech",
          "label": "Technology"
        }
      ]
    },
    "content": {
      "titleInfo": {
        "primary": "The Future of Tech",
        "alternateTitles": ["Tech Talk 2024"]
      },
      "body": "An in-depth look at upcoming trends in technology.",
      "media": {
        "audioAsset": {
          "url": "https://example.com/audio/ep001.mp3",
          "format": "mp3"
        }
      }
    },
    "flags": {
      "availability": {
        "isActive": true,
        "isGeoRestricted": false
      }
    }
  }
]

Undesirable JSON from the server

And then we would need to create the following models to decode it

struct EnterpriseEpisode: Codable {
    let metadata: EpisodeMetadata
    let content: EpisodeContent
    let flags: FlagsContainer
}

struct EpisodeMetadata: Codable {
    let identifiers: EpisodeIdentifiers
    let timing: EpisodeTiming
    let tags: [Tag]?
}

struct EpisodeIdentifiers: Codable {
    let id: String
    let externalRef: String?
}

struct EpisodeTiming: Codable {
    let published: String // ISO8601 date string
    let durationSeconds: Int
}

struct Tag: Codable {
    let code: String
    let label: String
}

struct EpisodeContent: Codable {
    let titleInfo: TitleInfo
    let body: String
    let media: MediaWrapper
}

struct TitleInfo: Codable {
    let primary: String
    let alternateTitles: [String]?
}

// The list goes on!
...

This isn't great as it has complex decoding, lots of models, and needs to be implemented on every client. In an ideal world, we would just have the data we need returned following a simple JSON structure like the following

[
  {
    "id": "ep001",
    "publishedDate": "2024-11-15T09:30:00Z",
    "title": "The Future of Tech",
    "durationSeconds": 1800,
    "isActive": true,
    "audioURL": "https://example.com/audio/ep001.mp3"
  }
]

Making a much more simple client side model to implement

struct ClientEpisode: Codable, Content {
    let id: String
    let title: String
    let publishedDate: Date
    let durationSeconds: Int
    let audioURL: URL
    let isActive: Bool
}

So how do we go about it?

Setting up a Swift server

I would recommend using Vapor and they have a really good setup guide that you can follow to create your Swift package that operates as a server. You can follow their guide here but I will walk through quick start steps below.

First install the Vapor CLI helper tool

brew install vapor

Then create your project using the helper you just installed

vapor new ProjectName -n

Open up the created Package.swift file in Xcode, hit run, and you will see the server hosted on http://127.0.0.1:8080

Sweet! Now we have a server we can start to shift some of our iOS client code into it.

Implementing the routes

We can implement a basic implementation using a Route or a RouteCollection

A route can be defined with something as simple as the following

app.get("hello") { req async -> String in
    "Hello, world!"
}

So making a GET request to /hello would return "Hello, world!" - it's as simple as that.

This wouldn't scale very well for a larger application so we can use RouteCollection. This works well for multiple request types e.g. GET, POST, UPDATE and for grouping path components together. We are going to continue with our demonstration above and create an implementation that will register a GET endpoint under collections/home in future, if we wanted to add more to the /collections path, this would be a great place to go about it.

struct CollectionController: RouteCollection {
    
    func boot(routes: any RoutesBuilder) throws {
        let collectionRoute = routes.grouped("collection")
        collectionRoute.get("home", use: getHome)
    }

    /// Gets the home collection under `/collection/home`
    /// - Parameter req: 
    /// - Returns: 
    func getHome(req: Request) async throws -> [ClientEpisode] {
        fatalError("Not yet implemented!")
    }
}

We can then register this controller in our routes.swift file that was created for us

try app.register(collection: CollectionController())

And if we make a request to http://127.0.0.1:8080/collection/home we will see that the request is made but fails and unfortunately, our server crashes! Let's go ahead and create an implementation for this to fix it.

Moving the client side logic

Loaders

Previous we had two loaders that we needed for this, a schedule loader and collection loader. Here is our mock ScheduleLoader for this example that we need to use first to get the home collection identifier to load

struct ScheduleLoader {
    
    func getHomeCollectionId() async throws -> String {
        // Simulate load
        try await Task.sleep(for: .seconds(0.2))
        
        // Simulate a collection identifier
        return "115789312"
    }
}

And we have a sample CollectionLoader that can load a collection given an identifier

struct CollectionLoader {
    
    func getCollection(id: String) async throws -> [EnterpriseEpisode] {
        // Simulate load
        try await Task.sleep(for: .seconds(0.2))
        
        // Simulate decoding
        let jsonData = Data(collectionJsonString.utf8)
        return try JSONDecoder().decode([EnterpriseEpisode].self, from: jsonData)
    }
}

Previously we would have these in the client and would require two sequential requests. Now we can simply shift this code into our server and put them in a Loader directory.

Use Cases

Now we need to move our UseCase that talks to the two Loaders to get the home collection id, load the collection, and filter and sort the data

struct CollectionUseCase {
    
    let scheduleLoader: ScheduleLoader
    let collectionLoader: CollectionLoader
    
    init(
        scheduleLoader: ScheduleLoader = ScheduleLoader(),
        collectionLoader: CollectionLoader = CollectionLoader()
    ) {
        self.scheduleLoader = scheduleLoader
        self.collectionLoader = collectionLoader
    }
    
    func loadHomeCollection() async throws -> [ClientEpisode] {
        // Load the home collection id 
        let homeCollectionId = try await scheduleLoader.getHomeCollectionId()
        
        // Load the collection
        let collection = try await collectionLoader.getCollection(
            id: homeCollectionId
        )
        
        // Handle business logic for filtering out inactive episodes and sort
        return collection
            .compactMap { ClientEpisode.init(from: $0) }
            .filter { $0.isActive }
            .sorted(by: { $0.publishedDate < $1.publishedDate })
    }
}

And then we can update our RouteCollection to show this

func getHome(req: Request) async throws -> [ClientEpisode] {
    return try await collectionUseCase.loadHomeCollection()
}

Now when we make a GET request to http://127.0.0.1:8080/collection/home we can see our JSON being returned, how good- we now have a working server!

Caching our request

Because we have to make multiple requests to get the same bit of data, we can reduce load on our servers (both the swift server, and existing infrastructure) by caching the response that we provide, as it's agnostic of the user. Fortunately Vapor has built in caching functionality and we can update our getHome function to support it.

We first check if we have valid cache, and if we have it- we can return it immediately.

Otherwise we can make the same call we did previously for fetching it and once we get a response we can set the cache with an expiry of five minutes. You can set this to what you like but five minutes is a good mix of updating frequently but still being able to take advantage of caching requests. You can set this to whatever you please depending on your domain.

func getHome(req: Request) async throws -> [ClientEpisode] {
    let cacheKey = "home_collection_episodes"
    
    // Check cache, and early return if we can
    if let cached: [ClientEpisode] = try await req.cache.get(cacheKey) {
        return cached
    }
    
    // Fetch and set cache
    let episodes = try await collectionUseCase.loadHomeCollection()
    try await req.cache.set(
        cacheKey,
        to: episodes,
        expiresIn: .minutes(5)
    )
    
    // Return episodes
    return episodes
}

Hosting

Assuming you want to get this out of a development environment and into something that can be used in production, there are various means for hosting your swift server, one option is to host via Heroku and you can view a tutorial on how to do so here.

Updating the client code

Now instead of pointing to several services within the clients, and having to duplicate network and business logic across the various clients and apps we can now simply point to our newly created swift server.

This would result in the clients code potentially looking as simple as the following

/// Clean simple Episode model
struct Episode: Codable, Content {
    let id: String
    let title: String
    let publishedDate: Date
    let durationSeconds: Int
    let audioURL: URL
    let isActive: Bool
}

/// Gets the home collection episodes
func getHomeCollection() async throws -> [Episode] {
    let url = "https://yourserver.com/collections/home"
    let (data, response) = try await URLSession.shared.data(from: url)    
    return try decoder.decode([Episode].self, from: data)
}

Which is a huge improvement from all of the code and business logic we had spread across the clients before

Wrapping up

We now have migrated this to act as a BFF (backend for front end) which acts as an intermediary layer using the Facade pattern.

While for this blog post this is a relatively simple example for demonstration, the complexities of client side code can continue to compound. Increasing the number of clients you have e.g. iOS, Android, Web increases the places that you need to manually implement, update and maintain this logic. This can often lead to mistakes and inconsistencies across platforms. Having the code in a centralised place means that we can change the behaviour for all clients in a single place, have a very fast deployment and alter the behaviour of production applications that may otherwise take up to 24 hours to deploy (looking at you App Store). In the situation that the upstream services were impacted you can quickly amend and deploy the intermediary layer, update encoding & decoding behaviour, and update business logic- all agnostic of users updating their app or requiring a deployment to the store.

We have also added in caching which will reduce load on the servers that may not otherwise support it, reduced the amount of requests required, and reduced the size of payloads and decreased the response times to the client.

From experience working across a range of organisations, where often there isn't infrastructure or resourcing in place to support a BFF- there is the option for the client side teams to create and maintain it themselves using existing team resources. This work would need to be done client side anyway, and likely across multiple platforms so having it centralised in a Swift server will likely reduce development time along with bringing all of the benefits already mentioned.

Have questions or would like to reach out? Add me on LinkedIn or Twitter (X)

Subscribe to Aniseed Apps - iOS Blog

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe