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
, completebuilds
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:
- 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.
- 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!
More Stories
From Blueprint to Reality: The Story Behind iOS Development Boom
Will JavaScript containers overtake Linux containers?
How To Resolve CFNetwork DLL Mistakes On Your Laptop or computer – “CfNetwork Is Missing” Error Deal with