Techblog

Android App Modularization with Kotlin-DSL and KOIN

Von Andreas Spindler
5. March 2021

In this article, I would like to present a way on how to configure a multi-module project using Gradle Kotlin-DSL and the service locator library KOIN. I assume you are already familiar with modularization, Kotlin-DSL and KOIN. In case you're not, I recommend the following articles as introductions:

Our three Challenges

As we started our new project, we were in the comfortable situation that we didn't get a legacy project to refactor. It was something completely new, we were standing on a green field. And what do you do in a green field project? You use the latest development approaches. In our case, this was a multi-module project with Gradle Kotlin-DSL and KOIN as service locator.

Side note: We decided against Dagger 2 because all of our devs didn't like the annotation magic, Dagger comes with. KOIN is much simpler to use and easier to understand, making it our tool of choice for decoupling classes from concrete dependencies.

When setting up the project as a multi-module project, we faced three main challenges with Kotlin-DSL and KOIN:

  • Challenge 1: How to avoid as much redundant configuration as possible in the build.gradle.kts for each module with Kotlin-DSL?
  • Challenge 2: How to use KOIN to setup the dependency tree in a multi-module project?
  • Challenge 3: How to have all the environment configurations in the app module and still be able to use them in the modules?

I will address each of these challenges in turn.

Challenge 1: Avoid as much redundant configuration as possible in the build.gradle.kts

In a multi-module project we usually have one app module and several android-library modules, each with their own gradle.build (gradle.build.kts in case of Kotlin-DSL) files. Having multiple gradle files brings a lot of redundant code. Think about the android {..} configuration block where you have to configure you sdk versions, compile and test options, buildTypes etc. in each gradle file. With Groovy, this would have been particularly easy: We extract everything to a separate file, e.g. base.gradle, and simply apply it to the build.gradle where needed:

  1. //base.gradle
  2. android {
  3.     defaultConfig {
  4.         applicationId "com.company.app"
  5.         minSdkVersion 23
  6.         targetSdkVersion 30
  7.     }
  8.     compileOptions {
  9.         sourceCompatibility JavaVersion.VERSION_1_8
  10.         targetCompatibility JavaVersion.VERSION_1_8
  11.     }
  12. }
  13.  
  14. //app:build.gradle
  15. apply from: "../base.gradle"
  16. android {
  17.     signingConfig {
  18.         //..our signing config
  19.     }
  20. }
  21. //resulting app:build.gradle
  22. android {
  23.     defaultConfig {
  24.         applicationId "com.company.app"
  25.         minSdkVersion 23
  26.         targetSdkVersion 30
  27.     }
  28.     compileOptions {
  29.         sourceCompatibility JavaVersion.VERSION_1_8
  30.         targetCompatibility JavaVersion.VERSION_1_8
  31.     }
  32.     signingConfig {
  33.         //..our signing config
  34.     }
  35. }

The content of our base.gradle will be merged with the content of the build.gradle. It's as easy as that.

Unfortunately, this doesn't work with Kotlin-DSL.

Kotlin-DSL is strongly typed, and thus we cannot simply use the android { } configuration block without any context.

Solution: Share configuration via a Gradle plug-in

Luckily, it's very easy to write a Gradle plugin. And this is the way we will solve this challenge and have a single file (actually, it's a class), where we can configure our shared android configuration.

Creating the Gradle plug-in

In our buildSrc module, create a java folder and add a new package gradle. Inside this package, create a new Kotlin class, let's call it AndroidBasePlugin. It should look like this:

The AndroidBasePlugin class must be a subclass of Plugin<Gradle>. As such, you have to implement a method fun apply(target: Project). The apply method is called during the project evaluation phase of Gradles build lifecycle. Within our target project, we have to get the android extension in order to configure it. There are several specific extension subclasses representing specific types of modules, e.g. an app, android-library, a dynamic-feature module and so on. But all those share a common base class called BaseExtension. The first thing we do, we get the current "android" extension and double check that it's a BaseExtension. Once we have it, we can simply apply our commonly shared configuration.

  1. class AndroidBasePlugin : Plugin<Project> {
  2.     override fun apply(target: Project) {
  3.         val extension = target.extensions.getByName("android")
  4.         if (extension is BaseExtension) {
  5.             extension.apply {
  6.                 // here we can do our configuration that goes into
  7.                 // the android {} block
  8.                 compileSdkVersion(30)
  9.                 buildToolsVersion(30.0.2)
  10.  
  11.                 defaultConfig {
  12.                     minSdkVersion(23)
  13.                     targetSdkVersion(30)
  14.                 }
  15.                 //and so on        
  16.             }
  17.         }
  18.       //you could even add common dependencies here
  19.     }
  20. }

Register the plug-in

Next, we need to register the plug-in. This is done in the build.gradle.kts of our buildSrc module. Simply add the following config block:

  1. gradlePlugin {
  2.     plugins {
  3.         register("android-base-plugin") {
  4.             id = "android-base-plugin"
  5.             implementationClass = "gradle.AndroidBasePlugin"
  6.         }
  7.     }
  8. }

Now, the plug-in is ready to be used in other gradle files.

Use the plug-in

The final step is to use the plugin in all the build.gradle.kts files you like to apply the default configuration. Simply add the plug-in with it's id to the plugins{} block:

  1. //e.g. app:build.gradle.kts, feature:build.gradle.kts, etc.
  2. plugins {
  3.     id("com.android.application")
  4.     kotlin("android")
  5.     kotlin("android.extensions")
  6.     `android-base-plugin`  //<= here it is
  7. }

 

That's it! We now have all the shared android configuration from our plug-in in our modules.

Challenge 2: How to use KOIN to setup the dependency tree in a multi-module project?

In software development you usually end up having some kind of layered architecture. For instance, you might have a presentation layer, domain layer, data layer, etc. As we started with project setup we had two options of modularization: by layer or by feature. Both come with pros and cons. As we had no idea what kind of feature to expect, we decided to create our modules by layer. So, we ended up having these modules: app, repo, data and networking. The dependencies are:

Of course we wanted to reflect this dependency graph in our KOIN modules as well. We didn't want the app module to know about e.g. the data or network module. Unfortunately, it's not possible to create this kind of dependency with your KOIN modules. Because, in your Application class, you must initialize KOIN and provide an array of your modules so KOIN can create the object graph:

  1. fun onCreate() {
  2.   startKoin {
  3.     androidContext(this@Application)
  4.       androidLogger(Level.INFO)
  5.       modules(
  6.         listOf(
  7.           //your modules
  8.         )
  9.       )
  10.    }
  11. }

In KOIN, there is no way to reference a module from another module, unfortunately. There were two options:

  • Option 1: Create static "initializer" class for each module, to add the depending modules to KOIN
  • Option 2: Let the app module know all existing modules

Solution: Breaking the rule

This was definitely one of the most emotionally discussed decisions, the team had to make. In the end we decided for Option 2:

  1. fun onCreate() {
  2.   startKoin {
  3.     androidContext(this@Application)
  4.       androidLogger(Level.INFO)
  5.       modules(
  6.         listOf(
  7.           appModule,
  8.           domainModule,
  9.           repoModule,
  10.           dataModule,
  11.           networkModule
  12.         )
  13.       )
  14.    }
  15. }

Wait! The app module knows about the lower layers?

There are quite a few discussions on whether the app module should know about the data and networking module in a clean code architecture on Reddit. We decided that the app module does know about all other modules, although it indeed feels a little strange. But the benefit we got regarding higher flexibility and configurability was worth the decision. So far, we haven't experienced any downsides or issues with this approach. If it turns out to be a misconception, I'll definitely write another article about it.

Challenge 3: How to have all the environment configurations in the app module and still be able to use them in the modules?

All apps I have developed over the years communicate with a backend. But while still working on the code for a feature you don't want your app to talk with the production servers. Usually, a "dev environment" and maybe several more environments are available for development, testing and integration purposes.  Say, we have three environments: dev, test and prod.  Let's also say, we have three modules: networking, analytics and authorization. The networking module needs different baseUrl, the analytics and authorization modules need different API-Keys for each environment. You would usually create different productFlavors with a certain flavourDimension in each of the modules build.gradle.kt, something like this:

  1. android {
  2.     flavorDimensions("environment")
  3.     productFlavors {
  4.         create("dev") {
  5.             dimension = "environment"
  6.             applicationIdSuffix = ".dev"
  7.         }
  8.         create("test") {
  9.             dimension = "environment"
  10.             applicationIdSuffix = ".test"
  11.         }
  12.         create("prod") {
  13.             dimension = "environment"
  14.         }
  15.     }
  16. }

Then, you either add a config.xml with the configuration values (baseUrl, api-keys, etc) in the respective "flavour folders" of your project structure or let your build pipeline provide the more critical values during the build. However, we would have had the same config at least three times. It is definitely worth a discussion in the development team how you want to structure flavours in your project, because there are pros and cons if you want to have the module config in the app module or separated in their own modules. We decided, that we want it in the app module and rather configure the individual modules. The challenge was: How do we get the config into the KOIN modules? If you read the KOIN documentation on modules, a module is defined as global val.

  1. // in module networking
  2. val networkingModule = module {
  3.   //Retrofit needs a baseUrl
  4.   single {
  5.     Retrofit.Builder()
  6.        .baseUrl(baseUrl)
  7.        //other config stuff
  8.        .build()
  9.        .create(MyApi::class.java)
  10.   }
  11. }
  12.  
  13. //in app:MyApplication class
  14. fun onCreate(){
  15.   startKoin {
  16.     androidContext(this@MyApplication)
  17.     androidLogger(Level.DEBUG)
  18.     modules(
  19.       listOf(
  20.         networkModule
  21.         //our other modules
  22.       )
  23.     )
  24.   }
  25. }

Solution: Think outside the documentation

The solution is so obvious, that we didn't see it at first. No one said, that you have to use a val to provide your module. You can also simply create a fun that returns your module:

  1. // in module networking
  2. fun getNetworkModule(
  3.   val baseUrl: String
  4. ) = module {
  5.   //Retrofit needs a baseUrl
  6.   single {
  7.     Retrofit.Builder()
  8.        .baseUrl(baseUrl)
  9.        //other config stuff
  10.        .build()
  11.        .create(MyApi::class.java)
  12.   }
  13. }
  14.  
  15. //in app:MyApplication class
  16. fun onCreate(){
  17.   startKoin {
  18.     androidContext(this@MyApplication)
  19.     androidLogger(Level.DEBUG)
  20.     modules(
  21.       listOf(
  22.         //getString() gets the flavor/buidType specific string, in this case
  23.         // the baseUrl from the `config.xml`
  24.         getNetworkModule(getString(R.string.baseUrl))
  25.         //our other modules
  26.       )
  27.     )
  28.   }
  29. }

There you are! Your now gained full flexibility to configure your KOIN modules. Nice side effect: You can also separate your KOIN module within your project module into multiple modules and load them in this new function by simply calling loadKoinModules(otherModules) at the beginning of your function, before calling the module{} builder.

Conclusion

So far, we are very satisfied with this setup. Since we set up the project about 6 months ago, at no point have we felt like we had to change anything. Since then, we have also added some new modules. Our chosen setup has proven to be very flexible and has not limited us in any way so far. I would most likely set up the next project in a similar manner.

Thank you for reading! I hope I was able to inspire you, or even help you with solving similar challenges.

Add new comment

Public Comment form

  • Allowed HTML tags: <a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd><p><h1><h2><h3>

Plain text

  • No HTML tags allowed.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Lines and paragraphs break automatically.

ME Landing Page Question