Java for C# Developers#

A working handbook for .NET engineers moving to Java and Spring Boot

Compiled bykodebot

Java 25 LTS .NET 10 · C# 14 Spring Boot 3 & 4

Preface#

This book exists because the syntax is the easy part.

A competent C# developer can read Java on the first afternoon. Curly braces, classes, interfaces, generics, lambdas, a garbage collector, a bytecode VM: the skeleton is shared, and it was shared deliberately. What costs you weeks is everything around the language. Which JDK. Why the build tool is a separate universe. Why your annotation silently did nothing. Why there is no await. Why a method you did not mark virtual was overridden anyway.

Those are the things this book is about.

Who it is for#

You write C# now, on .NET 8 or later, and you are moving to Java, most likely to a Spring Boot service. You do not need to be taught what a class is, what dependency injection is for, or why immutability helps. You need to know where your instincts transfer and where they are confidently wrong.

Every explanation is anchored to something you already know. Almost every page has a side-by-side comparison with C# on the left and Java on the right.

How it is organised#

ModeShowsTakesLeaves you
Fastthe core of every chapterabout ninety minuteswriting Java productively
Fulleverything, including folded sections and historical asidestwo to three hoursable to work on anything in a Java codebase

Use the toggle in the sidebar. In Fast mode the secondary material folds into one-line headings; nothing is hidden from you, and you can open any single one that looks relevant. Full opens all of it, including the asides marked Legacy that explain code you will inherit but should not write.

There is a third way to read it: press ⌘K and type a C# name. Every row of every mapping table is indexed, so IEnumerable, IOptions or ConfigureAwait takes you straight to its Java answer.

What it targets#

SideVersionNote
Java25 LTSJava 21 differences are called out where they matter
.NET10, C# 14modern idiom throughout: records, patterns, primary constructors
SpringBoot 3 and Boot 4shown side by side wherever they diverge

Roughly half the Java features people write about are still preview features that need --enable-preview and can change between releases. Every one of them is badged as such, because copying a preview API into production code is a specific and avoidable way to lose an afternoon.

On accuracy#

Java is thirty years old and its search results do not sort by relevance to the version you are on. An answer that was correct in 2011 sits next to one that is correct today, with nothing to tell them apart. That is the single most hazardous thing about learning Java from the internet.

So the version-sensitive claims here are checked against primary sources: the OpenJDK project pages for the JDK 21, 22, 24 and 25 feature lists, the JEPs themselves for API shapes, and the Spring release notes and migration guides for the Boot 3 to Boot 4 differences. Where something could not be verified it was either hedged or left out.

It will still age. When this book and the reference documentation disagree, the documentation is right.

What it does not cover#

This is a transition handbook for server-side Java, aimed at someone building web services and APIs. Java runs in a great many other places, and none of them are covered here.

Not coveredWhere Java is used for itWhy it is out of scope
Mobile developmentAndroid, and Kotlin Multiplatforma different SDK, build system, lifecycle and UI model; almost none of Part 9 applies
Desktop applicationsJavaFX, Swing, SWTa separate UI stack with no ASP.NET Core parallel to translate from
GameslibGDX, jMonkeyEngineniche on the JVM, and the .NET comparison would be Unity
Embedded and IoTJava ME, embedded JVMsa subset of the platform with different constraints
Big data and analyticsSpark, Flink, Hadoop, Kafka Streamslarge ecosystems in their own right; the language is the smallest part
Applets and browser pluginsremoved from the platformsee Appendix C, which explains what you may still find
Jakarta EE application serversWildFly, WebSphere, Open LibertyAppendix C covers enough to recognise them; Spring Boot is assumed
Java as a first languageany introductory textthis book assumes you are fluent in C# already

Within server-side Java it is still selective. It will not teach you JVM internals, the memory model in depth, bytecode, or reactive programming beyond the decision of whether to use it. Kotlin gets an appendix rather than a chapter, because the platform facts in this book apply unchanged to a Kotlin codebase and only the syntax differs.

Appendix D maps the expert territory: what each topic is, the symptom that means you now need it, and where to read properly. It signposts rather than teaches, deliberately. Appendix E covers Kotlin.

A note on sources#

The structure of this book was informed by an older, freely circulated PDF comparing the two ecosystems, which was written against C# 5 and Java 8 and is now substantially out of date. No text was taken from it. Several of its recommendations are corrected here explicitly, the most important being its advice to override finalize(), which has been deprecated for removal since Java 18.

Why Java feels familiar#

Part P0 · Day one · Chapter 01· 3 min read

C# was designed by people who knew Java well, and it shows. Curly braces, single inheritance, interfaces, garbage collection, a bytecode VM, generics, lambdas. The skeleton is the same. You will read Java on day one.

What costs you time is not syntax. It is the six places where your instincts are confidently wrong: checked exceptions, erased generics, no properties, package-private default access, no async, and a build system that is a different species from MSBuild. This chapter is a map of those; the rest of the book is the detail.

What transfers unchanged#

Write this pair out and the resemblance is almost uncomfortable.

C# 14
public sealed class OrderService(IOrderRepo repo)
{
    public async Task<Order> PlaceAsync(Cart cart)
    {
        if (cart.IsEmpty)
            throw new ArgumentException("empty cart");
        var order = new Order(Guid.NewGuid(), cart.Total);
        await repo.SaveAsync(order);
        return order;
    }
}
Java 25
public final class OrderService {
    private final OrderRepo repo;

    public OrderService(OrderRepo repo) { this.repo = repo; }

    public Order place(Cart cart) {
        if (cart.isEmpty())
            throw new IllegalArgumentException("empty");
        var order = new Order(UUID.randomUUID(), cart.total());
        repo.save(order);
        return order;
    }
}

Classes, final for sealed, constructor injection, var, exceptions, generics are all one-to-one. Note what vanished: the async and the Task<T>. That is not an omission. See Threads are cheap now.

The six things that will actually cost you time#

Your instinctThe Java realityChapter
Exceptions are all uncheckedChecked exceptions must be declared or caughtExceptions and resources
Generics are reifiedType arguments are erased at runtimeGenerics and type erasure
Properties are a language featureGetters and setters are just methodsFields and properties
Default access is privateDefault access is package-privateAccess and packages
I need async for scaleBlock on a virtual thread insteadThreads are cheap now
The build is part of the IDEMaven or Gradle is a separate universeMaven vs csproj
Gotcha

The single most expensive false friend: protected does not mean what you think. In C# it means “this class and its subclasses”. In Java it means “this class, its subclasses, and everything else in the same package”. Java's protected is strictly wider than C#'s, and nothing warns you.

A mental model for the platform#

The .NET ecosystem is a product: one vendor ships the runtime, the base class library, the web framework, the ORM, the DI container and the test runner, versioned together. The Java ecosystem is a specification plus a market. Oracle, Eclipse, Amazon, Azul and Red Hat all ship a JDK to the same spec. Web framework, DI and ORM come from Spring or Quarkus, not from the JDK.

.NETJavaNote
CLRJVMboth compile bytecode to machine code as it runs
ILbytecodeone .class file per type, zipped into a JAR
BCL / System.*java.*, javax.*, jakarta.*the standard library, and much smaller than the BCL
NuGetMaven Centrala package is identified by group + artifact, not one name
.csproj + MSBuildpom.xml + MavenGradle and build.gradle.kts are the alternative
Assembly (.dll)JARa zip of compiled classes; the unit you ship
SolutionMulti-module buildone parent pom lists the child modules
ASP.NET CoreSpring Bootnot shipped with Java; a third-party dependency
Entity Framework CoreHibernate / Spring Data JPAalso not shipped with Java
xUnit + MoqJUnit 5 + Mockitoalso not shipped with Java; there is no built-in test runner
Note

The “not shipped with Java” rows are the real culture shock. There is no blessed web framework. Spring Boot is overwhelmingly the default in enterprise Java, and this book devotes its largest part to it, but it remains a third-party choice. Quarkus and Micronaut are credible alternatives.

Conventions that differ#

C#JavaExample
PascalCase methodscamelCase methodsGetName() to getName()
IFoo for interfacesno prefixIRepo to Repo
_camelCase fieldscamelCase, no prefix_name to name
Namespace need not match folderPackage must match folderenforced by javac
One public type per file, looselyOne public type per file, strictlyfilename must match
PropertiesgetX() / setX()or a record component
LegacyThe Impl suffix

Older Java codebases pair every interface with a single implementation named FooServiceImpl. You will see it constantly, especially in Spring code written before about 2015. It is no longer recommended: if there is exactly one implementation you usually do not need the interface, and if you do need it, name the implementation for what makes it distinct (JdbcOrderRepo, InMemoryOrderRepo).

How to read this book#

The book has two modes, and the toggle is in the sidebar.

ModeShowsTakesLeaves you
Fastthe core of every chapterabout ninety minutesable to write Java productively
Fulleverything, including folded sections and historical asidestwo to three hoursable to work on anything in a Java codebase

Fast is a single sitting. It keeps each chapter's summary, the main side-by-side comparison, the mapping tables and the traps that will actually bite you. Everything else is folded into a one-line heading you can click if a particular topic matters to you right now.

Full opens all of it: the edge cases, the mechanisms, and the historical asides marked Legacy that explain code you will inherit but should not write.

There is a third way to use it. Press ⌘K and type a C# name. Every row of every mapping table is indexed, so IEnumerable, IOptions and ConfigureAwait all resolve to their Java answers without reading anything.

If you have been handed a Spring Boot service on Monday, read Parts 0 and 1 in Fast mode, then skip to Spring Boot orientation and use the rest as reference.

Getting a JDK, and the release train#

Part P0 · Day one · Chapter 02· 4 min read

There is no single “install Java”. You pick a version (use an LTS: 21 or 25) and a distribution (Temurin unless you have a reason). They are all built from the same OpenJDK source; the differences are support, licensing and a few extras.

Install with SDKMAN on macOS or Linux, not with the system package manager; you will need several JDKs side by side, exactly like having several .NET SDKs on PATH.

Five minutes to a running service#

Before any of the detail below, here is the whole loop end to end. Paste it and you have a Spring Boot service answering HTTP requests.

# 1. a JDK, without touching the system package manager
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk install java 25-tem

# 2. a project, generated from the command line
curl https://start.spring.io/starter.tgz \
     -d dependencies=web \
     -d type=maven-project \
     -d javaVersion=25 \
     -d artifactId=demo -d name=demo \
     | tar -xzf - -C . && cd demo

# 3. run it
./mvnw spring-boot:run

Then add one file, src/main/java/com/example/demo/Hello.java:

package com.example.demo;

import org.springframework.web.bind.annotation.*;

@RestController
public class Hello {

    @GetMapping("/hello")
    public String hello(@RequestParam(defaultValue = "world") String name) {
        return "hello " + name;
    }
}
curl "http://localhost:8080/hello?name=java"
# hello java
Note

That is the equivalent of dotnet new webapi plus dotnet run. Two things are worth noticing straight away: ./mvnw is a wrapper script committed to the repository, so nobody needs Maven installed, and the artefact it eventually builds contains its own web server, so there is nothing to deploy into.

The Spring Initializr at start.spring.io is the same generator with a web interface, and IntelliJ has it built in under New Project.

The release train#

Java ships every six months, in March and September. Since Java 17 every fourth release is an LTS, so one lands every two years; the earlier gaps were three years, which is why the table below is not evenly spaced. Non-LTS releases get six months of updates and then nothing. They exist so features can bake in the open.

ReleaseDateStatus.NET analogy
Java 82014LTS, still everywhere.NET Framework 4.8
Java 112018LTS, legacy.NET Core 3.1
Java 172021LTS, common.NET 6
Java 212023LTS, the current default.NET 8
Java 252025LTS, newest.NET 10
Note

Target Java 21 unless you control the whole deployment. It is where most production Java sits, it has virtual threads and pattern matching, and every library supports it. Java 25 is the right choice for greenfield. Anything non-LTS is for experiments.

Gotcha

Java 8 is not a historical curiosity the way .NET Framework 4.8 is becoming. A large amount of running enterprise Java is still on 8, and it lacks almost everything in Part 2 of this book: no var, no records, no switch expressions, no text blocks. If you inherit a Java 8 codebase, check before you reach for a modern idiom.

Which distribution#

DistributionVendorPick it when
Eclipse TemurinEclipse Adoptiumdefault; free, TCK-certified, no strings
Amazon CorrettoAmazonyou deploy on AWS
Azul ZuluAzulyou want commercial support options
Oracle JDKOracleyour employer has a contract
GraalVMOracleyou want native-image ahead-of-time compilation
Microsoft Build of OpenJDKMicrosoftfamiliar vendor, Azure shops

They are the same OpenJDK code. Do not spend a week choosing; take Temurin.

Installing and switching#

# install SDKMAN once
curl -s "https://get.sdkman.io" | bash

sdk list java              # every version and vendor available
sdk install java 25-tem    # Temurin 25
sdk install java 21-tem    # Temurin 21 alongside it
sdk use java 21-tem        # this shell only
sdk default java 25-tem    # new shells

java -version              # what am I actually running
.NETJavaNote
dotnet --list-sdkssdk list java --installedinstalled versions
global.json.sdkmanrc, or Maven toolchainspins which JDK this project builds with
dotnet --versionjava -versionprints to stderr, oddly
DOTNET_ROOTJAVA_HOMEmany tools read it directly
Gotcha

JAVA_HOME matters, but not everywhere, and the difference is what causes the confusion. The mvn and gradle launcher scripts use $JAVA_HOME/bin/java when the variable is set, and fall back to whatever java is on PATH when it is not. IntelliJ and Eclipse ignore it entirely and compile against the JDK configured in the project settings, which you set inside the IDE.

That is exactly why a project can build in the IDE and fail on the command line, or compile against two different versions in the same afternoon. When the versions disagree, check both: JAVA_HOME for the terminal, and the project SDK for the IDE.

jshell: the REPL#

Java has had a REPL since Java 9 Java 9. It is the fastest way to check a library's behaviour, and the equivalent of C# Interactive.

$ jshell
|  Welcome to JShell -- Version 25

jshell> var xs = List.of(1, 2, 3, 4)
xs ==> [1, 2, 3, 4]

jshell> xs.stream().filter(x -> x % 2 == 0).toList()
$2 ==> [2, 4]

jshell> /exit

Running source directly#

Since Java 11 you can run a single .java file with no compile step, and since Java 22 a program can span multiple files. Java 25 finalised compact source files and instance main methods Java 25, which strips the ceremony that made Java's hello world a punchline.

C# top-level statements
// Program.cs
Console.WriteLine("Hello");

// dotnet run
Java 25 compact source file
// Hello.java
void main() {
    IO.println("Hello");
}

// java Hello.java

No public class, no String[] args, no static. This is finalised in Java 25 (JEP 512), so it is safe to use in scripts and teaching, but note that most real code still lives in a normal class with a public static void main(String[] args).

LegacyThe full main method

What you will see in essentially every existing codebase, and what a build tool still expects as an entry point:

public class App {
    public static void main(String[] args) {
        System.out.println("Hello");
    }
}

System.out.println is the Console.WriteLine you will meet in real code; IO.println is new in 25 and only available in compact source files.

Command cheat sheet#

Task.NETJava
Compiledotnet buildjavac Foo.java, or mvn compile
Rundotnet runjava Foo, or mvn spring-boot:run
Run one filedotnet scriptjava Foo.java
REPLdotnet-script / C# Interactivejshell
Testdotnet testmvn test
Packagedotnet publishmvn package
Add dependencydotnet add package Xedit pom.xml by hand
Cleandotnet cleanmvn clean
Gotcha

There is no mvn add. You edit pom.xml in your editor and paste the coordinates. Every Java developer does this, IntelliJ autocompletes it, and nobody finds it strange. See Maven vs csproj.

How Java runs your code#

Part P0 · Day one · Chapter 03· 7 min read

In .NET you rarely think about how the runtime finds code. An assembly describes itself, the build resolves dependencies, and dotnet run does the rest. Java puts that machinery in front of you, and one word does most of the work: the classpath.

The classpath is an ordered list of folders and JAR files that the JVM searches whenever it needs a class, and the first match wins. Nearly every confusing Java startup error is this list being wrong: a JAR missing, in the wrong order, or there twice.

From source to a running program#

.java  --javac-->  .class  --jar-->  .jar  --java-->  JVM
source   compiler  bytecode   zip    archive launcher  runtime

.cs    --Roslyn--> IL, inside a .dll assembly  --dotnet-->  CLR
.NETJavaNote
.cs source file.java source fileone public class per file, named to match
Roslyn / cscjavaccompiles to bytecode, not to machine code
ILbytecodethe JVM's portable instruction set
assembly (.dll)JAR of .class filesa JAR is a zip archive
CLRJVMJIT-compiles bytecode as it runs
.NET SDKJDKcompiler, tools and a runtime
.NET runtimeJREa runtime only; see the note below
deps.json and assembly probingthe classpathhow the runtime finds your dependencies
dotnet MyApp.dlljava -jar myapp.jarrun a packaged application
Note

You want the JDK. Oracle stopped offering a separate JRE download with Java 11, and recommends building a trimmed runtime with jlink when you need a small one. Some other distributions still publish a runtime-only package for deployment, but for development there is only one answer: install a JDK.

Gotcha

Bytecode records the Java version it was compiled for. Compile with JDK 25, run on a Java 21 runtime, and the JVM refuses to load the class with UnsupportedClassVersionError: class file version 69.0. The build and the runtime must agree; see What that error means.

The classpath is a search list#

Where a .NET assembly records its own entry point, Java expects you to name the class to start, by its package plus its class name. The package also has to match the folder the class sits in.

C#: the assembly knows its entry point
// Program.cs, top-level statements
Console.WriteLine("hello");

// the entry point is recorded in the
// assembly, so dotnet run just works
Java: you name the class to start
// src/main/java/com/acme/App.java
package com.acme;

public class App {
    public static void main(String[] a) {
        System.out.println("hello");
    }
}
// java -cp target/classes com.acme.App

When code first touches a class, the JVM looks it up by that fully qualified name, say com.acme.billing.Invoice, by walking the classpath from the first entry to the last. In a folder entry it looks for com/acme/billing/Invoice.class; in a JAR entry it looks for the same path inside the archive. The first match is used and the search stops.

looking for  com/acme/billing/Invoice.class

classpath    1  target/classes/            not here
             2  lib/billing-core-2.1.jar   FOUND: loaded, search stops
             3  lib/billing-core-1.9.jar   never looked at
             4  lib/jackson-databind.jar
SymptomWhat the classpath got wrong
Could not find or load main classthe entry holding your main class is missing, or the package and folder disagree
ClassNotFoundExceptiona class looked up by name at runtime is on no entry
NoClassDefFoundErrora class present when you compiled is on no entry now
NoSuchMethodErrorthe class was found, but in the wrong version of its JAR
Gotcha

Two JARs holding the same class do not conflict. One silently wins. In the diagram, billing-core-1.9.jar is never consulted, so its version of Invoice might as well not exist. If code compiled against 1.9 calls a method that 2.1 removed, you get NoSuchMethodError at runtime, far from the cause. This is what people mean by “JAR hell”, and it is why Maven's dependency resolution, covered in Maven vs csproj, matters so much.

Running with -cp or -jar#

# name the classpath and the main class yourself
java -cp "target/classes:lib/*" com.acme.App

# or let the JAR's manifest decide both
java -jar target/app.jar
Gotcha

java -jar ignores -cp. The JDK documentation is explicit that with -jar, other class path settings are ignored, and that includes the CLASSPATH environment variable. Adding -cp lib/extra.jar to a -jar command does nothing, silently. Either list the dependency in the JAR's manifest, bundle it into a fat JAR as Spring Boot does, or drop -jar and use -cp with the main class named.

Note

Entries are separated by : on macOS and Linux and by ; on Windows. An entry ending in /* expands to every JAR in that folder, but quote it so the shell does not expand it first. With no -cp at all, the classpath is just the current directory.

LegacyThe CLASSPATH environment variable

Old instructions often tell you to set a CLASSPATH environment variable. It works, it applies to every Java program started from that shell, and it is almost never what you want: it mixes JAR versions across unrelated programs, which is exactly how the NoSuchMethodError above happens. It is also ignored by java -jar. Pass -cp explicitly, or let Maven build the classpath, and if a machine behaves strangely, check that nothing has set it.

Inside a JAR: the manifest#

A JAR is a zip file; rename one to .zip and you can open it. Next to the .class files it carries META-INF/MANIFEST.MF, a short text file that the launcher reads when you use -jar.

Manifest-Version: 1.0
Main-Class: com.acme.App
Class-Path: lib/jackson-databind.jar lib/slf4j-api.jar
AttributeDoesNote
Main-Classnames the class whose main method starts the programno .class suffix
Class-Pathadds more JARs to the classpathspace-separated, relative to this JAR's own folder
Gotcha

no main manifest attribute, in app.jar means the manifest has no Main-Class, so java -jar does not know where to start. A JAR built without its main class configured produces exactly this. The Spring Boot plugin sets it for you, which is one reason its JARs simply run.

Class-Path entries resolve relative to the JAR's own location, not to the directory you run from, and are separated by spaces. Move the JAR without its lib folder and every class those entries named disappears.

Class loading, at the level you need#

Classes are not all loaded at startup. Each one is loaded the first time your code touches it, by a class loader, and the loaders are arranged so that the JDK's own classes always come first.

bootstrap loader     java.lang.String, java.util.List  (the JDK core)
      ^  asked first
platform loader      the other JDK modules
      ^
application loader   YOUR classpath: target/classes, lib/*.jar

Each loader asks its parent before looking itself. That is why putting your own java.lang.String on the classpath achieves nothing: the bootstrap loader finds the real one first.

ErrorMeansUsual cause
ClassNotFoundExceptionan explicit lookup by name failedClass.forName, or a driver or plugin named in configuration
NoClassDefFoundErrora class that existed at compile time is gone at runtimea dependency scoped provided or test, or a JAR left out
ExceptionInInitializerErrorthe class was found, but its static initialiser threwread the cause; the class stays unusable afterwards
Note

Application servers and some frameworks add their own loaders beneath these, often one per application or plugin, and that is where the rarer failures live. The depth is signposted in Going deeper. You do not need it to run a Spring Boot service.

Compile and run by hand, once#

Maven hides every one of these steps, just as dotnet build hides assembly probing. Doing it by hand once makes the Maven version readable. Take two files:

// src/com/acme/Greeter.java
package com.acme;

public class Greeter {
    public String greet(String name) { return "hello " + name; }
}
// src/com/acme/App.java
package com.acme;

public class App {
    public static void main(String[] args) {
        System.out.println(new Greeter().greet("java"));
    }
}
# compile; -d says where the .class files go
javac -d out src/com/acme/*.java

ls out/com/acme
# App.class  Greeter.class      the folders mirror the package

# run: the classpath is "out", the main class is fully qualified
java -cp out com.acme.App
# hello java

# package it, recording the main class in the manifest
jar --create --file app.jar --main-class com.acme.App -C out .
java -jar app.jar
# hello java
Gotcha

The name you pass to java is the package plus the class, not a file path. java -cp out com.acme.App works, while java -cp out App and java out/com/acme/App.class both fail with Could not find or load main class. So does pointing -cp at the wrong folder, because the package folders must sit directly under a classpath entry.

Maven performs exactly these steps. It compiles into target/classes, assembles the classpath from your declared dependencies, and packages the result. Nothing here is special to Maven; it is simply doing it for you.

Resources live on the classpath too#

Everything under src/main/resources is copied into the JAR beside your classes and read through the classpath, not from disk. That is why application.yml is found wherever the JAR runs, and why this works in the IDE and fails after packaging:

// works in the IDE, where resources are files on disk
String cfg = Files.readString(Path.of("src/main/resources/rates.json"));

// works everywhere: read it from the classpath
try (var in = getClass().getResourceAsStream("/rates.json")) {
    String cfg = new String(in.readAllBytes(), UTF_8);
}

The failure is a NoSuchFileException in production, or a NullPointerException when getResourceAsStream returns null because the name is wrong. The Files and I/O chapter has the rest.

System properties and environment variables#

A JVM process takes settings from two separate places, where .NET mostly has one.

C#
// environment variable
var url = Environment
    .GetEnvironmentVariable("RATES_URL");

// the nearest thing to a system property:
// AppContext.GetData, fed by runtimeconfig.json
Java 25
// environment variable
String url = System.getenv("RATES_URL");

// system property, set with -Drates.url=...
String url2 = System.getProperty("rates.url");
SettingPassed asRead withSpring key
Environment variableRATES_URL=...System.getenvrates.url, by relaxed binding
System propertyjava -Drates.url=... -jar app.jarSystem.getPropertyrates.url
Command-line argumentjava -jar app.jar --rates.url=...Spring onlyrates.url
Gotcha

When the same key is set more than one way, Spring Boot prefers the command-line argument, then the system property, then the environment variable. So a -D flag in a startup script quietly beats the environment variable your container platform sets. The full order is in Dependency injection and configuration.

Gotcha

-D must come before -jar. Everything after the JAR name is handed to your program as an argument, so java -jar app.jar -Dx=1 sets no property at all.

Where your dependencies actually live#

.NETMavenNote
~/.nuget/packages~/.m2/repositorya shared cache, one copy per version
nuget.config sources~/.m2/settings.xmlrepositories, mirrors and credentials
obj/project.assets.jsonthe resolved dependency treemvn dependency:tree prints it

When Maven builds, it downloads each dependency into the local repository, with the group id's dots turned into folders, for example ~/.m2/repository/org/slf4j/slf4j-api/2.0.16/, and puts those JARs on the compile and test classpath. Your project never contains its dependencies, only a description of them.

ls ~/.m2/repository/org/slf4j/slf4j-api/
# 2.0.16/

mvn dependency:build-classpath     # print the exact classpath Maven will use
Gotcha

A corrupt or half-downloaded JAR in ~/.m2 produces baffling errors such as invalid LOC header or zip END header not found, and they survive mvn clean, because clean only empties target/. Delete that artifact's folder under ~/.m2/repository and let Maven download it again. It is the Java equivalent of clearing a stuck NuGet cache.

JVM flags at a glance#

FlagControlsExample
-Da system property your code or Spring reads-Dspring.profiles.active=prod
-Xan extra JVM option, mostly memory and diagnostics-Xmx512m, -Xss512k
-XX:an advanced or tuning option-XX:MaxRAMPercentage=70
anything after the JARyour program's own arguments--server.port=9090

So the familiar pieces sort themselves out: -D is configuration, -X and -XX: tune the JVM itself, and everything after the JAR name belongs to your program. A typo in a -XX: flag stops the JVM from starting with Unrecognized VM option. Tuning is covered in The JVM at runtime.

IntelliJ for Visual Studio users#

Part P0 · Day one · Chapter 04· 3 min read

Use IntelliJ IDEA. The Community Edition is free and covers core Java, Maven, Gradle, Git and JUnit; Ultimate adds Spring, JPA and database tooling and is what most Java shops buy. Eclipse and NetBeans exist and you will meet Eclipse in older organisations, but IntelliJ is the closest thing to Visual Studio plus ReSharper, and it is what the ecosystem assumes.

VS Code with the Red Hat Java and Spring Boot extension packs is a credible lighter option, closest to what you already know if you use VS Code for C#.

The project model is different#

This trips people up on day one. In Visual Studio a solution contains projects, and each project is an assembly. IntelliJ has a project containing modules, and the build tool, not the IDE, owns the truth.

Visual StudioIntelliJ IDEANote
Solution (.sln)Projectthe window you open
Project (.csproj)Moduleeach has its own pom.xml and produces one JAR
Assembly outputJARproduced by Maven or Gradle
Solution ExplorerProject tool windowAlt+1
Package ManagerMaven or Gradle tool windowshows the tree; you add dependencies by editing pom.xml
Build menuBuild, or the Maven panelthe IDE delegates to the build tool
Gotcha

The build tool is the source of truth, not the IDE. If you add a dependency to pom.xml, IntelliJ needs to re-import before it resolves: usually automatic, but the manual trigger is the refresh button in the Maven panel. A build that works in the IDE but fails on mvn clean package almost always means the IDE state drifted. Trust the command line.

Keyboard map#

IntelliJ's defaults differ enough to be frustrating for a week. There is a “Visual Studio” keymap in Settings → Keymap, but most people adapt; the muscle memory transfers faster than you expect.

ActionVisual StudioIntelliJ (macOS)
Go to definitionF12Cmd+B
Find usagesShift+F12Alt+F7
RenameCtrl+R,RShift+F6
Quick fixCtrl+.Alt+Enter
Search everywhereCtrl+TShift Shift
Go to fileCtrl+Shift+TCmd+Shift+O
ReformatCtrl+K,DCmd+Alt+L
RunF5Ctrl+R
DebugF5Ctrl+D
Step overF10F8
Generate memberCtrl+.Cmd+N
Extract methodCtrl+R,MCmd+Alt+M
Organise importsCtrl+R,GCtrl+Alt+O
Note

Alt+Enter is the one to learn first. It is Ctrl+. but far broader; it fixes, imports, converts a loop to a stream, adds a missing dependency, generates a missing method, and suggests language-level upgrades such as turning an anonymous class into a lambda or a chain of if into a switch expression.

Generating the boilerplate Java expects#

Java has no properties, so getters and setters are real methods someone has to write. Nobody writes them by hand. Cmd+N generates constructors, getters, setters, equals/hashCode, toString and delegation methods.

C#: the compiler generates it
public class Customer
{
    public string Name { get; set; }
    public int Age { get; set; }
}
Java: the IDE generates it
public class Customer {
    private String name;
    private int age;

    // Cmd+N -> Getter and Setter
    public String getName()          { return name; }
    public void   setName(String n)  { this.name = n; }
    public int    getAge()           { return age; }
    public void   setAge(int a)      { this.age = a; }
}

Better still, most of that ceremony disappears if the type can be a record Java 16; see Records.

Debugging#

FeatureVisual StudioIntelliJ
Conditional breakpointright-click breakpointright-click breakpoint
Immediate windowImmediateEvaluate Expression, Alt+F8
WatchWatch windowVariables panel, or Add to Watches
Data tipshoverhover
Edit and ContinuesupportedHotSwap, method bodies only
Exception breakpointException SettingsBreakpoints, Java Exception Breakpoints
Attach to processDebug, AttachRun, Attach to Process
Gotcha

Hot reload is weaker than Edit and Continue. Standard HotSwap replaces method bodies only, add a field or change a signature and you must restart. Spring Boot DevTools does a fast context restart, and the JetBrains Runtime plus the paid JRebel go further, but do not expect the seamless C# experience.

Static analysis#

Java's equivalent of Roslyn analyzers is a set of separate, mature tools you wire into the build. IntelliJ's own inspections are strong out of the box and roughly fill the ReSharper role, but CI should run these:

.NETJavaCatches
Roslyn analyzersError Pronereal bug patterns at compile time
StyleCopCheckstyleformatting and naming
FxCop / analyzersSpotBugsbytecode-level bug patterns
EditorConfigEditorConfigsupported by IntelliJ directly
dotnet formatSpotlessapplies formatting in the build
Check yourself: Day one
1You have three JDKs installed and a Maven build compiles against the wrong one. Where do you look first?
JAVA_HOME. The mvn and gradle scripts use it when set. The IDE does not, which is why the two can disagree; IntelliJ uses its own project SDK.
2Which Java version should a new production service target, and why not the newest?
An LTS: 21 or 25. Non-LTS releases get six months of updates and then nothing, so they are for trying features, not for shipping.
3A colleague sends you a snippet using var and a record. What is the minimum Java version it needs?
16, for records. var arrived in 10. On a Java 8 codebase, neither compiles.
4What is the Java equivalent of the solution file, and who owns it?
A parent pom.xml listing modules. The build tool owns the truth, not the IDE, so a build that works only in IntelliJ means the IDE state has drifted.
5You run java -jar app.jar -cp lib/extra.jar and a class from extra.jar is not found. Why?
-jar ignores every other classpath setting, and here -cp also comes after the JAR name, so it is passed to your program as an argument. List the JAR in the manifest, build a fat JAR, or drop -jar and run java -cp with the main class named.

Classes, constructors and initialisation#

Part P1 · Language core · Chapter 05· 2 min read

Classes are near-identical. The differences that matter: extends instead of :, final instead of sealed, constructor chaining happens in the body rather than the signature, and there are no static classes, only classes that cannot be instantiated.

Java also has two things C# lacks: instance initialiser blocks and static initialiser blocks, which you will meet in older code.

Declaration#

C#
public class TypeResolver : BaseResolver, IDisposable
{
}

public sealed class Final { }
public abstract class Shape { }
public static class Util { }
Java 25
public class TypeResolver extends BaseResolver
        implements AutoCloseable {
}

public final class Final { }
public abstract class Shape { }
// no static classes; see below
C#JavaNote
: Baseextends Baseone superclass only, same as C#
: IFooimplements Foomultiple allowed, same as C#
sealed classfinal classcannot be subclassed; Java's sealed means something else
abstract classabstract classidentical
static classfinal class + private constructorJava has no static classes; this is the idiom
partial classno equivalentuse composition or generated code
internal classpackage-private (no keyword)visible to the same package only, not the whole JAR
nested classstatic nested classnon-static means something else
static class

Java has no static classes. The idiom for a pure utility holder is a final class with a private constructor, which is what java.util.Objects and java.util.Collections do:

public final class Money {
    private Money() { throw new AssertionError("no instances"); }

    public static BigDecimal round(BigDecimal v) { ... }
}

An interface with only static methods is sometimes used instead, but it is a weaker signal of intent, interfaces are implementable.

Constructors and chaining#

Both languages chain constructors, but Java does it as the first statement rather than in the signature. this(...) calls a sibling constructor; super(...) calls the superclass.

C#
public class TypeResolver : BaseResolver
{
    public TypeResolver()
        : this("default", 0)
    {
    }

    public TypeResolver(string name, int type)
        : base(name)
    {
    }
}
Java 25
public class TypeResolver extends BaseResolver {

    public TypeResolver() {
        this("default", 0);
    }

    public TypeResolver(String name, int type) {
        super(name);
    }
}
Gotcha

Historically this(...) or super(...) had to be the very first statement, so you could not validate an argument before delegating. Java 25 finalised flexible constructor bodies Java 25, which allows statements before the constructor call as long as they do not touch the instance:

public Range(int lo, int hi) {
    if (lo > hi) throw new IllegalArgumentException("lo > hi");  // now legal
    super();
    this.lo = lo;
}

On Java 21 and earlier this does not compile. The workaround is a private static helper called inside the super(...) argument itself, as in super(requireValid(name)), which you will see in a lot of existing code.

Initialiser blocks#

Java has two constructs with no C# equivalent. Both run at predictable points and both appear in real code, particularly older code and static registries.

public class Registry {

    private static final Map<String, Handler> HANDLERS;

    // static initialiser: runs once, when the class is first loaded.
    // Roughly a C# static constructor, but you can have several and
    // they run top to bottom.
    static {
        HANDLERS = new HashMap<>();
        HANDLERS.put("json", new JsonHandler());
    }

    private final long created;

    // instance initialiser: runs before every constructor body,
    // after super(). Rare; usually a constructor is clearer.
    {
        created = System.nanoTime();
    }

    public Registry() { }
}
C#JavaRuns when
static Foo() { }static { }first use of the class
field initialiserfield initialiserbefore constructor body
n/ainstance initialiser { }before constructor body, after super()
Note

Java's class initialisation is lazy and thread-safe, like C#'s. The JVM guarantees a class's static initialiser runs exactly once, before any static member is read. That is why the static-holder idiom is the classic thread-safe lazy singleton in Java.

Every class extends Object#

As in C#, but the method names differ and the contract is enforced by convention rather than by the compiler.

C#JavaNote
ToString()toString()used by string concatenation
Equals(object)equals(Object)value comparison; == compares references instead
GetHashCode()hashCode()must agree with equals
GetType()getClass()returns Class, not Type
MemberwiseClone()clone()avoid; use a copy constructor
Finalize()finalize()deprecated for removal
n/await() / notify()every object has a built-in lock; a 1990s condition-variable API
Legacyclone() and Cloneable

Java's cloning mechanism is widely considered a design mistake, Cloneable is a marker interface with no clone method, and the default implementation is a shallow field copy that bypasses constructors. You will encounter it. Prefer a copy constructor, a static factory, or a record with a with-style method.

Access modifiers and packages#

Part P1 · Language core · Chapter 06· 2 min read

Two traps, and they are the most expensive false friends in the language.

One: Java's protected is wider than C#'s; it also grants access to everything in the same package. Two: the default when you write no modifier at all is not private, it is package-private, which has no C# equivalent at all.

There is also no internal. The nearest thing is the module system, and it operates at a different granularity.

The mapping#

Java modifierVisible toClosest C#
publiceveryonepublic
protectedpackage + subclasses anywhereno exact equal
(none)the same package onlyno equal, package-private
privatethe declaring classprivate
C# modifierVisible toClosest Java
publiceveryonepublic
protectedsubclasses onlyno exact equal (Java's is wider)
internalthe assemblypackage-private, roughly
protected internalassembly or subclassesprotected, roughly
private protectedsubclasses in the assemblyno equal
privatethe declaring typeprivate
Gotcha

protected is not a narrowing of public in Java the way you expect. Because it also grants package access, any class placed in the same package, including one written later, by someone else, can read a protected member without subclassing. If you want “subclasses only”, Java cannot express it. Design around it: keep the member private and expose a protected accessor whose contract you control, or use a sealed hierarchy.

Gotcha

Forgetting a modifier is not the same as writing private. A field with no modifier is visible to the whole package. In C# a field with no modifier is private, so the muscle memory is precisely inverted.

public class Account {
    BigDecimal balance;          // package-private! anyone in this package can write it
    private BigDecimal fee;      // what you meant
}

Packages are not namespaces#

A C# namespace is a purely logical grouping; it need not match the folder layout, one file can declare several, and nesting is meaningful for resolution. A Java package is a physical thing: the package declaration must match the directory structure, and javac enforces it.

C#
// Anywhere/AtAll/Resolver.cs
namespace Acme.Sandbox;

public class TypeResolver { }
Java 25
// src/main/java/com/acme/sandbox/TypeResolver.java
package com.acme.sandbox;

public class TypeResolver { }
Gotcha

Packages do not nest. This is the second half of the protected trap. com.acme.sandbox and com.acme.sandbox.internal look nested in the IDE tree, but to the compiler they are two unrelated packages. A package-private member in the parent is not visible to the child package.

In C#, Acme.Sandbox.Internal can see internal members of Acme.Sandbox because they share an assembly. In Java they share nothing.

Imports#

C#JavaNote
using System;import java.util.List;Java imports types, not namespaces
using System.*;import java.util.*;pulls in every type in that package
using RX = System.Text.RegularExpressions;no alias importsmust use the full name
using static Math;import static java.lang.Math.max;imports one member, not the whole type
global usingno equivalentevery file repeats its imports
implicit usingsjava.lang is always importedString, Object, Integer
Note

There are no import aliases in Java. If you need java.util.Date and java.sql.Date in one file, one of them must be written fully qualified everywhere. Java 25 added module import declarations Java 25 (import module java.base;) which pulls in every exported package of a module at once: convenient for scripts, generally too broad for production code.

There is no internal#

This is a real gap, and it shapes Java library design. C# gives you an assembly-wide visibility level plus InternalsVisibleTo for tests. Java's options are weaker:

GoalJava approachCost
Hide from other packageskeep it package-privatetests must share the package
Hide from other JARsJPMS module, do not exportall-or-nothing per package
Signal do not usename the package .internalconvention only, not enforced
Grant test accessput tests in the same packagestandard practice

The last row is the one to internalise: Java tests conventionally live in the same package as the code they test, under src/test/java rather than src/main/java. That gives them package-private access with no equivalent of InternalsVisibleTo. See Modules, JPMS and internal.

Package naming#

Reverse domain name, all lowercase, no underscores: com.acme.billing.api. The convention is universal and tooling assumes it. Unlike C#, the company prefix is not optional decoration; it exists so JARs from different vendors never collide on the classpath.

Interfaces, abstract classes and sealing#

Part P1 · Language core · Chapter 07· 2 min read

Interfaces are close to C#'s and, since Java 8, have gained the same capabilities: default implementations, static methods, and private helpers. No I prefix.

Java's sealed is not C#'s sealed; it is C#'s missing feature. Java sealed means “only these named types may extend me”, which combined with pattern matching gives you real discriminated unions. C# still has no equivalent.

Interfaces#

C#
public interface IResolver
{
    Type Resolve(Guid id);

    // C# 8+ default implementation
    bool CanResolve(Guid id) => Resolve(id) is not null;

    static IResolver Null => new NullResolver();
}
Java 25
public interface Resolver {
    Class<?> resolve(UUID id);

    default boolean canResolve(UUID id) {
        return resolve(id) != null;
    }

    static Resolver nullResolver() {
        return new NullResolver();
    }
}
FeatureC#Java
Default method bodyC# 8Java 8
Static methodC# 8Java 8
Private methodC# 8Java 9
Constant fieldnoyes, implicitly public static final
Fieldsnono (constants only)
Explicit implementationyesno
Generic variance on the interfaceyes, in/outno, use-site only
Gotcha

Every field in an interface is implicitly public static final. There is no way to declare instance state. Older code sometimes abuses this as a “constant interface” that classes implement purely to inherit its constants. That is a recognised anti-pattern: use a final class or an enum instead.

Explicit interface implementation

C# lets you implement two interfaces with the same method signature separately, and hide a member from the class's public surface. Java has neither. If two interfaces declare the same signature, one method satisfies both, and it is always public.

If they declare the same signature with incompatible default bodies, the class must override it and can disambiguate explicitly:

public class Both implements A, B {
    @Override public String name() {
        return A.super.name();     // pick a specific default
    }
}

Sealed types: Java's discriminated unions#

Be careful with the word. sealed means the opposite thing in each language.

IntentC#Java
Cannot be extended at allsealed classfinal class
Only named types may extendno equivalentsealed interface / sealed class

A sealed hierarchy declares its complete set of subtypes. The compiler then knows the set is closed, which makes exhaustive switching possible without a default branch.

public sealed interface Shape
        permits Circle, Square, Rectangle {
}

public record Circle(double radius) implements Shape { }
public record Square(double side) implements Shape { }
public record Rectangle(double w, double h) implements Shape { }
C# 14: no exhaustiveness
// the compiler cannot know the set is closed,
// so a default arm is always required
double Area(Shape s) => s switch
{
    Circle c    => Math.PI * c.Radius * c.Radius,
    Square q    => q.Side * q.Side,
    Rectangle r => r.W * r.H,
    _ => throw new ArgumentException()   // unavoidable
};
Java 21+: exhaustive
// no default needed, add a fourth Shape and this
// stops compiling until you handle it
double area(Shape s) {
    return switch (s) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Square q    -> q.side() * q.side();
        case Rectangle r -> r.w() * r.h();
    };
}
Note

This is one of the few places where Java is meaningfully ahead of C#. Sealed interfaces Java 17 plus record patterns and pattern matching for switch Java 21 give you the algebraic data types C# developers usually reach for a library or a visitor pattern to fake. See Sealed types and pattern matching.

The rules for permits#

RuleDetail
Same module or packagepermitted subtypes must be in the same module, or same package if unnamed
Every subtype must chooseit must be final, sealed, or non-sealed
permits may be omittedif all subtypes are in the same file
non-sealedreopens the hierarchy for that branch
public sealed interface Event permits UserEvent, SystemEvent { }

public final class UserEvent implements Event { }        // closed
public non-sealed class SystemEvent implements Event { } // anyone may extend this branch

Abstract classes#

Essentially identical to C#. The choice between an abstract class and an interface with default methods follows the same reasoning: state and constructors force an abstract class; otherwise prefer the interface.

C#Java
abstract class Cabstract class C
abstract void M();abstract void m();
override void M()@Override void m()
virtual by opt-invirtual by default, use final to opt out
new (member hiding)no equivalent for methods
Gotcha

Java methods are virtual by default. C# requires virtual to allow overriding; Java requires final to forbid it. A method you did not intend to be an extension point is one, unless you say otherwise. @Override is an annotation the compiler checks, not a keyword that enables anything, but always write it, because it catches typo'd signatures that would otherwise silently overload.

Methods and parameters#

Part P1 · Language core · Chapter 08· 3 min read

Methods are the same idea with fewer features. Java has no ref or out, no optional parameters, no named arguments, no extension methods and no operator overloading. Overloading and varargs are the only tools, and the idioms that fill the gaps, overload ladders, builders, and static utility classes, are everywhere in Java code as a result.

Signatures#

C#
public async Task<int> CountAsync(
    string filter,
    bool caseSensitive = false,
    int limit = 100)
{
    ...
}

var n = await CountAsync("x", limit: 50);
Java 25
public int count(String filter) {
    return count(filter, false, 100);
}
public int count(String filter, boolean cs) {
    return count(filter, cs, 100);
}
public int count(String filter, boolean cs, int limit) {
    ...
}

int n = count("x", false, 50);   // no named args
Optional and named parameters

Java has neither. The three replacements, in order of preference:

  • Overload ladder. Each shorter overload delegates to the fullest one. Fine for two or three parameters.
  • Builder. The standard answer beyond that. Verbose to write, pleasant to call, and what most libraries expose.
  • A parameter object, usually a record, when the arguments form a meaningful group.
// builder; the idiomatic replacement for named + optional arguments
var req = HttpRequest.newBuilder()
        .uri(URI.create("https://example.com"))
        .timeout(Duration.ofSeconds(10))
        .header("Accept", "application/json")
        .GET()
        .build();
Gotcha

Parameter names are not in the compiled output by default. Reflection sees arg0, arg1 unless the class was compiled with the -parameters flag. This bites when a framework binds by parameter name. Spring needs it for some constructor injection and request binding. Spring Boot's Maven and Gradle plugins enable it for you; a hand-rolled build may not.

No ref, no out#

Java is strictly pass-by-value. Object references are passed by value too; you can mutate what a reference points at, but you cannot make the caller's variable point somewhere else.

C#
if (int.TryParse(s, out var value))
{
    Use(value);
}

void Swap(ref int a, ref int b) { ... }
Java 25
// return a value that models absence instead
OptionalInt parsed = parseInt(s);
if (parsed.isPresent()) {
    use(parsed.getAsInt());
}

// or a record for multiple results
record Pair(int a, int b) { }
Pair swapped = swap(a, b);
C# patternJava replacement
out parameterreturn Optional, or a record
TryParseOptional-returning method, or catch NumberFormatException
ref parameterreturn the new value and reassign
Multiple return valuesa record, or a small value class
ref struct / SpanByteBuffer, MemorySegment, or an array plus offsets
in parameternothing needed; everything is already by value

Varargs#

Identical in spirit to params, with the same restriction that it must be last.

C#
void Log(string fmt, params object[] args) { }
Log("a {0}", 1);
Java 25
void log(String fmt, Object... args) { }
log("a %s", 1);
Gotcha

Passing an array to a varargs parameter of type Object... is ambiguous: log("x", myArray) spreads the array rather than passing it as one argument. Cast to force it: log("x", (Object) myArray). Compilers warn about this, and the warning is worth reading.

No extension methods#

Extension methods

Java has no way to add a method to a type you do not own. The replacements:

  • A static utility class, StringUtils.isBlank(s) rather than s.IsBlank(). This is why Apache Commons and Guava exist.
  • A default method, if you own the interface.
  • A wrapper type, when the extension is substantial.

The practical consequence: Java code reads inside-out where C# reads left-to-right. Collections.unmodifiableList(new ArrayList<>(xs)) instead of a fluent chain. Streams are the notable exception, and part of why they feel so different from the rest of the language.

No operator overloading#

Java overloads exactly one operator, + for strings, and does not let you add more. This has a direct practical consequence for anyone writing financial code:

C#
decimal total = price * quantity + shipping;
if (total > limit) { ... }
Java 25
BigDecimal total = price.multiply(quantity).add(shipping);
if (total.compareTo(limit) > 0) { ... }

See Numbers, money and time; this is the single most common source of quiet correctness bugs when a C# developer writes their first Java money code.

Lambdas and method references#

C#JavaNote
x => x + 1x -> x + 1arrow differs
(x, y) => x + y(x, y) -> x + ysame
() => Foo()() -> foo()same
Foo.Bar (method group)Foo::barpasses the method itself as a lambda
new Foo() as factoryFoo::newpasses the constructor as a factory function
Func<int, string>Function<Integer, String>primitives must be boxed in a generic
Action<T>Consumer<T>void-returning
Predicate<T>Predicate<T>same name
Gotcha

Captured variables must be effectively final. Java lets a lambda capture a local only if that local is never reassigned after initialisation. C# closes over the variable itself and permits mutation.

int count = 0;
list.forEach(x -> count++);      // does not compile

var count = new AtomicInteger(); // the usual workaround
list.forEach(x -> count.incrementAndGet());

Awkward, but it removes a whole class of closure-capture surprise, and it is what makes lambdas safe to hand to another thread.

Fields, properties and immutability#

Part P1 · Language core · Chapter 09· 3 min read

Java has no properties. A “property” is a naming convention: a private field plus getX() and setX() methods. Frameworks (Jackson, Spring, Hibernate) discover them by that convention, which is why it matters more than it looks.

For immutable data, stop writing this by hand and use a record Java 16. For mutable data, let the IDE generate the accessors.

The property gap#

C#
public class Customer
{
    public string FirstName { get; set; }
    public string LastName  { get; init; }
    public string FullName => $"{FirstName} {LastName}";
}
Java 25
public class Customer {
    private String firstName;
    private final String lastName;

    public Customer(String last) { this.lastName = last; }

    public String getFirstName()         { return firstName; }
    public void   setFirstName(String v) { this.firstName = v; }
    public String getLastName()          { return lastName; }
    public String getFullName() {
        return firstName + " " + lastName;
    }
}
C#JavaNote
{ get; set; }getX() / setX()frameworks find them by this exact naming rule
{ get; }final field + getX()set in the constructor
{ get; init; }final field + constructoror a record
=> expressiona plain methodcomputed, no field
requiredno equivalentmake the field final and demand it in the constructor
field keywordno equivalentwrite the field yourself
Note

The convention is exact and tooling depends on it: getName() for a name property, isActive() for a boolean, and setName(v). Get the prefix wrong and Jackson will not serialise the field and Hibernate will not map it: with no error, just a missing value.

Records make most of this disappear#

If the type is immutable data, a DTO, a value object, a query result, a record replaces the whole ceremony, including equals, hashCode and toString.

C# record
public record Customer(string FirstName, string LastName)
{
    public string FullName => $"{FirstName} {LastName}";
}

var c2 = c1 with { FirstName = "Ada" };
Java 16+ record
public record Customer(String firstName, String lastName) {
    public String fullName() {
        return firstName + " " + lastName;
    }
}

// no with-expression; see the Records chapter
var c2 = new Customer("Ada", c1.lastName());
Gotcha

Record accessors have no get prefix. A record component name is read with c.name(), not c.getName(). This breaks the JavaBean convention deliberately, and older framework versions that expected getX() could not bind records. Modern Jackson and Spring handle records natively, but a library that has not been updated since about 2021 may not.

final is not readonly, quite#

C#JavaMeaning
readonly fieldfinal fieldassignable once, in constructor or initialiser
conststatic finalcompile-time constant, inlined
static readonlystatic finalassigned in a static initialiser
readonly structno equivalentno value types yet
init-onlyfinal + constructorrecords do this for you
Gotcha

final makes the reference unmodifiable, not the object. This is identical to C#'s readonly, and catches people equally in both languages:

private final List<String> names = new ArrayList<>();

names.add("ada");             // fine, mutating the list
names = new ArrayList<>();    // does not compile, rebinding the reference

For genuine immutability use List.of(...) Java 9, which returns an unmodifiable list that throws on mutation.

static final constants#

C#
public const int MaxRetries = 3;
public static readonly TimeSpan Timeout =
    TimeSpan.FromSeconds(30);
Java 25
public static final int MAX_RETRIES = 3;
public static final Duration TIMEOUT = Duration.ofSeconds(30);

Constants are SCREAMING_SNAKE_CASE by convention, the one place Java departs from camelCase. The rule is universal.

Gotcha

A static final primitive or String initialised with a compile-time constant is inlined into callers, exactly like C#'s const. If you publish a library, change such a constant, and a consumer does not recompile, they keep the old value. Use a static method or a non-constant initialiser for anything that might change.

A word on Lombok#

LegacyProject Lombok

Lombok is an annotation processor that generates getters, setters, constructors, equals/hashCode and builders at compile time. You will meet it in a great many Java codebases, and it genuinely removed a lot of pain before records existed.

@Getter @Setter @Builder
public class Customer {
    private String firstName;
    private String lastName;
}

It is not deprecated and remains widely used, but it is worth knowing the tradeoff: it patches the compiler's internal API, which has caused breakage on several JDK upgrades. For immutable data, a record is now the better answer and needs no dependency. For mutable entities, many teams still reach for Lombok.

Check yourself: Language core
1A field declared with no access modifier. Who can see it?
Everything in the same package. Java's default is package-private, not private. This is inverted from C#, and the compiler will not warn you.
2You mark a method protected to restrict it to subclasses. Did that work?
No. Java's protected also grants access to the whole package, so it is strictly wider than C#'s. Java cannot express subclass-only access.
3Which is virtual: a Java method, or a C# method?
The Java one. Java methods are virtual unless marked final; C# requires virtual to opt in. Anything you write is an extension point by default.
4How do you write a C# auto-property in Java?
You do not. A private field plus getX() and setX(), or better, a record if the type is immutable data. Frameworks find them by that exact naming rule.

Records#

Part P2 · Modern Java · Chapter 10· 2 min read

Java records Java 16 and C# records solve the same problem and look almost identical. Both give you a constructor, accessors, equals, hashCode and toString from one line.

Three differences matter: Java records are always immutable (no set, ever), there is no with expression, and accessors have no get prefix.

The basics#

C#
public record Point(int X, int Y);

var p = new Point(1, 2);
Console.WriteLine(p);      // Point { X = 1, Y = 2 }
Console.WriteLine(p.X);
var q = p with { Y = 5 };
Java 25
public record Point(int x, int y) { }

var p = new Point(1, 2);
System.out.println(p);     // Point[x=1, y=2]
System.out.println(p.x()); // note the ()
// no with-expression
FeatureC# recordJava record
Immutable by defaultinit-only, but can add settersalways, no exceptions
Positional syntaxyesyes
Nominal (body) propertiesyesno: components only
with expressionyesno
Value equalityyesyes
Inheritancerecords can inherit recordsno inheritance at all
Can implement interfacesyesyes
struct variantrecord structno
Custom constructoryesyes, plus compact form

Compact constructors#

Java has a form C# lacks: a compact constructor that validates or normalises without restating the parameter list or the assignments.

public record Range(int lo, int hi) {

    // compact canonical constructor
    public Range {
        if (lo > hi) throw new IllegalArgumentException("lo > hi");
        hi = Math.min(hi, 1000);   // normalise; assignment to the field is implicit
    }

    // extra constructor must delegate to the canonical one
    public Range(int hi) {
        this(0, hi);
    }
}
Note

Inside a compact constructor you assign to the parameter, and the field is assigned from it automatically when the body completes. Writing this.hi = hi is not allowed there. It reads oddly the first time and then becomes natural.

The missing with expression#

with expressions

Java has no with. Java 25 has no equivalent shipped. The options:

// 1. Just construct it, fine for small records
var q = new Point(p.x(), 5);

// 2. Hand-written wither: common in domain code
public record Point(int x, int y) {
    public Point withY(int newY) { return new Point(x, newY); }
}

// 3. A builder, for records with many components

Derived record creation has been discussed for a future release but is not in Java 25. For a record with more than about four components, a builder is the practical answer.

Where records fit#

UseSuitableWhy
DTO / API request or responseyesJackson binds records natively
Value object (Money, Range)yesimmutability is the point
Query result / projectionyesSpring Data supports record projections
Pattern-matching payloadyesrecord patterns destructure them
JPA entitynoHibernate needs a no-arg constructor and mutability
Mutable domain objectnorecords cannot be mutated
Needs inheritancenorecords are implicitly final
Gotcha

Records are shallowly immutable. A record holding a List<String> hands out the same mutable list to every caller. Defensive copying is your job:

public record Team(String name, List<String> members) {
    public Team {
        members = List.copyOf(members);   // now genuinely immutable
    }
}

This is identical to the C# situation with a record holding a List<T>, but Java gives you List.copyOf as a one-liner in the compact constructor.

Records and pattern matching#

Records are the payload type that makes record patterns work. Because the component list is part of the type's contract, the compiler can destructure them positionally:

sealed interface Shape permits Circle, Rect { }
record Circle(double r) implements Shape { }
record Rect(double w, double h) implements Shape { }

String describe(Shape s) {
    return switch (s) {
        case Circle(double r) when r > 100 -> "big circle";
        case Circle(double r)              -> "circle of " + r;
        case Rect(double w, double h)      -> w + " by " + h;
    };
}

C# can pattern-match on positional records too, but without sealed hierarchies it cannot prove exhaustiveness, so a discard arm is always required.

Sealed types and pattern matching#

Part P2 · Modern Java · Chapter 11· 2 min read

Everything C# 9-12 gave you for pattern matching, Java has by Java 21, plus one thing C# still lacks: exhaustiveness over a sealed hierarchy. Combine sealed Java 17, records Java 16 and pattern matching for switch Java 21 and you get real discriminated unions, checked by the compiler.

The gap the other way: Java has no property patterns, no list patterns, and no relational patterns except in when guards.

Type patterns#

C#
if (o is string s && s.Length > 3)
{
    Use(s);
}
Java 25
if (o instanceof String s && s.length() > 3) {
    use(s);
}

Pattern matching for instanceof Java 16 removed the cast-after-check ceremony that defined older Java. The bound variable's scope is flow-sensitive in the same way as C#'s.

LegacyCast after instanceof

Every Java codebase older than about 2021 is full of this. It still compiles; there is no reason to write it now.

if (o instanceof String) {
    String s = (String) o;
    use(s);
}

Pattern matching in switch#

C#
string Describe(object o) => o switch
{
    null            => "nothing",
    int i when i < 0 => "negative",
    int i           => $"int {i}",
    string s        => $"string of {s.Length}",
    _               => "other"
};
Java 25
String describe(Object o) {
    return switch (o) {
        case null            -> "nothing";
        case Integer i when i < 0 -> "negative";
        case Integer i       -> "int " + i;
        case String s        -> "string of " + s.length();
        default              -> "other";
    };
}
C#JavaNote
is T xinstanceof T xsame
switch expression armscase T x ->same shape
when clausewhen clausesame keyword since Java 21
_ discarddefaultJava uses default, or _ for unnamed variables
case nullcase nullJava 21; before that switch threw NPE
positional patternrecord patternJava 21
property pattern { X: 1 }no equivalenttest the property in a when clause instead
list pattern [1, .., 2]no equivalentnone planned
relational pattern > 5only inside whenno bare relational patterns
Gotcha

A switch on a reference type throws NullPointerException unless you write case null. This is the historical behaviour preserved for compatibility. C#'s switch expression handles null through a pattern arm and throws only if nothing matches. Always consider whether you need case null, or case null, default -> to fold it into the fallback.

Record patterns#

Record patterns Java 21 destructure positionally, and they nest.

sealed interface Json permits JNull, JBool, JNum, JStr, JArr, JObj { }
record JNull()                        implements Json { }
record JBool(boolean value)           implements Json { }
record JNum(double value)             implements Json { }
record JStr(String value)             implements Json { }
record JArr(List<Json> items)         implements Json { }
record JObj(Map<String, Json> fields) implements Json { }

String render(Json j) {
    return switch (j) {
        case JNull()          -> "null";
        case JBool(var b)     -> String.valueOf(b);
        case JNum(var n)      -> String.valueOf(n);
        case JStr(var s)      -> "\"" + s + "\"";
        case JArr(var items)  -> items.stream().map(this::render)
                                      .collect(joining(",", "[", "]"));
        case JObj(var fields) -> fields.entrySet().stream()
                                      .map(e -> "\"" + e.getKey() + "\":" + render(e.getValue()))
                                      .collect(joining(",", "{", "}"));
    };
}
Note

No default arm, and none is permitted to be needed; the compiler proves the six cases are complete. Add a seventh Json subtype and every switch over Json in the codebase stops compiling until it is handled. That is the property C# developers usually simulate with a visitor or an abstract method.

Nested patterns and unnamed variables#

Patterns nest to any depth, and Java 22 added unnamed patterns and variables Java 22, _ for a binding you do not intend to use.

record Pair(Object left, Object right) { }

String f(Object o) {
    return switch (o) {
        // destructure two levels, ignore the second component
        case Pair(String s, _)          -> "left string " + s;
        case Pair(Pair(var a, _), _)    -> "nested " + a;
        default                         -> "other";
    };
}
Gotcha

A nested pattern only matches if every level matches, and a null at any level fails the pattern rather than binding. That is usually what you want, but it means case Pair(String s, _) silently skips a Pair(null, x). If null is meaningful, match it explicitly.

What is still preview#

Primitive types in patterns, instanceof and switch Java 25 preview extends pattern matching to primitives, so you could write case int i against a double and have the compiler check the conversion is exact. It is a third preview in Java 25 and requires --enable-preview. Do not ship it.

Gotcha

Anything marked preview in this book needs --enable-preview at both compile and run time, is not covered by the usual compatibility guarantees, and can change or disappear in the next release. Class files compiled with preview features refuse to load on a different JDK version.

Switch expressions and statements#

Part P2 · Modern Java · Chapter 12· 2 min read

Java has two switches. The old statement form with colons and fall-through, and the expression form with arrows Java 14, which is what C# gave you in C# 8 and is what you should write.

The arrow form does not fall through, must be exhaustive when used as an expression, and returns a value.

The arrow form#

C#
var label = day switch
{
    DayOfWeek.Saturday or DayOfWeek.Sunday => "weekend",
    DayOfWeek.Friday => "almost",
    _ => "weekday"
};
Java 25
var label = switch (day) {
    case SATURDAY, SUNDAY -> "weekend";
    case FRIDAY           -> "almost";
    default               -> "weekday";
};
C#Java
value switch { ... }switch (value) { ... }
pattern => resultcase pattern -> result
or between patternscomma between labels
_default
throw in an armthrow in an arm
must be exhaustivemust be exhaustive

Multi-statement arms#

When an arm needs more than one expression, use a block and yield. This is Java's answer to a C# arm that needs statements, where C# would force you into a local function or a statement switch.

int size = switch (shape) {
    case Circle c -> {
        var r = c.radius();
        log.debug("circle r={}", r);
        yield (int) (Math.PI * r * r);
    }
    case Square s -> s.side() * s.side();
};
Gotcha

yield is a contextual keyword; it returns a value from a switch block, and has nothing whatever to do with C#'s yield return. Java has no generator support at all; see Streams vs LINQ for what replaces it.

Exhaustiveness#

A switch expression must cover every possible value. For an enum that means every constant or a default; for a sealed type, every permitted subtype.

enum Status { NEW, ACTIVE, CLOSED }

// no default needed; all three covered.
// Add a fourth constant and this stops compiling.
String describe(Status s) {
    return switch (s) {
        case NEW    -> "new";
        case ACTIVE -> "active";
        case CLOSED -> "closed";
    };
}
Note

Deliberately omitting default over an enum is a useful technique: it converts “someone added a constant and forgot a code path” from a runtime surprise into a compile error. C# cannot do this; its switch expression always wants a discard arm to avoid a warning, and adding an enum member never breaks the build.

The old statement form#

LegacyColon switch with fall-through

Still legal, still everywhere, and still the source of the classic missing-break bug. Recognise it; do not write it.

switch (status) {
    case NEW:
        init();
        // falls through, intentional? nobody knows
    case ACTIVE:
        run();
        break;
    default:
        throw new IllegalStateException();
}

The arrow form cannot fall through at all, which removes the bug class entirely. When you need the same action for several labels, list them: case NEW, ACTIVE ->.

What you can switch on#

TypeJavaNote
int, short, char, byteyessince forever
StringyesJava 7
enumyessince Java 5
sealed interface / classyesJava 21, via patterns
any ObjectyesJava 21, via patterns
long, float, double, booleannouse if/else, or preview primitive patterns
Gotcha

You cannot switch on long. It is an odd historical gap that catches people switching on a timestamp or an ID. Use if/else if, or a Map<Long, ...> lookup.

var, strings and text blocks#

Part P2 · Modern Java · Chapter 13· 3 min read

var Java 10 works as you expect. Text blocks Java 15 are C#'s raw string literals. The one genuine loss is string interpolation. Java has none, and the feature that would have provided it was previewed twice and then withdrawn.

var#

C#
var list = new List<string>();
var count = 0;
foreach (var item in list) { }
Java 25
var list = new ArrayList<String>();
var count = 0;
for (var item : list) { }
ContextC# varJava var
Local variableyesyes
for / foreach variableyesyes
Fieldnono
Method parameternono
Return typenono
Lambda parameterimplicityes, explicit var allowed
Without an initialisernono
With nullnono
Gotcha

var plus the diamond operator infers something useless:

var list = new ArrayList<>();     // ArrayList<Object>, almost never intended
var list = new ArrayList<String>();  // what you meant

No string interpolation#

String interpolation

There is no $"..." in Java, and there is no plan for one in Java 25. String templates were previewed in Java 21 and 22 and then withdrawn; they are not in Java 23, 24 or 25, and the design is being reconsidered. Do not write tutorials-era code that uses them.

The replacements:

// concatenation: fine, and the compiler optimises it well
String s = "Hello " + name + ", you are " + age;

// String.format / formatted; when you need alignment or precision
String s = "Hello %s, you are %d".formatted(name, age);
String s = String.format("%,.2f", amount);

// StringBuilder: in a loop, or building something large
var sb = new StringBuilder();
for (var x : xs) sb.append(x).append(',');

// text block + formatted, for anything multi-line
String q = """
    SELECT * FROM orders
    WHERE customer = '%s'
    """.formatted(customerId);
C#JavaNote
$"{a} and {b}"a + " and " + bor .formatted()
$"{x:F2}""%.2f".formatted(x)printf-style specifiers
string.FormatString.formatsame idea
StringBuilderStringBuildersame
string.Join(",", xs)String.join(",", xs)same
string.IsNullOrEmpty(s)s == null || s.isEmpty()or use isBlank()
string.IsNullOrWhiteSpace(s)s == null || s.isBlank()isBlank is Java 11
s.Contains(t)s.contains(t)same
s == ts.equals(t)== compares references and silently fails on runtime strings
Gotcha

== on strings compares references, not contents. It often appears to work because the JVM interns string literals, so "abc" == "abc" is true, while a string built at runtime compares false against an identical literal. This is the single most common beginner bug in Java, and unlike C# there is no operator overload rescuing you.

String a = "abc";
String b = new String("abc");
a == b            // false
a.equals(b)       // true
Objects.equals(a, b)   // true, and null-safe on both sides

Text blocks#

C# raw string literal
var json = """
    {
      "name": "ada",
      "age": 36
    }
    """;
Java 15+ text block
String json = """
    {
      "name": "ada",
      "age": 36
    }
    """;

The syntax is the same three quotes. Both languages strip incidental indentation based on the closing delimiter's position. Differences worth knowing:

BehaviourC# raw stringJava text block
Opening delimiter""" on its own line""" must be followed by a newline
Indentation strippingrelative to closing """relative to the least-indented line and closing """
Escapes processednoyes: \n, \t and \" still work
Interpolation$""" ... """none
Line continuationno\ at end of line joins lines
Trailing spacepreservedstripped, unless \s is used
Gotcha

A Java text block still processes escape sequences. That matters for regexes and Windows paths, \d in a text block is an invalid escape and will not compile, so you still write \\d. C#'s raw strings do no escape processing at all, which is why they are strictly better for regex. In Java, prefer a text block for SQL, JSON and HTML; for a regex the extra backslashes are unavoidable.

Regular expressions#

Pattern and Matcher replace Regex. The regex dialects are close, but the API is two objects rather than one, and the escaping is worse because Java has no verbatim string literal.

C#
var re = new Regex(@"(?<area>\d{3})-(?<num>\d{4})");

var m = re.Match(input);
if (m.Success)
{
    var area = m.Groups["area"].Value;
}

var all = re.Matches(input);
var cleaned = re.Replace(input, "$1");
Java 25
var re = Pattern.compile("(?<area>\\d{3})-(?<num>\\d{4})");

var m = re.matcher(input);
if (m.find()) {
    String area = m.group("area");
}

var all = m.results().toList();       // results() is Java 9
String cleaned = m.replaceAll("$1");
.NETJavaNote
new Regex(p)Pattern.compile(p)compile once, store it in a static final field
re.Match(s)p.matcher(s).find()matcher is stateful and NOT thread-safe
re.IsMatch(s)p.matcher(s).find()matches() requires the WHOLE string to match
re.Matches(s)p.matcher(s).results()Java 9; a Stream of MatchResult
m.Groups["name"]m.group("name")named groups work in both
m.Groups[1]m.group(1)group(0) is the whole match in both
re.Replace(s, r)m.replaceAll(r)$1 for a group in both
re.Split(s)p.split(s)or String.split, which compiles each call
RegexOptions.IgnoreCasePattern.CASE_INSENSITIVEsecond argument to compile
RegexOptions.Compilednot neededPattern is already compiled
@"verbatim"no equivalentevery backslash must be doubled
Gotcha

Two traps, both from the missing verbatim literal.

Every backslash is doubled. \d in a .NET verbatim string is "\\d" in Java. A text block does not help: it still processes escapes, so \d inside one is an invalid escape and will not compile. This is the one place where C# raw strings are unambiguously better, and there is no workaround.

matches() is not IsMatch. Matcher.matches() requires the entire input to match; find() searches for the first occurrence. Reaching for the familiar-looking name is a common and silent bug.

Note

Pattern is immutable and thread-safe, so compile it once into a private static final field. Matcher is neither, so create one per use. String.matches, String.split and String.replaceAll all recompile the pattern on every call, which is fine occasionally and expensive in a loop.

Concatenation performance#

Note

Do not reach for StringBuilder reflexively. Since Java 9, the compiler compiles a + b + c into an efficient invokedynamic call that allocates once. StringBuilder is still the right tool inside a loop, where the compiler cannot fuse the concatenations, but for a fixed number of pieces plain + is both clearer and faster.

Enums#

Part P2 · Modern Java · Chapter 14· 2 min read

Java enums are far more powerful than C#'s and completely different underneath. A C# enum is a named integer. A Java enum is a final class with a fixed set of instances; it can have fields, constructors, methods, and per-constant behaviour.

The cost: no arbitrary numeric values, no flags, and no cast from an int.

The shape of the difference#

C#
public enum Status
{
    New = 1,
    Active = 2,
    Closed = 4
}

var s = (Status) 2;
int n = (int) Status.Active;
Java 25
public enum Status {
    NEW, ACTIVE, CLOSED
}

Status s = Status.valueOf("ACTIVE");
int n = Status.ACTIVE.ordinal();   // 1, but see the gotcha
Gotcha

Never persist ordinal(). It is the declaration position, so inserting a constant silently renumbers everything after it, and every stored row now means something different. Persist name(), or better, an explicit code field you control. This is the enum equivalent of persisting a database identity column you do not own.

Enums with state and behaviour#

This is where Java enums leave C# behind. Each constant is a real object.

public enum Planet {
    MERCURY(3.303e+23, 2.4397e6),
    VENUS  (4.869e+24, 6.0518e6),
    EARTH  (5.976e+24, 6.37814e6);

    private final double mass;
    private final double radius;

    Planet(double mass, double radius) {   // constructors are implicitly private
        this.mass = mass;
        this.radius = radius;
    }

    public double surfaceGravity() {
        return 6.67300E-11 * mass / (radius * radius);
    }
}

double g = Planet.EARTH.surfaceGravity();

Constants can even override methods individually, giving you a closed set of strategies with no separate class per case:

public enum Operation {
    PLUS  { public int apply(int a, int b) { return a + b; } },
    MINUS { public int apply(int a, int b) { return a - b; } },
    TIMES { public int apply(int a, int b) { return a * b; } };

    public abstract int apply(int a, int b);
}

int r = Operation.TIMES.apply(6, 7);

Mapping table#

C#JavaNote
Enum.Parse<T>(s)Status.valueOf(s)throws IllegalArgumentException if unknown
Enum.TryParseno equivalentcatch, or build a Map lookup
Enum.GetValues<T>()Status.values()returns a fresh array each call
(int) ee.ordinal()the declaration position; never store it, it shifts
(Status) 2no equivalentno int-to-enum cast
e.ToString()e.name()name() is final and exact
[Flags]EnumSetno bitwise enums; EnumSet is a bit vector underneath
[Description] attributea field on the enumfar cleaner
Dictionary keyed by enumEnumMaparray-backed, very fast
[Flags] enums

Java has no flags enums and no bitwise enum arithmetic. The replacement is EnumSet, which is implemented as a bit vector internally and so is just as efficient, while being type-safe and readable:

C#
[Flags]
enum Perm { Read = 1, Write = 2, Exec = 4 }

var p = Perm.Read | Perm.Write;
if (p.HasFlag(Perm.Write)) { }
Java 25
enum Perm { READ, WRITE, EXEC }

var p = EnumSet.of(Perm.READ, Perm.WRITE);
if (p.contains(Perm.WRITE)) { }

Enums and switch#

An exhaustive switch over an enum needs no default, and omitting it is a feature; see Switch expressions.

String label = switch (status) {
    case NEW    -> "new";
    case ACTIVE -> "active";
    case CLOSED -> "closed";
};
Note

Inside a case label you write the bare constant, not Status.NEW. Qualifying it is a compile error in the classic switch, a small asymmetry that surprises everyone once.

The enum singleton#

Because the JVM guarantees a single instance per constant, including across serialisation and reflection, a single-constant enum is the most robust singleton in Java:

public enum Registry {
    INSTANCE;

    private final Map<String, Handler> handlers = new ConcurrentHashMap<>();

    public void register(String k, Handler h) { handlers.put(k, h); }
}

In practice, in an application using Spring you would make this a bean instead, but the idiom is worth recognising in library code.

Generics and type erasure#

Part P2 · Modern Java · Chapter 15· 3 min read

Generics look the same and behave differently in one decisive way: Java erases type arguments at runtime. List<String> and List<Integer> are the same class once compiled. C#'s generics are reified; the runtime knows the type argument.

Consequences: no typeof(T), no new T(), no is List<string>, no primitive type arguments, and variance is declared at the use site rather than the declaration site.

Declaration#

C#
public abstract class Message<T> where T : Header
{
    protected Message(T header) { Header = header; }
    public T Header { get; }
}
Java 25
public abstract class Message<T extends Header> {
    private final T header;

    protected Message(T header) { this.header = header; }

    public T header() { return header; }
}
C# constraintJava equivalentNote
where T : Base<T extends Base>classes and interfaces both use extends
where T : IFoo, IBar<T extends Foo & Bar>ampersand, class first if present
where T : classno equivalenteverything is a reference type anyway
where T : structno equivalentno value types
where T : new()no equivalentpass a Supplier<T>
where T : unmanagedno equivalentJava has no unmanaged or pointer types

What erasure takes away#

Gotcha

None of these compile in Java, and all of them are routine in C#:

class Box<T> {
    void bad() {
        T t = new T();                  // no, cannot instantiate T
        T[] a = new T[10];              // no, cannot create a generic array
        if (x instanceof List<String>)  // no, cannot test an erased type
        Class<T> c = T.class;           // no. T has no class literal
    }
    static int count;                   // shared across ALL Box<?>; not per T
}

The workaround for each is to pass the type in explicitly, which is why Java APIs are full of Class<T> parameters where C# would use typeof(T):

C#
T Read<T>(string json) =>
    JsonSerializer.Deserialize<T>(json);

var c = Read<Customer>(s);
Java 25
<T> T read(String json, Class<T> type) {
    return mapper.readValue(json, type);
}

var c = read(s, Customer.class);
Note

This is why getBean(MyService.class), readValue(s, Foo.class) and Mockito.mock(Foo.class) all take a class literal. Once you see erasure as the reason, the whole ecosystem's API style makes sense.

Variance is at the use site#

C# declares variance once, on the interface, with in and out. Java declares it at every point of use, with wildcards. Same power, opposite ergonomics.

C#
// declaration-site: IEnumerable<out T>
IEnumerable<object> objs = new List<string>();

// contravariance: IComparer<in T>
IComparer<string> c = new ObjectComparer();
Java 25
// use-site: the wildcard goes where the type is used
List<? extends Object> objs = new ArrayList<String>();

Comparator<? super String> c = new ObjectComparator();
WildcardMeansYou canMnemonic
List<String>exactly Stringread and write Stringinvariant
List<? extends Number>some unknown subtyperead as Number, cannot addproducer
List<? super Integer>some unknown supertypeadd Integer, read as Objectconsumer
List<?>unknownread as Object, cannot addany
Note

The mnemonic Java developers use is PECS. Producer Extends, Consumer Super. If the parameter produces values for you, use ? extends. If it consumes values you supply, use ? super. It is exactly C#'s out/in distinction, moved to the call site.

No primitive type arguments#

Gotcha

List<int> is not legal. Type arguments must be reference types, so primitives are boxed: List<Integer>. That has a real memory and speed cost; a List<Integer> of a million entries is a million heap objects plus an array of references, where C#'s List<int> is one contiguous block.

The workarounds: int[] for storage, and the specialised stream types IntStream, LongStream, DoubleStream for pipelines. Project Valhalla intends to fix this properly; it has not shipped in Java 25.

C#JavaNote
List<int>List<Integer> or int[]each element becomes a heap object
Dictionary<int,V>Map<Integer,V>keys are boxed
IEnumerable<int>IntStreamavoids boxing
Nullable<int> / int?Integernull is the absent case

Getting type information back#

Erasure removes the type argument from an instance, but it is retained in class metadata for fields, method signatures and superclass declarations. That is how libraries like Jackson and Spring recover generic types:

// the anonymous subclass captures List<Customer> in its superclass signature
var customers = mapper.readValue(json, new TypeReference<List<Customer>>() { });

// Spring's equivalent
var body = restClient.get().uri(u).retrieve()
        .body(new ParameterizedTypeReference<List<Customer>>() { });
Gotcha

This trick; an anonymous subclass whose only job is to record a type argument, looks bizarre until you know why it exists. If you see new TypeReference<...>() { } with the trailing braces, that is what is happening. Omit the braces and it will not compile.

Raw types#

LegacyRaw types

For backward compatibility with pre-generics Java, you can still write List with no type argument. Everything becomes Object and all type checking is off, with an unchecked warning. It exists solely for Java 1.4 compatibility.

List raw = new ArrayList();   // legal, warns, and will bite you
raw.add("a");
raw.add(1);
String s = (String) raw.get(1);   // ClassCastException at runtime

Treat any unchecked warning as an error. If you must suppress one, scope @SuppressWarnings("unchecked") to the narrowest possible declaration and comment why it is safe.

Annotations and attributes#

Part P2 · Modern Java · Chapter 16· 3 min read

Annotations are C# attributes with a different syntax and one large difference: Java has a standard compile-time annotation processing pipeline. That is why so much of the Java ecosystem generates code, Lombok, MapStruct, Micronaut, the JPA metamodel, where .NET reached for reflection until source generators arrived.

Syntax#

C#
[Serializable]
[Obsolete("Use NewApi")]
[Route("/orders/{id}")]
public class OrderController
{
    [HttpGet]
    public IActionResult Get([FromQuery] int page) { }
}
Java 25
@Deprecated(since = "2.0")
@RestController
@RequestMapping("/orders/{id}")
public class OrderController {

    @GetMapping
    public ResponseEntity<?> get(@RequestParam int page) { }
}
C#JavaNote
[Attr]@Attrno brackets
[Attr(1, Name = "x")]@Attr(value = 1, name = "x")named form is the same idea
[Attr] on its own line@Attr on its own linesame convention
AttributeUsage@Targetwhich declarations it may be attached to
n/a@Retentionhow long it survives; only RUNTIME is visible to reflection
[Obsolete]@Deprecatedplus @deprecated javadoc
[Conditional]no equivalentno way to strip calls at compile time
Multiple same attribute@RepeatableJava 8

Retention: the concept C# lacks#

Every Java annotation declares how long it survives. C# attributes are always in metadata and always reflectable; Java lets an annotation vanish after compilation.

RetentionSurvives toUsed for
SOURCEcompiler only@Override, @SuppressWarnings, Lombok
CLASSthe .class file, not reflectionbytecode tools; the default
RUNTIMEreflectionSpring, Jackson, JUnit, JPA
Gotcha

If you write your own annotation and forget @Retention(RetentionPolicy.RUNTIME), it silently disappears before your framework can see it. The default is CLASS, not RUNTIME, which is the opposite of what you want almost every time.

Declaring one#

C#
[AttributeUsage(AttributeTargets.Method)]
public class AuditedAttribute : Attribute
{
    public string Action { get; }
    public AuditedAttribute(string action) => Action = action;
}
Java 25
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
    String action();
    String actor() default "system";
}
Note

@interface, not class. Members are declared as methods with no body, and defaults use the default keyword. A member named value is special: @Audited("create") is shorthand for @Audited(value = "create"), which is why so many annotations name their primary member value.

Ones you will see constantly#

AnnotationMeaning
@Overrideasserts this overrides a supertype method; catches typos
@Deprecatedas [Obsolete]; pair with @deprecated in javadoc
@SuppressWarnings("unchecked")silences a specific compiler warning
@FunctionalInterfaceasserts exactly one abstract method
@SafeVarargsasserts a generic varargs method does not leak the array
@Nullable / @NonNullnullability; see Optional and JSpecify
@Entity, @ColumnJPA mapping
@Test, @ParameterizedTestJUnit
@Service, @Component, @BeanSpring
@JsonProperty, @JsonIgnoreJackson
Gotcha

Always write @Override. It is not required, but without it a method that was meant to override becomes a silent overload if you get the signature slightly wrong; a capitalisation, a boxed parameter, an extra argument. C#'s override keyword is mandatory and so this bug cannot exist there.

Annotation processing vs source generators#

Java's annotation processors run inside javac and can generate new source files, which are then compiled in the same run. It is a mature pipeline that predates C# source generators by well over a decade, and a lot of the ecosystem depends on it.

.NETJavaDoes
Source generatorsAnnotation processors (APT)generate code at compile time
Roslyn analyzersError Prone, annotation processorsreport errors at compile time
IL weaving (Fody)bytecode manipulation (ByteBuddy)rewrite after compilation
Reflection at startupreflection, or APTframeworks increasingly prefer APT
ToolGeneratesReplaces in .NET
Lombokgetters, setters, builders, equalsboilerplate, or a source generator
MapStructtype-to-type mappersAutoMapper, but at compile time
Micronaut / QuarkusDI wiring, no runtime reflectioncompile-time DI
JPA metamodeltyped criteria query classesEF Core's typed queries
Immutablesimmutable value classesrecords
Note

The MapStruct comparison is worth dwelling on. AutoMapper resolves mappings by reflection at runtime and fails at runtime when a property is missing. MapStruct generates a plain Java class at compile time, so a missing or mismatched field is a compile error and the generated mapper is as fast as hand-written code. If you liked AutoMapper's convenience but resented its debugging story, MapStruct is a straight upgrade.

Reading annotations at runtime#

C#
var attr = typeof(Order)
    .GetCustomAttribute<AuditedAttribute>();
if (attr is not null) Use(attr.Action);
Java 25
Audited a = Order.class.getAnnotation(Audited.class);
if (a != null) use(a.action());

This only works for RUNTIME retention. Spring, Jackson and JUnit all rely on it, which is also why Java frameworks have historically paid a startup cost scanning the classpath, and why Quarkus and Micronaut moved that work to compile time.

Check yourself: Modern Java
1You want string interpolation. What is the Java syntax?
There is not one. String templates were previewed in Java 21 and 22 and then withdrawn. Use concatenation, formatted(), or a text block.
2A sealed interface has four permitted records. Your switch handles all four. Do you need a default arm?
No, and you should not add one. Without it, adding a fifth subtype breaks the build until every switch handles it. C# cannot do this.
3You wrote a custom annotation and your framework cannot see it at runtime. Why?
The default retention is CLASS, not RUNTIME. Without @Retention(RetentionPolicy.RUNTIME) it vanishes before reflection can find it.
4What does @Override actually do, given it is only an annotation?
It makes the compiler check that the method really does override something. Without it, a slightly wrong signature silently becomes an overload instead.

Nullability: Optional and JSpecify#

Part P3 · Nullability · Chapter 17· 3 min read

Java has no string?. There is no compiler-enforced nullability, no ! operator, no ?. on arbitrary types, and no warning when you dereference something that might be null.

Two partial answers exist. Optional<T> Java 8 for return values that may be absent. JSpecify annotations plus a static analyser for everything else. Spring Framework 7 and Boot 4 adopted JSpecify across their whole API, which makes it the emerging standard.

The gap, stated plainly#

C#JavaNote
string? nameString nameno way to say it in the language
string name (non-null)String nameidentical declaration
name!.Lengthname.length()no assertion operator
name?.Lengthname == null ? null : name.length()no ?. operator; chain with Optional.map instead
a ?? ba != null ? a : bor Objects.requireNonNullElse(a, b)
a ??= bif (a == null) a = b;no compound form
Nullable reference types warningsJSpecify + NullAway or IntelliJopt-in, tool-dependent
ArgumentNullException.ThrowIfNull(x)Objects.requireNonNull(x)throws NullPointerException

Optional is for return values#

C#
Customer? Find(int id);

var c = Find(7);
var name = c?.Name ?? "unknown";
Java 25
Optional<Customer> find(int id);

String name = find(7)
        .map(Customer::name)
        .orElse("unknown");
Optional methodDoesC# analogy
Optional.of(x)wraps, throws if x is nulln/a
Optional.ofNullable(x)wraps, empty if nulln/a
Optional.empty()absentnull
.map(f)transform if present?.
.flatMap(f)transform returning Optional?. returning nullable
.filter(p)keep if it matchesn/a
.orElse(v)value or default??
.orElseGet(sup)value or lazily computed default?? with a factory
.orElseThrow()value or NoSuchElementException?? throw
.ifPresent(c)run if presentif (x is not null)
.isPresent() / .isEmpty()testis not null / is null
.stream()0 or 1 element streamJava 9
Gotcha

Optional is not a general-purpose nullable wrapper. The designers were explicit: it is for return types where absence is a normal outcome. Do not use it for:

  • Fields; it is not Serializable and adds an allocation per instance.
  • Method parameters; the caller now has to wrap, and can still pass null anyway. Use an overload.
  • Collections; an empty collection already means “nothing”; never return Optional<List<T>>.

And Optional itself can be null, which is the joke that writes itself. Never return a null Optional.

Gotcha

orElse evaluates its argument eagerly, even when the value is present. If the default is expensive or has side effects, use orElseGet:

find(id).orElse(expensiveDefault());     // always calls expensiveDefault()
find(id).orElseGet(() -> expensiveDefault());  // only when absent

JSpecify: the annotation standard#

For parameters, fields and everything Optional should not touch, the answer is annotations plus a checker. Java has had a dozen competing @Nullable annotations, javax.annotation, JetBrains, Spring's own, Checker Framework, Android's. JSpecify is the industry effort to converge on one, and Spring Framework 7 migrated its entire API to it.

<dependency>
  <groupId>org.jspecify</groupId>
  <artifactId>jspecify</artifactId>
  <version>1.0.0</version>          <!-- stable since 2024 -->
</dependency>
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

@NullMarked                       // everything in this package is non-null by default
package com.acme.billing;

// ...

public Invoice load(String id) { ... }              // id and result are non-null

public @Nullable Invoice find(String id) { ... }    // result may be null
Note

@NullMarked on a package or module flips the default to non-null, so you only annotate the exceptions. That is exactly how C#'s <Nullable>enable</Nullable> works: opt in per assembly, then mark the nullable ones. The difference is that nothing in the JDK enforces it; you need a tool.

CheckerRunsStrength
IntelliJ inspectionsin the IDEgood, but IDE-only
NullAwaybuild, via Error Pronefast, practical, catches most real bugs
Checker Frameworkbuildrigorous and sound; slower, steeper
Gotcha

Without one of those tools wired into your build, JSpecify annotations are documentation. They will not fail a build and will not warn at compile time. If your team decides to adopt them, adopt a checker in CI at the same time or the annotations will drift out of truth within months.

Defensive checks#

C#
public Order(Customer customer)
{
    ArgumentNullException.ThrowIfNull(customer);
    _customer = customer;
}
Java 25
public Order(Customer customer) {
    this.customer =
        Objects.requireNonNull(customer, "customer");
}

Objects.requireNonNull returns its argument, so it composes into a field assignment. Passing the parameter name as the second argument is worth the keystrokes; it lands in the exception message.

Note

Java 14 added helpful NullPointerException messages behind a flag, and they have been on by default since Java 15. The JVM tells you which expression was null, e.g. Cannot invoke "Customer.name()" because "order.customer" is null. This removed most of the pain of debugging an NPE on a chained call, and it is a genuine quality-of-life improvement over older Java that people who left the ecosystem often do not know about.

Check yourself: Nullability
1What is the Java equivalent of string??
There is none in the language. Optional<T> is for return values only; for everything else you use JSpecify annotations plus a checker such as NullAway.
2Is Optional<List<T>> ever the right return type?
No. An empty collection already means nothing was found. Optional is also wrong for fields and for parameters.
3find(id).orElse(expensiveDefault()). What is wrong with it?
orElse evaluates its argument eagerly, even when the value is present. Use orElseGet with a supplier.
4Without a build-time checker, what do JSpecify annotations give you?
Documentation. They will not fail a build or warn at compile time on their own, so adopt a checker at the same time or they drift out of truth.

Collections#

Part P4 · Collections and streams · Chapter 18· 2 min read

The same data structures with different names. The two things to internalise: IEnumerable<T> maps to Iterable<E> (not Iterator), and Java separates the interface from the implementation far more strictly; you declare List and instantiate ArrayList, always.

The interface hierarchy#

Iterable<E>                    ← IEnumerable<T>
  └─ Collection<E>
       ├─ List<E>              ← IList<T>
       │    ├─ ArrayList<E>    ← List<T>
       │    └─ LinkedList<E>   ← LinkedList<T>
       ├─ Set<E>               ← ISet<T>
       │    ├─ HashSet<E>      ← HashSet<T>
       │    ├─ LinkedHashSet<E>
       │    └─ TreeSet<E>      ← SortedSet<T>
       └─ Queue<E> / Deque<E>  ← Queue<T> / Stack<T>

Map<K,V>                       ← IDictionary<TKey,TValue>   (not a Collection)
  ├─ HashMap<K,V>              ← Dictionary<TKey,TValue>
  ├─ LinkedHashMap<K,V>          ← OrderedDictionary<K,V> (.NET 9+)
  └─ TreeMap<K,V>              ← SortedDictionary<TKey,TValue>
Gotcha

IEnumerable<T> is Iterable<E>, not Iterator<E>. The parallel is exact: Iterable can be iterated repeatedly and is what for (var x : xs) accepts; Iterator is the single-use cursor, matching IEnumerator<T>. Getting these the wrong way round leads to APIs that can only be consumed once.

The full mapping#

.NETJavaNote
IEnumerable<T>Iterable<E>can be iterated more than once
IEnumerator<T>Iterator<E>single use; supports remove()
ICollection<T>Collection<E>size, add, remove
IList<T>List<E>indexed
List<T>ArrayList<E>array-backed
LinkedList<T>LinkedList<E>almost always slower than ArrayList; avoid
T[]E[]fixed size, covariant in both
ISet<T>Set<E>
HashSet<T>HashSet<E>unordered
SortedSet<T>TreeSet<E>sorted by comparator
n/aLinkedHashSet<E>insertion-ordered set
IDictionary<K,V>Map<K,V>Map is NOT a Collection
Dictionary<K,V>HashMap<K,V>unordered
SortedDictionary<K,V>TreeMap<K,V>sorted by key
OrderedDictionary<K,V>LinkedHashMap<K,V>insertion-ordered; .NET 9+; LinkedHashMap is also LRU-capable
Queue<T>ArrayDeque<E>as a queue
Stack<T>ArrayDeque<E>as a stack; not java.util.Stack
ConcurrentDictionary<K,V>ConcurrentHashMap<K,V>
BlockingCollection<T>BlockingQueue<E>
ImmutableList<T>List.of(...)Java 9
ReadOnlyCollection<T>Collections.unmodifiableLista read-only window; changes to the source show through
KeyValuePair<K,V>Map.Entry<K,V>
Comparer<T>Comparator<E>
IComparable<T>Comparable<E>

Creating collections#

C#
// collection expression
List<string> a = ["x", "y"];
var b = new List<string> { "x", "y" };
var c = new Dictionary<string,int> { ["x"] = 1 };
IReadOnlyList<string> d = ["x"];
Java 25
var a = new ArrayList<>(List.of("x", "y"));   // mutable
var b = List.of("x", "y");                    // immutable
var c = Map.of("x", 1);                       // immutable
List<String> d = List.of("x");
Gotcha

List.of(...) and Map.of(...) Java 9 return immutable collections that throw UnsupportedOperationException on any mutation, and reject null elements. They are not a drop-in for new ArrayList<>(). If you need to add later, wrap: new ArrayList<>(List.of(...)).

Map.of also caps out at 10 pairs; beyond that use Map.ofEntries(entry(k, v), ...).

Declare the interface, instantiate the class#

Note

Java convention is stricter than C#'s here and it is worth adopting immediately:

List<String> names = new ArrayList<>();       // yes
Map<String, Integer> counts = new HashMap<>(); // yes

ArrayList<String> names = new ArrayList<>();   // avoid, over-specified

The diamond <> infers the type argument from the left-hand side, so it is not repetition. Fields, parameters and return types should all be declared with the interface.

Sequenced collections#

Java 21 added sequenced collections Java 21, filling a long-standing gap: there was no uniform way to ask any ordered collection for its first or last element.

var list = new ArrayList<>(List.of(1, 2, 3));

list.getFirst();      // 1, was list.get(0)
list.getLast();       // 3, was list.get(list.size() - 1)
list.addFirst(0);
list.reversed();      // a reversed *view*, not a copy

var map = new LinkedHashMap<String,Integer>();
map.firstEntry();
map.lastEntry();
C#Java 21+Before Java 21
list[0]list.getFirst()list.get(0)
list[^1]list.getLast()list.get(list.size() - 1)
list.Reverse()list.reversed()Collections.reverse (mutates!)
list[1..3]list.subList(1, 3)same; it is a view
Gotcha

subList returns a view, not a copy. Mutating the sublist mutates the backing list, and structurally modifying the backing list invalidates the sublist with a ConcurrentModificationException. C#'s range operator copies. If you want a copy: new ArrayList<>(list.subList(1, 3)).

Legacy collections#

LegacyVector, Hashtable, Stack, Enumeration

Java 1.0 collections, retrofitted onto the modern interfaces but synchronised on every method, so you pay for locking you almost never need, and they still are not safe for compound operations. You will meet them in old code.

LegacyUse instead
VectorArrayList, or CopyOnWriteArrayList if concurrent
HashtableHashMap, or ConcurrentHashMap if concurrent
java.util.StackArrayDeque
EnumerationIterator
Collections.synchronizedListConcurrentHashMap-backed types, or a proper concurrent collection

Modifying while iterating#

Gotcha

Both languages forbid it, but Java's escape hatch differs. C# gives you no way out except iterating a copy. Java's Iterator supports remove(), and there is removeIf:

// throws ConcurrentModificationException
for (var s : list) if (s.isBlank()) list.remove(s);

// correct, removeIf is the idiomatic answer
list.removeIf(String::isBlank);

// correct, explicit iterator
var it = list.iterator();
while (it.hasNext()) if (it.next().isBlank()) it.remove();

Equality, hashing and comparison#

Part P4 · Collections and streams · Chapter 19· 2 min read

== on any reference type compares references, always. There is no operator overloading to rescue you, so == on String, Integer, BigDecimal or LocalDate is a bug waiting for a large enough input.

Use .equals(), or Objects.equals(a, b) when either side may be null. Override equals and hashCode together, or use a record and get both for free.

== versus equals#

C#
// == is overloaded for string, and
// record/struct equality is value-based
var a = "abc";
var b = ReadFromFile();
if (a == b) { }        // value comparison, works
Java 25
String a = "abc";
String b = readFromFile();
if (a == b) { }              // reference comparison, bug
if (a.equals(b)) { }         // value comparison
if (Objects.equals(a, b)) { } // null-safe on both sides
CompareC#Java
Value equalitya == b, a.Equals(b)a.equals(b)
Value equality, null-safea == bObjects.equals(a, b)
Reference identityReferenceEquals(a, b)a == b
Primitivesa == ba == b
Orderinga.CompareTo(b)a.compareTo(b)
Custom orderingIComparer<T>Comparator<T>
Gotcha

The boxed-integer cache. The JVM caches Integer objects for values from −128 to 127, so == appears to work on small numbers and silently stops working above 127. This is the single most notorious Java gotcha:

Integer a = 127, b = 127;
a == b        // true: same cached instance

Integer c = 128, d = 128;
c == d        // false; two distinct objects

c.equals(d)   // true, always use this

The same trap exists for Long, Short, Byte and Character. Any test that passes with small fixtures and fails in production is worth checking for this.

The equals and hashCode contract#

Java's collections rely on this being honoured, and nothing enforces it. If you override one, override the other.

RuleMeaning
Reflexivea.equals(a) is true
Symmetrica.equals(b) implies b.equals(a)
Transitivea=b and b=c implies a=c
Consistentrepeated calls give the same result
Nulla.equals(null) is false, never throws
hashCode agreementa.equals(b) implies a.hashCode() == b.hashCode()
Gotcha

The last rule is the one that breaks systems. Two objects that are equals but hash differently will both be stored in a HashSet, and a HashMap.get with an equal key will return null. The failure is silent, data dependent, and often only shows under load when the map resizes.

Writing them#

C#
public sealed class Money : IEquatable<Money>
{
    public decimal Amount { get; init; }
    public string Currency { get; init; }

    public bool Equals(Money? o) =>
        o is not null && Amount == o.Amount
                      && Currency == o.Currency;

    public override bool Equals(object? o) =>
        Equals(o as Money);
    public override int GetHashCode() =>
        HashCode.Combine(Amount, Currency);
}
Java 25
public final class Money {
    private final BigDecimal amount;
    private final String currency;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Money m)) return false;
        return amount.compareTo(m.amount) == 0
            && currency.equals(m.currency);
    }

    @Override
    public int hashCode() {
        return Objects.hash(amount, currency);
    }
}
Note

Objects.hash(...) is HashCode.Combine(...). It allocates a varargs array, so for a very hot type write the classic 31 * result + field.hashCode() loop instead, but measure first.

Or skip all of it. A record generates a correct equals, hashCode and toString from its components:

public record Money(BigDecimal amount, String currency) { }
Gotcha

Note the compareTo in the hand-written example above. BigDecimal.equals compares scale as well as value, so new BigDecimal("1.0").equals(new BigDecimal("1.00")) is false, while compareTo returns 0. A record over a BigDecimal inherits the scale-sensitive behaviour, which is usually wrong for money. Normalise the scale in the compact constructor.

Ordering#

C#
people.Sort((a, b) => a.Age.CompareTo(b.Age));

var sorted = people
    .OrderBy(p => p.LastName)
    .ThenByDescending(p => p.Age)
    .ToList();
Java 25
people.sort(Comparator.comparingInt(Person::age));

var sorted = people.stream()
    .sorted(Comparator.comparing(Person::lastName)
            .thenComparing(Person::age, reverseOrder()))
    .toList();
LINQJava Comparator
OrderBy(f)Comparator.comparing(f)
OrderByDescending(f)Comparator.comparing(f).reversed()
ThenBy(f).thenComparing(f)
ThenByDescending(f).thenComparing(f, Comparator.reverseOrder())
key is intComparator.comparingInt(f), avoids boxing
nullsComparator.nullsFirst(cmp) / nullsLast(cmp)
Gotcha

Comparable.compareTo should be consistent with equals; that is, a.compareTo(b) == 0 should agree with a.equals(b). When it does not, TreeSet and TreeMap behave differently from HashSet and HashMap, because the sorted collections use compareTo and the hashed ones use equals. BigDecimal is the standard example of the inconsistency, and the standard source of confusion.

Streams vs LINQ#

Part P4 · Collections and streams · Chapter 20· 3 min read

Streams are LINQ-to-Objects with different names and one structural difference: a stream is single-use. LINQ query objects re-enumerate their source every time; a consumed stream throws IllegalStateException.

There is no LINQ-to-SQL equivalent, because there are no expression trees. Java lambdas compile to code, not to inspectable syntax, so nothing can translate them to SQL. That is why the Java ORM story is JPQL strings, a criteria API, or jOOQ's generated DSL.

The shape#

C#
var names = customers
    .Where(c => c.Name.EndsWith("Doe"))
    .Select(c => c.Name)
    .OrderBy(n => n)
    .ToList();
Java 25
var names = customers.stream()
    .filter(c -> c.name().endsWith("Doe"))
    .map(Customer::name)
    .sorted()
    .toList();

Operator translation#

LINQStreamNote
Wherefilter
Selectmap
SelectManyflatMap
OrderBysorted(comparator)
Take(n)limit(n)
Skip(n)skip(n)
TakeWhiletakeWhileJava 9
SkipWhiledropWhileJava 9
Distinctdistinct()compares with equals and hashCode, so implement both
Reverseno direct equalsort with a reversed comparator
ConcatStream.concat(a, b)static, only two at a time
Any()findAny().isPresent()or !stream.iterator().hasNext()
Any(p)anyMatch(p)
All(p)allMatch(p)
Count()count()returns long
First()findFirst().orElseThrow()
FirstOrDefault()findFirst().orElse(null)
Single()no direct equalreduce, or collect and check size
Sum()mapToInt(f).sum()
Average()mapToInt(f).average()returns OptionalDouble
Min / Maxmin(cmp) / max(cmp)return Optional
Aggregatereduce
ToList()toList()Java 16; immutable
ToArray()toArray(String[]::new)
ToDictionarycollect(toMap(k, v))
GroupBycollect(groupingBy(f))
Zipno equivalentuse IntStream.range over indices
Chunk(n)Stream.gather(Gatherers.windowFixed(n))Java 24
DefaultIfEmptyno equivalentcheck isEmpty first
AsParallel()parallelStream()shares one JVM-wide pool; riskier than it looks

Single use#

Gotcha

A stream is a pipeline over a source, consumed exactly once. This is the difference that actually bites when translating LINQ.

var s = customers.stream().filter(c -> c.active());

var a = s.toList();
var b = s.toList();   // IllegalStateException: stream has already been operated upon

In C# the equivalent IEnumerable would simply re-run the query. In Java, either re-create the stream, or materialise once into a List and reuse that.

Collectors#

collect is the general terminal operation, and Collectors is where the LINQ conveniences live.

C#
var byCity = customers
    .GroupBy(c => c.City)
    .ToDictionary(g => g.Key, g => g.Count());

var csv = string.Join(", ", names);
Java 25
var byCity = customers.stream()
    .collect(groupingBy(Customer::city, counting()));

var csv = names.stream().collect(joining(", "));
// or simply: String.join(", ", names)
CollectorProduces
toList() / toSet()a List or Set
toMap(k, v)a Map: throws on duplicate keys
toMap(k, v, merge)a Map with a merge function for duplicates
groupingBy(f)Map<K, List<T>>
groupingBy(f, downstream)Map<K, R>, e.g. counting(), summingInt()
partitioningBy(p)Map<Boolean, List<T>>
joining(sep, prefix, suffix)a String
counting(), summingInt(f), averagingDouble(f)aggregates
teeing(c1, c2, merge)two collectors combined. Java 12
Gotcha

Collectors.toMap throws IllegalStateException on a duplicate key, where LINQ's ToDictionary throws too, but Java also throws NullPointerException if a value is null, which ToDictionary tolerates. Supply the three-argument form with a merge function whenever duplicates are possible.

Gatherers#

Streams long lacked a way to write your own intermediate operation. Stream gatherers Java 24 fixed that, and shipped several built-ins that cover LINQ gaps:

// fixed-size batching. LINQ's Chunk(n)
var batches = ids.stream()
        .gather(Gatherers.windowFixed(100))
        .toList();                       // List<List<String>>

// sliding window
prices.stream().gather(Gatherers.windowSliding(3))

// stateful running fold
nums.stream().gather(Gatherers.scan(() -> 0, Integer::sum))
Note

Before Java 24 there was no clean way to batch a stream, and every codebase had its own partitioning helper or used Guava's Lists.partition. If you are on Java 21, that is still the situation, gatherers were preview in 22 and 23.

Primitive streams#

Because generics cannot hold primitives, Java provides IntStream, LongStream and DoubleStream to avoid boxing every element.

int total = orders.stream()
        .mapToInt(Order::quantity)   // Stream<Order> -> IntStream
        .sum();

IntStream.range(0, 10).forEach(System.out::println);

// boxing back when you need a collection
List<Integer> xs = IntStream.range(0, 10).boxed().toList();
NeedMethod
Stream to IntStreammapToInt / mapToLong / mapToDouble
IntStream to Streamboxed(), or mapToObj(f)
A rangeIntStream.range(a, b) or rangeClosed(a, b)
StatisticssummaryStatistics(): count, sum, min, max, average

Parallel streams#

Gotcha

parallelStream() looks like AsParallel() and is more dangerous. It runs on the shared common ForkJoinPool, so one slow parallel stream can starve every other one in the JVM, including ones inside libraries. It also only pays off for large, CPU-bound, easily-split sources.

Rules of thumb: do not use it for I/O (use virtual threads, see Threads are cheap now), do not use it on LinkedList or Iterator-backed sources, and measure before and after. For anything with a deadline, submit to your own pool instead.

No expression trees#

Expression trees / IQueryable

C# can inspect a lambda as data and translate it; that is how EF Core turns Where(c => c.Age > 18) into SQL. Java lambdas compile straight to bytecode and cannot be inspected, so no Java ORM can do this. The alternatives:

ApproachLooks likeType-safe
JPQL string"select c from Customer c where c.age > :age"no
Criteria APIcb.greaterThan(root.get("age"), 18)partly
JPA metamodelcb.greaterThan(root.get(Customer_.age), 18)yes, generated at compile time
jOOQdsl.selectFrom(CUSTOMER).where(CUSTOMER.AGE.gt(18))yes, generated from the schema

jOOQ is the closest in spirit to LINQ-to-SQL: it generates a typed DSL from your actual database schema, so a renamed column breaks the build. See Data access.

yield return

Java has no generators. To produce a lazy sequence, use Stream.iterate or Stream.generate with a limit, or implement Iterator by hand.

Stream.iterate(1, n -> n * 2).limit(10).toList();     // 1,2,4,...512
Stream.iterate(seed, this::hasNext, this::next);       // Java 9, 3-arg form

Files and I/O#

Part P4 · Collections and streams · Chapter 21· 3 min read

java.nio.file.Files is System.IO.File, and Path is the path type. Almost everything you want is a static method on Files.

Two things to remember: anything returning a Stream must be closed, so it goes in a try-with-resources, and always pass a charset, because the platform default has burned every language that has one.

The everyday operations#

C#
string text = File.ReadAllText(path);
string[] lines = File.ReadAllLines(path);
File.WriteAllText(path, text);
File.AppendAllText(path, more);

bool there = File.Exists(path);
File.Delete(path);
File.Copy(src, dst, overwrite: true);
Directory.CreateDirectory(dir);
Java 25
Path p = Path.of("data", "orders.csv");

String text = Files.readString(p);
List<String> lines = Files.readAllLines(p);
Files.writeString(p, text);
Files.writeString(p, more, StandardOpenOption.APPEND);

boolean there = Files.exists(p);
Files.deleteIfExists(p);
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
Files.createDirectories(dir);
System.IOjava.nio.fileNote
File.ReadAllTextFiles.readString(p)Java 11; UTF-8 by default
File.ReadAllLinesFiles.readAllLines(p)reads it all into memory
File.ReadLines (lazy)Files.lines(p)returns a Stream, so it must be closed
File.WriteAllTextFiles.writeString(p, s)Java 11
File.AppendAllTextFiles.writeString(p, s, APPEND)StandardOpenOption
File.ExistsFiles.exists(p)
File.DeleteFiles.delete(p) / deleteIfExists(p)delete throws if absent
File.Copy / MoveFiles.copy / Files.movepass REPLACE_EXISTING to overwrite
Directory.CreateDirectoryFiles.createDirectories(p)creates parents too
Directory.GetFilesFiles.list(dir)a Stream, and not recursive
Directory.EnumerateFiles(recursive)Files.walk(dir)a Stream; use try-with-resources
Path.Combine("a","b")Path.of("a","b")or dir.resolve("b")
Path.GetFileNamep.getFileName()returns a Path, not a String
Path.GetExtensionno equivalentparse the filename yourself
FileStreamFiles.newInputStream / newOutputStream
StreamReaderFiles.newBufferedReader(p)
MemoryStreamByteArrayInputStream / ByteArrayOutputStream
Path.GetTempFileNameFiles.createTempFile(prefix, suffix)
FileSystemWatcherWatchServicemuch lower level than the .NET one
Gotcha

Files.lines, Files.walk and Files.list return a Stream that holds an open file handle. Unlike File.ReadLines in .NET, the stream will not release it for you, and on Windows the file stays locked. They must go in a try-with-resources:

// leaks a file handle
long n = Files.lines(p).filter(l -> !l.isBlank()).count();

// correct
try (var lines = Files.lines(p)) {
    long n = lines.filter(l -> !l.isBlank()).count();
}

This is the single most common Java file-handling bug, and it usually shows up as “too many open files” under load rather than as an obvious failure.

Charsets, and why to always pass one#

Java's older I/O APIs use the platform default charset when you do not specify one, which means the same code produces different bytes on a developer's Mac and a Linux container. This is the classic source of mojibake in Java systems.

// depends on the machine; avoid
new String(bytes);
new FileReader(file);
new PrintWriter(file);

// explicit; always do this
new String(bytes, StandardCharsets.UTF_8);
Files.newBufferedReader(p, StandardCharsets.UTF_8);
Files.readString(p);          // UTF-8 by contract, safe
Note

Java 18 changed the default charset for most APIs to UTF-8, which removes a great deal of this hazard on modern JDKs. On Java 17 and earlier it is still real, and some APIs, notably System.out, still follow the console encoding. Passing the charset explicitly costs nothing and works on every version.

Streams, readers and buffering#

Java splits I/O into byte streams and character streams, which .NET mostly hides behind one Stream plus a StreamReader.

KindJava typeFor
Bytes inInputStreambinary reading
Bytes outOutputStreambinary writing
Characters inReadertext reading
Characters outWritertext writing
BufferingBufferedInputStream / BufferedReaderwrap the above; always worth it
BridgingInputStreamReader(in, charset)bytes to characters
try (var in = Files.newInputStream(p);
     var reader = new BufferedReader(new InputStreamReader(in, UTF_8))) {
    String first = reader.readLine();
}

// copy a stream, the Java equivalent of CopyTo
try (var in = Files.newInputStream(src); var out = Files.newOutputStream(dst)) {
    in.transferTo(out);          // Java 9
}
Gotcha

Unbuffered reading one byte or character at a time is dramatically slower in Java than the equivalent .NET code, because .NET's FileStream buffers by default and Java's does not. Wrap in a Buffered* unless you have a reason not to.

Walking a directory tree#

// every .java file under src, as a list
try (var paths = Files.walk(Path.of("src"))) {
    List<Path> javaFiles = paths
            .filter(Files::isRegularFile)
            .filter(p -> p.toString().endsWith(".java"))
            .toList();
}

// bounded depth, and following symlinks explicitly
try (var paths = Files.walk(dir, 2, FileVisitOption.FOLLOW_LINKS)) { ... }

// pattern matching without walking manually
try (var found = Files.newDirectoryStream(dir, "*.{csv,tsv}")) {
    for (Path p : found) { ... }
}
.NETJava
Directory.EnumerateFiles(dir, "*.csv")Files.newDirectoryStream(dir, "*.csv")
Directory.EnumerateFiles(dir, "*", AllDirectories)Files.walk(dir)
Directory.EnumerateDirectoriesFiles.list(dir).filter(Files::isDirectory)
new DirectoryInfo(d).GetFiles()Files.list(d)

Reading a file from inside the JAR#

Anything under src/main/resources is packaged into the JAR, read through the classpath, and is not a file on disk at runtime. Trying to open it with Files.readString(Path.of("config.json")) works in the IDE and fails after packaging, which is a confusing and very common first deployment failure.

C#
// embedded resource
var asm = Assembly.GetExecutingAssembly();
using var s = asm.GetManifestResourceStream(
    "MyApp.config.json");
Java 25
try (var in = getClass()
        .getResourceAsStream("/config.json")) {
    String json = new String(
        in.readAllBytes(), UTF_8);
}
Note

In Spring the tidier form is ClassPathResource, or injecting @Value("classpath:config.json") Resource r. A leading slash on getResourceAsStream means “from the classpath root”; without it the lookup is relative to the class's own package.

Check yourself: Collections and streams
1What does IEnumerable<T> map to?
Iterable<E>. Iterator<E> is the single-use cursor, matching IEnumerator<T>. Getting these the wrong way round gives you APIs that can only be consumed once.
2You call .toList() on a stream, then call it again on the same stream. What happens?
IllegalStateException. A stream is single use, where a LINQ query re-enumerates its source. Materialise once and reuse the list.
3Files.lines(p).filter(...).count(). What is the bug?
The stream holds an open file handle and nothing closes it. It must go in a try-with-resources. This shows up as \u201ctoo many open files\u201d under load.
4list.add(...) on the result of List.of(1, 2). What happens?
UnsupportedOperationException. List.of is immutable and rejects nulls. Wrap it in new ArrayList<>(...) if you need to mutate.

Threads are cheap now#

Part P5 · Concurrency · Chapter 22· 5 min read

Stop looking for await. There isn't one, and you don't need one. C# made I/O scalable by making functions asynchronous. Java made I/O scalable by making threads almost free. Since Java 21 you write ordinary, top-to-bottom blocking code, run it on a virtual thread, and the runtime does the unmounting your await keyword used to do explicitly.

One consequence dominates everything else in this chapter: there is no function colouring in Java. No async infecting every caller, no Task<T> in your signatures, no sync-over-async deadlocks, no ConfigureAwait.

The colour problem, and how Java sidestepped it#

In C#, the moment one method needs to await, its signature changes, and so does every caller's, all the way up. That is function colouring. Java never introduced the colour, so the same method works in both worlds.

C# 14
// every caller of this must also become async
async Task<Report> BuildAsync(int id)
{
    Customer c = await _api.GetCustomerAsync(id);
    Orders   o = await _api.GetOrdersAsync(id);
    return Merge(c, o);
}

// and calling it from sync code is a trap
var r = BuildAsync(7).Result;   // deadlock risk
Java 21+
// no colour. Callers are unaffected.
Report build(int id) {
    Customer c = api.getCustomer(id);   // blocks
    Orders   o = api.getOrders(id);     // blocks
    return merge(c, o);
}

// run it on a virtual thread and it scales anyway
Thread.startVirtualThread(() -> build(7));

The Java version blocks twice. On a platform thread that would be wasteful. On a virtual thread it costs almost nothing: when getCustomer blocks on I/O, the JVM unmounts the virtual thread from its carrier and parks the continuation on the heap. The OS thread goes off to run something else. That is precisely what await does in .NET: except the compiler isn't rewriting your method into a state machine, and your signature never changed.

async / await

Java has no async or await keywords and is not getting them. The equivalent capability is where you run the code, not how you write it. If you find yourself hunting for the Java await, the answer is always “put it on a virtual thread and write it straight”.

Creating and running them#

Three ways, in rough order of how often you'll want them.

// 1. An executor; the one you'll use in real code.
//    Not a pool: it creates a fresh virtual thread per task.
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int id : ids) {
        exec.submit(() -> handle(id));
    }
}   // close() waits for every task to finish

// 2. Fire one off.
Thread t = Thread.startVirtualThread(() -> handle(7));
t.join();

// 3. Build one without starting it; when you need a name or an
//    uncaught-exception handler.
Thread t2 = Thread.ofVirtual()
                  .name("import-", 0)
                  .unstarted(() -> handle(7));
t2.start();
.NETJava 21+Notes
Task.Run(() => f())exec.submit(() -> f())with a virtual-thread executor
await SomeIoAsync()someIo()just call it; blocking is fine
Task.WhenAll(a, b)StructuredTaskScopefork subtasks in a scope; still preview in Java 25
Task.Delay(d)Thread.sleep(d)cheap on a virtual thread
CancellationTokenThread.interrupt()cooperative in both
IAsyncEnumerable<T>no direct equalstream from a BlockingQueue
AsyncLocal<T>ScopedValuefinal in Java 25
SemaphoreSlimSemaphorestill needed to limit concurrency
ThreadPool.QueueUserWorkItemExecutorService.submitplatform pool for CPU work
Note

ExecutorService implements AutoCloseable since Java 19, which is why try (var exec = ...) works above. That block does not exit until every submitted task has completed, which is a tidy structural join you get for free.

What a virtual thread actually isdeep dive

A virtual thread is a Thread whose stack lives on the Java heap as a continuation rather than in a fixed OS-allocated stack. Two numbers explain the whole design:

  • A platform thread reserves around 1 MB of stack up front. Tens of thousands of them is already painful.
  • A virtual thread starts at a few hundred bytes and grows on demand. Millions is routine.

Virtual threads run on a small pool of carrier platform threads, a dedicated ForkJoinPool sized by default to the number of available processors. When a virtual thread hits a blocking operation the JDK has instrumented (socket reads, file I/O, Thread.sleep, most locks), the runtime copies its stack out to the heap, releases the carrier, and rejoins it when the operation completes.

You can size the scheduler with -Djdk.virtualThreadScheduler.parallelism=N, but the default is nearly always right. Unlike a .NET thread-pool starvation incident, there is no injection delay to tune; the carrier pool only ever runs work that is genuinely on-CPU.

When virtual threads are the wrong tool#

Gotcha

Virtual threads do nothing for CPU-bound work. They make waiting cheap, not computing. A million virtual threads doing matrix multiplication will run exactly as fast as the number of carrier threads allows, plus scheduling overhead.

For CPU-bound work, keep a bounded platform-thread pool sized near your core count, the same instinct you already have from Parallel.For.

Gotcha

Never pool virtual threads. Pooling exists to amortise the cost of creating a thread. Virtual threads are so cheap to create that pooling them adds contention and reintroduces the queueing the design removes. newVirtualThreadPerTaskExecutor() allocates a new one per task on purpose.

If you need to limit concurrency, say, to protect a database with 20 connections, that is a Semaphore, not a pool. Limit the resource, not the thread count.

Gotcha

ThreadLocal still works, but scales badly. One ThreadLocal value per thread was fine at 200 threads. At 500,000 it is a memory problem. Use ScopedValue Java 25 instead; it is the closer analogue to AsyncLocal<T> anyway, being immutable and bounded to a well-defined scope rather than living until the thread dies.

Pinning: the one performance trapdeep dive

A virtual thread that cannot be unmounted while blocked is pinned; it holds its carrier hostage. On Java 21 to 23 the two causes were blocking inside a synchronized block, and blocking inside a native frame (JNI).

JEP 491 fixed the synchronized case in Java 24 Java 24: virtual threads now unmount correctly while blocked inside a synchronized block, and the pre-24 advice to rewrite every one of them as a ReentrantLock no longer applies. On Java 24 and later, only native frames pin.

If you are on Java 21, the old workaround still matters:

// pinned the carrier on Java 21-23 while waiting on I/O
synchronized (lock) {
    var row = db.query(sql);
}

// unmounts correctly
lock.lock();
try {
    var row = db.query(sql);
} finally {
    lock.unlock();
}

Diagnose with -Djdk.tracePinnedThreads=full, which prints a stack trace whenever a virtual thread parks while pinned. On Java 24+ this should be quiet unless you are calling into native code.

Blocking becomes the normal style again#

Once blocking is cheap, patterns that felt expensive in .NET become ordinary. Wrapping a synchronous call in a retry is the clearest example, and it is one of the places Spring Boot 4 changed materially, moving retry support out of an add-on and into the framework core.

// Boot 3.5: needs the Spring Retry dependency,
// or Resilience4j, as an explicit add-on.
@EnableRetry
@Configuration
class RetryConfig { }

@Service
class RatesClient {

    @Retryable(maxAttempts = 3)
    public Rate fetch(String pair) {
        return restClient.get()          // blocks, fine on a
                .uri("/rates/{p}", pair) // virtual thread
                .retrieve()
                .body(Rate.class);
    }
}
// Boot 4.0, @Retryable lives in Spring Framework core
// (org.springframework.core.retry). No extra dependency.
@EnableResilientMethods
@Configuration
class RetryConfig { }

@Service
class RatesClient {

    @Retryable(maxRetries = 3)
    public Rate fetch(String pair) {
        return restClient.get()          // blocks, fine on a
                .uri("/rates/{p}", pair) // virtual thread
                .retrieve()
                .body(Rate.class);
    }
}

Spring Boot will run your whole web tier on virtual threads with a single property, from Boot 3.2 onward. Each request gets its own virtual thread, and a blocking controller stops being a scalability problem:

spring:
  threads:
    virtual:
      enabled: true

What you'll still meet in existing code#

LegacyExecutors.newFixedThreadPool(n)

The pre-21 default for I/O work: a pool sized by guesswork, tuned by incident. You will see it everywhere, and it still works. For I/O-bound tasks on Java 21+, replace it with Executors.newVirtualThreadPerTaskExecutor() and delete the sizing constant. Keep it only for genuinely CPU-bound work.

LegacyCompletableFuture chains as the async style

Before virtual threads, non-blocking Java meant chaining thenApply / thenCompose / thenCombine. Java's closest equivalent to a Task continuation chain, and about as readable as ContinueWith was before await.

// still compiles, still works, rarely the right choice now
CompletableFuture.supplyAsync(() -> api.getCustomer(id))
    .thenCombine(CompletableFuture.supplyAsync(() -> api.getOrders(id)),
                 this::merge)
    .join();

CompletableFuture remains the right tool when you need a value that completes later; see the next chapters. It is no longer the right tool for merely running two blocking calls at once.

LegacyThread.stop() and Thread.suspend()

Unsafe since Java 1.2, and now removed outright, calling them throws. There has never been a safe way to kill a thread from outside, in Java or .NET. Cancellation is cooperative: interrupt, and let the target notice.

Migration checklist#

If your .NET instinct isDo this in JavaBecause
Make the method asyncLeave it blockingvirtual threads unmount for you
Add Task<T> to the signatureReturn Tno colouring to propagate
Tune the thread pool sizeDelete the poolone virtual thread per task
Worry about sync-over-asyncDon'tthere is no async to be over
Use AsyncLocal for contextScopedValueimmutable, scope-bounded
Fire-and-forget with Task.Runexec.submit on a scoped executorkeeps the join

The next chapter covers the piece this one deliberately left out: running several blocking calls concurrently and joining them with proper cancellation. Java's answer to Task.WhenAll plus a CancellationToken.

Structured concurrency and cancellation#

Part P5 · Concurrency · Chapter 23· 3 min read

This is Java's answer to Task.WhenAll plus a CancellationToken: fork several subtasks inside a scope, join them, and have the scope guarantee that none outlive it. If one fails, the others are cancelled automatically.

It is still a preview feature in Java 25 Java 25 preview. It is the fifth preview, and the API has changed shape between previews. For production on Java 21 or 25, use the ExecutorService pattern at the bottom of this chapter instead.

Gotcha

Structured concurrency requires --enable-preview at both compile and run time. Class files built with preview features will not load on a different JDK version. Anything you find in a blog post from 2023 or 2024 uses an older API shape, new StructuredTaskScope<>() and ShutdownOnFailure; that no longer matches Java 25.

The problem it solves#

Run two calls concurrently, fail fast if either fails, and make sure nothing is left running. Here is the shape in each language.

C# 14
async Task<Report> BuildAsync(int id, CancellationToken ct)
{
    var a = FetchCustomerAsync(id, ct);
    var b = FetchOrdersAsync(id, ct);
    await Task.WhenAll(a, b);
    return Merge(a.Result, b.Result);
}
Java 25 (preview)
Report build(int id) throws Exception {
    try (var scope = StructuredTaskScope.open()) {
        var a = scope.fork(() -> fetchCustomer(id));
        var b = scope.fork(() -> fetchOrders(id));

        // waits; propagates the first failure
        scope.join();

        return merge(a.get(), b.get());
    }
}

The try block is the cancellation scope. Leaving it, normally, by exception, or by interrupt, cancels every subtask still running. There is no token to thread through your call graph, because the scope is the token, and it is bounded by the block.

.NETJava structured concurrency
Task.WhenAll(a, b)fork twice, then join
Task.WhenAny(a, b)a scope configured to complete on the first success
CancellationTokenSourcethe scope itself
ct.ThrowIfCancellationRequested()Thread.interrupted() checks, mostly implicit
CancelAfter(timeout)a timeout configured on the scope
try/finally to clean upthe try-with-resources block
OperationCanceledExceptionInterruptedException

Completion policies#

open() with no argument waits for every subtask and fails fast on the first failure. Passing a Joiner selects a different policy; this is where WhenAll and WhenAny diverge.

JoinerBehaviour.NET analogue
anySuccessfulResultOrThrow()the first successful result; cancels the restTask.WhenAny
allSuccessfulOrThrow()a stream of all subtasks, all succeededTask.WhenAll
awaitAll()waits for all, success or failureWhenAll then inspect
awaitAllSuccessfulOrThrow()waits for all; throws on any failureWhenAll with fail-fast
allUntil(Predicate)cancels when the predicate is satisfiedno direct equivalent
// first one home wins, the rest are cancelled
try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<Rate>anySuccessfulResultOrThrow())) {
    scope.fork(() -> primary.fetch(pair));
    scope.fork(() -> secondary.fetch(pair));
    return scope.join();          // the winning value
}

Why a scope rather than a token#

Note

The insight is the same one behind structured programming. An unstructured executor.submit() is a goto; the task's lifetime is unrelated to the code that started it, so leaks and orphans are possible. A scope makes concurrent lifetimes follow the block structure of the code, so a subtask cannot outlive the syntactic block that created it, and the relationship shows up correctly in thread dumps.

C#'s CancellationToken achieves cancellation but not containment: nothing stops you forgetting to await a task, and nothing guarantees it stops when its caller returns.

Cancellation is interruption#

Java's cancellation primitive is Thread.interrupt(). It is cooperative, like a CancellationToken: it sets a flag, and blocking calls in the JDK throw InterruptedException when they see it.

public void work() {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            doChunk();
            Thread.sleep(100);          // throws InterruptedException if interrupted
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();  // restore the flag!
            return;
        }
    }
}
Gotcha

Catching InterruptedException clears the interrupt flag. If you swallow it without calling Thread.currentThread().interrupt(), every layer above you loses the cancellation signal and the thread keeps working. This is the Java equivalent of catching OperationCanceledException and ignoring it, and it is much easier to do by accident.

Either restore the flag and return, or let the exception propagate. Never just log it.

ScopedValue replaces AsyncLocal#

Scoped values Java 25 are final in Java 25: unlike structured concurrency, these are production-ready. They carry context down a call tree without passing parameters, and are inherited by forked subtasks.

C#
static readonly AsyncLocal<string> User = new();

User.Value = "ada";
await DoWorkAsync();     // sees "ada"
Java 25
static final ScopedValue<String> USER =
        ScopedValue.newInstance();

ScopedValue.where(USER, "ada").run(() -> {
    // USER.get() is "ada" here, and in forked subtasks
    doWork();
});                      // rebound to nothing after the block
AsyncLocal<T>ScopedValue<T>
Mutable, assign any timeImmutable, bound for a block
Lifetime is ambientLifetime is the block
Flows to async continuationsInherited by forked subtasks
Can leak if never clearedCannot leak; unbinds on block exit
Note

ScopedValue is also the recommended replacement for ThreadLocal when running on virtual threads. A ThreadLocal holds a value per thread; with a million virtual threads that is a million values.

What to write today#

Until structured concurrency is final, this is the production-safe equivalent using only final APIs. It gives you fail-fast and guaranteed cleanup, without preview flags.

Report build(int id) throws Exception {
    try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
        Future<Customer> a = exec.submit(() -> fetchCustomer(id));
        Future<Orders>   b = exec.submit(() -> fetchOrders(id));

        // get() propagates the first failure as ExecutionException
        return merge(a.get(), b.get());
    }
    // close() waits for all tasks; on exception the block still exits cleanly
}
Gotcha

This pattern does not cancel the sibling when one task fails, close() waits for the other to finish rather than interrupting it. If fail-fast cancellation matters, call b.cancel(true) in a catch, or use invokeAny/invokeAll which have their own semantics. This gap is precisely what structured concurrency exists to close.

CompletableFuture and Task#

Part P5 · Concurrency · Chapter 24· 2 min read

CompletableFuture<T> is Task<T>: a value that completes later, with combinators to chain work onto it. The difference is that C# hid the chaining behind await, and Java never did, so Java code that uses it reads like pre-await C# with ContinueWith.

Since virtual threads, you need it far less. Reach for it when you genuinely need a value that completes later, a callback-based API, an event, a cache fill, not merely to run two blocking calls at once.

Translation#

.NETJavaNote
Task<T>CompletableFuture<T>
Task (void)CompletableFuture<Void>
Task.FromResult(v)CompletableFuture.completedFuture(v)
Task.Run(f)CompletableFuture.supplyAsync(f)
Task.Run(action)CompletableFuture.runAsync(action)
TaskCompletionSource<T>new CompletableFuture<>()complete() it manually
.ContinueWith(t => f(t.Result)).thenApply(f)
.ContinueWith returning Task.thenCompose(f)flattens a future of a future into one
Task.WhenAll(a, b)CompletableFuture.allOf(a, b)returns Void; see gotcha
Task.WhenAny(a, b)CompletableFuture.anyOf(a, b)returns Object
combine two results.thenCombine(other, fn)waits for both, then merges; .NET has no single call
.Result / .GetAwaiter().GetResult().join()join throws unchecked
await task.join(), or just block on a virtual thread
try/catch around await.exceptionally(fn) or .handle(fn)
Task.DelayCompletableFuture.delayedExecutor(...)Java 9
IProgress<T>no equivalentpass a Consumer

Chaining#

C#
var report = await FetchCustomerAsync(id)
    .ContinueWith(t => Enrich(t.Result))
    .Unwrap();

// or, idiomatically
var c = await FetchCustomerAsync(id);
var report = await EnrichAsync(c);
Java 25
CompletableFuture<Report> f =
    fetchCustomerAsync(id)
        .thenApply(this::decorate)        // sync transform
        // enrichAsync returns another future
        .thenCompose(this::enrichAsync);

Report report = f.join();
MethodRunsReturns
thenApply(fn)on the completing threadCompletableFuture<R>
thenApplyAsync(fn)on the common pool, or a supplied executorCompletableFuture<R>
thenCompose(fn)fn returns a future; flattensCompletableFuture<R>
thenCombine(other, fn)when both completeCompletableFuture<R>
thenAccept(consumer)side effectCompletableFuture<Void>
exceptionally(fn)only on failureCompletableFuture<T>
handle(fn)on success or failureCompletableFuture<R>
whenComplete(action)on either; does not change the valueCompletableFuture<T>
Gotcha

allOf returns CompletableFuture<Void>, not a future of the results. Unlike Task.WhenAll, which gives you Task<T[]>, you must go back and collect each future's value yourself:

List<CompletableFuture<Order>> futures = ids.stream()
        .map(id -> supplyAsync(() -> load(id)))
        .toList();

CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();

List<Order> orders = futures.stream()
        .map(CompletableFuture::join)   // safe: all are already complete
        .toList();

Which thread does the work#

Gotcha

The *Async variants without an explicit executor run on ForkJoinPool.commonPool(), which is sized to your CPU count minus one and is shared with parallel streams and everything else in the JVM. Blocking on it starves everything.

Always pass an executor for anything that blocks:

var exec = Executors.newVirtualThreadPerTaskExecutor();

CompletableFuture.supplyAsync(() -> httpCall(), exec)   // blocking, but on a virtual thread
        .thenApplyAsync(this::transform, exec);

Exceptions#

A failed future wraps the cause in CompletionException (from join) or ExecutionException (from get). This is the same wrapping as .NET's AggregateException, and just as annoying.

try {
    var r = future.join();
} catch (CompletionException e) {
    Throwable cause = e.getCause();     // the real exception
}

// or handle it in the pipeline
future.exceptionally(ex -> {
        log.warn("failed", ex);
        return Report.empty();
    })
    .thenAccept(this::publish);
Note

get() throws checked ExecutionException and InterruptedException; join() throws unchecked CompletionException. Inside a lambda, join() is almost always what you want, because checked exceptions and lambdas do not mix; see Exceptions and resources.

When to still use it#

SituationUse
Two blocking calls concurrentlyvirtual threads + an executor, not CompletableFuture
Adapting a callback API to a valueCompletableFuture, completed manually
Caffeine or another async cacheCompletableFuture; the API requires it
Spring WebFlux / reactive codeMono/Flux, which are a different model again
Fire-and-forget with a completion hookCompletableFuture.runAsync(...).thenRun(...)
LegacyFuture&lt;T&gt;

The original Future from Java 5 has no combinators at all; you can only get() (blocking), cancel() and isDone(). It is what ExecutorService.submit returns, and it is perfectly serviceable when you intend to block on a virtual thread anyway. CompletableFuture implements Future, so it is a strict superset.

Locks, atomics and the memory model#

Part P5 · Concurrency · Chapter 25· 2 min read

synchronized is lock. AtomicInteger is Interlocked. ConcurrentHashMap is ConcurrentDictionary. The primitives line up closely.

The one place to be careful is volatile: the keyword exists in both languages and means something stronger in Java. C#'s volatile is acquire/release; Java's is sequentially consistent and also guarantees visibility of everything written before it.

Mutual exclusion#

C#
private readonly object _gate = new();

public void Add(int x)
{
    lock (_gate)
    {
        _total += x;
    }
}
Java 25
private final Object gate = new Object();

public void add(int x) {
    synchronized (gate) {
        total += x;
    }
}

Java also allows synchronized as a method modifier, which locks on this (or the class object, for a static method):

public synchronized void add(int x) { total += x; }   // locks on this
Gotcha

Never synchronized on this or on a public field. Any external code holding a reference to your object can lock on it too, and now your class's correctness depends on strangers. Use a private final lock object. The same advice applies to lock(this) in C#, and for the same reason, but Java's synchronized method modifier makes the mistake much easier to make by accident.

ReentrantLock#

When you need a timeout, interruptibility, fairness or multiple condition variables, synchronized is not enough.

NeedC#Java
Basic mutual exclusionlocksynchronized, or ReentrantLock
Try with timeoutMonitor.TryEnter(o, ts)lock.tryLock(t, unit)
Interruptible acquireno direct equallock.lockInterruptibly()
Read/write splitReaderWriterLockSlimReentrantReadWriteLock
Condition variableMonitor.Wait / PulseCondition.await / signal
Fair queueingnonew ReentrantLock(true)
Non-reentrantSemaphoreSlim(1)Semaphore(1)
private final ReentrantLock lock = new ReentrantLock();

if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
    try {
        mutate();
    } finally {
        lock.unlock();          // ALWAYS in a finally
    }
} else {
    throw new TimeoutException();
}
Note

On virtual threads, ReentrantLock has historically been preferable to synchronized because the runtime can unmount a virtual thread that is waiting on it; see the pinning discussion in Threads are cheap now.

Atomics#

C#
private int _count;
Interlocked.Increment(ref _count);
Interlocked.CompareExchange(ref _count, 5, 4);
Java 25
private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();
count.compareAndSet(4, 5);
.NETJava
Interlocked.Increment(ref x)atomicInt.incrementAndGet()
Interlocked.Add(ref x, n)atomicInt.addAndGet(n)
Interlocked.Exchange(ref x, v)atomicRef.getAndSet(v)
Interlocked.CompareExchange(ref x, v, c)atomicRef.compareAndSet(c, v)
n/aatomicInt.updateAndGet(fn)
n/aatomicRef.accumulateAndGet(v, fn)
Interlocked for high contentionLongAdder, far better under contention
Volatile.Read / Writevolatile field, or VarHandle
Note

LongAdder has no .NET equivalent and is worth knowing. Under heavy contention it beats AtomicLong substantially by striping the count across cells and summing on read. Use it for metrics and counters where you write constantly and read occasionally.

volatile means more in Java#

Gotcha

Both languages have the keyword; the semantics differ.

C# volatileJava volatile
Reorderingacquire on read, release on writefull sequential consistency
Visibilitythe field itselfthe field, plus everything written before it
Atomicity of 64-bitnot guaranteed for long/double on 32-bitguaranteed
Use for a flagyesyes
Use for double-checked lockinginsufficient alonesufficient

The practical consequence: the double-checked locking idiom that is subtly broken in C# without extra barriers is correct in Java provided the field is volatile. Java 5 fixed the memory model to make this guarantee; pre-Java-5 articles saying double-checked locking is broken are describing a JVM that no longer exists.

// correct in Java 5+ with volatile
private volatile Config config;

public Config get() {
    var c = config;
    if (c == null) {
        synchronized (this) {
            c = config;
            if (c == null) config = c = load();
        }
    }
    return c;
}
Note

Better still, avoid the idiom. A static holder class gives you lazy, thread-safe initialisation with no locking at all, because the JVM guarantees class initialisation runs once:

private static class Holder {
    static final Config INSTANCE = load();
}
public static Config get() { return Holder.INSTANCE; }

Java 25 also previews stable values Java 25 preview, which are essentially Lazy<T> with the JIT treating them as constants after initialisation.

Concurrent collections#

.NETJavaNote
ConcurrentDictionary<K,V>ConcurrentHashMap<K,V>
GetOrAdd(k, factory)computeIfAbsent(k, fn)atomic, fn runs at most once
AddOrUpdatecompute(k, fn) / merge(k, v, fn)
ConcurrentQueue<T>ConcurrentLinkedQueue<E>unbounded, non-blocking
BlockingCollection<T>LinkedBlockingQueue<E>bounded, blocking
BlockingCollection with capArrayBlockingQueue<E>fixed capacity
ConcurrentBag<T>no direct equaluse a concurrent queue
ImmutableList + swapCopyOnWriteArrayList<E>every write copies the array; only for rare writes
Channel<T>BlockingQueue, or SubmissionPublisher
CountdownEventCountDownLatchcounts down to zero once; cannot be reset
BarrierCyclicBarrierreleases all waiters, then resets for the next round
ManualResetEventSlimCountDownLatch, or a Condition
SemaphoreSlimSemaphore
Gotcha

computeIfAbsent holds a bin lock while the mapping function runs. Calling back into the same map from inside that function, even indirectly, can deadlock or throw IllegalStateException. Keep the function short and self-contained. The same warning applies to ConcurrentDictionary.GetOrAdd, except that .NET may run the factory more than once, where Java guarantees at most once.

Legacy concurrency you will still meet#

Part P5 · Concurrency · Chapter 26· 2 min read

Everything in this chapter still works and is still all over production codebases. None of it is what you should write on Java 21 or later. It is here so you can read existing code and know what to replace it with.

What .NET taught you
// .NET has always pooled for you
ThreadPool.QueueUserWorkItem(_ => Handle(id));

var t = Task.Run(() => Handle(id));
await t;
What older Java code does
// pre-21 Java: you sized the pool yourself
ExecutorService pool = Executors.newFixedThreadPool(50);
Future<?> f = pool.submit(() -> handle(id));
f.get();
pool.shutdown();

Raw threads#

Legacynew Thread(...).start()

The Java 1.0 API. Creating a platform thread per task costs about 1 MB of stack and a system call, which is why thread pools exist.

Thread t = new Thread(() -> doWork());
t.setName("worker-1");
t.setDaemon(true);
t.start();
t.join();

Replace with: Thread.ofVirtual().start(...) for I/O work, or an executor. The Thread class itself is not deprecated, virtual threads are Thread instances too.

Concept.NETJavaNote
Background threadIsBackground = truesetDaemon(true)JVM exits without waiting
Foreground threaddefaultdefaultJVM waits for it
NameThread.NamesetName()shows in thread dumps
PriorityThread.PrioritysetPriority()advisory only; ignore it
Wait for completionJoin()join()same
Kill itAbort(), removedstop(), removednever possible safely

Fixed thread pools#

LegacyExecutors.newFixedThreadPool(n)

The pre-21 standard for concurrent I/O. The size is a guess that trades throughput against memory, and getting it wrong is the classic production incident: too small and you queue, too large and you exhaust memory.

ExecutorService pool = Executors.newFixedThreadPool(50);
try {
    List<Future<Order>> fs = pool.invokeAll(tasks);
    for (var f : fs) use(f.get());
} finally {
    pool.shutdown();
    pool.awaitTermination(30, TimeUnit.SECONDS);
}

Replace with: Executors.newVirtualThreadPerTaskExecutor() for I/O-bound work, and delete the sizing constant. Keep a bounded platform pool only for CPU-bound work.

FactoryGives youModern replacement
newFixedThreadPool(n)n platform threadsvirtual threads, for I/O
newCachedThreadPool()unbounded, reusedvirtual threads
newSingleThreadExecutor()serial executionstill fine for serialising work
newScheduledThreadPool(n)timer-like schedulingstill the right tool
newWorkStealingPool()ForkJoinPoolstill right for CPU-bound divide and conquer
newVirtualThreadPerTaskExecutor()one virtual thread per taskJava 21+
Gotcha

ExecutorService is AutoCloseable only since Java 19. In older code you will see the shutdown() / awaitTermination() / shutdownNow() dance in a finally block. Forgetting it leaks non-daemon threads and the JVM never exits, which is a common cause of a build or CLI that hangs after finishing its work.

wait, notify and notifyAll#

LegacyObject.wait() / notify()

The original condition-variable mechanism, built into every object. It must be called while holding the object's monitor, and it is famously easy to get wrong: a missed notify deadlocks, notify instead of notifyAll can wake the wrong waiter, and spurious wakeups mean the wait must always be in a loop.

synchronized (queue) {
    while (queue.isEmpty()) {    // while, never if, spurious wakeups are real
        queue.wait();
    }
    return queue.poll();
}

Replace with: a BlockingQueue, which does exactly this correctly, or a ReentrantLock with a Condition.

Synchronized wrappers#

LegacyCollections.synchronizedMap and friends

Wraps every method in a lock. That makes single operations safe but does nothing for compound ones, so this is still a race:

Map<String, Integer> m = Collections.synchronizedMap(new HashMap<>());

if (!m.containsKey(k)) {   // another thread can insert here
    m.put(k, 0);
}

Replace with: ConcurrentHashMap and its atomic compound operations, putIfAbsent, computeIfAbsent, merge.

java.util.Timer#

LegacyTimer and TimerTask

Single-threaded, and one task throwing an unchecked exception kills the timer thread and silently cancels every other scheduled task. Replace with: ScheduledExecutorService, or Spring's @Scheduled.

var sched = Executors.newScheduledThreadPool(1);
sched.scheduleAtFixedRate(this::poll, 0, 30, TimeUnit.SECONDS);

Note that even with ScheduledExecutorService, an uncaught exception cancels that repeating task. Wrap the body in a try/catch if it must keep running.

ThreadLocal#

LegacyThreadLocal&lt;T&gt;

Java's AsyncLocal, and still correct for platform threads. Two problems on modern Java: with virtual threads you may have a million of them, and values must be removed explicitly or they leak when a pooled thread is reused.

private static final ThreadLocal<User> CURRENT = new ThreadLocal<>();

CURRENT.set(user);
try {
    doWork();
} finally {
    CURRENT.remove();      // essential in a pooled thread
}

Replace with: ScopedValue Java 25, which is immutable, bounded to a block, and cannot leak. See Structured concurrency.

Summary#

If you seeReplace with
new Thread(...)Thread.ofVirtual(), or an executor
newFixedThreadPool for I/OnewVirtualThreadPerTaskExecutor
wait / notifyBlockingQueue, or Condition
Collections.synchronizedMapConcurrentHashMap
java.util.TimerScheduledExecutorService
ThreadLocalScopedValue
Thread.stop / suspendnothing; they are removed
CompletableFuture merely to parallelise blocking callsvirtual threads

Reactive: WebFlux and Reactor#

Part P5 · Concurrency · Chapter 27· 4 min read

Reactor is Rx.NET, and WebFlux is an ASP.NET Core pipeline that never blocks. If you have used IObservable or IAsyncEnumerable, the model is familiar.

You probably do not need it. The argument for reactive Java was that threads were expensive, so you could not afford one per request. Virtual threads Java 21 removed that argument. What remains is a genuine but much narrower case: streaming, backpressure, and very large fan-out.

The model#

.NETReactorMeans
Task<T>Mono<T>zero or one value, later
IAsyncEnumerable<T>Flux<T>many values, later
IObservable<T>Flux<T>Reactor merges both roles
Task.FromResult(v)Mono.just(v)
Task.CompletedTaskMono.empty()
.Select.map
.SelectMany.flatMap
.Where.filter
await.block(), almost always wrongsee below
IAsyncEnumerable + ChannelFlux + backpressureReactor makes it explicit
C#: async/await
public async Task<Report> BuildAsync(int id)
{
    var c = await _api.GetCustomerAsync(id);
    var o = await _api.GetOrdersAsync(id);
    return Merge(c, o);
}
Java: Reactor
public Mono<Report> build(int id) {
    return Mono.zip(
            api.getCustomer(id),
            api.getOrders(id))
        .map(t -> merge(t.getT1(), t.getT2()));
}
Note

Reactor is what Java had instead of async/await. C# solved non-blocking I/O with a compiler transform that let you keep writing straight-line code. Java could not, so the ecosystem built a combinator library instead, which is why reactive Java reads like C# would if await had never been added and everyone used ContinueWith.

Why the case for it collapsed#

The reactive pitch was never really about elegance. It was arithmetic.

Before Java 21                        Java 21+
─────────────────────────────         ─────────────────────────────
1 request = 1 platform thread         1 request = 1 virtual thread
1 thread  ≈ 1 MB of stack             1 vthread ≈ a few hundred bytes
10,000 concurrent requests            10,000 concurrent requests
   ≈ 10 GB of stacks                     ≈ a few MB
   → impossible                          → unremarkable

So: don't block. Restructure           So: block. It's fine.
everything into callbacks.
What you'd write in C#
var c = await _api.GetCustomerAsync(id);
var o = await _api.GetOrdersAsync(id);
return Merge(c, o);
Java 21+: and it scales
var c = api.getCustomer(id);   // blocks
var o = api.getOrders(id);     // blocks
return merge(c, o);

The second column is the whole point of Threads are cheap now. It needs no reactive types, is debuggable with a normal stack trace, and scales to the same numbers.

Gotcha

Do not adopt WebFlux for throughput on a new Boot service. That decision made sense in 2018 and is usually wrong now. It costs you readable stack traces, ordinary debugging, a large chunk of the blocking library ecosystem, and a much steeper learning curve for every future maintainer, in exchange for a scalability property that Spring MVC on virtual threads already has.

When it is still the right answer#

CaseWhy virtual threads do not solve it
Server-sent events, long-lived streamsthe value is many results over time, not one
Backpressure across a boundarya slow consumer must actually slow the producer
Very large fan-out (thousands of concurrent calls per request)a combinator graph expresses this better than thousands of scopes
Streaming a large result without buffering itFlux processes element by element
You are already on WebFluxmixing blocking code into it is worse than committing
Kafka Streams, R2DBC, RSocketthe library is reactive; fighting it is worse
Note

Backpressure is the concept with no equivalent in the virtual-thread model. It is the ability for a slow consumer to tell a fast producer to send less. A blocking pipeline gets this implicitly, if you do not read, the producer blocks, but only within one process. Across a network boundary, or through a queue, Reactor makes it an explicit, tunable part of the contract. That is a real capability, and it is the strongest remaining argument for reactive code.

The blocking hazard#

Gotcha

A blocking call on an event-loop thread stalls unrelated requests. WebFlux runs on a small number of Netty event-loop threads, roughly one per core. Block one and every request assigned to it stops, including requests that have nothing to do with yours.

@GetMapping("/orders/{id}")
public Mono<Order> get(@PathVariable long id) {
    Order o = jdbcRepo.findById(id);     // blocking JDBC on an event loop
    return Mono.just(o);                 // catastrophic under load
}

This is why WebFlux is all-or-nothing: one blocking library anywhere in the request path undoes the entire model. If you must, push it to a bounded scheduler:

return Mono.fromCallable(() -> jdbcRepo.findById(id))
           .subscribeOn(Schedulers.boundedElastic());

Add the BlockHound agent in tests and it will fail loudly on any blocking call that reaches an event loop.

WebFlux against MVC#

Spring MVCSpring WebFlux
ServerTomcat (servlet)Netty (event loop)
Threadingone thread per requesta few event-loop threads
Return typesOrder, ResponseEntityMono, Flux
Data accessJDBC, JPAR2DBC, reactive Mongo
Blocking librariesfineforbidden in the request path
Stack tracesordinary and readableassembled from operators; hard
Debuggingstep throughHooks.onOperatorDebug, and patience
Scales to 10k requestsyes, on virtual threadsyes
Gotcha

There is no reactive JPA. R2DBC is a different driver with a different API and no ORM at all: no entity graph, no lazy loading, no Hibernate. Choosing WebFlux for a database-backed CRUD service means giving up Spring Data JPA, which is usually a much larger cost than the one being optimised away.

Reading it#

Even if you never write reactive code you will read it, because Spring Cloud Gateway, some Kafka integrations and a fair amount of existing service code use it.

public Flux<OrderDto> recentOrders(String customerId) {
    return repo.findByCustomer(customerId)          // Flux<Order>
            .filter(Order::isActive)
            .take(20)
            .flatMap(this::enrich)                  // returns Mono<OrderDto> each
            .onErrorResume(e -> Flux.empty())       // swallow and continue
            .timeout(Duration.ofSeconds(3))
            .doOnNext(o -> log.debug("sending {}", o.id()));
}
OperatorDoes
maptransform each element
flatMaptransform each into a publisher and merge; order not preserved
concatMapas flatMap, but preserves order
zipcombine several publishers element-wise
switchIfEmptyfall back when nothing was emitted
onErrorResumesubstitute a publisher on failure
retryWhenretry with a policy
timeoutfail if nothing arrives in time
subscribeOn / publishOnchoose which scheduler runs what
blockwait for the value, never in reactive code
Gotcha

Nothing happens until you subscribe. A Mono or Flux is a recipe, not a running computation. Building one and discarding it does nothing at all; no request is sent, no row is written. In Spring the framework subscribes for you when you return it from a controller, which is why forgetting to return a publisher is a silent no-op rather than an error.

repo.save(order);            // does nothing, never subscribed
return repo.save(order);     // correct
LegacyRxJava

Before Reactor, the reactive library in Java was RxJava, a direct port of Rx.NET that is still common in Android. Spring standardised on Reactor, but both implement the Reactive Streams specification, so Publisher types interoperate. If you meet Observable and Single rather than Flux and Mono, you are looking at RxJava.

Check yourself: Concurrency
1Where is Java's await?
There is not one and you do not need it. Run the blocking code on a virtual thread and the runtime unmounts it for you. No function colouring, no Task<T> in signatures.
2Should you pool virtual threads?
No. Pooling amortises creation cost, and virtual threads are almost free to create. To limit concurrency, bound the resource with a Semaphore.
3Structured concurrency looks perfect for your fan-out. Can you ship it?
Not without --enable-preview. It is still preview in Java 25, on its fifth iteration, and the API has changed between previews. Use a virtual-thread executor instead.
4Is WebFlux the right choice for a new database-backed service?
Almost certainly not. Virtual threads removed the scalability argument, and WebFlux means giving up Spring Data JPA, readable stack traces and every blocking library.

The week-one gotchas#

Part P6 · Gotchas · Chapter 28· 3 min read

Every item here is something a competent C# developer gets wrong in their first fortnight of Java, because the C# instinct is correct in C# and wrong in Java. Skim this once now, and come back when something behaves impossibly.

1. == on objects#

Gotcha

== compares references for every reference type, including String. It appears to work because literals are interned, then fails on a string built at runtime. Use .equals(), or Objects.equals(a, b) when either side may be null. See Equality and hashing.

C#
var a = "abc";
var b = ReadFromFile();     // also "abc"

a == b          // true, string == is value equality
a.Equals(b)     // true
Java 25
String a = "abc";
String b = readFromFile();   // also "abc"

a == b          // FALSE, reference comparison
a.equals(b)     // true
Objects.equals(a, b)   // true, and null-safe

2. The Integer cache#

Gotcha

Integer a = 127, b = 127; a == b is true. Integer a = 128, b = 128; a == b is false. Boxed integers from −128 to 127 are cached. Tests pass, production fails.

3. Auto-unboxing throws NullPointerException#

Gotcha

A null Integer unboxes to an NPE, at a line that contains no visible method call:

Map<String, Integer> counts = new HashMap<>();
int n = counts.get("missing");     // NullPointerException, not 0

In C#, int? n = dict["missing"] would be a KeyNotFoundException, a clearer failure. Use getOrDefault(k, 0).

4. No modifier means package-private#

Gotcha

In C# a member with no access modifier is private. In Java it is visible to the entire package. Inverted muscle memory, silent consequence. See Access modifiers and packages.

5. protected is wider than you think#

Gotcha

Java's protected grants access to the whole package as well as subclasses. There is no way to express C#'s subclass-only protected.

6. Methods are virtual by default#

Gotcha

C# opts in with virtual; Java opts out with final. Any public method you write is an extension point unless you say otherwise, which matters most when you call an overridable method from a constructor, and the subclass override runs before the subclass's fields are initialised.

class Base {
    Base() { init(); }              // calls the override...
    void init() { }
}
class Child extends Base {
    private final String name = "x";
    @Override void init() {
        System.out.println(name);   // prints null, field not yet assigned
    }
}

7. Arrays are covariant and it is unsound#

Gotcha

Both languages made this mistake, and both throw at runtime rather than compile time:

Object[] objects = new String[2];
objects[0] = 42;         // compiles; throws ArrayStoreException at runtime

Generics are invariant precisely to avoid this, which is why List<String> is not a List<Object>.

8. Inner classes capture the outer instance#

Gotcha

A C# nested class is always independent. In Java, a nested class is only independent if you write static. A non-static inner class holds a hidden reference to the enclosing instance, which means it cannot be constructed without one, and it keeps the outer object alive.

class Outer {
    class Inner { }           // holds an implicit Outer.this
    static class Nested { }   // independent, what C# gives you by default
}

new Outer().new Inner();      // the bizarre syntax this forces
new Outer.Nested();           // normal

Default to static on every nested class unless you deliberately want the outer reference. Forgetting it is a common memory-leak source, especially for listeners and callbacks.

9. Integer division and overflow#

Gotcha

5 / 2 is 2 in both languages. But Java has no checked block: integer overflow always wraps silently, and there is no way to opt into throwing. Use Math.addExact, multiplyExact and friends when overflow would be a correctness bug; they throw ArithmeticException.

Java also has no unsigned types. There is no uint or ulong; use the static helpers Integer.divideUnsigned, Long.compareUnsigned and so on.

10. Checked exceptions do not fit in lambdas#

Gotcha

This is the one that generates the most swearing. A lambda passed to map cannot throw a checked exception, because Function's method does not declare one:

files.stream()
     .map(f -> Files.readString(f))   // does not compile: IOException
     .toList();

You must wrap it, which is ugly and universal:

.map(f -> {
    try { return Files.readString(f); }
    catch (IOException e) { throw new UncheckedIOException(e); }
})

See Exceptions and resources.

11. switch on a reference type throws on null#

Gotcha

switch (s) where s is a null String or enum throws NullPointerException unless you write case null Java 21. C# handles null through a pattern arm.

12. Date and SimpleDateFormat are traps#

Gotcha

java.util.Date is mutable, Calendar months are zero-based, and SimpleDateFormat is not thread-safe; a shared static instance produces silently wrong dates under concurrency. Use java.time exclusively. See Numbers, money and time.

13. There is no decimal#

Gotcha

Java has no 128-bit decimal primitive. Money is BigDecimal, which is an object with no operator overloading, so arithmetic becomes method calls and comparison becomes compareTo. Using double for money is the single most common correctness bug a C# developer introduces into Java.

14. Views are not copies#

Gotcha

subList, keySet, values, entrySet, Arrays.asList and reversed() all return views backed by the original. Mutating the view mutates the source, and mutating the source can invalidate the view.

var fixed = Arrays.asList(1, 2, 3);
fixed.set(0, 9);     // fine, writes through to the array
fixed.add(4);        // UnsupportedOperationException, fixed size

15. finally can swallow exceptions#

Gotcha

A return or throw inside finally discards whatever was propagating from try or catch, silently. C# forbids returning from a finally block; Java permits it. Never return from finally.

Numbers, money and time#

Part P6 · Gotchas · Chapter 29· 3 min read

Two things to get right on day one. There is no decimal. Money is BigDecimal, an object, so arithmetic is method calls rather than operators. And use java.time for everything. It shares its ancestry with Noda Time, since both descend from Joda-Time, and it is much better than the old Date and Calendar classes, which are a trap.

Primitive types#

C#JavaSizeNote
sbytebyte8-bitJava's byte is SIGNED
byteno equivalentuse short, or byte with care
shortshort16-bit
ushortchar16-bitchar is the only unsigned type
intint32-bit
uintno equivalentuse long, or Integer.*Unsigned helpers
longlong64-bit
ulongno equivalentuse Long.*Unsigned helpers
floatfloat32-bit
doubledouble64-bit
decimalno equivalentuse BigDecimal
boolbooleannote the spelling
charchar16-bitUTF-16 code unit in both
nint / nuintno equivalent
Gotcha

Java's byte is signed, range −128 to 127. C#'s byte is unsigned and sbyte is the signed one. Reading binary data written by C# code and printing a byte will show negative numbers. Mask with b & 0xFF to get the unsigned value.

Wrapper types#

PrimitiveWrapperC# analogy
intIntegerint? (roughly)
longLonglong?
doubleDoubledouble?
booleanBooleanbool?
charCharacterchar?

Autoboxing converts between them implicitly, which is convenient and the source of two bugs already covered: the Integer cache and NPE on unboxing.

Money is BigDecimal#

C#
decimal price = 19.99m;
decimal total = price * 3 + shipping;

if (total > limit) { }
Console.WriteLine(total.ToString("C"));
Java 25
BigDecimal price = new BigDecimal("19.99");
BigDecimal total = price.multiply(BigDecimal.valueOf(3))
                        .add(shipping);

if (total.compareTo(limit) > 0) { }
NumberFormat.getCurrencyInstance().format(total);
OperationBigDecimal
a + ba.add(b)
a - ba.subtract(b)
a * ba.multiply(b)
a / ba.divide(b, scale, RoundingMode.HALF_UP)
a > ba.compareTo(b) > 0
a == b (value)a.compareTo(b) == 0
roundinga.setScale(2, RoundingMode.HALF_UP)
from a literalnew BigDecimal("19.99"), always the String constructor
Gotcha

Three traps, all of which produce wrong money:

  • new BigDecimal(0.1) is wrong. The double literal is already imprecise, so you get 0.1000000000000000055511151231257827. Always use the String constructor, or BigDecimal.valueOf(double) which routes through Double.toString.
  • divide without a scale throws ArithmeticException on a non-terminating result such as 1/3. Always supply a scale and a RoundingMode.
  • equals compares scale. new BigDecimal("1.0").equals(new BigDecimal("1.00")) is false. Use compareTo.
Note

Some teams avoid BigDecimal entirely by storing money as a long of minor units (pence, cents) and formatting only at the edges. That is a legitimate design, avoids all three traps, and is what many payment systems do.

java.time#

If you know Noda Time, you know this API. Jon Skeet based Noda Time on Joda-Time, and java.time Java 8 is Joda-Time's successor by the same author. It distinguishes the concepts that DateTime conflates.

Conceptjava.time.NET
Date, no time, no zoneLocalDateDateOnly
Time, no date, no zoneLocalTimeTimeOnly
Date and time, no zoneLocalDateTimeDateTime (Unspecified)
Instant on the timelineInstantDateTimeOffset (UTC)
Date, time and zoneZonedDateTimeDateTimeOffset + TimeZoneInfo
Date, time and offsetOffsetDateTimeDateTimeOffset
Amount of timeDurationTimeSpan
Calendar amountPeriodno direct equal
Time zoneZoneIdTimeZoneInfo
FormattingDateTimeFormatterformat strings
C#
var now = DateTimeOffset.UtcNow;
var due = now.AddDays(30);
var d   = DateOnly.FromDateTime(DateTime.Today);
var span = due - now;
Java 25
var now = Instant.now();
var due = now.plus(30, ChronoUnit.DAYS);
var d   = LocalDate.now();
var span = Duration.between(now, due);
Note

All java.time types are immutable and thread-safe, including DateTimeFormatter. Every mutation returns a new instance, plusDays, withYear, truncatedTo. This is the same model as .NET's DateTime, so it should feel natural.

Gotcha

Duration and Period are not interchangeable. Duration is exact machine time, seconds and nanos. Period is calendar time: years, months, days. Adding one month is a Period operation and lands on a different day-of-month depending on the source date; adding 30 days is a Duration-style operation. Daylight-saving transitions make the distinction load-bearing.

The old date API#

LegacyDate, Calendar and SimpleDateFormat

You will meet all three. Each has a serious defect:

ClassProblem
java.util.Datemutable; it is really an instant, despite the name
java.sql.Dateextends Date but forbids the time part
Calendarmonths are ZERO-based. January is 0
SimpleDateFormatNOT thread-safe; a shared instance corrupts output silently
TimeZonesuperseded by ZoneId

The SimpleDateFormat one deserves emphasis: a static final SimpleDateFormat shared across request threads is a real and common production bug that produces plausible but wrong dates. DateTimeFormatter is immutable and safe to share.

Converting at the boundary:

Instant i = oldDate.toInstant();
Date d = Date.from(instant);
LocalDate ld = oldDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();

Formatting numbers#

C#Java
x.ToString("N2")String.format("%,.2f", x)
x.ToString("C")NumberFormat.getCurrencyInstance().format(x)
x.ToString("P")NumberFormat.getPercentInstance().format(x)
x.ToString("X")Integer.toHexString(x)
int.Parse(s)Integer.parseInt(s)
int.TryParse(s, out v)catch NumberFormatException
double.Parse(s, CultureInfo.Invariant)Double.parseDouble(s), always invariant
Gotcha

Integer.parseInt is locale-independent, but NumberFormat and String.format are not; they use the default locale unless you pass one. A server running in a locale that uses comma as the decimal separator will format 1.5 as 1,5. Pass Locale.ROOT explicitly for anything machine-readable.

Exceptions and resources#

Part P6 · Gotchas · Chapter 30· 3 min read

One structural difference: checked exceptions. Java's compiler forces you to declare or handle certain exception types. C# has no equivalent, and this is the feature C# deliberately declined to copy.

Everything else lines up. try-with-resources is using, and AutoCloseable is IDisposable. Never use finalize; it is deprecated for removal.

The hierarchy#

Throwable
  ├─ Error                    unchecked. JVM problems, do not catch
  │    ├─ OutOfMemoryError
  │    └─ StackOverflowError
  └─ Exception                CHECKED by default
       ├─ IOException         checked
       ├─ SQLException        checked
       └─ RuntimeException    unchecked
            ├─ NullPointerException
            ├─ IllegalArgumentException
            ├─ IllegalStateException
            └─ ...
RuleMeaning
Extends RuntimeException or Errorunchecked: like every C# exception
Extends Exception, not RuntimeExceptionchecked, must be declared or caught

Checked exceptions#

C#
public string Read(string path)
{
    // may throw; nothing to declare
    return File.ReadAllText(path);
}
Java 25
public String read(String path) throws IOException {
    // must declare it, or catch it
    return Files.readString(Path.of(path));
}

Your two options, and their costs:

// 1. Propagate; the caller now has the same obligation
public String read(Path p) throws IOException {
    return Files.readString(p);
}

// 2. Wrap in an unchecked exception; the obligation stops here
public String read(Path p) {
    try {
        return Files.readString(p);
    } catch (IOException e) {
        throw new UncheckedIOException(e);   // always keep the cause
    }
}
Note

The Java community has largely settled on wrapping for application code. Spring, Hibernate and the AWS SDK all convert checked exceptions to unchecked ones at their boundaries, on the reasoning that most callers cannot meaningfully recover from an IOException and forcing every layer to declare it is noise.

Keep checked exceptions for genuinely recoverable, expected conditions in a library API. Propagate them at most one or two layers.

Gotcha

Always pass the cause when wrapping. throw new RuntimeException(e), not throw new RuntimeException(e.getMessage()). Dropping the cause discards the original stack trace, and the resulting production incident is unfixable from logs alone. This is the same as C#'s throw new X("...", inner).

Checked exceptions and lambdas#

Gotcha

The functional interfaces in java.util.function declare no checked exceptions, so no lambda passed to map, forEach or Optional.map may throw one. This is the single most-complained-about interaction in modern Java.

// does not compile
paths.stream().map(Files::readString).toList();

// the ceremony you actually write
paths.stream()
     .map(p -> {
         try { return Files.readString(p); }
         catch (IOException e) { throw new UncheckedIOException(e); }
     })
     .toList();

Teams commonly extract a small Unchecked.wrap(...) helper, or use a library such as Vavr. There is no language-level fix in Java 25.

Exception mapping#

C#Java
ExceptionException / RuntimeException
ArgumentExceptionIllegalArgumentException
ArgumentNullExceptionNullPointerException
InvalidOperationExceptionIllegalStateException
NotSupportedExceptionUnsupportedOperationException
NotImplementedExceptionUnsupportedOperationException
FormatExceptionNumberFormatException, DateTimeParseException
IndexOutOfRangeExceptionIndexOutOfBoundsException / ArrayIndexOutOfBoundsException
KeyNotFoundExceptionNoSuchElementException
NullReferenceExceptionNullPointerException
IOExceptionIOException
TimeoutExceptionTimeoutException
OperationCanceledExceptionInterruptedException
OverflowExceptionArithmeticException, only from *Exact methods
AggregateExceptionCompletionException / ExecutionException
StackOverflowExceptionStackOverflowError
OutOfMemoryExceptionOutOfMemoryError

try, catch, finally#

C#
try
{
    Do();
}
catch (IOException or SqlException ex)
{
    Log(ex);
    throw;              // rethrow, preserving the stack
}
finally
{
    Cleanup();
}
Java 25
try {
    doIt();
} catch (IOException | SQLException e) {   // multi-catch
    log(e);
    throw e;            // rethrow the same instance
} finally {
    cleanup();
}
C#JavaNote
catch (A or B)catch (A | B e)one catch for several types, Java 7
throw;throw e;Java rethrows the same object, so the trace is preserved
when (filter)no equivalentfilter inside the catch and rethrow
finallyfinallysame
usingtry-with-resourcessee below
Gotcha

Never return from a finally block. It discards any exception propagating from try or catch: silently, with no warning. C# makes this a compile error; Java allows it.

try {
    throw new IllegalStateException("real problem");
} finally {
    return 0;      // the exception vanishes
}

try-with-resources#

C#
using var conn = new SqlConnection(cs);
using var cmd = new SqlCommand(sql, conn);
conn.Open();
Java 25
try (var conn = dataSource.getConnection();
     var stmt = conn.prepareStatement(sql)) {
    ...
}   // closed in reverse order, even on exception
C#Java
IDisposableAutoCloseable
IAsyncDisposableno equivalent
Dispose()close()
using statementtry-with-resources
using declarationno equivalent, always a block
Note

AutoCloseable.close() declares throws Exception; Closeable narrows it to IOException. Implement AutoCloseable and override close() without a throws clause when your cleanup cannot fail; that spares every caller a catch block.

Gotcha

If the body throws and close() throws, Java keeps the body's exception as primary and attaches the close failure as a suppressed exception, retrievable via getSuppressed(). C# loses the original in the same situation. Worth knowing when a stack trace shows a "Suppressed:" section.

Cleanup without a using block#

Legacyfinalize()

Object.finalize() is deprecated for removal deprecated since Java 18. It is unpredictable, can resurrect objects, delays collection, and may never run at all. Do not override it, and do not follow older guides that recommend it for unmanaged resources.

The supported mechanism for a last-resort cleanup is Cleaner Java 9, which is roughly a SafeHandle plus a finalizer queue, and, as in .NET, the deterministic path should still be the primary one.

public class Buffer implements AutoCloseable {
    private static final Cleaner CLEANER = Cleaner.create();

    // MUST be a static class holding no reference to the outer instance,
    // or the object can never become unreachable
    private record State(long handle) implements Runnable {
        @Override public void run() { free(handle); }
    }

    private final State state;
    private final Cleaner.Cleanable cleanable;

    public Buffer(long size) {
        this.state = new State(allocate(size));
        this.cleanable = CLEANER.register(this, state);
    }

    @Override public void close() { cleanable.clean(); }   // deterministic path
}
Note

The rule is the same as .NET's: the Cleaner is a safety net for callers who forget, not the primary mechanism. Always give the type a close() and expect callers to use try-with-resources.

Check yourself: Gotchas
1Integer a = 128, b = 128; a == b. True or false?
False. Boxed integers are cached only from -128 to 127, so this works with small fixtures and fails in production. Always use equals.
2A service method throws a checked exception. Does the transaction roll back?
No. Spring rolls back for unchecked exceptions only. You need @Transactional(rollbackFor = Exception.class), and C# gives you no instinct for this at all.
3Why should every nested class be static unless you decide otherwise?
A non-static inner class holds a hidden reference to the enclosing instance, so it keeps that object alive. C# nested classes are always independent.
4Money in Java: which type, and how do you compare two values?
BigDecimal, and a.compareTo(b) == 0. Its equals compares scale as well as value, so 1.0 and 1.00 are not equal.

Maven vs csproj#

Part P7 · Build and tooling · Chapter 31· 3 min read

Maven is not MSBuild. MSBuild is a general build engine you script; Maven is a convention-driven lifecycle you configure. You do not tell Maven how to build; you tell it what your project is, and it applies a fixed sequence of phases.

The other shock: there is no mvn add. You edit pom.xml by hand. Everyone does.

The standard layout#

Maven's conventions are not defaults you can casually override; they are the point. Deviating costs you configuration everywhere.

my-service/
├── pom.xml
├── mvnw                      ← wrapper script; commit it
├── mvnw.cmd
└── src/
    ├── main/
    │   ├── java/             ← production source
    │   │   └── com/acme/billing/BillingApp.java
    │   └── resources/        ← config, templates; goes into the JAR
    │       └── application.yml
    └── test/
        ├── java/             ← test source, SAME package names
        └── resources/
Gotcha

Tests live under src/test/java but in the same package as the code they test. That is how they get package-private access. Java's replacement for InternalsVisibleTo. A test in a different package cannot see package-private members.

pom.xml against csproj#

Directory.Build.props + csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Serilog"
                      Version="4.1.0" />
  </ItemGroup>
</Project>
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.acme</groupId>
  <artifactId>billing</artifactId>
  <version>1.0.0-SNAPSHOT</version>

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

  <dependencies>
    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-api</artifactId>
      <version>2.0.16</version>
    </dependency>
  </dependencies>
</project>
csprojpom.xmlNote
PackageIdgroupId + artifactIdan org prefix plus a name, so vendors never collide
VersionversionSNAPSHOT means "in development"
TargetFrameworkmaven.compiler.releasethe bytecode target
PackageReferencedependency
ProjectReferencedependency on a sibling modulesame syntax
Directory.Build.propsa parent POMchildren inherit from it; it is not textually included
Directory.Packages.propsdependencyManagementcentral version pinning
nuget.configsettings.xmlrepositories and credentials
dotnet restoreno separate stepMaven downloads what it needs during the build

Coordinates#

A NuGet package has one identity: Serilog. A Maven artifact has two, plus a version:

<dependency>
  <groupId>org.springframework.boot</groupId>      <!-- who publishes it -->
  <artifactId>spring-boot-starter-webmvc</artifactId>  <!-- which artifact -->
  <version>4.0.0</version>
  <scope>compile</scope>                            <!-- optional -->
</dependency>
ScopeAvailable at.NET analogy
compileeverywhere; defaultnormal PackageReference
providedcompile and test, not packageda framework reference
runtimerun and test, not compilea runtime-only dependency
testtest onlya test-project-only reference
importonly in dependencyManagementfor BOMs

The lifecycle#

Running a phase runs every phase before it. This is the concept with no MSBuild equivalent, and once you internalise it the CLI makes sense.

validate → compile → test → package → verify → install → deploy
                                  ↑
                          mvn package runs everything up to here
Task.NETMaven
Compiledotnet buildmvn compile
Run testsdotnet testmvn test
Build a JAR / dlldotnet buildmvn package
Skip testsdotnet buildmvn package -DskipTests
Cleandotnet cleanmvn clean
Install locallydotnet pack + local feedmvn install
Publish to a repodotnet nuget pushmvn deploy
Run the appdotnet runmvn spring-boot:run
Dependency treedotnet list package --include-transitivemvn dependency:tree
Check for updatesdotnet outdatedmvn versions:display-dependency-updates
Note

mvn clean package is the command you will type a thousand times. It is the Java equivalent of a full rebuild, and unlike dotnet build it is genuinely common to run clean because Maven does less incremental work.

The wrapper#

Commit mvnw, mvnw.cmd and .mvn/ to your repository. They pin the Maven version and download it on first use, so CI and every developer build identically; there is no .NET equivalent because the SDK version is pinned by global.json instead.

./mvnw clean package        # always use the wrapper, not a system mvn
./mvnw -q test              # quiet
./mvnw -o package           # offline
./mvnw -pl billing-api -am package   # one module and what it depends on

A Spring Boot pom#

Spring Boot supplies a parent POM that pins compatible versions of several hundred libraries, so your dependencies mostly omit <version>. This is the biggest single quality-of-life feature in the Java build world.

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.5.0</version>
</parent>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.0.0</version>
</parent>

<dependencies>
  <!-- RENAMED in Boot 4: web -> webmvc -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>
Gotcha

spring-boot-starter-web became spring-boot-starter-webmvc in Boot 4.0, as part of a wider renaming to the pattern spring-boot-<technology>. It is the first line of your first pom.xml, so it is also the first thing that breaks when following a Boot 3 tutorial against Boot 4. See Migrating Boot 3 to 4.

Profiles and BOMs#

Two everyday Maven mechanisms with no single csproj equivalent.

A profile is a named block of configuration switched on by a flag, a property, or the environment. It is how one pom builds differently for CI, for a native image, or for a particular database.

<profiles>
  <profile>
    <id>integration</id>
    <activation>
      <property><name>env.CI</name></property>   <!-- on when $CI is set -->
    </activation>
    <build><plugins>
      <plugin>
        <artifactId>maven-failsafe-plugin</artifactId>
        <executions><execution><goals>
          <goal>integration-test</goal><goal>verify</goal>
        </goals></execution></executions>
      </plugin>
    </plugins></build>
  </profile>
</profiles>
./mvnw -Pintegration verify      # activate explicitly
./mvnw help:active-profiles      # which are actually on
Gotcha

Do not use profiles to build a different artifact for each environment. The point of a deployable JAR is that the same bytes go to every environment and the configuration differs at runtime, through Spring profiles and environment variables. Maven profiles are for varying the build, not the product.

A BOM, or bill of materials, is a pom that publishes nothing but a set of compatible versions. Importing one pins a whole family of libraries at once, which is what spring-boot-starter-parent does for you and what Directory.Packages.props does in .NET.

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>4.0.0</version>
      <type>pom</type>
      <scope>import</scope>          <!-- import, not inherit -->
    </dependency>
  </dependencies>
</dependencyManagement>
Note

scope=import with type=pom is the form to remember. Use it when you cannot inherit from spring-boot-starter-parent, which is common in a multi-module build that already has its own parent. It gives you the managed versions without the parent's plugin configuration.

Dependency conflicts#

Maven resolves a version conflict by nearest wins; the declaration closest to your project in the dependency tree, not the highest version. NuGet picks the highest. This surprises people.

./mvnw dependency:tree
./mvnw dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind

To force a version, declare it directly in your own <dependencies>, depth zero always wins, or pin it in <dependencyManagement>.

Gotcha

“Nearest wins” means adding an unrelated dependency can silently downgrade a transitive one. If something breaks after adding a library, run dependency:tree before anything else.

Gradle and multi-module builds#

Part P7 · Build and tooling · Chapter 32· 2 min read

Gradle is the other build tool. It is a real programming environment (Kotlin or Groovy) rather than a declarative document, so it is more powerful and easier to make a mess with. It is also substantially faster on large builds thanks to incremental tasks and a build cache.

A Maven reactor or Gradle multi-project build is your solution file: several modules built together, with dependencies between them.

Gradle against Maven#

AspectMavenGradle
FormatXML, declarativeKotlin or Groovy DSL, imperative
Learning curveshallow; conventions do the worksteeper; more rope
Speed on big buildsslowermuch faster: incremental + build cache
Customisationwrite or find a pluginwrite code inline
Predictabilityvery highdepends on the author
Ecosystem defaultenterprise, Spring tutorialsAndroid, newer projects
Note

If you are choosing: pick Maven. Its rigidity is a feature on a team, the Spring documentation assumes it, and a Maven build written by someone else is always readable. Choose Gradle when build times genuinely hurt, or the project is Android.

The same project, both ways#

Maven: pom.xml
<properties>
  <maven.compiler.release>25</maven.compiler.release>
</properties>

<dependencies>
  <dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>2.0.16</version>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>
Gradle: build.gradle.kts
plugins {
    java
    id("org.springframework.boot") version "4.0.0"
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

dependencies {
    implementation("org.slf4j:slf4j-api:2.0.16")
    testImplementation("org.junit.jupiter:junit-jupiter")
}
Maven scopeGradle configurationMeaning
compileimplementationused internally; NOT exposed to consumers
compile (exported)apiexposed to consumers transitively
providedcompileOnlycompile only, not packaged
runtimeruntimeOnlynot on the compile classpath
testtestImplementationtests only
Gotcha

implementation versus api has no Maven equivalent and is Gradle's best feature. implementation dependencies do not leak onto your consumers' compile classpath, which speeds up builds and stops accidental coupling. In Maven every compile dependency is transitively visible, which is how projects accumulate accidental dependencies on things they never declared.

Command mapping#

TaskMavenGradle
Compilemvn compile./gradlew classes
Testmvn test./gradlew test
Packagemvn package./gradlew build
Cleanmvn clean./gradlew clean
Run a Boot appmvn spring-boot:run./gradlew bootRun
Dependency treemvn dependency:tree./gradlew dependencies
One modulemvn -pl mod -am package./gradlew :mod:build
Skip tests-DskipTests-x test
List tasksn/a./gradlew tasks

Multi-module: your solution file#

A Maven aggregator POM lists modules; Gradle uses settings.gradle.kts. The shape is the same as a .NET solution with project references.

billing/                       ← aggregator; the "solution"
├── pom.xml                    ← <modules> list + <dependencyManagement>
├── billing-domain/
│   └── pom.xml                ← no dependencies on siblings
├── billing-persistence/
│   └── pom.xml                ← depends on billing-domain
└── billing-api/
    └── pom.xml                ← depends on both; the deployable
<!-- billing/pom.xml; the aggregator -->
<packaging>pom</packaging>

<modules>
  <module>billing-domain</module>
  <module>billing-persistence</module>
  <module>billing-api</module>
</modules>

<!-- versions declared once, inherited by every module -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.acme</groupId>
      <artifactId>billing-domain</artifactId>
      <version>${project.version}</version>
    </dependency>
  </dependencies>
</dependencyManagement>
.NETMavenGradle
Solution (.sln)aggregator pom with <modules>settings.gradle.kts
ProjectReferencea normal dependency on the siblingproject(":billing-domain")
Directory.Packages.props<dependencyManagement>version catalog (libs.versions.toml)
Build the solutionmvn package at the root./gradlew build at the root
Build one projectmvn -pl billing-api -am package./gradlew :billing-api:build
Note

-am means “also make”, build the dependencies of the named module too. Without it, Maven expects the siblings to already be installed in your local repository, and you get a confusing “could not resolve” error for your own code. -pl module -am is the incantation worth memorising.

Where artifacts come from#

.NETJava
nuget.orgMaven Central (repo1.maven.org)
~/.nuget/packages~/.m2/repository
nuget.config~/.m2/settings.xml
Azure Artifacts / GitHub PackagesNexus, Artifactory, GitHub Packages
packages.lock.jsonno true equivalent, pin versions explicitly
Gotcha

Maven has no lockfile by default. Reproducibility comes from pinning exact versions and never using version ranges. SNAPSHOT versions are explicitly mutable, 1.0.0-SNAPSHOT is re-downloaded and can change under you. Never depend on someone else's SNAPSHOT from a release build.

Modules, JPMS and the missing internal#

Part P7 · Build and tooling · Chapter 33· 2 min read

The Java Platform Module System Java 9 is the nearest thing to an assembly boundary. A module declares what it exports and what it requires, and the JVM enforces it.

Most applications do not use it. It is essential for the JDK itself and for libraries, useful for jlink, and largely optional for a Spring Boot service. Know what module-info.java means when you see one; do not feel obliged to write one.

The problem it solves#

Before Java 9 the classpath was one flat namespace. Any class could reach any other public class in any JAR, public meant “public to the entire world”, and two JARs containing the same class name silently shadowed each other, the notorious “JAR hell”. C# never had this because assemblies are real boundaries with internal inside them.

// src/main/java/module-info.java
module com.acme.billing {

    requires java.sql;                       // depend on a JDK module
    requires transitive com.acme.domain;     // and re-export it to my consumers

    exports com.acme.billing.api;            // public to everyone
    exports com.acme.billing.spi
        to com.acme.plugins;                 // public only to a named module

    // com.acme.billing.internal is NOT exported: unreachable outside,
    // even though its classes are public

    provides PaymentProvider
        with com.acme.billing.internal.StripeProvider;   // service loading
}
DirectiveMeans.NET analogy
requires XI depend on module Xassembly reference
requires transitive Xand my consumers get X tooa public dependency
exports ppackage p is publicpublic types in the assembly
exports p to Mpackage p is visible only to MInternalsVisibleTo, inverted
opens pallow deep reflection into pneeded by Spring, Hibernate, Jackson
uses / providesServiceLoader wiringDI at the platform level
C#: per type, inside an assembly
// visible only within this assembly
internal class StripeGateway { }

// and grant one friend assembly access
[assembly: InternalsVisibleTo("Acme.Billing.Tests")]
Java: per package, inside a module
// module-info.java: the package is simply not exported,
// so its public types are unreachable outside the module
module com.acme.billing {
    exports com.acme.billing.api;
    // com.acme.billing.internal stays hidden
}

This is the closest thing to internal#

GoalC#Java
Public to my code, hidden outsideinternala non-exported package in a module
Public to everyonepublicexported package + public type
Visible to my testsInternalsVisibleTotests in the same package
Visible to one other componentInternalsVisibleTo("X")exports p to X
Gotcha

The granularity differs in a way that matters. C#'s internal is per type or member. JPMS export is per package, all or nothing. You cannot export a package and hide one class in it, so a module-based design forces you to separate API and implementation into different packages, which is good discipline but a real restructuring.

Classpath vs module path#

ClasspathModule path
Flat namespaceyesno
Access enforcednoyes
Split packages allowedyesno
Needs module-infonoyes (or it becomes an automatic module)
Most Spring Boot appsthis onerarely
Note

A JAR without a module-info.java placed on the module path becomes an automatic module: its name is derived from the filename, and it exports everything and reads everything. That is the migration bridge, and it is where most of the ecosystem still sits.

Why frameworks need opens#

Gotcha

Spring, Hibernate and Jackson all use deep reflection to read private fields. Under JPMS that is blocked unless the package is open. If you modularise an application that uses them, expect:

InaccessibleObjectException: Unable to make field private java.lang.String
com.acme.Customer.name accessible: module com.acme.billing does not "opens
com.acme" to unnamed module @0x1b6d3586

The fix is opens com.acme.model to com.fasterxml.jackson.databind;, or open module to open everything. This friction is the main reason ordinary applications skip JPMS entirely.

Flags you will meet#

FlagDoes
--add-opens M/p=ALL-UNNAMEDgrant deep reflection into a JDK package
--add-exports M/p=ALL-UNNAMEDgrant compile/runtime access to a non-exported package
--add-modules Madd a module not required transitively
--illegal-access=permitremoved in Java 17; no longer available
Gotcha

Strong encapsulation of JDK internals is on by default since Java 17. Libraries that reached into sun.misc.Unsafe or java.lang.reflect internals now fail hard rather than warn. If you are upgrading an old application and see InaccessibleObjectException from a library, the short-term fix is --add-opens; the real fix is upgrading the library.

When to bother#

SituationUse JPMS?
Spring Boot service in a containerno; the fat JAR is the boundary
Library published to Maven Centralyes, publish at least an Automatic-Module-Name
Desktop app shipped with jlinkyes, jlink requires modules
Large internal platform with many teamsmaybe, enforced boundaries help
Anything on Java 8not available
Note

Even if you skip module-info.java, add an Automatic-Module-Name entry to your JAR manifest when publishing a library. It costs one line and gives consumers a stable module name if they do modularise.

Testing#

Part P7 · Build and tooling · Chapter 34· 4 min read

JUnit 5 is xUnit. Mockito is Moq. AssertJ is FluentAssertions. Testcontainers is literally the same project; the .NET library is a port.

The stack is not in the JDK, so it comes from dependencies, but Spring Boot's spring-boot-starter-test bundles all of it in one line.

The stack#

.NETJavaNote
xUnit / NUnit / MSTestJUnit 5 (Jupiter)the default by a wide margin
[Fact]@Test
[Theory] + [InlineData]@ParameterizedTest + @ValueSource
[Trait]@Tag
Constructor / IDisposable@BeforeEach / @AfterEach
IClassFixture@BeforeAll / @AfterAllruns once per class; the method must be static
Assert.EqualassertEquals, or AssertJassertEquals takes expected first, then actual
FluentAssertionsAssertJassertThat(x).isEqualTo(y)
MoqMockito
NSubstituteMockito
AutoFixtureInstancio, EasyRandom
BogusJava Faker, Datafaker
TestcontainersTestcontainerssame project, JVM original
WireMockWireMocksame project, JVM original
BenchmarkDotNetJMH
FsCheckjqwikproperty-based testing
ArchUnitNETArchUnitarchitecture rules as tests
coverletJaCoCocoverage

A test#

xUnit
public class MoneyTests
{
    [Fact]
    public void Add_SumsAmounts()
    {
        var a = new Money(10m, "GBP");
        var b = new Money(5m, "GBP");

        var result = a.Add(b);

        result.Amount.Should().Be(15m);
    }
}
JUnit 5
class MoneyTest {

    @Test
    void add_sumsAmounts() {
        var a = new Money(new BigDecimal("10"), "GBP");
        var b = new Money(new BigDecimal("5"), "GBP");

        var result = a.add(b);

        assertThat(result.amount())
            .isEqualByComparingTo("15");
    }
}
Gotcha

Test classes and methods should be package-private, not public. JUnit 5 does not require public, and the convention is to omit the modifier, which also keeps them out of your published API surface. Older JUnit 4 required public, so you will see it in existing code.

Gotcha

assertEquals(expected, actual) takes expected first, the opposite of some .NET habits and the reverse of how the failure message reads if you get it backwards. AssertJ's assertThat(actual).isEqualTo(expected) removes the ambiguity, which is one reason most Java teams use it in preference to raw JUnit assertions.

Parameterised tests#

C#
[Theory]
[InlineData(1, 1, 2)]
[InlineData(2, 3, 5)]
public void Adds(int a, int b, int expected)
    => Assert.Equal(expected, Add(a, b));
Java 25
@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "2, 3, 5"
})
void adds(int a, int b, int expected) {
    assertThat(add(a, b)).isEqualTo(expected);
}
SourceProvides
@ValueSource(ints = {1,2,3})one primitive argument
@CsvSource({"a,1", "b,2"})several arguments inline
@CsvFileSource(resources = "/data.csv")from a file
@MethodSource("provider")from a static method returning a Stream
@EnumSource(Status.class)every enum constant
@NullAndEmptySourcenull and empty string

Mocking#

Moq
var repo = new Mock<IOrderRepo>();
repo.Setup(r => r.Find(7))
    .Returns(new Order(7));

var svc = new OrderService(repo.Object);
svc.Process(7);

repo.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);
Mockito
OrderRepo repo = mock(OrderRepo.class);
when(repo.find(7))
    .thenReturn(new Order(7));

var svc = new OrderService(repo);
svc.process(7);

verify(repo, times(1)).save(any(Order.class));
MoqMockito
new Mock<T>()mock(T.class)
mock.Objectthe mock itself; no .Object
Setup(...).Returns(v)when(...).thenReturn(v)
Setup(...).Throws(e)when(...).thenThrow(e)
Verify(..., Times.Once)verify(mock, times(1))
It.IsAny<T>()any(T.class)
It.Is<T>(p)argThat(predicate)
CallbackthenAnswer(invocation -> ...)
MockBehavior.StrictMockito.mock(T.class, RETURNS_SMART_NULLS)
Gotcha

Mockito cannot mock final classes or static methods by default; the same limitation Moq has, for the same reason. The inline mock maker (default since Mockito 5) can handle finals, and mockStatic exists but should be a last resort. If a class is hard to mock, that is usually a design signal.

Testcontainers#

If you have used Testcontainers in .NET, this is the same library; the JVM version is the original. Real dependencies in Docker for integration tests.

@Testcontainers
class OrderRepositoryTest {

    @Container
    static PostgreSQLContainer<?> db =
        new PostgreSQLContainer<>("postgres:16")
            .withDatabaseName("billing");

    @Test
    void findsSavedOrder() {
        // db.getJdbcUrl() points at a real Postgres
    }
}
Note

Spring Boot integrates directly: @ServiceConnection on a container field wires the datasource properties automatically, with no manual property overriding. See Testing Spring Boot.

WireMock: faking an HTTP dependency#

The same project as WireMock.Net, and the JVM original. It stands up a real HTTP server so your client code is exercised end to end rather than mocked away.

WireMock.Net
var server = WireMockServer.Start();
server
  .Given(Request.Create()
      .WithPath("/rates/GBPUSD").UsingGet())
  .RespondWith(Response.Create()
      .WithStatusCode(200)
      .WithBodyAsJson(new { rate = 1.27 }));
WireMock
@WireMockTest
class RatesClientTest {

  @Test
  void fetchesRate(WireMockRuntimeInfo wm) {
    stubFor(get("/rates/GBPUSD")
        .willReturn(okJson("{\"rate\":1.27}")));

    var client = new RatesClient(wm.getHttpBaseUrl());
    assertThat(client.fetch("GBPUSD").rate())
        .isEqualTo(1.27);
  }
}
Note

Spring Boot has a lighter alternative for pure client tests: @RestClientTest with MockRestServiceServer, which intercepts at the RestClient level and needs no port. Use WireMock when you want a real socket, a delay, or a fault injected.

JMH against BenchmarkDotNet#

You cannot benchmark Java with a stopwatch loop. The JIT compiles hot code after some thousands of iterations and eliminates work whose result you discard, so a naive loop measures the optimiser, not your code. JMH handles warmup, forking and dead-code elimination.

BenchmarkDotNet
[MemoryDiagnoser]
public class ParseBench
{
    [Params(10, 1000)]
    public int N;

    [Benchmark]
    public int Sum() => Enumerable.Range(0, N).Sum();
}

BenchmarkRunner.Run<ParseBench>();
JMH
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
@Fork(2)
public class ParseBench {

    @Param({"10", "1000"})
    public int n;

    @Benchmark
    public int sum() {
        return IntStream.range(0, n).sum();
    }
}
Gotcha

Return the result, or JMH's Blackhole must consume it. A benchmark whose value is unused can be deleted entirely by the JIT, and you will measure an empty loop at impossible speed. This failure mode is far more aggressive on the JVM than on .NET, which is the main reason hand-rolled Java benchmarks are usually wrong.

ArchUnit: architecture rules as tests#

Same project as ArchUnitNET. Layering rules become ordinary failing tests rather than review comments.

@AnalyzeClasses(packages = "com.acme.billing")
class ArchitectureTest {

    @ArchTest
    static final ArchRule domainIsPure =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("..api..", "..persistence..");

    @ArchTest
    static final ArchRule controllersAreThin =
        classes().that().areAnnotatedWith(RestController.class)
            .should().onlyDependOnClassesThat()
            .resideOutsideOfPackage("..persistence..");

    @ArchTest
    static final ArchRule noFieldInjection =
        noFields().should().beAnnotatedWith(Autowired.class);
}

JaCoCo against coverlet#

Coverage is a Maven plugin rather than a CLI tool, and it can fail the build on a threshold.

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <executions>
    <execution><goals><goal>prepare-agent</goal></goals></execution>
    <execution>
      <id>check</id>
      <goals><goal>check</goal></goals>
      <configuration>
        <rules><rule><limits><limit>
          <counter>LINE</counter>
          <minimum>0.80</minimum>
        </limit></limits></rule></rules>
      </configuration>
    </execution>
  </executions>
</plugin>
.NETJavaNote
coverletJaCoCoruns as a java agent during the test phase
ReportGeneratorjacoco:reportwrites HTML into target/site
threshold gate in CIjacoco:checkfails the build itself
Stryker.NETPITmutation testing; PIT is the JVM original

JUnit 5 and JUnit 6#

Gotcha

Spring Framework 7 baselines JUnit 6. If you are on Spring Boot 4 you are on JUnit 6; on Boot 3 you are on JUnit 5. The programming model is continuous: @Test, @ParameterizedTest and the extension API work as before.

Two things do change. JUnit 6 raises the baseline to Java 17, and it unifies the version numbers: Platform, Jupiter and Vintage now share one number, where previously the Platform was on 1.x while Jupiter was on 5.x. If you pin versions by hand rather than inheriting them from the Boot parent, that is the line to fix. junit-platform-runner and junit-platform-jfr are also gone.

LegacyJUnit 4

Recognisable by org.junit.Test rather than org.junit.jupiter.api.Test, public test classes, @Before/ @After instead of @BeforeEach/@AfterEach, and @RunWith instead of @ExtendWith. The junit-vintage-engine runs JUnit 4 tests inside JUnit 5, which is how large codebases migrate incrementally.

Naming#

Java has no [DisplayName]-free convention as strong as .NET's, but two styles dominate: methodUnderTest_condition_expectedResult, or a readable sentence via @DisplayName.

@Test
@DisplayName("rejects an order when the cart is empty")
void rejectsEmptyCart() { ... }
Check yourself: Build and tooling
1You added a dependency and an unrelated library broke. What is the first command?
mvn dependency:tree. Maven resolves conflicts by nearest wins, not highest version, so a new dependency can silently downgrade a transitive one.
2Where do tests live, and why does the package matter?
src/test/java, in the same package as the code under test. That is how they get package-private access; Java has no InternalsVisibleTo.
3Why can a hand-rolled timing loop give a Java benchmark that is orders of magnitude too fast?
The JIT compiles hot code after thousands of iterations and deletes work whose result you discard. Use JMH, which handles warmup, forking and dead-code elimination.
4What does assertEquals(actual, expected) do?
Passes or fails correctly, but reports the failure backwards. The order is expected first. AssertJ's assertThat(actual).isEqualTo(expected) removes the ambiguity.

Your library, translated#

Part P8 · Ecosystem · Chapter 35· 5 min read

One page, everything you reach for in .NET and what a Java team would use instead. Every row is indexed by the search palette, so ⌘K and the .NET name will bring you here.

Where a row says “same project”, the .NET library is a port of the Java original. Testcontainers, WireMock and Quartz all started on the JVM.

Web and API#

.NETJavaNote
ASP.NET CoreSpring Bootoverwhelmingly the default
Minimal APIsSpring Boot, Javalin, HelidonJavalin is closest in spirit
Kestrelembedded Tomcat, Netty, JettyTomcat is the Boot default
IIS hostinga JAR with an embedded serverno external server needed
Swashbuckle / NSwagspringdoc-openapigenerates OpenAPI from controllers
SignalRSpring WebSocket + STOMPno direct 1:1 equivalent
gRPC for .NETgrpc-java
YARPSpring Cloud Gateway
BlazorVaadin, Thymeleaf, JTEdifferent models entirely
Razor / Razor PagesThymeleaf, JTE, Freemarkerserver-side templating

Data access#

.NETJavaNote
Entity Framework CoreHibernate / Spring Data JPAthe default ORM
DapperJdbcTemplate, JDBIyou write the SQL; it maps rows to objects
LINQ to SQL, IQueryablejOOQtyped SQL DSL generated from the schema
ADO.NETJDBCthe raw layer
EF MigrationsFlyway, LiquibaseFlyway is plain SQL files
DbContextEntityManager, or a Spring Data repository
IDbConnectionDataSource, Connection
Npgsql / SqlClientthe PostgreSQL / MSSQL JDBC driver
StackExchange.RedisLettuce, JedisLettuce is the Spring default
MongoDB.Drivermongodb-driver-sync
Elasticsearch.Netco.elastic.clients

Serialisation#

.NETJavaNote
System.Text.JsonJacksonthe default
Newtonsoft.JsonJackson, GsonGson is simpler, less capable
JsonSerializer.SerializeobjectMapper.writeValueAsString
[JsonPropertyName]@JsonProperty
[JsonIgnore]@JsonIgnore
JsonSerializerOptionsObjectMapper configuration
protobuf-netprotobuf-java
MessagePackmsgpack-java
YamlDotNetSnakeYAML, Jackson YAML
CsvHelperOpenCSV, Jackson CSV
Gotcha

Spring Boot 4 moves to Jackson 3, whose root package is tools.jackson rather than com.fasterxml.jackson. Every import line changes, and Boot renamed several of its own types with it, @JsonComponent became @JacksonComponent, @JsonMixin became @JacksonMixin. See Migrating Boot 3 to 4.

DI, logging, config#

.NETJavaNote
Microsoft.Extensions.DependencyInjectionSpring, or Jakarta CDI
AutofacSpring
ILogger<T>SLF4J Loggerthe interface you code against; Logback implements it
SerilogLogback, Log4j2Logback is the Boot default
NLogLog4j2
structured loggingLogstash encoder, or Boot structured logging
IConfigurationSpring Environment
appsettings.jsonapplication.yml / application.properties
IOptions<T>@ConfigurationProperties
User Secretsa local profile, or Vault
Azure App ConfigurationSpring Cloud Config

Resilience, messaging, scheduling#

.NETJavaNote
PollyResilience4jthe Boot 3 answer; a separate library
PollySpring Framework @Retryablethe Boot 4 answer; retry moved into the framework
HttpClientFactoryRestClient, HTTP interface clients
Refit@HttpExchange interfacesdeclarative HTTP
MediatRSpring ApplicationEvents, Axonno single library covers the same ground
MassTransit / NServiceBusSpring Integration, Camel, Axon
RabbitMQ.ClientSpring AMQP
Confluent.KafkaSpring Kafka
HangfireQuartz, Spring @ScheduledQuartz is the Java original
Quartz.NETQuartzsame project
Azure FunctionsSpring Cloud Function

Testing and quality#

.NETJavaNote
xUnit / NUnitJUnit 5 (JUnit 6 on Boot 4)
Moq / NSubstituteMockito
FluentAssertionsAssertJ
TestcontainersTestcontainerssame project
WireMock.NetWireMocksame project
AutoFixtureInstancio
BogusDatafaker
BenchmarkDotNetJMH
coverlet + ReportGeneratorJaCoCo
SonarAnalyzerSonarQubesame product
Roslyn analyzersError Pronecompile-time bug detection
StyleCopCheckstyle
dotnet formatSpotless
ArchUnitNETArchUnitsame project

Observability and diagnostics#

.NETJavaNote
dotnet-countersJFR, Micrometer
dotnet-trace / PerfViewJFR + JDK Mission ControlJFR is built into the JDK
dotnet-dumpjmap, jcmd
OpenTelemetry .NETOpenTelemetry Java agentzero-code instrumentation
App Insights / PrometheusMicrometer + PrometheusMicrometer is the metrics interface; the backend plugs in
HealthChecksSpring Boot Actuator
Visual Studio Profilerasync-profiler, JMCasync-profiler is excellent
Note

JFR, Java Flight Recorder, has no direct .NET equivalent and is worth learning early. It is a production-grade, always-on profiler built into the JDK with roughly 1% overhead. jcmd <pid> JFR.start on a running process gives you allocation, lock contention, I/O and CPU data with no restart. See The JVM at runtime.

Utility libraries#

.NETJavaNote
LINQStreamsbuilt in
Humanizerno direct equivalent
AutoMapperMapStructcompile-time, so mismatches are build errors
FluentValidationJakarta Bean Validation@NotNull, @Size, custom validators
System.Collections.ImmutableList.of, Guava immutables
Nito.AsyncExvirtual threadsthe problems it solves largely disappear
CommandLineParserpicocli
Scriban / HandlebarsThymeleaf, Mustache
NodaTimejava.timethe ancestor, built in
Guard clauses librariesObjects.requireNonNull, Guava Preconditions

Jackson against System.Text.Json#

The single most-used library in Java after the JDK itself. Spring Boot configures it for you, so most of the time you only meet the annotations.

System.Text.Json
var opts = new JsonSerializerOptions {
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    DefaultIgnoreCondition =
        JsonIgnoreCondition.WhenWritingNull
};

string json = JsonSerializer.Serialize(order, opts);
var back = JsonSerializer.Deserialize<Order>(json, opts);
Jackson
ObjectMapper mapper = JsonMapper.builder()
    .addModule(new JavaTimeModule())
    .serializationInclusion(Include.NON_NULL)
    .build();

String json = mapper.writeValueAsString(order);
Order back = mapper.readValue(json, Order.class);
Attributes
public record Order(
    [property: JsonPropertyName("order_id")] long Id,
    [property: JsonIgnore] string Secret,
    [property: JsonConverter(typeof(MoneyConverter))]
    decimal Total);
Annotations
public record Order(
    @JsonProperty("order_id") long id,
    @JsonIgnore String secret,
    @JsonSerialize(using = MoneySerializer.class)
    BigDecimal total) { }
System.Text.JsonJacksonNote
JsonSerializer.Serializemapper.writeValueAsString
JsonSerializer.Deserialize<T>mapper.readValue(s, T.class)erasure: pass the class
Deserialize a generic typenew TypeReference<List<T>>() { }note the trailing braces
[JsonPropertyName]@JsonProperty
[JsonIgnore]@JsonIgnore
[JsonConverter]@JsonSerialize / @JsonDeserialize
JsonSerializerOptionsJsonMapper.builder()
CamelCase policyPropertyNamingStrategies.LOWER_CAMEL_CASEJava is already camelCase
DateTime supportJavaTimeModulemust be registered, or dates fail
Unknown members ignoredFAIL_ON_UNKNOWN_PROPERTIESJackson FAILS by default
Gotcha

Two defaults differ from .NET and both bite on day one. Jackson throws on an unknown JSON property unless you disable FAIL_ON_UNKNOWN_PROPERTIES, where System.Text.Json ignores it. And java.time types need the JavaTimeModule registered or they serialise as unreadable objects. Spring Boot registers the module and relaxes the failure for you, which is why this only bites when you build an ObjectMapper by hand.

MapStruct against AutoMapper#

The difference is when the mapping is resolved. AutoMapper matches properties by reflection at runtime; MapStruct generates a plain Java class at compile time, so a missing or mistyped field is a build error rather than a production surprise.

AutoMapper
public class OrderProfile : Profile
{
    public OrderProfile()
    {
        CreateMap<Order, OrderDto>()
            .ForMember(d => d.CustomerName,
                o => o.MapFrom(s => s.Customer.Name));
    }
}

var dto = _mapper.Map<OrderDto>(order);
MapStruct
@Mapper(componentModel = "spring")
public interface OrderMapper {

    @Mapping(target = "customerName",
             source = "customer.name")
    OrderDto toDto(Order order);
}

// injected like any bean
var dto = orderMapper.toDto(order);
Note

componentModel = "spring" makes the generated implementation a Spring bean, so you inject the interface and never see the generated class. Look in target/generated-sources to read it: it is ordinary field-by-field assignment, with no reflection and no startup cost.

Java's built-in serialization, and why to avoid it#

Java has a native binary serialization mechanism, older than JSON's popularity, built into the language through the Serializable marker interface. You will meet it, and you should not choose it.

class Order implements Serializable {          // a marker: no methods
    private static final long serialVersionUID = 1L;
    private transient String secret;           // excluded from the bytes
}

try (var out = new ObjectOutputStream(Files.newOutputStream(p))) {
    out.writeObject(order);                    // opaque binary
}
Gotcha

Deserializing untrusted data is remote code execution. The format encodes which classes to instantiate, and the reader constructs them before you can inspect anything. Gadget chains assembled from ordinary library classes turn a byte array into arbitrary code. This is the single most exploited class of Java vulnerability, and it is the reason so many Java CVEs look alike.

If you must read serialized data you do not control, use a deserialization filter, added in Java 9 as JEP 290, to allow-list the classes permitted to appear:

java -Djdk.serialFilter='com.acme.**;java.base/*;!*' -jar app.jar
ProblemDetail
Securityuntrusted input can execute code; see above
VersioningserialVersionUID must be managed by hand, or old data stops loading
Couplingthe wire format is your private field layout, so refactoring breaks it
Portabilityonly Java can read it; nothing else on the wire understands it
Bypasses constructorsobjects are reconstructed without running your invariants
Note

Use Jackson. JSON is inspectable, versionable, language-neutral and does not instantiate arbitrary classes. Where you genuinely need a compact binary format, use Protobuf, Avro or CBOR, all of which have schemas and none of which have this hazard.

Serializable is still required in a few places, notably session objects in some servlet containers and older RMI or JMS code. Implementing the interface is harmless. Calling readObject on bytes from outside your system is not.

SLF4J and Logback against ILogger and Serilog#

SLF4J is the interface, Logback is the implementation. You always code against SLF4J, exactly as you code against ILogger<T> rather than against Serilog.

ILogger + Serilog
private readonly ILogger<OrderService> _log;

_log.LogInformation(
    "Placed {OrderId} for {Total}", id, total);

using (_log.BeginScope(new Dictionary<string, object>
       { ["CorrelationId"] = cid }))
{
    _log.LogWarning("slow");
}
SLF4J + Logback
private static final Logger log =
    LoggerFactory.getLogger(OrderService.class);

log.info("Placed {} for {}", id, total);

MDC.put("correlationId", cid);
try {
    log.warn("slow");
} finally {
    MDC.remove("correlationId");
}
Gotcha

SLF4J placeholders are positional {}, not named. There is no {OrderId}, so structured logging needs MDC or a structured encoder. This is a genuine regression from Serilog, and it is why MDC matters more in Java than logging scopes do in .NET.

# application.yml covers most of what a Serilog config file would
logging:
  level:
    root: INFO
    com.acme.billing: DEBUG
  pattern:
    console: "%d{HH:mm:ss} %-5level [%X{correlationId}] %logger{36} - %msg%n"

Lombok against source generators#

C#
// records and primary constructors cover most of it
public record Customer(string Name, int Age);

public class Order(IOrderRepo repo)
{
    private readonly IOrderRepo _repo = repo;
}
Java 25
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
@EqualsAndHashCode
public class Customer {
    private String name;
    private int age;
}

@RequiredArgsConstructor        // one ctor for all final fields
public class OrderService {
    private final OrderRepo repo;
}
Note

For immutable data a record now beats Lombok and needs no dependency. Lombok still earns its place for mutable JPA entities, which cannot be records, and for @Slf4j, which declares the logger field for you. @Builder is the other common survivor.

Build and deploy#

.NETJavaNote
MSBuildMaven, Gradle
NuGetMaven Central
dotnet publishmvn packageproduces a fat JAR for Boot
Self-contained deploymentfat JAR, or jlink
NativeAOTGraalVM native-image
ReadyToRunAOT cache, CDSJava 24/25 improved this substantially
dotnet toolJBang, or a shaded JAR
global.json.sdkmanrc, Maven toolchains

Spring Boot orientation#

Part P9 · ASP.NET Core to Spring Boot · Chapter 36· 3 min read

Spring Boot is ASP.NET Core: an opinionated application host with dependency injection, configuration, an embedded web server, health endpoints and a production-ready build. If you know Program.cs, builder.Services and appsettings.json, you already know the shape.

The difference is that Spring Boot infers most of its wiring from what is on the classpath. Add the JPA starter and it configures a datasource, an entity manager and a transaction manager without you writing a line.

Which version#

LineSpring FrameworkBaselinesUse when
Boot 3.xFramework 6Java 17, Jakarta EE 10existing systems; most tutorials
Boot 4.xFramework 7Java 17 (25 recommended), Jakarta EE 11, Kotlin 2.2, GraalVM 25, JUnit 6greenfield
Note

Spring Boot releases a minor version every six months, in May and November. A minor is supported for at least 12 months and a major for at least three years from release, provided you are on a supported minor. So staying current means one planned upgrade a year, not one every six months.

Boot 4.0 was the first of the 4.x line; the line has since moved on, so check spring.io/projects/spring-boot for the current release rather than pinning to the version in any tutorial, including this one.

This book shows both. Where an API differs you get a tabbed block; where something is new in 4 it carries a Boot 4 badge.

The entry point#

ASP.NET Core 8+
// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddDbContext<AppDb>();

var app = builder.Build();
app.MapControllers();
app.Run();
Spring Boot
// BillingApplication.java
@SpringBootApplication
public class BillingApplication {

    public static void main(String[] args) {
        SpringApplication.run(BillingApplication.class, args);
    }
}
// services are discovered by annotation, not registered here
Gotcha

There is no central registration list. @SpringBootApplication implies @ComponentScan over the package containing the class and everything below it. A component in a sibling package is invisible.

Put the application class in your root package, com.acme.billing, and everything under it is scanned. This one convention causes more “bean not found” confusion than anything else in Spring.

Starters#

A starter is a dependency that pulls in a coherent set of libraries and triggers auto-configuration. It is the unit of “I want this capability”.

CapabilityBoot 3.5 starterBoot 4.0 starter
Web MVCspring-boot-starter-webspring-boot-starter-webmvc
Reactive webspring-boot-starter-webfluxspring-boot-starter-webflux
JPAspring-boot-starter-data-jpaspring-boot-starter-data-jpa
Securityspring-boot-starter-securityspring-boot-starter-security
OAuth2 clientspring-boot-starter-oauth2-clientspring-boot-starter-security-oauth2-client
Validationspring-boot-starter-validationspring-boot-starter-validation
Testingspring-boot-starter-testspring-boot-starter-test
Actuatorspring-boot-starter-actuatorspring-boot-starter-actuator
SOAP servicesspring-boot-starter-web-servicesspring-boot-starter-webservices
OpenTelemetryn/aspring-boot-starter-opentelemetry
Gotcha

Boot 4 renamed modules to a uniform spring-boot-<technology> pattern, with root packages org.springframework.boot.<technology>. The rename you hit first is starter-web becoming starter-webmvc, line one of every tutorial you will read.

Auto-configuration#

This is the concept with no ASP.NET Core equivalent. Each starter ships conditional configuration classes that activate based on what is present.

// roughly what Boot does internally, simplified
@AutoConfiguration
@ConditionalOnClass(DataSource.class)          // the JDBC classes are present
@ConditionalOnMissingBean(DataSource.class)    // and you have not defined your own
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
    @Bean
    DataSource dataSource(DataSourceProperties props) { ... }
}
Note

The rule that matters: anything you define yourself wins. Declare your own DataSource bean and the auto-configured one steps aside, because of @ConditionalOnMissingBean. You override by defining, not by unregistering.

# what did Boot configure, and why?
./mvnw spring-boot:run -Ddebug
# prints a "CONDITIONS EVALUATION REPORT": matched and unmatched, with reasons

Project layout#

src/main/java/com/acme/billing/
├── BillingApplication.java     ← must be in the root package
├── api/                        ← @RestController
├── domain/                     ← entities, value objects, domain services
├── persistence/                ← repositories
└── config/                     ← @Configuration classes

src/main/resources/
├── application.yml             ← the appsettings.json
├── application-dev.yml         ← per-profile overrides
└── db/migration/               ← Flyway SQL

Running it#

Task.NETSpring Boot
Rundotnet run./mvnw spring-boot:run
Run with a profileASPNETCORE_ENVIRONMENT=Development--spring.profiles.active=dev
Build a deployabledotnet publish./mvnw package
Run the artifactdotnet MyApp.dlljava -jar target/billing-1.0.0.jar
Watch and reloaddotnet watchspring-boot-devtools
Default port5000 / 50018080
Change the port--urls--server.port=9090
Note

./mvnw package on a Boot project produces an executable fat JAR containing your code, every dependency and an embedded Tomcat. java -jar runs it anywhere a JVM exists. It is the closest analogue to a self-contained dotnet publish, and it is why Java web apps no longer need an application server.

The alternatives#

FrameworkPitchCompared to
Spring Bootthe default; vast ecosystemASP.NET Core
Quarkuscompile-time DI, fast startup, native-firstASP.NET Core + NativeAOT
Micronautcompile-time DI, no runtime reflectionsimilar to Quarkus
Javalintiny, explicit, no magicMinimal APIs
HelidonOracle, MicroProfile and NimaMinimal APIs

Quarkus and Micronaut do their dependency injection at compile time via annotation processors, which removes Spring's classpath scanning and reflection at startup. That gives startup times in the tens of milliseconds and much better native-image support; the same trade NativeAOT makes. Spring Boot has closed much of this gap with AOT processing, but not all of it.

Dependency injection and configuration#

Part P9 · ASP.NET Core to Spring Boot · Chapter 37· 3 min read

Spring's container is IServiceCollection with discovery instead of registration. You annotate a class @Service and Spring finds it; you take a constructor parameter and Spring injects it.

Configuration is application.yml instead of appsettings.json, profiles instead of environments, and @ConfigurationProperties instead of IOptions<T>.

Registration by annotation#

C#
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IClock, SystemClock>();

public class OrderService(IOrderRepo repo) : IOrderService
{
}
Java 25
// no registration line; the annotation is the registration
@Service
public class OrderService {

    private final OrderRepo repo;

    // repo is injected automatically
    public OrderService(OrderRepo repo) {
        this.repo = repo;
    }
}
AnnotationMeansNote
@Componenta managed bean"bean" is Spring's word for an object it creates and injects
@Servicea component; business logicsemantic only
@Repositorya component; data accessalso translates persistence exceptions
@Controller / @RestControllera component; web endpoint
@Configurationa class that declares @Bean methods
@Beana factory methoduse it for classes you cannot annotate, such as library types
Note

A single-constructor class needs no @Autowired. Spring uses it automatically. Constructor injection with final fields is the recommended style and matches what you already do in ASP.NET Core. Field injection (@Autowired on a field) exists, appears in older code, and should be avoided: it hides dependencies and breaks testability.

Lifetimes#

ASP.NET CoreSpringNote
AddSingletonsingletonTHE DEFAULT in Spring
AddScopedrequestweb only; needs @Scope("request")
AddTransientprototypea new instance per injection point
n/asessionone per HTTP session
n/aapplicationone per deployed web application
Gotcha

The defaults are opposite. ASP.NET Core makes you choose a lifetime every time. Spring defaults to singleton, meaning one instance for the whole application. A bean with mutable state is therefore shared across every request thread unless you say otherwise, and nothing warns you.

Keep beans stateless. If you need per-request state, pass it as a method argument, or use @Scope("request"), but stateless is nearly always the right answer.

Beans for types you do not own#

C#
builder.Services.AddSingleton(sp =>
    new HttpClient { BaseAddress = new Uri(url) });
Java 25
@Configuration
public class HttpConfig {

    @Bean
    RestClient ratesClient(RestClient.Builder builder,
                           @Value("${rates.url}") String url) {
        return builder.baseUrl(url).build();
    }
}

Multiple implementations#

C#
builder.Services.AddKeyedScoped<IPay, Card>("card");
builder.Services.AddKeyedScoped<IPay, Bank>("bank");

public class Checkout([FromKeyedServices("card")] IPay pay);
Java 25
@Service("card") class CardPayment implements Pay { }
@Service("bank") class BankPayment implements Pay { }

@Service
public class Checkout {
    Checkout(@Qualifier("card") Pay pay) { }
}
NeedSpring
Pick one by name@Qualifier("name")
Prefer one by default@Primary on that bean
Inject all of themList<Pay> or Map<String, Pay> parameter
Conditional registration@ConditionalOnProperty, @Profile
Note

Injecting List<Pay> gives you every implementation: the strategy pattern with no registry to maintain. Injecting Map<String, Pay> gives you them keyed by bean name. Both are idiomatic and have no ASP.NET Core equivalent as concise.

Configuration files#

appsettings.json
{
  "ConnectionStrings": {
    "Default": "Server=localhost;Database=billing"
  },
  "Rates": {
    "Url": "https://rates.example.com",
    "TimeoutSeconds": 5
  },
  "Logging": {
    "LogLevel": { "Default": "Information" }
  }
}
application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost/billing

rates:
  url: https://rates.example.com
  timeout-seconds: 5

logging:
  level:
    root: INFO
    com.acme.billing: DEBUG

Profiles are environments#

.NETSpringNote
ASPNETCORE_ENVIRONMENTSPRING_PROFILES_ACTIVE
appsettings.Development.jsonapplication-dev.yml
IHostEnvironment.IsDevelopment()@Profile("dev")
--environment Development--spring.profiles.active=dev
Multiple environmentsmultiple active profilescomma-separated
@Service
@Profile("!prod")           // active in every profile except prod
class FakeEmailSender implements EmailSender { }

IOptions becomes @ConfigurationProperties#

C#
public class RatesOptions
{
    public string Url { get; set; }
    public int TimeoutSeconds { get; set; }
}

builder.Services.Configure<RatesOptions>(
    builder.Configuration.GetSection("Rates"));

public class Client(IOptions<RatesOptions> opts);
Java 25
@ConfigurationProperties(prefix = "rates")
public record RatesProperties(
        String url,
        int timeoutSeconds) { }

@EnableConfigurationProperties(RatesProperties.class)
@Configuration
class Config { }

// inject the record directly; no IOptions wrapper
@Service
class Client {
    Client(RatesProperties props) { }
}
Note

Note two wins over IOptions<T>. You inject the properties type directly rather than an IOptions<T> wrapper, and it can be an immutable record. Relaxed binding means timeout-seconds, timeoutSeconds and TIMEOUT_SECONDS all bind to the same component, so an environment variable maps cleanly with no extra configuration.

Single values and precedence#

@Value("${rates.url}")                    String url;
@Value("${rates.timeout-seconds:5}")      int timeout;   // with a default

Configuration sources are layered, highest priority first:

PrioritySource
1command-line arguments
2SPRING_APPLICATION_JSON
3Java system properties, set with -D
4OS environment variables
5application-{profile}.yml
6application.yml
7@PropertySource
8defaults in code
Note

Java system properties rank above environment variables, so a -D flag in a startup script beats the variable your container platform sets. Servlet and JNDI sources also sit between SPRING_APPLICATION_JSON and system properties, but rarely matter in a Boot service. See How Java runs your code for what a system property is.

Gotcha

Environment variables use relaxed binding: RATES_TIMEOUT_SECONDS binds to rates.timeout-seconds. This is how you configure containers, and it works without any of the double-underscore convention ASP.NET Core requires for nesting.

Secrets#

.NETSpring
User Secretsapplication-local.yml, gitignored
Azure Key VaultSpring Cloud Azure, or Vault
AWS Secrets ManagerSpring Cloud AWS
Environment variablesenvironment variables
Gotcha

There is no built-in equivalent of dotnet user-secrets. The common pattern is an application-local.yml listed in .gitignore, activated with a local profile. Establish this on day one, because the alternative, a committed password, is the most common Spring repository accident.

How Spring actually works#

Part P9 · ASP.NET Core to Spring Boot · Chapter 38· 4 min read

Three times in this book you have been told that @Transactional, @PreAuthorize and @Retryable are “proxy-based” and that calling the method from inside the same class silently does nothing. This chapter is the mechanism behind that warning.

The short version: Spring does not modify your class. It wraps it. The bean other code holds is not your object; it is a proxy that delegates to your object, doing the annotation's work on the way through. An internal call never leaves the object, so it never passes through the wrapper.

The bean lifecycle#

ASP.NET Core builds an object and hands it to you. Spring builds an object, then runs it through a pipeline that can decorate, replace or reject it before anyone sees it.

scan / read @Bean methods
      │
      ▼
instantiate  ──── constructor injection happens here
      │
      ▼
populate     ──── @Value, @Autowired fields, setters
      │
      ▼
BeanPostProcessor.postProcessBeforeInitialization()
      │
      ▼
@PostConstruct  →  InitializingBean.afterPropertiesSet()  →  initMethod
      │
      ▼
BeanPostProcessor.postProcessAfterInitialization()   ← PROXIES ARE CREATED HERE
      │
      ▼
the bean handed to everyone else  ──── may not be your object
      │
      ▼
@PreDestroy  →  DisposableBean.destroy()  →  destroyMethod
ASP.NET CoreSpringWhen
constructorconstructordependencies arrive
n/a@PostConstructafter all dependencies are set
n/aInitializingBean.afterPropertiesSet()same point, interface form
n/aBeanPostProcessoraround every bean; how the framework extends itself
IDisposable / IAsyncDisposable@PreDestroycontainer shutdown
IHostedService.StartAsyncApplicationRunner, @EventListener(ApplicationReadyEvent)after the context is up
Gotcha

Do not use injected dependencies in a constructor body for work that needs the whole context. At construction time your dependencies exist but may themselves be half-built, and no proxying has happened yet. Anything that needs a fully-formed collaborator belongs in @PostConstruct.

This is also why calling an @Transactional method from a constructor or from @PostConstruct does nothing: the proxy that would have started the transaction does not exist yet.

What a proxy actually is#

When a bean has an annotation that needs behaviour wrapped around it, a BeanPostProcessor returns a different object in its place. Everyone who injects that bean gets the wrapper.

caller ──► OrderService$$SpringCGLIB          ← what is actually injected
               │  start transaction
               │  check @PreAuthorize
               ▼
           OrderService (yours)               ← the real object
               │
               │  this.helper()  ────────────► stays inside; NO proxy
               ▼
           OrderService.helper()               annotations here do nothing
C#: you wire it yourself
// nothing built in: a DispatchProxy, or Scrutor's Decorate
services.AddScoped<IOrderService, OrderService>();
services.Decorate<IOrderService, TransactionalDecorator>();

// the decoration is visible in your code
Spring: the container wraps it
@Service
public class OrderService {

    @Transactional
    public void place(Cart c) { ... }
}
// nothing here shows that a wrapper exists

Two kinds of proxy#

KindUsed whenBuilt byLimitation
JDK dynamic proxythe bean implements an interface, and proxyTargetClass is falsejava.lang.reflect.Proxyonly interface methods are proxied
CGLIB proxythere is no interface, or proxyTargetClass is truea generated subclasscannot proxy final classes or final methods
Note

Spring Boot sets proxyTargetClass=true by default, so you almost always get a CGLIB subclass. That is why the bean's class name in a stack trace looks like OrderService$$SpringCGLIB$$0, and why the subclass needs a non-private constructor to exist at all.

Gotcha

A CGLIB proxy is a subclass, so final defeats it silently. Mark a class or a method final and the annotation stops working, with no error. This is a real hazard in Java because, unlike C#, methods are virtual by default, so a well-meaning final added for correctness quietly disables your transaction.

It is also why Kotlin's Spring plugin exists: Kotlin classes are final by default, and the plugin unfinalises Spring-annotated ones.

Why self-invocation fails#

@Service
public class OrderService {

    public void placeAll(List<Cart> carts) {
        for (var c : carts) {
            place(c);          // ← plain "this" call. No proxy. No transaction.
        }
    }

    @Transactional
    public void place(Cart c) { ... }
}
FixHowCost
Move the method to another beaninject it and call itbest; usually better design anyway
Inject yourself@Autowired OrderService self; then self.place(c)works, reads oddly
AopContext.currentProxy()cast the result and call through itneeds exposeProxy=true; obscure
Use AspectJ weaving instead of proxiescompile-time or load-time weavingpowerful, much heavier
Note

Every proxy-based annotation shares this behaviour, which is why the same warning appears under Data access, Security and HTTP clients and resilience. Learn it once here.

AOP: writing your own cross-cutting behaviour#

Proxies are the mechanism; AOP is the user-facing API for adding your own. This is the Spring answer to a .NET middleware, an action filter or a DispatchProxy decorator, but it applies to any bean method, not only to controllers.

@Aspect
@Component
public class TimingAspect {

    private static final Logger log = LoggerFactory.getLogger(TimingAspect.class);

    // "any public method in a class annotated @Service"
    @Around("@within(org.springframework.stereotype.Service) && execution(public * *(..))")
    public Object time(ProceedingJoinPoint pjp) throws Throwable {
        long t0 = System.nanoTime();
        try {
            return pjp.proceed();          // call the real method
        } finally {
            log.debug("{} took {}ms", pjp.getSignature(),
                      (System.nanoTime() - t0) / 1_000_000);
        }
    }
}
AdviceRuns.NET analogy
@Beforebefore the methodfilter OnActionExecuting
@AfterReturningafter a successful returnOnActionExecuted
@AfterThrowingonly on exceptionexception filter
@Afteralways, like finallyfinally block
@Aroundwraps the call; you invoke proceed()middleware
Pointcut expressionMatches
execution(* com.acme..*Service.*(..))any method on any *Service under that package
@annotation(com.acme.Audited)any method annotated @Audited
@within(org.springframework.stereotype.Service)any method in a class annotated @Service
within(com.acme.billing..*)any method in that package tree
args(String, ..)first argument is a String
Gotcha

Aspects are applied by the same proxy machinery, so every limitation above applies: no self-invocation, no final classes or methods, and beans excluded from proxying are excluded from your aspect too. An aspect that appears not to run is almost always one of those three.

SpEL#

Spring Expression Language is a small expression evaluator that runs inside annotation values. You have already seen it in @PreAuthorize; it also appears in @Value, @Cacheable keys and conditional annotations.

@Value("#{systemProperties['user.region'] ?: 'eu'}")     String region;
@Value("${rates.timeout:5}")                             int timeout;   // property, not SpEL

@PreAuthorize("#order.owner == authentication.name")
public void cancel(Order order) { }

@Cacheable(value = "rates", key = "#pair + ':' + #date")
public Rate lookup(String pair, LocalDate date) { }
Gotcha

${...} and #{...} are different things. ${...} is a property placeholder; it reads configuration. #{...} is SpEL; it evaluates an expression. Mixing them up gives a literal string or a startup failure, and the error message rarely says which mistake you made.

Circular dependencies#

ASP.NET Core throws on a dependency cycle. Spring historically resolved some of them by injecting a half-built object, which produced spectacular bugs. Since Boot 2.6 cycles are rejected by default.

The dependencies of some of the beans form a cycle:
   orderService
      ↓
   billingService
      ↓
   orderService
FixNote
Extract the shared logic into a third beanalmost always the right answer
@Lazy on one of the injection pointsdefers to a proxy; hides a design problem
spring.main.allow-circular-references=truere-enables the old behaviour; avoid
Setter injectionworks, and is why field injection let cycles happen unnoticed
Note

A cycle is a design signal, not a container limitation. It nearly always means two beans share a responsibility that wants its own name. The escape hatches exist for legacy code being migrated, not for new work.

Ordering and conditional beans#

NeedAnnotation
Order a list of injected beans@Order(n) on each, or implement Ordered
Force one bean to be built first@DependsOn("otherBean")
Register only if a property is set@ConditionalOnProperty
Register only if a class is present@ConditionalOnClass
Register only if nobody else defined one@ConditionalOnMissingBean
Register only in some profiles@Profile("!prod")
Gotcha

@Order controls the order of beans within an injected collection and the order of aspects and filters. It does not control the order in which beans are created: dependency edges do that, and @DependsOn is the only way to add an edge that is not a real dependency.

Controllers, routing and binding#

Part P9 · ASP.NET Core to Spring Boot · Chapter 39· 2 min read

@RestController is [ApiController], @GetMapping is [HttpGet], and binding attributes map almost one-to-one. The main structural difference: Spring returns the value directly or wraps it in ResponseEntity, rather than using an IActionResult hierarchy.

A controller#

ASP.NET Core
[ApiController]
[Route("api/orders")]
public class OrdersController(IOrderService svc)
    : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<OrderDto>> Get(int id)
    {
        var o = await svc.FindAsync(id);
        return o is null ? NotFound() : Ok(o);
    }

    [HttpPost]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrder cmd)
    {
        var o = await svc.CreateAsync(cmd);
        return CreatedAtAction(nameof(Get),
            new { id = o.Id }, o);
    }
}
Spring MVC
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService svc;

    public OrderController(OrderService svc) {
        this.svc = svc;
    }

    @GetMapping("/{id}")
    public ResponseEntity<OrderDto> get(@PathVariable long id) {
        return svc.find(id)
                  .map(ResponseEntity::ok)
                  .orElseGet(() ->
                      ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<OrderDto> create(
            @RequestBody CreateOrder cmd) {
        var o = svc.create(cmd);
        return ResponseEntity
                .created(URI.create("/api/orders/" + o.id()))
                .body(o);
    }
}

Routing and verbs#

ASP.NET CoreSpringNote
[Route("api/x")]@RequestMapping("/api/x")on the class
[HttpGet]@GetMapping
[HttpPost]@PostMapping
[HttpPut]@PutMapping
[HttpPatch]@PatchMapping
[HttpDelete]@DeleteMapping
[HttpGet("{id:int}")]@GetMapping("/{id}")Spring infers the type from the parameter
route constraint :intno route constraintsbinding failure produces a 400
[Produces("application/json")]produces = "application/json"an attribute on the mapping
[Consumes(...)]consumes = "..."

Model binding#

ASP.NET CoreSpringBinds from
[FromRoute]@PathVariablethe URL path
[FromQuery]@RequestParamthe query string
[FromBody]@RequestBodythe request body, via Jackson
[FromHeader]@RequestHeadera header
[FromForm]@RequestParam / @ModelAttributeform data
[FromServices]just take a constructor dependencythe container
n/a@CookieValuea cookie
n/a@RequestPartone part of a multipart request
@GetMapping("/search")
public List<OrderDto> search(
        @RequestParam String q,                              // required
        @RequestParam(defaultValue = "0") int page,          // optional with default
        @RequestParam(required = false) String status,       // optional, may be null
        @RequestHeader("X-Tenant") String tenant,
        Pageable pageable) {                                 // Spring Data resolves this
    ...
}
Gotcha

@RequestParam is required by default. A missing parameter produces a 400 rather than null, which differs from ASP.NET Core's default of binding to the parameter's default value. Use required = false or supply a defaultValue.

Records as DTOs#

Jackson binds records natively, so request and response types should be records.

public record CreateOrder(
        @NotBlank String customerId,
        @NotEmpty List<LineItem> items) { }

public record OrderDto(long id, String status, BigDecimal total) { }
Gotcha

A record has no no-argument constructor and its accessors are id() rather than getId(). Modern Jackson handles this; a library or Jackson version older than roughly 2.12 does not, and will fail to deserialise with a confusing message about missing creators. If you hit that, check the Jackson version before rewriting the record as a class.

Returning results#

ASP.NET CoreSpring
Ok(value)ResponseEntity.ok(value), or just return the value
NotFound()ResponseEntity.notFound().build()
BadRequest(x)ResponseEntity.badRequest().body(x)
NoContent()ResponseEntity.noContent().build()
Created(uri, x)ResponseEntity.created(uri).body(x)
StatusCode(418)ResponseEntity.status(418).build()
File(...)ResponseEntity with a Resource body
Problem()see Validation and error responses
Note

If you do not need to set a status or headers, return the value directly and let Spring serialise it with a 200. Use ResponseEntity<T> only where you actually vary the response. An @ResponseStatus(HttpStatus.CREATED) annotation on the method is a third option for a fixed non-200 status.

Middleware#

ASP.NET CoreSpringRuns
app.Use(...) middlewareServlet Filterbefore the dispatcher; sees every request
IActionFilterHandlerInterceptoraround controller methods
IAsyncActionFilterHandlerInterceptor
IExceptionFilter@ControllerAdvice + @ExceptionHandler
IAuthorizationFilterSpring Security filter chain
Endpoint routingDispatcherServlet
@Component
public class CorrelationIdFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        var id = Optional.ofNullable(((HttpServletRequest) req).getHeader("X-Correlation-Id"))
                         .orElseGet(() -> UUID.randomUUID().toString());
        MDC.put("correlationId", id);        // shows up in every log line
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.clear();
        }
    }
}
Note

MDC, Mapped Diagnostic Context, is SLF4J's per-thread key/value bag that logging patterns can print automatically. It is the standard way to get a correlation ID onto every log line, and the closest analogue to a logging scope in Serilog.

API versioning#

Spring Framework 7 added first-class API versioning Boot 4. On Boot 3 you roll your own with separate paths or a custom request condition.

// Boot 3.5: versioning by path, the common convention
@RestController
@RequestMapping("/api/v1/orders")
class OrderControllerV1 { }

@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 { }
// Boot 4.0: first-class version support in the mapping,
// configured via spring.mvc.apiversion.*
@RestController
@RequestMapping("/api/orders")
class OrderController {

    @GetMapping(version = "1.0")
    OrderDtoV1 getV1() { ... }

    @GetMapping(version = "1.1")
    OrderDtoV2 getV11() { ... }
}
Note

Boot 4 also carries the version through to the client side, RestClient, WebClient and HTTP interface clients can all send a version, and to the test side via WebTestClient and MockMvc.

OpenAPI#

.NETSpring
Swashbuckle / NSwagspringdoc-openapi
[ProducesResponseType]@ApiResponse
[SwaggerOperation]@Operation
XML doc commentsjavadoc, plus annotations
/swagger/swagger-ui.html

Add the springdoc-openapi-starter-webmvc-ui dependency and the UI appears with no configuration, generated from your controllers, records and Bean Validation annotations.

Validation and error responses#

Part P9 · ASP.NET Core to Spring Boot · Chapter 40· 2 min read

Jakarta Bean Validation is DataAnnotations plus much of what you use FluentValidation for. Annotate a record, add @Valid to the parameter, and Spring rejects bad input before your method runs.

For errors, Spring supports RFC 9457 ProblemDetail, the same standard as ASP.NET Core's ProblemDetails.

Constraint annotations#

C#
public record CreateOrder(
    [Required, StringLength(50)] string CustomerId,
    [Range(1, 100)] int Quantity,
    [EmailAddress] string Email);
Java 25
public record CreateOrder(
        @NotBlank @Size(max = 50) String customerId,
        @Min(1) @Max(100) int quantity,
        @Email String email) { }
DataAnnotationsJakarta ValidationNote
[Required]@NotNullnull only
[Required] on a string@NotBlanknull, empty, or whitespace
n/a@NotEmptynull or empty; works on collections too
[StringLength(n)]@Size(max = n)also for collections and maps
[Range(a, b)]@Min(a) @Max(b)or @Range from Hibernate Validator
[EmailAddress]@Email
[RegularExpression(p)]@Pattern(regexp = p)
[Compare]no equivalentwrite a class-level constraint
n/a@Positive, @Negative, @PositiveOrZero
n/a@Past, @Future, @PastOrPresentfor java.time types
n/a@Valid on a nested fieldcascades validation
[CreditCard]@CreditCardNumberHibernate Validator
Gotcha

@NotNull is not [Required] for strings. @NotNull permits "". Use @NotBlank for a string that must contain non-whitespace, and @NotEmpty for a collection that must have elements. Getting this wrong is the most common Bean Validation mistake.

Triggering validation#

@PostMapping
public ResponseEntity<OrderDto> create(@Valid @RequestBody CreateOrder cmd) {
    // if validation fails this method never runs;
    // Spring throws MethodArgumentNotValidException -> 400
}

// on a query parameter or path variable, validate at class level
@RestController
@Validated
public class OrderController {
    @GetMapping
    List<OrderDto> list(@RequestParam @Min(0) int page) { ... }
}
Gotcha

Forgetting @Valid silently disables validation; the annotations are still there, and nothing checks them. There is no warning. If invalid input is reaching your handler, check for the missing @Valid first.

Custom constraints#

This is where Bean Validation covers FluentValidation's territory. A constraint is an annotation plus a validator class.

@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CurrencyValidator.class)
public @interface ValidCurrency {
    String message() default "unknown currency";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class CurrencyValidator
        implements ConstraintValidator<ValidCurrency, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        return value == null || Currency.getAvailableCurrencies()
                .stream().anyMatch(c -> c.getCurrencyCode().equals(value));
    }
}
Note

The three members message, groups and payload are mandatory boilerplate on every constraint annotation; the specification requires them. Copy them and move on.

ProblemDetail#

Both frameworks implement the same RFC. Spring's type is ProblemDetail, and ResponseEntityExceptionHandler already produces one for framework exceptions.

C#
// ASP.NET Core produces this automatically
// for [ApiController] validation failures
{
  "type": "https://tools.ietf.org/...",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": { "Email": ["The Email field is invalid."] }
}
Java 25
{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Invalid request content.",
  "instance": "/api/orders"
}
# Boot: turn on ProblemDetail responses for framework exceptions
spring:
  mvc:
    problemdetails:
      enabled: true

Global exception handling#

C#
app.UseExceptionHandler(...);

// or a filter
public class ApiExceptionFilter : IExceptionFilter
{
    public void OnException(ExceptionContext ctx) { ... }
}
Java 25
@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail handleNotFound(OrderNotFoundException e) {
        var pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, e.getMessage());
        pd.setTitle("Order not found");
        pd.setType(URI.create(
                "https://acme.com/errors/not-found"));
        pd.setProperty("orderId", e.orderId());
        return pd;
    }
}
NeedSpring
Handle one exception type@ExceptionHandler(X.class)
Handle several@ExceptionHandler({A.class, B.class})
Apply to all controllers@RestControllerAdvice
Apply to some controllers@RestControllerAdvice(basePackages = "...")
Override framework error shapesextend ResponseEntityExceptionHandler
Add fields to the responseproblemDetail.setProperty(k, v)

Returning field-level errors#

Spring's default for a validation failure does not itemise the fields the way ASP.NET Core does. Most teams add this:

@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handleValidation(MethodArgumentNotValidException e) {
    var errors = e.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.toMap(
                    FieldError::getField,
                    f -> Objects.requireNonNullElse(f.getDefaultMessage(), "invalid"),
                    (a, b) -> a));

    var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    pd.setTitle("Validation failed");
    pd.setProperty("errors", errors);
    return pd;
}
Gotcha

Collectors.toMap throws on duplicate keys, and two constraint violations on the same field produce exactly that. The three-argument form with a merge function, as above, is not optional here.

Data access, JPA and migrations#

Part P9 · ASP.NET Core to Spring Boot · Chapter 41· 5 min read

Spring Data JPA is EF Core with a different philosophy. EF Core builds queries from LINQ expression trees; JPA has no expression trees, so queries come from method names, JPQL strings, or a criteria API.

Migrations are not part of the ORM. Flyway or Liquibase own the schema, and they are plain SQL rather than generated C#.

The landscape#

EF CoreJavaNote
DbContextEntityManagertracks loaded entities and flushes changes on commit
DbSet<T>a Spring Data repository
[Table], [Column]@Entity, @Table, @Column
OnModelCreatingannotations, or orm.xml
SaveChangesflush, usually automatic on commit
MigrationsFlyway or Liquibaseseparate tools; the ORM does not own the schema
Include()@EntityGraph, or JOIN FETCH
AsNoTracking()a read-only transaction, or a projection
FromSqlRaw@Query(nativeQuery = true)
DapperJdbcTemplate, JDBI
LINQ to SQLjOOQgenerates typed Java from your real schema, so renames break the build

An entity#

C#
public class Order
{
    public long Id { get; set; }
    public string CustomerId { get; set; }
    public decimal Total { get; set; }
    public List<LineItem> Items { get; set; } = [];
}
Java 25
@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String customerId;

    private BigDecimal total;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL,
               orphanRemoval = true)
    private List<LineItem> items = new ArrayList<>();

    protected Order() { }   // JPA requires a no-arg constructor
}
Gotcha

Entities cannot be records. JPA needs a no-argument constructor and mutable fields so it can proxy and lazily populate them. Records are final and immutable. Use a class for entities and a record for the DTO you expose, which is better design anyway.

Gotcha

@Enumerated defaults to ORDINAL, which stores the enum's declaration position as an integer. Insert or reorder a constant later and every stored row silently means something different. There is no error, no migration, and no way to tell from the data which mapping was in force when a row was written.

@Enumerated                              // ORDINAL by default: NEW=0, ACTIVE=1, CLOSED=2
private Status status;

@Enumerated(EnumType.STRING)             // always do this
private Status status;

This is the same hazard as persisting ordinal(), described in Enums, except that here the unsafe option is the default. Always write EnumType.STRING, or map an explicit code column with an AttributeConverter if you want short values.

equals and hashCode on entities#

The general contract in Equality and hashing applies, but entities break the usual advice in two specific ways.

ProblemWhy
The id is null before persistan entity put in a HashSet before saving changes its hash when the id is assigned, and is then lost in the set
Hibernate hands you proxiesa lazy association is a generated subclass, so getClass() comparison fails
Lombok @Data on an entitygenerates equals over every field, which triggers lazy loading and can recurse through both sides of an association
@Entity
public class Order {

    @Id @GeneratedValue
    private Long id;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        // instanceof, not getClass(), so a Hibernate proxy still matches
        if (!(o instanceof Order other)) return false;
        // only equal when both have an id, and the ids match
        return id != null && id.equals(other.id);
    }

    @Override
    public int hashCode() {
        // constant, so the hash never changes when the id is assigned
        return getClass().hashCode();
    }
}
Gotcha

A constant hashCode looks wrong and is deliberate: it keeps the object findable in a HashSet across the transition from unsaved to saved. It degrades a hash set of entities to a linear scan, which is acceptable because entity collections are small. The alternative, hashing the id, breaks the collection the moment the entity is persisted.

Never put @Data or @EqualsAndHashCode from Lombok on a JPA entity. It is the most common cause of unexpected lazy loading and of StackOverflowError from two entities referencing each other.

Repositories#

This is the part with no EF Core equivalent: declare an interface and Spring implements it at runtime by parsing the method names.

public interface OrderRepository extends JpaRepository<Order, Long> {

    // parsed into: where customer_id = ?
    List<Order> findByCustomerId(String customerId);

    // where customer_id = ? and status = ? order by created desc
    List<Order> findByCustomerIdAndStatusOrderByCreatedDesc(
            String customerId, Status status);

    // where total > ?
    List<Order> findByTotalGreaterThan(BigDecimal amount);

    boolean existsByCustomerId(String customerId);
    long countByStatus(Status status);

    // when the name would get silly, write the query
    @Query("select o from Order o join fetch o.items where o.id = :id")
    Optional<Order> findWithItems(@Param("id") Long id);
}
KeywordSQL
findBy / getBy / readByselect
And, Orand / or
Between, LessThan, GreaterThancomparisons
Like, StartingWith, Containinglike
In, NotInin
IsNull, IsNotNullis null
OrderBy...Asc/Descorder by
Top, Firstlimit
Distinctdistinct
existsBy, countBy, deleteByexists / count / delete
Note

JpaRepository already gives you save, findById, findAll, delete, paging and sorting. You only declare the queries that are specific to your domain. There is no equivalent to writing a repository class by hand unless you want one.

The N+1 problem#

Gotcha

This is the defining JPA production issue, and it is worse than in EF Core because lazy loading is the default for collections and there is no Include() to reach for reflexively.

// one query for the orders, then one MORE per order for its items
for (var order : repo.findAll()) {
    order.getItems().size();     // triggers a query, every iteration
}

The fixes, in order of preference:

// 1. JOIN FETCH in the query
@Query("select distinct o from Order o join fetch o.items")
List<Order> findAllWithItems();

// 2. @EntityGraph: declarative, composes with derived queries
@EntityGraph(attributePaths = "items")
List<Order> findByStatus(Status status);

// 3. a projection, fetch only what you need
interface OrderSummary { Long getId(); BigDecimal getTotal(); }
List<OrderSummary> findByStatus(Status status);

Set spring.jpa.properties.hibernate.generate_statistics=true in development, or add the datasource-proxy or Hypersistence Utils libraries, so an N+1 shows up as a number rather than as latency in production.

Lazy loading and detached entities#

AssociationJPA defaultAdvice
@OneToManyLAZYkeep it lazy; fetch explicitly
@ManyToManyLAZYkeep it lazy
@ManyToOneEAGERset it to LAZY explicitly
@OneToOneEAGERset it to LAZY explicitly
Gotcha

LazyInitializationException is the JPA rite of passage. Touching a lazy association after the transaction has closed, typically in a controller, after the service method returned, throws. EF Core has the same concept with a disposed DbContext, but JPA hits it far more often because of the defaults above.

The fix is to fetch what you need inside the transaction, or map to a DTO there. Do not reach for spring.jpa.open-in-view, which is on by default and papers over the problem by holding the session open for the whole request; it hides N+1s and holds connections longer than necessary. Most experienced teams set it to false and fix the resulting exceptions properly.

Transactions#

C#
using var tx = db.Database.BeginTransaction();
db.Orders.Add(order);
await db.SaveChangesAsync();
tx.Commit();
Java 25
@Transactional
public Order place(CreateOrder cmd) {
    var order = new Order(cmd);
    repo.save(order);
    // committed when the method returns
    return order;
}
Gotcha

@Transactional works by creating a proxy around the bean. Two consequences that catch everyone:

  • Self-invocation does not work. Calling this.other() from inside the same class bypasses the proxy, so @Transactional on other() has no effect; see How Spring actually works for why, and Transactions in depth for propagation and isolation.
  • Only unchecked exceptions roll back by default. A checked exception commits. Use @Transactional(rollbackFor = Exception.class) if you throw checked exceptions.

Also put it on the service, not the repository; the transaction boundary is the use case.

Migrations#

EF Core migrations
dotnet ef migrations add AddOrderStatus
dotnet ef database update

// generated C# with Up/Down methods
Flyway
-- src/main/resources/db/migration/V3__add_order_status.sql
ALTER TABLE orders
    ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'NEW';

-- applied automatically at startup
AspectEF MigrationsFlyway
Formatgenerated C#plain SQL you write
NamingtimestampedV{version}__{description}.sql
Applieddotnet ef database updateautomatically on app startup
RollbackDown() methodforward-only; write a new migration
Baseline an existing DBpossible, fiddlyflyway.baselineOnMigrate
Checksum enforcementnoyes, editing an applied migration fails the build
Gotcha

Flyway checksums every applied migration. Edit a file that has already run in any environment and startup fails with a checksum mismatch. This is a feature, it guarantees every environment ran identical SQL, but it means the fix for a mistake is always a new migration, never an edit.

Gotcha

Set spring.jpa.hibernate.ddl-auto=validate in every environment. The default in some setups is update, which lets Hibernate alter your schema at startup: convenient in a demo, catastrophic in production. Let Flyway own the schema and have Hibernate merely check that the mapping agrees with it.

When not to use JPA#

SituationBetter tool
Complex reporting queriesjOOQ, or JdbcTemplate with SQL
You want typed SQL like LINQjOOQ
Simple CRUD, no object graphSpring Data JDBC, much simpler model
Bulk operationsnative SQL; JPA is poor at bulk
Read-heavy projectionsinterface or record projections
Note

jOOQ deserves a look if you miss LINQ-to-SQL. It generates a typed DSL from your real database schema, so DSL.selectFrom(ORDERS).where(ORDERS.TOTAL.gt(x)) is compile-time checked and a renamed column breaks the build. It is the closest thing in Java to what EF Core gives you, and it is honest about being SQL rather than pretending to be objects.

Transactions, propagation and isolation#

Part P9 · ASP.NET Core to Spring Boot · Chapter 42· 3 min read

@Transactional looks like a checkbox and is really a policy with seven propagation modes, four isolation levels and a rollback rule that surprises people. Getting it wrong produces the worst class of bug: data that is quietly, occasionally wrong.

The three facts to hold on to: only unchecked exceptions roll back by default, self-invocation bypasses it entirely, and REQUIRES_NEW is a different physical transaction, so it can deadlock against its own caller.

The default#

C#
using var tx = await db.Database.BeginTransactionAsync();
try
{
    db.Orders.Add(order);
    await db.SaveChangesAsync();
    await tx.CommitAsync();
}
catch
{
    await tx.RollbackAsync();
    throw;
}
Java 25
@Transactional
public Order place(CreateOrder cmd) {
    var order = new Order(cmd);
    repo.save(order);
    return order;
}
// commit on normal return,
// rollback on an unchecked exception

Put it on the service, not the repository. The transaction boundary is the use case: everything inside one business operation should commit or fail together.

The rollback rule#

Gotcha

A checked exception commits. Spring rolls back for RuntimeException and Error only. Throw a checked exception out of a transactional method and the transaction commits on the way out, which is almost never what the author intended.

@Transactional
public void transfer(Account a, Account b, BigDecimal amt) throws InsufficientFunds {
    a.debit(amt);
    if (b.isClosed()) throw new InsufficientFunds();   // COMMITS the debit
}

@Transactional(rollbackFor = Exception.class)          // the fix
public void transfer(...) throws InsufficientFunds { ... }

C# has no checked exceptions, so a .NET developer has no instinct for this at all. If your codebase throws checked exceptions from service methods, set rollbackFor or convert them to unchecked ones at the boundary.

SettingEffect
rollbackFor = Exception.classroll back for checked exceptions too
noRollbackFor = NotFound.classcommit even though this was thrown
readOnly = truea hint; lets Hibernate skip dirty checking and the driver optimise
timeout = 5seconds before the transaction is rolled back

Propagation#

Propagation answers one question: what should happen if a transaction is already running when this method is called?

ModeIf a transaction existsIf none existsUse for
REQUIREDjoin itstart onethe default; almost always right
REQUIRES_NEWsuspend it, start a separate onestart oneaudit rows that must survive a rollback
NESTEDa savepoint inside itstart onepartial rollback, JDBC only
SUPPORTSjoin itrun with noneread-only helpers
NOT_SUPPORTEDsuspend it, run with nonerun with nonelong non-transactional work
MANDATORYjoin itthrowassert a caller opened one
NEVERthrowrun with noneassert nobody opened one
@Transactional                                     // REQUIRED
public void placeOrder(Cart cart) {
    orders.save(new Order(cart));
    audit.record("order placed");                  // see below
    throw new PaymentFailed();                     // rolls the order back
}

@Service
class AuditService {
    // survives the caller's rollback, because it is a separate transaction
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String what) {
        auditRepo.save(new AuditRow(what));
    }
}
Gotcha

REQUIRES_NEW can deadlock against its own caller. The outer transaction is suspended but still holds its locks. If the inner transaction touches a row the outer one has already written, it waits for a lock the outer transaction will not release until the inner one returns. The result is a connection-pool-wide stall that only appears under load.

Use it for genuinely independent writes, audit trails, outbox rows, failure logs, and never for the same aggregate the caller is modifying.

Gotcha

REQUIRES_NEW takes a second connection from the pool. A pool of ten and a nested call means five concurrent operations, not ten. Pools sized without accounting for this deadlock at a load the arithmetic did not predict.

Note

NESTED is not a second transaction; it is a JDBC savepoint inside the existing one. Rolling it back undoes only the inner work, but a rollback of the outer transaction still discards everything. It needs a DataSourceTransactionManager and is not supported by JPA's transaction manager, so it is rarer than it looks.

Isolation#

LevelPreventsStill allows.NET name
READ_UNCOMMITTEDnothingdirty readsReadUncommitted
READ_COMMITTEDdirty readsnon-repeatable reads, phantomsReadCommitted
REPEATABLE_READnon-repeatable readsphantom readsRepeatableRead
SERIALIZABLEeverythingnothing; may abortSerializable
DEFAULTwhatever the database default is,Unspecified
AnomalyMeans
Dirty readyou see another transaction's uncommitted write
Non-repeatable readthe same row changes between two reads in your transaction
Phantom readthe same query returns new rows between two reads
Gotcha

Isolation.DEFAULT defers to the database, and the databases disagree: PostgreSQL and Oracle default to READ_COMMITTED, MySQL InnoDB to REPEATABLE_READ, SQL Server to READ_COMMITTED. Code that is correct on MySQL can be subtly wrong on PostgreSQL. If a business rule depends on isolation, state it explicitly rather than inheriting it.

Note

Prefer optimistic locking to raising the isolation level. A @Version column on the entity makes Hibernate check that the row has not changed since you read it, and throws OptimisticLockException if it has. It scales far better than SERIALIZABLE and makes the conflict explicit.

@Entity
public class Order {
    @Id private Long id;
    @Version private int version;     // Hibernate manages this
}

The proxy trap, again#

Gotcha

@Transactional is applied by a proxy, so it does nothing when the method is called from inside the same class, when the method is private, or when the class or method is final. The mechanism is explained in How Spring actually works.

@Service
public class OrderService {
    public void placeAll(List<Cart> carts) {
        carts.forEach(this::place);      // no transaction, every time
    }

    @Transactional
    public void place(Cart c) { ... }
}

Doing something after the commit#

Publishing an event or sending a message from inside a transaction is a classic bug: the message goes out, then the transaction rolls back, and a downstream system now believes something that never happened.

@Transactional
public void place(Cart cart) {
    var order = repo.save(new Order(cart));
    events.publishEvent(new OrderPlaced(order.getId()));
}

@Component
class OrderPlacedListener {

    // only runs if the transaction actually committed
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderPlaced e) {
        messaging.send(e);
    }
}
PhaseRuns
BEFORE_COMMITbefore the commit; can still fail the transaction
AFTER_COMMITafter a successful commit; the default
AFTER_ROLLBACKonly if it rolled back
AFTER_COMPLETIONeither way
Note

For guaranteed delivery this still is not enough; the process can die between commit and send. The robust pattern is the transactional outbox: write the message to a table in the same transaction, and have a separate poller publish it. Same solution as in .NET.

Checklist#

SymptomLikely cause
Annotation seems ignoredself-invocation, private method, or final class
Data committed despite an exceptionit was a checked exception; set rollbackFor
Deadlocks under loadREQUIRES_NEW touching rows the caller wrote
Connection pool exhaustedREQUIRES_NEW doubling connection use, or open-in-view
Works on MySQL, wrong on PostgreSQLisolation inherited from the database default
Lost update with no errorno @Version; add optimistic locking
Message sent for a rolled-back changepublish in AFTER_COMMIT, or use an outbox

Security#

Part P9 · ASP.NET Core to Spring Boot · Chapter 43· 2 min read

Spring Security is a servlet filter chain that sits in front of everything. It is more powerful than ASP.NET Core's authentication and authorisation stack and considerably less approachable; the defaults lock everything down, and the configuration DSL takes some getting used to.

The mental model: a chain of filters establishes an Authentication in a SecurityContext, then authorisation rules decide what it may do.

Concept mapping#

ASP.NET CoreSpring SecurityNote
AddAuthenticationSecurityFilterChain bean
AddAuthorizationauthorizeHttpRequests(...)
[Authorize]@PreAuthorize, or a chain rule
[Authorize(Roles = "Admin")]@PreAuthorize("hasRole('ADMIN')")
[AllowAnonymous]permitAll() in the chain
ClaimsPrincipalAuthentication / Principal
User.Identity.Nameauthentication.getName()
Policy-based authorisationSpEL in @PreAuthorize, or an AuthorizationManager
IdentityUserUserDetails
UserManagerUserDetailsService
JwtBeareroauth2ResourceServer().jwt()
Cookie authenticationformLogin() + session
Data protectionno direct equivalent
Antiforgery tokenCSRF protection, on by default

The filter chain#

C#
builder.Services.AddAuthentication()
    .AddJwtBearer();
builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers().RequireAuthorization();
Java 25
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain chain(HttpSecurity http)
            throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/api/admin/**")
                    .hasRole("ADMIN")
                .anyRequest().authenticated())
            .oauth2ResourceServer(o ->
                o.jwt(Customizer.withDefaults()))
            // stateless API only
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s ->
                s.sessionCreationPolicy(STATELESS))
            .build();
    }
}
Gotcha

Rules are matched in order and the first match wins. Put anyRequest() last, always. A broad rule placed early silently shadows every rule after it, and the failure mode is an endpoint that is more open than you intended.

Gotcha

CSRF protection is on by default, including for POST requests to a JSON API. If your first POST returns 403 with no useful message, this is why. Disable it only for genuinely stateless token-authenticated APIs; keep it for anything cookie-based.

Method-level authorisation#

C#
[Authorize(Roles = "Admin")]
public async Task Delete(int id) { }

[Authorize(Policy = "OwnsOrder")]
public async Task<Order> Get(int id) { }
Java 25
@PreAuthorize("hasRole('ADMIN')")
public void delete(long id) { }

@PreAuthorize("@orderGuard.owns(#id, authentication)")
public Order get(long id) { }
@Configuration
@EnableMethodSecurity      // required, method annotations do nothing without it
public class MethodSecurityConfig { }
ExpressionMeans
hasRole('ADMIN')authority ROLE_ADMIN
hasAuthority('orders:write')that exact authority
hasAnyRole('A','B')any of them
isAuthenticated()not anonymous
permitAll() / denyAll()always / never
#ida method parameter, by name
authenticationthe current Authentication
@beanName.method(...)call a bean, arbitrary policy logic
Gotcha

hasRole('ADMIN') checks for the authority ROLE_ADMIN. Spring prepends the prefix for you in hasRole but not in hasAuthority. Mixing them up produces authorisation that silently never matches. If your JWT carries "roles": ["ADMIN"] you must map them to ROLE_ADMIN authorities, or use hasAuthority('ADMIN') consistently.

Gotcha

@PreAuthorize is proxy-based, exactly like @Transactional. A call from inside the same class bypasses it entirely. Security checks that depend on self-invocation are not checks. The mechanism is in How Spring actually works.

The current user#

C#
public IActionResult Get()
{
    var id = User.FindFirst("sub")?.Value;
}
Java 25
@GetMapping
public OrderDto get(@AuthenticationPrincipal Jwt jwt) {
    String id = jwt.getSubject();
}

// or anywhere, without threading it through
var auth = SecurityContextHolder.getContext()
                                .getAuthentication();
Gotcha

SecurityContextHolder is backed by a ThreadLocal. It does not propagate to a thread you start yourself, including tasks submitted to an executor. Spring provides DelegatingSecurityContextExecutor to carry it across, which you need whenever you fan out work.

Passwords#

@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
Note

The delegating encoder stores a prefix such as {bcrypt} in the hash, so you can migrate algorithms without invalidating existing passwords. It is the equivalent of ASP.NET Identity's versioned password hashes, and it is the right default.

Null-safety in the Spring API#

Note

Spring Framework 7 migrated its entire API from JSR-305 to JSpecify annotations Boot 4. If you use a null-checker, Spring's own signatures now carry precise nullness information including for generics, which makes the checker substantially more useful against Spring APIs than it was on Boot 3. See Nullability.

HTTP clients and resilience#

Part P9 · ASP.NET Core to Spring Boot · Chapter 44· 2 min read

RestClient is HttpClient with a fluent API. Declarative interface clients, @HttpExchange, are Refit.

The Polly answer changes between Boot versions: Resilience4j on Boot 3, and @Retryable in Spring Framework core on Boot 4.

The client options#

ClientStyleUse when
RestClientfluent, blockingthe default on Boot 3.2+
WebClientfluent, reactiveyou are in WebFlux, or need streaming
@HttpExchange interfacedeclarativeyou want Refit-style typed clients
RestTemplatefluent, blockinglegacy; see below
java.net.http.HttpClientJDK built-inno Spring dependency wanted
C#
var client = httpClientFactory.CreateClient("rates");
var rate = await client
    .GetFromJsonAsync<Rate>($"/rates/{pair}");
Java 25
Rate rate = restClient.get()
        .uri("/rates/{pair}", pair)
        .retrieve()
        .body(Rate.class);
Note

That call blocks, and on a virtual thread that is exactly right. You do not need WebClient for scalability any more; reactive types are for streaming and backpressure, not for avoiding thread starvation. See Threads are cheap now.

Declarative clients#

Refit
public interface IRatesApi
{
    [Get("/rates/{pair}")]
    Task<Rate> GetRate(string pair);

    [Post("/quotes")]
    Task<Quote> CreateQuote([Body] QuoteRequest req);
}

builder.Services
    .AddRefitClient<IRatesApi>()
    .ConfigureHttpClient(c =>
        c.BaseAddress = new Uri(url));
Spring HTTP interfaces
public interface RatesApi {

    @GetExchange("/rates/{pair}")
    Rate getRate(@PathVariable String pair);

    @PostExchange("/quotes")
    Quote createQuote(@RequestBody QuoteRequest req);
}

Registering it is where the versions differ. Boot 4 added auto-configuration for @HttpExchange interfaces; on Boot 3 you build the proxy yourself.

// Boot 3.5, build the proxy factory by hand
@Configuration
class ClientConfig {

    @Bean
    RatesApi ratesApi(RestClient.Builder builder,
                      @Value("${rates.url}") String url) {
        RestClient client = builder.baseUrl(url).build();
        var adapter = RestClientAdapter.create(client);
        var factory = HttpServiceProxyFactory
                .builderFor(adapter).build();
        return factory.createClient(RatesApi.class);
    }
}
// Boot 4.0: declare the group; Spring registers the proxy bean
@Configuration
@ImportHttpServices(group = "rates", types = RatesApi.class)
class ClientConfig { }

// configured in application.yml under the group name,
// then injected like any other bean

Resilience: the Polly answer#

This is the clearest Boot 3 versus Boot 4 difference in day-to-day code. Spring Framework 7 moved core retry support into org.springframework.core.retry, so @Retryable no longer needs a separate project.

// Boot 3.5. Resilience4j, added as a dependency
@Service
class RatesClient {

    @Retry(name = "rates", fallbackMethod = "cached")
    @CircuitBreaker(name = "rates", fallbackMethod = "cached")
    @Bulkhead(name = "rates")
    public Rate fetch(String pair) {
        return restClient.get()
                .uri("/rates/{p}", pair)
                .retrieve().body(Rate.class);
    }

    private Rate cached(String pair, Throwable t) {
        return cache.get(pair);
    }
}
// Boot 4.0, @Retryable and @ConcurrencyLimit are in
// Spring Framework core; no extra dependency
@EnableResilientMethods
@Configuration
class ResilienceConfig { }

@Service
class RatesClient {

    // core defaults: 3 retries, 1s delay, retries any exception
    @Retryable(includes = RatesUnavailableException.class,
               maxRetries = 4, delay = 200, multiplier = 2,
               jitter = 20, maxDelay = 2000)
    @ConcurrencyLimit(10)
    public Rate fetch(String pair) {
        return restClient.get()
                .uri("/rates/{p}", pair)
                .retrieve().body(Rate.class);
    }
}
PollyBoot 3 (Resilience4j)Boot 4 (Spring Framework core)
Retry@Retry@Retryable
Bulkhead@Bulkhead@ConcurrencyLimit
Circuit breaker@CircuitBreakernot in core; keep Resilience4j
Rate limiter@RateLimiternot in core; keep Resilience4j
Timeout@TimeLimiternot in core; set a request-factory timeout
FallbackfallbackMethod@Recover
ProgrammaticRetryRegistryRetryTemplate with RetryPolicy.builder()
Gotcha

The attribute is maxRetries, not maxAttempts. Spring Retry, the Boot 3 add-on, uses maxAttempts and counts the first call. Framework 7's core annotation uses maxRetries and counts only the retries, so the default of 3 means four calls in total, one second apart, retrying on any exception. Pasting a Boot 3 snippet into a Boot 4 codebase fails to compile, which is the good outcome. Carrying the wrong mental model of the count is the bad one.

Note

Core covers retry and concurrency limiting only. There is no circuit breaker, rate limiter, bulkhead or timeout in Spring Framework, so Resilience4j remains the answer for those on both versions. What the core additions buy is one less dependency for the two cases most services actually need.

@Retryable also works on reactive methods, decorating the returned Mono or Flux with a Reactor retry, and every failed attempt publishes a MethodRetryEvent you can listen to for logging.

Gotcha

@Retryable, like @Transactional and @PreAuthorize, is proxy-based; see How Spring actually works. Self-invocation does not retry. And be careful retrying non-idempotent operations; a POST that timed out may well have succeeded.

Timeouts#

Gotcha

Set timeouts explicitly. The defaults are effectively infinite. A hung downstream will otherwise consume a connection until the socket dies. This is true of RestClient, RestTemplate and the JDK client alike.

@Bean
RestClient ratesClient(RestClient.Builder builder) {
    var factory = new SimpleClientHttpRequestFactory();
    factory.setConnectTimeout(Duration.ofSeconds(2));
    factory.setReadTimeout(Duration.ofSeconds(5));
    return builder.baseUrl(url).requestFactory(factory).build();
}

Error handling#

Rate rate = restClient.get()
        .uri("/rates/{p}", pair)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError,
                  (req, res) -> { throw new UnknownPairException(pair); })
        .onStatus(HttpStatusCode::is5xxServerError,
                  (req, res) -> { throw new RatesUnavailableException(); })
        .body(Rate.class);
SituationRestClient default
4xxthrows HttpClientErrorException
5xxthrows HttpServerErrorException
Want the status, not an exceptionuse .exchange(...) instead of .retrieve()

RestTemplate#

LegacyRestTemplate

The original Spring HTTP client, and still in most existing codebases. It is in maintenance mode; not deprecated, but no new features, and the documentation steers you to RestClient, which wraps the same infrastructure with a better API.

Rate rate = restTemplate.getForObject(
        "/rates/{p}", Rate.class, pair);

Migration is mostly mechanical, and a RestClient can be built from an existing RestTemplate's configuration with RestClient.create(restTemplate).

Messaging and WebSockets#

Part P9 · ASP.NET Core to Spring Boot · Chapter 45· 2 min read

Spring's messaging abstractions map closely to what you know. @KafkaListener and @RabbitListener are the consumer side, and a Template class is the producer side, in the same shape as MassTransit or the raw client libraries.

For push to the browser there is no single SignalR equivalent. Spring gives you raw WebSocket, a STOMP broker on top of it, and server-sent events; SignalR's automatic transport fallback and hub proxies have no counterpart.

Kafka#

Confluent.Kafka / MassTransit
// producer
await _producer.ProduceAsync("orders",
    new Message<string, string> {
        Key = order.Id, Value = json });

// consumer
_consumer.Subscribe("orders");
while (!ct.IsCancellationRequested)
{
    var cr = _consumer.Consume(ct);
    Handle(cr.Message.Value);
}
Spring Kafka
// producer
kafkaTemplate.send("orders", order.id(), order);

// consumer
@KafkaListener(topics = "orders",
               groupId = "billing")
public void handle(OrderPlaced order) {
    process(order);
}
spring:
  kafka:
    bootstrap-servers: localhost:9092
    consumer:
      group-id: billing
      auto-offset-reset: earliest
      properties:
        spring.json.trusted.packages: "com.acme.billing.events"
    producer:
      acks: all
Gotcha

The listener method commits the offset by returning normally. If it throws, Spring's default error handler retries and then, after the configured attempts, gives up and moves on. So an unhandled exception can silently drop a message. Configure a DefaultErrorHandler with a DeadLetterPublishingRecoverer so failures land in a dead-letter topic rather than disappearing.

Ordering, retries and the outbox#

ConcernSpring Kafka
Orderingguaranteed per partition only; key by aggregate id to keep an entity's events ordered
Concurrencyconcurrency = "3" on @KafkaListener, capped by partition count
Manual acksAckMode.MANUAL plus an Acknowledgment parameter
Retry with backoffDefaultErrorHandler with an ExponentialBackOff
Dead letterDeadLetterPublishingRecoverer, publishes to topic.DLT
Batch consumptionbatch = "true" and a List parameter
TransactionsKafkaTransactionManager, but it does not span Kafka and your database
Gotcha

There is no distributed transaction across Kafka and your database. Publishing inside an @Transactional method that later rolls back still sends the message. The standard fix is the transactional outbox: write the event to a table in the same transaction, and let a separate poller publish it. This is the same problem and the same solution as in .NET, and it is discussed in Transactions in depth.

RabbitMQ#

C#
_bus.Publish(new OrderPlaced(order.Id));

public class Consumer : IConsumer<OrderPlaced>
{
    public Task Consume(
        ConsumeContext<OrderPlaced> ctx) { ... }
}
Java 25
rabbitTemplate.convertAndSend(
    "orders.exchange", "order.placed", event);

@RabbitListener(queues = "orders.queue")
public void handle(OrderPlaced event) { ... }
ConceptSpring AMQP
Declare topology@Bean Queue / TopicExchange / Binding
SerialisationJackson2JsonMessageConverter, registered as a bean
Retryspring.rabbitmq.listener.simple.retry.*
Dead letterx-dead-letter-exchange argument on the queue
Manual ackAcknowledgeMode.MANUAL plus a Channel parameter
Note

MassTransit and NServiceBus do a great deal that Spring AMQP leaves to you: saga state, scheduling, conventional routing. The nearest Java equivalents are Spring Integration, Apache Camel and Axon, and none of them is a drop-in replacement.

WebSockets and the SignalR gap#

SignalR

There is no Java framework that bundles what SignalR does. SignalR gives you a hub abstraction, automatic transport negotiation and fallback, typed client proxies, reconnection and backplane scale-out in one package. Spring gives you the pieces:

SignalR featureSpring equivalent
Hub@Controller with @MessageMapping, over STOMP
Strongly typed hub clientnone; you send to a destination by name
Transport fallbacknone; WebSocket with a SockJS fallback, and SockJS is legacy
GroupsSTOMP destinations, or a broker topic
Backplane for scale-outan external broker: RabbitMQ or ActiveMQ as a STOMP relay
Automatic reconnectclient-side library concern
@Configuration
@EnableWebSocketMessageBroker
public class WsConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").setAllowedOriginPatterns("*");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic");     // in-memory, single instance
        registry.setApplicationDestinationPrefixes("/app");
    }
}

@Controller
class PriceController {

    // client sends to /app/subscribe, replies go to /topic/prices
    @MessageMapping("/subscribe")
    @SendTo("/topic/prices")
    public Price onSubscribe(SubscribeRequest req) {
        return prices.current(req.pair());
    }
}

// push from anywhere in the application
messagingTemplate.convertAndSend("/topic/prices", price);
Gotcha

enableSimpleBroker is an in-memory broker. It works perfectly on one instance and breaks the moment you scale out, because a message published on instance A never reaches a client connected to instance B. For more than one replica you need enableStompBrokerRelay pointing at RabbitMQ or ActiveMQ, which is the equivalent of adding a SignalR backplane.

Server-sent events, the simpler option#

If you only need server to client push, and not full duplex, server-sent events are far less machinery than WebSocket and work through most proxies.

C#
Response.ContentType = "text/event-stream";
await Response.WriteAsync($"data: {json}\n\n");
await Response.Body.FlushAsync();
Java 25
@GetMapping(value = "/prices",
    produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream() {
    var emitter = new SseEmitter(0L);   // no timeout
    executor.submit(() -> {
        emitter.send(price);
        emitter.complete();
    });
    return emitter;
}
Note

On WebFlux the equivalent is simply returning a Flux<Price> with the same produces. And on virtual threads, an SseEmitter fed from a blocking loop is now a reasonable design where it once would not have been. See Threads are cheap now.

Actuator and observability#

Part P9 · ASP.NET Core to Spring Boot · Chapter 46· 3 min read

Actuator is health checks, metrics and diagnostics in one dependency, more than ASP.NET Core gives you out of the box. Micrometer is the metrics facade, the way SLF4J is the logging facade.

The thing with no .NET equivalent is JFR: a production-grade profiler built into the JDK that you can turn on against a running process.

Actuator endpoints#

EndpointGives you.NET analogy
/actuator/healthliveness and readinessAddHealthChecks
/actuator/metricsevery metric, queryabledotnet-counters
/actuator/prometheusPrometheus scrape formatprometheus-net
/actuator/infobuild and git info
/actuator/envresolved configuration
/actuator/loggersread AND CHANGE log levels at runtimeno equivalent
/actuator/threaddumpevery thread's stackdotnet-dump
/actuator/heapdumpa heap dump filedotnet-gcdump
/actuator/mappingsevery route
/actuator/beansthe whole container
/actuator/configpropsbound @ConfigurationProperties
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,loggers
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true        # /health/liveness and /health/readiness for Kubernetes
Gotcha

Only health is exposed over HTTP by default, everything else must be opted in. Do not expose env, heapdump, beans or configprops publicly; they leak configuration and memory contents. Put Actuator on a separate port (management.server.port) that is not routed from the internet, or secure it.

Note

/actuator/loggers is worth knowing about: you can raise the log level for one package on a running production instance with a POST, gather what you need, and put it back; no restart, no redeploy. There is no ASP.NET Core equivalent.

Custom health checks#

C#
builder.Services.AddHealthChecks()
    .AddCheck<RatesHealthCheck>("rates");

public class RatesHealthCheck : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(...)
        => Task.FromResult(HealthCheckResult.Healthy());
}
Java 25
@Component
public class RatesHealthIndicator implements HealthIndicator {

    @Override
    public Health health() {
        try {
            ratesClient.ping();
            return Health.up()
                    .withDetail("latencyMs", ms).build();
        } catch (Exception e) {
            return Health.down(e).build();
        }
    }
}
Note

The bean name determines the key in the health response, RatesHealthIndicator appears as rates. Spring Boot registers indicators automatically for the datasource, Redis, Kafka and most other configured infrastructure, so a great deal of this is free.

Metrics#

C#
var counter = meter.CreateCounter<long>("orders.placed");
counter.Add(1, new("status", "ok"));
Java 25
private final Counter placed;

OrderService(MeterRegistry registry) {
    this.placed = Counter.builder("orders.placed")
            .tag("status", "ok")
            .register(registry);
}

placed.increment();
InstrumentMicrometerUse for
CounterCountermonotonic counts
GaugeGaugea current value
HistogramDistributionSummarya distribution of values
Timer / durationTimerlatency
n/aLongTaskTimerin-flight long operations
// declarative timing, the equivalent of a filter
@Timed(value = "orders.place", percentiles = {0.5, 0.95, 0.99})
public Order place(CreateOrder cmd) { ... }
Gotcha

Never tag a metric with something unbounded: a user ID, an order ID, a raw URL. Each distinct tag combination is a separate time series, and a high-cardinality tag will take down your metrics backend before it takes down your application. This is the same rule as in Prometheus generally, but Micrometer makes it easy to do by accident inside a loop.

Logging#

C#
private readonly ILogger<OrderService> _log;

_log.LogInformation("Placed order {OrderId} for {Total}",
    id, total);
Java 25
private static final Logger log =
        LoggerFactory.getLogger(OrderService.class);

log.info("Placed order {} for {}", id, total);
.NETJavaNote
ILogger<T>SLF4J Loggeryou code against SLF4J; Logback does the actual writing
SerilogLogbackthe Boot default implementation
NLogLog4j2
{Named} placeholders{} positional placeholdersSLF4J has no names
BeginScopeMDCthread-local key/value pairs
appsettings logging levelslogging.level.* in application.yml
Gotcha

SLF4J placeholders are {} and positional, not named. And use them, log.debug("x is {}", expensive()) still calls expensive(), but log.debug("x is " + expensive()) also builds the string even when debug is off. For a genuinely expensive argument, guard with if (log.isDebugEnabled()) or pass a Supplier.

logback-spring.xml and JSON logging#

application.yml covers levels and a console pattern. Anything more, such as JSON output, per-appender routing or file rotation, needs a Logback configuration file. Name it logback-spring.xml rather than logback.xml: the -spring form is loaded by Boot, which means Spring properties and profiles work inside it.

<!-- src/main/resources/logback-spring.xml -->
<configuration>
  <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

  <springProfile name="!prod">
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <root level="INFO"><appender-ref ref="CONSOLE"/></root>
  </springProfile>

  <springProfile name="prod">
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
      <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>correlationId</includeMdcKeyName>
      </encoder>
    </appender>
    <root level="INFO"><appender-ref ref="JSON"/></root>
  </springProfile>
</configuration>
Serilog conceptLogback equivalent
Sinkappender
EnricherMDC, or a custom converter
JSON formatterLogstashEncoder, or Boot's own structured logging
Minimum level override per namespacelogging.level.com.acme in application.yml
Rolling fileRollingFileAppender with a TimeBasedRollingPolicy
Environment-specific configspringProfile blocks, as above
Note

Spring Boot 3.4 added built-in structured logging, so logging.structured.format.console=ecs in application.yml gives you JSON without the Logstash encoder dependency. Prefer that on a recent Boot; reach for the XML only when you need routing or rotation it does not cover.

Tracing#

Micrometer Tracing plus OpenTelemetry gives you distributed tracing. Boot 4 adds a dedicated starter Boot 4.

<!-- Boot 4 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>
Note

The alternative, often the better one, is the OpenTelemetry Java agent, attached with -javaagent:opentelemetry-javaagent.jar. It instruments Spring, JDBC, HTTP clients and Kafka with zero code changes. There is no equivalent in .NET because the CLR does not support this style of bytecode instrumentation at load time. If you want tracing across a fleet of existing services quickly, this is how.

JFR: the tool with no .NET equivalent#

Java Flight Recorder is a always-on, low-overhead (roughly 1%) event recorder built into the JDK. You can start it against a running production process without a restart.

# start a recording on a running process
jcmd <pid> JFR.start name=diag settings=profile duration=60s filename=/tmp/rec.jfr

# or from launch
java -XX:StartFlightRecording=duration=60s,filename=rec.jfr -jar app.jar

jcmd <pid> JFR.dump name=diag filename=/tmp/now.jfr

Open the file in JDK Mission Control and you get allocation profiles, lock contention, GC pauses, I/O, exceptions thrown, and CPU sampling, correlated on one timeline. Java 25 added CPU-time profiling Java 25 experimental and method timing and tracing events.

Need.NETJava
Continuous production profilinglimitedJFR
Deep CPU profileVisual Studio Profiler, PerfViewasync-profiler, JFR
Heap analysisdotnet-gcdumpjmap + Eclipse MAT
Thread dumpdotnet-dumpjcmd Thread.print
GC logsGC ETW events-Xlog:gc*

Caching, scheduling and events#

Part P9 · ASP.NET Core to Spring Boot · Chapter 47· 3 min read

Three cross-cutting abstractions you will meet within weeks. All three are proxy-based, so everything in How Spring actually works applies: no self-invocation, no final methods.

@Cacheable is IMemoryCache with the plumbing removed. @Scheduled is a BackgroundService timer, or Hangfire without the dashboard. ApplicationEventPublisher is in-process MediatR.

Caching#

C#
public async Task<Rate> GetRateAsync(string pair)
{
    if (_cache.TryGetValue(pair, out Rate hit))
        return hit;

    var rate = await _client.FetchAsync(pair);
    _cache.Set(pair, rate, TimeSpan.FromMinutes(5));
    return rate;
}
Java 25
@Cacheable("rates")
public Rate getRate(String pair) {
    return client.fetch(pair);
}
// lookup, miss handling and population
// are all done by the proxy
AnnotationDoes.NET equivalent
@Cacheablereturn the cached value, or call the method and cache itGetOrCreate
@CachePutalways call the method, then update the cacheSet
@CacheEvictremove an entryRemove
@CacheEvict(allEntries = true)clear the cacheClear
@Cachingcombine several of the aboveseveral calls
@EnableCachingswitch the whole mechanism onAddMemoryCache
@Configuration
@EnableCaching          // without this, every annotation below is inert
class CacheConfig { }

@Service
public class RatesService {

    @Cacheable(value = "rates", key = "#pair + ':' + #date")
    public Rate lookup(String pair, LocalDate date) { ... }

    @CacheEvict(value = "rates", key = "#rate.pair()")
    public void invalidate(Rate rate) { }

    @Cacheable(value = "rates", unless = "#result == null")
    public Rate maybeNull(String pair) { ... }

    @Cacheable(value = "rates", condition = "#pair.length() == 6")
    public Rate onlyValidPairs(String pair) { ... }
}
Gotcha

Forgetting @EnableCaching makes every cache annotation do nothing: silently, with no warning, and the code still works because it just calls the method every time. The same is true of @EnableScheduling and @EnableAsync. A caching layer that appears to have no effect is nearly always this.

Gotcha

condition and unless are not the same. condition is evaluated before the call and decides whether caching applies at all; unless is evaluated after and decides whether to store the result. Only unless can see #result.

ProviderAddNotes
Simple (ConcurrentHashMap)nothing; the defaultno eviction, no size limit, dev only
Caffeinecom.github.ben-manes.caffeinethe default choice for in-process
Redisspring-boot-starter-data-redisshared across instances
Hazelcast, Infinispantheir startersdistributed, clustered
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=5m
Gotcha

The default cache manager is an unbounded ConcurrentHashMap. It never evicts and never expires, so it is a memory leak wearing a cache costume. Configure Caffeine or Redis before anything reaches production.

Scheduling#

C#
public class ReconcileService : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            await ReconcileAsync();
            await Task.Delay(TimeSpan.FromMinutes(30), ct);
        }
    }
}
Java 25
@Component
public class ReconcileJob {

    @Scheduled(fixedDelay = 30, timeUnit = TimeUnit.MINUTES)
    public void reconcile() {
        ...
    }
}
AttributeMeans
fixedDelaywait this long after the previous run finishes
fixedRatestart this often, regardless of how long a run takes
initialDelaywait before the first run
cron = "0 0 3 * * *"six-field cron; note the leading seconds field
Gotcha

Spring cron expressions have six fields, not five. The first is seconds. A Unix crontab line pasted directly will be interpreted one field out, "0 3 * * *" does not mean 3am. Also note fixedRate does not run concurrently by default: if a run overruns, the next one queues rather than overlapping.

Gotcha

Every instance runs every schedule. Deploy three replicas and your nightly reconciliation runs three times. There is no built-in leader election. The standard answer is ShedLock, which takes a lock in a shared database or Redis:

@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "reconcile", lockAtMostFor = "30m")
public void reconcile() { ... }

For anything richer, retries, persistence, a dashboard, Quartz is the Hangfire equivalent.

Gotcha

The scheduler runs on a single thread by default, so one slow job delays every other job. Set spring.task.scheduling.pool.size, and on Java 21+ consider virtual threads for jobs that block.

Application events#

Spring's in-process publish/subscribe. The closest .NET analogue is MediatR's notifications, and it is used for the same purpose: decoupling a side effect from the action that caused it.

MediatR
public record OrderPlaced(long Id) : INotification;

await _mediator.Publish(new OrderPlaced(order.Id));

public class SendEmail : INotificationHandler<OrderPlaced>
{
    public Task Handle(OrderPlaced n, CancellationToken ct)
        => _email.SendAsync(n.Id);
}
Spring events
public record OrderPlaced(long id) { }

events.publishEvent(new OrderPlaced(order.getId()));

@Component
class SendEmail {

    @EventListener
    public void on(OrderPlaced e) {
        email.send(e.id());
    }
}
Gotcha

@EventListener is synchronous by default; the publisher blocks until every listener returns, on the same thread, inside the same transaction. That is often what you want, but it means a slow listener slows the request and a throwing listener fails the caller.

Add @Async to run it on another thread, but note that this then escapes the transaction; see @TransactionalEventListener in Transactions.

AnnotationRuns
@EventListenersynchronously, in the caller's thread and transaction
@EventListener(condition = "#e.total > 100")only when the SpEL condition holds
@Async @EventListeneron another thread; outside the transaction
@TransactionalEventListenerafter the transaction commits; the safe default for side effects

@Async#

C#
_ = Task.Run(() => _reports.Rebuild());
Java 25
@Async
public void rebuild() { ... }        // returns immediately

@Async
public CompletableFuture<Report> build() {
    return CompletableFuture.completedFuture(...);
}
Gotcha

@Async is proxy-based like the rest, so a self-invocation runs synchronously, which is a particularly nasty failure because the code still works, just on the wrong thread, and only under load does it matter.

It also needs @EnableAsync, and an @Async method returning a plain value rather than void or CompletableFuture silently discards the result.

Note

On Java 21+, point the executor at virtual threads and @Async stops needing a tuned pool size:

spring:
  threads:
    virtual:
      enabled: true

Testing Spring Boot#

Part P9 · ASP.NET Core to Spring Boot · Chapter 48· 2 min read

@SpringBootTest is WebApplicationFactory<T>: it boots the real application context for an integration test. Test slices, @WebMvcTest, @DataJpaTest: start only one layer, and have no ASP.NET Core equivalent.

Two Boot 4 changes will break Boot 3 test code: @MockBean is removed, and @SpringBootTest no longer auto-provides MockMvc.

Test slices#

AnnotationStartsUse for
@SpringBootTestthe whole contextend-to-end integration
@WebMvcTest(X.class)web layer only; no databasecontroller tests
@DataJpaTestJPA and an in-memory or container DBrepository tests
@JdbcTestJDBC onlyJdbcTemplate tests
@JsonTestJackson onlyserialisation tests
@RestClientTestHTTP client + mock serveroutbound client tests
(no annotation)nothing: plain JUnitunit tests, which should be most of them
Note

Slices are fast because they start a fraction of the context, and Spring caches contexts across test classes with the same configuration. The corollary: every distinct configuration creates a new context, so scattering different @TestPropertySource values across many classes silently multiplies your suite's runtime.

Testing a controller#

ASP.NET Core
public class OrdersTests
    : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    [Fact]
    public async Task Get_ReturnsOrder()
    {
        var res = await _client.GetAsync("/api/orders/1");
        res.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}
Spring Boot 4
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;

    @MockitoBean OrderService svc;    // Boot 4 name

    @Test
    void get_returnsOrder() throws Exception {
        when(svc.find(1L)).thenReturn(Optional.of(anOrder()));

        mvc.perform(get("/api/orders/1"))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.id").value(1));
    }
}
Gotcha

@MockBean and @SpyBean are removed in Boot 4. Not deprecated, removed. Replace with @MockitoBean and @MockitoSpyBean, which were introduced in Boot 3.4, so on 3.4+ you can migrate before upgrading.

// Boot 3.5 and earlier
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;

    @MockBean OrderService svc;
    @SpyBean  AuditService audit;
}
// Boot 4.0, @MockBean / @SpyBean no longer exist
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;

    @MockitoBean    OrderService svc;
    @MockitoSpyBean AuditService audit;
}
Gotcha

Boot 4 no longer auto-configures MockMvc under @SpringBootTest. You must add @AutoConfigureMockMvc explicitly. Under @WebMvcTest it is still provided. A Boot 3 integration test that injects MockMvc will fail to start after upgrading, with a “no qualifying bean” error that does not obviously point at this change.

// Boot 3.5. MockMvc arrives automatically
@SpringBootTest
class OrderIntegrationTest {
    @Autowired MockMvc mvc;
}
// Boot 4.0, must be requested explicitly
@SpringBootTest
@AutoConfigureMockMvc
class OrderIntegrationTest {
    @Autowired MockMvc mvc;
}

Full integration tests#

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@Testcontainers
class OrderIntegrationTest {

    @Container
    @ServiceConnection                       // wires the datasource automatically
    static PostgreSQLContainer<?> db =
            new PostgreSQLContainer<>("postgres:16");

    @Autowired TestRestTemplate rest;

    @Test
    void placesAnOrder() {
        var res = rest.postForEntity("/api/orders", aCommand(), OrderDto.class);
        assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }
}
Note

@ServiceConnection replaces the old @DynamicPropertySource boilerplate that copied container URLs into properties. Annotate the container and Boot wires the datasource, Redis connection or Kafka bootstrap servers for you. It is one of the best quality-of-life features in modern Boot testing.

ClientUse for
MockMvcfast; no real server, no network
TestRestTemplatea real HTTP call against a random port
WebTestClientfluent, works for MVC and WebFlux
RestTestClientnew in Boot 4; a test client for RestClient

Testing repositories#

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)   // use the real DB, not H2
@Testcontainers
class OrderRepositoryTest {

    @Container @ServiceConnection
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16");

    @Autowired OrderRepository repo;

    @Test
    void findsByCustomer() {
        repo.save(anOrder("cust-1"));
        assertThat(repo.findByCustomerId("cust-1")).hasSize(1);
    }
}
Gotcha

@DataJpaTest replaces your datasource with an in-memory H2 by default and wraps each test in a transaction that rolls back. Both defaults cause trouble: H2 does not behave like PostgreSQL for native queries, JSON columns or sequences, and the rollback hides constraint violations that only fire on commit. Use Testcontainers with Replace.NONE, as above.

Test configuration#

NeedAnnotation
Override properties@TestPropertySource(properties = "x=y")
Activate a profile@ActiveProfiles("test")
Add or replace beans@TestConfiguration + @Import
Replace one bean with a mock@MockitoBean
Reset context after a test@DirtiesContext: use sparingly, it is slow
@TestConfiguration
static class TestClock {
    @Bean Clock clock() {
        return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
    }
}
Gotcha

@DirtiesContext discards the cached application context, so the next test class pays full startup cost again. One careless @DirtiesContext on a frequently run class can add minutes to a suite. Prefer resetting state explicitly.

Keep most tests plain#

Note

The most common mistake a team makes with Spring is testing everything through @SpringBootTest. Context startup dominates, the suite slows to minutes, and the tests tell you little about your logic.

Domain logic should be tested with plain JUnit and no Spring at all, which is achievable precisely because constructor injection means your services are ordinary classes you can new. Reserve slices for the wiring, and full @SpringBootTest for a handful of end-to-end paths.

Migrating Spring Boot 3 to 4#

Part P9 · ASP.NET Core to Spring Boot · Chapter 49· 3 min read

Spring Boot 4.0 sits on Spring Framework 7 and Jakarta EE 11. It is not the wrenching change that Boot 2 to 3 was; that one renamed javax to jakarta across the entire ecosystem, but four things will break your build on day one: renamed starters, Jackson 3’s package move, removed test annotations (including MockMvc no longer being auto-configured), and the switch to JSpecify.

Baseline requirements#

RequirementBoot 3.5Boot 4.0
Java17+17+, 25 recommended
Spring Framework6.x7.x
Jakarta EE10 (Servlet 6.0)11 (Servlet 6.1)
Kotlin1.9+2.2+
GraalVM22+25+
JUnit56
Gradle8.x9 supported, 8.14+ still works

1. Renamed starters#

Modules follow a uniform spring-boot-<technology> pattern, with root packages org.springframework.boot.<technology>.

Boot 3.5Boot 4.0
spring-boot-starter-webspring-boot-starter-webmvc
spring-boot-starter-web-servicesspring-boot-starter-webservices
spring-boot-starter-oauth2-clientspring-boot-starter-security-oauth2-client
spring-boot-data-mongodb (health indicators)spring-boot-mongodb
Boot 3 pom
<parent>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.5.x</version>
</parent>

<dependency>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
Boot 4 pom
<parent>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.x.x</version>
</parent>

<dependency>
  <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>

2. Jackson 2 to Jackson 3#

Boot 4 defaults to Jackson 3, whose root package is tools.jackson rather than com.fasterxml.jackson. Jackson 2 support still ships, deprecated.

Boot 3.5Boot 4.0
com.fasterxml.jackson.*tools.jackson.*
@JsonComponent@JacksonComponent
@JsonMixin@JacksonMixin
JsonObjectSerializerObjectValueSerializer
JsonValueDeserializerObjectValueDeserializer
Jackson2ObjectMapperBuilderCustomizerJsonMapperBuilderCustomizer
Jackson2ObjectMapperBuilderremoved, use Jackson's own builders
spring.jackson.read.*spring.jackson.json.read.*
spring.jackson.write.*spring.jackson.json.write.*
Gotcha

The com.fasterxml.jackson to tools.jackson move is a root package rename, so every import line in every file that touches Jackson changes. A find-and-replace handles most of it, but check for libraries in your dependency tree that still expose Jackson 2 types in their own APIs; those will pull Jackson 2 back in alongside Jackson 3.

3. Test annotations#

Boot 3.5Boot 4.0Note
@MockBean@MockitoBeanREMOVED, not deprecated
@SpyBean@MockitoSpyBeanREMOVED, not deprecated
MockitoTestExecutionListenerMockito's MockitoExtension
@SpringBootTest gives MockMvcadd @AutoConfigureMockMvc
@AutoConfigureMockMvc(htmlUnit-related)@AutoConfigureMockMvc(htmlUnit = @HtmlUnit(...))
@PropertyMappingmoved to org.springframework.boot.test.contextsame annotation, new package
Note

@MockitoBean and @MockitoSpyBean exist from Boot 3.4 onwards. If you are on 3.4 or 3.5, migrate these before you upgrade; it removes a whole class of breakage from the upgrade commit.

4. JSpecify null-safety#

Spring migrated its whole API from JSR-305 to JSpecify annotations. If you referenced org.springframework.lang.Nullable in your own code, switch to org.jspecify.annotations.Nullable.

Note

The upside: Spring's signatures now carry precise nullness including generic type arguments, so a null-checker such as NullAway gives you meaningfully better results against Spring APIs than it did on Boot 3. See Nullability.

Removals in Spring Framework 7#

RemovedReplacement
javax.annotation / javax.inject supportthe jakarta.* equivalents
spring-jclstandard SLF4J and Logback
ListenableFutureCompletableFuture
Undertow supportTomcat, Jetty, or Netty
suffixPatternMatch and similar path optionsexplicit mappings
Jackson2ObjectMapperBuilderJackson's native builders
Certificate validity threshold in SSL infon/a
Gotcha

Undertow support is gone. If your service runs on Undertow you must switch to Tomcat, Jetty or Netty as part of the upgrade. This is easy to miss because it is a dependency swap rather than a compile error.

What you gain#

FeatureDetail
@Retryable and @ConcurrencyLimit in coreorg.springframework.core.retry, via @EnableResilientMethods
API versioningspring.mvc.apiversion.*, spring.webflux.apiversion.*
HTTP service client auto-config@ImportHttpServices for @HttpExchange interfaces
BeanRegistrarprogrammatic bean registration, AOT-friendly
JmsClienta unified JMS send/receive API alongside JmsTemplate
spring-boot-starter-opentelemetryfirst-class OTel starter
spring-boot-starter-kotlin-serializationKotlin serialization support
RestTestClienta test client for RestClient

Configuration property renames#

Boot 3.5Boot 4.0
spring.jackson.read.*spring.jackson.json.read.*
spring.jackson.write.*spring.jackson.json.write.*
spring.data.mongodb.* (driver-level)spring.mongodb.*
spring.session.redisspring.session.data.redis
Note

spring.data.mongodb.auto-index-creation stays where it is; it is a Spring Data setting rather than a driver setting, and the split is deliberate.

A suggested order#

StepDo
1Get to Boot 3.5 and Java 17+ first; fix all deprecation warnings
2Replace @MockBean and @SpyBean with @MockitoBean and @MockitoSpyBean
3Move off Undertow if you are on it
4Bump the parent to Boot 4.0; fix the starter names
5Run the build; work through Jackson import errors
6Add @AutoConfigureMockMvc wherever @SpringBootTest injected MockMvc
7Check the property renames above against your application.yml
8Consider dropping Resilience4j for core @Retryable where it fits
Gotcha

Do not combine a Boot 4 upgrade with a Java version upgrade in the same change. Each produces its own class of failure, and separating them makes both far easier to diagnose.

Check yourself: ASP.NET Core to Spring Boot
1You added @Transactional and nothing happens. Name three possible causes.
Self-invocation through this, a private method, or a final class or method that CGLIB cannot subclass. All three defeat the proxy.
2Your service class has a mutable field. What is the risk?
Spring beans are singletons by default, the opposite of ASP.NET Core's explicit lifetime choice. That field is shared across every request thread.
3An audit row must survive the caller's rollback. Which propagation, and what is the danger?
REQUIRES_NEW. It takes a second connection and the suspended outer transaction still holds its locks, so touching the same rows deadlocks under load.
4You are upgrading to Boot 4 and your tests will not compile. What changed?
@MockBean and @SpyBean were removed, not deprecated. Use @MockitoBean and @MockitoSpyBean, and add @AutoConfigureMockMvc where @SpringBootTest used to provide MockMvc.

Packaging and native images#

Part P10 · Ship it · Chapter 50· 3 min read

The Java equivalent of dotnet publish is a fat JAR; your code, every dependency and an embedded web server in one file that java -jar runs. It needs a JVM present, so it is not self-contained the way a .NET single-file publish is.

For a true standalone binary, GraalVM native-image is NativeAOT: millisecond startup, small memory, at the cost of build time and reflection restrictions.

The options#

ApproachProducesNeeds a JVM installed.NET analogy
Plain JARjust your classesyes, plus the classpatha bare dll
Fat / uber JAReverything in one JARyesframework-dependent publish
jlinka trimmed JVM + your appnoself-contained publish
jpackagea platform installernoMSI / dmg installer
GraalVM native-imageone native binarynoNativeAOT

The fat JAR#

./mvnw package
java -jar target/billing-1.0.0.jar

Spring Boot's plugin produces an executable JAR with a nested layout that keeps dependency JARs intact rather than merging their classes, and builds the classpath from them when it starts. That avoids the classic shading problem where two dependencies each ship a META-INF/services file and one silently wins.

TaskCommand
Build it./mvnw package
Run itjava -jar target/app.jar
Run with a profilejava -jar app.jar --spring.profiles.active=prod
Override a propertyjava -jar app.jar --server.port=9090
Set JVM optionsjava -Xmx512m -jar app.jar
Inspect the layersjava -Djarmode=tools -jar app.jar list-layers

Containers#

The naive Dockerfile copies the fat JAR and re-downloads every layer on any code change. Layered JARs fix that: dependencies change rarely, your classes change constantly.

# build
FROM eclipse-temurin:25-jdk AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -B dependency:go-offline          # cached unless pom.xml changes
COPY src ./src
RUN ./mvnw -B package -DskipTests
RUN java -Djarmode=tools -jar target/*.jar extract --layers --launcher

# run
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /app/target/extracted/dependencies/ ./
COPY --from=build /app/target/extracted/spring-boot-loader/ ./
COPY --from=build /app/target/extracted/snapshot-dependencies/ ./
COPY --from=build /app/target/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "app.jar"]
Note

Spring Boot can also build an optimised image with no Dockerfile at all, using Cloud Native Buildpacks:

./mvnw spring-boot:build-image

It picks a JRE, applies layering, sets sensible memory flags for the container, and runs as a non-root user. It is the closest thing to dotnet publish /t:PublishContainer and is a good default for teams who do not want to maintain a Dockerfile.

ToolProducesUse for
jlinka custom runtime image with only the modules you needshrinking a container
jpackagea native installer: msi, dmg, debdesktop distribution
# a minimal runtime containing only what the app uses
jlink --add-modules java.base,java.sql,java.naming \
      --strip-debug --no-header-files --no-man-pages \
      --compress=zip-9 --output custom-jre

# a platform installer around it
jpackage --name Billing --input target/ --main-jar billing-1.0.0.jar \
         --runtime-image custom-jre --type dmg
Gotcha

jlink requires everything to be modular or an automatic module. Many libraries still are not, which is why fat JARs and containers dominate on the server. It is much more useful for desktop apps and CLI tools than for Spring Boot services.

GraalVM native-image#

NativeAOT
<PublishAot>true</PublishAot>

dotnet publish -r linux-x64 -c Release
GraalVM native-image
<!-- Maven, with the Spring Boot parent -->
./mvnw -Pnative native:compile
./target/billing

<!-- Gradle -->
./gradlew nativeCompile
AspectJVM fat JARNative image
Startup1-3 seconds30-60 milliseconds
Memory at rest200-400 MB50-100 MB
Peak throughputhigher; the JIT wins over timelower
Build timesecondsminutes
Reflectionfreemust be registered
Debugging in productionJFR, full toolingmore limited
Gotcha

Native image uses closed-world analysis: everything reachable must be known at build time. Reflection, dynamic proxies, resource loading and JNI all need explicit registration or they fail at runtime, not build time.

Spring's own closed-world assumption adds more. Bean definitions are fixed at build time, so beans cannot appear or disappear at runtime, @Profile is restricted, and @ConditionalOnProperty is not supported. A feature toggle that switches a bean on by configuration works on the JVM and silently does not in a native image, which is a genuinely nasty way to find out.

Spring Boot's AOT processing generates most of the required metadata for you, which is why Spring native works at all, but a library that does its own reflection may need hints:

@RegisterReflectionForBinding(OrderDto.class)
@Configuration
class NativeHints { }
Use native image whenUse the JVM when
Serverless, scale-to-zeroLong-running services
CLI toolsPeak throughput matters
Very high instance countsYou need JFR and full diagnostics
Memory is the binding constraintBuild time matters

Startup without going native#

Java 24 and 25 shipped ahead-of-time class loading and linking work; an AOT cache that records the classes an application loads during a training run and reuses that on subsequent starts.

# Java 25: one-command AOT cache creation
java -XX:AOTCacheOutput=app.aot -jar app.jar     # training run
java -XX:AOTCache=app.aot -jar app.jar           # subsequent runs start faster
Note

Java 25 finalised ahead-of-time command-line ergonomics and ahead-of-time method profiling Java 25. Combined with compact object headers Java 25, which shrinks every object by several bytes, JVM startup and footprint on 25 are noticeably better than on 21, often enough to make native image unnecessary.

LegacyWAR files and application servers

Before embedded servers, you built a WAR and deployed it into a shared Tomcat, JBoss or WebSphere. That model is largely gone: Spring Boot produces a JAR with the server inside it, one process per service, which is what containers want.

You will still meet WARs in older enterprise estates, and Spring Boot can still produce one by setting <packaging>war</packaging> and extending SpringBootServletInitializer. Do not choose it for anything new.

The JVM at runtime: memory, GC and containers#

Part P10 · Ship it · Chapter 51· 3 min read

The JVM is more configurable than the CLR and more likely to need it. The two things to get right: heap sizing in a container, and which collector.

Modern defaults are good, G1 with container awareness, so start by changing nothing, measure with JFR, and tune only what the data points at.

The memory model#

Total process memory
├── Heap                    -Xmx controls THIS ONLY
│   ├── Young generation    short-lived objects
│   └── Old generation      survivors
├── Metaspace               class metadata; grows unbounded by default
├── Code cache              JIT-compiled code
├── Thread stacks           ~1 MB per PLATFORM thread
├── GC overhead             structures the collector needs
└── Direct / native memory  ByteBuffers, Netty, JNI
Gotcha

-Xmx is not a limit on the process. It bounds the heap only. A container killed with OOMKill while heap usage looks fine is almost always metaspace, thread stacks, or direct buffers. Budget roughly 25-30% above -Xmx for everything else, or use -XX:MaxRAMPercentage and let the JVM do the arithmetic.

.NETJavaNote
Server GCG1 (default)
Workstation GCSerialGC
GCHeapHardLimit-Xmx / -XX:MaxRAMPercentage
DOTNET_GCHeapCount-XX:ParallelGCThreads
GC.Collect()System.gc()a hint; usually ignored
Gen0/1/2Young / OldG1 is region-based, not strictly generational
LOHhumongous regions in G1large objects get their own regions, and collect poorly
GCSettings.LatencyModechoice of collector
runtimeconfig.json / env
{
  "configProperties": {
    "System.GC.Server": true,
    "System.GC.HeapHardLimitPercent": 70
  }
}
JVM flags
java -XX:+UseG1GC \
     -XX:MaxRAMPercentage=70 \
     -XX:MaxMetaspaceSize=256m \
     -XX:+ExitOnOutOfMemoryError \
     -jar app.jar

Containers#

The JVM has been container-aware since Java 10; it reads cgroup limits rather than the host's CPU and memory. But the default heap is only a fraction of the limit.

# a 1 GB container: the default max heap is ~25% of it, i.e. ~256 MB
docker run -m 1g eclipse-temurin:25 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize

# take a deliberate share instead
java -XX:MaxRAMPercentage=70 -jar app.jar
Gotcha

Use -XX:MaxRAMPercentage rather than a fixed -Xmx in a container. A hardcoded -Xmx2g in an image that later runs with a 1 GB limit gets OOMKilled; a percentage adapts. And always set -XX:MaxMetaspaceSize, metaspace is unbounded by default and a class-loading leak will consume the container.

FlagDoes
-XX:MaxRAMPercentage=70heap as a share of the container limit
-XX:InitialRAMPercentage=70avoid heap resizing churn
-XX:MaxMetaspaceSize=256mbound class metadata
-XX:+ExitOnOutOfMemoryErrordie rather than limp; let the orchestrator restart you
-XX:+HeapDumpOnOutOfMemoryErrorwrite a dump for analysis
-XX:HeapDumpPath=/dumpswhere to write it
-Xss512ksmaller platform thread stacks

Choosing a collector#

CollectorPause timesThroughputUse when
SerialGChighfine for tiny heapssmall containers, CLI tools
ParallelGChigh, but efficienthighestbatch jobs; latency does not matter
G1 (default)~10-200 msvery goodalmost everything
ZGCunder 1 msslightly lowerlarge heaps, latency-critical
Shenandoahunder 1 msslightly loweras ZGC; generational since Java 25
java -XX:+UseG1GC        -jar app.jar    # default; leave it alone
java -XX:+UseZGC         -jar app.jar    # generational by default since Java 23
java -XX:+UseSerialGC    -jar app.jar    # small container, single CPU
Note

ZGC's pause times are essentially independent of heap size. A 100 GB heap pauses about as long as a 4 GB one, because the work is concurrent. There is no .NET equivalent, even Server GC with background collection has pauses that scale with the heap. If you have a large-heap, latency-sensitive service, this is a genuine reason to prefer the JVM.

Diagnosing#

jcmd -l                              # list JVMs, like dotnet-counters ps
jcmd <pid> VM.flags                  # what flags are actually in effect
jcmd <pid> GC.heap_info              # heap usage now
jcmd <pid> Thread.print              # full thread dump
jcmd <pid> GC.class_histogram        # what is on the heap, by class
jcmd <pid> JFR.start duration=60s filename=/tmp/r.jfr

jmap -dump:live,format=b,file=heap.hprof <pid>   # heap dump for Eclipse MAT

java -Xlog:gc*:file=gc.log:time,uptime -jar app.jar   # GC logging
SymptomLook at
Container OOMKilled, heap looks finemetaspace, thread stacks, direct buffers
Long pausesGC logs; consider ZGC
High CPU, low throughputJFR CPU profile, or async-profiler
Memory grows steadilyheap dump, then Eclipse MAT dominator tree
Threads climbingthread dump; a leaked executor
Slow startupAOT cache, CDS, or the auto-configuration report
Gotcha

Thread stacks are the hidden cost of platform threads. Each reserves around 1 MB. A pool of 500 threads is 500 MB of address space before your application allocates anything. This is one more reason virtual threads matter: their stacks live on the heap and start at a few hundred bytes. See Threads are cheap now.

The JIT and warmup#

Note

The JVM starts interpreted, then compiles hot methods. C1 quickly, then C2 for the hottest. Peak performance therefore arrives after some thousands of executions, which is why Java benchmarks that do not warm up are worthless, and why JMH exists. It is BenchmarkDotNet's counterpart and handles warmup, forking and dead-code elimination for you.

The practical consequence in production: the first few hundred requests after a deploy are measurably slower. Java 25's AOT method profiling reduces this by carrying profile data from a training run. If you run a load balancer with aggressive health checks, give a new instance a moment before sending it full traffic.

Check yourself: Ship it
1Your container is OOMKilled but the heap graph looks flat. Where is the memory?
Outside the heap: metaspace, thread stacks or direct buffers. -Xmx bounds the heap only, so budget 25 to 30 percent above it for everything else.
2Why prefer -XX:MaxRAMPercentage to a fixed -Xmx in an image?
A hardcoded -Xmx2g in an image later run with a 1 GB limit gets OOMKilled. A percentage adapts to whatever limit the container is given.
3What does UnsupportedClassVersionError: class file version 69.0 mean?
The code was compiled by a newer JDK than the one running it. Subtract 44: 69 is Java 25, 65 is 21, 61 is 17. Check the build JDK against the runtime image.
4What is the Java equivalent of a self-contained dotnet publish?
A fat JAR, which still needs a JVM present. For a true standalone binary you need jlink, jpackage, or GraalVM native-image.

Appendix A: Java 9 to 25#

Part APP · Appendices · Chapter 52· 2 min read

What landed when, and, crucially, whether it is final, preview, or was withdrawn. Roughly half the Java features people blog about are still preview-only, and preview features need --enable-preview and can change or vanish between releases.

LTS releases are marked. Target an LTS.

Note

Java 21, 22, 24 and 25 below are verified against their openjdk.org project pages. Entries for 9 through 20 and for 23 are widely documented and stable, but were not re-verified for this edition; check openjdk.org/projects/jdk/<n> if you are relying on an exact release number.

Language features#

FeatureReleaseStatusC# analogue
var for locals10finalvar
Text blocks15finalraw string literals
Records16finalrecords
instanceof pattern16finalis T x
Sealed classes and interfaces17finalno equivalent
Switch expressions14finalswitch expressions
Pattern matching for switch21finalswitch on patterns
Record patterns21finalpositional patterns
Unnamed variables and patterns22finaldiscard _
Module import declarations25finalglobal using
Compact source files, instance main25finaltop-level statements
Flexible constructor bodies25finalno restriction to remove
Primitive types in patterns25PREVIEWrelational patterns
String templates21, 22WITHDRAWNinterpolation. Java has none
Gotcha

String templates were previewed in Java 21 and 22, then withdrawn. They are not in 23, 24 or 25. Java has no string interpolation, and any tutorial showing STR."..." is describing a feature that no longer exists. See var, strings and text blocks.

Concurrency#

FeatureReleaseStatusNote
CompletableFuture improvements9final
Virtual threads21finalthe async/await answer
Synchronize virtual threads without pinning24finalJEP 491, removes the synchronized trap
Scoped values25finalAsyncLocal analogue
Structured concurrency25PREVIEWfifth preview; API has churned
Stable values25PREVIEWLazy<T> analogue
Gotcha

Structured concurrency is still preview in Java 25. JEP 505, the fifth preview. Virtual threads are final and safe; the scope API around them is not. Anything you read from 2023 or 2024 uses an earlier API shape.

Library and API#

FeatureReleaseStatusNote
Collection factories List.of, Map.of9finalimmutable
Stream takeWhile, dropWhile, iterate9final
Optional.stream, ifPresentOrElse9final
New HTTP client (java.net.http)11finalHttpClient analogue
String isBlank, lines, strip, repeat11final
Files.readString, writeString11final
Collectors.teeing12final
Stream.toList()16finalreplaces collect(toList())
Sequenced collections21finalgetFirst, getLast, reversed
Foreign Function and Memory API22finalP/Invoke analogue
Class-File API24finalReflection.Emit analogue
Stream gatherers24finalcustom intermediate ops; LINQ Chunk
Ahead-of-Time Class Loading and Linking24finalfaster startup
Permanently disable the Security Manager24finalit was already deprecated
ZGC: remove non-generational mode24finalgenerational is the only mode
Quantum-resistant ML-KEM and ML-DSA24finalpost-quantum crypto
Key Derivation Function API25final
PEM encodings25PREVIEW
Vector API25INCUBATORtenth incubation; System.Numerics.Vector analogue

Runtime, GC and tooling#

FeatureReleaseStatusNote
jshell (REPL)9final
JPMS modules9final
Single-file source launch11finaljava Foo.java
Flight Recorder open-sourced11final
Helpful NullPointerExceptions14finalon by default since 15
Strong encapsulation of JDK internals17finalbreaks old libraries
Deprecate finalization for removal18deprecatednever use finalize()
Generational ZGC21final
Multi-file source launch22final
Generational ZGC by default23final
Compact object headers25finalsmaller objects, less memory
Ahead-of-time command-line ergonomics25finalAOT cache
Ahead-of-time method profiling25finalfaster warmup
Generational Shenandoah25final
JFR CPU-time profiling25EXPERIMENTAL
JFR cooperative sampling25final
JFR method timing and tracing25final
Remove the 32-bit x86 port25final

Release cadence#

ReleaseDateLTSNotes
Java 82014LTSstill widespread; lacks almost everything above
Java 92017modules, jshell, collection factories
Java 112018LTSHTTP client, var refinements, single-file launch
Java 172021LTSsealed types, strong encapsulation
Java 212023LTSvirtual threads, pattern matching, sequenced collections
Java 252025LTSscoped values, compact source files, AOT, compact headers
Note

Java ships every six months, in March and September; every fourth release is an LTS, so an LTS lands every two years. Non-LTS releases receive six months of updates. Use an LTS in production, and treat non-LTS releases as a way to try preview features early.

Appendix B: C# to Java, A to Z#

Part APP · Appendices · Chapter 53· 2 min read

Every C# construct, keyword, type and library, alphabetically, with its Java answer. This is the page to reach for when you know the C# word and need the Java one.

Faster still: press ⌘K and type the C# name; every row of every table in this book is indexed.

Rows that point at runtime concepts, such as assembly probing and the classpath, lead to How Java runs your code. Read that chapter once before relying on them.

A to C#

C#JavaChapter
abstractabstractInterfaces and inheritance
Action<T>Consumer<T>Methods and parameters
AggregateExceptionCompletionException / ExecutionExceptionCompletableFuture
AppContext.GetDataSystem.getPropertyHow Java runs your code
asno equivalent; instanceof patternSealed types and patterns
assembly probingthe classpathHow Java runs your code
async / awaitnothing, use virtual threadsThreads are cheap now
AsyncLocal<T>ScopedValueStructured concurrency
AutoMapperMapStructAnnotations
basesuperClasses and members
BenchmarkDotNetJMHTesting
boolbooleanNumbers, money and time
byte (unsigned)no equivalent; byte is signedNumbers, money and time
CancellationTokenThread.interrupt, or a scopeStructured concurrency
checked / uncheckedno equivalent; Math.addExactWeek-one gotchas
classclassClasses and members
conststatic finalFields and properties
ConcurrentDictionaryConcurrentHashMapLocks and atomics
ConfigureAwaitno equivalent; not neededThreads are cheap now

D to F#

C#JavaChapter
DataAnnotationsJakarta Bean ValidationValidation and errors
DateTimeLocalDateTime / InstantNumbers, money and time
DateTimeOffsetOffsetDateTime / InstantNumbers, money and time
DbContextEntityManager / repositoryData access
decimalBigDecimalNumbers, money and time
default(T)null, or a primitive defaultGenerics
delegatea functional interfaceMethods and parameters
deps.jsonthe classpath, or the manifest Class-PathHow Java runs your code
Dictionary<K,V>HashMap<K,V>Collections
dotnet CLImvn / gradleMaven vs csproj
dynamicno equivalentGenerics
Entity FrameworkHibernate / Spring Data JPAData access
enumenum: far more powerfulEnums
Environment.GetEnvironmentVariableSystem.getenvHow Java runs your code
eventa listener list, or a functional interfaceInterfaces and inheritance
Expression<T>no equivalent; no expression treesStreams vs LINQ
extension methoda static utility, or a default methodMethods and parameters
[Flags]EnumSetEnums
FluentAssertionsAssertJTesting
FluentValidationBean ValidationValidation and errors
Func<T,R>Function<T,R>Methods and parameters

G to L#

C#JavaChapter
GetHashCodehashCodeEquality and hashing
GetType()getClass()Classes and members
global usingno equivalentAccess and packages
gotolabelled break / continueSwitch expressions
HttpClientRestClient / java.net.http.HttpClientHTTP clients
IAsyncDisposableno equivalentExceptions and resources
IAsyncEnumerable<T>no equivalent; a BlockingQueueStreams vs LINQ
IComparable<T>Comparable<T>Equality and hashing
IDisposableAutoCloseableExceptions and resources
IEnumerable<T>Iterable<E>Collections
IEnumerator<T>Iterator<E>Collections
ILogger<T>SLF4J LoggerActuator and observability
in parameternot neededMethods and parameters
initfinal field, or a recordFields and properties
internalpackage-private, or a moduleAccess and packages
IOptions<T>@ConfigurationPropertiesDI and configuration
is T xinstanceof T xSealed types and patterns
locksynchronized / ReentrantLockLocks and atomics
LINQStreamsStreams vs LINQ
List<T>ArrayList<E>Collections

M to R#

C#JavaChapter
MediatRSpring events / AxonEcosystem
MoqMockitoTesting
namespacepackageAccess and packages
NativeAOTGraalVM native-imagePackaging
Newtonsoft.JsonJacksonEcosystem
NodaTimejava.timeNumbers, money and time
NuGetMaven CentralMaven vs csproj
NuGet global packages folder~/.m2/repositoryHow Java runs your code
nameofno equivalentAnnotations
Nullable<T> / T?the wrapper type; Optional for returnsNullability
null-forgiving !no equivalentNullability
null-conditional ?.Optional.map, or an explicit checkNullability
null-coalescing ??Objects.requireNonNullElseNullability
operator overloadingno equivalentMethods and parameters
out parameterreturn a record, or OptionalMethods and parameters
override@Override; an annotation, not a keywordInterfaces and inheritance
paramsvarargs, Object...Methods and parameters
partial classno equivalentClasses and members
PollyResilience4j, or @Retryable on Boot 4HTTP clients
Predicate<T>Predicate<T>Methods and parameters
ProblemDetailsProblemDetailValidation and errors
propertygetX / setX, or a record componentFields and properties
readonlyfinalFields and properties
recordrecordRecords
record structno equivalentRecords
ref parameterno equivalentMethods and parameters
Refit@HttpExchange interfacesHTTP clients

S to Z#

C#JavaChapter
sealed classfinal classInterfaces and inheritance
SerilogLogback via SLF4JEcosystem
SignalRWebSocket + STOMPEcosystem
Span<T>ByteBuffer / MemorySegmentGenerics
static classfinal class, private constructorClasses and members
stringStringvar, strings and text blocks
string interpolationnone: concatenation or formatted()var, strings and text blocks
structno equivalent, use a recordClasses and members
Swashbucklespringdoc-openapiControllers and binding
switch expressionswitch expressionSwitch expressions
System.Text.JsonJacksonEcosystem
Task<T>CompletableFuture<T>CompletableFuture
Task.Runexecutor.submitThreads are cheap now
Task.WhenAllStructuredTaskScope, or futuresStructured concurrency
TestcontainersTestcontainers, same projectTesting
this() constructor chainingthis(...) in the bodyClasses and members
ThreadPoolExecutorServiceLegacy concurrency
ToStringtoStringClasses and members
TryParsecatch NumberFormatExceptionNumbers, money and time
typeof(T)T.class, or Class<T>Generics
uint / ulongno equivalentNumbers, money and time
using directiveimportAccess and packages
using statementtry-with-resourcesExceptions and resources
varvarvar, strings and text blocks
virtualthe default; use final to preventInterfaces and inheritance
volatilevolatile, stronger in JavaLocks and atomics
where T : X<T extends X>Generics
with expressionno equivalentRecords
xUnitJUnit 5Testing
yield returnno equivalent; Stream.iterateStreams vs LINQ
Note

The rows worth committing to memory, because they are the ones that change how you design rather than merely how you type:

C#JavaWhy it matters
async / awaitvirtual threadsno function colouring; write blocking code
IEnumerable<T>Iterable<E>not Iterator; the wrong one gives single-use APIs
decimalBigDecimalmethod-call arithmetic; the top money-bug source
sealedfinalJava's sealed is a different, better feature
protectedwider than C#'spackage access comes with it
no modifierpackage-privateinverted from C#
Expression<T>nothingno LINQ-to-SQL is possible

Appendix C: The Java you'll inherit#

Part APP · Appendices · Chapter 54· 6 min read

Java is thirty years old and takes backward compatibility more seriously than almost any platform. Code written in 1999 still compiles. That is a strength, and it means a real codebase is a geological cross-section. Struts under Spring XML under Spring Boot.

This appendix is the field guide: what each layer is, which era it came from, and what replaced it. Switch to Full mode to see the historical asides; in Fast mode they collapse away.

Four eras#

EraYearsHouse style.NET contemporary
Applets and J2EE1995-2005XML descriptors, heavyweight app servers.NET Framework 1.x
Spring and annotations2005-2014Spring XML, then annotations; Maven.NET 2.0-4.5, WebForms
Boot and microservices2014-2020Spring Boot, embedded servers, DockerASP.NET Core 1-3
Modern Java2020 torecords, virtual threads, Jakarta.NET 6-10

Dating a codebase at a glance#

If you seeIt was written aroundEra
import javax.servletbefore 2020pre-Jakarta
Struts action classes2001-2008J2EE
EJB with Home interfaces1999-2006J2EE
build.xml (Ant)before 2008J2EE
applicationContext.xml2004-2013Spring XML
Hibernate .hbm.xml mappings2003-2010Spring XML
@Autowired on fields2007-2016annotation era
new StringBuilder() everywhereany era; a habit from Java 6-
Anonymous inner classes as callbacksbefore 2014pre-lambda
Guava for collections2010-2018pre-Java-9
@SpringBootApplication2014 onwardBoot
records and switch expressions2021 onwardmodern

The javax to jakarta rename#

The single most disruptive event in recent Java history, and the one most likely to explain a stalled upgrade. When Oracle transferred Java EE to the Eclipse Foundation, the javax.* trademark did not come with it. Every enterprise API package was renamed.

BeforeAfterAffects
javax.servlet.*jakarta.servlet.*every web application
javax.persistence.*jakarta.persistence.*every JPA entity
javax.validation.*jakarta.validation.*every @NotNull
javax.annotation.*jakarta.annotation.*@PostConstruct, @Resource
javax.transaction.*jakarta.transaction.*@Transactional (the JTA one)
Gotcha

This is a hard break, not a deprecation. Spring Boot 2 uses javax; Spring Boot 3 and 4 use jakarta. There is no compatibility shim in the framework, so a Boot 2 to Boot 3 upgrade means touching every import in every entity, controller and validator. Tooling exists, the Eclipse Transformer, OpenRewrite, but plan it as a project, not an afternoon.

java.* packages were never affected. Only the enterprise APIs moved.

Web frameworks, in order of extinction#

LegacyApplets

Java in the browser, 1995-2015. Sandboxed UI code downloaded as a JAR and run by a browser plugin. Killed by security vulnerabilities, then by browsers dropping NPAPI entirely. The Applet API was deprecated in Java 9 and removed in Java 17. Java Web Start went the same way.

You will not meet a working applet. You may meet the corpse of one in an internal tool nobody dares delete.

LegacyServlets and JSP

The 1999 foundation, and unlike applets these are very much alive, just wrapped. A servlet is a class handling an HTTP request; a JSP is an HTML page with embedded Java that gets compiled into one.

public class OrderServlet extends HttpServlet {
    protected void doGet(HttpServletRequest req, HttpServletResponse res)
            throws ServletException, IOException {
        res.setContentType("text/html");
        res.getWriter().println("<h1>Orders</h1>");
    }
}

Spring MVC is built on the Servlet API, DispatcherServlet is a servlet, so this is still the substrate under every Spring Boot web application. Writing servlets by hand is what stopped. JSP is genuinely legacy; use Thymeleaf.

LegacyStruts 1 and 2

The dominant MVC framework of the early 2000s. Actions configured in struts-config.xml, forms as ActionForm beans. Struts 1 reached end of life in 2013; Struts 2 is still maintained but rarely chosen. It is best known now for CVE-2017-5638, the remote-code-execution flaw behind the Equifax breach.

If you inherit Struts, the migration target is Spring MVC, and treat the version as a security matter, not a preference.

LegacyJSF and Java EE MVC

JavaServer Faces, the component-based, stateful framework Sun offered as the standard answer, conceptually close to ASP.NET WebForms, with the same server-side view state and the same eventual problems. It survives as Jakarta Faces, mostly in enterprises with long-lived internal applications.

The 2020 reference PDF this handbook replaces recommended “Java EE 8 MVC” as the ASP.NET MVC equivalent. That was already unusual advice then and is not the answer now: it is Spring Boot.

EJB and the application server#

LegacyEJB 2.x

Enterprise JavaBeans, the component model at the heart of J2EE. In version 2 every bean needed a Home interface, a Remote interface, an implementation class and an XML deployment descriptor: four artefacts to get one method call, plus a checked RemoteException everywhere.

public interface OrderServiceHome extends EJBHome {
    OrderService create() throws RemoteException, CreateException;
}
public interface OrderService extends EJBObject {
    Order place(Cart cart) throws RemoteException;
}

The weight of this is precisely why Spring exists: Rod Johnson's 2002 book argued you could get the same transactional guarantees with plain objects and a container that stayed out of the way. EJB 3 (2006) adopted annotations and became reasonable, but by then Spring had won.

LegacyApplication servers

WebSphere, WebLogic, JBoss, GlassFish. You built a WAR or EAR and deployed it into a long-running server shared by several applications, administered through a web console. Deployment was an operation performed on a server, not a process you started.

Spring Boot inverted this: the server is a library inside your JAR, one process per service, and the artefact is immutable. That is what made Java work in containers. You will still meet WebSphere in banking and insurance.

Configuration by XML#

LegacySpring XML configuration

Before annotations, every bean was declared in applicationContext.xml. Large systems had thousands of lines of it, and refactoring a class name silently broke the wiring because the XML held strings.

<beans>
  <bean id="orderService" class="com.acme.OrderServiceImpl">
    <constructor-arg ref="orderRepository"/>
    <property name="auditEnabled" value="true"/>
  </bean>
  <bean id="orderRepository" class="com.acme.JdbcOrderRepository">
    <property name="dataSource" ref="dataSource"/>
  </bean>
</beans>

Annotations arrived in Spring 2.5 (2007) and @Configuration classes in Spring 3 (2009). XML still works in Spring 7; you will find it in older modules, often alongside annotations in the same application.

LegacyHibernate hbm.xml mappings

Before JPA annotations, entity mapping lived in a parallel XML file per class. Renaming a field meant editing two files, and forgetting the second was a runtime failure.

<hibernate-mapping>
  <class name="com.acme.Order" table="ORDERS">
    <id name="id" column="ORDER_ID"><generator class="native"/></id>
    <property name="total" column="TOTAL"/>
  </class>
</hibernate-mapping>

Replaced by @Entity and @Column in 2006. Same engine underneath.

Build tools#

LegacyAnt and Ivy

Ant (2000) is a general XML task runner, closer to MSBuild than to Maven. It has no conventions and no dependency management, so every project invented its own layout and JARs were committed to source control in a lib/ folder. Ivy later bolted dependency resolution on.

<target name="compile" depends="init">
  <javac srcdir="${src}" destdir="${build}" classpath="lib/junit.jar"/>
</target>

Maven's contribution in 2004 was not the XML; it was the convention plus a central repository. Ant survives in old builds and inside some Gradle scripts.

SOAP and web services#

LegacyJAX-WS, WSDL and SOAP

Before REST, service contracts were WSDL documents and messages were SOAP envelopes over HTTP. Tooling generated client stubs from the WSDL, which gave genuine type safety across languages; the thing REST gave up and OpenAPI later tried to win back.

@WebService
public class RateService {
    @WebMethod
    public Rate getRate(@WebParam(name = "pair") String pair) { ... }
}

The WS-* stack (WS-Security, WS-ReliableMessaging, WS-Addressing) is why “enterprise integration” acquired its reputation. Still current in banking, government and telecoms; spring-boot-starter-webservices exists for exactly this. The equivalent .NET journey ran WCF to ASP.NET Core.

Language habits from before Java 8#

LegacyAnonymous inner classes as callbacks

Java had no lambdas until 2014, so every callback was a whole class expression. This is the single most recognisable marker of pre-Java-8 code.

C# has always had this
list.Sort((a, b) => a.Age - b.Age);
Java before 8
Collections.sort(list, new Comparator<Person>() {
    @Override
    public int compare(Person a, Person b) {
        return a.getAge() - b.getAge();
    }
});

Today: list.sort(comparingInt(Person::getAge)). IntelliJ converts these automatically with Alt+Enter.

LegacyGuava and Apache Commons

Before the JDK grew collection factories, Optional, and string utilities, Google's Guava and Apache Commons filled the gaps, and their APIs are still all over older code.

Old third-party callModern JDK equivalentSince
Lists.newArrayList()new ArrayList<>()always; the helper predated the diamond
ImmutableList.of(a, b)List.of(a, b)Java 9
Optional (Guava)java.util.OptionalJava 8
StringUtils.isBlank(s)s.isBlank()Java 11
Lists.partition(xs, n)Gatherers.windowFixed(n)Java 24
Joiner.on(",").join(xs)String.join(",", xs)Java 8

Neither library is dead. Guava's caches, multimaps and graph types have no JDK equivalent. But new code should not reach for them to do what the JDK now does.

LegacyChecked exceptions everywhere

Early Java APIs declared checked exceptions liberally, and the resulting try/catch noise is a large part of the language's reputation for verbosity. The ecosystem moved decisively the other way: Spring, Hibernate and the AWS SDK all wrap checked exceptions in unchecked ones at their boundaries.

See Exceptions and resources.

Actually removed from the platform#

RemovedWhenNote
Applet APIJava 17browsers dropped plugins
Java Web StartJava 11use jpackage
CORBA and Java EE modulesJava 11JAXB, JAX-WS now separate dependencies
Nashorn JavaScript engineJava 15use GraalJS
Security ManagerJava 24permanently disabled
Thread.stop and suspendJava 20never safe; now throws
32-bit x86 portJava 2564-bit only
Non-generational ZGCJava 24generational is the only mode
Gotcha

The Java EE removals in Java 11 break more upgrades than anything except the Jakarta rename. Code using JAXB (javax.xml.bind) compiled fine on Java 8 and fails on Java 11 with ClassNotFoundException, because the module was removed from the JDK rather than deprecated. The fix is adding the artefact as an explicit dependency, but the error gives no hint of that.

Why any of this matters#

Note

Two practical reasons to know the history.

Dating tells you what to trust. A codebase using applicationContext.xml and anonymous inner classes was probably written before 2014, so its patterns predate lambdas, records and virtual threads. Search results and Stack Overflow answers from that era will be equally out of date, and Java's long tail of stale advice is genuinely hazardous; this handbook exists partly because the reference document it replaces recommended overriding finalize().

Java's compatibility promise is real. Nothing in this appendix was deleted lightly, and most of it still runs. That is why a Java estate accumulates layers rather than being rewritten, and why the skill of reading old Java stays valuable in a way that reading old JavaScript does not.

Appendix D: Going deeper#

Part APP · Appendices · Chapter 55· 5 min read

This appendix is a map, not the territory. Everything below needs more than a handbook section; some of it needs a book. Each entry tells you what the topic is, an observable symptom that means you need it, where a C# instinct will mislead you, and where to read.

Spring internals get short real treatments, because they are conventions and are learnable in a few hundred words. JVM internals get signposts, because they are behaviour you have to measure and no summary substitutes for that.

Start here: the symptom index#

What you are seeingWhat explains itWhere
An annotation silently does nothingproxying and self-invocationHow Spring actually works
A bean is null inside @PostConstructbean lifecycle orderingHow Spring actually works
Data committed despite an exceptionchecked exceptions do not roll backTransactions in depth
Deadlocks that only appear under loadREQUIRES_NEW, or connection pool sizingTransactions in depth
Correct on MySQL, wrong on PostgreSQLisolation level defaults differTransactions in depth
p99 is spiky, the mean is fineGC pauses, or JIT deoptimisationGC internals, JIT
Works in tests, fails in the app serverclassloader hierarchyClassloaders
ClassCastException naming the same class twicetwo loaders loaded itClassloaders
Slow build, unexplained generated sourcesannotation processingAnnotation processing
A concurrency bug you cannot reproducethe Java Memory Modelhappens-before
Memory grows but the heap looks flatmetaspace, direct buffers, thread stacksThe JVM at runtime
Startup is slow and you have no idea whyauto-configuration report, class loadingSpring Boot orientation, AOT
You need to add behaviour to a class you do not ownbytecode manipulationByteBuddy and ASM
Your own starter is not auto-configuringregistration file and conditionsWriting an auto-configuration
Native image compiles but fails at runtimereflection metadataSpring AOT, Packaging

Spring internals#

Writing an auto-configuration and a starter#

RowDetail
What it isA configuration class that registers beans only when conditions hold, discovered from a registration file rather than by component scanning.
You need it whenYou are packaging shared infrastructure for several services and want it to work by adding one dependency.
The C# instinctAn extension method on IServiceCollection. AddMyThing(). Spring inverts it: the library declares itself and the application does nothing.
// 1. the configuration
@AutoConfiguration
@ConditionalOnClass(RatesClient.class)
@ConditionalOnMissingBean(RatesClient.class)
@EnableConfigurationProperties(RatesProperties.class)
public class RatesAutoConfiguration {

    @Bean
    RatesClient ratesClient(RatesProperties props) {
        return new RatesClient(props.url());
    }
}
# 2. the registration file; this is what makes it discoverable
src/main/resources/META-INF/spring/
    org.springframework.boot.autoconfigure.AutoConfiguration.imports

com.acme.rates.RatesAutoConfiguration
Gotcha

Two things bite here. The registration file path is exact and unforgiving; a typo means your configuration is simply never found, with no error. And an auto-configuration must not be under a package that the application component-scans, or it is picked up twice and the conditions stop meaning anything.

The pre-Boot-2.7 mechanism was a spring.factories file; you will still meet it, and it is deprecated.

Note

Read: Spring Boot reference, "Creating Your Own Auto-configuration"; the source of any spring-boot-autoconfigure module; they are unusually readable

BeanFactoryPostProcessor and BeanPostProcessor#

RowDetail
What it isTwo extension points. BeanFactoryPostProcessor edits bean *definitions* before anything is instantiated; BeanPostProcessor edits or replaces bean *instances* as they are created. Every proxy in Spring is made by the second one.
You need it whenYou are registering beans from an external source, rewriting definitions, or applying your own wrapper across many beans.
The C# instinctThere is no direct equivalent. The nearest is intercepting IServiceCollection before Build(), plus a decorator registration, but Spring's version runs inside the container and sees everything.
@Component
public class TimingPostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessAfterInitialization(Object bean, String name) {
        if (!(bean instanceof RatesClient)) return bean;
        return Proxy.newProxyInstance(
                bean.getClass().getClassLoader(),
                bean.getClass().getInterfaces(),
                (proxy, method, args) -> {
                    long t0 = System.nanoTime();
                    try { return method.invoke(bean, args); }
                    finally { record(method, System.nanoTime() - t0); }
                });
    }
}
Gotcha

A BeanPostProcessor is itself a bean, and it must be created before the beans it processes. Anything it depends on is therefore instantiated very early, before most of the context exists, which produces confusing "bean is not eligible for post-processing" warnings. Keep them dependency-free, or inject an ObjectProvider and resolve lazily.

Note

Read: Spring Framework reference, "Container Extension Points"

Spring AOT and native processing#

RowDetail
What it isA build-time step that runs the container's decision-making early and emits generated Java source plus reflection metadata, so startup does less work and GraalVM can see everything statically.
You need it whenYou are targeting native images, or startup time matters (serverless, scale-to-zero).
The C# instinctNativeAOT plus source generators, doing the same job for the same reason: closed-world analysis needs everything decided before runtime.
Note

Read: Spring Boot reference, "Ahead-of-Time Processing"; then Packaging and native images in this book

JVM internals#

Classloaders and the classpath#

RowDetail
What it isJava resolves classes through a hierarchy of loaders that delegate to their parent first. A class's identity is its name *plus its loader*, so the same name loaded twice is two distinct types.
You need it whenClassCastException naming the same class on both sides; a library that works in tests and not in the container; you are building a plugin system or a hot-reload mechanism.
The C# instinctAssemblyLoadContext exists and you almost never touch it. In Java the hierarchy is load-bearing, leaks into ordinary bugs, and is what "JAR hell" refers to.
ReadJava Language Specification 12.2; Oaks, "Java Performance"; the Tomcat classloader documentation for the app-server case
Note

The basics, meaning the classpath as a search list, the delegation order and the two class-not-found errors, are in How Java runs your code. This entry is for when those are not enough.

The JIT: tiers, inlining, deoptimisation#

RowDetail
What it isHotSpot interprets first, compiles warm methods with C1, then recompiles the hottest with C2 using profile data. Aggressive assumptions are made and thrown away when they turn out wrong; that is deoptimisation.
You need it whenThroughput changes shape minutes after startup; a microbenchmark reports impossible numbers; p99 spikes that GC logs do not explain.
The C# instinctRyuJIT compiles once per method, with no tiered profile-guided recompilation of the same depth and no deoptimisation. Java's peak performance arrives later and is harder to reason about.
ReadAleksey Shipilev's blog is the reference; -XX:+PrintCompilation and JITWatch to see it; always measure with JMH, never a hand-rolled loop

The Java Memory Model and happens-before#

RowDetail
What it isThe formal rules stating when a write by one thread is guaranteed visible to another. Everything about volatile, synchronized and the atomics derives from it.
You need it whenA concurrency bug you cannot reproduce; you are writing a lock-free structure; you need to know whether a field really needs volatile.
The C# instinctThe CLR memory model is stronger in practice on x86 and weaker on paper. Java's model is specified precisely and was fixed in Java 5; advice written before that is wrong.
ReadGoetz, "Java Concurrency in Practice", still the definitive treatment; JSR-133 and its FAQ for the specification

MethodHandle, VarHandle and reflection performance#

RowDetail
What it isFaster, typed alternatives to core reflection. VarHandle also gives fine-grained memory ordering, plain, opaque, acquire/release, volatile, which volatile alone cannot express.
You need it whenReflection is measurably hot; you are writing a framework or serialiser; you need ordering weaker than volatile but stronger than plain.
The C# instinctExpression trees compiled to delegates, or Unsafe.As. MethodHandle is closer to a compiled delegate than to reflection.
Readjava.lang.invoke package documentation; JEP 193 for VarHandle

Bytecode manipulation#

RowDetail
What it isGenerating or rewriting classes at runtime or build time. ByteBuddy is the high-level library, ASM the low-level one; the Class-File API made it a standard JDK capability in Java 24.
You need it whenYou are writing an agent, a mocking framework, or an APM tool; you must add behaviour to a class you cannot change.
The C# instinctIL weaving with Fody or Cecil. The difference is that Java agents can attach to a *running* process, which is how zero-code observability works on the JVM and does not on .NET.
ReadByteBuddy tutorial; JEP 484 for the Class-File API; java.lang.instrument for agents

NIO channels, selectors and memory-mapped files#

RowDetail
What it isThe low-level I/O layer beneath every HTTP server and database driver: buffers, channels, selectors for multiplexing, and files mapped directly into memory.
You need it whenYou are writing a protocol implementation, a high-throughput file processor, or debugging why a driver behaves oddly under load.
The C# instinctSpan, Memory and SocketAsyncEventArgs. ByteBuffer is clumsier: position and limit are stateful and a frequent source of bugs, and MemorySegment from the FFM API is the modern replacement.
Readjava.nio package documentation; Netty's source for how it is really used

Platform#

TopicYou need it whenRead
JMX and MBeansyou must expose or change a runtime value on a live JVM, or an ops tool demands itjavax.management docs; Actuator exposes endpoints over JMX too
ServiceLoader and the SPI patternyou are building pluggable implementations discovered from the classpath; it is how JDBC drivers and Java's own crypto providers are foundjava.util.ServiceLoader; the provides directive in Modules, JPMS and internal
JCA, JCE, keystores and TLSyou must configure mutual TLS, load a keystore, or pick a cipher suiteJava Security Standard Algorithm Names; keytool documentation
Foreign Function and Memory APIyou need to call native code, or manage off-heap memory preciselyJEP 454; it replaces JNI and, for off-heap, ByteBuffer
Vector APIyou have measurable SIMD-shaped numeric workJEP 508: still an incubator in Java 25, so not for production
Locale, charset and i18ntext is corrupted, or sorting differs between environmentsalways set charset explicitly; ICU4J for real i18n

Ecosystem, beyond one service#

TopicWhat it isYou need it when
Spring Cloudconfig server, service discovery, gateway, distributed tracingyou run many services and need shared configuration and routing
Spring Batchchunk-oriented batch processing with restartabilityyou have long-running jobs that must resume after failure, not restart
Spring Integration and Camelenterprise integration patterns as a DSLyou are routing and transforming between many systems
Kafka patternsconsumer groups, exactly-once, the transactional outboxyou are building event-driven services and need delivery guarantees
Testcontainers at scaleshared containers, reuse, parallel suitesyour integration suite has become the slowest part of CI
Mutation testing (PIT)mutates your code to check the tests noticecoverage is high and you do not trust it
ArchUnitarchitecture rules enforced as unit testslayering keeps eroding and review is not catching it

The short list#

If you read only a few things after this handbook:

ForRead
Concurrency, properlyGoetz, "Java Concurrency in Practice"
Everyday idiom and API designBloch, "Effective Java"
Performance and the JVMOaks, "Java Performance"; Shipilev's blog for depth
Springthe Spring Boot and Spring Framework reference documentation: genuinely good, and better than most books about them
What is comingthe JEP index at openjdk.org/jeps
Note

One meta-point worth carrying: Java's stale advice problem is severe. The language is thirty years old and search results do not sort by relevance to the version you are on. An answer that was correct in 2011 will be confidently presented alongside one that is correct today. Check the date, check the Java version, and prefer the JEP or the reference documentation over a blog post; this handbook exists partly because the document it replaced still recommended overriding finalize().

Appendix E: If your team uses Kotlin#

Part APP · Appendices · Chapter 56· 3 min read

Kotlin runs on the JVM, interoperates with Java in both directions, and is a first-class Spring Boot language. If you land on a Kotlin team, the good news is that Kotlin is much closer to C# than Java is: it has properties, null safety in the type system, extension functions, data classes, string interpolation and operator overloading, all of which Java lacks.

The bad news is that you still need the Java in this book. The libraries, the JVM, Spring, Maven and every runtime behaviour described here are identical. Kotlin changes the syntax, not the platform.

What Kotlin gives back that Java took away#

C# featureJavaKotlin
PropertiesgetX() / setX()val / var, real properties
Nullable reference typesOptional plus annotationsString? in the type system
Extension methodsstatic utility classesextension functions
String interpolationnone"$name has ${x.size}"
Operator overloadingnoneoperator fun plus
Recordsrecorddata class
with expressionnonecopy()
Named and optional argumentsnonefull support
Top-level functionsnone, everything in a classsupported
switch expressionswitch expressionwhen expression
async/awaitvirtual threadscoroutines, plus virtual threads
C# 14
public record Customer(string Name, int Age)
{
    public string Label => $"{Name} ({Age})";
}

var c2 = c1 with { Age = 41 };
string? maybe = Find(id)?.Name;
Kotlin
data class Customer(val name: String, val age: Int) {
    val label get() = "$name ($age)"
}

val c2 = c1.copy(age = 41)
val maybe: String? = find(id)?.name
Gotcha

The Rosetta panes throughout this book are labelled C# and Java. On a Kotlin project the right-hand side changes but the runtime facts do not. Erasure, the equality contract, the JPA traps, proxy-based annotations, virtual threads, the class loader, GC tuning and the whole of Spring behave exactly as described here. Kotlin is a different front end to the same platform.

Kotlin with Spring Boot#

Spring supports Kotlin properly rather than tolerating it. Boot 4 requires Kotlin 2.2 or later.

@RestController
@RequestMapping("/api/orders")
class OrderController(private val service: OrderService) {

    @GetMapping("/{id}")
    fun get(@PathVariable id: Long): OrderDto =
        service.find(id) ?: throw OrderNotFound(id)

    @PostMapping
    fun create(@RequestBody @Valid cmd: CreateOrder): OrderDto =
        service.create(cmd)
}
ConcernWhat to know
Constructor injectionprimary constructor parameters, no annotation needed
Final by defaultKotlin classes are final, which breaks CGLIB proxies
kotlin-spring pluginopens Spring-annotated classes automatically; essential
kotlin-jpa plugingenerates the no-arg constructor JPA needs on entities
Null safety across the boundarySpring's JSpecify annotations map to Kotlin nullability
Coroutines in controllerssupported on WebFlux; suspend functions map to Mono
Gotcha

Without the kotlin-spring compiler plugin, @Transactional and every other proxy-based annotation silently does nothing, because Kotlin classes and methods are final by default and CGLIB cannot subclass them. It is the same failure described in How Spring actually works, arriving by a different route, and it is the first thing to check on a Kotlin Spring project where an annotation appears to be ignored.

Calling Java from Kotlin and back#

DirectionWhat happens
Kotlin calls JavaJava types arrive as "platform types", where nullability is unknown and unchecked
Java calls Kotlindata class becomes a normal class; properties become getX()/setX()
Kotlin null safety at the boundarya Java method returning null into a non-null Kotlin val throws at the assignment
Default arguments from Javanot visible unless annotated @JvmOverloads
Top-level functions from Javaappear as static methods on FileNameKt
Companion object membersneed @JvmStatic to look static from Java
Gotcha

Platform types are the hole in Kotlin's null safety. Anything coming from a Java library has unknown nullability, and Kotlin will not force you to check it. A NullPointerException from Kotlin code almost always came across a Java boundary. Spring Framework 7's move to JSpecify helps here, because Spring's own signatures now carry real nullability that Kotlin can read.

Coroutines against virtual threads#

Kotlin's answer to async I/O predates virtual threads and is closer to C#'s.

C#KotlinJava 21+
async Task<T>suspend funa plain blocking method
awaitjust call the suspend functionjust call the method
Task.WhenAllawaitAll / coroutineScopeStructuredTaskScope, still preview
CancellationTokenthe coroutine Job hierarchythread interrupt, or a scope
Function colouringyes: suspend infects callersno
Note

Coroutines have the colouring problem that virtual threads removed: suspend propagates up the call graph exactly as async does in C#. In exchange they give you structured concurrency that has been stable for years, where Java's equivalent is still preview. On a Kotlin project, follow the team's existing choice rather than mixing the two.

Should you argue for Kotlin?#

Reason to choose itReason to stay on Java
Genuinely less ceremony, particularly for data and null handlingThe Java talent pool is far larger
Null safety enforced by the compilerRecords, patterns and virtual threads closed much of the gap
Excellent Spring and Gradle supportOne language means one set of build and tooling problems
Your team already knows itKotlin adds a compiler plugin dependency to Spring

The honest position for someone arriving from C#: Kotlin will feel more comfortable sooner, and Java has closed a lot of the distance since version 8. Neither choice is wrong, and it is rarely yours to make on an existing codebase.

Appendix F: What that error means#

Part APP · Appendices · Chapter 57· 4 min read

The fastest way to lose confidence in a new platform is a stack trace you cannot read. This appendix is a lookup: the exception or compiler message on the left, what it actually means in the middle, and what to do about it on the right.

One structural thing first. Java distinguishes Exception, which your code may reasonably catch, from Error, which signals the JVM is in trouble. If the class name ends in Error, catching it is almost always wrong.

Spring Boot will not start#

MessageWhat it meansFix
Port 8080 was already in useanother process holds the portkill it, or set server.port
NoSuchBeanDefinitionExceptionnothing satisfies a constructor parameterthe class is missing @Service, or it lives outside the scanned package
UnsatisfiedDependencyExceptiona bean could not be built because one of its dependencies could notread the "Caused by" at the bottom; that is the real error
BeanCurrentlyInCreationExceptiontwo beans depend on each otherbreak the cycle, see How Spring actually works
Failed to configure a DataSource: 'url' is not specifiedthe JPA starter is present but no database is configuredset spring.datasource.url, or exclude the auto-configuration
Parameter 0 of constructor required a bean of type Xsame as NoSuchBeanDefinition, with the location namedcheck the package, and that X is annotated
Consider defining a bean of type XSpring's own suggestion, usually correctadd @Component to X, or an @Bean method
No qualifying bean of type X: expected single matching bean but found 2two implementations, no tie-break@Primary on one, or @Qualifier at the injection point
Gotcha

Spring stack traces are long and the useful line is at the bottom, in the last Caused by:. Boot also prints a short, human-readable summary above the trace under a banner reading APPLICATION FAILED TO START. Read that first; it is usually the whole answer, and people scroll past it out of habit.

Common runtime exceptions#

ExceptionWhat it meansWhere to look
NullPointerExceptiona null reference was dereferencedJava 15+ names the exact expression; see Nullability
ClassCastExceptiona cast failed at runtimeoften erasure, or two classloaders; see Generics
ConcurrentModificationExceptiona collection changed while being iterateduse removeIf or an explicit Iterator; see Collections
UnsupportedOperationExceptionyou mutated an immutable collectionList.of and Arrays.asList are not mutable; see Collections
NumberFormatExceptiona string would not parse as a numberInteger.parseInt has no TryParse; catch it
ArrayStoreExceptionwrong element type stored into an arrayarray covariance; see Week-one gotchas
IllegalStateException: stream has already been operated upona Stream was consumed twicestreams are single-use; see Streams vs LINQ
ArithmeticException: / by zerointeger division by zeroonly integers throw; doubles give Infinity
DateTimeParseExceptiona date string did not match the formattercheck the pattern letters, they differ from .NET

Persistence#

ExceptionWhat it meansFix
LazyInitializationExceptiona lazy association was touched after the session closedfetch it inside the transaction, or map to a DTO there; see Data access
TransactionRequiredExceptiona write was attempted with no active transactionadd @Transactional to the service method
DataIntegrityViolationExceptiona database constraint was violatedread the cause for the constraint name
ObjectOptimisticLockingFailureExceptiona @Version check failed; someone else changed the rowretry, or surface a conflict to the user
detached entity passed to persistyou called persist on something that already has an iduse merge, or save from the repository
No identifier specified for entitythe class has no @Idadd one
could not initialize proxy: no Sessionthe same as LazyInitializationException, from a different pathsame fix

Web and JSON#

ExceptionWhat it meansFix
HttpMessageNotReadableExceptionthe request body would not deserialisecheck the JSON shape against the record
MethodArgumentNotValidException@Valid failedthis is the 400 you want; handle it in @ControllerAdvice
InvalidDefinitionException: no serializer foundJackson cannot serialise a typeusually a missing getter, or a lazy JPA proxy
UnrecognizedPropertyExceptionthe JSON has a field the type does notJackson fails by default, unlike System.Text.Json
HttpMediaTypeNotSupportedExceptionContent-Type does not match what the endpoint consumessend application/json
MissingServletRequestParameterExceptiona required @RequestParam was absentrequired is true by default
403 with no message on a POSTCSRF protectionsee Security

Errors, not exceptions#

ErrorWhat it meansFix
UnsupportedClassVersionError: class file version 69.0compiled by a newer JDK than the one running itclass file 69 is Java 25, 65 is 21, 61 is 17; align the versions
NoClassDefFoundErrorthe class was present when compiled and is missing nowa dependency scope problem, or a missing runtime jar
ClassNotFoundExceptionthe class was never found, at load timeusually the same cause; this one is a checked exception
NoSuchMethodErrorthe class is present but the method signature changedtwo versions of a library on the classpath; run mvn dependency:tree
IncompatibleClassChangeErrora class changed shape since its callers were compiledsame cause: a version conflict
StackOverflowErrorrunaway recursionoften mutual toString or equals between two entities
OutOfMemoryError: Java heap spacethe heap is genuinely fullraise -Xmx, or find the leak with a heap dump
OutOfMemoryError: Metaspaceclass metadata exhausteda classloader leak; set -XX:MaxMetaspaceSize and investigate
OutOfMemoryError: unable to create native threadtoo many platform threadsuse virtual threads, or bound the pool
ExceptionInInitializerErrora static initialiser threwread the cause; the class is now permanently unusable
Gotcha

NoClassDefFoundError and ClassNotFoundException are not the same thing, and the difference tells you where to look. ClassNotFoundException means an explicit lookup failed, usually Class.forName. NoClassDefFoundError means the class was on the classpath at compile time and is not there now, which points at a packaging or dependency scope problem rather than at your code.

Gotcha

Class file version numbers are the fastest version check you have. Subtract 44 to get the Java release: 69 is Java 25, 65 is Java 21, 61 is Java 17, 52 is Java 8. UnsupportedClassVersionError almost always means the build used a newer JDK than the runtime, so check JAVA_HOME against the container image.

Compiler messages#

javac saysWhat it meansFix
cannot find symbolan unknown namea missing import, a typo, or a module not on the path
unreported exception X; must be caught or declareda checked exception is unhandledcatch it or add throws; see Exceptions and resources
incompatible types: possible lossy conversion from long to intJava will not narrow silentlycast explicitly, and consider whether it is safe
variable x might not have been initializeda local was read on some path before assignmentJava is stricter than C# about definite assignment
local variables referenced from a lambda must be final or effectively finalyou reassigned a captured localuse an AtomicReference, or restructure
non-static variable cannot be referenced from a static contextinstance state used from main or a static methodthe classic first-day error
class X is public, should be declared in a file named X.javafilename must match the public classrename the file
bad operand types for binary operatorusually == on objects, or arithmetic on boxed typesuse equals; see Equality and hashing
reached end of file while parsingan unbalanced bracethe IDE will point at it faster than javac

Reading a Java stack trace#

The conventions differ from .NET in two ways that matter.

Exception in thread "main" java.lang.IllegalStateException: no rate for GBPUSD
    at com.acme.RatesService.lookup(RatesService.java:42)     ← where it was thrown
    at com.acme.OrderService.price(OrderService.java:88)
    at com.acme.OrderController.create(OrderController.java:31)
    ... 47 more                                              ← frames identical to the cause
Caused by: java.net.SocketTimeoutException: Read timed out    ← THE REAL PROBLEM
    at java.base/java.net.SocketInputStream.read(...)
    ... 51 more
ConventionMeaning
Top framewhere the exception was thrown, not where it was caught
Caused bythe underlying exception; the last one is usually the real cause
... N moreframes shared with the enclosing trace, elided
Suppressedan exception from close() that did not replace the primary one
java.base/the module the class came from, since Java 9
Note

Read a Java trace bottom up. In a wrapped exception the deepest Caused by is the actual failure, and everything above it is the layers that re-threw it. This is the reverse of the habit most people bring from .NET, where the InnerException is nested rather than printed last.

Appendix G: The one-page card#

Part APP · Appendices · Chapter 58· 1 min read

Everything you need in week one, on one side of paper. Print it and put it next to the keyboard.

Syntax you will reach for
C#Java
var x = ...var x = ...
sealed classfinal class
: Baseextends Base
: IFooimplements Foo
override@Override
readonlyfinal
conststatic final
namespacepackage
using X;import X;
stringString
boolboolean
x => x + 1x -> x + 1
Foo.BarFoo::bar
new Foo()Foo::new
nameof(x)no equivalent
$"{a} b"a + " b"
Collections
C#Java
IEnumerable<T>Iterable<E>
IEnumerator<T>Iterator<E>
List<T>ArrayList<E>
Dictionary<K,V>HashMap<K,V>
HashSet<T>HashSet<E>
Queue / StackArrayDeque<E>
ImmutableListList.of(...)
ConcurrentDictionaryConcurrentHashMap
LINQ to Stream
LINQStream
Wherefilter
Selectmap
SelectManyflatMap
OrderBysorted
First()findFirst().orElseThrow()
Any(p) / All(p)anyMatch / allMatch
ToList()toList()
GroupBycollect(groupingBy(f))
Sum()mapToInt(f).sum()
Chunk(n)gather(windowFixed(n))
Traps that cost a day
Looks rightActually
a == b on Stringcompares references; use equals
Integer 128 == 128false; cache stops at 127
no access modifierpackage-private, not private
protectedalso grants package access
methodsvirtual unless final
map.get(missing)null, then NPE on unboxing
switch (nullRef)throws; add case null
stream reusedIllegalStateException
List.of(..).add(..)UnsupportedOperationException
double for moneyuse BigDecimal
new BigDecimal(0.1)imprecise; use the String form
1.0.equals(1.00)false; use compareTo
nested classinner unless static
checked exception in lambdadoes not compile
return in finallyswallows the exception
Spring, day one
ASP.NET CoreSpring
AddScoped@Service, singleton by default
[ApiController]@RestController
[HttpGet("x")]@GetMapping("/x")
[FromBody]@RequestBody
[FromQuery]@RequestParam
[FromRoute]@PathVariable
IOptions<T>@ConfigurationProperties
appsettings.jsonapplication.yml
Environmentsprofiles
ILogger<T>SLF4J Logger
[Authorize]@PreAuthorize
PollyResilience4j, or @Retryable
Commands
.NETMaven
dotnet build./mvnw compile
dotnet test./mvnw test
dotnet run./mvnw spring-boot:run
dotnet publish./mvnw package
dotnet add packageedit pom.xml by hand
list --include-transitivedependency:tree
When something breaks
SymptomLook at
Could not find or load main classclasspath, or package and folder mismatch
Annotation does nothingself-invocation, private, or final
Bean not foundpackage outside the scan root
LazyInitializationExceptionfetch inside the transaction
class file version 69built on 25, run on something older
Container OOMKilledmetaspace, stacks, direct buffers
Note

The rule of thumb behind most of the traps column: Java makes the safe thing explicit and the unsafe thing the default more often than C# does. Reference equality, package-private access, virtual methods and ORDINAL enum mapping are all defaults you have to opt out of.

If a term on the card is unfamiliar, the classpath above all, start with How Java runs your code.

End of the book