Free Handbook · Every example compiled & verified

Java + Tools

Nine mini-labs on Java as it is used at work: Maven, Gradle, JUnit 5, Spring Boot, PostgreSQL over JDBC, Hibernate, Docker, Kafka and Git.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 12 · what you'll be able to do

  • Read and write a Maven pom.xml and a Gradle build file, and run the standard build and test commands
  • Write JUnit 5 tests with assertions, parameterized cases and assertThrows
  • Build a small Spring Boot REST endpoint and query PostgreSQL safely with JDBC and with Hibernate/JPA
  • Package a Java service in a multi-stage Docker image that runs as a non-root user
  • Send and receive Kafka messages from plain Java, and keep build output out of Git
01

The Java toolchain at a glance

Up to now every program has been one Main.java run with java Main.java. Real Java projects have hundreds of classes and dozens of libraries, so a job listing that says "Java" nearly always means Java plus a build tool, a test framework, a web framework and a database layer. These labs show the smallest real version of each. The code here uses libraries, so it is shown as static snippets rather than verified examples.

ToolJobYou meet it when
Maven / GradleDownload libraries, compile, test, package a JARDay one of any Java job
JUnit 5Unit testsEvery pull request
Spring BootWeb APIs, dependency injection, configurationMost backend Java roles
JDBC / Hibernate (JPA)Talk to SQL databasesAny service that stores data
DockerShip the app with its runtimeDeploying anywhere
KafkaEvent streaming between servicesMicroservices and data platforms
GitVersion controlEvery day
02

Java + Maven

Maven is the most common Java build tool. A pom.xml declares the project's coordinates (groupId, artifactId, version), the Java version, and its dependencies. Maven downloads each dependency from Maven Central into ~/.m2 and puts it on the classpath. Sources live in src/main/java, tests in src/test/java — every Java developer expects that layout.

Java + Apache Maven

A minimal pom.xml for a Java 21 project with tests

The maven.compiler.release property compiles for Java 21 no matter which newer JDK is installed. JUnit is declared with <scope>test</scope>, so it is available to tests but never shipped in the final JAR. Run mvn test to compile and test, mvn package to build target/inventory-1.0.0.jar.

xml
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example</groupId>
  <artifactId>inventory</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.13.4</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <!-- runs JUnit 5 tests during mvn test -->
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.3</version>
      </plugin>
    </plugins>
  </build>
</project>

<!--
  mvn clean test          compile + run tests
  mvn package             build target/inventory-1.0.0.jar
  mvn dependency:tree     see every library and why it is there
-->
Use the wrapper
Projects commit mvnw (the Maven Wrapper) so everyone and every CI job builds with the same Maven version. Type ./mvnw test instead of mvn test in any project that has it.
03

Java + Gradle

Gradle does the same job as Maven with a build script instead of XML. It is the default for Android and common in newer backend teams because it is faster on large builds (incremental compilation and a build cache). The directory layout is identical to Maven's.

Java + Gradle

build.gradle.kts with a Java toolchain and JUnit 5

The toolchain block tells Gradle which JDK to compile with (and download it if missing), so the build no longer depends on what is installed on the laptop. implementation is a normal dependency; testImplementation is test-only, like Maven's test scope. useJUnitPlatform() is the line people forget — without it Gradle finds no JUnit 5 tests.

kotlin
// build.gradle.kts
plugins {
    application
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.google.guava:guava:33.4.8-jre")
    testImplementation(platform("org.junit:junit-bom:5.13.4"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

application {
    mainClass = "com.example.inventory.Main"
}

tasks.test {
    useJUnitPlatform()
}

// ./gradlew test     compile + run tests
// ./gradlew run      run mainClass
// ./gradlew build    test + package into build/libs/

Maven

  • XML, declarative, very predictable
  • Most common in enterprise backends
  • Output in target/

Gradle

  • Kotlin or Groovy script, flexible
  • Standard for Android, faster on big builds
  • Output in build/
04

Java + JUnit 5

JUnit 5 (the Jupiter API) is the standard Java test framework. A test is a method annotated @Test in a class under src/test/java. Assertions check results; assertThrows checks that bad input fails the way you expect; @ParameterizedTest runs one test over many inputs.

Java + JUnit5

Testing a price calculator: happy path, table of cases, and failure

Each test follows Arrange, Act, Assert. The parameterized test replaces four copy-pasted tests with one method and a table of inputs. assertThrows returns the exception, so you can assert on its message too. Run with ./mvnw test or ./gradlew test; a failure prints expected versus actual.

java
// src/main/java/com/example/shop/Pricing.java
package com.example.shop;

public class Pricing {
    public static int totalCents(int unitCents, int qty) {
        if (qty < 0) throw new IllegalArgumentException("qty must be >= 0, was " + qty);
        int total = unitCents * qty;
        return qty >= 10 ? total * 90 / 100 : total;   // 10% bulk discount
    }
}

// src/test/java/com/example/shop/PricingTest.java
package com.example.shop;

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;

class PricingTest {

    @Test
    void singleItemHasNoDiscount() {
        assertEquals(250, Pricing.totalCents(250, 1));
    }

    @ParameterizedTest(name = "{1} x {0} cents = {2}")
    @CsvSource({
        "100, 0,  0",
        "100, 9,  900",
        "100, 10, 900",     // discount starts at 10
        "100, 20, 1800",
    })
    void bulkDiscount(int unit, int qty, int expected) {
        assertEquals(expected, Pricing.totalCents(unit, qty));
    }

    @Test
    void negativeQuantityIsRejected() {
        var e = assertThrows(IllegalArgumentException.class, () -> Pricing.totalCents(100, -1));
        assertTrue(e.getMessage().contains("-1"));
    }
}
Test the edges
The cases worth writing are the boundaries: 0, 1, the value just below a threshold (9) and exactly at it (10), and invalid input. That is the same "test small cases" habit from Problem Solving, written down so it runs on every commit.
05

Java + Spring Boot

Spring Boot is the framework behind most Java web services. You write plain classes; annotations tell Spring what they are. @RestController turns methods into HTTP endpoints, records are converted to and from JSON automatically, and constructor parameters are injected — Spring creates the @Service and passes it in, so the controller never calls new.

Java + Spring Boot

A REST endpoint with validation and dependency injection

Start from start.spring.io with the Spring Web and Validation dependencies, add these classes, and run ./mvnw spring-boot:run. GET /api/products/1 returns JSON; a POST with a blank name is rejected with HTTP 400 before your code runs. The controller only translates HTTP; the logic lives in the service, where it is easy to unit test.

java
package com.example.shop;

import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;

@SpringBootApplication
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

record Product(long id, String name, int priceCents) {}
record NewProduct(@NotBlank String name, @PositiveOrZero int priceCents) {}

@Service
class ProductService {
    private final Map<Long, Product> store = new ConcurrentHashMap<>();   // many requests at once
    private final AtomicLong ids = new AtomicLong();

    Optional<Product> find(long id) { return Optional.ofNullable(store.get(id)); }

    Product create(NewProduct p) {
        long id = ids.incrementAndGet();
        Product saved = new Product(id, p.name(), p.priceCents());
        store.put(id, saved);
        return saved;
    }
}

@RestController
@RequestMapping("/api/products")
class ProductController {
    private final ProductService service;

    ProductController(ProductService service) {   // injected by Spring
        this.service = service;
    }

    @GetMapping("/{id}")
    ResponseEntity<Product> get(@PathVariable long id) {
        return ResponseEntity.of(service.find(id));  // 200 with body, or 404
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    Product create(@Valid @RequestBody NewProduct body) {
        return service.create(body);
    }
}

// curl -X POST localhost:8080/api/products -H "Content-Type: application/json" \
//      -d '{"name":"Pen","priceCents":150}'
// curl localhost:8080/api/products/1
Dependency injection and the patterns Spring is built on →
In real jobs
Controllers are singletons shared by every request thread, which is why the store above is a ConcurrentHashMap and the id an AtomicLong — the rules from Concurrency apply directly. Keep request-specific data in local variables, never in controller or service fields.
06

Java + PostgreSQL (JDBC)

JDBC is the standard Java API for SQL databases; every framework above it (Hibernate, Spring Data, jOOQ) eventually calls JDBC. Knowing it directly makes you better at debugging all of them. The two rules: always use a PreparedStatement with ? placeholders, and always close connections with try-with-resources.

Java + PostgreSQL

Query and insert with PreparedStatement and a transaction

Add the driver org.postgresql:postgresql to your build. The ? placeholders send values separately from the SQL text, so a name like '; DROP TABLE users; -- is just a string, never code — that is how you prevent SQL injection. setAutoCommit(false) groups the two updates into one transaction: both happen or neither does.

java
import java.sql.*;

public class Accounts {
    static final String URL = "jdbc:postgresql://localhost:5432/bank";

    public static void main(String[] args) throws SQLException {
        try (Connection db = DriverManager.getConnection(URL, "app", System.getenv("DB_PASSWORD"))) {

            // 1. parameterised query
            String sql = "SELECT id, owner, balance_cents FROM accounts WHERE owner = ?";
            try (PreparedStatement ps = db.prepareStatement(sql)) {
                ps.setString(1, "ada");
                try (ResultSet rs = ps.executeQuery()) {
                    while (rs.next()) {
                        System.out.println(rs.getLong("id") + " " + rs.getLong("balance_cents"));
                    }
                }
            }

            // 2. transfer money atomically
            db.setAutoCommit(false);
            try (PreparedStatement debit = db.prepareStatement(
                     "UPDATE accounts SET balance_cents = balance_cents - ? WHERE id = ? AND balance_cents >= ?");
                 PreparedStatement credit = db.prepareStatement(
                     "UPDATE accounts SET balance_cents = balance_cents + ? WHERE id = ?")) {
                debit.setLong(1, 500); debit.setLong(2, 1); debit.setLong(3, 500);
                if (debit.executeUpdate() != 1) throw new SQLException("insufficient funds");
                credit.setLong(1, 500); credit.setLong(2, 2);
                credit.executeUpdate();
                db.commit();
            } catch (SQLException e) {
                db.rollback();          // undo the half-done transfer
                throw e;
            }
        }
    }
}
Sharpen the SQL itself in SQL Mastery →
Never build SQL with +
"... WHERE owner = '" + name + "'" is the textbook SQL injection bug and fails every code review and security scan. Placeholders are also faster, because the database can reuse the query plan.
07

Java + Hibernate (JPA)

Writing JDBC for every table gets repetitive. JPA (Jakarta Persistence) is the standard for mapping classes to tables, and Hibernate is the implementation almost everyone uses. You annotate an @Entity; Hibernate generates the SQL. With Spring Data JPA you do not even write the repository — you declare an interface and Spring implements it from the method names.

Java + Hibernate

An entity, a relationship and a derived query with Spring Data JPA

Each CustomerOrder row belongs to one Customer. findByCustomerEmail is parsed by Spring into a join query. The @Transactional service method changes an entity and Hibernate writes the UPDATE when the transaction commits — no save call needed, because the entity is managed. Turn on spring.jpa.show-sql=true while learning so you see every statement Hibernate sends.

java
import jakarta.persistence.*;
import org.springframework.data.jpa.repository.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Entity
@Table(name = "customers")
class Customer {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    @Column(nullable = false, unique = true)
    String email;
}

@Entity
@Table(name = "orders")
class CustomerOrder {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    Customer customer;

    @Enumerated(EnumType.STRING)
    Status status = Status.NEW;

    enum Status { NEW, PAID, SHIPPED }
}

interface OrderRepository extends JpaRepository<CustomerOrder, Long> {
    List<CustomerOrder> findByCustomerEmail(String email);            // query derived from the name

    @Query("select o from CustomerOrder o join fetch o.customer where o.status = :status")
    List<CustomerOrder> findWithCustomer(CustomerOrder.Status status);         // one query, no N+1
}

@Service
class OrderService {
    private final OrderRepository orders;
    OrderService(OrderRepository orders) { this.orders = orders; }

    @Transactional
    void markPaid(long id) {
        CustomerOrder o = orders.findById(id).orElseThrow();
        o.status = CustomerOrder.Status.PAID;          // dirty checking writes the UPDATE on commit
    }
}
The N+1 problem
Loading 100 orders and then touching order.customer in a loop can fire 1 query for the orders plus 100 for the customers. It is the most common JPA performance bug in production. Watch the SQL log, and use a join fetch query (as above) or an entity graph when you know you need the related rows.
08

Java + Docker

A Docker image bundles your JAR with the exact Java runtime it needs, so it runs the same on a laptop, in CI and in Kubernetes. The standard shape is a multi-stage build: a JDK image compiles and tests, and only the finished JAR is copied into a much smaller JRE image.

Java + Docker

A multi-stage Dockerfile for a Maven Spring Boot app

Copying pom.xml and downloading dependencies before copying the source means Docker caches that slow layer; editing a Java file only rebuilds from COPY src. The final image has no compiler, no Maven and no source code, and runs as a non-root user. Modern JVMs read the container's memory limit, and MaxRAMPercentage sizes the heap from it.

dockerfile
# ---- build stage ----
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw -q dependency:go-offline      # cached until pom.xml changes
COPY src src
RUN ./mvnw -q package                    # compiles AND runs the tests

# ---- run stage ----
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 app
COPY --from=build /src/target/*.jar app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

# docker build -t shop:1.0 .
# docker run --rm -p 8080:8080 -m 512m shop:1.0
Tests inside the build
Because mvnw package runs the tests, a failing test fails docker build, and a broken image can never be produced. If your CI runs tests in a separate step, add -DskipTests here to avoid running them twice.
09

Java + Apache Kafka

Kafka is a distributed log that services use to publish events ("order placed") that other services consume in their own time. Kafka itself is written in Java and Scala, and its Java client (org.apache.kafka:kafka-clients) is the reference client every other language copies. A producer sends records to a topic; a consumer in a consumer group polls them.

Java + Apache Kafka

A producer and a consumer with the plain Java client

The record key (the order id) decides the partition, so all events for one order stay in order. acks=all waits until the replicas have the record before calling it sent. The consumer loop polls in batches and commits offsets automatically; running two copies with the same group.id splits the partitions between them. Both clients are AutoCloseable.

java
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.*;
import java.time.Duration;
import java.util.*;

public class Orders {
    static final String TOPIC = "orders";

    static void produce() {
        Properties p = new Properties();
        p.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        p.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        p.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        p.put(ProducerConfig.ACKS_CONFIG, "all");

        try (Producer<String, String> producer = new KafkaProducer<>(p)) {
            var record = new ProducerRecord<>(TOPIC, "order-42", "{\"status\":\"PLACED\"}");
            producer.send(record, (meta, err) -> {
                if (err != null) err.printStackTrace();
                else System.out.println("sent to partition " + meta.partition() + " offset " + meta.offset());
            });
        }   // close() flushes pending sends
    }

    static void consume() {
        Properties c = new Properties();
        c.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        c.put(ConsumerConfig.GROUP_ID_CONFIG, "billing");
        c.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        c.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        c.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");

        try (Consumer<String, String> consumer = new KafkaConsumer<>(c)) {
            consumer.subscribe(List.of(TOPIC));
            while (true) {
                for (ConsumerRecord<String, String> r : consumer.poll(Duration.ofMillis(500))) {
                    System.out.println(r.key() + " -> " + r.value());
                }
            }
        }
    }
}
Where Kafka fits in a data platform →
In real jobs
Consumers must be idempotent: after a crash, Kafka redelivers records whose offsets were not committed, so the same event can arrive twice. Store the processed event id (or use a unique key in the database) so a duplicate becomes a no-op.
10

Java + Git

Git needs almost no Java-specific setup, but a Java repository has a lot of generated files that must never be committed: compiled .class files, the target/ or build/ output, IDE settings, and downloaded dependencies. Committing them causes endless merge conflicts and bloats the repository.

Java + Git

A Java .gitignore, and what you DO commit

Commit what is needed to rebuild the project from scratch: sources, tests, the build file, and the build wrapper (mvnw, .mvn/wrapper/, or gradlew and gradle/wrapper/). Ignore everything the build or the IDE can regenerate. The ! line re-includes the wrapper JAR that *.jar would otherwise hide.

bash
# .gitignore for a Java project

# build output
target/
build/
out/
*.class
*.jar
!.mvn/wrapper/maven-wrapper.jar
!gradle/wrapper/gradle-wrapper.jar

# Gradle caches
.gradle/

# IDEs
.idea/
*.iml
.vscode/
.classpath
.project
.settings/

# secrets and local config
.env
application-local.properties

# OS noise
.DS_Store

# --- then, the usual loop ---
# git switch -c feature/bulk-discount
# ./mvnw test && git add -A && git commit -m "Add 10% bulk discount at qty >= 10"
# git push -u origin feature/bulk-discount
Build tool
Maven or Gradle: downloads dependencies, compiles, runs tests and packages the JAR.
Dependency scope
Where a library is available: compile/implementation everywhere, test/testImplementation in tests only.
Dependency injection
The framework creates objects and passes them into constructors, instead of classes calling new on their collaborators.
JDBC
The standard Java API for talking to SQL databases.
JPA / Hibernate
Jakarta Persistence maps classes to tables; Hibernate is its most used implementation.
Multi-stage build
A Dockerfile that compiles in a big JDK image and ships only the JAR in a small JRE image.
Consumer group
Kafka consumers sharing a group id, which split a topic's partitions between them.
Quick check

Your Gradle build compiles, but ./gradlew test reports that no tests ran even though you wrote JUnit 5 tests. What is the most likely cause?

Frequently asked questions

Should I learn Maven or Gradle first?
Learn to read both, and start with Maven: its pom.xml is declarative and most enterprise Java projects and tutorials use it. The concepts — coordinates, dependencies, scopes, the src/main and src/test layout — transfer directly to Gradle, which you will need for Android and many newer backends.
Do I need Spring Boot to get a Java job?
For backend roles, very often yes: Spring Boot is the most common Java web framework in job postings. Learn core Java first (this handbook), then build one small Spring Boot REST API with a database and tests. That single project covers most of what a junior interview asks about.
What is the difference between JDBC and Hibernate?
JDBC is the low-level API: you write SQL and read rows from a ResultSet by hand. Hibernate is an ORM built on top of JDBC: you work with annotated entity classes and it generates the SQL. Most teams use Hibernate through Spring Data JPA, and drop down to plain SQL for complex or performance-critical queries.

Finish the Java handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.