Wallapop Android architecture journey

Oriol Tobar
Inside_Wallapop
Published in
11 min readMay 24, 2024

Introduction

The Android version of Wallapop was released in 2013, a time when there was not Kotlin, Coroutines, Jetpack… and not even architecture guidelines!

It was a time when developers just wrote the code inside the Activity and used AsyncTasks to perform asynchronous operations. Some people even tried to apply their own architecture to their projects but in general there were no guidelines, rules or principles widely adopted by the community.

During those years the Android ecosystem improved heavily with the appearance of libraries such as RxJava, ViewBinding and the first architecture guidelines were proposed with the introduction of MVP (model-view-presenter). This pattern was first popularised after Fernando Cejas posted an article about how Architecture should be in Android Architecting Android…The clean way?.

There were other patterns like MVI or MVVM but they were more niche compared to MVP. This trend changed around 2018 when Google released the Android ViewModel which was the perfect match to build MVVM based apps.

Figure 1. Android architecture in Google trends

At Wallapop we have been growing over the years which means that new Android developers joined the company so we needed to define common agreements and architecture to avoid having a massive app with hard-to-maintain code.

MVP era (2016–2022)

The first architecture adopted by the team was, of course, MVP. This led to the creation of the Agreements, which was the bible that every developer needed to know in order to create good quality code (maintainable, testable).

At least this was our main goal but if the reader has worked with Android/MVP they will notice that having an architecture helps to improve the code but also creates new problems such as:

  • Boilerplate code.
  • Complex Dagger graphs.
  • Asynchronous problems (first with RxJava and later on with Coroutines/Flow).
  • Inconsistent UI states (due to lifecycle problems most of the times).

On the other hand the benefits:

  • Organised and homogenous code.
  • Each class/component has a single responsibility.
  • Relatively easy to do tests (thanks to assertions and mocks).
  • Reusable code.

The photograph of Wallapop in the MVP era (2017–2022) looked like:

  • Multi module: Modules for each feature and some common modules used by the rest.
  • Multi activity: Each view had its own Activity → Fragment → Custom views.
  • Java and Kotlin (since 2018).
  • Vanilla Dagger as DI provider. We introduced Dagger-Anvil on 2021.
  • RxJava for asynchronous operations which was migrated to Flows once they were stable.
  • XMLs views: first with the old findViewbyId and later on with ViewBinding.
  • Classic Android stack: Retrofit, Realm, Glide and other libraries widely used by almost every big company.

Each feature module followed the classic separation of layers:

Figure 2. Layers in a feature module
  1. View and presenter communicate with each other on every user interaction.
  2. Presenter communicates with the use cases using flows.
  3. Use cases communicates with commands and/or repositories.
  4. Command are an in-house definition, referring to use cases used only by other use cases, not presenters. The goal of this class is to split big use cases into smaller pieces of code to reduce complexity and make those components reusable across other use cases.
  5. Repositories communicates with data sources.
  6. Data sources can be either local or cloud.

This separation of layers is pretty common, especially if the reader comes from the Android world, most of the companies used a similar approach.

Problems with MVP

Model-view-presenter was a great step forward in terms of good practices but it also brought issues to our project. This issues were related on how we implemented MVP in the app and the interaction with third party libraries.

  • UI can have inconsistencies because the presenter can call different methods from the view without restrictions.
  • No internal state, view and presenter are always recreated and wasting resources as a consequence.
  • Testing always with mocked views so we rely on physical testing to assert UI elements are correct.
  • Huge increase in team size during 2021–2022 and the architecture didn’t scale as expected.

Additionally, it does not work well with Compose since MVP is not exposing streams of data to update the view.

Research and investigation

During 2022 we wanted to propose a new architecture to fit the current ways of working in the Android world, where the most important one was the usage of Jetpack Compose (this library follows the reactive programming principles).
One important detail is that the scope of the proposal was tied to the presentation layer only as we had limited bandwidth to implement the new proposal so we had to make a trade-off for the first iteration.
We decided to maintain domain and data the same as it is shown in Figure 2. and iterate the solution in the future based in the feedback from the team.

As it probably happened in a lot of companies, we wanted our architecture to be reactive using unidirectional data flow (UDF), a technique that growth in popularity in the latest years. The idea behind this concept is simple:

In unidirectional data flow the data follows a one-way path through the code. This data is inmutable and can only be modified by certain actors such as the reducer.

The main benefits of this technique is ensuring that the data safely travels across the app instead of having multiple actors modifying the data during the interactions (single source of truth).

Figure 3. Unidirectional data flow

As it can be seen in Figure 3, the unidirectional data flow basically consists in one-way communication through the app.

For example:

  1. The user clicks on a button on the screen.
  2. The interaction is propagated and it may trigger a side effect such as a call to cloud.
  3. The data state is updated and the change is reflected on the screen (i.e.: show dialog, navigate to next screen, etc).

The proposal

After some investigation about how to implement UDF in Android we started with the most basic approach which is to use MVVM pattern with ViewModel (by Google) and Flows to communicate between layers. At that time Google was proposing to use several flows inside a ViewModel in charge of the different parts of the screen.

This did not convince us since having a code like the following:

class ItemsViewModel(): ViewModel() {
val isLoading = MutableStateFlow<Boolean>(true)
val error = MutableStateFlow<Exception?>(null)
val items = MutableStateFlow<List<Item>?>(null)
...
}

May cause inconsistencies in the UI as it happened with MVP since error, items and isLoading are not part of a single state that controls the whole UI and can be desynchronised easily. We soon moved towards to a single state, where state will contain all the necessary data to render the screen:

class ItemsViewModel(): ViewModel() {
val state = MutableStateFlow<ViewState>(ItemScreenState.initial)
...
}

data class ItemScreenState(
val isLoading: Boolean,
val items: List<Items>,
val error: Exception? = null
) {
companion object {
val initial = ItemScreenState(
isLoading = true,
items = emptyList(),
error = null
)
}
}

At this point we extracted some conclusions and questions to be addressed:

  1. Each ViewModel will need a StateFlow in which the View will be subscribed to receive state updates.
  2. Each ViewModel will also need a SharedFlow to notify the View about elements that should be displayed but does not modify the state. E.g.: a snackbar message
  3. Is dependency with Google’s ViewModel needed?
  4. How to persist the state after a process death, activity destroyed or configuration change?
Figure 4. Architecture proposal

Figure 4 displays the proposal after solving the previous questions:

  1. Extract the common code into a class, this is how the Store concept was born. This class contains both the StateFlow and SharedFlow in which the View will subscribe on in order to update the UI.
  2. Discard Google’s view model in order to use our own view models and create our own mechanism to persist the state.
  3. The mechanism to persist the state is called Time Capsule (inspired by Badoo’s amazing MVICore library -Saving/restoring Feature state — MVICore-). It is responsible for persisting the State and restoring it after a process death, a configuration change or an Activity being destroyed.

The internals

This section gives a more in-depth technical view about the core elements of the proposal: ViewModel — Store — State/Event.

Initially we thought about adding Google’s ViewModel because on one hand it brings useful utilities like built-in coroutine scope, persistence mechanism and lifecycle awareness but on the other hand it makes the injection more complex and testing harder since for example, the built-in coroutine scope needs to be configured in each test (at least at that time).

Additionally, after some debate we thought about the possibility of this class being deprecated in the future or suffer important changes so we decided to not use it.

Classic Google

We decided to just create our ViewModels like we did with the presenters but instead of having a contract to interact with the View, it will have a Store that will expose the flows in which the View will subscribe to update the UI.

Figure 5. ViewModel diagram

As it can be seen, there is not a relationship View ↔︎ ViewModel as there was with MVP. ViewModel does not know anything about the view.

The Store is the cornerstone of the architecture and it holds the Flows in charge of making UDF possible. Additionally it can have interceptors and an optional instance of a Time Capsule.

Figure 6. Store diagram

Figure 6. depicts the anatomy of the Store:

  1. Interceptors are a man in the middle which are used to update the time capsule and testing purposes on every update. Basically, they perform any logic that needs to be executed when changes are happening in the store.
  2. Time Capsule is optional since not all views need to survive configuration changes.

Finally, it is the turn for States and Events which are the chunks of data that are being sent in our UDF-highways.

STATE represents the different possible stages a UI can render, for example, Uninitialized, Loading or ItemsDisplayed.

sealed class ItemScreenState {
data object Loading : ItemScreenState()
data object Error : ItemScreenState()
data class Loaded(val uiModel: ItemScreenUiModel) : ItemScreenState()
}

EVENT represent single-shot operations that can happen in the UI, for example, CloseScreen, ShowSnackbar, Navigate.

sealed class ItemScreenEvent {
data object CloseScreen : ItemScreenEvent()
data object NavigateToHelp : ItemScreenEvent()
data class ShowSnackbar(val message: StringResource) : ItemScreenEvent()
}

Compose

As mentioned in the introduction, Compose is one of the main reasons to switch to the new architecture since it is made following reactive programming principles.

In our early days our approach was to create: Activity → Fragment with XML → Custom views (if needed) but in Compose we decided to drop the Fragments and keep: Activity → Composable.

We adopted the guideline to create all the new features with Compose and migrate the old views as much as possible. The activities are subscribing to the flows exposed by the ViewModel’s store and updating the UI accordingly:

setContent {
val currentState by viewModel.store.state.collectAsStateWithLifecycle()
ItemsView(
state = currentState,
...
)
}

Useful tip!

We started using collectAsState() in our Activities but after reading this article from Manuel Vivo (Consuming flows safely in Jetpack Compose) we decided to use collectAsStateWithLifecycle to listen to the latest state updates with a lifecycle awareness perspective.

Another interesting improvement that Compose brought is that we discovered Paparazzi (GitHub — cashapp/paparazzi: Render your Android screens without a physical device or emulator) which allowed us to create effortless snapshot tests for our UIs. It also works with XML as well but at Wallapop we are only using it for Compose.

We totally recommend it!

Testing

In the first iteration of the proposal we created some internal tooling and a handy DSL in order to test the view models as we did with presenters, i.e.: unit tests with mocked dependencies and verifying the state using jUnit.

As it is mentioned in the Store section, we included interceptors in it in order to be able to intercept each new state. This is specially useful for testing since we can record all the states in which the ViewModel has been and then assert it with the expected values.

In 2024 we implemented a new kind of test called Integration Tests. The goal of these tests is to remove the dependencies with mocks and test the whole stack until the data layer.

We needed to build a new tooling system for this kind of tests in which we used Dagger to provide the fake dependencies.

Figure 7. Integration test scope

The two main differences with our previous ways of working with unit tests are:

  1. Instead of using mocks we decided to use fakes (Test double). This dependencies are feed in the GIVEN block using mother objects (bliki: Object Mother) which are reusable pieces of code across the different tests.
  2. To assert the states we are relying in KotlinSnapshot from Karumi (GitHub — pedrovgs/KotlinSnapshot: Snapshot Testing framework for Kotlin.) which with the help of the interceptors records the output of the test into a json file.
    So instead of having to declare the whole part for the assertion we just need to record it. This recorded file is used to compare subsequent executions of the test.

An integration test looks as simples as:

@Inject
lateinit var itemService: FakeItemService
@Inject
lateinit var testContext: TestContext

@Test
fun `GIVEN success cloud call WHEN view is initialised THEN render item information`() =
testContext.integrationTest(viewModel.store) {
itemService.push(ItemsMotherObject())

viewModel.initView()

assertAllWithSnapshot()
}

Lessons learned

To wrap up with this journey we would like to talk about the lessons learned during the process of creating a new architecture. For us there are some important learnings:

  1. Unidirectional data flow is a change of mindset compared to MVP (reactive vs. imperative) so we decided to simplify the proposal after receiving feedback from the team to make the change less abrupt.
  2. Instead of simplifying the proposal we could have dedicated more time in trainings since the simplification brought some drawbacks that affect complex screens.
  3. The creation of the testing tools and the DSL made the transition easier since it simplified the testing with the flows.
  4. The usage of flows in overall had a positive impact in our ways of working but sometimes we still have weird issues hard to reproduce due to interoperability with our legacy code or weird emissions and collects. We are in a continuous learning process to better understand the internals of Coroutines and Flow.

Next steps

As we all know, Android is a tough ecosystem, we need to deal with lifecycles and a myriad of different devices and OS customisations so we are aware that we do not have a perfect and unique solution for every possible case.

We are working every day to improve our code and quality and the next steps are:

  1. Iterate the presentation layer and start thinking about how can domain layer be improved.
  2. Improve how we handle complex and big screens. One option could be to add a reducer in the picture for this kind of screens.

And that is all! If you enjoyed this article please share your thoughts in the comment section. We would like to know how other companies are doing!

--

--