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.
After this you will be able to
- Model data with a
recordvalidated in its compact constructor and anenumfor a fixed set of values - Choose between
List,SetandMapand process a collection with the Stream API:filter,map,sorted,groupingBy - Return an
Optionalinstead ofnulland tell a checked exception from an unchecked one - Build a Maven project with JUnit tests using
mvn packageand run the resultingjar
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
- Java and the JVM: from source to a running program
- Types, var, classes, enums and records
- Collections: List, Set and Map
- The Stream API: filtering, sorting, grouping
- Optional instead of null
- Exceptions: checked and unchecked
- Maven: pom.xml, the build and a JUnit test
- Common 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.
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.
// 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);
}$ java --version
openjdk 25.0.4.1 2026-08-18 LTS
…
$ java Hello.java
Hello, InterLAB! Year: 2026, services: 3java 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
trueorfalse- 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.
package com.example.catalog;
public enum Category {
HAIR,
NAILS,
SPA
}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.
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
}
}
}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;TreeSetkeeps sorted order
Map
- Common implementation
HashMap,LinkedHashMap,TreeMap- When to use
- lookup by key;
LinkedHashMapremembers insertion order,TreeMapsorts 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.
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 valuefindByCode(code).orElseThrow(() -> new IllegalArgumentException(...))— absence is an errorOptionalas a method return type
Don’t
optional.get()without a check — the docs nameorElseThrow()as the preferred alternativereturn null;from a method that returnsOptionalOptionalin 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.
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:
$ javac Neg.java
Neg.java:6: error: unreported exception IOException; must be caught or declared to be thrown
return Files.readString(p);
^
1 errorjavac 25 output for a method calling Files.readString without throws.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:
pom.xml
project coordinates (
groupId,artifactId,version), Java version, dependencies and pluginssrc/main/java
application code; the folder path mirrors the package:
com/example/catalogsrc/test/java
tests; they do not end up in the final
jartarget
build output:
.classfiles, test reports andcatalog-1.0-SNAPSHOT.jar; never committed to Git
<?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.
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:
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
EOFservices.csv in the project root: a header and five services, separated by semicolons.$ 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: 150services.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) orBigDecimal - Do not return
nullinstead of an empty list or anOptional - Do not try to modify results of
toList()orList.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.xmland addtarget/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.