Setting Up Your Java Toolchain
What You'll Learn
- Create a Gradle project (Kotlin DSL) with the standard Java directory layout and run it immediately.
- Map every Gradle/Maven concept to its Cargo equivalent so you can navigate a Java project from day one.
- Write a
module-info.javafile that exports a public package and requires a standard library module. - Run JUnit 5 tests via Gradle and understand how the test source layout works.
Why This Matters
Every module in this plan assumes you have a working Java project to add code to. Before you can explore records, sealed types, virtual threads, or streams, you need a place to put them — and that place is a Gradle project.
Coming from Rust, the mental model shift is smaller than you might expect. Gradle and Maven fill the same role as Cargo: they resolve dependencies, compile your code, run your tests, and produce publishable artifacts. The names differ, the config files differ, and there are more choices to make — but the job is the same. This module gives you everything you need to create and work inside a Java project for the rest of this plan.
Core Concept
If you have used Cargo, you already understand build tools. The key difference in the Java world is that there are two dominant tools — Maven and Gradle — and you will encounter both in the wild.
Maven uses pom.xml (an XML file) to declare your project. It is deterministic: given the same pom.xml, Maven will always produce the same build. It is convention-heavy: if you follow the standard directory layout, almost nothing needs to be configured. Maven is the Cargo of the Java world if Cargo had chosen XML. It is verbose but predictable, and many regulated environments mandate it precisely because of that predictability.
Gradle uses build.gradle.kts (a Kotlin script) as the primary build file, paired with settings.gradle.kts for project name and subproject declarations. It is faster than Maven — Gradle's incremental builds and build cache can make day-to-day rebuilds 10–30x faster than a clean Maven build — and it is significantly more flexible. The flexibility is a double-edged sword: Gradle gives you more power, but also more rope to hang yourself with. For this plan, Gradle with the Kotlin DSL is the primary tool. It is what most modern Java projects use for greenfield development.
Directory Layout
Both Maven and Gradle assume the same standard directory layout:
payment-processor/
src/
main/
java/ <- Application source code
resources/ <- Config files, SQL scripts, etc.
test/
java/ <- Test source code
build.gradle.kts <- Primary build config
settings.gradle.kts
gradlew <- Gradle wrapper script (commit this)
gradlew.bat
gradle/
wrapper/
gradle-wrapper.properties
This is the same concept as Cargo's src/ directory, just with more nesting to accommodate the separation of test code and resources.
The Gradle Wrapper
The gradlew script (Gradle Wrapper) downloads and runs a specific version of Gradle. Always commit the wrapper files. This is equivalent to checking in a rust-toolchain.toml — it pins the build tool version so every developer and CI server uses the same Gradle.
Everyday Commands
| Cargo command | Gradle equivalent | What it does |
|---|---|---|
cargo build | ./gradlew build | Compile, run tests, produce JAR |
cargo test | ./gradlew test | Compile and run tests only |
cargo run | ./gradlew run | Run the main class (requires application plugin) |
cargo check | ./gradlew compileJava | Compile only, no tests |
cargo clean | ./gradlew clean | Delete the build/ directory |
Dependency Management
In Cargo, you declare dependencies in [dependencies] inside Cargo.toml. In Gradle, you declare them inside the dependencies {} block in build.gradle.kts. Maven's equivalent is the <dependencies> section of pom.xml.
| Concept | Cargo | Maven | Gradle |
|---|---|---|---|
| Manifest file | Cargo.toml | pom.xml | build.gradle.kts |
| Registry | crates.io | Maven Central | Maven Central (default) |
| Local cache | ~/.cargo/registry | ~/.m2/repository | ~/.gradle/caches |
| Lock file | Cargo.lock | N/A (managed internally) | gradle.lockfile (opt-in) |
| Install global tool | cargo install | mvn install | ./gradlew publishToMavenLocal |
| Workspace | Cargo workspace | Maven multi-module | Gradle multi-project |
Dependencies have scopes that determine when they are available:
implementation— available at compile time and runtime, not exposed to consumers (most dependencies go here).testImplementation— available only in test code (equivalent to[dev-dependencies]in Cargo).compileOnly— available at compile time only; not bundled (like a build-time Cargo dependency that isn't linked).
JPMS and module-info.java
Java 9 introduced the Java Platform Module System (JPMS), which lets you declare exactly which packages your code exports and which external modules it requires. You write this in a module-info.java file placed at the root of your source tree.
Note: JPMS is optional. Most Java projects do not use it. You will encounter it in large codebases and standard library code, so you need to know it exists and what it does. This module gives you enough to read and write basic module declarations; a full JPMS deep-dive is out of scope for this plan.
The JPMS module visibility scope — the boundary defined by exports and requires — is analogous to Rust's visibility rules:
| Rust visibility | JPMS equivalent |
|---|---|
pub on a type | exports com.example.payments in module-info.java |
pub(crate) | No exports declaration (package stays internal) |
pub(super) | No direct equivalent; use package structure |
| Crate boundary | Module boundary defined by module-info.java |
Concrete Example
Here is the exact setup for the payment-processor project you will build out through all eight modules in this plan.
Step 1: Initialize with gradle init
Run this in a new directory:
$ mkdir payment-processor && cd payment-processor
$ gradle init
Select type of build to generate:
1: Application
2: Library
...
Enter selection (default: Application) [1..4] 1
Select implementation language:
1: Java
...
Enter selection (default: Java) [1..6] 1
Enter target Java version (min: 7, default: 21): 21
Project name (default: payment-processor): payment-processor
Select build script DSL:
1: Kotlin
2: Groovy
Enter selection (default: Kotlin) [1..2] 1
Generate build using new APIs and behavior (some features may change in the next minor release)? (default: no) [yes, no] no
Gradle generates the project skeleton. You will then replace app/build.gradle.kts contents to match the config below.
Step 2: settings.gradle.kts
rootProject.name = "payment-processor"
include("app")Step 3: app/build.gradle.kts
plugins {
java
application
}
group = "com.example"
version = "0.1.0"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
application {
mainClass = "com.example.payments.Main"
}
tasks.named<Test>("test") {
useJUnitPlatform()
}
Note: The
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }block tells Gradle to compile and run with Java 21. This same block appears in Module 08 when configuringStructuredTaskScope— keep it here and it applies to the whole project.
Step 4: Source Files
Create the directory structure:
app/src/main/java/com/example/payments/Main.java
app/src/test/java/com/example/payments/MainTest.java
Main.java:
package com.example.payments;
public class Main {
public static void main(String[] args) {
System.out.println("Payment Processor starting...");
}
}
MainTest.java:
package com.example.payments;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class MainTest {
@Test
void sanityCheck() {
assertEquals(2, 1 + 1);
}
}Step 5: Build and Test
$ ./gradlew test
> Task :app:test
com.example.payments.MainTest > sanityCheck() PASSED
BUILD SUCCESSFUL in 3s
That is your foundation. Every subsequent module adds classes to src/main/java/com/example/payments/.
Step 6: A Basic module-info.java
Place this file at app/src/main/java/module-info.java:
module com.example.payments {
requires java.base;
exports com.example.payments;
}
This declares the JPMS module scope for your project: any class in com.example.payments is accessible to modules that require com.example.payments; any class in an internal subpackage (e.g., com.example.payments.internal) is hidden unless you add a separate exports line.
Note: Adding
module-info.javachanges how Gradle compiles and runs the project. If you encounter classpath-related errors after adding it, check that your test dependencies are also declared withrequiresor useopen modulesyntax for test access. For this plan's purposes, themodule-info.javaabove is sufficient.
Analogy
Think of a Gradle project as a Cargo workspace with a slightly different shape. The settings.gradle.kts file is your workspace Cargo.toml — it names the root project and lists subprojects. Each subproject's build.gradle.kts is that subproject's Cargo.toml. Maven Central is crates.io. The Gradle wrapper is rust-toolchain.toml.
The module-info.java file is like declaring visibility at the crate level. In Rust, you control what's public by marking items with pub or pub(crate). In Java, without JPMS, everything public in a JAR is accessible to any code on the classpath — there is no crate boundary. JPMS adds that boundary explicitly. Writing exports com.example.payments is the Java equivalent of making types pub at the crate root; not exporting a package is the equivalent of pub(crate).
Going Deeper
JUnit 5 Architecture
JUnit 5 is not a single library — it is three components:
- JUnit Jupiter — The new API:
@Test,@DisplayName,@ParameterizedTest,@Nested, extensions. This is what you write in test files. - JUnit Vintage — A backward-compatibility bridge for JUnit 4 tests. You will see this in legacy codebases.
- JUnit Platform — The launcher that Gradle and IDEs use to discover and run tests.
useJUnitPlatform()inbuild.gradle.ktstells Gradle to use this launcher.
The junit-jupiter dependency in the example above pulls in Jupiter API and Engine together. The junit-platform-launcher is needed from JUnit 5.10 onward for Gradle to work correctly.
Useful annotations beyond @Test:
@DisplayName("Payment validation tests")
class PaymentValidatorTest {
@ParameterizedTest
@ValueSource(doubles = {-1.0, 0.0, Double.NaN})
void rejectsInvalidAmounts(double amount) {
// amount is injected for each value
}
@Nested
@DisplayName("when account is missing")
class WhenAccountIsMissing {
@Test
void returnsFailure() { ... }
}
}Multi-Project Gradle Builds
For larger projects, you split code across subprojects (the Gradle term for what Cargo calls crates in a workspace). Your settings.gradle.kts lists them:
rootProject.name = "payment-processor"
include("api", "core", "gateway")
Each subproject has its own build.gradle.kts. The core project can depend on api:
// core/build.gradle.kts
dependencies {
implementation(project(":api"))
}
This mirrors Cargo workspace members depending on each other via path dependencies.
Maven Central vs. crates.io
Maven Central is the default registry for Java dependencies. Unlike crates.io, it has no central identity system — anyone can publish to Maven Central if they own a domain name matching the group ID (e.g., com.example). This means you need to be more deliberate about evaluating unfamiliar dependencies. Look at download counts, GitHub activity, and whether the library is maintained by a recognizable organization.
Common pitfall: Unlike Cargo, Maven and Gradle do not lock transitive dependencies by default. If library A depends on library B at version 1.0 and library C depends on library B at version 2.0, Gradle picks one using its conflict resolution strategy (newest wins by default). This can cause silent version changes across builds. For reproducible builds, either use Gradle's dependency locking (
./gradlew dependencies --write-locks) or specify exact versions for all critical dependencies.
Common Misconceptions
1. "I should learn the Groovy DSL instead of the Kotlin DSL."
New projects should use the Kotlin DSL (build.gradle.kts). It provides type-safe autocompletion in IDEs, catches configuration errors at compile time, and is the direction Gradle is actively investing in. The Groovy DSL (build.gradle) still works and is common in older projects, but you do not need to learn it for new work. If you read a Groovy build script, the structure is nearly identical — just without the type safety.
2. "JPMS is required for modern Java."
JPMS is optional. The vast majority of Java projects — including large production systems — do not use module-info.java. The JVM still runs without it; you simply operate on the unnamed module (the classic classpath). JPMS is valuable for library authors who want strong API encapsulation, and for modular application deployments with jlink. For the projects in this plan, it is introduced so you know what it is, not because every Java project uses it.
3. "The Gradle cache is like a node_modules folder — delete it to fix things."
The Gradle user home cache (~/.gradle/caches) is shared across all your projects, like Cargo's ~/.cargo/registry. Deleting it just forces a re-download of all dependencies. Use ./gradlew clean to delete the project's build outputs (build/ directory), which is the equivalent of cargo clean. The global cache rarely needs manual intervention.
Check Your Understanding
-
In Cargo,
[dev-dependencies]are dependencies only available when running tests. What is the Gradle equivalent, and where do you declare it?Answer: The Gradle equivalent is the
testImplementationconfiguration. You declare it inside thedependencies {}block inbuild.gradle.kts:dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") }Dependencies declared with
testImplementationare available only when compiling and running tests — they are not included in the production JAR. -
You write a helper class in a subpackage
com.example.payments.internal.utils.CurrencyUtils. You want it accessible within thecom.example.paymentsmodule but not to external modules. What do you put inmodule-info.javato achieve this?Answer: Nothing — you simply omit an
exportsdeclaration forcom.example.payments.internal.utils. JPMS module scope hides any package not explicitly exported. Onlyexports com.example.payments(or another explicit export) would make a package visible. Internal packages that are not exported remain inaccessible to code outside the module, even if the class itself is declaredpublic. -
A colleague tells you to delete
gradlewand just install Gradle globally. Why is this a bad idea?Answer: The Gradle Wrapper (
gradlew) pins the exact Gradle version for the project. Without it, different developers may use different Gradle versions, leading to inconsistent builds — the same problem as not committingrust-toolchain.toml. The wrapper also ensures CI servers use the correct version automatically. Always commitgradlew,gradlew.bat, andgradle/wrapper/gradle-wrapper.properties. -
What is the difference between
./gradlew buildand./gradlew testin terms of what they do?Answer:
./gradlew testcompiles source and test code, then runs the tests../gradlew builddoes everythingtestdoes plus assembles the output artifact (a JAR file inbuild/libs/). For day-to-day development when you just want to verify your code compiles and tests pass,./gradlew testis sufficient and slightly faster. Use./gradlew buildwhen you need the JAR (for deployment, publishing, or manual inspection). -
You add a class
FraudDetectorto thecom.example.paymentspackage. Yourmodule-info.javahasexports com.example.payments. IsFraudDetectorautomatically accessible to other modules?Answer: Yes, if
FraudDetectoritself is declaredpublic. The JPMS module scope boundary works in two layers: first, the package must be exported (yourexportsdeclaration handles this); second, the type within the package must bepublic. A non-publicFraudDetectorin an exported package is still inaccessible. Both conditions must be true for external code to use the type.
Key Takeaways
- Gradle (Kotlin DSL) is the primary Java build tool for new projects.
build.gradle.ktsis yourCargo.toml;settings.gradle.ktsnames the project and lists submodules. - The three everyday Gradle commands are
./gradlew build,./gradlew test, and./gradlew run— direct analogs to their Cargo equivalents. - Maven is deterministic and convention-heavy (good for regulated environments); Gradle is flexible and faster (good for complex builds). You will read both in the wild.
module-info.javadefines the JPMS module scope: packages listed inexportsare public to other modules; all others are hidden, regardless of their Java visibility modifier.- JUnit 5 (Jupiter) is the standard test framework.
useJUnitPlatform()inbuild.gradle.ktswires it into Gradle's test runner.@Test,@DisplayName, and@ParameterizedTestare the annotations you will use most.