Android Architecture for the Rocketship - Part 1 : Modularisation

We discuss our approach to modularising the Afterpay Android app in our journey to build a strong and scalable mobile platform, to support our business growth.

Android Architecture for the Rocketship - Part 1 : Modularisation

Photo by Chris Kursikowski on Unsplash

By: Huan Nguyen


Eight months ago at Afterpay, we kicked off our “app rewrite” project in which we are rewriting our React Native apps in native Android and iOS. As part of this project, we are not only aiming at building an app, we are also aiming to build a strong and scalable mobile platform which supports our fast-growing business.

Modularisation isn’t a new concept. In fact, it has been used extensively in various software systems with the goal of dividing the system into smaller, more manageable and maintainable sub-systems. In this article, we discuss our approach to modularising our Android app to best suit our needs.


Motivation

We identified the following key business problems to solve from the architectural perspective.

  1. How do we deliver features quickly at a large scale? This is surely the goal of any team and any organisation. However with a fast-paced business like Afterpay, this is even more critical. The business will expand, new products and services will be established, new teams will be formed, more code will be written. It is extremely important for us to support smooth parallel feature development without much toe stepping between developers working on the same codebase. We need to be able to move as quickly as possible while maintaining a high level of code quality. It can be fairly manageable with a small team. We, however, need to be prepared for the future when our mobile team is scaled to hundreds of developers.
  2. How do we support clear and simple ownership of features across teams? At Afterpay, teams are currently structured based on a chunk of inter-connected features such as Shop, In-store etc. The way the platform is built should not only support this current arrangement, but also allow for flexible team division/structure that may happen in the future. Being able to define clear code ownership is critical for custodianship and code maintenance in the long term.
  3. How do we efficiently reuse the components we’ve built in other apps in the business? In a fast-growing business like Afterpay, we should also be prepared for the possibility that new apps would be launched alongside the existing one. Since apps from the same company would likely to operate within the same domain, our components and features (login, registration, analytics, etc.) should be built in a way which allows them to be consistently and easily imported into another app.

Design Goals

To address the above problems, modularisation is the key. However, how do we modularise our app? What does a “module” mean in our architecture? How do such modules work together? To answer exactly these questions, we’ve defined the following design goals.

G1. Single responsibility. Every module should have a single responsibility. Some key challenges developers usually face in a modular app is determining in which module a certain piece of code should reside and cyclic dependency between modules. Ensuring each module has a single responsibility helps avoid such issues. Moreover, it helps us see clearly what the role of a certain module in the app is. We use the following heuristic to ensure a module doesn’t take on too much responsibility: “By only one simple sentence we should be able to tell what a module is for”. If such a sentence gets lengthy and complicated, it is a sign that the module is holding too much responsibility and needs to be split up. A few examples: Login module provides a capability to log users in, Analytics module provides an interface for the app to send tracking data. This, on the one hand, ensures the separation of concern between modules and on the other hand, helps with the code ownership assignment.

G2. Clearly defined APIs. Every module should expose clear interfaces that define how it is consumed/interacted with (e.g., What are the dependencies and inputs? What are the outputs?). A consuming module should not know about the implementation details of the module it consumes. This ensures that changes in the implementation details of a certain module would not affect other parts of the app (unless its public interfaces are changed) and thus minimising regression bugs.

G3. Tech stack agnostic. A module should be importable to another app without architectural constraints or technology requirements. For instance, we should not enforce an app that consumes our module to use Dagger or other DI frameworks like Koin etc. Similarly, the second app should not need to copy the architecture of the original app in order to use a certain module.

G4. Parallel feature development. In order to move fast, we should be able to build and test features efficiently and in isolation. For instance, if two developers are working on different (but can be related) features such as Order and Profile. Once the technical plans have been worked out and agreed, each of the developers should be able to work on the end-to-end features independently. Manual or automated functional testing (e.g., Appium tests) of the features should also be able to happen in parallel/isolation. This aims at supporting the business to smoothly scale in both the numbers of teams/developers and features.

Our Approach

Module Hierarchy

We define 3 levels on which modules can be placed, depicted by the following figure.

At the lowest level are the core modules which provide the common utility methods which benefit other modules on the higher levels. These modules mostly contain Android/Kotlin extension/util methods to improve developer's efficiency. They are not specific to the Afterpay domain.

Service modules are those that provide the foundational services (not to be confused with Android's Service component) to the entire app. For instance, Localization is concerned with everything related to localised experiences such as getting string resources specific to the selected country or the country's specific dial code. UserPreferences provides a common interface for storing and retrieving user preferences (e.g., did the user enable biometric-based login) across the app.

Feature modules are those that provide actual user-visible “features” in the app. Each of them can be considered a “flow” of screens which share a common goal. For instance, Registration flow's goal is to allow users to register an account while ProfileCreation supports users to fill in profile details as part of the onboarding process. Each feature flow is itself atomic, in a sense that it always conforms to the single responsibility rule discussed above. Moreover, each feature module should only expose a single public method to launch its series of screens. This helps ensure the focus of the module is always clear and at the same time prevent the module’s responsibilities from unmanageably growing. As an example, the following RegistrationLauncher interface below is exposed to “launch” the Registration flow.

interface RegistrationLauncher {
  fun launch(parent: FlowContainer, navInstruction: NavInstruction, input: I?)
}

Each flow however can “use” one more more flow internally if it requires functionalities from the other flows. We’ve built a Flow Navigation architecture which provides an easy and consistent pattern to “plug” a certain flow to any other flow and thus maximise their reusability. More details on our Flow Navigation will be discussed in a later post. Stay tuned!

Dependency rules

We enforce the following rules regarding the dependencies between modules.

  • Each dependency always goes sideway or from top to bottom. Lower-level modules never depend on higher-level modules.
  • Service modules generally do not depend on each other. However there can be some exceptions that some lower-level services are relied upon by other higher-level services.

What does a module look like?

To achieve the discussed design goals, every single feature/service module in the app is structured into Gradle sub-modules as follows (inspired by this awesome talk from Square).

:api

Contains public interfaces and models that define how to use/interact with the feature/service. For instance:

  • The dependencies the feature/service needs to work (the way dependencies are injected differ between feature and service modules. Details will be discussed in the next article on our Flow Navigation pattern).
  • How the feature/service is invoked
  • How the result (if any) from the feature/service is obtained

An example “interface” of a service module which is defined in its :api sub-module looks like.

interface AnalyticsManager {
  fun setup(dependencies: AnalyticsDependencies)
  fun setUserId(id: String)
  fun trackAction(action: ActionEvent)
  fun trackImpression(impression: ImpressionEvent)
}

:impl

Contains the implementation details of the feature/service. Consumers of the module should never import this sub-module. This restriction helps avoid tightly coupled implementation details. It allows feature/service’s implementation to change without affecting the consumers’ code, unless the public interfaces have to also change.

:wiring

Allows consumers to access the feature/service. Since the implementation details of the feature/service is hidden away from the consumer, :wiring provides the consumer with access to the implementation via the abstraction (the public interfaces).

A typical “wiring” implementation looks like below, with the Wirer defined as:

object AnalyticsWirer {
  fun wire(dependencies: AnalyticsDependencies): AnalyticsManager =
    AmplitudeAnalyticsManager(EventParser()).apply { setup(dependencies) }
}

AnalyticsWirer is tech stack agnostic. If we use Dagger in the main app, AnalyticsManager is injected as follows.

@Module
class AnalyticsModule {

  @Provides
  @Singleton
  fun analyticsDependencies(app: Application): AnalyticsDependencies = TODO("create AnalyticsDependencies")

  @Provides
  @Singleton
  fun analyticsManager(analyticsDependencies: AnalyticsDependencies): AnalyticsManager =
    AnalyticsWirer.wire(analyticsDependencies)
}

:fakes

:fakes sub-module is only applicable to service modules. The idea is to provide a set of fake implementations for the public interfaces that the module exposes. These fakes can be used in the unit and UI tests of the module’s consumers to swap out dependencies. The fakes can also be used in :demo apps (discussed below)to avoid relying on the real implementation of dependencies and instead focus on what the module should do.

:demo

:demo sub-module is for creating a sample/sandbox app which shows how to use/integrate with the module under development. It can be used in a number of scenarios:

  • Showcase what the module can do (e.g., login-demo showcases how the Login flow works)
  • Allow parallel feature development. In fact, in a large team with multiple features being under development at the same time, it’d be helpful to isolate the development of features and only integrate it to the app when completed. :demo apps help in such a situation since a simple app can be built with the sole purpose of testing the feature and how it is meant to be integrated to the app. Moreover, working on :demo app in isolation means developers can avoid building the whole app (which could take significant time if the codebase gets very large).
  • Feature UI testing. We could test features in isolation using the demo apps, avoiding the need of writing a whole bunch of code to navigate to a specific flow in the real app while the key concern is only to test that flow.

There were some key concerns when we started this modularisation journey.

Are we over-engineering? Why do we need these sub-modules?

Surely decoupling API, implementation and wiring logic is by no mean a new concept and certainly we could achieve the same level of separation of concern with a single module in some way, e.g., have the single module containing all the code and rely on function/variable scopes (public/internal/private) to control what is visible to the consumer.

However, there are multiple benefits we found with this module structure. First, it provides us with a consistent framework to plan for every single module. For instance, every time when building a module we’d always start with the public interfaces that it needs to expose. In a sense, we would always define what the module is for and what it provides before working on its implementation. Second, having a separate :api module provides a quick and consistent way to obtain a holistic view of the responsibilities of each module. Third, thanks to the sub-module structure, implementation details can be swapped easily when necessary (basically by changing the wiring code in the :wiring module) e.g., if different app flavours require pretty much different implementations. Moreover, this structure allows the fake implementations to be attached to the module and easily importable to tests or demo apps.

It may not be fun to set up modules using this structure.

As developers we like doing cool stuff and it is certain that not many of us enjoy manually creating at least three sub-modules every time we build a new module. Due to the atomicity of modules, creating new modules is not a rare task for us. Like everything else in life, if we don’t enjoy doing something manually, we’d automate it! In fact, we wrote a Kotlin script to help us set up the modules with just a single command.

What did we learn?

We’re in the ninth month in this project and feedback from the team has been very positive. In fact, the team believes our architecture helps us move fast due to the high level of separation of concern between modules. Additionally, our Kotlin scripts help a lot by automating the initial module set up and thereby our developers only need to focus on the actual logic. Moreover, we’ve also gained the benefit of a general modular app such as faster build time etc.

Although this is a great start for us, we’re still at an early stage in the journey of building a strong mobile platform at Afterpay and thus haven’t been able to validate how our architecture works at a larger scale. Moreover, like everything else, architecture will need to keep evolving to support new requirements and address new challenges. We will continue sharing our stories in future posts!

Sounds Interesting?

Interested in joining us in a Software Engineering role and helping us improve lives for millions of consumers around the world, every day? Consider joining our team.