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:

graph TD :app-->A :app-->B :app-->C A[:presentation] B[:domain] C[:data]

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:

graph TD A[:presentation] B[:domain] C[:data] A --> B C --> B

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:

  1. Single Module
  2. By Architectural Boundaries
  3. By Feature
  4. Combined/Hybrid Modularisation

1. Single Module

graph TD :app

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

graph LR subgraph :presentation A[Feature 1] B[Feature 2] C[Feature 3] end subgraph :domain D[Feature 1] E[Feature 2] F[Feature 3] end subgraph :data G[Feature 1] H[Feature 2] I[Feature 3] end

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

graph TD subgraph Feature 3 direction RL G[:presentation] H[:domain] I[:data] end subgraph Feature 2 direction RL D[:presentation] E[:domain] F[:data] end subgraph Feature 1 direction RL A[:presentation] B[:domain] C[:data] end

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:

graph LR subgraph Feature 2 direction RL D[:presentation] E[:domain] F[:data] end subgraph Feature 1 direction RL A[:presentation] B[:domain] C[:data] end subgraph shared direction RL H[:shared:presentation] G[:shared:data] end C-->G F-->G A-->H D-->H

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:

graph TD :domain subgraph :presentation direction LR A[:feature1] B[:feature2] C[:common] end subgraph :data direction LR G[:database] H[:network-models] I[:preferences] end :presentation-->:domain :data-->:domain A-->C B-->C

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.