Modularisation in the Just Eat Android Consumer Application

In this blog post I am going to share an insight into the technical approach to app modularisation of a monolithic Android app and how we use Dagger in a Gradle multi-module setup.
This article does not aim to describe best practices of modern modular application development since this is subjective to how old your application code is, this is more about the patterns and approaches we currently use and if you are a heavy Dagger user like us maybe it could take your monolithic application to the world of modular also.
If you want to know more about the benefits and other useful info regarding a modular application my colleague Alberto De Bortoli from the iOS team here at Just-Eat wrote a great blog post on that here https://tech.just-eat.com/2019/12/18/modular-ios-architecture-just-eat/.

What is in a feature module?

A typical feature module at Just Eat is a gradle module with an activity (sometimes more than one) that provides a logical slice of the application such as Home, Search, Help, Menu, Checkout and Payment.
These types of features we consider core features, usually whole screens of functionality and we have feature teams that maintain them. The following component diagram shows an example of two feature modules, feature-checkout and feature-payment that both share a core gradle module and the main application module justeat-app that depends on both features.
modular-android-diagram-1
In our app we have a core module to share top level dependencies that are frequently used throughout the app such as the okhttp dependency and any other core dependencies that we want to consistently provide to the rest of the application specially those dagger dependencies that we scope as @AppScope dependencies.
Taking a look inside our core module, we have a single dagger component – AppComponent.
modular-android-diagram-2
AppComponent has the scope annotation @AppScope which allows us to provide core dependencies once (app wide singletons) which is useful in some scenarios where some third party API’s impose it for performance gains or some other reason (OkHttp, etc)
We hang a number of dagger modules from this component to provide these scoped (and other non-scoped) dependencies.
modular-android-diagram-7
Our feature modules pretty much have the same setup, they are mostly geared around an activity, or several activities, sometimes a single activity and many fragments.
modular-android-diagram-3
In the diagram we show that the module feature-checkout contains an Activity CheckoutActivity and its dagger counterpart CheckoutActivityComponent which depends on AppComponent.
This allows us to share those @AppScope dependencies downstream to our feature modules so they can benefit from dependencies that are provided from above.
Also our feature module’s dagger component is annotated with @FeatureScope which allows features to retain dependencies once they are provided to our feature code effectively giving us a singleton like scope on some dependencies until a configuration change occurs.

A practical example

This is all fair and well to show as conceptual diagrams however there are some practical concerns that can only be demonstrated with code examples, in particular how we can make our feature components such as CheckoutActivityComponent gain a reference to AppComponent to fulfill its dependency.
In a nutshell we create a singleton reference to AppComponent which we initialize in our application, where our app component looks something like this.

@Component
@ApplicationScope
interface AppComponent {
    @Component.Builder
    interface Builder {
        @BindsInstance
        fun application(application: Application): Builder
        fun build(): AppComponent
    }
    fun application(): Application
    companion object {
        lateinit var instance: AppComponent
        fun init(application: Application) {
            instance = DaggerAppComponent
                            .builder()
                            .application(application)
                            .build()
        }
    }
}

Then in a custom Application class we can initialize our core DI component.

class ExampleApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        AppComponent.init(this)
    }
}

Now for our downstream components (specifically feature components) it is a simple matter of gaining a reference to AppComponent.instance and passing it to our feature component.
The following example shows a simplified dagger component for our example feature module feature-checkout.

@Component(dependencies = [AppComponent::class])
@FeatureScope
interface CheckoutActivityComponent {
    @Component.Builder
    interface Builder {
        @BindsInstance
        fun activity(activity: Activity): Builder
        fun appComponent(component: AppComponent): Builder
        fun build(): CheckoutActivityComponent
    }
}

Then in our Activity we can bootstrap the feature component and set its parent component

class CheckoutActivity : AppCompatActivity() {
    private lateinit var component: CheckoutActivityComponent
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        component = DaggerCheckoutActivityComponent
            .builder()
            .appComponent(AppComponent.instance)
            .build()
    }
}

And that is the most basic practical example of the approach we use in our modular application at Just Eat.
In reality our app is far more complex, has many modules hanging off AppComponent and each feature component such as CheckoutActivityComponent also have local dagger modules that provide dependencies on the feature level.

When Feature X wants to depend on Feature Y?

We won’t allow this. Our modular approach does not always start out with the best intentions of sharing common code especially in situations where say, Feature X wants to access data where access is only implemented in Feature Y.
Generally we do not allow features depending on features. The approach we use is to identify what code from the other feature we want, and move that up above both feature modules.
Referring back to our first diagram
modular-android-diagram-1
If feature-payment wants to reuse code from feature-checkout then we would move that code up into an API module such as checkout-api.
modular-android-diagram-5
In this case our checkout-api does not declare a dagger component in order to include its functionality though can include a dagger module that provides dependencies in a way that might be useful for other downstream feature modules to include, alternatively those feature modules can create the dependencies themselves.
In some cases where you would need to include checkout-api everywhere to every module then you could provide it from the core component.
modular-android-diagram-6
In the diagram we show that checkout-api has been moved up to core. Downstream modules will now receive dependencies from checkout-api through AppComponent given that it includes a CheckoutModule that provides checkout-api dependencies.
Another thing to note is our API style modules we like to keep pure, no UI, no activities or fragments, typically they provide data and contain things like repositories and networking code, effectively the back-end code of features.
Similarly if a feature has UI code that we want to share with other features, we could follow the same approach and create a checkout-ui module that we can include to downstream feature-* modules.

Dealing with a large number of core APIs and Services

In our core module we have more than a few dependencies provided by our core component AppComponent. For instance we have a number of other modules such as network, analytics and logging which we provide through our core component AppComponent, the following diagram provides an overview of our core gradle module dependencies as well as their respective dagger modules which we include into our core AppComponent.
modular-android-diagram-4

Conclusion

This article has briefly described the modularisation approach we use in our Android Application at Just Eat. With the advent of new technologies like Jetpack Compose and the fact that the development community have trended toward single activity applications this approach may no longer be a good starting point. However this post could still serve as a means to move from a monolithic activity per feature based application into a modular one, especially if you are invested in Dagger.

References