Java Crash Course in 10 Minutes (Java 25 + Real App)

2 views

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.HttpClient and com.sun.net.httpserver.HttpServer are enough for a small JSON web app. – Sealed interfaces plus pattern-matching switch give 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 -version should 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

java
// 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

bash
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

java
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

java
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

java
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

java
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

java
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

java
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

java
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

java
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

java
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

java
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();
bash
mvn -q package && java -jar target/weather.jar
curl "localhost:8080/api/forecast?city=London"

Step 13: Virtual threads for parallel lookups

java
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. Check mvn -v and set JAVA_HOME.
  • no main manifest attribute: configure mainClass in the jar plugin.
  • Requests hang: add connectTimeout and per-request timeout.
  • ConcurrentModificationException: you modified a regular ArrayList or HashMap from several threads. Use concurrent collections or immutable records.
  • Garbled characters: always encode with StandardCharsets.UTF_8 and 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 Optional returns over null.
  • 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:

java
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:

java
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:

java
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.

Open the playlist on YouTube · CheatCoders on YouTube

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.

Subscribe free →


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.