Modern Java for Rust Engineers
Module 1 of 8 Beginner 30 min

Setting Up Your Java Toolchain

What You'll Learn

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 commandGradle equivalentWhat it does
cargo build./gradlew buildCompile, run tests, produce JAR
cargo test./gradlew testCompile and run tests only
cargo run./gradlew runRun the main class (requires application plugin)
cargo check./gradlew compileJavaCompile only, no tests
cargo clean./gradlew cleanDelete 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.

ConceptCargoMavenGradle
Manifest fileCargo.tomlpom.xmlbuild.gradle.kts
Registrycrates.ioMaven CentralMaven Central (default)
Local cache~/.cargo/registry~/.m2/repository~/.gradle/caches
Lock fileCargo.lockN/A (managed internally)gradle.lockfile (opt-in)
Install global toolcargo installmvn install./gradlew publishToMavenLocal
WorkspaceCargo workspaceMaven multi-moduleGradle multi-project

Dependencies have scopes that determine when they are available:

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 visibilityJPMS equivalent
pub on a typeexports com.example.payments in module-info.java
pub(crate)No exports declaration (package stays internal)
pub(super)No direct equivalent; use package structure
Crate boundaryModule 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 configuring StructuredTaskScope — 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.java changes how Gradle compiles and runs the project. If you encounter classpath-related errors after adding it, check that your test dependencies are also declared with requires or use open module syntax for test access. For this plan's purposes, the module-info.java above 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:

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

  1. 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 testImplementation configuration. You declare it inside the dependencies {} block in build.gradle.kts:

    dependencies {
        testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
    }

    Dependencies declared with testImplementation are available only when compiling and running tests — they are not included in the production JAR.

  2. You write a helper class in a subpackage com.example.payments.internal.utils.CurrencyUtils. You want it accessible within the com.example.payments module but not to external modules. What do you put in module-info.java to achieve this?

    Answer: Nothing — you simply omit an exports declaration for com.example.payments.internal.utils. JPMS module scope hides any package not explicitly exported. Only exports 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 declared public.

  3. A colleague tells you to delete gradlew and 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 committing rust-toolchain.toml. The wrapper also ensures CI servers use the correct version automatically. Always commit gradlew, gradlew.bat, and gradle/wrapper/gradle-wrapper.properties.

  4. What is the difference between ./gradlew build and ./gradlew test in terms of what they do?

    Answer: ./gradlew test compiles source and test code, then runs the tests. ./gradlew build does everything test does plus assembles the output artifact (a JAR file in build/libs/). For day-to-day development when you just want to verify your code compiles and tests pass, ./gradlew test is sufficient and slightly faster. Use ./gradlew build when you need the JAR (for deployment, publishing, or manual inspection).

  5. You add a class FraudDetector to the com.example.payments package. Your module-info.java has exports com.example.payments. Is FraudDetector automatically accessible to other modules?

    Answer: Yes, if FraudDetector itself is declared public. The JPMS module scope boundary works in two layers: first, the package must be exported (your exports declaration handles this); second, the type within the package must be public. A non-public FraudDetector in an exported package is still inaccessible. Both conditions must be true for external code to use the type.

Key Takeaways