JAVA / CAPSTONE PROJECTS
Where to go next as a Java developer
Pick your next Java topic from evidence: what your target JDK ships, what your class files target, and which APIs have quietly been removed.
What you will learn
- Probe a JDK with Class.forName and Runtime.version() instead of trusting a feature list
- Use --release, not -source/-target, so old runtimes cannot hit NoSuchMethodError
- Read a class file's major version to see which Java release it demands
- Commit to one axis: JVM tooling, one framework, or post-baseline language features
Understanding Where to go next as a Java developer
Five projects in, you have the language and the shape of a service: files, a database, threads, streams and an HTTP API. What decides your next step is one number rather than a topic list, and that number is the feature release your code has to run on, because Java's compatibility guarantee only runs one way: a class file built for 17 loads on 21, never the reverse. Take that number from the machine that will run the code, not from the JDK on your laptop, and hand it to javac as --release so the compiler refuses newer APIs on your behalf.
With the constraint fixed, the remaining ground splits into three axes, and one axis at a time is the only version that works. The runtime axis treats the JVM as a process you can question: -Xlog:gc for allocation pressure, jcmd Thread.print for a stuck service, a heap dump on OutOfMemoryError, JFR for where time actually goes, and JMH once you accept that a nanoTime loop mostly measures the JIT warming up. The ecosystem axis is one build tool and one framework carried end to end: Maven or Gradle, Spring Boot or Quarkus, JDBC against JPA, Testcontainers so the database under test is the real engine. The language axis is whatever landed after your baseline: records with sealed interfaces and pattern-matched switch in place of visitor classes, virtual threads in place of hand-tuned pools for blocking work, and the preview end of the pipeline such as structured concurrency and scoped values.
The best material for all three axes ships inside the JDK itself. lib/src.zip holds the source of every platform class, so you can read ArrayList.grow or CompletableFuture and see the idioms the library authors trust; every Javadoc entry carries an @since tag that dates an API against your baseline; javap -c shows what the compiler did behind your syntax, such as string concatenation turning into an invokedynamic call into StringConcatFactory. Read those alongside the JEP list for each six-month release and you can judge advice you find online instead of copying it. Then keep one project alive for months, because design decisions only teach you something when you are the one who has to live with them.
None
import java.lang.reflect.Method;
public class NextStep {
static boolean typeExists(String name) {
try {
Class.forName(name);
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
static boolean methodExists(String type, String name) {
try {
Method m = Class.forName(type).getMethod(name);
return m != null;
} catch (ClassNotFoundException | NoSuchMethodException e) {
return false;
}
}
static void report(String api, int since, boolean present) {
System.out.println(api + " (Java " + since + "): " + (present ? "usable here" : "missing here"));
}
public static void main(String[] args) {
System.out.println("Running on feature release " + Runtime.version().feature());
report("java.net.http.HttpClient", 11, typeExists("java.net.http.HttpClient"));
report("Stream.toList()", 16, methodExists("java.util.stream.Stream", "toList"));
report("java.util.SequencedCollection", 21, typeExists("java.util.SequencedCollection"));
report("java.io.IO", 25, typeExists("java.io.IO"));
}
}Your next step is set by the gap between the JDK release you must ship on and what that release actually contains, so learn to interrogate the platform rather than collect tutorials.
Worked examples
What this JDK already ships
Asks the runtime image which platform modules exist, so you know what needs a dependency and what does not.
import java.lang.module.ModuleFinder;
public class ShippedModules {
public static void main(String[] args) {
ModuleFinder system = ModuleFinder.ofSystem();
String[] names = {"java.base", "java.net.http", "java.sql", "jdk.jfr", "javafx.controls"};
for (String name : names) {
boolean shipped = system.find(name).isPresent();
System.out.println(name + ": " + (shipped ? "in the JDK" : "external dependency"));
}
}
}Example explained
Line 1ModuleFinder.ofSystem() reads the modules built into the running image, so it reports observable modules even when the application never resolved them.
Line 2java.net.http and java.sql being present means an HTTP client and JDBC need no library at all, which changes what is worth learning first.
Line 3jdk.jfr present means Flight Recorder, the profiler for your next performance question, is already installed on every machine running your code.
Line 4javafx.controls is absent from stock OpenJDK builds, so desktop UI work starts with a dependency and packaging decision rather than an import.
Which release your class files demand
Reads the first eight bytes of its own class file to show the difference between the JDK that compiled the code and the JDK that can run it.
import java.io.DataInputStream;
import java.io.InputStream;
public class TargetCheck {
public static void main(String[] args) throws Exception {
try (InputStream in = TargetCheck.class.getResourceAsStream("TargetCheck.class");
DataInputStream bytes = new DataInputStream(in)) {
int magic = bytes.readInt();
int minor = bytes.readUnsignedShort();
int major = bytes.readUnsignedShort();
System.out.printf("magic %08X, class file %d.%d%n", magic, major, minor);
System.out.println("compiled for Java " + (major - 44));
System.out.println("preview features baked in: " + (minor == 65535));
}
}
}Example explained
Line 1readInt() consumes the four magic bytes every class file starts with, which is how the JVM rejects a file that is not bytecode before parsing anything else.
Line 2Major version 65 means Java 21: the numbering starts at 45 for Java 1.1, so major - 44 gives the feature release.
Line 3A minor version of 65535 marks a class file compiled with --enable-preview, and such a file only loads on that exact release with the same flag.
Line 4The runtime may be newer than this major version but never older, and that asymmetry is the whole reason to choose a baseline deliberately.
Removed features fail quietly
Shows that a feature dropped from the JDK surfaces as an empty lookup rather than an error, using the script engine API after Nashorn's removal.
import javax.script.ScriptEngineFactory;
import javax.script.ScriptEngineManager;
public class GoneQuietly {
public static void main(String[] args) {
ScriptEngineManager manager = new ScriptEngineManager();
System.out.println("engines installed: " + manager.getEngineFactories().size());
System.out.println("engine for 'js': " + manager.getEngineByName("js"));
for (ScriptEngineFactory factory : manager.getEngineFactories()) {
System.out.println("found " + factory.getEngineName());
}
}
}Example explained
Line 1getEngineFactories() goes through ServiceLoader, so it reports what is actually installed rather than what an older article promises.
Line 2Nashorn was removed in Java 15, so a stock JDK 21 finds zero providers and getEngineByName returns null instead of throwing.
Line 3The for loop prints nothing because the factory list is empty, which is exactly how a removal shows up in a log: silence.
Line 4Removals like this are why a probe against the real runtime beats reading a version table when planning a migration.
Important notes
The outputs here come from a stock OpenJDK 21 build; lines flip on other releases, which is the point, so re-run the probe after each upgrade instead of trusting a printed table.
Finding a class is not permission to use it: preview APIs are visible to reflection but guarded, so compile-time checks with --release decide what you may actually call.
Common mistakes
Shipping class files compiled with --enable-preview: they carry minor version 65535 and refuse to load on any other release, so the jar that worked all week dies the moment the runtime is upgraded.
Using -source 17 -target 17 instead of --release 17: javac still links against the newer JDK's class library, so a call like list.getFirst() compiles cleanly and throws NoSuchMethodError on the Java 17 server.
Choosing performance as the next axis and measuring with a System.nanoTime loop: JIT warmup and dead-code elimination make the second variant look several times faster, and you end up tuning code the compiler had already deleted.
Try it yourself
Change, predict, then run
In a browser editor, print Runtime.version().feature() and then use Class.forName to test for java.util.SequencedCollection, java.lang.ScopedValue and java.io.IO. Note which of the three you would actually be allowed to call on that runtime, and why presence alone does not answer that.
Open the Java workspaceCheck your understanding
You compile code that calls List.getFirst() on a JDK 21 using -source 17 -target 17, then run the resulting jar on a Java 17 runtime. What happens?
- It compiles, then throws NoSuchMethodError on Java 17, because -target only sets the class file version while javac still linked against JDK 21's List
- It fails at compile time, because -source 17 hides APIs added after Java 17
- It runs fine, because a class file targeting 17 makes the JVM fall back to an older List implementation
- It fails to load with UnsupportedClassVersionError, because the code was built on JDK 21
Show answer
-source controls the language level and -target the class file version; neither changes which class library javac resolves against, so getFirst() from Java 21 is compiled in and only fails when Java 17 tries to resolve the missing method. Option 3 is tempting but describes a different failure: UnsupportedClassVersionError comes from a class file built for a newer release, whereas this file is version 61 and loads happily before breaking. Only --release 17 substitutes Java 17's API signatures and rejects the call at compile time.