How to buildOctober 11, 202610 min read
JavaStep 1 of 6

Java for backend: syntax, types, collections and Maven

In short

Backend Java needs a small but dense core: static types and var, classes and record for data, the List, Set and Map collections, the Stream API to process them, Optional instead of null, and a clear grasp of checked and unchecked exceptions. Maven builds the project: pom.xml declares the Java version and dependencies, and mvn package compiles the code, runs JUnit tests and packages a jar. The current LTS release is Java 25.

TutorialLevel: BeginnerJavaPractice: ~1.5–3 h

After this you will be able to

  • Model data with a record validated in its compact constructor and an enum for a fixed set of values
  • Choose between List, Set and Map and process a collection with the Stream API: filter, map, sorted, groupingBy
  • Return an Optional instead of null and tell a checked exception from an unchecked one
  • Build a Maven project with JUnit tests using mvn package and run the resulting jar

Verified with

Java 25 LTS (Eclipse Temurin 25.0.4)Maven 3.10.0JUnit 6.1.3maven-compiler-plugin 3.16.0maven-surefire-plugin 3.6.0

Checked against docs: October 11, 2026

Contents
  1. 01Java and the JVM: from source to a running program
  2. 02Types, var, classes, enums and records
  3. 03Collections: List, Set and Map
  4. 04The Stream API: filtering, sorting, grouping
  5. 05Optional instead of null
  6. 06Exceptions: checked and unchecked
  7. 07Maven: pom.xml, the build and a JUnit test
  8. 08Common mistakes, exercises and next steps

Java and the JVM: from source to a running program

Java is a compiled, statically typed language: the javac compiler checks the types of variables and methods before the program runs. It turns a .java source file into bytecode rather than machine code — .class files executed by the Java Virtual Machine (JVM). That is why the same jar archive runs on a Linux server, in a Docker container and on a developer laptop. The development kit is called the JDK: it bundles the compiler, the JVM and tools; a server only needs a runtime, for example the eclipse-temurin:25-jre image.

Hello.javajavacHello.classJVMOutput
build (JDK)run
The compiler turns source into bytecode, the JVM executes it.

A new Java release ships every six months, but servers run long-term support (LTS) versions. According to Oracle’s support roadmap, the current LTS is Java 25 (September 2025) and the previous one is Java 21. All code in this lesson was checked on Java 25 (Eclipse Temurin 25.0.4); the project classes, except Hello.java, also compile on Java 21.

Hello.java
// A compact source file (Java 25): no class declaration and no static
void main() {
    String name = "InterLAB";
    int year = 2026;
    var services = 3; // var — the type int is inferred from the value
    IO.println("Hello, " + name + "! Year: " + year + ", services: " + services);
}
A compact source file (JEP 512, final in Java 25): handy for first steps and scripts.
terminal
$ java --version
openjdk 25.0.4.1 2026-08-18 LTS
…
$ java Hello.java
Hello, InterLAB! Year: 2026, services: 3
java Hello.java compiles the file in memory and runs it right away — no separate javac call.

Types, var, classes, enums and records

Java has two kinds of types. Primitives (int, long, double, boolean, char) hold the value itself and can never be null. Everything else is a reference type: String, List, your own classes and records. A reference variable holds a pointer to an object and may be null — the source of the most common beginner error, NullPointerException.

int

What it holds
an integer up to ~2.1 billion
Example
int minutes = 60;

long

What it holds
a large integer: IDs, amounts in minor units
Example
long id = 10_000_000_000L;

double

What it holds
a floating-point number
Example
double rating = 4.8;

boolean

What it holds
true or false
Example
boolean active = true;

String

What it holds
a reference to an immutable string
Example
String title = "Manicure";

BigDecimal

What it holds
an exact decimal — for money with cents
Example
new BigDecimal("19.99")

The var keyword (since Java 10) asks the compiler to infer the type from the value: var services = 3 is still an int, not “any type”. As dev.java explains, var works only for local variables with an initializer — not for fields or method parameters; var x; without a value does not compile.

The lesson’s practice project is a salon service catalog. The set of categories is fixed, so it is an enum: the compiler will not let you pass a misspelled category as a string. The service itself is a record.

src/main/java/com/example/catalog/Category.java
package com.example.catalog;

public enum Category {
    HAIR,
    NAILS,
    SPA
}
src/main/java/com/example/catalog/ServiceItem.java
package com.example.catalog;

// A record is an immutable data carrier: the compiler generates the fields,
// constructor, accessors, equals, hashCode and toString
public record ServiceItem(String code, String title, Category category, int price, int minutes) {

    // compact constructor: checks run before the fields are assigned
    public ServiceItem {
        if (code == null || code.isBlank()) {
            throw new IllegalArgumentException("Service code must not be blank");
        }
        if (price < 0) {
            throw new IllegalArgumentException("Price cannot be negative: " + price);
        }
        if (minutes <= 0) {
            throw new IllegalArgumentException("Duration must be greater than zero: " + minutes);
        }
    }
}

A single line, record ServiceItem(...), replaces a fifty-line class. According to dev.java, the compiler creates a private final field for every component, an accessor with the same name (item.price(), not getPrice()), the canonical constructor, and equals, hashCode and toString. A record class is implicitly final, cannot extend another class, and cannot declare instance fields beyond its components. The compact constructor is where validation goes: an object with a negative price simply never gets created.

Collections: List, Set and Map

Java collections are interfaces plus implementations. You declare a variable with the interface type (List<String>) and pick the concrete class to the right of = (new ArrayList<>()), so the implementation can change without touching the rest of the code. The angle brackets are generics: a List<String> holds only strings, and the compiler enforces it.

CollectionsDemo.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class CollectionsDemo {

    public static void main(String[] args) {
        // List — an ordered list, duplicates allowed
        List<String> visits = new ArrayList<>();
        visits.add("haircut");
        visits.add("manicure");
        visits.add("haircut");
        System.out.println(visits + ", size " + visits.size()); // [haircut, manicure, haircut], size 3
        System.out.println(visits.get(1)); // manicure

        // Set — unique values only
        Set<String> unique = new HashSet<>(visits);
        System.out.println(unique.size() + " " + unique.contains("haircut")); // 2 true

        // Map — key → value pairs, keys are unique
        Map<String, Integer> prices = new HashMap<>();
        prices.put("haircut", 9000);
        prices.put("manicure", 8000);
        prices.put("haircut", 9500); // same key: the value is overwritten
        System.out.println(prices.get("haircut")); // 9500
        System.out.println(prices.get("pedicure")); // null — no such key
        System.out.println(prices.getOrDefault("pedicure", 0)); // 0

        // List.of — an unmodifiable list
        List<String> fixed = List.of("HAIR", "NAILS", "SPA");
        try {
            fixed.add("BARBER");
        } catch (UnsupportedOperationException e) {
            System.out.println("List.of cannot be changed"); // List.of cannot be changed
        }
    }
}
Run: java CollectionsDemo.java. The comments on the right are the actual output.

List

Common implementation
ArrayList
When to use
an ordered sequence, index access, duplicates allowed

Set

Common implementation
HashSet, TreeSet
When to use
unique values only; fast contains; TreeSet keeps sorted order

Map

Common implementation
HashMap, LinkedHashMap, TreeMap
When to use
lookup by key; LinkedHashMap remembers insertion order, TreeMap sorts keys

The Stream API: filtering, sorting, grouping

A stream is a processing pipeline over a collection. It stores nothing itself: the source (values().stream()) supplies elements, intermediate operations (filter, map, sorted) only describe steps and return a new stream, and the work starts at a terminal operation (toList, collect, sum). The whole service catalog is built from these operations.

src/main/java/com/example/catalog/ServiceCatalog.java
package com.example.catalog;

import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Collectors;

public class ServiceCatalog {

    // LinkedHashMap keeps insertion order; the key is the service code
    private final Map<String, ServiceItem> byCode = new LinkedHashMap<>();

    public void add(ServiceItem item) {
        if (byCode.containsKey(item.code())) {
            throw new IllegalArgumentException("Service with code " + item.code() + " already exists");
        }
        byCode.put(item.code(), item);
    }

    public int size() {
        return byCode.size();
    }

    // the service may be missing — say so with the type, not with null
    public Optional<ServiceItem> findByCode(String code) {
        return Optional.ofNullable(byCode.get(code));
    }

    // services of a category, cheapest first
    public List<ServiceItem> byCategory(Category category) {
        return byCode.values().stream()
                .filter(item -> item.category() == category)
                .sorted(Comparator.comparingInt(ServiceItem::price))
                .toList();
    }

    // titles of services that fit the budget
    public List<String> titlesWithin(int budget) {
        return byCode.values().stream()
                .filter(item -> item.price() <= budget)
                .map(ServiceItem::title)
                .toList();
    }

    // categories with at least one service; TreeSet keeps them in enum order
    public Set<Category> categories() {
        return byCode.values().stream()
                .map(ServiceItem::category)
                .collect(Collectors.toCollection(TreeSet::new));
    }

    // number of services per category: {HAIR=2, NAILS=1, SPA=2}
    public Map<Category, Long> countByCategory() {
        return byCode.values().stream()
                .collect(Collectors.groupingBy(ServiceItem::category, TreeMap::new, Collectors.counting()));
    }

    // duration of a visit with several services; an unknown code is the caller's mistake
    public int totalMinutes(List<String> codes) {
        return codes.stream()
                .map(code -> findByCode(code)
                        .orElseThrow(() -> new IllegalArgumentException("No service with code " + code)))
                .mapToInt(ServiceItem::minutes)
                .sum();
    }
}

filter(item -> …)

Kind
intermediate
What it does in the catalog
keeps services of a category or within budget

map(ServiceItem::title)

Kind
intermediate
What it does in the catalog
turns a service into its title

sorted(Comparator.comparingInt(…))

Kind
intermediate
What it does in the catalog
sorts by price

toList()

Kind
terminal
What it does in the catalog
collects the result into an unmodifiable List

collect(groupingBy(…, counting()))

Kind
terminal
What it does in the catalog
counts services per category into a Map

mapToInt(…).sum()

Kind
terminal
What it does in the catalog
sums up the visit minutes

ServiceItem::title is a method reference, a shorthand for the lambda item -> item.title(). The byCode field is private final: from the outside the catalog changes only through add, which protects the “service codes are unique” invariant. Methods return the result of toList() — per the Java SE docs, an unmodifiable list, so callers cannot corrupt the catalog’s data.

Optional instead of null

findByCode may not find a service. Returning null relies on the caller’s memory: forget the check and you get a NullPointerException somewhere later. Optional<ServiceItem> states the possible absence right in the signature. Optional.empty() creates an empty one, and Optional.ofNullable(map.get(code)) turns a possible null into an empty container.

Do

  • findByCode(code).map(ServiceItem::title).orElse("no such service") — a default value
  • findByCode(code).orElseThrow(() -> new IllegalArgumentException(...)) — absence is an error
  • Optional as a method return type

Don’t

  • optional.get() without a check — the docs name orElseThrow() as the preferred alternative
  • return null; from a method that returns Optional
  • Optional in fields, parameters and collection elements

The last point is not a matter of taste: the class documentation says Optional is primarily intended as a method return type where there is a clear need to represent “no result”, and dev.java explicitly calls fields, collections and arguments the wrong place for it. totalMinutes in the catalog shows the other side: inside the stream, orElseThrow turns an unknown code into an IllegalArgumentException with a clear message.

Exceptions: checked and unchecked

An exception is an error object that the JVM passes up the call stack until a matching catch handles it. Java has two kinds. Checked exceptions (subclasses of Exception such as IOException) follow the “catch or specify” requirement: the compiler refuses code that ignores them. Unchecked ones (RuntimeException, Error and their subclasses) are not tracked by the compiler.

src/main/java/com/example/catalog/CatalogLoader.java
package com.example.catalog;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public final class CatalogLoader {

    private CatalogLoader() {
    }

    // IOException is a checked exception: the compiler forces the caller
    // to either catch it or declare it in its own throws clause
    public static ServiceCatalog load(Path file) throws IOException {
        List<String> lines = Files.readAllLines(file);
        ServiceCatalog catalog = new ServiceCatalog();
        for (int i = 1; i < lines.size(); i++) { // line 0 is the header
            String line = lines.get(i);
            if (line.isBlank()) {
                continue;
            }
            catalog.add(parse(line, i + 1));
        }
        return catalog;
    }

    static ServiceItem parse(String line, int lineNumber) {
        String[] parts = line.split(";");
        if (parts.length != 5) {
            throw new IllegalArgumentException("Line " + lineNumber + ": expected 5 fields, got " + parts.length);
        }
        try {
            return new ServiceItem(
                    parts[0].trim(),
                    parts[1].trim(),
                    Category.valueOf(parts[2].trim()),
                    Integer.parseInt(parts[3].trim()),
                    Integer.parseInt(parts[4].trim()));
        } catch (IllegalArgumentException e) { // NumberFormatException is a subclass
            throw new IllegalArgumentException("Line " + lineNumber + ": " + e.getMessage(), e);
        }
    }
}

Remove throws IOException from a method that reads a file, and the compiler stops the build:

terminal
$ javac Neg.java
Neg.java:6: error: unreported exception IOException; must be caught or declared to be thrown
        return Files.readString(p);
                               ^
1 error
Actual javac 25 output for a method calling Files.readString without throws.
src/main/java/com/example/catalog/Main.java
package com.example.catalog;

import java.io.IOException;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        Path file = Path.of(args.length > 0 ? args[0] : "services.csv");
        ServiceCatalog catalog;
        try {
            catalog = CatalogLoader.load(file);
        } catch (NoSuchFileException e) { // catch the narrower type before the general one
            System.err.println("File not found: " + file);
            return;
        } catch (IOException e) {
            System.err.println("Could not read " + file + ": " + e.getMessage());
            return;
        }

        System.out.println("Services in catalog: " + catalog.size());
        System.out.println("Categories: " + catalog.categories());
        System.out.println("By category: " + catalog.countByCategory());
        System.out.println("Up to 10,000 KZT: " + catalog.titlesWithin(10_000));

        String spa = catalog.findByCode("SPA-01")
                .map(ServiceItem::title)
                .orElse("no such service");
        System.out.println("SPA-01: " + spa);

        String unknown = catalog.findByCode("X-99")
                .map(ServiceItem::title)
                .orElse("no such service");
        System.out.println("X-99: " + unknown);

        System.out.println("Visit, minutes: " + catalog.totalMinutes(List.of("HAIR-01", "NAILS-01")));
    }
}

checked

Examples
IOException, NoSuchFileException
What to do
an external failure you can recover from: catch and report, or declare in throws

unchecked: RuntimeException

Examples
IllegalArgumentException, NullPointerException, NumberFormatException
What to do
bad input or a bug: validate input and throw with a clear message, fix the bug

Error

Examples
OutOfMemoryError, StackOverflowError
What to do
do not catch in regular code — the environment or the whole program is in trouble

The dev.java guideline: if a client can reasonably be expected to recover from an exception, make it checked; if not, make it unchecked. In Main the catch blocks go from the narrow type to the broad one: NoSuchFileException extends IOException, and the reverse order does not compile. In CatalogLoader.parse the original error is kept: it is passed as the second argument (the cause), so the stack trace shows both.

Maven: pom.xml, the build and a JUnit test

Maven is the standard build tool for Java projects: it downloads dependencies, compiles code, runs tests and packages a jar. Everything is described in pom.xml, and the folder layout follows a convention, so there is nothing to configure:

  1. pom.xml

    project coordinates (groupId, artifactId, version), Java version, dependencies and plugins

  2. src/main/java

    application code; the folder path mirrors the package: com/example/catalog

  3. src/test/java

    tests; they do not end up in the final jar

  4. target

    build output: .class files, test reports and catalog-1.0-SNAPSHOT.jar; never committed to Git

pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.example</groupId>
  <artifactId>catalog</artifactId>
  <version>1.0-SNAPSHOT</version>

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

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.junit</groupId>
        <artifactId>junit-bom</artifactId>
        <version>6.1.3</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

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

  <build>
    <pluginManagement>
      <plugins>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.16.0</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>3.6.0</version>
        </plugin>
      </plugins>
    </pluginManagement>
  </build>
</project>
maven.compiler.release sets the Java version; the junit-bom BOM aligns all JUnit module versions, so junit-jupiter has no version of its own.

Tests use JUnit 6, the current version of the framework (6.1.3 at the time of writing). Annotations and assertions live in the same org.junit.jupiter.api package as in JUnit 5; JUnit 6 requires Java 17+ and Maven Surefire 3.0.0 or later. @BeforeEach prepares a fresh catalog before every test, assertEquals compares expected and actual values, and assertThrows checks that the code throws the right exception and returns it so you can check the message.

src/test/java/com/example/catalog/ServiceCatalogTest.java
package com.example.catalog;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class ServiceCatalogTest {

    private ServiceCatalog catalog;

    @BeforeEach
    void setUp() { // a fresh catalog before every test
        catalog = new ServiceCatalog();
        catalog.add(new ServiceItem("HAIR-02", "Hair coloring", Category.HAIR, 25000, 150));
        catalog.add(new ServiceItem("HAIR-01", "Women's haircut", Category.HAIR, 9000, 60));
        catalog.add(new ServiceItem("SPA-01", "Back massage", Category.SPA, 12000, 45));
    }

    @Test
    void sortsCategoryByPrice() {
        List<String> codes = catalog.byCategory(Category.HAIR).stream()
                .map(ServiceItem::code)
                .toList();
        assertEquals(List.of("HAIR-01", "HAIR-02"), codes);
    }

    @Test
    void returnsEmptyOptionalForUnknownCode() {
        assertTrue(catalog.findByCode("X-99").isEmpty());
        assertEquals("Back massage", catalog.findByCode("SPA-01").orElseThrow().title());
    }

    @Test
    void countsByCategory() {
        assertEquals(Map.of(Category.HAIR, 2L, Category.SPA, 1L), catalog.countByCategory());
    }

    @Test
    void rejectsDuplicateCode() {
        ServiceItem duplicate = new ServiceItem("SPA-01", "Another massage", Category.SPA, 10000, 30);
        IllegalArgumentException error =
                assertThrows(IllegalArgumentException.class, () -> catalog.add(duplicate));
        assertEquals("Service with code SPA-01 already exists", error.getMessage());
    }

    @Test
    void recordRejectsNegativePrice() {
        assertThrows(IllegalArgumentException.class,
                () -> new ServiceItem("BAD-01", "Broken", Category.SPA, -1, 30));
    }

    @Test
    void resultListIsUnmodifiable() {
        List<ServiceItem> hair = catalog.byCategory(Category.HAIR);
        assertThrows(UnsupportedOperationException.class, () -> hair.add(hair.get(0)));
    }

    @Test
    void sumsMinutesOfVisit() {
        assertEquals(105, catalog.totalMinutes(List.of("HAIR-01", "SPA-01")));
        assertThrows(IllegalArgumentException.class, () -> catalog.totalMinutes(List.of("X-99")));
    }
}

mvn package is a lifecycle phase: Maven runs every phase before it in order — validate, compile, test, package. A failing test therefore stops the build, and no broken jar is produced. With Java and Maven installed locally, mvn package in the project folder is enough; without them, use a Docker image with JDK 25 and Maven:

bash
cat > services.csv <<'EOF'
code;title;category;price;minutes
HAIR-01;Women's haircut;HAIR;9000;60
HAIR-02;Hair coloring;HAIR;25000;150
NAILS-01;Gel manicure;NAILS;8000;90
SPA-01;Back massage;SPA;12000;45
SPA-02;Body wrap;SPA;15000;60
EOF
Creates services.csv in the project root: a header and five services, separated by semicolons.
terminal
$ docker run --rm -v "$PWD:/app" -v "$HOME/.m2:/root/.m2" -w /app maven:3.10-eclipse-temurin-25 mvn -B package
…
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.example.catalog.ServiceCatalogTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
…
[INFO] --- jar:3.5.1:jar (default-jar) @ catalog ---
[INFO] BUILD SUCCESS

$ docker run --rm -v "$PWD:/app" -w /app eclipse-temurin:25-jre java -cp target/catalog-1.0-SNAPSHOT.jar com.example.catalog.Main
Services in catalog: 5
Categories: [HAIR, NAILS, SPA]
By category: {HAIR=2, NAILS=1, SPA=2}
Up to 10,000 KZT: [Women's haircut, Gel manicure]
SPA-01: Back massage
X-99: no such service
Visit, minutes: 150
Output shortened. services.csv sits in the project root next to pom.xml; running with the argument missing.csv prints “File not found: missing.csv”.

Common mistakes, exercises and next steps

  • Compare strings with equals, not ==: == compares object references, not text
  • Do not store money with cents in double — use whole minor units (long) or BigDecimal
  • Do not return null instead of an empty list or an Optional
  • Do not try to modify results of toList() or List.of() — new ArrayList<>(list) gives a mutable copy
  • Do not swallow exceptions with an empty catch; pass the original cause to the new exception
  • Pin dependency and plugin versions in pom.xml and add target/ to .gitignore

Spring Boot readiness checklist: you read the signature public Optional<ServiceItem> findByCode(String code) and know what comes back; you write a record with validation in the compact constructor; you process data with filter, map and groupingBy; you can tell a checked exception from an unchecked one; you run mvn package and read the test report. Next step: a REST API with Spring Boot — the same Maven, records as DTOs, and exceptions turned into HTTP responses.

How this was verified5 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.