06/10/2024

Fabrikant Tech

Tech Specialists

Make a monorepo for your Android projects

Make a monorepo for your Android projects

This post describes how you can share code between your side projects without code duplication, remote hosting or versioning. Create your own Android monorepo repository using the Gradle build system and some wizardry!

What is a monorepo?

In version control systems, a monorepo (“mono” meaning ‘single’ and “repo” being short for ‘repository’) is a software development strategy where code for many projects is stored in the same repository.

https://en.wikipedia.org/wiki/Monorepo

As developers, we can have many (unfinished) side projects and a lot of the time we are doing the same things over and over again. Most apps need logging, most apps hit network endpoints, most apps load images etc etc.

Our side projects usually involve copy and pasting around code, then in the latest version modifying that code to improve it more (or creating a library and hosting it). Sometimes I’ll then go back to an old project and have to copy paste back my updated features. Other times I’ll forget the awesome feature I added in the last project, (such as exponential back off for network requests) and the new side project won’t be as “fancy” in that area.

A monorepo can help. When starting a new side project, if you make everything a module, you include the modules you want and you are then up and running and concentrating on what’s new about this new side project, instead of scaffolding and reinventing your personal wheel every time.

Without further ado, let’s get started.

I think it’s best to start by looking at the end, from what we want to achieve, set that expectation and then work towards it. All code is available in the Git Repo here. This is the example layout from the GitHub Repo. It’s what you’ll have when you use a monorepo:

/monorepo
    /mono-libraries            << Shared app(s) code
        /logging
        /http
    /mono-build-logic          << Shared build code
        /android-plugins
        /android-k-plugins
        /tut-app-1-plugins
        /tut-app-2-plugins
    /tut-app-1                 << Specific app code
        /app 
        /libraries 
    /tut-app-2                 << Specific app code
        /app  
        /libraries 

Now you understand how a real world example will look. We can take a look what the project structure will be with some explanatory naming. Hopefully seeing both will let you understand and map between to the two when doing it for yourself. This is what our monorepo folder structure will look like, as well as the location of the Gradle files involved, when it’s complete:

/monorepo
    /mono-libraries                << Shared app(s) code
        /shared-library-1
        /shared-library-2
        /shared-library-N
            build.gradle.kts
    /mono-build-logic              << Shared build code
        settings.gradle.kts
        /shared-plugins
            build.gradle.kts
            /src/main/kotlin/plugin-shared-android.gradle.kts
        /side-project-1-plugins
        /side-project-2-plugins
        /side-project-N-plugins
            build.gradle.kts
            /src/main/kotlin/plugin-side-project-N-app.gradle.kts
    /side-project-1                << Specific app code
    /side-project-2                << Specific app code
    /side-project-N                << Specific app code
        build.gradle.kts
        settings.gradle.kts 
        /libraries
        /app
            build.gradle.kts   

For comparison this is what a ‘typical’ side project single app project would look like:

/side-project
/app
build.gradle.kts
/module1
/module2
/moduleN
build.gradle.kts
build.gradle.kts
settings.gradle.kts

We can see that modules in a solo side-project becomes shared-libraries in a monorepo. The Gradle build logic usually in app & moduleN folders moves to its own ‘project’. In Gradle speak this is known as a Composite Build, and is the crux of how your monorepo will work.

A monorepo starts with a root folder, the root folder holds 3 things:
1) Side Projects
– each of the apps (side projects) you have worked/working on (1 folder each)
2) Gradle builds logic
– the build logic for our monorepo & apps (1 folder with subfolders for each app)
– shared build logic modules between apps
3) Shared Libraries
-the libraries you want to share between projects (1 folder for each library)

A composite build is simply a build that includes other builds. In many ways a composite build is similar to a Gradle multi-project build, except that instead of including single projects, complete builds are included.

https://docs.gradle.org/current/userguide/composite_builds.html

The rest of this blog post is quite a long explanation (we’re going to be creating/modifying 12 Gradle build files in total!) but stick with it and at the end you will have a monorepo that you can use again and again for every side project. Alternatively, if you just want to get going; fork the GitHub repo, scroll to the bottom, read the conclusion and you can be up and running straight away, skipping the step by step understanding. 🙂

Prerequisite: Folder Structure

Create a folder called monorepo, Inside this folder (i.e. in the root monorepo folder) create another folder called mono-libraries and another called mono-build-logic. Now we can start to fill out these folders with Gradle configuration files.

/monorepo
    /mono-libraries
    /mono-build-logic
    

If you want to add this monorepo to version control make sure you call git init from the monorepo folder and not from any of the subfolders.

1) Side Project creation

Use the Android Studio wizard to start a new project (or do it however you usually create a new project). Move this project inside of your folder called monorepo and this is where we will start from. From now on we’ll call this tut-app-1.

/monorepo
    /mono-libraries
    /mono-build-logic
    /tut-app-1
        /app
        /libs
        gradlew
        build.gradle
        ...etc

IMPORTANT NOTE
With a monorepo, the idea is to have one instance of the IDE (Android Studio) open per side-project (per app). The IDE works with “gradle roots” and we’re going to make each side project app a Gradle root. i.e. if your monorepo has two projects and you want to work on both, then you should have two instances of your IDE running.
(You technically can work on both in the same IDE instance, but only one will be on the classpath and therefore only one will be compiling/have IDE assistance. I would see this as a good thing, as you are not compiling code you are not working on; saving yourself compile time.)

/tut-app-1/settings.gradle.kts

Edit /monorepo/tut-app-1/settings.gradle.kts and inside the pluginManagement block add an includeBuild method call, this is called a Composite Build by the Gradle build system and will allow us to separate our Android build logic from the Android apps we are making. The includeBuild will reference our mono-build-logic folder. Like so:

pluginManagement 
    repositories 
        gradlePluginPortal()
        google()
        mavenCentral()
    
    includeBuild("../mono-build-logic")

Next we’ll have our project include our shared-library-1 (think http or logging from the Git repo). This is what you’d do normally with the include method, and you can see the app module is included like that. For a monorepo the libraries have to be included slightly differently, but if you create a helper method (monoInclude) it can easily be achieved. Like so:

include("app")
monoInclude("shared-library-1")

fun monoInclude(name: String) 
    include(":$name")
    project(":$name").projectDir = File("../mono-libraries/$name")

We’ve declared that we want to build monorepo/mono-libraries/shared-library-1 but we haven’t created that module yet. That happens later on (below).

That’s the /monorepo/app/settings.gradle file done. Next is to declare the tut-app-1‘s project build file.

/tut-app-1/build.gradle.kts

The project build file does not have any changes out of the ordinary for a monorepo setup, except for one. Here we will declare a project extra property for the compose_version. This will allow us to reference the version number for compose in all dependencies in our app later on. If you where to take your monorepo to the next level, you’d likely move this versioning into the mono-build-logic.

buildscript 
    extra.apply 
        set("compose_version", "1.0.5")
    

    repositories 
        google()
        mavenCentral()
    
    dependencies 
       classpath("com.android.tools.build:gradle:7.0.4")
       classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31")
    

That’s the /tut-app-1/build.gradle.kts file done. Next is to declare our app module build file, whilst delegating most the work to our mono-build-logic.

/tut-app-1/app/build.gradle.kts

This file is usually where your app’s build logic is, however we are going to be delegating that to the mono-build-logic project with the idea of reducing duplication and repetition, didn’t I just say that?

Your file ends up looking like this:

plugins 
    id("tut-app-1")


dependencies 
    val implementation by configurations
    val debugImplementation by configurations

    implementation(project(":logging"))

    val composeVersion = rootProject.extra["compose_version"]
    implementation("androidx.core:core-ktx:1.7.0")
    implementation("androidx.appcompat:appcompat:1.4.1")
    implementation("com.google.android.material:material:1.5.0")
    implementation("androidx.compose.ui:ui:$composeVersion")
    // + other dependencies; see git repo
    debugImplementation("androidx.compose.ui:ui-tooling:$composeVersion")

Notice that we don’t declare a dependency on the id("com.android.application") plugin like a usual stand alone application would. We’re going to use our own plugin tut-app-1 this will help reduce duplication; and we will declare the dependency on the android plugin in there (no magic, just movement!).

To have the tut-app-1 module depend on the shared-library-1(i.e. logging module in the Git repo), we use the implementation(project( nested method call. Like so:

dependencies 
 ...
 implementation(project(":shared-library-1"))
 ...

Notice that the dependencies block uses:

val implementation by configurations

This is because we have moved the Gradle build logic from the app to the composite build plugins (to avoid duplication in the monorepo, keep reading it is shown next) and as such, the Gradle Kotlin DSL File can’t know at script compilation time that these configurations will be available when the script is applied. Hence the Kotlin extensions (implementation()) for these configuration not being available at script compilation time. You have to reference the configurations by name at runtime.

That’s the /tut-app-1/app/build.gradle.kts file done and that is also the tut-app-1 setup complete as well. Next is to recreate the build logic we have omitted from this file, by creating plugins in our mono-build-logic project.

/monorepo
    /mono-libraries
    /mono-build-logic               < UP NEXT
    /tut-app-1                      < COMPLETE
        build.gradle.kts
        settings.gradle.kts
        /app
            build.gradle.kts
            /src

2) Sharing Gradle Build Logic

Our build logic project is where we can declare how our build runs. (the good news is this section is a one off, so its a lot but we won’t need to do it again!) We want to declare that our project uses Android, that it’s an app and has libraries available, and we declare the usual things like minSDK version and application version etc.

Think of this build logic folder as a project in itself (because it is), it gives us the capability to make individual Gradle plugins and have each of our app project modules use these plugins. As such we will have a plugin for Android Libraries, a plugin for Android Apps and then app specific plugins that can then customise this shared build logic for the individual app. Makes sense? Doesn’t make sense? Read on to find out more.

First thing for this build logic project is to create the file structure. We already have a mono-build-logic folder. Inside that folder make three sub-folders, naming them android-plugins, android-plugins-k, tut-app-1-plugins. These are going to be the modules in this project.

/monorepo
    /mono-libraries
    /mono-build-logic
        /android-plugins
        /android-plugins-k
        /tut-app-1-plugins
    /tut-app-1

/mono-build-logic/android-plugins/build.gradle.kts

First up, create a new file in the android-plugins folder called build.gradle.kts. This is the file for declaring how this module (android-plugins) is built. For this module we are going to use the Groovy language for the plugin, the rest of the monorepo is all in the Kotlin language. We use Groovy here as it has a much more powerful reflection syntax, so we can configure the Android build system without having to depend on the Android plugins ourself. id("groovy-gradle-plugin") enables us to write Groovy Gradle plugins. Your file should end up looking like this:

plugins 
    id("groovy-gradle-plugin") // This enables src/main/groovy


dependencies 
    implementation("com.android.tools.build:gradle:7.0.4")

That’s the /mono-build-logic/android-plugins/build.gradle.kts file done. Now we have a project to create groovy plugins in. Next is to declare a Groovy plugin in this module that will setup the defaults of all our Android builds.

/mono-build-logic/android-plugins/src/main/groovy/android-module.gradle

Create a android-module.gradle in the android-plugins/src/main/groovy folder (you need to create that path). This is going to be a script plugin that forms the basis of every Android module in any side project (app) that’ll be included in the monorepo.

When you have script plugins like this, they use their filename as the name when referencing them by id. ie. using this plugin would look like id("android-module") because the filename is android-module.build.kts.

As you can see below, we’re using afterEvalute to check if the project (module) this plugin is being attached to is an Android project. If it is we configure the android closure with all the defaults we don’t want to keep duplicating in every app.

We are using a Groovy plugin here as it has a much more powerful reflection syntax, so we can configure the Android build system without having to depend on the Android app or library plugins directly (because we want it to work for both).

afterEvaluate { project ->
    if (project.hasProperty("android")) {
        android 
            compileSdk 31

            defaultConfig 
                minSdk 28
                targetSdk 30

                vectorDrawables 
                    useSupportLibrary true
                

                testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
            

            testOptions 
                unitTests.returnDefaultValues = true
            
            composeOptions 
                kotlinCompilerExtensionVersion compose_version
            
            packagingOptions 
                resources 
                    excludes += "/META-INF/AL2.0,LGPL2.1"
                
            
            compileOptions 
                sourceCompatibility JavaVersion.VERSION_11
                targetCompatibility JavaVersion.VERSION_11
            
            kotlinOptions 
                jvmTarget = "11"
                useIR = true
            
            buildFeatures 
                compose true
            
        
    }
}

It’s entirely up to you what defaults you have here, I would say start with this and migrate / overwrite properties as you find a specific differentiation in a certain app (such as minSDK).

That’s the /mono-build-logic/android-plugins/src/main/groovy/android-module.gradle file done. We now have a reusable plugin available that declares some rudimentary shared android properties. Next we will create another shared plugin project, so that we can declare individual plugins that can be used specifically for an android library or android application module.

/mono-build-logic/android-plugins-k/build.gradle.kts

We’ve made the android-plugins build logic project, now we need to create another project android-plugins-k. Create a file called build.gradle.kts inside of the /mono-build-logic/android-plugins-k/ folder. The only difference will be this module houses Kotlin script plugins and the previous module housed a Groovy script plugin.

This project (module) depends on the kotlin-dsl plugin and this will allow us to write Kotlin script plugins. Notice we have also added an api dependency on our Groovy android-plugins module. This allows the current module to access the plugins of the latter module and to make them available to any dependent projects.

plugins 
    `kotlin-dsl` // This enables src/main/kotlin


dependencies 
    api(project(":android-plugins"))
    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31")
    implementation("com.android.tools.build:gradle:7.0.4")

That’s the /mono-build-logic/android-plugins-k/build.gradle.kts file done. Now we have a Kotlin project to create plugins in. Next is to declare two Kotlin plugins in this module that will setup the defaults for our Android library & Android app builds.

/mono-build-logic/android-plugins-k/src/main/kotlin/app-android-module.gradle.kts

Create the /src/main/kotlin folder structure and inside create a file called app-android-module.gradle.kts. This will be our plugin that we use for every Android app. We will declare specifics to do with app modules here, but we will not have specifics to do with any one particular app yet, that comes next.

This plugin depends on the android-module plugin we have already created. It also declares dependencies on com.android.application (because this plugin is expected to be used for android applications), and kotlin-android (because we are using Kotlin in our apps).

The plugin then declares two build types for any android app that will depend on it. The plugin ensures the release build type has minification turned on and the debug build has “debug” added to its application id (i.e. install package).

plugins 
    id("android-module")
    id("com.android.application")
    id("kotlin-android")


android 
    buildTypes 
        getByName("release") 
            isMinifyEnabled = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        
        getByName("debug") 
            isDebuggable = true
            applicationIdSuffix = ".debug"
        
    

That’s the /mono-build-logic/android-plugins-k/src/main/kotlin/app-android-module.gradle file done. We now have a reusable android application plugin available that declares app module specific shared android properties. Next we will create another shared plugin, this time for shared android library modules.

/mono-build-logic/android-plugins-k/src/main/kotlin/library-android-module.gradle.kts

Alongside the app plugin, inside src/main/kotlin, create a file called library-android-module.gradle.kts. This will be our plugin that we use for every Android library module. We will declare specifics to do with library modules here, but we will not have specifics to do any one particular library – that would be in the shared library itself.

This plugin depends on the android-module plugin we have already created. It also declares dependencies on com.android.library (because this plugin is expected to be used for android library modules), and kotlin-android (because we are using Kotlin in our apps).

We don’t have any other extra configuration in this plugin for the library modules.

plugins 
    id("android-module")
    id("com.android.library")
    id("kotlin-android")

That’s the /mono-build-logic/android-plugins-k/src/main/kotlin/library-android-module.gradle file done. We now have a reusable android library plugin available that declares library module specific shared android properties. Next we will create a specific plugin for our tut-app-1 side project to depend on.

/mono-build-logic
        /android-plugins                      << COMPLETE
            android-module.gradle
        /android-plugins-k                    << COMPLETE
            app-android-module.gradle.kts
            library-android-module.gradle.kts
        /tut-app-1-plugins                    << UP NEXT

/mono-build-logic/tut-app-1-plugins/build.gradle.kts

Create a new file in the tut-app-1 folder called build.gradle.kts. This is the file for declaring how this module (tut-app-1) is built. We’re going to use this module to create an app specific build script, allowing us to separate the build logic into its own separate testable project and simplifying the side-project-1‘s Gradle file. Notice we have also added an implementation dependency on our Kotlin android-plugins-k module. This allows the current module to access the plugins of the latter module (and its api declared groovy module).

plugins 
    `kotlin-dsl` // This enables src/main/kotlin


dependencies 
    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.31")
    implementation("com.android.tools.build:gradle:7.0.4")
    implementation(project(":android-k-plugins"))

That’s the /mono-build-logic/tut-app-1-plugins/build.gradle.kts file done. Now we have a build logic project to create an app specific plugin in. Next is to create the plugin itself for tut-app-1.

/mono-build-logic/tut-app-1-plugins/src/main/kotlin/tut-app-1.gradle.kts

Create a new file tut-app-1.gradle.kts inside of src/main/kotlin/, here is where we will depend on the app-android-module (because this plugin is for Android app modules).

We set up the details that are specific to this side-project (app). Therefore we declare what the installation applicationId is and the app version.

The rest of the Android build logic is handled up stream in the plugins: app-android-module and android-module (which we configured previously). If you are wondering, the only thing left to declare (coming up soon) is the dependencies block. This will be handled in the side project (app) itself.

plugins 
    id("app-android-module")


android 
    defaultConfig 
        applicationId = "com.blundell.tut1"
        versionCode = 1
        versionName = "1.0.0"
    

That’s the /mono-build-logic/tut-app-1-plugins/src/main/kotlin/tut-app-1.gradle.kts file done. Now we have an app specific plugin that allows us to abstract the complexity of building the project from building the app. Next is to update the settings.gradle.kts file to enable this project (mono-build-logic) to build each of the modules (projects) we have just created.

/mono-build-logic/settings.gradle.kts

Now let’s enable all the above modules we have create to be recognised and compiled by Gradle in this project. Create a settings.gradle.kts in the /mono-build-logic root folder. Once created, sync your IDE and it will recognise all the modules declared.

dependencyResolutionManagement 
    repositories 
        gradlePluginPortal()
        mavenCentral()
        google()
    


include("android-plugins")
include("android-k-plugins")
include("tut-app-1-plugins")

That’s the /mono-build-logic/settings.gradle.kts file done and that is also the mono-build-logic setup complete as well. Next is the final part, creating shared libraries that will be available to your side projects (apps).

/monorepo
  /mono-shared-libraries            << UP NEXT
  /mono-build-logic                 << COMPLETE
        settings.gradle.kts
        /android-plugins
            build.gradle.kts
            /src/main/groovy/android-module.gradle
        /android-plugins-k
            build.gradle.kts
            /src/main/kotlin/app-android-module.gradle.kts
            /src/main/kotlin/library-android-module.gradle.kts
        /side-project-1-plugins
        /side-project-2-plugins
        /side-project-N-plugins
            build.gradle.kts
            /src/main/kotlin/tut-app-1.gradle.kts
  /tut-app-1                        << COMPLETE

3) Sharing libraries

Use the Android Studio wizard to create an Android Library project (or move one that you already have), ensure it is created in the /mono-repo/mono-libraries folder. From now on we’ll call this shared-library-1 (in the GitHub repo example we have a http and a logging module; they work exactly the same way).

/monorepo
  /mono-shared-libraries            << COMPLETE
      /shared-library-1

/mono-libraries/shared-library-1/build.gradle.kts

I’m sure you get the pattern by now. First up we have to enable this module to use the Gradle build system. Create a build.gradle.kts file inside of shared-library-1.

Here we’re wanting to have our module build an Android Library therefore we depend on our newly created library-android-module plugin. This allows the targetSDK, testOptions, compileOptions, packingExcludes etc etc to be inherited from our shared-build-logic plugins.

The only thing left to do for every library module now is to declare what dependencies you want to use. As below:

plugins 
    id("library-android-module")


dependencies 
    val implementation by configurations
    val testImplementation by configurations

    implementation("androidx.core:core-ktx:1.7.0")
    // + other dependencies; see git repo
    implementation("com.squareup.okhttp3:okhttp:4.9.1")

    testImplementation("junit:junit:latest.release")

That’s it! That’s the monorepo/mono-libraries/logging/build.gradle.kts file written, and I hope you can see that that is also the template for any libraries you want to create or add in the future. Fore each shared library, it is a one line addition to the build file to depend on our library-android-module plugin, declare your dependencies and you are up and running.

/monorepo
  /mono-shared-libraries            << COMPLETE
      /shared-library-1
          build.gradle.kts
          /src/main/
  /mono-build-logic                 << COMPLETE
  /tut-app-1                        << COMPLETE

Recap

You now have a side project tut-app-1 with a concise build.gradle.kts file that only declares its dependencies and then delegates via Gradle plugins for the rest of the Gradle configuration.

tut-app-1 depends on the app-android-module plugin.

tut-app-1 depends on our shared libraries using a declaration such as: implementation(project(":shared-library-1"))

The shared-library-1 shared library module, matches the simplicity of the app build.gradle.kts file, in that it only declares its dependencies and then delegates via Gradle plugins for the rest of the Gradle configuration.

shared-library-1 depends on library-android-module.

app-android-module declares any build configuration specific to your application build (such as the versionCode).

library-android-module declares any build configuration specific to all shared libraries (such as a constant build config field).

app-android-module and library-android-module both depend on the android-module plugin.

Finally the android-module plugin declares all shared Gradle configuration that is typically duplicated between side-projects / apps and modules.

Conclusion

Whilst the initial setup of a monorepo is more in-depth than a single project. Once it is setup, it gives you a powerful mechanism for sharing code between projects and for separating the responsibilities of build configuration from app development.

A couple of things to be wary of with monorepo’s:

  1. With a monorepo, the idea is to have one instance of the IDE (Android Studio) open per side-project (per app). The IDE works with “gradle roots” and each side project app is a Gradle root. i.e. if your monorepo has two projects and you want to work on both, then you should have two instances of your IDE running.
  2. Your shared libraries are shared between apps. This means if you change the public API in one of your libraries, the other apps using that API will be broken and will need fixing. This isn’t so much of a problem if you can fix projects as you open them or ignore your old broken projects. An alternative solution is, when you find a shared library is used and updated a lot between projects; it is a good candidate to be separated into its own build and versioned for release.

I hope you’ve enjoyed this walk through, all code is available on the GitHub repo and any feedback is always appreciated!