Skip to content

Gradle Deep Dive

If you come from TypeScript you know npm/yarn/pnpm plus package.json: simple, declarative, works. If you come from Go you know go mod plus go.mod — even simpler. JVM projects need more, and Gradle is what fills the gap. This module maps every Gradle concept back to the npm/go-mod tools you already use.

This module assumes you’ve completed the language fundamentals (modules 01–04), have JDK 21 installed, and are comfortable on the command line.

JVM languages need more than package.json because:

  • Compilation step — Kotlin/Java must be compiled before running (unlike TS with ts-node or Go’s fast compiler).
  • Complex classpaths — the JVM loads classes at runtime from JARs; dependency resolution matters more.
  • Multi-module monorepos — enterprise JVM projects often have 10–100+ modules in one repo.
  • Build variants — test vs. production, different JVM targets, native images, fat JARs.

Gradle handles all of this. It uses Kotlin (or Groovy) as its build-script language, so your build files are actual programs, not just config.

You’ll see Maven in older projects. Quick comparison:

AspectGradleMaven
Config languageKotlin DSL (.kts) or GroovyXML (pom.xml)
SpeedIncremental + build cacheFull rebuilds
FlexibilityTuring-complete scriptsPlugin-only extension
Learning curveSteeperSimpler (less powerful)
Kotlin supportFirst-classVia plugin, works fine
Conceptnpm (TypeScript)go mod (Go)Gradle (Kotlin)
Project manifestpackage.jsongo.modbuild.gradle.kts
Lock filepackage-lock.jsongo.sumgradle.lockfile (opt-in)
Dependency installnpm installgo mod download./gradlew build (auto)
Run scriptsnpm run devgo run ../gradlew run
Testnpm testgo test ./..../gradlew test
Workspaces / modulesnpm workspacesGo workspaceMulti-module project
Registrynpmjs.comproxy.golang.orgMaven Central / Google
Version pinning^1.2.3 in package.jsonv1.2.3 in go.modVersion catalogs
Dev dependenciesdevDependenciesN/A (build tags)testImplementation
Build outputdist/binary in $GOPATHbuild/ directory
Task runnernpm scripts / MakefileMakefileGradle tasks
Project settingspackage.json name/versiongo.mod module pathsettings.gradle.kts

The minimal Kotlin project layout, with each file annotated:

  • Directorymy-app/
    • build.gradle.kts build configuration (like package.json)
    • settings.gradle.kts project name + module declarations
    • gradle.properties build properties (JVM args, versions)
    • Directorygradle/
      • Directorywrapper/
        • gradle-wrapper.jar Gradle bootstrap (committed to git)
        • gradle-wrapper.properties Gradle version config
      • libs.versions.toml version catalog (centralized deps)
    • gradlew Unix wrapper script (committed to git)
    • gradlew.bat Windows wrapper script
    • Directorysrc/
      • Directorymain/
        • Directorykotlin/ Kotlin source files
          • Directorycom/example/
            • App.kt
        • Directoryresources/ non-code files (config, templates)
          • application.yml
      • Directorytest/
        • Directorykotlin/ test source files
          • Directorycom/example/
            • AppTest.kt
        • Directoryresources/ test-specific resources

The same project, compared across ecosystems:

  • Directorymy-ts-app/
    • package.json ≈ build.gradle.kts
    • tsconfig.json kotlin { jvmToolchain(21) }
    • package-lock.json ≈ gradle.lockfile
    • Directorynode_modules/ ≈ ~/.gradle/caches/ (global, not per-project)
    • Directorysrc/
      • index.ts ≈ src/main/kotlin/App.kt
    • Directorytests/
      • index.test.ts ≈ src/test/kotlin/AppTest.kt
SystemLocation
npm./node_modules/ (per-project)
Go$GOPATH/pkg/mod/ (global cache)
Gradle~/.gradle/caches/ (global cache)

Gradle never copies dependencies into your project. They live in a global cache and are resolved at build time. This is why there’s no node_modules equivalent to accidentally commit.

Here’s the minimal build script, with each block mapped to a concept you know:

build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0" // Kotlin compiler plugin
application // Enables `./gradlew run`
}
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral() // Where to find dependencies (like npmjs.com)
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
testImplementation(kotlin("test")) // Kotlin test framework
}
kotlin {
jvmToolchain(21) // Target JDK 21
}
application {
mainClass.set("com.example.AppKt") // Entry point for `./gradlew run`
}
tasks.test {
useJUnitPlatform() // Use JUnit 5 test runner
}

The same project’s manifest in the ecosystems you already know:

package.json
{
"name": "com.example.my-app",
"version": "1.0.0",
"main": "src/index.ts",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"test": "jest"
},
"dependencies": {
"some-async-lib": "^1.9.0"
},
"devDependencies": {
"typescript": "^5.0.0",
"jest": "^29.0.0",
"@types/jest": "^29.0.0"
},
"engines": {
"node": ">=21"
}
}

Plugins extend Gradle’s capabilities. Think of them as npm packages that modify the build process itself.

plugins {
// The Kotlin JVM compiler plugin -- without this, Gradle can't compile .kt files
kotlin("jvm") version "2.1.0"
// Adds `run` task and creates start scripts for distribution
application
// Spring Boot plugin (when doing Spring)
id("org.springframework.boot") version "3.4.1"
// Ktor plugin (when doing Ktor)
id("io.ktor.plugin") version "3.0.3"
}
npm analogyGradle plugin
typescript (devDep)kotlin("jvm")
ts-nodeapplication plugin
jestBuilt into Kotlin test plugin

Where Gradle downloads dependencies from. There’s no single default like npmjs.com.

repositories {
mavenCentral() // The main public repo (like npmjs.com)
google() // Google's repo (Android, some libs)
maven("https://jitpack.io") // JitPack (build from GitHub repos)
mavenLocal() // Your local ~/.m2 cache
}

This is the big one. The configurations control where a dependency is visible:

dependencies {
// Your app needs this at runtime -- like npm "dependencies"
implementation("io.ktor:ktor-server-netty:3.0.3")
// Like implementation, but also exposes the dependency to consumers
// Only matters in library modules -- like npm "peerDependencies"
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// Only for tests -- like npm "devDependencies" (test-only)
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
// Needed at runtime but not at compile time
// Example: database drivers, logging backends
runtimeOnly("org.postgresql:postgresql:42.7.4")
// Needed at compile time but not at runtime (rare)
compileOnly("org.projectlombok:lombok:1.18.36")
// Annotation processors
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
}

A JVM dependency coordinate is group:artifact:version, e.g. "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0":

org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0
│ │ │
├── Group ID ├── Artifact ID └── Version
(like an npm scope) (like a package name)
Partnpm EquivalentExample
groupnpm scope (@scope/)org.jetbrains.kotlinx
artifactpackage namekotlinx-coroutines-core
versionversion1.9.0

In Go terms, it’s like github.com/org/repo v1.9.0 but split into group + artifact.

Gradle Configurationnpm EquivalentGo EquivalentWhen to Use
implementationdependenciesrequireLibrary your code calls directly
apipeerDependenciesN/ALibrary you expose in your public API
testImplementationdevDependencies (test)_test.go importsTest-only libraries
runtimeOnly--Needed at runtime, not compile (drivers)
compileOnly@types/* packages-Needed for compilation only

This distinction only matters when you’re writing a library module that other modules depend on. Use implementation when the dependency stays internal, and api when a type from it appears in your module’s public interface.

// Module: data-layer
dependencies {
// INTERNAL: only data-layer sees this dependency
implementation("com.zaxxer:HikariCP:6.2.1")
// EXPOSED: any module depending on data-layer also gets this
api("org.jetbrains.exposed:exposed-core:0.57.0")
}

In TypeScript terms: implementation is an import used only inside this package, while api is an import that shows up in your package’s .d.ts types.

NeedWhere to Searchnpm Equivalent
Any JVM librarysearch.maven.orgnpmjs.com
Kotlin librariessearch.maven.orgnpmjs.com
Version infomvnrepository.comnpmjs.com versions tab
GitHub-hostedjitpack.ionpm from GitHub
Terminal window
# Show all dependencies (like `npm ls`)
./gradlew dependencies
# Show dependencies for a specific configuration
./gradlew dependencies --configuration runtimeClasspath
# Show why a specific dependency is included (like `npm why`)
./gradlew dependencyInsight --dependency kotlin-stdlib

Sometimes a library pulls in something you don’t want:

dependencies {
implementation("some.library:core:1.0") {
// Exclude a transitive dependency
exclude(group = "commons-logging", module = "commons-logging")
}
}

The npm equivalent uses overrides:

{
"overrides": {
"some-package": {
"unwanted-dep": "npm:empty-package@1.0.0"
}
}
}
configurations.all {
resolutionStrategy {
// Force a specific version (like npm overrides)
force("com.google.guava:guava:33.4.0-jre")
// Fail on version conflicts instead of silently resolving
failOnVersionConflict()
}
}

A BOM locks a set of related dependencies to compatible versions — like having a curated package.json from a framework author.

dependencies {
// Import a BOM -- all Spring Boot dependencies use compatible versions
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.4.1"))
// Now you can omit versions -- the BOM provides them
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
}

Version catalogs solve the “dependency version scattered everywhere” problem. They’re Gradle’s single source of truth for dependency versions.

Without a catalog, versions are scattered and easy to drift out of sync:

// build.gradle.kts (module A)
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}
// build.gradle.kts (module B) -- hope this matches!
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}

Create gradle/libs.versions.toml with [versions], [libraries], [bundles], and [plugins] sections:

gradle/libs.versions.toml
[versions]
kotlin = "2.1.0"
coroutines = "1.9.0"
ktor = "3.0.3"
spring-boot = "3.4.1"
kotest = "5.9.1"
exposed = "0.57.0"
logback = "1.5.15"
postgresql = "42.7.4"
koin = "4.0.2"
[libraries]
# Format: module = { group = "...", name = "...", version.ref = "..." }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" }
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages", version.ref = "ktor" }
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web", version.ref = "spring-boot" }
spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test", version.ref = "spring-boot" }
kotest-runner-junit5 = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotest" }
kotest-assertions-core = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" }
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }
exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" }
logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }
postgresql = { group = "org.postgresql", name = "postgresql", version.ref = "postgresql" }
koin-ktor = { group = "io.insert-koin", name = "koin-ktor", version.ref = "koin" }
[bundles]
# Group related dependencies into bundles
ktor-server = ["ktor-server-core", "ktor-server-netty", "ktor-server-content-negotiation", "ktor-serialization-kotlinx-json", "ktor-server-status-pages"]
kotest = ["kotest-runner-junit5", "kotest-assertions-core"]
exposed = ["exposed-core", "exposed-jdbc"]
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ktor = { id = "io.ktor.plugin", version.ref = "ktor" }
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }

Then reference them type-safely in build.gradle.kts:

build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm) // Uses version from catalog
alias(libs.plugins.ktor)
}
dependencies {
// Single library
implementation(libs.kotlinx.coroutines.core)
// Bundle (multiple related libs at once)
implementation(libs.bundles.ktor.server)
// Test dependencies
testImplementation(libs.bundles.kotest)
}
FeaturenpmGoGradle Version Catalog
Central version filepackage.jsongo.modlibs.versions.toml
Auto-completionNoNoYes (IDE support)
Type-safe referencesNoNoYes (libs.ktor.server.core)
BundlesNoN/AYes (group related deps)
Shared across modulesnpm workspacesGo workspaceBuilt-in

In npm land you might use npm workspaces or a monorepo tool (Turborepo, Nx). In Go, you might have a Go workspace. In Gradle, multi-module projects are first-class.

Use multi-module when you have:

  • Shared library code between services
  • A separate API module from implementation
  • Independent build/test cycles for different parts
  • Architectural boundaries to enforce (module A can’t import module B’s internals)

A typical platform layout:

  • Directorymy-platform/
    • settings.gradle.kts declares all modules
    • build.gradle.kts root: shared config for all modules
    • Directorygradle/
      • libs.versions.toml shared version catalog
    • Directorycommon/ shared library module
      • build.gradle.kts
      • Directorysrc/main/kotlin/
    • Directoryapi-models/ shared DTOs/models
      • build.gradle.kts
      • Directorysrc/main/kotlin/
    • Directoryservice-users/ microservice 1
      • build.gradle.kts
      • Directorysrc/main/kotlin/
    • Directoryservice-orders/ microservice 2
      • build.gradle.kts
      • Directorysrc/main/kotlin/

The root settings.gradle.kts names the project and declares every module:

settings.gradle.kts
rootProject.name = "my-platform"
include("common")
include("api-models")
include("service-users")
include("service-orders")

The npm workspaces and Go workspace equivalents:

// package.json (npm workspaces)
{
"workspaces": ["common", "api-models", "service-users", "service-orders"]
}

Root build.gradle.kts (Shared Configuration)

Section titled “Root build.gradle.kts (Shared Configuration)”

The root build script holds config shared by all subprojects:

build.gradle.kts (root)
plugins {
kotlin("jvm") version "2.1.0" apply false // Declare but don't apply to root
}
// Configuration shared by ALL subprojects
subprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
group = "com.example.platform"
version = "1.0.0"
repositories {
mavenCentral()
}
// All modules use JDK 21
configure<org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension> {
jvmToolchain(21)
}
tasks.withType<Test> {
useJUnitPlatform()
}
}

The apply false pattern is like installing a package globally but not importing it — it makes the plugin available to subprojects without applying it to the root project.

Each module declares only what it needs; the version came from the root.

common/build.gradle.kts
plugins {
kotlin("jvm") // No version needed -- declared in root
}
dependencies {
// Only api() here -- this is a library module
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
testImplementation(kotlin("test"))
}
service-users/build.gradle.kts
plugins {
kotlin("jvm")
application
}
dependencies {
// Depend on sibling modules
implementation(project(":common"))
implementation(project(":api-models"))
// Service-specific dependencies
implementation("io.ktor:ktor-server-netty:3.0.3")
testImplementation(kotlin("test"))
}
application {
mainClass.set("com.example.users.AppKt")
}

implementation(project(":common")) is like importing from a workspace package in npm (import { User } from "@platform/common") or in Go (import "github.com/example/platform/common").

// Applies to root + all modules
allprojects {
repositories {
mavenCentral()
}
}
// Applies to all modules but NOT root
subprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
}

For large projects, use buildSrc or a convention plugin to avoid duplicating build logic:

  • Directorymy-platform/
    • DirectorybuildSrc/
      • build.gradle.kts
      • Directorysrc/main/kotlin/
        • kotlin-conventions.gradle.kts shared build logic
    • Directoryservice-users/
      • build.gradle.kts just applies the convention
buildSrc/build.gradle.kts
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
buildSrc/src/main/kotlin/kotlin-conventions.gradle.kts
plugins {
kotlin("jvm")
}
group = "com.example.platform"
version = "1.0.0"
repositories {
mavenCentral()
}
kotlin {
jvmToolchain(21)
}
tasks.test {
useJUnitPlatform()
}
service-users/build.gradle.kts
plugins {
id("kotlin-conventions") // Apply your custom convention
application
}
dependencies {
implementation(project(":common"))
}

Tasks are Gradle’s unit of work. Every action (build, test, run) is a task, and you can create your own. The npm equivalent is scripts in package.json; the Go equivalent is Makefile targets.

List the available tasks like you’d look at package.json scripts:

Terminal window
# List all tasks
./gradlew tasks
# List tasks with descriptions
./gradlew tasks --all
build.gradle.kts
// Simple task -- just prints something
tasks.register("hello") {
group = "custom" // Groups in `./gradlew tasks` output
description = "Prints a greeting"
doLast {
println("Hello from Gradle!")
}
}
// Task with inputs
tasks.register("printVersion") {
group = "custom"
description = "Prints the project version"
doLast {
println("Version: ${project.version}")
}
}
Terminal window
./gradlew hello
# > Task :hello
# Hello from Gradle!
./gradlew printVersion
# > Task :printVersion
# Version: 1.0.0

Code directly inside the task block runs during configuration; doFirst/doLast blocks run during execution:

tasks.register("lifecycle") {
// Runs during CONFIGURATION phase (avoid doing real work here)
println("This runs during configuration!")
doFirst {
// Runs first during EXECUTION phase
println("First action")
}
doLast {
// Runs last during EXECUTION phase
println("Last action")
}
}
tasks.register("generateDocs") {
group = "documentation"
doLast {
println("Generating documentation...")
}
}
tasks.register("publishDocs") {
group = "documentation"
dependsOn("generateDocs") // generateDocs runs first
doLast {
println("Publishing documentation...")
}
}
Terminal window
./gradlew publishDocs
# > Task :generateDocs
# Generating documentation...
# > Task :publishDocs
# Publishing documentation...

Gradle has built-in task types for common operations. Note the tasks.register<Copy>(...) syntax — the type goes in angle brackets:

// Copy files
tasks.register<Copy>("copyConfigs") {
from("src/main/resources")
into("build/configs")
include("*.yml", "*.properties")
}
// Delete files
tasks.register<Delete>("cleanLogs") {
delete("logs/")
}
// Execute a command
tasks.register<Exec>("dockerBuild") {
commandLine("docker", "build", "-t", "my-app:latest", ".")
}
// Create a ZIP archive
tasks.register<Zip>("packageApp") {
from("build/libs")
archiveFileName.set("my-app-${project.version}.zip")
destinationDirectory.set(layout.buildDirectory.dir("dist"))
}
// Run something before/after tests
tasks.test {
doFirst {
println("Setting up test environment...")
}
doLast {
println("Cleaning up test environment...")
}
}
// Make build depend on your custom task
tasks.build {
dependsOn("generateDocs")
}

Practical Example: Database Migration Task

Section titled “Practical Example: Database Migration Task”
tasks.register<Exec>("dbMigrate") {
group = "database"
description = "Run database migrations"
commandLine(
"docker", "exec", "kotlin-course-postgres",
"psql", "-U", "dev", "-d", "kotlin_course",
"-f", "/migrations/migrate.sql"
)
}
tasks.register<Exec>("dbReset") {
group = "database"
description = "Reset the database"
commandLine(
"docker", "exec", "kotlin-course-postgres",
"psql", "-U", "dev", "-d", "kotlin_course",
"-c", "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;"
)
}

The npm equivalent is a couple of scripts entries:

{
"scripts": {
"db:migrate": "docker exec postgres psql -U dev -d mydb -f /migrations/migrate.sql",
"db:reset": "docker exec postgres psql -U dev -d mydb -c 'DROP SCHEMA...'"
}
}

Gradle builds happen in three distinct phases. Understanding this prevents subtle bugs.

Gradle build lifecycle
Rendering diagram…
  1. Initialization — reads settings.gradle.kts, determines which projects (modules) to include, and creates Project instances.

  2. Configuration — executes ALL build.gradle.kts files, creates and configures ALL tasks (even ones you won’t run), and sets up the task dependency graph. This is where most code in build.gradle.kts runs.

  3. Execution — runs only the requested tasks and their dependencies. The doFirst { } and doLast { } blocks execute here.

// BAD: This runs during configuration, every time, even for `./gradlew help`
tasks.register("deploy") {
val version = Runtime.getRuntime().exec("git describe --tags") // WRONG!
.inputStream.bufferedReader().readText().trim()
doLast {
println("Deploying version $version")
}
}
// GOOD: Defer work to execution phase
tasks.register("deploy") {
doLast {
val version = providers.exec {
commandLine("git", "describe", "--tags")
}.standardOutput.asText.get().trim()
println("Deploying version $version")
}
}

Think of it this way: the configuration phase is npm reading package.json to understand what scripts exist; the execution phase is npm actually running npm run build.

The Gradle Wrapper (gradlew) is a script that downloads and runs a specific Gradle version. It ensures everyone on the team uses the same version — which is why you always use ./gradlew instead of gradle.

These wrapper files should be committed to git:

  • Directorygradle/
    • Directorywrapper/
      • gradle-wrapper.jar small bootstrap JAR (~60KB)
      • gradle-wrapper.properties specifies Gradle version
  • gradlew Unix shell script
  • gradlew.bat Windows batch script

The version lives in gradle-wrapper.properties:

gradle/wrapper/gradle-wrapper.properties
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

Update the wrapper with the wrapper task, then commit the changes:

Terminal window
# Update to a specific version
./gradlew wrapper --gradle-version 8.12
# Update to the latest release
./gradlew wrapper --gradle-version latest
Approachnpm EquivalentProblem
Global GradleGlobal npmVersion conflicts between projects
Gradle Wrappernpx / corepackConsistent versions, no global install

In Go terms: it’s like if go.mod also pinned the Go compiler version and automatically downloaded it. Go doesn’t do this, but Gradle does.

Everyday commands:

Terminal window
# Build the project (compile + test + package)
./gradlew build
# Run the application (requires `application` plugin)
./gradlew run
# Run tests
./gradlew test
# Clean build outputs
./gradlew clean
# Clean and rebuild
./gradlew clean build
# Run a specific test class
./gradlew test --tests "com.example.UserServiceTest"
# Run a specific test method
./gradlew test --tests "com.example.UserServiceTest.should create user"
# Run tests with console output
./gradlew test --info
# Continuous build (re-run on file changes, like nodemon)
./gradlew build --continuous
# or
./gradlew -t build

Dependency commands:

Terminal window
# Show all dependencies (like `npm ls`)
./gradlew dependencies
# Show runtime dependencies only
./gradlew dependencies --configuration runtimeClasspath
# Why is a dependency included? (like `npm why`)
./gradlew dependencyInsight --dependency kotlin-stdlib --configuration runtimeClasspath
# Check for dependency updates
# (requires the versions plugin: id("com.github.ben-manes.versions"))
./gradlew dependencyUpdates

Project info commands:

Terminal window
# List all tasks
./gradlew tasks
# List all tasks including hidden ones
./gradlew tasks --all
# Show project properties
./gradlew properties
# Show subprojects
./gradlew projects

Multi-module commands:

Terminal window
# Build everything
./gradlew build
# Build a specific module
./gradlew :service-users:build
# Run a specific module
./gradlew :service-users:run
# Test a specific module
./gradlew :common:test

Performance commands:

Terminal window
# Build with build scan (performance analysis)
./gradlew build --scan
# Build with parallel execution
./gradlew build --parallel
# Build with more memory
./gradlew build -Dorg.gradle.jvmargs="-Xmx4g"
# Show build cache stats
./gradlew build --build-cache

Full comparison table:

TaskGradlenpmGo
Install deps./gradlew build (auto)npm installgo mod download
Build./gradlew buildnpm run buildgo build ./...
Run./gradlew runnpm startgo run .
Test./gradlew testnpm testgo test ./...
Clean./gradlew cleanrm -rf dist/go clean
Dep tree./gradlew dependenciesnpm lsgo mod graph
Watch mode./gradlew -t buildnodemonN/A (fast compile)
Format./gradlew ktlintFormatnpx prettier --write .go fmt ./...
Lint./gradlew ktlintChecknpx eslint .go vet ./...
Package./gradlew build (JAR)npm packgo build -o app

Plugins add tasks, configurations, and conventions to your build — think of them as build-system middleware. The npm analogy: tools like eslint, prettier, and jest that plug into your pipeline.

plugins {
// Core plugin (built into Gradle)
application
`java-library`
// Community plugin with version
kotlin("jvm") version "2.1.0"
// Plugin from version catalog
alias(libs.plugins.kotlin.jvm)
// Plugin by ID
id("io.ktor.plugin") version "3.0.3"
}
PluginPurposenpm Equivalent
kotlin("jvm")Compile Kotlintypescript
applicationRun as app, create distts-node / start script
kotlin("plugin.serialization")kotlinx.serializationNone (built into JSON.parse)
kotlin("plugin.spring")Open classes for SpringNone (classes open by default in TS)
id("org.springframework.boot")Spring Boot packagingNone (Express has no build step)
id("io.ktor.plugin")Ktor fat JAR, DockerNone
id("com.google.devtools.ksp")Kotlin Symbol ProcessingBabel plugins

A “fat JAR” bundles your app plus all dependencies into a single JAR file — like pkg or webpack for Node.js, one file to deploy.

plugins {
kotlin("jvm") version "2.1.0"
id("com.gradleup.shadow") version "8.3.5"
application
}
application {
mainClass.set("com.example.AppKt")
}
// Now ./gradlew shadowJar creates build/libs/my-app-all.jar
// Run it with: java -jar build/libs/my-app-all.jar
plugins {
id("org.jlleitschuh.gradle.ktlint") version "12.1.2"
}
// Now you can:
// ./gradlew ktlintCheck -- lint (like `npx eslint .`)
// ./gradlew ktlintFormat -- auto-fix (like `npx prettier --write .`)

gradle.properties holds project-level settings — like .env but for Gradle:

gradle.properties
# JVM memory for the Gradle daemon
org.gradle.jvmargs=-Xmx2g -XX:+UseParallelGC
# Enable parallel build
org.gradle.parallel=true
# Enable build cache
org.gradle.caching=true
# Enable configuration cache (faster repeat builds)
org.gradle.configuration-cache=true
# Project properties (accessible in build.gradle.kts)
kotlin.code.style=official
myapp.version=1.0.0
// Read from gradle.properties (the delegate name must match the property key)
val myVersion: String by project
// Or with findProperty (null-safe)
val dbUrl = findProperty("db.url")?.toString() ?: "jdbc:postgresql://localhost:5432/dev"
// Or from command line: ./gradlew build -Penv=production
val env = findProperty("env")?.toString() ?: "development"

Project properties use -P and are read with project.findProperty(...); system properties use -D and are read with System.getProperty(...):

Terminal window
# Project property (-P): accessible via project.findProperty()
./gradlew build -PmyProp=value
# System property (-D): accessible via System.getProperty()
./gradlew build -Dmy.system.prop=value
# In build.gradle.kts:
# project.findProperty("myProp") -> "value"
# System.getProperty("my.system.prop") -> "value"

You can also override property defaults on the command line:

Terminal window
./gradlew run -Pdb.url=jdbc:postgresql://prod-host:5432/mydb

“Could not resolve dependency” — usually the wrong repository. Make sure the right one is declared:

repositories {
mavenCentral() // Most libraries
google() // Google/Android libs
maven("https://jitpack.io") // GitHub-hosted libs
}

“Unresolved reference” after adding a dependency — the IDE hasn’t synced. In IntelliJ, click the elephant icon in the Gradle toolbar (or press Ctrl+Shift+O); from the terminal, ./gradlew build forces resolution.

Build cache issues — clear progressively:

Terminal window
# Nuclear option: clear everything
./gradlew clean
rm -rf ~/.gradle/caches/
rm -rf .gradle/
# Less aggressive: just clean build outputs
./gradlew clean build --no-build-cache

“Daemon was stopped” or out of memory — give the daemon more heap:

# gradle.properties -- increase memory
org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError

Dependency version conflicts — find and inspect them:

Terminal window
# Find the conflict
./gradlew dependencies --configuration runtimeClasspath | grep -i "FAILED\|CONFLICT"
# See why a specific version was chosen
./gradlew dependencyInsight --dependency problematic-lib

Slow builds — turn on the performance flags:

gradle.properties
org.gradle.parallel=true # Parallel module builds
org.gradle.caching=true # Reuse outputs from previous builds
org.gradle.daemon=true # Keep Gradle process warm (default)
org.gradle.configuration-cache=true # Cache configuration phase
Terminal window
# Generate a build scan for analysis
./gradlew build --scan
ConceptWhat You Learned
Project structurebuild.gradle.kts + settings.gradle.kts + gradle.properties
Dependenciesimplementation vs api vs testImplementation vs runtimeOnly
Version catalogslibs.versions.toml for centralized, type-safe dependency management
Multi-moduleinclude() in settings, project(":module") for inter-module deps
Custom taskstasks.register("name") { doLast { } } with typed tasks
Build lifecycleInit → Configuration → Execution
Gradle wrapperAlways use ./gradlew, commit wrapper files
Build optimizationParallel builds, build cache, configuration cache

Put the build system to work — wire up a real multi-module project end to end.