Gradle Modularisation
As a project grows in size and complexity, and build times start becoming a source of frustration, the topic of modularisation invariably arises.
In this post, we’ll explore how to choose the right modularisation strategy for your project and the pitfalls to avoid. We'll delve into the benefits of modularisation, such as improved build times and enforced architectural boundaries, and weigh them against potential downsides like increased build complexity. Additionally, we'll discuss practical alternatives to modularisation and offer guidance on how to effectively structure your modules to best suit your project's needs. Whether you’re just starting a new project or considering restructuring an existing one, this guide will help you make informed decisions about modularisation.
A Brief Explanation of Modularisation
Typically, an app starts with a single module, :app
. All of the code lives together, and we can organise it by separating code into different packages.
:app/
/src/main/kotlin/com.myapp.package/
data/
domain/
ui/
A modular app is one which contains many modules. The exact number and types of modules depends on your project. Here’s a simple example:
Benefits of Modularisation
Improved Build Times
Any change to the code in the :app
module requires the entire module to be re-built. If your project contains annotation processing, for things like Hilt or Room, this can start to impact build times.
Organising code into distinct modules allows you to make changes without rebuilding everything. It also makes it possible for parallel builds - so different parts of your app can be compiled at the same time.
Improving build times is one of the most compelling reasons to modularise a project.
Enforcing Architecture
The second reason to modularise an Android project, is to enforce architectural boundaries. You can use modularisation to prevent certain pieces of code from being accessed by other pieces of code.
For example, if you’re following the principles of Clean Architecture, you might want to make it impossible to access the data
layer from your presentation
layer. You could implement a module structure like so:
With a structure like this, it’s impossible to lead :data
related code into the :presentation
module, and vice-versa.
Other Benefits
There are some other minor benefits of modularisation:
- Improved code separation can help to reduce merge conflicts
- Modules can be turned into libraries, and shared with other projects
Downsides
Modularisation increases build complexity. Developers need to understand which code belongs in which module, and the interdependencies between modules.
Modularisation can reduce discoverability of code. Is this piece of code part of data, or domain? Does it belong to a specific feature, or is it shared?
Alternatives to Modularisation
Before diving off the deep end and creating a bunch of modules, consider whether there are other levers you can pull first:
Improving Build times
There are other tools for improving build times:
- Do you rely on annotation processing, for Room, Hilt or your serialization library? Have you moved from Kapt to KSP?
- Is your
gradle.properties
file configured correctly? Are you allocating enough RAM to the JVM? - Are you using the configuration cache?
- More tips at developer.android.com
Enforcing Architecture
While it is possible to use Gradle Modularisation to enforce architectural boundaries, it’s not necessarily the only tool, or even the best tool for this purpose.
Konsist
Konsist is a tool for Kotlin that can help to enforce architectural rules. It can be used to ensure that certain layers of your architecture only depend on specific other layers: You write unit tests to define the architectural boundaries of your project:
class ArchitectureTest {
@Test
fun `ui layer should not depend on data layer`() {
Konsist.assertThat()
.module(":ui")
.shouldNotDependOnModule(":data")
}
}
ArchUnit
ArchUnit is a similar tool for Kotlin and Java
class ArchitectureTest {
@Test
fun `ui layer should not depend on data layer`() {
val importedClasses: JavaClasses =
ClassFileImporter().importPackages("com.example.app")
noClasses()
.that()
.resideInAPackage("..ui..")
.should()
.dependOnClassesThat()
.resideInAPackage("..data..")
.check(importedClasses)
}
}
Dependency Injection
It’s also possible to enforce architectural boundaries via dependency injection tools like Hilt. For example, you can define interfaces in your domain
layer, and implement those interfaces in the data
layer. Then, using Hilt’s @Binds
, ensure that only the interfaces (not the concrete implementations) are exposed to other layers. However, this approach is complex and brittle, and not advised.
Other Modularisation Alternatives
It’s tempting to argue that modularisation helps ‘clean up’ your codebase, and organises it into something better. But any code organisation you can do via modularisation can more easily be done via packaging. The benefit of using packages is you don’t have to worry about managing dependencies. All the code can access all the other code.
Should I Modularise My Project?
Now that we understand the pros and cons, and we’ve talked about some alternatives, let’s consider if modularisation is right for you.
A wise friend said recently:
If I was to start this project again today, I would just use a single module
Initially I found that confronting. Historically, I tend to start projects really granular. I’ve decided on a modularisation strategy before I’ve really considered the needs of the project. I like Clean Architecture, I like to prevent data
from leaking into presentation
. I like the structure of NowInAndroid, even just on an aesthetic level. And, I like the challenge that comes with modularisation, trying to find the right home for code, and enforcing architectural principles.
But, after giving it lots of thought, they were totally right. Why create a bunch of modules, and introduce the complexity of managing their dependencies in Gradle - finding the right home for code, and having to move code into new, shared modules - before you’ve considered the needs of the project.
Factors to Consider
When deciding on a modularisation strategy, there are a few factors to consider:
The size of your team & complexity of your codebase
If you only have a handful of developers, or you’re developing a small app, prototype or MVP, you probably don’t need modularisation. If you’re not suffering from slow build times, then modularisation just adds complexity and slows you down.
The maturity of your team & codebase
All teams have different strengths and weaknesses. Your team might not have much experience with the Gradle build system. Or they’re not used to worrying about dependency management. Modularisation might improve your build times, but it could cost you much more in development time. You might need a couple of team members who are dedicated to managing the build system. Even then, it might be time to look for other easy ways to clean up and improve the build system. Consider other ways to improve build times. Maybe you need to migrate from Groovy to Kts first. Perhaps dependency management will be simplified with version catalogs or Gradle Convention Plugins.
Current build times
Are build times actually a problem for you? Have you measured it yet? What are the main causes? How much developer time are you losing to Gradle builds? Think about other ways to improve build times before reaching for modularisation.
Your architectural principles
If your goal is to enforce architectural boundaries through modularisation, it helps to understand where those architectural boundaries should be. Has your team agreed on an architectural approach? Are you using Clean Architecture? Event-driven architecture? Have you documented this somewhere? Ultimately you need to define a set of architectural principles before you can implement them.
None of this is to say modularisation is bad or scary. It’s just something that should be considered and planned before being implemented.
How to Modularise My Project?
OK, so you want to improve build times and potentially enforce some architectural principles. The question is, how should the modules be structured?
This really depends on the factors mentioned above - particularly around the complexity of the codebase, and your architectural principles.
If you have a highly complex codebase, it might be wise to make small changes to begin with. Can you extract that database logic so the annotation processing can be contained to a single module? Can you move all your backend networking code into its own module?
Slices
There’s no limit to the number of ways a project can be sliced, but I’ll provide a few options:
- Single Module
- By Architectural Boundaries
- By Feature
- Combined/Hybrid Modularisation
1. Single Module
This is how an Android project usually starts out. There is no separation between features or architectural layers.
Advantages:
- Very simple, easy to understand dependency management
- Enables fast iteration
Disadvantages:
- No architectural boundaries
- Slower build times
- Increased risk of merge conflicts
2. Slicing by Architectural Boundaries
This involves organising layers of code into the same module, across multiple features. For example, having a module for the domain
layer, another module for the data
layer, and a third module for the presentation
layer.
Several potentially unrelated features contribute code to the same layer.
Advantages:
- Simplified dependency management. There are only a few modules to manage, and it’s relatively easy to understand where code belongs.
- Can help to enforce architectural principles. e.g. you could prevent
:presentation
from depending on:data
.
Disadvantages:
- Code for unrelated features is grouped together
- Easier to create code conflicts
This approach is a good starting place. It’s not too granular. If there’s :data
code that’s shared between Feature 1
and Feature 2
, it already lives together in the same module - so no additional dependency management is required.
3. Slicing by Feature
In this approach, each feature is encapsulated in its own module, containing all necessary layers - data
, domain
and presentation
. This approach groups all aspects of a specific feature together, including business logic, data handling and UI components.
Advantages:
- Clear feature boundaries
- Enhanced discoverability of features and related code
Disadvantages:
- More complex dependency management
- Difficult to share code between modules
- Increased code duplication
- No enforcement of architectural boundaries
Vertical slicing makes it more difficult to share common code. Let’s say you have the same network :data
models serving both Feature 1
and Feature 2
. Or, some :presentation
UI that both features use.’ You can either duplicate that code, or you have to create a separate, shared module:
It can quickly become difficult to understand whether code belongs in a feature module, or in :shared
. It’s easy to miss code that was implemented in Feature 1
and accidentally duplicate it in Feature 2
.
4. Hybrid Slicing
If you’re going for anything complex, beyond a single module, or the ‘by architecture’ approach described above, then you’re most likely going to land on a ‘hybrid’ approach. This mixes and matches module boundaries based on your code complexity, architecture, features, etc.
This can be great for really minimising build times, without increasing the build complexity too much. Or, you can be extremely granular, enforcing architectural boundaries and encapsulating code to your wits end.
Here’s an example of a possible approach:
In the above example, we use ‘architectural’ slicing to separate code into architectural layers, :presentation
, :domain
and :data
.
The :presentation
layer is then sliced into features, with common UI code shared via the :presentation:common
module.
The :data
layer is then sliced into individual data sources (database, network, etc.). This is a particularly good improvement, as these layers often contain annotation processors. Isolating them to their own modules helps avoid compilation when unrelated code changes.
Summary
Choosing the right modularisation strategy for your project can significantly impact build times and help maintain clear architectural boundaries. Modularisation improves build efficiency by isolating changes to specific modules and enabling parallel builds while preventing unwanted dependencies between different parts of your code. However, it introduces additional complexity, as developers must manage module dependencies and navigate a potentially less discoverable codebase.
Before adopting modularisation, consider alternatives like optimising build configurations and using tools like Konsist or ArchUnit to enforce architectural rules. If you decide to modularise, start with simpler strategies like slicing by architectural boundaries. The complexity of your modularisation approach should align with your project's size, complexity, team maturity, and architectural goals. By carefully considering these factors and planning your strategy, you can maximise the benefits of modularisation while minimising its drawbacks, ensuring a more efficient and maintainable codebase.