Android Feature Development at Babylon Health — Part 2: From Designs to Architecture

Darren Atherton
Babylon Engineering
10 min readSep 1, 2020
Photo by James Sutton on Unsplash

This article is part 2 of a series on feature development at Babylon Health, describing my thoughts from the design stage all the way to feature completion. In part 1, we discussed the tools we use at Babylon to manage and track our feature work. We then discussed how I begin to approach problems, including how I analyse designs and try to come up with an appropriate solution by asking questions, both to myself and to the team.

Part 1 was largely focused on the designs of the UI/UX of the feature. In this part, I will show how I started to approach a solution by thinking about how to translate our designs into software architecture. This includes asking myself more questions but this time focused on the technical implementation aspect rather than the UI designs.

Asking the Technical Questions

Once I’m sure that I have the answers to the design-related questions in the previous article, I start to form a mental image of how I will translate those designs into actual classes and functions. This comes with its own set of topics to run through. For example:

  • How do I ensure consistency with the current app architecture?
  • How will everything fit together, e.g. when I need to reference my new code from the existing code, or vice-versa?
  • How will I maintain the project’s current coding conventions for design patterns (both in our presentation layer and SDK) and overall architecture?

Feature Architecture

The first topic that usually crosses my mind when figuring out how to implement some design is how the code would appear visually in terms of architecture. I start to think about which classes I will need to create and how they will all fit together. One technique that helps me to determine these classes is to try to visualise the architecture in my mind. If it helps, you could also sketch out a quick diagram of how you think each class should interact and reference each other.

At Babylon, we have a guideline that all new features which involve UI should use the Model-View-Intent (MVI) pattern (also known as unidirectional data flow as popularised by Redux) and Kotlin. Legacy features continue to use Model-View-Presenter (MVP) and Java until there is a need to rewrite the feature. The library that we use to enforce the MVI design pattern is called Orbit, which was created and open-sourced here at Babylon. For this article, I am going to assume some prior knowledge of MVI. However, if you would like to learn more about it and how we implement it at Babylon, then I would recommend our overview as well as this great article by my colleague Mikolaj Leszczynski.

The Feature We Are Developing

To refresh our memory on what we are developing, Figure 1 below shows the design for the phone widget from the previous article:

Figure 1: Original phone widget design
Figure 1: Original phone widget design (designed by Daniel Spagnolo)

Approaching this new feature, I started to visualise how I would represent this using MVI with Orbit. An orbit consists of several classes:

  • Actions: typically representing the user’s behaviours as events. For example, ClickCountryFlagAction represents when the user is selecting a new country. Creating a list of actions requires thinking about how the user will interact with our screen and what events may be generated asynchronously, such as API call results.
  • Transformers: takes actions and transforms them into new events, typically after fetching some data or performing some business logic. For example, fetching a list of countries to choose from an API after a button click. Writing a transformer allows us to think about which data you will need and when to load it.
  • State: holds the data shown on screen (a UI model). For example, storing the list of countries and currently selected country. Defining state is usually straightforward as they are usually a 1–1 mapping with what you see on screen.
  • Reducers: Takes transformers events or user actions and updates the State. Defining reducers forces us to think about the different states a screen can be in. For example, under what conditions would the user reach an error state?
  • Middleware: Wires the above classes together — listens for events and routes them to transformers and reducers. Persisted across process deaths inside a ViewModel. Defining a middleware allows you to think about how the screen should behave — given action x, behaviours y and z should take place sequentially. For example, “when the user clicks on a country, log some analytics, update the state and navigate to the previous screen”.
  • Renderer: Optionally, we have a Renderer class to render more complex lists of Item (an abstraction of RecyclerView.ViewHolder) based on our State. Again, this allows us to clearly define the different states that a screen can be in, thinking about how a loading screen, error screen or content screen should look.

At Babylon, we also have an internal Android Studio plugin which generates the above classes (and related test classes) for an orbit.

Given the design, I identified that I would need two orbits — we typically create one orbit stack (middleware, transformers, reducers and state classes) per screen. The output of an orbit is the State to be rendered on screen. This is how I imagined the feature would look like when translated into our architecture (and the resulting code was very close to this):

Figure 2: Phone widget architecture defined as two orbits
Figure 2: Phone widget architecture defined as two orbits

In Figure 2 above, we can see that I represented both the phone widget itself and the country code picker screen that it opens as two separate orbits. They are two separate pieces of UI, however, one of the widgets/screens is never used without the other, which couples them together. Upon observing this fact, I considered that the two screens could somehow share an orbit (and therefore share a State). However, I decided that they were sufficiently complex enough on their own to require their own orbit and State. This turned out to be a good decision because it helped me to delegate only a single responsibility to each class and place screen-specific business logic in the appropriate place while keeping each State object simple.

Considering that the two components are combined as one feature, the next logical step in my thinking was how should our two orbits communicate? I wanted the phone widget to also be able to react to country code picker state updates and side effects. One use case I had in mind was for the phone widget to know when the user had selected a country code on the picker screen so that it could update itself accordingly (by updating the country flag and phone code displayed). To solve this, I had both the phone widget and country code picker keep a reference to the country picker ViewModel and had both listen for updates. When the country code picker receives a new state and event, the phone widget will also be aware of it.

Consider How Your Code Will Be Used

Knowing how you will integrate your new feature into your existing app is important, though highly app-specific. While many features can be considered standalone or only to be used once, features such as our phone widget are intended to be used in multiple places. As a result, it can be crucial that we consider the surrounding context of our features through the lens of interoperability and reusability.

Having some knowledge about how your feature will be integrated is useful as it can influence its design and help shape its public API. Remember, there is a difference between knowing about its environment and actually tightly-coupling your software to its surroundings. The intention here is to make it reusable and easy integrate, rather than change your feature to only suit the surrounding software.

A good way of knowing if your code will be tightly-coupled is if you have written logic which depends on the outer surrounding. For example, if our phone widget needed to ‘reach out’ to a surrounding (specific) Activity to find something out, it would be considered tightly-coupled and not reusable. We are looking to have widgets which don’t require this type of code since we want to be able to render them anywhere.

Since most of the screens in the Babylon Health app are implemented as a RecyclerView with a list of Groupie Item, this determines how we now design our widgets. I had the prior knowledge that the entire phone component would need to be resolved to an Item in the sign-up screen RecyclerView and other potential screens (with a RecyclerView). This helped to shape the phone widget API because all we needed was a render() function which returns an Item to expose to our Activity, Fragment or View.

In the example below, we can see how we design our widgets to emit a new state when it is ready to be re-rendered. We then have a render function which takes that state and returns an Item to be included in whichever screen we are currently displaying:

class PhoneWidget {

fun connect(
activity: Activity,
stateConsumer: (PhoneWidgetState) -> Unit
) {
// Initialises ViewModels with stateConsumer listener
}

fun render(
state: PhoneWidgetState
): List<Item> {}
}

class SignUpActivity {

override fun onStart() {
super.onStart()
phoneWidget.connect(
activity = this,
stateConsumer = ::phoneWidgetStateConsumer
)
}

private fun phoneWidgetStateConsumer(
phoneWidgetState: PhoneWidgetState
) {
phoneWidgetSection.update(
phoneWidget.render(state = phoneWidgetState)
)
}
}

This pattern is very effective — what we essentially have is a list of self-sufficient components that are just being rendered by a parent list. By self-sufficient, I mean that they manage their own business logic, fetch their own data from the data layer, specify how to be rendered and are decoupled from their host. This also promotes reusability which is already paying off, as this phone component has now been used in multiple places inside the app with minimal ‘glue’ code required to integrate it.

Consistency vs Suitability

One of the benefits of enforcing the same MVI pattern everywhere is consistency. If we know that we implement all of our UI features using the same patterns, then that immediately gives us confidence in both debugging existing features and implementing new features. Consistent architecture across the codebase serves as documentation for future features. It gives me a wealth of code to use as a reference for my own feature. If I am tasked with debugging another developer’s work, I am immediately familiar with the flow of data and the general structure of the feature.

Now, there is an argument to make that you should always pick the most suitable design pattern for your specific task. To some extent, I agree with this — some features are much simpler than others and may benefit from a design pattern involving less code. However, I have also witnessed the benefits that consistency gives when you have a large team and lots of features. The familiarity that it provides to both new and existing developers has been invaluable.

An example of this is in the presentation layer of our codebase — as mentioned earlier in the article, legacy features use the MVP pattern while newer features use MVI. Typically, MVI in our codebase involves the creation of many classes required by an orbit. Now let’s say we wanted to introduce a new (simple) feature which has only one piece of logic and fetches one piece of data. Is the same design pattern (MVI) always the correct tool here?

There is no doubt that some features could be implemented in a simpler form if we choose a different pattern for each feature. This would certainly result in some features being much smaller, but what do we lose? There is a certain shared understanding that the team gains from being consistent.

Since we have a specific way of implementing features/screens, this gives us many benefits such as:

  • More focused PR reviews so that the reviewers can focus on the business logic with less ‘noise’ from the structure.
  • Easier transitions between different features for developers since they are all structured similarly.
  • Common expectations for testing, such as how to test features, which files to create and naming conventions.
  • Smoother onboarding of new team members as there is only one presentation layer pattern to understand.

Conclusion

Translating your designs into a suitable architecture can seem like a complex task. However, the process can be made smoother by visualising/drawing the feature out, referencing existing feature code, considering the surrounding context and agreeing on project conventions for developing features.

When it comes to conventions, we could make an argument for both consistency and suitability. What is most important is that it is agreed upon by your team as a shared understanding of how to write code is ultimately more important, especially when working on larger projects.

In the next part, we will continue planning our implementation by exploring how we can take our designs and translate them into code, so stay tuned!

At Babylon, we believe it’s possible to put an accessible and affordable health service in the hands of every person on earth. The technology we use is at the heart and soul of that mission. Babylon is a Healthcare Platform, currently providing online consultations via in-app video and phone calls, AI-assisted Triage and Predictive Health Assistance.

If you are interested in helping us build the future of healthcare, please find our open roles here.

Follow Babylon Engineering on Twitter @Babylon_Eng and the author of this article @DarrenAtherton.

Babylon Engineering logo

--

--