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.
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
.
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.
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.
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
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
.
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.
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
.
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
- Modular iOS Architecture @ Just Eat https://tech.just-eat.com/2019/12/18/modular-ios-architecture-just-eat/
- Dagger https://github.com/google/dagger