Server-driven UI from scratch
Server-driven UI from scratch
August 23, 2024
Read Time: ~12 minutes
One of the downsides of mobile app development, compared to backend or web, is the lack of control we have over our apps in the wild. While a web developer can redeploy their site and have the updated version instantly available to every user, mobile developers are reliant on Play/App Store review processes, as well as users choosing to update their apps to the latest version. This greatly slows down rapid product iteration. Server-driven UI allows us to have a greater degree of control of our app’s functionality from afar.
Introduction
Something is broken in production. How do we fix it?
The answer is simple for a web or backend developer: make the fix, test it, and deploy it. Job done. But for those of us in the mobile space, the answer is a bit more complex: We don’t actually control the entire deployment lifecycle!
Once our fix is ready, releasing it to the public is at the whims of Apple, Google and their store review processes. Once past that gauntlet, and published to the store, we have to wait for users to install the new app on their device. Altogether, it can take a week or two for a change to reach the majority of our user base. This greatly gets in the way of being able to rapidly improve and iterate on our product, as well as deal with urgent bugs and crashes in a timely manner.
We do have some tools in our toolkit to help with this problem, such as tactical force update mechanisms, feature toggling, and server-side workarounds through to more strategic choices like hybrid apps. However, being able to quickly deploy new features and bug fixes to our apps in the wild is a capability most mobile developers can only dream of.
One potential solution for this problem is Server-driven UI. At Empower, we’ve recently explored this approach in our new app for the Southeast Asia market. This approach gives us significant control over the functionality of our app, while maintaining native mobile app look-and-feel and performance.
Our situation
Earlier this year, Empower acquired Cashalo, a Filipino lending company. Part of the process of integrating Cashalo into the wider Empower business is building a new mobile app and backend for Cashalo, on Empower’s tech stack.
Initially, we planned to build this using the same techniques that have proven successful in our Empower US app. This stack includes native Android and iOS apps which talk to a .NET backend in a RESTful-ish way and allows for frequent client releases to deploy new features and bug fixes.
However, there had been a desire from both our product team and engineering management to take this (mostly) green-fields opportunity in the Philippines to try something new - a different approach that would give us faster iteration and development speed, and the ability to quickly build and test product experiments to deliver better outcomes for our users and the business.
Approach
From the get-go, we wanted a complete solution for moving client-side logic out of our mobile apps. So rather than the piecemeal approach of making particular screens, parts of screens or flows to be server-driven, we sought an approach that abstracted away the important facets of a mobile app to a remote system:
- Displaying screens, modals and other UI elements
- Submitting user input and selections to the backend
- Navigation (including deep-links) and flows through the app
The diagram above demonstrates a traditional mobile-server approach on top, and our new approach on the bottom - which adds a Backend-for-Frontend into the mix.
In the traditional approach, the mobile client app contains the definition of a screen - how it looks, what data it needs to fetch from the server, and how to plug that data into its UI.
By contrast, with a server-driven UI approach, the client only knows the name of a screen it needs to display (and potentially some additional arguments). The definition of a screen is held in the Backend-for-Frontend; when a client requests a certain screen, it is the BFF that makes any required requests to our server, plugs the resulting data into the screen structure, and returns a representation of the screen to the client for rendering.
A Backend-for-Frontend is a backend server application that services a specific client, typically focusing on one user experience. This design pattern was introduced originally at SoundCloud and popularized by Sam Newman.
Empower uses a variation on this pattern whereby a backend servers are positioned as a sort of middleware that sit between our client app and general API server. Their purpose is to prepare data from the server in ways specific for each client.
This approach is powerful. It prevents domain logic from living in the client app, and decouples the apps deployment lifecycle from deployment lifecycle of the actual business code. It means the client doesn’t even know that what kind of app it is! By updating and re-deploying the BFF, you could change the entire app in an instant.
Technology decisions
The following are a few of the more impactful tech choices we made when designing this system:
- Language: Kotlin using the Ktor framework
- We’re using Ktor as both a server (receiving GraphQL requests from the mobile app) and a client (making requests to our server)
- Communication Protocol: GraphQL
- This takes care of the transport layer, allows us to generate client side models from the GraphQL schema, and takes care of some versioning concerns (clients will only request elements and fields that they can support)
- Deployment, Infrastructure: The app is Dockerized and deployed to an Azure App Service, which is managed in terraform.
- Monitoring: The app emits logs and telemetry consumed by Azure App Insights.
Building blocks
We’ve built our server-driven UI system to meet the requirements and capabilities we need in mobile apps. A lot of the concepts are similar to those found in declarative UI frameworks like Jetpack Compose, since that is what we’re effectively doing - serving a declarative UI remotely.
Components
Components make up the visual structure of our server-driven UI system.
Components can be a single atomic UI element, or a structure made up of multiple elements, or a container for other components.
Here are some of the examples our BFF can serve to clients:
- Button component
- Loan Summary component
- Instruction List component
Some components are static, while others can read or write state - such as text fields or checkboxes.
We current maintain about about 40 components, each of which increases the scope of features we can build without a client release.
Actions
Actions describe what the client should do when various components are interacted with. Common actions we use include:
- Navigate to a new screen
- Submit a form
- Show a modal
- Start a 3rd party SDK
Many actions also include:
- An analytics event to fire when the action is invoked
- A follow up Action to fire when the original Action has completed. For example, an Action to start a 3rd party integration like Veriff KYC would have a follow-up action to reload the screen post-ID verification.
Screens
A screen definition is defined in Kotlin code; when a screen is requested by a client, the BFF makes API calls to our server (if necessary for that screen) then builds up a Screen Response containing a User Interface and an initial Screen State. These are then returned to the client and displayed by a rendering layer in the mobile app; this maps Components to equivalents in Jetpack Compose (Android) or Swift UI (iOS).
User interface
The User Interface contains the Components to be displayed on screen, divided up based on which section of the screen they live in:
- Top bar (typically a toolbar)
- Content (main scrolling content of the screen)
- Bottom bar (pinned to bottom of screen, typically CTA buttons and important disclaimers)
It also contains metadata about the screen itself like:
- System styling (status and navigation bar color)
- Lifecycle Actions e.g. on back press, on load
Example
Here is an example screen definition from our KYC flow. It contains:
- A header with title and subtitle
- An info list with 2 cells
- A pinned bottom bar, containing a disclaimer label + a button with a NavigateScreenAction to the next screen in the flow.
The client logic receives this payload from the BFF, and maps each component into Jetpack Compose components to render a screen that looks like this:
As you can see, with the generic mapping logic in place you could easily change the app's behavior by altering the screen data returned by the BFF:
- You could update copy, add an additional InfoList item, or add another component to the screen
- You could change the navigation flow by changing the screen navigated to in the NavigateScreenAction.
Outcomes
We are already seeing the benefits of this approach realized:
- Our recent release cadence has been 2-3 BFF releases for every 1 client app release. We expect this to improve further as additional parts of the app are converted to SDUI, and our component and action libraries grow to cover more use cases.
- We’ve recently done our first product experiment. We were able to push a flow change and new screen to production in <2 hours using a BFF release. This would have taken 1-2 weeks using a traditional approach with a client release.
- We were able to release a new feature and major navigation change to our Loan Application flow by simply deploying an updated version of our BFF, while our Android client release was blocked by a slow Google Play review.
Future
At the moment, our new Cashalo app is still at small scale. Our first priority going forward is expanding the capabilities and reliability of our SDUI system as we scale. Once we’ve cleared those hurdles, a couple of other items on our roadmap are:
- Potentially expand use of this system across other teams in Empower, or inspire/support similar efforts (this is already happening!)
- Currently, all our screens are defined in Kotlin code on the BFF. Eventually, we’d like to move them out of our codebase into a database somewhere.
- That opens up the option of creating a web-based internal tool, for our product team to build screens and flows without involving an engineer, and save them to the database to be served by the BFF.
About Hamish Cundy
I'm Hamish, a mobile engineer from New Zealand who enjoys designing and implementing innovative software solutions. I've been working in the mobile space for about a decade, building apps in the utility, parking, banking and finance sectors. I aspire to work on and build solutions that make a positive impact on the world and peoples lives.
I split my time between Melbourne, Australia, and the beautiful South Wairarapa coast of New Zealand.
In my spare time I enjoy photography (mostly via drone), cycling and hiking. I also enjoy the frequent travel involved in my current role; getting to meet up with my colleagues in a new and exciting place twice a year makes all the difference when working remotely day-to-day.