# Building with Gradle

When developing applications, there is a large list of steps that need to be completed before we can release our software to users. We might need to:

  • Make sure that we have the correct version of source code and assets.
  • Check that we have the correct versions of external libraries.
  • Make sure we're running on the correct version of the OS.
  • Setup our build environment and compile everything.
  • Setup our test environment, and run tests.
  • Build installers to distribute to users.

Performing these steps manually is error-prone and time-consuming. Instead of doing this by-hand, we tend to rely on build systems: software that is used to build other software. There are a number of build systems on the market that attempt to simplify and propvide consistency in these tasks. These tools often language or toolchain dependent due to the specifics of each programming language. e.g. make or scons for C++; cargo for Rust, maven for Java.

Builds systems automate what would otherwise be a manual process. They provide consistency and help address issues like:

  • How do I make sure that all of the steps are being handled properly?
  • How do I ensure that everyone is building software the same way i.e. using the same compiler options, and libraries?
  • How do I ensure that tests are being run before changes are committed?

# Requirements

We’re going to use Gradle in this course:

  • It handles our long-list of requirements (which is, frankly, pretty impressive).
  • It fits nicely into the Kotlin and JVM ecosystem, and is recommended for new projects.
  • It’s the official build tool for Android builds, so you will need it for Android applications.
  • It's cross-platform and language agnostic.
  • It's open source and has a large community of users.

Let's introduce some of the key features of Gradle. These are features that most modern build systems will provide; we'll discuss them in terms of how they are implemented in Gradle specifically.

# Build Tasks

Projects often have complex build requirements that include a series of steps that need to be performed. Minimally, you need to:

  1. compile your source code,
  2. run tests to make sure it built and works properly,
  3. build a distributable package.

You might also have additional steps: generate documentation, run static analysis, or deploy to a server.

Any build system needs to support a wide range of tasks like this, and it should allow you to define how these tasks will be performed. It should also run them in the correct order.

Gradle uses the term task to describe a single unit of work that needs to be performed. You can define tasks to compile code, run tests, build a distributable package, and so on. You can also define dependencies between tasks, so that one task runs before another. This makes it easy to build complex projects, and ensures that the build process is consistent across all of your projects.

Luckily, Gradle knows how to do most of these tasks out-of-the-box, so you typically just need to provide configuration details. For example, you can run the build task to compile your code, run tests, and build a distributable package. We'll discuss tasks in more detail in the next sections.

# Build Configuration

It's certainly possible to write custom scripts e.g, bash shell scripts to execute these tasks, but they are challenging to maintain, and need to be written for each project. Gradle provides a way to define tasks in build configuration files, and then run them with a single command. This makes it easy to build complex projects, and ensures that the build process is consistent across all of your projects.

Unlike other configuration-based build systems, Gradle uses a Domain Specific Language (DSL) to define build scripts; you actually write your scripts in Groovy or Kotlin. This makes Gradle extremely configurable and extensible to meet complex project requirements.

We'll discuss build configuration in more detail in the next sections as well.

# Dependency Management

The final core function is managing libraries and dependencies.

When we write software, we often rely on external libraries to provide functionality that we don't want to write ourselves. For example, we might use some library to handle networking, or to provide a user interface. These libraries are known as dependencies, and they are a critical part of modern software development.

A large challenge of any build system is managing these dependencies properly. For example, you need to make sure that you have the correct version of a library, and that any dependencies that it might need are also installed (called transitive dependencies). You also need to make sure that the library is compatible with the rest of your software, and that it doesn't introduce any security vulnerabilities.

Gradle is designed so that you can specify your dependencies in your build scripts. Gradle will download them from an online repository as part of your build process, and manage them for you.

mavenCentral is the standard online repository for Java and Kotlin libraries.

# Gradle Concepts

Gradle leverages build tasks and build configuration files to provide a flexible and powerful build system.

In Gradle, a task is a set of related functions that can be applied to a particular type of project. For example, you might have a build task to build your Kotlin console project, and a clean task to remove temporary build files. Complex projects will add additional tasks that are required for that style of project e.g., build my Android app.

Gradle is declarative, in that you describe what you want to do, rather than how you want to do it. e.g., "compile my Kotlin application" rather than "run the kotlinc compiler with a particular set of options". You describe your tasks in a build script, and Gradle takes care of running them in the correct order.

# Project Setup

Your Gradle project is simply a directory structure with a set of configuration files that describe how to build your project i.e. that tell Gradle which plugins to apply, which dependencies to use, and which tasks to run.

# Create a Project

Use the File > New Project wizard in IntelliJ to create a new empty project. This will give you a top-level project with starting configuration files.

Projects can be created with a variety of configurations, but for this course, you should choose Kotlin as your programming language, Gradle for your build system, and Kotlin for your DSL language.

New project dialog
New project dialog

The result of this process is a new project with a directory structure and configuration files that are ready to use with Gradle. Note that the project will be specific to the type of project that you chose e.g., Android, Desktop, Web Service.

# Using the Gradle Wrapper

At the top-level of your project's directory structure will be a script, gradlew (or gradlew.bat for Windows users). This is the Gradle wrapper, and it is a script that you use to run Gradle tasks without having to install Gradle on your machine. You run it and pass it the same command-line arguments that you would normally pass to Gradle.

For example, we can invoke the Gradle wrapper with the help task to get information about how Gradle is configured and how to use it. This is the same as running gradle help if you had Gradle installed on your machine.

$ ./gradlew help
Starting a Gradle Daemon (subsequent builds will be faster)

> Task :help

Welcome to Gradle 7.5.1.

To run a build, run gradlew <task> ...
To see a list of available tasks, run gradlew tasks
To see more detail about a task, run gradlew help --task <task>
To see a list of command-line options, run gradlew --help
For more detail on using Gradle, see https://docs.gradle.org/7.5.1/userguide/command_line_interface.html
For troubleshooting, visit https://help.gradle.org

Aside from convenience, we use a wrapper to ensure that tasks are always executed with the correct version of Gradle. If needed, the wrapper script will download and cache the version of Gradle configured for your project, and then use it to run the tasks that you specified.

# Wrapper Configuration

The Gradle project configuration (gradle/gradle-wrapper.properties) lists the version of Gradle to be used for your project.

To change the version of Gradle being used, update the gradle-wrapper.properties file, and change the distributionURL line to the correct version e.g., Gradle 8.0.2 below.

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

The gradle-wrapper.properties file should be stored in Git, as part of your build configuration scripts.

# Build Tasks

In Gradle, a task is a set of related functions that can be applied to a particular type of project. Some Gradle tasks are built-in, but others will need to be specified. Let's discuss working with tasks first.

# Running Tasks

To run Gradle tasks from the command line, use the Gradle wrapper with the appropriate the task name. We can run ./gradlew tasks to see a list of tasks that are supported in your project.

$ ./gradlew tasks

> Task :tasks

------------------------------------------------------------
Tasks runnable from root project 'gradle'
------------------------------------------------------------

Application tasks
-----------------
run - Runs this project as a JVM application

Build tasks
-----------
assemble - Assembles the outputs of this project.
build - Assembles and tests this project.
buildDependents - Assembles and tests this project and all projects that depend on it.
buildNeeded - Assembles and tests this project and all projects it depends on.
classes - Assembles main classes.
clean - Deletes the build directory.
jar - Assembles a jar archive containing the classes of the 'main' feature.
testClasses - Assembles test classes.

# many more that are not shown

You can execute any of these tasks through the Gradle wrapper. e.g. ./gradlew build or ./gradlew clean.

Commonly used tasks include:

  • ./gradlew help will provide online help.
  • ./gradlew tasks will list all of the tasks that are available.
  • ./gradlew clean will remove temporary build files.
  • ./gradlew build will build your project.
  • ./gradlew run will usually run your project (depending on the platform).

You can also run tasks from within IntelliJ. Both IntelliJ IDEA and Android Studio include a Gradle IDE plugin, which allows you to run Gradle tasks.

View > Tool Windows > Gradle will open the Gradle window. Tasks are grouped by category. You can run individual tasks by double-clicking on them.

Gradle window
Gradle window

# Using Plugins

Gradle comes with a small number of predefined tasks. You will usually need to add additional tasks that are specific to your type of project. We do this via plugins.

A plugin is a collection of tasks that have been bundled together to perform a specific function. For example, the java plugin adds tasks for compiling Java code, running tests, and building a distributable package. Plugins can also add additional configuration options, or set defaults for existing options.

There are different types of plugins.

  1. Core plugins: These are the plugins that are included with Gradle by default. They provide basic functionality that is required by many projects. Core plugins include java (which adds support for compiling Java code), and application (which adds support for running code, and building a distributable package).

Core plugins are listed in the Gradle documentation, and you can use them in your project by adding a line to the plugins section of your build.gradle.kts file.

plugins {
    java
}
  1. Community Plugins: These are plugins that are created by the community and are not included with Gradle by default. They provide additional functionality that is not available in the core plugins. An example would be the 'io.github.gmazzo.codeowners.jvm' plugin, which provides some very specific functionality.

Community plugins can be found in the Gradle Plugin Portal. This is a repository of plugins that have been created by the community. You can search for plugins by name, or by category.

Under the plugin page on the Gradle Plugin Portal, you will find instructions on how to add the plugin to your project. Typically, you will need to add a line to the plugins section of your build.gradle.kts file.

Plugin details (gradle.org)
Plugin details (gradle.org)

# Build Configuration

This directory structure is opinionated: Gradle requires a specific directory structure to work correctly, so you should work within the structure that it gives you. Most of these files are configuration file, or scripts that help execute gradle. The actual source code will be placed in the app/src directory.

A standard Gradle project has the following directory structure:

.
├── build.gradle.kts
├── gradle
│   └── wrapper
├── gradle.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    └── test

# Configuration Files

build.gradle.kts and settings.kts are the configuration files for your project, describing logical structure, dependencies, and so on.

settings.gradle.kts

This is the top-level configuration file. You don't need to modify this for single-target projects.

plugins {
    id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
}

// top-level descriptive name
rootProject.name = "project-name"

build.gradle.kts

This is the detailed build configuration. You might need to modify this file to:

  • Add a new dependency (i.e. library)
  • Add a new plugin (i.e. set of custom tasks)
  • Update the version number of a product release (version below).
// includes jvm tasks
plugins {
    kotlin("jvm") version "1.9.21"
}

// product release info
group = "org.example"
version = "1.0-SNAPSHOT"

// location to find libraries
repositories {
    mavenCentral()
}

// add libraries here
dependencies {
    testImplementation(`org.jetbrains.kotlin:kotlin-test`)
}

tasks.test {
    useJUnitPlatform()
}

// java version
kotlin {
    jvmToolchain(17)
}

# Multi-Project Builds

A simple build is suitable for most standalone, independent projects. Keep in mind that it is restricted to the specific project type that you chose with the New Project wizard e.g., Android, Desktop, Web Service.

What do you do if you want to build a more complex project e.g., a combination of desktop, Android, web service and so on? You could do this with multiple separate projects, but it's often beneficial to have them in the same project.

In Gradle, this is known as a multi-project build, and it allows you to build multiple projects from a single top-level project. This is useful when you have multiple projects that depend on each other, or when you want to share code between projects.

There is a specific structure that you need to follow to create a multi-project build. In the example below, we have subprojects application, models and server which represent different projects.

.
├── application
│   ├── bin
│   ├── build.gradle.kts
│   └── src
├── gradle
│   └── wrapper
├── gradle.properties
├── gradlew
├── gradlew.bat
├── local.properties
├── models
│   ├── bin
│   ├── build.gradle.kts
│   └── src
├── server
│   ├── bin
│   ├── build.gradle.kts
│   └── src
└── settings.gradle.kts

To create a multi-project build, you need to create subprojects for each project that you want to manage. Each subproject should have its own build.gradle.kts file, and a src directory containing the source code for that project. The top-level settings.gradle.kts describes common dependencies and the overall project structure.

Let's walk through an example of how to create a multi-project build.

# Step 1: Move the application code into an application project:

Move the application code into an application project:

  1. Create a directory named application.
  2. Move the build.gradle.kts and src folder from the root into the application directory.
  3. Edit the top-level settings.gradle.kts and add the following line to the bottom of the file:
include("application")

# Step 2: Add a server project

To add a server project:

  • Create a type of project using the IntelliJ IDEA Project Wizard e.g., Ktor server.
  • Place it in a subdirectory of your main project. Each subdirectory should just contain the generated src directory, and the build.gradle.kts file. You don't want anything else in the project subdirectory.
  • Finally, update the settings.gradle.kts to add each project subdirectory in the include statement.
include("application", "server")

# Step 3: Add a models project

Repeat Step 2 with an empty project to hold shared models.

# Dependency Management

We'll use Gradle for managing our dependencies. Gradle can download specific versions of libraries for us from an online repository: a location where libraries are stored and made available. Typically, a repository will offer a large collection of libraries and include many years of releases, so that a package manager is able to request a specific version of a library and all of its dependencies.

# Setting Up Repositories

Repositories can be local (e.g. a large company can maintain its own repository of internal or external libraries), or external (e.g. a collection of public libraries). The most popular Java/Kotlin repository is mavenCentral, and we'll use it with Gradle to import any external dependencies that we might require.

You can control the repository that Gradle uses by specifying its location in the build.gradle.kts file. By default, Maven Central should already be included.

repositories {
     mavenCentral()
}

# Browsing Repositories

You can browse repository websites to find libraries that you might want to use. Additionally, you can use an online package directory.

Each package information page will include the details of how to import the package into your Gradle project. e.g.

Dependency details
Dependency details

# Add Dependencies

You add a specific module or dependency by adding it into the dependencies section of the build.gradle.kts file. Dependencies need to be specified using a "group name: module name: version number" (with a colon separating each one).

From the previous example, we can copy and paste the dependency line from the package information page directly into our build.gradle.kts

dependencies {
    implementation("io.coil-kt.coil3:coil-jvm:3.0.0-alpha06")
}

You generally want the most recent version that is available, but you can specify a specific version if you need to. Gradle will download the library and any dependencies that it needs, and make them available to your project.

# Version Catalogs

One challenge is keeping track of the versions of libraries that you are using. It's important to keep your libraries up-to-date to ensure that you have the latest features and bug fixes. However, updating libraries can be a time-consuming process, as you need to check the library's website for new versions, update your build file, and test your application to make sure that everything still works.

Gradle has a feature called version catalogs, which is a centralized file that contains a list of libraries and their versions. When you run a Gradle build, Gradle will check the version catalog to see if there are any updates available for the libraries that you are using. If there are updates available, Gradle will automatically update the libraries in your project to the latest versions.

In Gradle 7.x or later, the version catalog is contained in a file libs.versions.toml in your gradle project directory.

A version catalog file looks like this:

[versions]
guava = "32.1.3-jre"
junit-jupiter = "5.10.1"

[libraries]
guava = { module = "com.google.guava:guava", version.ref = "guava" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }

In build.gradle.kts, we can use these library names to refer to these specific libraries and versions.

dependencies {
    // Use JUnit Jupiter for testing.
    testImplementation(libs.junit.jupiter)

    testRuntimeOnly("org.junit.platform:junit-platform-launcher")

    // This dependency is used by the application.
    implementation(libs.guava)
}

# Last Word

It's staggering how much software is available through package repositories…