Gradle: from Newbie to Strong fundamentals

y9vad9

Vadym Yaroshchuk

Posted on September 7, 2023

Gradle: from Newbie to Strong fundamentals

When developing on Kotlin, every beginner faces the problem of not understanding the appropriate tooling for working with the programming language. That's what this article was created for - to explain the work of Gradle for Kotlin and on Kotlin. Let's go!

Definition
Gradle is a system for automating the assembly, including building and compilation. It’s designed for complex build flows, where the task is not only to execute the code, but to create custom build logic, handle multi-module projects, and integrate with continuous integration systems.

But let's start with something simple - how to create our first project using Gradle?

Project

Let's start with the basic concept that exists in the application building system - Project.

Definitions
Project is an independent unit of application organization as a set of dependent modules and rules for them.

Module is an independent unit of code organization that has a certain set of rules (how it is built, etc.). Exists for the same purpose as packages in Kotlin — to divide the code into logical blocks in order to improve the quality of the source code (reuse of code, both in one project and in others).

What rules am I talking about? In fact, everything is very simple - we describe how our project will be built (description of technical features), for which platform (for example, Android or iOS), in which language, and with which means (project dependencies).

Structure

Let's create an example project structure:
Simple project structure

P.S: The names in the example have no special sense, they're just terminology from Foobar.

All modules are required to have build.gradle.kts in order to work. It's important to note that modules cannot exist on their own and they only work if we include them to our project explicitly via settings.gradle.kts

As you can see, we have a kind of "chief supervisor", who determines which modules will be in our project and how they will work, and "local supervisors" who set rules only for the code subordinate to them (modules), but, it's worth noting that Project has more priority than modules when it comes to rules (but it isn't something that we will cover in this particular article).

What rules are there? In fact, there are many of them - it all depends on what you do, but the basic ones are, for example:

  • Project name, version, group (an identifier that is a kinda package from Kotlin)
  • Programming language (Java / Scala / Groovy / Kotlin / etc)
  • Platform (Only relevant for Kotlin, to be more clear, for Kotlin Multiplatform plugin)
  • Dependencies (libraries or frameworks used in the code)

Modules

Let's consider the modules: how and with what they are eaten. Let me remind you what a module is:

Definition
Module is an independent unit of code organization that has a certain set of formal rules that define the module's behavior.

For an example, let's take foo module:
Module structure explanation
To create a module, first of all, we create the directories of this module. After that, we create a file with the name build.gradle.kts (or build.gradle if you use Groovy as script language, no other way), where we will already prescribe what our module can do.

build.gradle.kts

Our settings file has the following structure:
build.gradle.kts scheme
Don't be afraid! Even if it looks quite complicated. 😄

The main component in any build.gradle.kts is the Plugins block. Dependencies and Repositories blocks are independent of Plugins block, but without it, they're like comedians telling jokes in an empty theater – they may have some great material, but there's no stage, no audience, and no laughter to be heard.

Plugins that are applied to your module usually consume what you've specified in the Dependencies block. So, dependencies without plugins that will use it are useless, that's why dependencies has a connection with plugins in our scheme.

Repositories are not dependent on plugins per se, but you always need them in place to apply any dependencies or plugins to your project. Therefore, without plugins or dependencies, it makes no sense to exist. So, using our previous analogy, it's akin to having a theater full of people without any comedians on stage.

Tasks are also a fundamental thing in your Gradle configuration files. They're always provided by plugins that you're applying to your module. In an empty module without any plugin, you will not have any tasks. However, there are some basic tasks that are available on a project level:

  • tasks (returns list of available tasks across the project: name, on which tasks task is dependent, etc.).
  • dependencies (prints a report of the project's dependencies, showing which dependencies are used and their versions)
  • help (returns list of available tasks across the project with a brief description).
  • model (provides a detailed report of your project's structure, tasks, etc.; helping you understand and debug your Gradle build)
  • etc.

💡 Bonus
Tasks can be dependent on other tasks, it's especially useful when you're needed in the result of other task's execution.

Example

Now, let's consider an example. For instance, let's create a Kotlin/JVM project with kotlinx.coroutines library as a dependency.

Firstly, we need to create our project configuration file – settings.gradle.kts in the root of our project:

rootProject.name = "our-first-project"
Enter fullscreen mode Exit fullscreen mode

To make it work, you should run gradle sync inside your IDE:

How to run gradle sync

You can either create a new folder for a new module or utilize the root folder as a module in the same way by simply adding a build.gradle.kts file in the root directory (our-first-project/build.gradle.kts).

❗️ Important
When we use root folder as a module we don't need to explicitly add it to our project configuration file, but for any new modules we should declare it by using the include function – for example, include(":foo") (for nested folders use include(":foo:bar"))

Let's start with plugins:

plugins {
    id("org.jetbrains.kotlin.jvm") version "1.9.0"
}
Enter fullscreen mode Exit fullscreen mode

💡 Bonus
We can simplify the declaration of Kotlin plugins (such as jvm, android, js, multiplatform) using kotlin function: id("org.jetbrains.kotlin.jvm") -> kotlin("jvm").
It automatically appends org.jetbrains.kotlin at the start.

Now, let's come to the Dependencies:

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
Enter fullscreen mode Exit fullscreen mode

Our Kotlin/JVM plugin provides a useful for us function – implementation. Without it, we would have to write explicitly a configuration name (identifier for plugin) that will consume our dependencies. As you can remember, dependencies do not live on their own. So, to be more clear, Dependencies block provides only the basic ability to add and consume added dependencies. We could add our dependency in the next way (but we still need a plugin that will consume it):

dependencies {
    add(configurationName = "implementation", "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
Enter fullscreen mode Exit fullscreen mode

configurationName stands for dividing dependencies with different target plugins (plugins that are consuming our dependencies).

But, if we try to build our module, we will have the next problem:

Could not resolve all dependencies for configuration ':compileClasspath'. > Could not find org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3.
Enter fullscreen mode Exit fullscreen mode

To resolve this issue, we need to specify the repository from which we want to implement our dependency. Let's look at the example:

repositories {
    // builtins:
    mavenCentral()
    mavenLocal()
    google()

    // or specify exact link to repository:
    maven("https://maven.y9vad9.com")
}
Enter fullscreen mode Exit fullscreen mode

Definition
Maven repositories – are like online stores or libraries for code. They are collections of pre-built software libraries and dependencies that you can easily access and use in your projects. These repositories provide a centralized and organized way to share and distribute code.

Also, it's important to notice, that Maven is another build tool with built-in support from Gradle.

But, for our case, we're only needed in mavenCentral(). So, our resulting build.gradle.kts is next:

plugins {
    kotlin("jvm") version "1.9.0"
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
Enter fullscreen mode Exit fullscreen mode

For such an example, we don't need to touch any tasks. But it would be good to mention that our Kotlin plugin provides the following tasks:

  • compileKotlin
  • compileJava
  • etc

But, usually, you don't need to call these tasks directly, unless you're developing your own plugin that is dependent on results/outputs from these tasks.

💡 Bonus
You can run gradle tasks either by using command line or through IDE:

How to run task

As an example I took gradle build task.

We will skip for now a full explanation of tasks and how they can be used. I will cover it in the next articles.

Now, let's finally come to how and where can we write our code.

Source sets

We figured out how to create a project and modules, but where should we write the code? In Gradle projects, there is a concept of different sets of source code (Source sets) - a kind of separation of code for different needs. For example, the following sets exist by default for our previous example:

  • main – The name says ifself, it's main place where code should be placed.
  • test – Used for code that are related to testing. It's dependent on main source set and has all dependencies / code you have written in main.

💡 Bonus
But it's not always the case, for instance, Kotlin/Multiplatform projects have dedicated source sets for every platform you're writing for (basically, the plugin creates a set of source sets for all the platforms we need). So, it's important to mention that it always depends on the plugins you're applying to the module and on your configuration. It's not a constant.

main

To start coding we need to create a folder for our programming language, for Kotlin it's src/main/kotlin folder. So, from now on, we can simply create our 'Hello, World!' project. Let's create Main.kt in our recently created folder:

fun main() = println("Hello, Kotlin!")
Enter fullscreen mode Exit fullscreen mode

You can run it using IDE, it will automatically handle the Gradle Building process.

test

As I've previously told you, this source set is used for testing purposes. But it's important to mention that it has its own dependencies (but it also forks it from main source-set). So, you can implement dependencies that will be available in this source-set. For example, let's implement kotlin.test library:

dependencies {
    // ...
    testImplementation(kotlin("test"))
}
Enter fullscreen mode Exit fullscreen mode

You can refer to full tutorial in Kotlin documentation of how you can test your code using kotlin.test.

Multi-module projects

The creation of different modules creates the need for their interaction with each other. Also, let's analyze the types of interaction, how you can't or shouldn't do it, and what's the point. Let's start!

If you remember our initial project structure it has a few modules:

  • foo
  • bar
  • qux

Let's consider foo as the main module where we have our entry point to application (Main.kt file). Let's begin with creating a configuration for all three modules (it will be simple without any dependencies):

plugins {
    kotlin("jvm") version ("1.9.0")
}

repositories {
    mavenCentral()
}
Enter fullscreen mode Exit fullscreen mode

To make it work, let's add our modules to settings.gradle.kts:

rootProject.name = "example"

include(":foo", ":bar", ":qux")
Enter fullscreen mode Exit fullscreen mode

Then, let's make foo dependent on bar module:

// File: /foo/build.gradle.kts

dependencies {
    implementation(project(":bar"))
}
Enter fullscreen mode Exit fullscreen mode

❗️ Important
To implement a module from your project, you should specify it using project function. When implementing modules or specifying it somewhere else, we use special notation where / is replaced with the : symbol.

And bonus: to implement the root module, just use implementation(project(":")).

From now, we can use any function or class from the bar inside the foo module (of course, if the visibility of these declarations allows it). For example, let's create a file in foo module:

package com.my.project

fun printMeow() = println("Meow!")
Enter fullscreen mode Exit fullscreen mode

And we can use it in foo module:

import com.my.project.printMeow

fun main() = printMeow()
Enter fullscreen mode Exit fullscreen mode

But, can't use it from qux module. Moreover, if we try to implement foo module, bar will still stay inaccessible. Module's dependencies are not exposed to other modules by default.

💡 Bonus
We can share dependencies for modules that implement our particular module using api function instead of implementation. In this way, for example, qux module can access bar module functions/classes/etc by implementing foo without explicit dependence on the bar module.

Limitations

Imagine that after the previous example, you need to get any class/function/etc. inside bar from foo module. If you will try to do this, you will have the next problem:

Circular dependency between the following tasks:
:bar:classes
\--- :bar:compileJava
     +--- :bar:compileKotlin
     |    \--- :foo:jar
     |         +--- :foo:classes
     |         |    \--- :foo:compileJava
     |         |         +--- :bar:jar
     |         |         |    +--- :bar:classes (*)
     |         |         |    +--- :bar:compileJava (*)
     |         |         |    \--- :bar:compileKotlin (*)
     |         |         \--- :foo:compileKotlin
     |         |              \--- :bar:jar (*)
     |         +--- :foo:compileJava (*)
     |         \--- :foo:compileKotlin (*)
     \--- :foo:jar (*)
Enter fullscreen mode Exit fullscreen mode

What is it all about? Everything is simple – you cannot create circular dependencies.

Circular dependencies in Gradle are like a never-ending loop that makes your build process stuck because tasks keep waiting for each other to finish, which never happens. It's essential to avoid them to ensure your build runs smoothly. Furthermore, it's always about violating Dependency Inversion Principle that is not good practice.

You can refer to this discussion to read about it more.

👨🏻‍🏫 Bonus for experienced
Usually, if we talk about, for example, mobile applications, we use Three-Tier Architecture. So, it's a good idea to divide it into different modules to enforce architectural rules:
Gradle project structure
It makes, for example, our domain layer not to be dependent on data layer – it's literally impossible as there will be a circular dependency problem.

Conclusion

It's not just another coffee brewing method; it's a powerful build automation tool that simplifies your software development process. With Gradle, you can manage dependencies, automate tasks, and keep your projects well-organized. It's like having a trusty assistant who takes care of the nitty-gritty, so you can focus on writing awesome code!

💖 💪 🙅 🚩
y9vad9
Vadym Yaroshchuk

Posted on September 7, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related