Building Freetrade

Mobile architecture at Freetrade

Joana Ferreira

October 28, 2021

Joana Ferreira

Hey, we have hidden a small bug in the article. If you spot it, we'd love to talk! Reach out to Luke.

This article talks about how we build mobile architecture at Freetrade and shares some of our best practices. 

When I first joined Freetrade, I had no mobile experience. This guide became very important, very quickly as I learned the mobile development ropes.

It was written by my onboarding mentor Alex Curran, who was a lifesaver, so I thought we might as well share it with the world!

How the Mobile team rolls at Freetrade

At Freetrade we build our native apps using Kotlin/RxKotlin for Android and Swift/RxSwift for iOS.

While both apps have a similar architecture, we do it this way to make it easier for developers to transition between them and keep our product consistent across platforms.

Before diving into the article, I would suggest checking out:

  • ReactiveX: this site has a brief introduction if you’re not familiar with RX 
  • Basic Kotlin and Swift syntax

As you go through this guide we’d suggest you jump between the different sections so that you can fully understand how the components interact with each other.

Freetrade’s architecture

Our architecture is loosely based on patterns such as  MVVM and Clean Architecture, so if you are familiar with these, you might find some similarities.

Let's start by looking at this diagram to understand the overall architecture of our apps:

Our mobile architecture

 

If we had to caption the first part of the diagram in a few words, we could say:

The ViewModel "stamps" a ViewState onto the View every time the underlying data changes. 

Views 

I like to think of View as the ‘closest’ component to the user. These are things like Activities or Fragments in Android, and Views or View Controllers in iOS.

Views should generally be responsible for:

  • Laying out views
  • Binding the ViewModel to its required inputs (button clicks, instrument IDs, etc)
  • Binding the ViewModels’ ViewState onto the screen

Ideally, Views should be basic and have minimal business logic, as they are hard to test. We tend to move as much of the logic as we can to ViewModels for this reason.

As a general rule, this is the only layer that should be dependent on anything specific to Android or iOS.

ViewStates

These are simple data classes or structs which contain data to display on the UI. They should have no state (i.e. they are never mutated).

ViewStates in action >>

data class TwrrLegendViewState(
   val yourInvestmentPercent: String,
  @ColorRes val percentColor: Int,
   val benchmarkPercent: String,
  val subtitle: String
  )
Portfolio performance screen


ViewModels

Probably the chunkiest bit of our code. ViewModels generally transform data into something the user can see and understand. They do this by creating view states that are passed on to the Views.

As an example:

In the app, you can see a button to switch the account. In the ViewModel (RateOfReturnViewModel below), we find out what accounts the user has and if they have more than one, this ViewModel tells the View Layer to show an account switcher button.

ViewModels in action >>


            data class TwrrLegendViewState(
    val yourInvestmentPercent: String,
    @ColorRes val percentColor: Int,
    val benchmarkPercent: String,
    val subtitle: String
)

@HiltViewModel
class RateOfReturnViewModel @Inject constructor(
    // ....
) : ViewModel() {

    val twrrLegendState = BehaviorRelay.create()
    val timeframes = BehaviorRelay.create>()

    private val timeframe = BehaviorRelay.create()
    private val selectedPoint = BehaviorRelay.create>()
    private val compositeDisposable = CompositeDisposable()

    init {
        val insights = timeframe
            .distinctUntilChanged()
            .flatMap { timeframeValue ->
                activeAccountInsights(timeframeValue)
            }
            .replay(1)
            .autoConnect()

        val twrrInsights = insights
            .mapSuccess { it.twrr }
            .share()
            .replay()
            .autoConnect()

        Observables.combineLatest(
            twrrInsights.successes(),
            selectedPoint.startWith(Optional.empty())
        )
            .map { (insights, selectedPoint) ->
                insights.toLegendViewState(selectedPoint.asNullable)
            }
            .distinctUntilChanged()
            .subscribeDefaultingError(twrrLegendState)

    }
}


ViewModels should be easily testable, so should be tested! They expose streams for the view layer to consume, so we can use them in unit testing.

Routers

These abstract the iOS or Android specific logic of moving from screen to screen away from our architecture, making it easy to test.

Routers can contain business logic like ViewModels but only if it relates to navigation (e.g. moving to different screens depending on some logic).

It’s a good idea to make sure that if routers listen to a database or do an API request, they let the UI know they are doing so. That way the user is aware something is going on behind the scenes. This can then be translated into a loading status, by other layers.

Be careful of using Observables in Routers. Listening to a stream to understand where to route a user to is common but you must be careful to only take one value from the stream. Otherwise, any changes to the data in the backend would trigger a new navigation!

Handling navigation between screens

On Android, you can use the registerForActivityResult/launch code to pass data from screen to screen.

For iOS, make sure your ViewController has callbacks in its initialiser, which it will call once it has completed doing whatever it needs to. The router can then route based on that result.

It's a good idea to avoid the ViewController knowing about the routers they're "contained" in because this makes it harder to reuse them. 

Service layer

This is a thin layer that transforms inputs from various external sources (Realtime Databases, APIs, etc) into validated domain objects that can drive business logic.

Any object that is emitted from a Service should be fully valid.

Classes in this layer are often called Services, or sometimes on iOS, Managers.

Final thoughts

There are a few reasons why we do it this way. 

It helps us keep the code testable and consistent between platforms. 

ReactiveX can become very messy, very quickly, but I’ve found that thinking about these layers tends to help me understand where to emit and observe values. With time, these decisions became more intuitive. 

While this architecture means that we have more layers to set up and maintain, we’ve also found that it helps us scale the apps and ramp up new engineers quickly, especially for those without strong mobile experience when joining Freetrade - like me!

Where you come in

Did you spot the bug? If you did don’t forget to get in touch with Luke

If you’d like to learn more about our mobile team at Freetrade or our mission to get everyone investing, get in touch. 

We're always on the lookout for engineers to help build our platform and mobile apps. ‍

If you want to help build a world where everyone is investing, come join the fun at freetrade.io/careers.



Sign up for our newsletter

Download the app and start
investing now.