Java has changed more in the last few releases than in the decade before. Records, sealed types, pattern matching, compact source files and virtual threads mean modern Java looks very little like the boilerplate-heavy code many developers remember. This crash course builds one real project with Java 25 LTS (Temurin 25.0.5) and Maven 3.9 with zero dependencies: a weather web server that looks up cities, shows live forecasts and compares five cities in parallel using the free Open-Meteo API, with nothing but the JDK’s own HttpServer and HttpClient.
⚡ TL;DR – Java 25 lets you start with a compact source file:
void main() { ... }, no class declaration needed. – Records replace hand-written data classes; compact constructors validate input. –java.net.http.HttpClientandcom.sun.net.httpserver.HttpServerare enough for a small JSON web app. – Sealed interfaces plus pattern-matchingswitchgive you exhaustive, compiler-checked branching. – Virtual threads make “one thread per request” cheap again, so you can fetch five cities in parallel with simple blocking code.
Before you start
- JDK 25 (Temurin or any OpenJDK build):
java -versionshould print 25. - Maven 3.9+:
mvn -v. - An editor with Java support (IntelliJ IDEA, VS Code with the Java extension pack).
- No API key: Open-Meteo is free for non-commercial use.
Step 1: Types, control flow and methods in a compact source file
// Hello.java — run with: java Hello.java
void main() {
var temps = new double[] { 21.5, 24.0, 19.0, 26.5 };
int warm = 0;
for (double t : temps) if (t > 20) warm++;
IO.println("Warm days: " + warm + ", average " + average(temps));
}
double average(double[] xs) {
double sum = 0;
for (double x : xs) sum += x;
return Math.round(sum / xs.length * 10) / 10.0;
}
var infers local types, but Java stays statically typed: temps is a double[] forever. Compact source files and the single-file launcher are perfect for learning and scripts; move to a Maven project when you need dependencies, tests or a jar.
Step 2: Create a Maven project
mvn archetype:generate -DgroupId=net.cheatcoders -DartifactId=weather \
-DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
In pom.xml, set <maven.compiler.release>25</maven.compiler.release> and configure the jar plugin’s mainClass so java -jar target/weather.jar works. There are no <dependencies> beyond the test scope.
Step 3: Classes, interfaces and default methods
public interface WeatherSource {
Forecast forecast(City city) throws WeatherException;
default String describe() { return getClass().getSimpleName(); }
}
Interfaces define the contract; default methods add behaviour without breaking existing implementations. Coding against WeatherSource lets you swap the real Open-Meteo client for a fake in tests.
Step 4: Records and compact constructors
public record City(String name, String country, double latitude, double longitude) {
public City { // compact constructor
if (name == null || name.isBlank()) throw new IllegalArgumentException("name required");
if (latitude < -90 || latitude > 90) throw new IllegalArgumentException("bad latitude");
}
}
public record Day(String date, double max, double min, int rainChance) {}
public record Forecast(City city, List<Day> days) {}
A record gives you a constructor, accessors (city.name()), equals, hashCode and toString in one line. Records are immutable, which makes them safe to share between threads, and that matters in Step 13.
Step 5: Collections and generics
Map<String, City> cache = new ConcurrentHashMap<>();
List<String> favorites = new ArrayList<>(List.of("Chennai", "London"));
City c = cache.computeIfAbsent(key, k -> geocode(k));
ConcurrentHashMap because the server handles requests on many threads. List.of creates an immutable list; wrap it in ArrayList if you need to modify it.
Step 6: Call the API with HttpClient
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)).build();
String get(String url) throws WeatherException {
var req = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(10)).GET().build();
try {
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new WeatherException("HTTP " + res.statusCode());
return res.body();
} catch (IOException | InterruptedException e) {
throw new WeatherException("Request failed", e);
}
}
Reuse one HttpClient; it holds a connection pool. Always set timeouts. The defaults wait indefinitely.
Step 7: Exceptions
public class WeatherException extends Exception {
public WeatherException(String msg) { super(msg); }
public WeatherException(String msg, Throwable cause) { super(msg, cause); }
}
public class CityNotFoundException extends WeatherException {
public CityNotFoundException(String city) { super("No city called " + city); }
}
Checked exceptions force callers to deal with expected failures (network, unknown city). Keep the original exception as the cause so stack traces stay useful. If you catch InterruptedException without rethrowing it, restore the flag with Thread.currentThread().interrupt().
Step 8: Streams, lambdas and method references
double avgMax = days.stream().mapToDouble(Day::max).average().orElse(Double.NaN);
List<String> rainy = days.stream().filter(d -> d.rainChance() >= 50).map(Day::date).toList();
Map<Boolean, List<Day>> byWarm = days.stream().collect(Collectors.groupingBy(d -> d.max() > 25));
Streams describe what you want rather than how to loop. Day::max is a method reference, shorthand for d -> d.max().
Step 9: Optional instead of null
Optional<City> findCity(String name) { ... }
String label = findCity("Atlantis")
.map(c -> c.name() + ", " + c.country())
.orElse("Unknown city");
Return Optional from methods that might not find anything, so the caller can’t forget the empty case. Don’t use it for fields or parameters.
Step 10: Switch expressions and pattern matching
String feel(double t) {
return switch ((int) Math.floor(t / 10)) {
case 0, 1 -> "cool";
case 2 -> "warm";
default -> t < 0 ? "freezing" : "hot";
};
}
Switch expressions return a value, use -> with no fall-through, and must be exhaustive.
Step 11: Sealed types
sealed interface Result permits Ok, NotFound, Failed {}
record Ok(Forecast forecast) implements Result {}
record NotFound(String city) implements Result {}
record Failed(String reason) implements Result {}
String toJson(Result r) {
return switch (r) {
case Ok(Forecast f) -> json(f);
case NotFound(String c) -> "{\"error\":\"not found: " + c + "\"}";
case Failed(String why) -> "{\"error\":\"" + why + "\"}";
}; // no default: the compiler knows every case
}
Add a fourth result type and every switch over Result stops compiling until you handle it. Record patterns deconstruct the value right in the case label.
Step 12: Build and run the server
var server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/api/forecast", ex -> {
var city = query(ex).getOrDefault("city", "");
var body = toJson(lookup(city)).getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(200, body.length);
try (var out = ex.getResponseBody()) { out.write(body); }
});
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
mvn -q package && java -jar target/weather.jar
curl "localhost:8080/api/forecast?city=London"
Step 13: Virtual threads for parallel lookups
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = cities.stream().map(c -> exec.submit(() -> lookup(c))).toList();
for (var f : futures) results.add(f.get());
}
Each lookup blocks on network I/O, which used to waste an expensive platform thread. Virtual threads are cheap, so you write plain blocking code and the JVM parks them while they wait. Five cities take about as long as the slowest one. The try-with-resources waits for all tasks before closing.
Troubleshooting
error: release version 25 not supported: Maven is running an older JDK. Checkmvn -vand setJAVA_HOME.no main manifest attribute: configuremainClassin the jar plugin.- Requests hang: add
connectTimeoutand per-requesttimeout. ConcurrentModificationException: you modified a regularArrayListorHashMapfrom several threads. Use concurrent collections or immutable records.- Garbled characters: always encode with
StandardCharsets.UTF_8and send the byte length, not the string length.
Best practices
- Model data with records and results with sealed interfaces.
- One shared
HttpClient, always with timeouts. - Prefer
Optionalreturns overnull. - Use virtual threads for I/O-bound concurrency; keep CPU-heavy work on a bounded pool.
Parsing JSON without a library
The JDK has no built-in JSON parser, so this zero-dependency app pulls out the few fields it needs. For anything bigger, add Jackson or Gson. For a learning project, a small focused extractor is fine, as long as it fails loudly:
static double number(String json, String key) {
var m = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*(-?[0-9.]+)").matcher(json);
if (!m.find()) throw new IllegalStateException("missing field " + key);
return Double.parseDouble(m.group(1));
}
Never build JSON responses by concatenating unescaped user input. A city name containing a quote breaks your output. Escape " and \ at minimum:
static String esc(String s) { return s.replace("\\", "\\\\").replace("\"", "\\\""); }
Testing with JUnit
Add junit-jupiter with <scope>test</scope>, and test the pure parts: records’ validation, feel(), and the JSON helpers. Use a fake WeatherSource so tests never touch the network:
class FakeSource implements WeatherSource {
public Forecast forecast(City c) { return new Forecast(c, List.of(new Day("2026-01-01", 20, 10, 0))); }
}
@Test void rejectsBadLatitude() {
assertThrows(IllegalArgumentException.class, () -> new City("X", "Y", 100, 0));
}
Run with mvn test. Because City validates in its compact constructor, invalid data can’t exist anywhere in the app, so there’s one place to test instead of many.
FAQ
Is Java 25 an LTS release?
Yes, which makes it a good target for new projects.
Do I still need Spring?
For a small service, no; the JDK is enough. Frameworks pay off with databases, security and larger teams.
Official documentation
Key takeaways
- Modern Java is concise: compact sources,
var, records and switch expressions. - Sealed types plus patterns give compiler-checked branching.
- The JDK alone can serve and consume HTTP.
- Virtual threads make simple blocking code scale for I/O.
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.