Java for C# Developers#
A working handbook for .NET engineers moving to Java and Spring Boot
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#
| Mode | Shows | Takes | Leaves you |
|---|---|---|---|
| Fast | the core of every chapter | about ninety minutes | writing Java productively |
| Full | everything, including folded sections and historical asides | two to three hours | able 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#
| Side | Version | Note |
|---|---|---|
| Java | 25 LTS | Java 21 differences are called out where they matter |
| .NET | 10, C# 14 | modern idiom throughout: records, patterns, primary constructors |
| Spring | Boot 3 and Boot 4 | shown 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 covered | Where Java is used for it | Why it is out of scope |
|---|---|---|
| Mobile development | Android, and Kotlin Multiplatform | a different SDK, build system, lifecycle and UI model; almost none of Part 9 applies |
| Desktop applications | JavaFX, Swing, SWT | a separate UI stack with no ASP.NET Core parallel to translate from |
| Games | libGDX, jMonkeyEngine | niche on the JVM, and the .NET comparison would be Unity |
| Embedded and IoT | Java ME, embedded JVMs | a subset of the platform with different constraints |
| Big data and analytics | Spark, Flink, Hadoop, Kafka Streams | large ecosystems in their own right; the language is the smallest part |
| Applets and browser plugins | removed from the platform | see Appendix C, which explains what you may still find |
| Jakarta EE application servers | WildFly, WebSphere, Open Liberty | Appendix C covers enough to recognise them; Spring Boot is assumed |
| Java as a first language | any introductory text | this 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#
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.
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;
}
}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 instinct | The Java reality | Chapter |
|---|---|---|
| Exceptions are all unchecked | Checked exceptions must be declared or caught | Exceptions and resources |
| Generics are reified | Type arguments are erased at runtime | Generics and type erasure |
| Properties are a language feature | Getters and setters are just methods | Fields and properties |
| Default access is private | Default access is package-private | Access and packages |
| I need async for scale | Block on a virtual thread instead | Threads are cheap now |
| The build is part of the IDE | Maven or Gradle is a separate universe | Maven vs csproj |
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.
| .NET | Java | Note |
|---|---|---|
| CLR | JVM | both compile bytecode to machine code as it runs |
| IL | bytecode | one .class file per type, zipped into a JAR |
| BCL / System.* | java.*, javax.*, jakarta.* | the standard library, and much smaller than the BCL |
| NuGet | Maven Central | a package is identified by group + artifact, not one name |
| .csproj + MSBuild | pom.xml + Maven | Gradle and build.gradle.kts are the alternative |
| Assembly (.dll) | JAR | a zip of compiled classes; the unit you ship |
| Solution | Multi-module build | one parent pom lists the child modules |
| ASP.NET Core | Spring Boot | not shipped with Java; a third-party dependency |
| Entity Framework Core | Hibernate / Spring Data JPA | also not shipped with Java |
| xUnit + Moq | JUnit 5 + Mockito | also not shipped with Java; there is no built-in test runner |
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# | Java | Example |
|---|---|---|
| PascalCase methods | camelCase methods | GetName() to getName() |
| IFoo for interfaces | no prefix | IRepo to Repo |
| _camelCase fields | camelCase, no prefix | _name to name |
| Namespace need not match folder | Package must match folder | enforced by javac |
| One public type per file, loosely | One public type per file, strictly | filename must match |
| Properties | getX() / 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.
| Mode | Shows | Takes | Leaves you |
|---|---|---|---|
| Fast | the core of every chapter | about ninety minutes | able to write Java productively |
| Full | everything, including folded sections and historical asides | two to three hours | able 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#
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:runThen 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 javaThat 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.
| Release | Date | Status | .NET analogy |
|---|---|---|---|
| Java 8 | 2014 | LTS, still everywhere | .NET Framework 4.8 |
| Java 11 | 2018 | LTS, legacy | .NET Core 3.1 |
| Java 17 | 2021 | LTS, common | .NET 6 |
| Java 21 | 2023 | LTS, the current default | .NET 8 |
| Java 25 | 2025 | LTS, newest | .NET 10 |
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.
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#
| Distribution | Vendor | Pick it when |
|---|---|---|
| Eclipse Temurin | Eclipse Adoptium | default; free, TCK-certified, no strings |
| Amazon Corretto | Amazon | you deploy on AWS |
| Azul Zulu | Azul | you want commercial support options |
| Oracle JDK | Oracle | your employer has a contract |
| GraalVM | Oracle | you want native-image ahead-of-time compilation |
| Microsoft Build of OpenJDK | Microsoft | familiar 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| .NET | Java | Note |
|---|---|---|
| dotnet --list-sdks | sdk list java --installed | installed versions |
| global.json | .sdkmanrc, or Maven toolchains | pins which JDK this project builds with |
| dotnet --version | java -version | prints to stderr, oddly |
| DOTNET_ROOT | JAVA_HOME | many tools read it directly |
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> /exitRunning 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.
// Program.cs
Console.WriteLine("Hello");
// dotnet run// Hello.java
void main() {
IO.println("Hello");
}
// java Hello.javaNo 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 | .NET | Java |
|---|---|---|
| Compile | dotnet build | javac Foo.java, or mvn compile |
| Run | dotnet run | java Foo, or mvn spring-boot:run |
| Run one file | dotnet script | java Foo.java |
| REPL | dotnet-script / C# Interactive | jshell |
| Test | dotnet test | mvn test |
| Package | dotnet publish | mvn package |
| Add dependency | dotnet add package X | edit pom.xml by hand |
| Clean | dotnet clean | mvn clean |
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#
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| .NET | Java | Note |
|---|---|---|
| .cs source file | .java source file | one public class per file, named to match |
| Roslyn / csc | javac | compiles to bytecode, not to machine code |
| IL | bytecode | the JVM's portable instruction set |
| assembly (.dll) | JAR of .class files | a JAR is a zip archive |
| CLR | JVM | JIT-compiles bytecode as it runs |
| .NET SDK | JDK | compiler, tools and a runtime |
| .NET runtime | JRE | a runtime only; see the note below |
| deps.json and assembly probing | the classpath | how the runtime finds your dependencies |
| dotnet MyApp.dll | java -jar myapp.jar | run a packaged application |
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.
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.
// Program.cs, top-level statements
Console.WriteLine("hello");
// the entry point is recorded in the
// assembly, so dotnet run just works// 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.AppWhen 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| Symptom | What the classpath got wrong |
|---|---|
| Could not find or load main class | the entry holding your main class is missing, or the package and folder disagree |
| ClassNotFoundException | a class looked up by name at runtime is on no entry |
| NoClassDefFoundError | a class present when you compiled is on no entry now |
| NoSuchMethodError | the class was found, but in the wrong version of its JAR |
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.jarjava -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.
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| Attribute | Does | Note |
|---|---|---|
| Main-Class | names the class whose main method starts the program | no .class suffix |
| Class-Path | adds more JARs to the classpath | space-separated, relative to this JAR's own folder |
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/*.jarEach 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.
| Error | Means | Usual cause |
|---|---|---|
| ClassNotFoundException | an explicit lookup by name failed | Class.forName, or a driver or plugin named in configuration |
| NoClassDefFoundError | a class that existed at compile time is gone at runtime | a dependency scoped provided or test, or a JAR left out |
| ExceptionInInitializerError | the class was found, but its static initialiser threw | read the cause; the class stays unusable afterwards |
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 javaThe 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.
// environment variable
var url = Environment
.GetEnvironmentVariable("RATES_URL");
// the nearest thing to a system property:
// AppContext.GetData, fed by runtimeconfig.json// environment variable
String url = System.getenv("RATES_URL");
// system property, set with -Drates.url=...
String url2 = System.getProperty("rates.url");| Setting | Passed as | Read with | Spring key |
|---|---|---|---|
| Environment variable | RATES_URL=... | System.getenv | rates.url, by relaxed binding |
| System property | java -Drates.url=... -jar app.jar | System.getProperty | rates.url |
| Command-line argument | java -jar app.jar --rates.url=... | Spring only | rates.url |
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.
-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#
| .NET | Maven | Note |
|---|---|---|
| ~/.nuget/packages | ~/.m2/repository | a shared cache, one copy per version |
| nuget.config sources | ~/.m2/settings.xml | repositories, mirrors and credentials |
| obj/project.assets.json | the resolved dependency tree | mvn 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 useA 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#
| Flag | Controls | Example |
|---|---|---|
| -D | a system property your code or Spring reads | -Dspring.profiles.active=prod |
| -X | an extra JVM option, mostly memory and diagnostics | -Xmx512m, -Xss512k |
| -XX: | an advanced or tuning option | -XX:MaxRAMPercentage=70 |
| anything after the JAR | your 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#
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 Studio | IntelliJ IDEA | Note |
|---|---|---|
| Solution (.sln) | Project | the window you open |
| Project (.csproj) | Module | each has its own pom.xml and produces one JAR |
| Assembly output | JAR | produced by Maven or Gradle |
| Solution Explorer | Project tool window | Alt+1 |
| Package Manager | Maven or Gradle tool window | shows the tree; you add dependencies by editing pom.xml |
| Build menu | Build, or the Maven panel | the IDE delegates to the build tool |
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.
| Action | Visual Studio | IntelliJ (macOS) |
|---|---|---|
| Go to definition | F12 | Cmd+B |
| Find usages | Shift+F12 | Alt+F7 |
| Rename | Ctrl+R,R | Shift+F6 |
| Quick fix | Ctrl+. | Alt+Enter |
| Search everywhere | Ctrl+T | Shift Shift |
| Go to file | Ctrl+Shift+T | Cmd+Shift+O |
| Reformat | Ctrl+K,D | Cmd+Alt+L |
| Run | F5 | Ctrl+R |
| Debug | F5 | Ctrl+D |
| Step over | F10 | F8 |
| Generate member | Ctrl+. | Cmd+N |
| Extract method | Ctrl+R,M | Cmd+Alt+M |
| Organise imports | Ctrl+R,G | Ctrl+Alt+O |
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.
public class Customer
{
public string Name { get; set; }
public int Age { get; set; }
}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#
| Feature | Visual Studio | IntelliJ |
|---|---|---|
| Conditional breakpoint | right-click breakpoint | right-click breakpoint |
| Immediate window | Immediate | Evaluate Expression, Alt+F8 |
| Watch | Watch window | Variables panel, or Add to Watches |
| Data tips | hover | hover |
| Edit and Continue | supported | HotSwap, method bodies only |
| Exception breakpoint | Exception Settings | Breakpoints, Java Exception Breakpoints |
| Attach to process | Debug, Attach | Run, Attach to Process |
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:
| .NET | Java | Catches |
|---|---|---|
| Roslyn analyzers | Error Prone | real bug patterns at compile time |
| StyleCop | Checkstyle | formatting and naming |
| FxCop / analyzers | SpotBugs | bytecode-level bug patterns |
| EditorConfig | EditorConfig | supported by IntelliJ directly |
| dotnet format | Spotless | applies formatting in the build |
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?
3A colleague sends you a snippet using var and a record. What is the minimum Java version it needs?
var arrived in 10. On a Java 8 codebase, neither compiles.4What is the Java equivalent of the solution file, and who owns it?
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#
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#
public class TypeResolver : BaseResolver, IDisposable
{
}
public sealed class Final { }
public abstract class Shape { }
public static class Util { }public class TypeResolver extends BaseResolver
implements AutoCloseable {
}
public final class Final { }
public abstract class Shape { }
// no static classes; see below| C# | Java | Note |
|---|---|---|
| : Base | extends Base | one superclass only, same as C# |
| : IFoo | implements Foo | multiple allowed, same as C# |
| sealed class | final class | cannot be subclassed; Java's sealed means something else |
| abstract class | abstract class | identical |
| static class | final class + private constructor | Java has no static classes; this is the idiom |
| partial class | no equivalent | use composition or generated code |
| internal class | package-private (no keyword) | visible to the same package only, not the whole JAR |
| nested class | static nested class | non-static means something else |
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.
public class TypeResolver : BaseResolver
{
public TypeResolver()
: this("default", 0)
{
}
public TypeResolver(string name, int type)
: base(name)
{
}
}public class TypeResolver extends BaseResolver {
public TypeResolver() {
this("default", 0);
}
public TypeResolver(String name, int type) {
super(name);
}
}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# | Java | Runs when |
|---|---|---|
| static Foo() { } | static { } | first use of the class |
| field initialiser | field initialiser | before constructor body |
| n/a | instance initialiser { } | before constructor body, after super() |
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# | Java | Note |
|---|---|---|
| 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/a | wait() / 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#
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 modifier | Visible to | Closest C# |
|---|---|---|
| public | everyone | public |
| protected | package + subclasses anywhere | no exact equal |
| (none) | the same package only | no equal, package-private |
| private | the declaring class | private |
| C# modifier | Visible to | Closest Java |
|---|---|---|
| public | everyone | public |
| protected | subclasses only | no exact equal (Java's is wider) |
| internal | the assembly | package-private, roughly |
| protected internal | assembly or subclasses | protected, roughly |
| private protected | subclasses in the assembly | no equal |
| private | the declaring type | private |
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.
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.
// Anywhere/AtAll/Resolver.cs
namespace Acme.Sandbox;
public class TypeResolver { }// src/main/java/com/acme/sandbox/TypeResolver.java
package com.acme.sandbox;
public class TypeResolver { }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# | Java | Note |
|---|---|---|
| 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 imports | must use the full name |
| using static Math; | import static java.lang.Math.max; | imports one member, not the whole type |
| global using | no equivalent | every file repeats its imports |
| implicit usings | java.lang is always imported | String, Object, Integer |
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:
| Goal | Java approach | Cost |
|---|---|---|
| Hide from other packages | keep it package-private | tests must share the package |
| Hide from other JARs | JPMS module, do not export | all-or-nothing per package |
| Signal do not use | name the package .internal | convention only, not enforced |
| Grant test access | put tests in the same package | standard 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.
Interfaces, abstract classes and sealing#
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#
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();
}public interface Resolver {
Class<?> resolve(UUID id);
default boolean canResolve(UUID id) {
return resolve(id) != null;
}
static Resolver nullResolver() {
return new NullResolver();
}
}| Feature | C# | Java |
|---|---|---|
| Default method body | C# 8 | Java 8 |
| Static method | C# 8 | Java 8 |
| Private method | C# 8 | Java 9 |
| Constant field | no | yes, implicitly public static final |
| Fields | no | no (constants only) |
| Explicit implementation | yes | no |
| Generic variance on the interface | yes, in/out | no, use-site only |
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.
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.
| Intent | C# | Java |
|---|---|---|
| Cannot be extended at all | sealed class | final class |
| Only named types may extend | no equivalent | sealed 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 { }// 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
};// 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();
};
}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#
| Rule | Detail |
|---|---|
| Same module or package | permitted subtypes must be in the same module, or same package if unnamed |
| Every subtype must choose | it must be final, sealed, or non-sealed |
| permits may be omitted | if all subtypes are in the same file |
| non-sealed | reopens 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 branchAbstract 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 C | abstract class C |
| abstract void M(); | abstract void m(); |
| override void M() | @Override void m() |
| virtual by opt-in | virtual by default, use final to opt out |
| new (member hiding) | no equivalent for methods |
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#
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#
public async Task<int> CountAsync(
string filter,
bool caseSensitive = false,
int limit = 100)
{
...
}
var n = await CountAsync("x", limit: 50);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 argsJava 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();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.
if (int.TryParse(s, out var value))
{
Use(value);
}
void Swap(ref int a, ref int b) { ... }// 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# pattern | Java replacement |
|---|---|
| out parameter | return Optional, or a record |
| TryParse | Optional-returning method, or catch NumberFormatException |
| ref parameter | return the new value and reassign |
| Multiple return values | a record, or a small value class |
| ref struct / Span | ByteBuffer, MemorySegment, or an array plus offsets |
| in parameter | nothing needed; everything is already by value |
Varargs#
Identical in spirit to params, with the same restriction that it must be
last.
void Log(string fmt, params object[] args) { }
Log("a {0}", 1);void log(String fmt, Object... args) { }
log("a %s", 1);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#
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 thans.IsBlank(). This is why Apache Commons and Guava exist. - A
defaultmethod, 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:
decimal total = price * quantity + shipping;
if (total > limit) { ... }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# | Java | Note |
|---|---|---|
| x => x + 1 | x -> x + 1 | arrow differs |
| (x, y) => x + y | (x, y) -> x + y | same |
| () => Foo() | () -> foo() | same |
| Foo.Bar (method group) | Foo::bar | passes the method itself as a lambda |
| new Foo() as factory | Foo::new | passes 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 |
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#
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#
public class Customer
{
public string FirstName { get; set; }
public string LastName { get; init; }
public string FullName => $"{FirstName} {LastName}";
}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# | Java | Note |
|---|---|---|
| { get; set; } | getX() / setX() | frameworks find them by this exact naming rule |
| { get; } | final field + getX() | set in the constructor |
| { get; init; } | final field + constructor | or a record |
| => expression | a plain method | computed, no field |
| required | no equivalent | make the field final and demand it in the constructor |
| field keyword | no equivalent | write the field yourself |
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.
public record Customer(string FirstName, string LastName)
{
public string FullName => $"{FirstName} {LastName}";
}
var c2 = c1 with { FirstName = "Ada" };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());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# | Java | Meaning |
|---|---|---|
| readonly field | final field | assignable once, in constructor or initialiser |
| const | static final | compile-time constant, inlined |
| static readonly | static final | assigned in a static initialiser |
| readonly struct | no equivalent | no value types yet |
| init-only | final + constructor | records do this for you |
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 referenceFor genuine immutability use List.of(...) Java 9, which
returns an unmodifiable list that throws on mutation.
static final constants#
public const int MaxRetries = 3;
public static readonly TimeSpan Timeout =
TimeSpan.FromSeconds(30);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.
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.
1A field declared with no access modifier. Who can see it?
2You mark a method protected to restrict it to subclasses. Did that work?
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?
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?
getX() and setX(), or better, a record if the type is immutable data. Frameworks find them by that exact naming rule.Records#
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#
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 };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| Feature | C# record | Java record |
|---|---|---|
| Immutable by default | init-only, but can add setters | always, no exceptions |
| Positional syntax | yes | yes |
| Nominal (body) properties | yes | no: components only |
| with expression | yes | no |
| Value equality | yes | yes |
| Inheritance | records can inherit records | no inheritance at all |
| Can implement interfaces | yes | yes |
| struct variant | record struct | no |
| Custom constructor | yes | yes, 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);
}
}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#
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 componentsDerived 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#
| Use | Suitable | Why |
|---|---|---|
| DTO / API request or response | yes | Jackson binds records natively |
| Value object (Money, Range) | yes | immutability is the point |
| Query result / projection | yes | Spring Data supports record projections |
| Pattern-matching payload | yes | record patterns destructure them |
| JPA entity | no | Hibernate needs a no-arg constructor and mutability |
| Mutable domain object | no | records cannot be mutated |
| Needs inheritance | no | records are implicitly final |
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#
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#
if (o is string s && s.Length > 3)
{
Use(s);
}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#
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"
};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# | Java | Note |
|---|---|---|
| is T x | instanceof T x | same |
| switch expression arms | case T x -> | same shape |
| when clause | when clause | same keyword since Java 21 |
| _ discard | default | Java uses default, or _ for unnamed variables |
| case null | case null | Java 21; before that switch threw NPE |
| positional pattern | record pattern | Java 21 |
| property pattern { X: 1 } | no equivalent | test the property in a when clause instead |
| list pattern [1, .., 2] | no equivalent | none planned |
| relational pattern > 5 | only inside when | no bare relational patterns |
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(",", "{", "}"));
};
}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";
};
}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.
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#
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#
var label = day switch
{
DayOfWeek.Saturday or DayOfWeek.Sunday => "weekend",
DayOfWeek.Friday => "almost",
_ => "weekday"
};var label = switch (day) {
case SATURDAY, SUNDAY -> "weekend";
case FRIDAY -> "almost";
default -> "weekday";
};| C# | Java |
|---|---|
| value switch { ... } | switch (value) { ... } |
| pattern => result | case pattern -> result |
| or between patterns | comma between labels |
| _ | default |
| throw in an arm | throw in an arm |
| must be exhaustive | must 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();
};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";
};
}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#
| Type | Java | Note |
|---|---|---|
| int, short, char, byte | yes | since forever |
| String | yes | Java 7 |
| enum | yes | since Java 5 |
| sealed interface / class | yes | Java 21, via patterns |
| any Object | yes | Java 21, via patterns |
| long, float, double, boolean | no | use if/else, or preview primitive patterns |
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#
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#
var list = new List<string>();
var count = 0;
foreach (var item in list) { }var list = new ArrayList<String>();
var count = 0;
for (var item : list) { }| Context | C# var | Java var |
|---|---|---|
| Local variable | yes | yes |
| for / foreach variable | yes | yes |
| Field | no | no |
| Method parameter | no | no |
| Return type | no | no |
| Lambda parameter | implicit | yes, explicit var allowed |
| Without an initialiser | no | no |
| With null | no | no |
var plus the diamond operator infers something useless:
var list = new ArrayList<>(); // ArrayList<Object>, almost never intended
var list = new ArrayList<String>(); // what you meantNo 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# | Java | Note |
|---|---|---|
| $"{a} and {b}" | a + " and " + b | or .formatted() |
| $"{x:F2}" | "%.2f".formatted(x) | printf-style specifiers |
| string.Format | String.format | same idea |
| StringBuilder | StringBuilder | same |
| 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 == t | s.equals(t) | == compares references and silently fails on runtime strings |
== 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 sidesText blocks#
var json = """
{
"name": "ada",
"age": 36
}
""";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:
| Behaviour | C# raw string | Java text block |
|---|---|---|
| Opening delimiter | """ on its own line | """ must be followed by a newline |
| Indentation stripping | relative to closing """ | relative to the least-indented line and closing """ |
| Escapes processed | no | yes: \n, \t and \" still work |
| Interpolation | $""" ... """ | none |
| Line continuation | no | \ at end of line joins lines |
| Trailing space | preserved | stripped, unless \s is used |
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.
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");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");| .NET | Java | Note |
|---|---|---|
| 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.IgnoreCase | Pattern.CASE_INSENSITIVE | second argument to compile |
| RegexOptions.Compiled | not needed | Pattern is already compiled |
| @"verbatim" | no equivalent | every backslash must be doubled |
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.
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#
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#
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#
public enum Status
{
New = 1,
Active = 2,
Closed = 4
}
var s = (Status) 2;
int n = (int) Status.Active;public enum Status {
NEW, ACTIVE, CLOSED
}
Status s = Status.valueOf("ACTIVE");
int n = Status.ACTIVE.ordinal(); // 1, but see the gotchaNever 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# | Java | Note |
|---|---|---|
| Enum.Parse<T>(s) | Status.valueOf(s) | throws IllegalArgumentException if unknown |
| Enum.TryParse | no equivalent | catch, or build a Map lookup |
| Enum.GetValues<T>() | Status.values() | returns a fresh array each call |
| (int) e | e.ordinal() | the declaration position; never store it, it shifts |
| (Status) 2 | no equivalent | no int-to-enum cast |
| e.ToString() | e.name() | name() is final and exact |
| [Flags] | EnumSet | no bitwise enums; EnumSet is a bit vector underneath |
| [Description] attribute | a field on the enum | far cleaner |
| Dictionary keyed by enum | EnumMap | array-backed, very fast |
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:
[Flags]
enum Perm { Read = 1, Write = 2, Exec = 4 }
var p = Perm.Read | Perm.Write;
if (p.HasFlag(Perm.Write)) { }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";
};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#
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#
public abstract class Message<T> where T : Header
{
protected Message(T header) { Header = header; }
public T Header { get; }
}public abstract class Message<T extends Header> {
private final T header;
protected Message(T header) { this.header = header; }
public T header() { return header; }
}| C# constraint | Java equivalent | Note |
|---|---|---|
| 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 : class | no equivalent | everything is a reference type anyway |
| where T : struct | no equivalent | no value types |
| where T : new() | no equivalent | pass a Supplier<T> |
| where T : unmanaged | no equivalent | Java has no unmanaged or pointer types |
What erasure takes away#
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):
T Read<T>(string json) =>
JsonSerializer.Deserialize<T>(json);
var c = Read<Customer>(s);<T> T read(String json, Class<T> type) {
return mapper.readValue(json, type);
}
var c = read(s, Customer.class);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.
// declaration-site: IEnumerable<out T>
IEnumerable<object> objs = new List<string>();
// contravariance: IComparer<in T>
IComparer<string> c = new ObjectComparer();// use-site: the wildcard goes where the type is used
List<? extends Object> objs = new ArrayList<String>();
Comparator<? super String> c = new ObjectComparator();| Wildcard | Means | You can | Mnemonic |
|---|---|---|---|
| List<String> | exactly String | read and write String | invariant |
| List<? extends Number> | some unknown subtype | read as Number, cannot add | producer |
| List<? super Integer> | some unknown supertype | add Integer, read as Object | consumer |
| List<?> | unknown | read as Object, cannot add | any |
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#
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# | Java | Note |
|---|---|---|
| List<int> | List<Integer> or int[] | each element becomes a heap object |
| Dictionary<int,V> | Map<Integer,V> | keys are boxed |
| IEnumerable<int> | IntStream | avoids boxing |
| Nullable<int> / int? | Integer | null 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>>() { });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 runtimeTreat 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#
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#
[Serializable]
[Obsolete("Use NewApi")]
[Route("/orders/{id}")]
public class OrderController
{
[HttpGet]
public IActionResult Get([FromQuery] int page) { }
}@Deprecated(since = "2.0")
@RestController
@RequestMapping("/orders/{id}")
public class OrderController {
@GetMapping
public ResponseEntity<?> get(@RequestParam int page) { }
}| C# | Java | Note |
|---|---|---|
| [Attr] | @Attr | no 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 line | same convention |
| AttributeUsage | @Target | which declarations it may be attached to |
| n/a | @Retention | how long it survives; only RUNTIME is visible to reflection |
| [Obsolete] | @Deprecated | plus @deprecated javadoc |
| [Conditional] | no equivalent | no way to strip calls at compile time |
| Multiple same attribute | @Repeatable | Java 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.
| Retention | Survives to | Used for |
|---|---|---|
| SOURCE | compiler only | @Override, @SuppressWarnings, Lombok |
| CLASS | the .class file, not reflection | bytecode tools; the default |
| RUNTIME | reflection | Spring, Jackson, JUnit, JPA |
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#
[AttributeUsage(AttributeTargets.Method)]
public class AuditedAttribute : Attribute
{
public string Action { get; }
public AuditedAttribute(string action) => Action = action;
}@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
String action();
String actor() default "system";
}@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#
| Annotation | Meaning |
|---|---|
| @Override | asserts this overrides a supertype method; catches typos |
| @Deprecated | as [Obsolete]; pair with @deprecated in javadoc |
| @SuppressWarnings("unchecked") | silences a specific compiler warning |
| @FunctionalInterface | asserts exactly one abstract method |
| @SafeVarargs | asserts a generic varargs method does not leak the array |
| @Nullable / @NonNull | nullability; see Optional and JSpecify |
| @Entity, @Column | JPA mapping |
| @Test, @ParameterizedTest | JUnit |
| @Service, @Component, @Bean | Spring |
| @JsonProperty, @JsonIgnore | Jackson |
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.
| .NET | Java | Does |
|---|---|---|
| Source generators | Annotation processors (APT) | generate code at compile time |
| Roslyn analyzers | Error Prone, annotation processors | report errors at compile time |
| IL weaving (Fody) | bytecode manipulation (ByteBuddy) | rewrite after compilation |
| Reflection at startup | reflection, or APT | frameworks increasingly prefer APT |
| Tool | Generates | Replaces in .NET |
|---|---|---|
| Lombok | getters, setters, builders, equals | boilerplate, or a source generator |
| MapStruct | type-to-type mappers | AutoMapper, but at compile time |
| Micronaut / Quarkus | DI wiring, no runtime reflection | compile-time DI |
| JPA metamodel | typed criteria query classes | EF Core's typed queries |
| Immutables | immutable value classes | records |
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#
var attr = typeof(Order)
.GetCustomAttribute<AuditedAttribute>();
if (attr is not null) Use(attr.Action);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.
1You want string interpolation. What is the Java syntax?
formatted(), or a text block.2A sealed interface has four permitted records. Your switch handles all four. Do you need a default arm?
3You wrote a custom annotation and your framework cannot see it at runtime. Why?
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?
Nullability: Optional and JSpecify#
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# | Java | Note |
|---|---|---|
| string? name | String name | no way to say it in the language |
| string name (non-null) | String name | identical declaration |
| name!.Length | name.length() | no assertion operator |
| name?.Length | name == null ? null : name.length() | no ?. operator; chain with Optional.map instead |
| a ?? b | a != null ? a : b | or Objects.requireNonNullElse(a, b) |
| a ??= b | if (a == null) a = b; | no compound form |
| Nullable reference types warnings | JSpecify + NullAway or IntelliJ | opt-in, tool-dependent |
| ArgumentNullException.ThrowIfNull(x) | Objects.requireNonNull(x) | throws NullPointerException |
Optional is for return values#
Customer? Find(int id);
var c = Find(7);
var name = c?.Name ?? "unknown";Optional<Customer> find(int id);
String name = find(7)
.map(Customer::name)
.orElse("unknown");| Optional method | Does | C# analogy |
|---|---|---|
| Optional.of(x) | wraps, throws if x is null | n/a |
| Optional.ofNullable(x) | wraps, empty if null | n/a |
| Optional.empty() | absent | null |
| .map(f) | transform if present | ?. |
| .flatMap(f) | transform returning Optional | ?. returning nullable |
| .filter(p) | keep if it matches | n/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 present | if (x is not null) |
| .isPresent() / .isEmpty() | test | is not null / is null |
| .stream() | 0 or 1 element stream | Java 9 |
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
Serializableand adds an allocation per instance. - Method parameters; the caller now has to wrap, and can still pass
nullanyway. 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.
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 absentJSpecify: 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@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.
| Checker | Runs | Strength |
|---|---|---|
| IntelliJ inspections | in the IDE | good, but IDE-only |
| NullAway | build, via Error Prone | fast, practical, catches most real bugs |
| Checker Framework | build | rigorous and sound; slower, steeper |
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#
public Order(Customer customer)
{
ArgumentNullException.ThrowIfNull(customer);
_customer = customer;
}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.
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.
1What is the Java equivalent of string??
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?
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?
Collections#
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>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#
| .NET | Java | Note |
|---|---|---|
| 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/a | LinkedHashSet<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.unmodifiableList | a 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#
// 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"];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");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#
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-specifiedThe 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 |
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.
| Legacy | Use instead |
|---|---|
| Vector | ArrayList, or CopyOnWriteArrayList if concurrent |
| Hashtable | HashMap, or ConcurrentHashMap if concurrent |
| java.util.Stack | ArrayDeque |
| Enumeration | Iterator |
| Collections.synchronizedList | ConcurrentHashMap-backed types, or a proper concurrent collection |
Modifying while iterating#
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#
== 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#
// == is overloaded for string, and
// record/struct equality is value-based
var a = "abc";
var b = ReadFromFile();
if (a == b) { } // value comparison, worksString 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| Compare | C# | Java |
|---|---|---|
| Value equality | a == b, a.Equals(b) | a.equals(b) |
| Value equality, null-safe | a == b | Objects.equals(a, b) |
| Reference identity | ReferenceEquals(a, b) | a == b |
| Primitives | a == b | a == b |
| Ordering | a.CompareTo(b) | a.compareTo(b) |
| Custom ordering | IComparer<T> | Comparator<T> |
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 thisThe 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.
| Rule | Meaning |
|---|---|
| Reflexive | a.equals(a) is true |
| Symmetric | a.equals(b) implies b.equals(a) |
| Transitive | a=b and b=c implies a=c |
| Consistent | repeated calls give the same result |
| Null | a.equals(null) is false, never throws |
| hashCode agreement | a.equals(b) implies a.hashCode() == b.hashCode() |
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#
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);
}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);
}
}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) { }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#
people.Sort((a, b) => a.Age.CompareTo(b.Age));
var sorted = people
.OrderBy(p => p.LastName)
.ThenByDescending(p => p.Age)
.ToList();people.sort(Comparator.comparingInt(Person::age));
var sorted = people.stream()
.sorted(Comparator.comparing(Person::lastName)
.thenComparing(Person::age, reverseOrder()))
.toList();| LINQ | Java Comparator |
|---|---|
| OrderBy(f) | Comparator.comparing(f) |
| OrderByDescending(f) | Comparator.comparing(f).reversed() |
| ThenBy(f) | .thenComparing(f) |
| ThenByDescending(f) | .thenComparing(f, Comparator.reverseOrder()) |
| key is int | Comparator.comparingInt(f), avoids boxing |
| nulls | Comparator.nullsFirst(cmp) / nullsLast(cmp) |
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#
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#
var names = customers
.Where(c => c.Name.EndsWith("Doe"))
.Select(c => c.Name)
.OrderBy(n => n)
.ToList();var names = customers.stream()
.filter(c -> c.name().endsWith("Doe"))
.map(Customer::name)
.sorted()
.toList();Operator translation#
| LINQ | Stream | Note |
|---|---|---|
| Where | filter | |
| Select | map | |
| SelectMany | flatMap | |
| OrderBy | sorted(comparator) | |
| Take(n) | limit(n) | |
| Skip(n) | skip(n) | |
| TakeWhile | takeWhile | Java 9 |
| SkipWhile | dropWhile | Java 9 |
| Distinct | distinct() | compares with equals and hashCode, so implement both |
| Reverse | no direct equal | sort with a reversed comparator |
| Concat | Stream.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 equal | reduce, or collect and check size |
| Sum() | mapToInt(f).sum() | |
| Average() | mapToInt(f).average() | returns OptionalDouble |
| Min / Max | min(cmp) / max(cmp) | return Optional |
| Aggregate | reduce | |
| ToList() | toList() | Java 16; immutable |
| ToArray() | toArray(String[]::new) | |
| ToDictionary | collect(toMap(k, v)) | |
| GroupBy | collect(groupingBy(f)) | |
| Zip | no equivalent | use IntStream.range over indices |
| Chunk(n) | Stream.gather(Gatherers.windowFixed(n)) | Java 24 |
| DefaultIfEmpty | no equivalent | check isEmpty first |
| AsParallel() | parallelStream() | shares one JVM-wide pool; riskier than it looks |
Single use#
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 uponIn 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.
var byCity = customers
.GroupBy(c => c.City)
.ToDictionary(g => g.Key, g => g.Count());
var csv = string.Join(", ", names);var byCity = customers.stream()
.collect(groupingBy(Customer::city, counting()));
var csv = names.stream().collect(joining(", "));
// or simply: String.join(", ", names)| Collector | Produces |
|---|---|
| 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 |
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))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();| Need | Method |
|---|---|
| Stream to IntStream | mapToInt / mapToLong / mapToDouble |
| IntStream to Stream | boxed(), or mapToObj(f) |
| A range | IntStream.range(a, b) or rangeClosed(a, b) |
| Statistics | summaryStatistics(): count, sum, min, max, average |
Parallel streams#
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#
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:
| Approach | Looks like | Type-safe |
|---|---|---|
| JPQL string | "select c from Customer c where c.age > :age" | no |
| Criteria API | cb.greaterThan(root.get("age"), 18) | partly |
| JPA metamodel | cb.greaterThan(root.get(Customer_.age), 18) | yes, generated at compile time |
| jOOQ | dsl.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.
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 formFiles and I/O#
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#
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);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.IO | java.nio.file | Note |
|---|---|---|
| File.ReadAllText | Files.readString(p) | Java 11; UTF-8 by default |
| File.ReadAllLines | Files.readAllLines(p) | reads it all into memory |
| File.ReadLines (lazy) | Files.lines(p) | returns a Stream, so it must be closed |
| File.WriteAllText | Files.writeString(p, s) | Java 11 |
| File.AppendAllText | Files.writeString(p, s, APPEND) | StandardOpenOption |
| File.Exists | Files.exists(p) | |
| File.Delete | Files.delete(p) / deleteIfExists(p) | delete throws if absent |
| File.Copy / Move | Files.copy / Files.move | pass REPLACE_EXISTING to overwrite |
| Directory.CreateDirectory | Files.createDirectories(p) | creates parents too |
| Directory.GetFiles | Files.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.GetFileName | p.getFileName() | returns a Path, not a String |
| Path.GetExtension | no equivalent | parse the filename yourself |
| FileStream | Files.newInputStream / newOutputStream | |
| StreamReader | Files.newBufferedReader(p) | |
| MemoryStream | ByteArrayInputStream / ByteArrayOutputStream | |
| Path.GetTempFileName | Files.createTempFile(prefix, suffix) | |
| FileSystemWatcher | WatchService | much lower level than the .NET one |
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, safeJava 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.
| Kind | Java type | For |
|---|---|---|
| Bytes in | InputStream | binary reading |
| Bytes out | OutputStream | binary writing |
| Characters in | Reader | text reading |
| Characters out | Writer | text writing |
| Buffering | BufferedInputStream / BufferedReader | wrap the above; always worth it |
| Bridging | InputStreamReader(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
}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) { ... }
}| .NET | Java |
|---|---|
| Directory.EnumerateFiles(dir, "*.csv") | Files.newDirectoryStream(dir, "*.csv") |
| Directory.EnumerateFiles(dir, "*", AllDirectories) | Files.walk(dir) |
| Directory.EnumerateDirectories | Files.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.
// embedded resource
var asm = Assembly.GetExecutingAssembly();
using var s = asm.GetManifestResourceStream(
"MyApp.config.json");try (var in = getClass()
.getResourceAsStream("/config.json")) {
String json = new String(
in.readAllBytes(), UTF_8);
}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.
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?
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#
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.
// 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// 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.
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();| .NET | Java 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) | StructuredTaskScope | fork subtasks in a scope; still preview in Java 25 |
| Task.Delay(d) | Thread.sleep(d) | cheap on a virtual thread |
| CancellationToken | Thread.interrupt() | cooperative in both |
| IAsyncEnumerable<T> | no direct equal | stream from a BlockingQueue |
| AsyncLocal<T> | ScopedValue | final in Java 25 |
| SemaphoreSlim | Semaphore | still needed to limit concurrency |
| ThreadPool.QueueUserWorkItem | ExecutorService.submit | platform pool for CPU work |
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#
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.
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.
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: trueWhat 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 is | Do this in Java | Because |
|---|---|---|
| Make the method async | Leave it blocking | virtual threads unmount for you |
| Add Task<T> to the signature | Return T | no colouring to propagate |
| Tune the thread pool size | Delete the pool | one virtual thread per task |
| Worry about sync-over-async | Don't | there is no async to be over |
| Use AsyncLocal for context | ScopedValue | immutable, scope-bounded |
| Fire-and-forget with Task.Run | exec.submit on a scoped executor | keeps 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#
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.
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.
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);
}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.
| .NET | Java structured concurrency |
|---|---|
| Task.WhenAll(a, b) | fork twice, then join |
| Task.WhenAny(a, b) | a scope configured to complete on the first success |
| CancellationTokenSource | the scope itself |
| ct.ThrowIfCancellationRequested() | Thread.interrupted() checks, mostly implicit |
| CancelAfter(timeout) | a timeout configured on the scope |
| try/finally to clean up | the try-with-resources block |
| OperationCanceledException | InterruptedException |
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.
| Joiner | Behaviour | .NET analogue |
|---|---|---|
| anySuccessfulResultOrThrow() | the first successful result; cancels the rest | Task.WhenAny |
| allSuccessfulOrThrow() | a stream of all subtasks, all succeeded | Task.WhenAll |
| awaitAll() | waits for all, success or failure | WhenAll then inspect |
| awaitAllSuccessfulOrThrow() | waits for all; throws on any failure | WhenAll with fail-fast |
| allUntil(Predicate) | cancels when the predicate is satisfied | no 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#
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;
}
}
}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.
static readonly AsyncLocal<string> User = new();
User.Value = "ada";
await DoWorkAsync(); // sees "ada"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 time | Immutable, bound for a block |
| Lifetime is ambient | Lifetime is the block |
| Flows to async continuations | Inherited by forked subtasks |
| Can leak if never cleared | Cannot leak; unbinds on block exit |
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
}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#
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#
| .NET | Java | Note |
|---|---|---|
| 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.Delay | CompletableFuture.delayedExecutor(...) | Java 9 |
| IProgress<T> | no equivalent | pass a Consumer |
Chaining#
var report = await FetchCustomerAsync(id)
.ContinueWith(t => Enrich(t.Result))
.Unwrap();
// or, idiomatically
var c = await FetchCustomerAsync(id);
var report = await EnrichAsync(c);CompletableFuture<Report> f =
fetchCustomerAsync(id)
.thenApply(this::decorate) // sync transform
// enrichAsync returns another future
.thenCompose(this::enrichAsync);
Report report = f.join();| Method | Runs | Returns |
|---|---|---|
| thenApply(fn) | on the completing thread | CompletableFuture<R> |
| thenApplyAsync(fn) | on the common pool, or a supplied executor | CompletableFuture<R> |
| thenCompose(fn) | fn returns a future; flattens | CompletableFuture<R> |
| thenCombine(other, fn) | when both complete | CompletableFuture<R> |
| thenAccept(consumer) | side effect | CompletableFuture<Void> |
| exceptionally(fn) | only on failure | CompletableFuture<T> |
| handle(fn) | on success or failure | CompletableFuture<R> |
| whenComplete(action) | on either; does not change the value | CompletableFuture<T> |
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#
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);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#
| Situation | Use |
|---|---|
| Two blocking calls concurrently | virtual threads + an executor, not CompletableFuture |
| Adapting a callback API to a value | CompletableFuture, completed manually |
| Caffeine or another async cache | CompletableFuture; the API requires it |
| Spring WebFlux / reactive code | Mono/Flux, which are a different model again |
| Fire-and-forget with a completion hook | CompletableFuture.runAsync(...).thenRun(...) |
LegacyFuture<T>
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#
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#
private readonly object _gate = new();
public void Add(int x)
{
lock (_gate)
{
_total += x;
}
}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 thisNever 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.
| Need | C# | Java |
|---|---|---|
| Basic mutual exclusion | lock | synchronized, or ReentrantLock |
| Try with timeout | Monitor.TryEnter(o, ts) | lock.tryLock(t, unit) |
| Interruptible acquire | no direct equal | lock.lockInterruptibly() |
| Read/write split | ReaderWriterLockSlim | ReentrantReadWriteLock |
| Condition variable | Monitor.Wait / Pulse | Condition.await / signal |
| Fair queueing | no | new ReentrantLock(true) |
| Non-reentrant | SemaphoreSlim(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();
}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#
private int _count;
Interlocked.Increment(ref _count);
Interlocked.CompareExchange(ref _count, 5, 4);private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();
count.compareAndSet(4, 5);| .NET | Java |
|---|---|
| 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/a | atomicInt.updateAndGet(fn) |
| n/a | atomicRef.accumulateAndGet(v, fn) |
| Interlocked for high contention | LongAdder, far better under contention |
| Volatile.Read / Write | volatile field, or VarHandle |
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#
Both languages have the keyword; the semantics differ.
| C# volatile | Java volatile | |
|---|---|---|
| Reordering | acquire on read, release on write | full sequential consistency |
| Visibility | the field itself | the field, plus everything written before it |
| Atomicity of 64-bit | not guaranteed for long/double on 32-bit | guaranteed |
| Use for a flag | yes | yes |
| Use for double-checked locking | insufficient alone | sufficient |
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;
}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#
| .NET | Java | Note |
|---|---|---|
| ConcurrentDictionary<K,V> | ConcurrentHashMap<K,V> | |
| GetOrAdd(k, factory) | computeIfAbsent(k, fn) | atomic, fn runs at most once |
| AddOrUpdate | compute(k, fn) / merge(k, v, fn) | |
| ConcurrentQueue<T> | ConcurrentLinkedQueue<E> | unbounded, non-blocking |
| BlockingCollection<T> | LinkedBlockingQueue<E> | bounded, blocking |
| BlockingCollection with cap | ArrayBlockingQueue<E> | fixed capacity |
| ConcurrentBag<T> | no direct equal | use a concurrent queue |
| ImmutableList + swap | CopyOnWriteArrayList<E> | every write copies the array; only for rare writes |
| Channel<T> | BlockingQueue, or SubmissionPublisher | |
| CountdownEvent | CountDownLatch | counts down to zero once; cannot be reset |
| Barrier | CyclicBarrier | releases all waiters, then resets for the next round |
| ManualResetEventSlim | CountDownLatch, or a Condition | |
| SemaphoreSlim | Semaphore |
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#
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.
// .NET has always pooled for you
ThreadPool.QueueUserWorkItem(_ => Handle(id));
var t = Task.Run(() => Handle(id));
await t;// 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 | .NET | Java | Note |
|---|---|---|---|
| Background thread | IsBackground = true | setDaemon(true) | JVM exits without waiting |
| Foreground thread | default | default | JVM waits for it |
| Name | Thread.Name | setName() | shows in thread dumps |
| Priority | Thread.Priority | setPriority() | advisory only; ignore it |
| Wait for completion | Join() | join() | same |
| Kill it | Abort(), removed | stop(), removed | never 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.
| Factory | Gives you | Modern replacement |
|---|---|---|
| newFixedThreadPool(n) | n platform threads | virtual threads, for I/O |
| newCachedThreadPool() | unbounded, reused | virtual threads |
| newSingleThreadExecutor() | serial execution | still fine for serialising work |
| newScheduledThreadPool(n) | timer-like scheduling | still the right tool |
| newWorkStealingPool() | ForkJoinPool | still right for CPU-bound divide and conquer |
| newVirtualThreadPerTaskExecutor() | one virtual thread per task | Java 21+ |
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<T>
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 see | Replace with |
|---|---|
| new Thread(...) | Thread.ofVirtual(), or an executor |
| newFixedThreadPool for I/O | newVirtualThreadPerTaskExecutor |
| wait / notify | BlockingQueue, or Condition |
| Collections.synchronizedMap | ConcurrentHashMap |
| java.util.Timer | ScheduledExecutorService |
| ThreadLocal | ScopedValue |
| Thread.stop / suspend | nothing; they are removed |
| CompletableFuture merely to parallelise blocking calls | virtual threads |
Reactive: WebFlux and Reactor#
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#
| .NET | Reactor | Means |
|---|---|---|
| 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.CompletedTask | Mono.empty() | |
| .Select | .map | |
| .SelectMany | .flatMap | |
| .Where | .filter | |
| await | .block(), almost always wrong | see below |
| IAsyncEnumerable + Channel | Flux + backpressure | Reactor makes it explicit |
public async Task<Report> BuildAsync(int id)
{
var c = await _api.GetCustomerAsync(id);
var o = await _api.GetOrdersAsync(id);
return Merge(c, o);
}public Mono<Report> build(int id) {
return Mono.zip(
api.getCustomer(id),
api.getOrders(id))
.map(t -> merge(t.getT1(), t.getT2()));
}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.var c = await _api.GetCustomerAsync(id);
var o = await _api.GetOrdersAsync(id);
return Merge(c, o);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.
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#
| Case | Why virtual threads do not solve it |
|---|---|
| Server-sent events, long-lived streams | the value is many results over time, not one |
| Backpressure across a boundary | a 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 it | Flux processes element by element |
| You are already on WebFlux | mixing blocking code into it is worse than committing |
| Kafka Streams, R2DBC, RSocket | the library is reactive; fighting it is worse |
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#
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 MVC | Spring WebFlux | |
|---|---|---|
| Server | Tomcat (servlet) | Netty (event loop) |
| Threading | one thread per request | a few event-loop threads |
| Return types | Order, ResponseEntity | Mono, Flux |
| Data access | JDBC, JPA | R2DBC, reactive Mongo |
| Blocking libraries | fine | forbidden in the request path |
| Stack traces | ordinary and readable | assembled from operators; hard |
| Debugging | step through | Hooks.onOperatorDebug, and patience |
| Scales to 10k requests | yes, on virtual threads | yes |
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()));
}| Operator | Does |
|---|---|
| map | transform each element |
| flatMap | transform each into a publisher and merge; order not preserved |
| concatMap | as flatMap, but preserves order |
| zip | combine several publishers element-wise |
| switchIfEmpty | fall back when nothing was emitted |
| onErrorResume | substitute a publisher on failure |
| retryWhen | retry with a policy |
| timeout | fail if nothing arrives in time |
| subscribeOn / publishOn | choose which scheduler runs what |
| block | wait for the value, never in reactive code |
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); // correctLegacyRxJava
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.
1Where is Java's await?
Task<T> in signatures.2Should you pool virtual threads?
Semaphore.3Structured concurrency looks perfect for your fan-out. Can you ship it?
--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?
The week-one gotchas#
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#
== 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.
var a = "abc";
var b = ReadFromFile(); // also "abc"
a == b // true, string == is value equality
a.Equals(b) // trueString a = "abc";
String b = readFromFile(); // also "abc"
a == b // FALSE, reference comparison
a.equals(b) // true
Objects.equals(a, b) // true, and null-safe2. The Integer cache#
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#
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 0In C#, int? n = dict["missing"] would be a
KeyNotFoundException, a clearer failure. Use
getOrDefault(k, 0).
4. No modifier means package-private#
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#
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#
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#
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 runtimeGenerics are invariant precisely to avoid this, which is why
List<String> is not a List<Object>.
8. Inner classes capture the outer instance#
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(); // normalDefault 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#
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#
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); }
})11. switch on a reference type throws on null#
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#
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#
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#
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 size15. finally can swallow exceptions#
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#
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# | Java | Size | Note |
|---|---|---|---|
| sbyte | byte | 8-bit | Java's byte is SIGNED |
| byte | no equivalent | use short, or byte with care | |
| short | short | 16-bit | |
| ushort | char | 16-bit | char is the only unsigned type |
| int | int | 32-bit | |
| uint | no equivalent | use long, or Integer.*Unsigned helpers | |
| long | long | 64-bit | |
| ulong | no equivalent | use Long.*Unsigned helpers | |
| float | float | 32-bit | |
| double | double | 64-bit | |
| decimal | no equivalent | use BigDecimal | |
| bool | boolean | note the spelling | |
| char | char | 16-bit | UTF-16 code unit in both |
| nint / nuint | no equivalent |
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#
| Primitive | Wrapper | C# analogy |
|---|---|---|
| int | Integer | int? (roughly) |
| long | Long | long? |
| double | Double | double? |
| boolean | Boolean | bool? |
| char | Character | char? |
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#
decimal price = 19.99m;
decimal total = price * 3 + shipping;
if (total > limit) { }
Console.WriteLine(total.ToString("C"));BigDecimal price = new BigDecimal("19.99");
BigDecimal total = price.multiply(BigDecimal.valueOf(3))
.add(shipping);
if (total.compareTo(limit) > 0) { }
NumberFormat.getCurrencyInstance().format(total);| Operation | BigDecimal |
|---|---|
| a + b | a.add(b) |
| a - b | a.subtract(b) |
| a * b | a.multiply(b) |
| a / b | a.divide(b, scale, RoundingMode.HALF_UP) |
| a > b | a.compareTo(b) > 0 |
| a == b (value) | a.compareTo(b) == 0 |
| rounding | a.setScale(2, RoundingMode.HALF_UP) |
| from a literal | new BigDecimal("19.99"), always the String constructor |
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 theStringconstructor, orBigDecimal.valueOf(double)which routes throughDouble.toString.dividewithout a scale throwsArithmeticExceptionon a non-terminating result such as 1/3. Always supply a scale and aRoundingMode.equalscompares scale.new BigDecimal("1.0").equals(new BigDecimal("1.00"))is false. UsecompareTo.
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.
| Concept | java.time | .NET |
|---|---|---|
| Date, no time, no zone | LocalDate | DateOnly |
| Time, no date, no zone | LocalTime | TimeOnly |
| Date and time, no zone | LocalDateTime | DateTime (Unspecified) |
| Instant on the timeline | Instant | DateTimeOffset (UTC) |
| Date, time and zone | ZonedDateTime | DateTimeOffset + TimeZoneInfo |
| Date, time and offset | OffsetDateTime | DateTimeOffset |
| Amount of time | Duration | TimeSpan |
| Calendar amount | Period | no direct equal |
| Time zone | ZoneId | TimeZoneInfo |
| Formatting | DateTimeFormatter | format strings |
var now = DateTimeOffset.UtcNow;
var due = now.AddDays(30);
var d = DateOnly.FromDateTime(DateTime.Today);
var span = due - now;var now = Instant.now();
var due = now.plus(30, ChronoUnit.DAYS);
var d = LocalDate.now();
var span = Duration.between(now, due);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.
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:
| Class | Problem |
|---|---|
| java.util.Date | mutable; it is really an instant, despite the name |
| java.sql.Date | extends Date but forbids the time part |
| Calendar | months are ZERO-based. January is 0 |
| SimpleDateFormat | NOT thread-safe; a shared instance corrupts output silently |
| TimeZone | superseded 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 |
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#
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
└─ ...| Rule | Meaning |
|---|---|
| Extends RuntimeException or Error | unchecked: like every C# exception |
| Extends Exception, not RuntimeException | checked, must be declared or caught |
Checked exceptions#
public string Read(string path)
{
// may throw; nothing to declare
return File.ReadAllText(path);
}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
}
}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.
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#
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 |
|---|---|
| Exception | Exception / RuntimeException |
| ArgumentException | IllegalArgumentException |
| ArgumentNullException | NullPointerException |
| InvalidOperationException | IllegalStateException |
| NotSupportedException | UnsupportedOperationException |
| NotImplementedException | UnsupportedOperationException |
| FormatException | NumberFormatException, DateTimeParseException |
| IndexOutOfRangeException | IndexOutOfBoundsException / ArrayIndexOutOfBoundsException |
| KeyNotFoundException | NoSuchElementException |
| NullReferenceException | NullPointerException |
| IOException | IOException |
| TimeoutException | TimeoutException |
| OperationCanceledException | InterruptedException |
| OverflowException | ArithmeticException, only from *Exact methods |
| AggregateException | CompletionException / ExecutionException |
| StackOverflowException | StackOverflowError |
| OutOfMemoryException | OutOfMemoryError |
try, catch, finally#
try
{
Do();
}
catch (IOException or SqlException ex)
{
Log(ex);
throw; // rethrow, preserving the stack
}
finally
{
Cleanup();
}try {
doIt();
} catch (IOException | SQLException e) { // multi-catch
log(e);
throw e; // rethrow the same instance
} finally {
cleanup();
}| C# | Java | Note |
|---|---|---|
| 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 equivalent | filter inside the catch and rethrow |
| finally | finally | same |
| using | try-with-resources | see below |
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#
using var conn = new SqlConnection(cs);
using var cmd = new SqlCommand(sql, conn);
conn.Open();try (var conn = dataSource.getConnection();
var stmt = conn.prepareStatement(sql)) {
...
} // closed in reverse order, even on exception| C# | Java |
|---|---|
| IDisposable | AutoCloseable |
| IAsyncDisposable | no equivalent |
| Dispose() | close() |
| using statement | try-with-resources |
| using declaration | no equivalent, always a block |
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.
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
}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.
1Integer a = 128, b = 128; a == b. True or false?
equals.2A service method throws a checked exception. Does the transaction roll back?
@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?
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#
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/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#
<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><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>| csproj | pom.xml | Note |
|---|---|---|
| PackageId | groupId + artifactId | an org prefix plus a name, so vendors never collide |
| Version | version | SNAPSHOT means "in development" |
| TargetFramework | maven.compiler.release | the bytecode target |
| PackageReference | dependency | |
| ProjectReference | dependency on a sibling module | same syntax |
| Directory.Build.props | a parent POM | children inherit from it; it is not textually included |
| Directory.Packages.props | dependencyManagement | central version pinning |
| nuget.config | settings.xml | repositories and credentials |
| dotnet restore | no separate step | Maven 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>| Scope | Available at | .NET analogy |
|---|---|---|
| compile | everywhere; default | normal PackageReference |
| provided | compile and test, not packaged | a framework reference |
| runtime | run and test, not compile | a runtime-only dependency |
| test | test only | a test-project-only reference |
| import | only in dependencyManagement | for 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 | .NET | Maven |
|---|---|---|
| Compile | dotnet build | mvn compile |
| Run tests | dotnet test | mvn test |
| Build a JAR / dll | dotnet build | mvn package |
| Skip tests | dotnet build | mvn package -DskipTests |
| Clean | dotnet clean | mvn clean |
| Install locally | dotnet pack + local feed | mvn install |
| Publish to a repo | dotnet nuget push | mvn deploy |
| Run the app | dotnet run | mvn spring-boot:run |
| Dependency tree | dotnet list package --include-transitive | mvn dependency:tree |
| Check for updates | dotnet outdated | mvn versions:display-dependency-updates |
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 onA 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>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 onDo 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>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-databindTo force a version, declare it directly in your own <dependencies>, depth zero always wins, or pin it in <dependencyManagement>.
“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#
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#
| Aspect | Maven | Gradle |
|---|---|---|
| Format | XML, declarative | Kotlin or Groovy DSL, imperative |
| Learning curve | shallow; conventions do the work | steeper; more rope |
| Speed on big builds | slower | much faster: incremental + build cache |
| Customisation | write or find a plugin | write code inline |
| Predictability | very high | depends on the author |
| Ecosystem default | enterprise, Spring tutorials | Android, newer projects |
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#
<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>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 scope | Gradle configuration | Meaning |
|---|---|---|
| compile | implementation | used internally; NOT exposed to consumers |
| compile (exported) | api | exposed to consumers transitively |
| provided | compileOnly | compile only, not packaged |
| runtime | runtimeOnly | not on the compile classpath |
| test | testImplementation | tests only |
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#
| Task | Maven | Gradle |
|---|---|---|
| Compile | mvn compile | ./gradlew classes |
| Test | mvn test | ./gradlew test |
| Package | mvn package | ./gradlew build |
| Clean | mvn clean | ./gradlew clean |
| Run a Boot app | mvn spring-boot:run | ./gradlew bootRun |
| Dependency tree | mvn dependency:tree | ./gradlew dependencies |
| One module | mvn -pl mod -am package | ./gradlew :mod:build |
| Skip tests | -DskipTests | -x test |
| List tasks | n/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>| .NET | Maven | Gradle |
|---|---|---|
| Solution (.sln) | aggregator pom with <modules> | settings.gradle.kts |
| ProjectReference | a normal dependency on the sibling | project(":billing-domain") |
| Directory.Packages.props | <dependencyManagement> | version catalog (libs.versions.toml) |
| Build the solution | mvn package at the root | ./gradlew build at the root |
| Build one project | mvn -pl billing-api -am package | ./gradlew :billing-api:build |
-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#
| .NET | Java |
|---|---|
| nuget.org | Maven Central (repo1.maven.org) |
| ~/.nuget/packages | ~/.m2/repository |
| nuget.config | ~/.m2/settings.xml |
| Azure Artifacts / GitHub Packages | Nexus, Artifactory, GitHub Packages |
| packages.lock.json | no true equivalent, pin versions explicitly |
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#
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
}| Directive | Means | .NET analogy |
|---|---|---|
| requires X | I depend on module X | assembly reference |
| requires transitive X | and my consumers get X too | a public dependency |
| exports p | package p is public | public types in the assembly |
| exports p to M | package p is visible only to M | InternalsVisibleTo, inverted |
| opens p | allow deep reflection into p | needed by Spring, Hibernate, Jackson |
| uses / provides | ServiceLoader wiring | DI at the platform level |
// visible only within this assembly
internal class StripeGateway { }
// and grant one friend assembly access
[assembly: InternalsVisibleTo("Acme.Billing.Tests")]// 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#
| Goal | C# | Java |
|---|---|---|
| Public to my code, hidden outside | internal | a non-exported package in a module |
| Public to everyone | public | exported package + public type |
| Visible to my tests | InternalsVisibleTo | tests in the same package |
| Visible to one other component | InternalsVisibleTo("X") | exports p to X |
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#
| Classpath | Module path | |
|---|---|---|
| Flat namespace | yes | no |
| Access enforced | no | yes |
| Split packages allowed | yes | no |
| Needs module-info | no | yes (or it becomes an automatic module) |
| Most Spring Boot apps | this one | rarely |
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#
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 @0x1b6d3586The 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#
| Flag | Does |
|---|---|
| --add-opens M/p=ALL-UNNAMED | grant deep reflection into a JDK package |
| --add-exports M/p=ALL-UNNAMED | grant compile/runtime access to a non-exported package |
| --add-modules M | add a module not required transitively |
| --illegal-access=permit | removed in Java 17; no longer available |
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#
| Situation | Use JPMS? |
|---|---|
| Spring Boot service in a container | no; the fat JAR is the boundary |
| Library published to Maven Central | yes, publish at least an Automatic-Module-Name |
| Desktop app shipped with jlink | yes, jlink requires modules |
| Large internal platform with many teams | maybe, enforced boundaries help |
| Anything on Java 8 | not available |
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#
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#
| .NET | Java | Note |
|---|---|---|
| xUnit / NUnit / MSTest | JUnit 5 (Jupiter) | the default by a wide margin |
| [Fact] | @Test | |
| [Theory] + [InlineData] | @ParameterizedTest + @ValueSource | |
| [Trait] | @Tag | |
| Constructor / IDisposable | @BeforeEach / @AfterEach | |
| IClassFixture | @BeforeAll / @AfterAll | runs once per class; the method must be static |
| Assert.Equal | assertEquals, or AssertJ | assertEquals takes expected first, then actual |
| FluentAssertions | AssertJ | assertThat(x).isEqualTo(y) |
| Moq | Mockito | |
| NSubstitute | Mockito | |
| AutoFixture | Instancio, EasyRandom | |
| Bogus | Java Faker, Datafaker | |
| Testcontainers | Testcontainers | same project, JVM original |
| WireMock | WireMock | same project, JVM original |
| BenchmarkDotNet | JMH | |
| FsCheck | jqwik | property-based testing |
| ArchUnitNET | ArchUnit | architecture rules as tests |
| coverlet | JaCoCo | coverage |
A test#
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);
}
}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");
}
}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.
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#
[Theory]
[InlineData(1, 1, 2)]
[InlineData(2, 3, 5)]
public void Adds(int a, int b, int expected)
=> Assert.Equal(expected, Add(a, b));@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5"
})
void adds(int a, int b, int expected) {
assertThat(add(a, b)).isEqualTo(expected);
}| Source | Provides |
|---|---|
| @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 |
| @NullAndEmptySource | null and empty string |
Mocking#
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);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));| Moq | Mockito |
|---|---|
| new Mock<T>() | mock(T.class) |
| mock.Object | the 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) |
| Callback | thenAnswer(invocation -> ...) |
| MockBehavior.Strict | Mockito.mock(T.class, RETURNS_SMART_NULLS) |
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
}
}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.
var server = WireMockServer.Start();
server
.Given(Request.Create()
.WithPath("/rates/GBPUSD").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithBodyAsJson(new { rate = 1.27 }));@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);
}
}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.
[MemoryDiagnoser]
public class ParseBench
{
[Params(10, 1000)]
public int N;
[Benchmark]
public int Sum() => Enumerable.Range(0, N).Sum();
}
BenchmarkRunner.Run<ParseBench>();@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();
}
}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>| .NET | Java | Note |
|---|---|---|
| coverlet | JaCoCo | runs as a java agent during the test phase |
| ReportGenerator | jacoco:report | writes HTML into target/site |
| threshold gate in CI | jacoco:check | fails the build itself |
| Stryker.NET | PIT | mutation testing; PIT is the JVM original |
JUnit 5 and JUnit 6#
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() { ... }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?
4What does assertEquals(actual, expected) do?
assertThat(actual).isEqualTo(expected) removes the ambiguity.Your library, translated#
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#
| .NET | Java | Note |
|---|---|---|
| ASP.NET Core | Spring Boot | overwhelmingly the default |
| Minimal APIs | Spring Boot, Javalin, Helidon | Javalin is closest in spirit |
| Kestrel | embedded Tomcat, Netty, Jetty | Tomcat is the Boot default |
| IIS hosting | a JAR with an embedded server | no external server needed |
| Swashbuckle / NSwag | springdoc-openapi | generates OpenAPI from controllers |
| SignalR | Spring WebSocket + STOMP | no direct 1:1 equivalent |
| gRPC for .NET | grpc-java | |
| YARP | Spring Cloud Gateway | |
| Blazor | Vaadin, Thymeleaf, JTE | different models entirely |
| Razor / Razor Pages | Thymeleaf, JTE, Freemarker | server-side templating |
Data access#
| .NET | Java | Note |
|---|---|---|
| Entity Framework Core | Hibernate / Spring Data JPA | the default ORM |
| Dapper | JdbcTemplate, JDBI | you write the SQL; it maps rows to objects |
| LINQ to SQL, IQueryable | jOOQ | typed SQL DSL generated from the schema |
| ADO.NET | JDBC | the raw layer |
| EF Migrations | Flyway, Liquibase | Flyway is plain SQL files |
| DbContext | EntityManager, or a Spring Data repository | |
| IDbConnection | DataSource, Connection | |
| Npgsql / SqlClient | the PostgreSQL / MSSQL JDBC driver | |
| StackExchange.Redis | Lettuce, Jedis | Lettuce is the Spring default |
| MongoDB.Driver | mongodb-driver-sync | |
| Elasticsearch.Net | co.elastic.clients |
Serialisation#
| .NET | Java | Note |
|---|---|---|
| System.Text.Json | Jackson | the default |
| Newtonsoft.Json | Jackson, Gson | Gson is simpler, less capable |
| JsonSerializer.Serialize | objectMapper.writeValueAsString | |
| [JsonPropertyName] | @JsonProperty | |
| [JsonIgnore] | @JsonIgnore | |
| JsonSerializerOptions | ObjectMapper configuration | |
| protobuf-net | protobuf-java | |
| MessagePack | msgpack-java | |
| YamlDotNet | SnakeYAML, Jackson YAML | |
| CsvHelper | OpenCSV, Jackson CSV |
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#
| .NET | Java | Note |
|---|---|---|
| Microsoft.Extensions.DependencyInjection | Spring, or Jakarta CDI | |
| Autofac | Spring | |
| ILogger<T> | SLF4J Logger | the interface you code against; Logback implements it |
| Serilog | Logback, Log4j2 | Logback is the Boot default |
| NLog | Log4j2 | |
| structured logging | Logstash encoder, or Boot structured logging | |
| IConfiguration | Spring Environment | |
| appsettings.json | application.yml / application.properties | |
| IOptions<T> | @ConfigurationProperties | |
| User Secrets | a local profile, or Vault | |
| Azure App Configuration | Spring Cloud Config |
Resilience, messaging, scheduling#
| .NET | Java | Note |
|---|---|---|
| Polly | Resilience4j | the Boot 3 answer; a separate library |
| Polly | Spring Framework @Retryable | the Boot 4 answer; retry moved into the framework |
| HttpClientFactory | RestClient, HTTP interface clients | |
| Refit | @HttpExchange interfaces | declarative HTTP |
| MediatR | Spring ApplicationEvents, Axon | no single library covers the same ground |
| MassTransit / NServiceBus | Spring Integration, Camel, Axon | |
| RabbitMQ.Client | Spring AMQP | |
| Confluent.Kafka | Spring Kafka | |
| Hangfire | Quartz, Spring @Scheduled | Quartz is the Java original |
| Quartz.NET | Quartz | same project |
| Azure Functions | Spring Cloud Function |
Testing and quality#
| .NET | Java | Note |
|---|---|---|
| xUnit / NUnit | JUnit 5 (JUnit 6 on Boot 4) | |
| Moq / NSubstitute | Mockito | |
| FluentAssertions | AssertJ | |
| Testcontainers | Testcontainers | same project |
| WireMock.Net | WireMock | same project |
| AutoFixture | Instancio | |
| Bogus | Datafaker | |
| BenchmarkDotNet | JMH | |
| coverlet + ReportGenerator | JaCoCo | |
| SonarAnalyzer | SonarQube | same product |
| Roslyn analyzers | Error Prone | compile-time bug detection |
| StyleCop | Checkstyle | |
| dotnet format | Spotless | |
| ArchUnitNET | ArchUnit | same project |
Observability and diagnostics#
| .NET | Java | Note |
|---|---|---|
| dotnet-counters | JFR, Micrometer | |
| dotnet-trace / PerfView | JFR + JDK Mission Control | JFR is built into the JDK |
| dotnet-dump | jmap, jcmd | |
| OpenTelemetry .NET | OpenTelemetry Java agent | zero-code instrumentation |
| App Insights / Prometheus | Micrometer + Prometheus | Micrometer is the metrics interface; the backend plugs in |
| HealthChecks | Spring Boot Actuator | |
| Visual Studio Profiler | async-profiler, JMC | async-profiler is excellent |
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#
| .NET | Java | Note |
|---|---|---|
| LINQ | Streams | built in |
| Humanizer | no direct equivalent | |
| AutoMapper | MapStruct | compile-time, so mismatches are build errors |
| FluentValidation | Jakarta Bean Validation | @NotNull, @Size, custom validators |
| System.Collections.Immutable | List.of, Guava immutables | |
| Nito.AsyncEx | virtual threads | the problems it solves largely disappear |
| CommandLineParser | picocli | |
| Scriban / Handlebars | Thymeleaf, Mustache | |
| NodaTime | java.time | the ancestor, built in |
| Guard clauses libraries | Objects.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.
var opts = new JsonSerializerOptions {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull
};
string json = JsonSerializer.Serialize(order, opts);
var back = JsonSerializer.Deserialize<Order>(json, opts);ObjectMapper mapper = JsonMapper.builder()
.addModule(new JavaTimeModule())
.serializationInclusion(Include.NON_NULL)
.build();
String json = mapper.writeValueAsString(order);
Order back = mapper.readValue(json, Order.class);public record Order(
[property: JsonPropertyName("order_id")] long Id,
[property: JsonIgnore] string Secret,
[property: JsonConverter(typeof(MoneyConverter))]
decimal Total);public record Order(
@JsonProperty("order_id") long id,
@JsonIgnore String secret,
@JsonSerialize(using = MoneySerializer.class)
BigDecimal total) { }| System.Text.Json | Jackson | Note |
|---|---|---|
| JsonSerializer.Serialize | mapper.writeValueAsString | |
| JsonSerializer.Deserialize<T> | mapper.readValue(s, T.class) | erasure: pass the class |
| Deserialize a generic type | new TypeReference<List<T>>() { } | note the trailing braces |
| [JsonPropertyName] | @JsonProperty | |
| [JsonIgnore] | @JsonIgnore | |
| [JsonConverter] | @JsonSerialize / @JsonDeserialize | |
| JsonSerializerOptions | JsonMapper.builder() | |
| CamelCase policy | PropertyNamingStrategies.LOWER_CAMEL_CASE | Java is already camelCase |
| DateTime support | JavaTimeModule | must be registered, or dates fail |
| Unknown members ignored | FAIL_ON_UNKNOWN_PROPERTIES | Jackson FAILS by default |
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.
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);@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);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
}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| Problem | Detail |
|---|---|
| Security | untrusted input can execute code; see above |
| Versioning | serialVersionUID must be managed by hand, or old data stops loading |
| Coupling | the wire format is your private field layout, so refactoring breaks it |
| Portability | only Java can read it; nothing else on the wire understands it |
| Bypasses constructors | objects are reconstructed without running your invariants |
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.
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");
}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");
}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#
// 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;
}@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;
}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#
| .NET | Java | Note |
|---|---|---|
| MSBuild | Maven, Gradle | |
| NuGet | Maven Central | |
| dotnet publish | mvn package | produces a fat JAR for Boot |
| Self-contained deployment | fat JAR, or jlink | |
| NativeAOT | GraalVM native-image | |
| ReadyToRun | AOT cache, CDS | Java 24/25 improved this substantially |
| dotnet tool | JBang, or a shaded JAR | |
| global.json | .sdkmanrc, Maven toolchains |
Spring Boot orientation#
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#
| Line | Spring Framework | Baselines | Use when |
|---|---|---|---|
| Boot 3.x | Framework 6 | Java 17, Jakarta EE 10 | existing systems; most tutorials |
| Boot 4.x | Framework 7 | Java 17 (25 recommended), Jakarta EE 11, Kotlin 2.2, GraalVM 25, JUnit 6 | greenfield |
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#
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddDbContext<AppDb>();
var app = builder.Build();
app.MapControllers();
app.Run();// BillingApplication.java
@SpringBootApplication
public class BillingApplication {
public static void main(String[] args) {
SpringApplication.run(BillingApplication.class, args);
}
}
// services are discovered by annotation, not registered hereThere 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”.
| Capability | Boot 3.5 starter | Boot 4.0 starter |
|---|---|---|
| Web MVC | spring-boot-starter-web | spring-boot-starter-webmvc |
| Reactive web | spring-boot-starter-webflux | spring-boot-starter-webflux |
| JPA | spring-boot-starter-data-jpa | spring-boot-starter-data-jpa |
| Security | spring-boot-starter-security | spring-boot-starter-security |
| OAuth2 client | spring-boot-starter-oauth2-client | spring-boot-starter-security-oauth2-client |
| Validation | spring-boot-starter-validation | spring-boot-starter-validation |
| Testing | spring-boot-starter-test | spring-boot-starter-test |
| Actuator | spring-boot-starter-actuator | spring-boot-starter-actuator |
| SOAP services | spring-boot-starter-web-services | spring-boot-starter-webservices |
| OpenTelemetry | n/a | spring-boot-starter-opentelemetry |
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) { ... }
}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 reasonsProject 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 SQLRunning it#
| Task | .NET | Spring Boot |
|---|---|---|
| Run | dotnet run | ./mvnw spring-boot:run |
| Run with a profile | ASPNETCORE_ENVIRONMENT=Development | --spring.profiles.active=dev |
| Build a deployable | dotnet publish | ./mvnw package |
| Run the artifact | dotnet MyApp.dll | java -jar target/billing-1.0.0.jar |
| Watch and reload | dotnet watch | spring-boot-devtools |
| Default port | 5000 / 5001 | 8080 |
| Change the port | --urls | --server.port=9090 |
./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#
| Framework | Pitch | Compared to |
|---|---|---|
| Spring Boot | the default; vast ecosystem | ASP.NET Core |
| Quarkus | compile-time DI, fast startup, native-first | ASP.NET Core + NativeAOT |
| Micronaut | compile-time DI, no runtime reflection | similar to Quarkus |
| Javalin | tiny, explicit, no magic | Minimal APIs |
| Helidon | Oracle, MicroProfile and Nima | Minimal 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#
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#
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IClock, SystemClock>();
public class OrderService(IOrderRepo repo) : IOrderService
{
}// 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;
}
}| Annotation | Means | Note |
|---|---|---|
| @Component | a managed bean | "bean" is Spring's word for an object it creates and injects |
| @Service | a component; business logic | semantic only |
| @Repository | a component; data access | also translates persistence exceptions |
| @Controller / @RestController | a component; web endpoint | |
| @Configuration | a class that declares @Bean methods | |
| @Bean | a factory method | use it for classes you cannot annotate, such as library types |
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 Core | Spring | Note |
|---|---|---|
| AddSingleton | singleton | THE DEFAULT in Spring |
| AddScoped | request | web only; needs @Scope("request") |
| AddTransient | prototype | a new instance per injection point |
| n/a | session | one per HTTP session |
| n/a | application | one per deployed web application |
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#
builder.Services.AddSingleton(sp =>
new HttpClient { BaseAddress = new Uri(url) });@Configuration
public class HttpConfig {
@Bean
RestClient ratesClient(RestClient.Builder builder,
@Value("${rates.url}") String url) {
return builder.baseUrl(url).build();
}
}Multiple implementations#
builder.Services.AddKeyedScoped<IPay, Card>("card");
builder.Services.AddKeyedScoped<IPay, Bank>("bank");
public class Checkout([FromKeyedServices("card")] IPay pay);@Service("card") class CardPayment implements Pay { }
@Service("bank") class BankPayment implements Pay { }
@Service
public class Checkout {
Checkout(@Qualifier("card") Pay pay) { }
}| Need | Spring |
|---|---|
| Pick one by name | @Qualifier("name") |
| Prefer one by default | @Primary on that bean |
| Inject all of them | List<Pay> or Map<String, Pay> parameter |
| Conditional registration | @ConditionalOnProperty, @Profile |
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#
{
"ConnectionStrings": {
"Default": "Server=localhost;Database=billing"
},
"Rates": {
"Url": "https://rates.example.com",
"TimeoutSeconds": 5
},
"Logging": {
"LogLevel": { "Default": "Information" }
}
}spring:
datasource:
url: jdbc:postgresql://localhost/billing
rates:
url: https://rates.example.com
timeout-seconds: 5
logging:
level:
root: INFO
com.acme.billing: DEBUGProfiles are environments#
| .NET | Spring | Note |
|---|---|---|
| ASPNETCORE_ENVIRONMENT | SPRING_PROFILES_ACTIVE | |
| appsettings.Development.json | application-dev.yml | |
| IHostEnvironment.IsDevelopment() | @Profile("dev") | |
| --environment Development | --spring.profiles.active=dev | |
| Multiple environments | multiple active profiles | comma-separated |
@Service
@Profile("!prod") // active in every profile except prod
class FakeEmailSender implements EmailSender { }IOptions becomes @ConfigurationProperties#
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);@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 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 defaultConfiguration sources are layered, highest priority first:
| Priority | Source |
|---|---|
| 1 | command-line arguments |
| 2 | SPRING_APPLICATION_JSON |
| 3 | Java system properties, set with -D |
| 4 | OS environment variables |
| 5 | application-{profile}.yml |
| 6 | application.yml |
| 7 | @PropertySource |
| 8 | defaults in code |
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.
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#
| .NET | Spring |
|---|---|
| User Secrets | application-local.yml, gitignored |
| Azure Key Vault | Spring Cloud Azure, or Vault |
| AWS Secrets Manager | Spring Cloud AWS |
| Environment variables | environment variables |
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#
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 Core | Spring | When |
|---|---|---|
| constructor | constructor | dependencies arrive |
| n/a | @PostConstruct | after all dependencies are set |
| n/a | InitializingBean.afterPropertiesSet() | same point, interface form |
| n/a | BeanPostProcessor | around every bean; how the framework extends itself |
| IDisposable / IAsyncDisposable | @PreDestroy | container shutdown |
| IHostedService.StartAsync | ApplicationRunner, @EventListener(ApplicationReadyEvent) | after the context is up |
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// nothing built in: a DispatchProxy, or Scrutor's Decorate
services.AddScoped<IOrderService, OrderService>();
services.Decorate<IOrderService, TransactionalDecorator>();
// the decoration is visible in your code@Service
public class OrderService {
@Transactional
public void place(Cart c) { ... }
}
// nothing here shows that a wrapper existsTwo kinds of proxy#
| Kind | Used when | Built by | Limitation |
|---|---|---|---|
| JDK dynamic proxy | the bean implements an interface, and proxyTargetClass is false | java.lang.reflect.Proxy | only interface methods are proxied |
| CGLIB proxy | there is no interface, or proxyTargetClass is true | a generated subclass | cannot proxy final classes or final methods |
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.
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) { ... }
}| Fix | How | Cost |
|---|---|---|
| Move the method to another bean | inject it and call it | best; usually better design anyway |
| Inject yourself | @Autowired OrderService self; then self.place(c) | works, reads oddly |
| AopContext.currentProxy() | cast the result and call through it | needs exposeProxy=true; obscure |
| Use AspectJ weaving instead of proxies | compile-time or load-time weaving | powerful, much heavier |
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);
}
}
}| Advice | Runs | .NET analogy |
|---|---|---|
| @Before | before the method | filter OnActionExecuting |
| @AfterReturning | after a successful return | OnActionExecuted |
| @AfterThrowing | only on exception | exception filter |
| @After | always, like finally | finally block |
| @Around | wraps the call; you invoke proceed() | middleware |
| Pointcut expression | Matches |
|---|---|
| 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 |
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) { }${...} 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| Fix | Note |
|---|---|
| Extract the shared logic into a third bean | almost always the right answer |
| @Lazy on one of the injection points | defers to a proxy; hides a design problem |
| spring.main.allow-circular-references=true | re-enables the old behaviour; avoid |
| Setter injection | works, and is why field injection let cycles happen unnoticed |
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#
| Need | Annotation |
|---|---|
| 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") |
@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#
@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#
[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);
}
}@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 Core | Spring | Note |
|---|---|---|
| [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 :int | no route constraints | binding failure produces a 400 |
| [Produces("application/json")] | produces = "application/json" | an attribute on the mapping |
| [Consumes(...)] | consumes = "..." |
Model binding#
| ASP.NET Core | Spring | Binds from |
|---|---|---|
| [FromRoute] | @PathVariable | the URL path |
| [FromQuery] | @RequestParam | the query string |
| [FromBody] | @RequestBody | the request body, via Jackson |
| [FromHeader] | @RequestHeader | a header |
| [FromForm] | @RequestParam / @ModelAttribute | form data |
| [FromServices] | just take a constructor dependency | the container |
| n/a | @CookieValue | a cookie |
| n/a | @RequestPart | one 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
...
}@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) { }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 Core | Spring |
|---|---|
| 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 |
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 Core | Spring | Runs |
|---|---|---|
| app.Use(...) middleware | Servlet Filter | before the dispatcher; sees every request |
| IActionFilter | HandlerInterceptor | around controller methods |
| IAsyncActionFilter | HandlerInterceptor | |
| IExceptionFilter | @ControllerAdvice + @ExceptionHandler | |
| IAuthorizationFilter | Spring Security filter chain | |
| Endpoint routing | DispatcherServlet |
@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();
}
}
}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() { ... }
}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#
| .NET | Spring |
|---|---|
| Swashbuckle / NSwag | springdoc-openapi |
| [ProducesResponseType] | @ApiResponse |
| [SwaggerOperation] | @Operation |
| XML doc comments | javadoc, 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#
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#
public record CreateOrder(
[Required, StringLength(50)] string CustomerId,
[Range(1, 100)] int Quantity,
[EmailAddress] string Email);public record CreateOrder(
@NotBlank @Size(max = 50) String customerId,
@Min(1) @Max(100) int quantity,
@Email String email) { }| DataAnnotations | Jakarta Validation | Note |
|---|---|---|
| [Required] | @NotNull | null only |
| [Required] on a string | @NotBlank | null, empty, or whitespace |
| n/a | @NotEmpty | null 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] | ||
| [RegularExpression(p)] | @Pattern(regexp = p) | |
| [Compare] | no equivalent | write a class-level constraint |
| n/a | @Positive, @Negative, @PositiveOrZero | |
| n/a | @Past, @Future, @PastOrPresent | for java.time types |
| n/a | @Valid on a nested field | cascades validation |
| [CreditCard] | @CreditCardNumber | Hibernate Validator |
@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) { ... }
}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));
}
}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.
// 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."] }
}{
"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: trueGlobal exception handling#
app.UseExceptionHandler(...);
// or a filter
public class ApiExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext ctx) { ... }
}@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;
}
}| Need | Spring |
|---|---|
| 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 shapes | extend ResponseEntityExceptionHandler |
| Add fields to the response | problemDetail.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;
}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#
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 Core | Java | Note |
|---|---|---|
| DbContext | EntityManager | tracks loaded entities and flushes changes on commit |
| DbSet<T> | a Spring Data repository | |
| [Table], [Column] | @Entity, @Table, @Column | |
| OnModelCreating | annotations, or orm.xml | |
| SaveChanges | flush, usually automatic on commit | |
| Migrations | Flyway or Liquibase | separate 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) | |
| Dapper | JdbcTemplate, JDBI | |
| LINQ to SQL | jOOQ | generates typed Java from your real schema, so renames break the build |
An entity#
public class Order
{
public long Id { get; set; }
public string CustomerId { get; set; }
public decimal Total { get; set; }
public List<LineItem> Items { get; set; } = [];
}@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
}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.
@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.
| Problem | Why |
|---|---|
| The id is null before persist | an 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 proxies | a lazy association is a generated subclass, so getClass() comparison fails |
| Lombok @Data on an entity | generates 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();
}
}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);
}| Keyword | SQL |
|---|---|
| findBy / getBy / readBy | select |
| And, Or | and / or |
| Between, LessThan, GreaterThan | comparisons |
| Like, StartingWith, Containing | like |
| In, NotIn | in |
| IsNull, IsNotNull | is null |
| OrderBy...Asc/Desc | order by |
| Top, First | limit |
| Distinct | distinct |
| existsBy, countBy, deleteBy | exists / count / delete |
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#
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#
| Association | JPA default | Advice |
|---|---|---|
| @OneToMany | LAZY | keep it lazy; fetch explicitly |
| @ManyToMany | LAZY | keep it lazy |
| @ManyToOne | EAGER | set it to LAZY explicitly |
| @OneToOne | EAGER | set it to LAZY explicitly |
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#
using var tx = db.Database.BeginTransaction();
db.Orders.Add(order);
await db.SaveChangesAsync();
tx.Commit();@Transactional
public Order place(CreateOrder cmd) {
var order = new Order(cmd);
repo.save(order);
// committed when the method returns
return order;
}@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@Transactionalonother()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#
dotnet ef migrations add AddOrderStatus
dotnet ef database update
// generated C# with Up/Down methods-- 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| Aspect | EF Migrations | Flyway |
|---|---|---|
| Format | generated C# | plain SQL you write |
| Naming | timestamped | V{version}__{description}.sql |
| Applied | dotnet ef database update | automatically on app startup |
| Rollback | Down() method | forward-only; write a new migration |
| Baseline an existing DB | possible, fiddly | flyway.baselineOnMigrate |
| Checksum enforcement | no | yes, editing an applied migration fails the build |
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.
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#
| Situation | Better tool |
|---|---|
| Complex reporting queries | jOOQ, or JdbcTemplate with SQL |
| You want typed SQL like LINQ | jOOQ |
| Simple CRUD, no object graph | Spring Data JDBC, much simpler model |
| Bulk operations | native SQL; JPA is poor at bulk |
| Read-heavy projections | interface or record projections |
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#
@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#
using var tx = await db.Database.BeginTransactionAsync();
try
{
db.Orders.Add(order);
await db.SaveChangesAsync();
await tx.CommitAsync();
}
catch
{
await tx.RollbackAsync();
throw;
}@Transactional
public Order place(CreateOrder cmd) {
var order = new Order(cmd);
repo.save(order);
return order;
}
// commit on normal return,
// rollback on an unchecked exceptionPut 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#
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.
| Setting | Effect |
|---|---|
| rollbackFor = Exception.class | roll back for checked exceptions too |
| noRollbackFor = NotFound.class | commit even though this was thrown |
| readOnly = true | a hint; lets Hibernate skip dirty checking and the driver optimise |
| timeout = 5 | seconds 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?
| Mode | If a transaction exists | If none exists | Use for |
|---|---|---|---|
| REQUIRED | join it | start one | the default; almost always right |
| REQUIRES_NEW | suspend it, start a separate one | start one | audit rows that must survive a rollback |
| NESTED | a savepoint inside it | start one | partial rollback, JDBC only |
| SUPPORTS | join it | run with none | read-only helpers |
| NOT_SUPPORTED | suspend it, run with none | run with none | long non-transactional work |
| MANDATORY | join it | throw | assert a caller opened one |
| NEVER | throw | run with none | assert 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));
}
}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.
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.
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#
| Level | Prevents | Still allows | .NET name |
|---|---|---|---|
| READ_UNCOMMITTED | nothing | dirty reads | ReadUncommitted |
| READ_COMMITTED | dirty reads | non-repeatable reads, phantoms | ReadCommitted |
| REPEATABLE_READ | non-repeatable reads | phantom reads | RepeatableRead |
| SERIALIZABLE | everything | nothing; may abort | Serializable |
| DEFAULT | whatever the database default is | , | Unspecified |
| Anomaly | Means |
|---|---|
| Dirty read | you see another transaction's uncommitted write |
| Non-repeatable read | the same row changes between two reads in your transaction |
| Phantom read | the same query returns new rows between two reads |
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.
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#
@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);
}
}| Phase | Runs |
|---|---|
| BEFORE_COMMIT | before the commit; can still fail the transaction |
| AFTER_COMMIT | after a successful commit; the default |
| AFTER_ROLLBACK | only if it rolled back |
| AFTER_COMPLETION | either way |
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#
| Symptom | Likely cause |
|---|---|
| Annotation seems ignored | self-invocation, private method, or final class |
| Data committed despite an exception | it was a checked exception; set rollbackFor |
| Deadlocks under load | REQUIRES_NEW touching rows the caller wrote |
| Connection pool exhausted | REQUIRES_NEW doubling connection use, or open-in-view |
| Works on MySQL, wrong on PostgreSQL | isolation inherited from the database default |
| Lost update with no error | no @Version; add optimistic locking |
| Message sent for a rolled-back change | publish in AFTER_COMMIT, or use an outbox |
Security#
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 Core | Spring Security | Note |
|---|---|---|
| AddAuthentication | SecurityFilterChain bean | |
| AddAuthorization | authorizeHttpRequests(...) | |
| [Authorize] | @PreAuthorize, or a chain rule | |
| [Authorize(Roles = "Admin")] | @PreAuthorize("hasRole('ADMIN')") | |
| [AllowAnonymous] | permitAll() in the chain | |
| ClaimsPrincipal | Authentication / Principal | |
| User.Identity.Name | authentication.getName() | |
| Policy-based authorisation | SpEL in @PreAuthorize, or an AuthorizationManager | |
| IdentityUser | UserDetails | |
| UserManager | UserDetailsService | |
| JwtBearer | oauth2ResourceServer().jwt() | |
| Cookie authentication | formLogin() + session | |
| Data protection | no direct equivalent | |
| Antiforgery token | CSRF protection, on by default |
The filter chain#
builder.Services.AddAuthentication()
.AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers().RequireAuthorization();@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();
}
}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.
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#
[Authorize(Roles = "Admin")]
public async Task Delete(int id) { }
[Authorize(Policy = "OwnsOrder")]
public async Task<Order> Get(int id) { }@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 { }| Expression | Means |
|---|---|
| hasRole('ADMIN') | authority ROLE_ADMIN |
| hasAuthority('orders:write') | that exact authority |
| hasAnyRole('A','B') | any of them |
| isAuthenticated() | not anonymous |
| permitAll() / denyAll() | always / never |
| #id | a method parameter, by name |
| authentication | the current Authentication |
| @beanName.method(...) | call a bean, arbitrary policy logic |
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.
@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#
public IActionResult Get()
{
var id = User.FindFirst("sub")?.Value;
}@GetMapping
public OrderDto get(@AuthenticationPrincipal Jwt jwt) {
String id = jwt.getSubject();
}
// or anywhere, without threading it through
var auth = SecurityContextHolder.getContext()
.getAuthentication();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();
}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#
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#
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#
| Client | Style | Use when |
|---|---|---|
| RestClient | fluent, blocking | the default on Boot 3.2+ |
| WebClient | fluent, reactive | you are in WebFlux, or need streaming |
| @HttpExchange interface | declarative | you want Refit-style typed clients |
| RestTemplate | fluent, blocking | legacy; see below |
| java.net.http.HttpClient | JDK built-in | no Spring dependency wanted |
var client = httpClientFactory.CreateClient("rates");
var rate = await client
.GetFromJsonAsync<Rate>($"/rates/{pair}");Rate rate = restClient.get()
.uri("/rates/{pair}", pair)
.retrieve()
.body(Rate.class);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#
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));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 beanResilience: 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);
}
}| Polly | Boot 3 (Resilience4j) | Boot 4 (Spring Framework core) |
|---|---|---|
| Retry | @Retry | @Retryable |
| Bulkhead | @Bulkhead | @ConcurrencyLimit |
| Circuit breaker | @CircuitBreaker | not in core; keep Resilience4j |
| Rate limiter | @RateLimiter | not in core; keep Resilience4j |
| Timeout | @TimeLimiter | not in core; set a request-factory timeout |
| Fallback | fallbackMethod | @Recover |
| Programmatic | RetryRegistry | RetryTemplate with RetryPolicy.builder() |
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.
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.
@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#
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);| Situation | RestClient default |
|---|---|
| 4xx | throws HttpClientErrorException |
| 5xx | throws HttpServerErrorException |
| Want the status, not an exception | use .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#
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#
// 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);
}// 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: allThe 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#
| Concern | Spring Kafka |
|---|---|
| Ordering | guaranteed per partition only; key by aggregate id to keep an entity's events ordered |
| Concurrency | concurrency = "3" on @KafkaListener, capped by partition count |
| Manual acks | AckMode.MANUAL plus an Acknowledgment parameter |
| Retry with backoff | DefaultErrorHandler with an ExponentialBackOff |
| Dead letter | DeadLetterPublishingRecoverer, publishes to topic.DLT |
| Batch consumption | batch = "true" and a List parameter |
| Transactions | KafkaTransactionManager, but it does not span Kafka and your database |
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#
_bus.Publish(new OrderPlaced(order.Id));
public class Consumer : IConsumer<OrderPlaced>
{
public Task Consume(
ConsumeContext<OrderPlaced> ctx) { ... }
}rabbitTemplate.convertAndSend(
"orders.exchange", "order.placed", event);
@RabbitListener(queues = "orders.queue")
public void handle(OrderPlaced event) { ... }| Concept | Spring AMQP |
|---|---|
| Declare topology | @Bean Queue / TopicExchange / Binding |
| Serialisation | Jackson2JsonMessageConverter, registered as a bean |
| Retry | spring.rabbitmq.listener.simple.retry.* |
| Dead letter | x-dead-letter-exchange argument on the queue |
| Manual ack | AcknowledgeMode.MANUAL plus a Channel parameter |
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#
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 feature | Spring equivalent |
|---|---|
| Hub | @Controller with @MessageMapping, over STOMP |
| Strongly typed hub client | none; you send to a destination by name |
| Transport fallback | none; WebSocket with a SockJS fallback, and SockJS is legacy |
| Groups | STOMP destinations, or a broker topic |
| Backplane for scale-out | an external broker: RabbitMQ or ActiveMQ as a STOMP relay |
| Automatic reconnect | client-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);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.
Response.ContentType = "text/event-stream";
await Response.WriteAsync($"data: {json}\n\n");
await Response.Body.FlushAsync();@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;
}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#
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#
| Endpoint | Gives you | .NET analogy |
|---|---|---|
| /actuator/health | liveness and readiness | AddHealthChecks |
| /actuator/metrics | every metric, queryable | dotnet-counters |
| /actuator/prometheus | Prometheus scrape format | prometheus-net |
| /actuator/info | build and git info | |
| /actuator/env | resolved configuration | |
| /actuator/loggers | read AND CHANGE log levels at runtime | no equivalent |
| /actuator/threaddump | every thread's stack | dotnet-dump |
| /actuator/heapdump | a heap dump file | dotnet-gcdump |
| /actuator/mappings | every route | |
| /actuator/beans | the whole container | |
| /actuator/configprops | bound @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 KubernetesOnly 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.
/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#
builder.Services.AddHealthChecks()
.AddCheck<RatesHealthCheck>("rates");
public class RatesHealthCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(...)
=> Task.FromResult(HealthCheckResult.Healthy());
}@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();
}
}
}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#
var counter = meter.CreateCounter<long>("orders.placed");
counter.Add(1, new("status", "ok"));private final Counter placed;
OrderService(MeterRegistry registry) {
this.placed = Counter.builder("orders.placed")
.tag("status", "ok")
.register(registry);
}
placed.increment();| Instrument | Micrometer | Use for |
|---|---|---|
| Counter | Counter | monotonic counts |
| Gauge | Gauge | a current value |
| Histogram | DistributionSummary | a distribution of values |
| Timer / duration | Timer | latency |
| n/a | LongTaskTimer | in-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) { ... }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#
private readonly ILogger<OrderService> _log;
_log.LogInformation("Placed order {OrderId} for {Total}",
id, total);private static final Logger log =
LoggerFactory.getLogger(OrderService.class);
log.info("Placed order {} for {}", id, total);| .NET | Java | Note |
|---|---|---|
| ILogger<T> | SLF4J Logger | you code against SLF4J; Logback does the actual writing |
| Serilog | Logback | the Boot default implementation |
| NLog | Log4j2 | |
| {Named} placeholders | {} positional placeholders | SLF4J has no names |
| BeginScope | MDC | thread-local key/value pairs |
| appsettings logging levels | logging.level.* in application.yml |
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 concept | Logback equivalent |
|---|---|
| Sink | appender |
| Enricher | MDC, or a custom converter |
| JSON formatter | LogstashEncoder, or Boot's own structured logging |
| Minimum level override per namespace | logging.level.com.acme in application.yml |
| Rolling file | RollingFileAppender with a TimeBasedRollingPolicy |
| Environment-specific config | springProfile blocks, as above |
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>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.jfrOpen 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 | .NET | Java |
|---|---|---|
| Continuous production profiling | limited | JFR |
| Deep CPU profile | Visual Studio Profiler, PerfView | async-profiler, JFR |
| Heap analysis | dotnet-gcdump | jmap + Eclipse MAT |
| Thread dump | dotnet-dump | jcmd Thread.print |
| GC logs | GC ETW events | -Xlog:gc* |
Caching, scheduling and events#
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#
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;
}@Cacheable("rates")
public Rate getRate(String pair) {
return client.fetch(pair);
}
// lookup, miss handling and population
// are all done by the proxy| Annotation | Does | .NET equivalent |
|---|---|---|
| @Cacheable | return the cached value, or call the method and cache it | GetOrCreate |
| @CachePut | always call the method, then update the cache | Set |
| @CacheEvict | remove an entry | Remove |
| @CacheEvict(allEntries = true) | clear the cache | Clear |
| @Caching | combine several of the above | several calls |
| @EnableCaching | switch the whole mechanism on | AddMemoryCache |
@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) { ... }
}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.
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.
| Provider | Add | Notes |
|---|---|---|
| Simple (ConcurrentHashMap) | nothing; the default | no eviction, no size limit, dev only |
| Caffeine | com.github.ben-manes.caffeine | the default choice for in-process |
| Redis | spring-boot-starter-data-redis | shared across instances |
| Hazelcast, Infinispan | their starters | distributed, clustered |
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=10000,expireAfterWrite=5mThe 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#
public class ReconcileService : BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await ReconcileAsync();
await Task.Delay(TimeSpan.FromMinutes(30), ct);
}
}
}@Component
public class ReconcileJob {
@Scheduled(fixedDelay = 30, timeUnit = TimeUnit.MINUTES)
public void reconcile() {
...
}
}| Attribute | Means |
|---|---|
| fixedDelay | wait this long after the previous run finishes |
| fixedRate | start this often, regardless of how long a run takes |
| initialDelay | wait before the first run |
| cron = "0 0 3 * * *" | six-field cron; note the leading seconds field |
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.
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.
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.
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);
}public record OrderPlaced(long id) { }
events.publishEvent(new OrderPlaced(order.getId()));
@Component
class SendEmail {
@EventListener
public void on(OrderPlaced e) {
email.send(e.id());
}
}@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.
| Annotation | Runs |
|---|---|
| @EventListener | synchronously, in the caller's thread and transaction |
| @EventListener(condition = "#e.total > 100") | only when the SpEL condition holds |
| @Async @EventListener | on another thread; outside the transaction |
| @TransactionalEventListener | after the transaction commits; the safe default for side effects |
@Async#
_ = Task.Run(() => _reports.Rebuild());@Async
public void rebuild() { ... } // returns immediately
@Async
public CompletableFuture<Report> build() {
return CompletableFuture.completedFuture(...);
}@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.
On Java 21+, point the executor at virtual threads and @Async stops needing
a tuned pool size:
spring:
threads:
virtual:
enabled: trueTesting Spring Boot#
@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#
| Annotation | Starts | Use for |
|---|---|---|
| @SpringBootTest | the whole context | end-to-end integration |
| @WebMvcTest(X.class) | web layer only; no database | controller tests |
| @DataJpaTest | JPA and an in-memory or container DB | repository tests |
| @JdbcTest | JDBC only | JdbcTemplate tests |
| @JsonTest | Jackson only | serialisation tests |
| @RestClientTest | HTTP client + mock server | outbound client tests |
| (no annotation) | nothing: plain JUnit | unit tests, which should be most of them |
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#
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);
}
}@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));
}
}@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;
}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);
}
}@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.
| Client | Use for |
|---|---|
| MockMvc | fast; no real server, no network |
| TestRestTemplate | a real HTTP call against a random port |
| WebTestClient | fluent, works for MVC and WebFlux |
| RestTestClient | new 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);
}
}@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#
| Need | Annotation |
|---|---|
| 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);
}
}@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#
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#
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#
| Requirement | Boot 3.5 | Boot 4.0 |
|---|---|---|
| Java | 17+ | 17+, 25 recommended |
| Spring Framework | 6.x | 7.x |
| Jakarta EE | 10 (Servlet 6.0) | 11 (Servlet 6.1) |
| Kotlin | 1.9+ | 2.2+ |
| GraalVM | 22+ | 25+ |
| JUnit | 5 | 6 |
| Gradle | 8.x | 9 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.5 | Boot 4.0 |
|---|---|
| spring-boot-starter-web | spring-boot-starter-webmvc |
| spring-boot-starter-web-services | spring-boot-starter-webservices |
| spring-boot-starter-oauth2-client | spring-boot-starter-security-oauth2-client |
| spring-boot-data-mongodb (health indicators) | spring-boot-mongodb |
<parent>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.x</version>
</parent>
<dependency>
<artifactId>spring-boot-starter-web</artifactId>
</dependency><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.5 | Boot 4.0 |
|---|---|
| com.fasterxml.jackson.* | tools.jackson.* |
| @JsonComponent | @JacksonComponent |
| @JsonMixin | @JacksonMixin |
| JsonObjectSerializer | ObjectValueSerializer |
| JsonValueDeserializer | ObjectValueDeserializer |
| Jackson2ObjectMapperBuilderCustomizer | JsonMapperBuilderCustomizer |
| Jackson2ObjectMapperBuilder | removed, use Jackson's own builders |
| spring.jackson.read.* | spring.jackson.json.read.* |
| spring.jackson.write.* | spring.jackson.json.write.* |
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.5 | Boot 4.0 | Note |
|---|---|---|
| @MockBean | @MockitoBean | REMOVED, not deprecated |
| @SpyBean | @MockitoSpyBean | REMOVED, not deprecated |
| MockitoTestExecutionListener | Mockito's MockitoExtension | |
| @SpringBootTest gives MockMvc | add @AutoConfigureMockMvc | |
| @AutoConfigureMockMvc(htmlUnit-related) | @AutoConfigureMockMvc(htmlUnit = @HtmlUnit(...)) | |
| @PropertyMapping | moved to org.springframework.boot.test.context | same annotation, new package |
@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.
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#
| Removed | Replacement |
|---|---|
| javax.annotation / javax.inject support | the jakarta.* equivalents |
| spring-jcl | standard SLF4J and Logback |
| ListenableFuture | CompletableFuture |
| Undertow support | Tomcat, Jetty, or Netty |
| suffixPatternMatch and similar path options | explicit mappings |
| Jackson2ObjectMapperBuilder | Jackson's native builders |
| Certificate validity threshold in SSL info | n/a |
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#
| Feature | Detail |
|---|---|
| @Retryable and @ConcurrencyLimit in core | org.springframework.core.retry, via @EnableResilientMethods |
| API versioning | spring.mvc.apiversion.*, spring.webflux.apiversion.* |
| HTTP service client auto-config | @ImportHttpServices for @HttpExchange interfaces |
| BeanRegistrar | programmatic bean registration, AOT-friendly |
| JmsClient | a unified JMS send/receive API alongside JmsTemplate |
| spring-boot-starter-opentelemetry | first-class OTel starter |
| spring-boot-starter-kotlin-serialization | Kotlin serialization support |
| RestTestClient | a test client for RestClient |
Configuration property renames#
| Boot 3.5 | Boot 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.redis | spring.session.data.redis |
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#
| Step | Do |
|---|---|
| 1 | Get to Boot 3.5 and Java 17+ first; fix all deprecation warnings |
| 2 | Replace @MockBean and @SpyBean with @MockitoBean and @MockitoSpyBean |
| 3 | Move off Undertow if you are on it |
| 4 | Bump the parent to Boot 4.0; fix the starter names |
| 5 | Run the build; work through Jackson import errors |
| 6 | Add @AutoConfigureMockMvc wherever @SpringBootTest injected MockMvc |
| 7 | Check the property renames above against your application.yml |
| 8 | Consider dropping Resilience4j for core @Retryable where it fits |
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.
1You added @Transactional and nothing happens. Name three possible causes.
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?
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#
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#
| Approach | Produces | Needs a JVM installed | .NET analogy |
|---|---|---|---|
| Plain JAR | just your classes | yes, plus the classpath | a bare dll |
| Fat / uber JAR | everything in one JAR | yes | framework-dependent publish |
| jlink | a trimmed JVM + your app | no | self-contained publish |
| jpackage | a platform installer | no | MSI / dmg installer |
| GraalVM native-image | one native binary | no | NativeAOT |
The fat JAR#
./mvnw package
java -jar target/billing-1.0.0.jarSpring 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.
| Task | Command |
|---|---|
| Build it | ./mvnw package |
| Run it | java -jar target/app.jar |
| Run with a profile | java -jar app.jar --spring.profiles.active=prod |
| Override a property | java -jar app.jar --server.port=9090 |
| Set JVM options | java -Xmx512m -jar app.jar |
| Inspect the layers | java -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"]Spring Boot can also build an optimised image with no Dockerfile at all, using Cloud Native Buildpacks:
./mvnw spring-boot:build-imageIt 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.
jlink and jpackage#
| Tool | Produces | Use for |
|---|---|---|
| jlink | a custom runtime image with only the modules you need | shrinking a container |
| jpackage | a native installer: msi, dmg, deb | desktop 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 dmgjlink 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#
<PublishAot>true</PublishAot>
dotnet publish -r linux-x64 -c Release<!-- Maven, with the Spring Boot parent -->
./mvnw -Pnative native:compile
./target/billing
<!-- Gradle -->
./gradlew nativeCompile| Aspect | JVM fat JAR | Native image |
|---|---|---|
| Startup | 1-3 seconds | 30-60 milliseconds |
| Memory at rest | 200-400 MB | 50-100 MB |
| Peak throughput | higher; the JIT wins over time | lower |
| Build time | seconds | minutes |
| Reflection | free | must be registered |
| Debugging in production | JFR, full tooling | more limited |
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 when | Use the JVM when |
|---|---|
| Serverless, scale-to-zero | Long-running services |
| CLI tools | Peak throughput matters |
| Very high instance counts | You need JFR and full diagnostics |
| Memory is the binding constraint | Build 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 fasterJava 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#
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-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.
| .NET | Java | Note |
|---|---|---|
| Server GC | G1 (default) | |
| Workstation GC | SerialGC | |
| GCHeapHardLimit | -Xmx / -XX:MaxRAMPercentage | |
| DOTNET_GCHeapCount | -XX:ParallelGCThreads | |
| GC.Collect() | System.gc() | a hint; usually ignored |
| Gen0/1/2 | Young / Old | G1 is region-based, not strictly generational |
| LOH | humongous regions in G1 | large objects get their own regions, and collect poorly |
| GCSettings.LatencyMode | choice of collector |
{
"configProperties": {
"System.GC.Server": true,
"System.GC.HeapHardLimitPercent": 70
}
}java -XX:+UseG1GC \
-XX:MaxRAMPercentage=70 \
-XX:MaxMetaspaceSize=256m \
-XX:+ExitOnOutOfMemoryError \
-jar app.jarContainers#
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.jarUse -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.
| Flag | Does |
|---|---|
| -XX:MaxRAMPercentage=70 | heap as a share of the container limit |
| -XX:InitialRAMPercentage=70 | avoid heap resizing churn |
| -XX:MaxMetaspaceSize=256m | bound class metadata |
| -XX:+ExitOnOutOfMemoryError | die rather than limp; let the orchestrator restart you |
| -XX:+HeapDumpOnOutOfMemoryError | write a dump for analysis |
| -XX:HeapDumpPath=/dumps | where to write it |
| -Xss512k | smaller platform thread stacks |
Choosing a collector#
| Collector | Pause times | Throughput | Use when |
|---|---|---|---|
| SerialGC | high | fine for tiny heaps | small containers, CLI tools |
| ParallelGC | high, but efficient | highest | batch jobs; latency does not matter |
| G1 (default) | ~10-200 ms | very good | almost everything |
| ZGC | under 1 ms | slightly lower | large heaps, latency-critical |
| Shenandoah | under 1 ms | slightly lower | as 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 CPUZGC'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| Symptom | Look at |
|---|---|
| Container OOMKilled, heap looks fine | metaspace, thread stacks, direct buffers |
| Long pauses | GC logs; consider ZGC |
| High CPU, low throughput | JFR CPU profile, or async-profiler |
| Memory grows steadily | heap dump, then Eclipse MAT dominator tree |
| Threads climbing | thread dump; a leaked executor |
| Slow startup | AOT cache, CDS, or the auto-configuration report |
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#
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.
1Your container is OOMKilled but the heap graph looks flat. Where is the memory?
-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?
-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?
4What is the Java equivalent of a self-contained dotnet publish?
jlink, jpackage, or GraalVM native-image.Appendix A: Java 9 to 25#
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.
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#
| Feature | Release | Status | C# analogue |
|---|---|---|---|
| var for locals | 10 | final | var |
| Text blocks | 15 | final | raw string literals |
| Records | 16 | final | records |
| instanceof pattern | 16 | final | is T x |
| Sealed classes and interfaces | 17 | final | no equivalent |
| Switch expressions | 14 | final | switch expressions |
| Pattern matching for switch | 21 | final | switch on patterns |
| Record patterns | 21 | final | positional patterns |
| Unnamed variables and patterns | 22 | final | discard _ |
| Module import declarations | 25 | final | global using |
| Compact source files, instance main | 25 | final | top-level statements |
| Flexible constructor bodies | 25 | final | no restriction to remove |
| Primitive types in patterns | 25 | PREVIEW | relational patterns |
| String templates | 21, 22 | WITHDRAWN | interpolation. Java has none |
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#
| Feature | Release | Status | Note |
|---|---|---|---|
| CompletableFuture improvements | 9 | final | |
| Virtual threads | 21 | final | the async/await answer |
| Synchronize virtual threads without pinning | 24 | final | JEP 491, removes the synchronized trap |
| Scoped values | 25 | final | AsyncLocal analogue |
| Structured concurrency | 25 | PREVIEW | fifth preview; API has churned |
| Stable values | 25 | PREVIEW | Lazy<T> analogue |
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#
| Feature | Release | Status | Note |
|---|---|---|---|
| Collection factories List.of, Map.of | 9 | final | immutable |
| Stream takeWhile, dropWhile, iterate | 9 | final | |
| Optional.stream, ifPresentOrElse | 9 | final | |
| New HTTP client (java.net.http) | 11 | final | HttpClient analogue |
| String isBlank, lines, strip, repeat | 11 | final | |
| Files.readString, writeString | 11 | final | |
| Collectors.teeing | 12 | final | |
| Stream.toList() | 16 | final | replaces collect(toList()) |
| Sequenced collections | 21 | final | getFirst, getLast, reversed |
| Foreign Function and Memory API | 22 | final | P/Invoke analogue |
| Class-File API | 24 | final | Reflection.Emit analogue |
| Stream gatherers | 24 | final | custom intermediate ops; LINQ Chunk |
| Ahead-of-Time Class Loading and Linking | 24 | final | faster startup |
| Permanently disable the Security Manager | 24 | final | it was already deprecated |
| ZGC: remove non-generational mode | 24 | final | generational is the only mode |
| Quantum-resistant ML-KEM and ML-DSA | 24 | final | post-quantum crypto |
| Key Derivation Function API | 25 | final | |
| PEM encodings | 25 | PREVIEW | |
| Vector API | 25 | INCUBATOR | tenth incubation; System.Numerics.Vector analogue |
Runtime, GC and tooling#
| Feature | Release | Status | Note |
|---|---|---|---|
| jshell (REPL) | 9 | final | |
| JPMS modules | 9 | final | |
| Single-file source launch | 11 | final | java Foo.java |
| Flight Recorder open-sourced | 11 | final | |
| Helpful NullPointerExceptions | 14 | final | on by default since 15 |
| Strong encapsulation of JDK internals | 17 | final | breaks old libraries |
| Deprecate finalization for removal | 18 | deprecated | never use finalize() |
| Generational ZGC | 21 | final | |
| Multi-file source launch | 22 | final | |
| Generational ZGC by default | 23 | final | |
| Compact object headers | 25 | final | smaller objects, less memory |
| Ahead-of-time command-line ergonomics | 25 | final | AOT cache |
| Ahead-of-time method profiling | 25 | final | faster warmup |
| Generational Shenandoah | 25 | final | |
| JFR CPU-time profiling | 25 | EXPERIMENTAL | |
| JFR cooperative sampling | 25 | final | |
| JFR method timing and tracing | 25 | final | |
| Remove the 32-bit x86 port | 25 | final |
Release cadence#
| Release | Date | LTS | Notes |
|---|---|---|---|
| Java 8 | 2014 | LTS | still widespread; lacks almost everything above |
| Java 9 | 2017 | modules, jshell, collection factories | |
| Java 11 | 2018 | LTS | HTTP client, var refinements, single-file launch |
| Java 17 | 2021 | LTS | sealed types, strong encapsulation |
| Java 21 | 2023 | LTS | virtual threads, pattern matching, sequenced collections |
| Java 25 | 2025 | LTS | scoped values, compact source files, AOT, compact headers |
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#
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# | Java | Chapter |
|---|---|---|
| abstract | abstract | Interfaces and inheritance |
| Action<T> | Consumer<T> | Methods and parameters |
| AggregateException | CompletionException / ExecutionException | CompletableFuture |
| AppContext.GetData | System.getProperty | How Java runs your code |
| as | no equivalent; instanceof pattern | Sealed types and patterns |
| assembly probing | the classpath | How Java runs your code |
| async / await | nothing, use virtual threads | Threads are cheap now |
| AsyncLocal<T> | ScopedValue | Structured concurrency |
| AutoMapper | MapStruct | Annotations |
| base | super | Classes and members |
| BenchmarkDotNet | JMH | Testing |
| bool | boolean | Numbers, money and time |
| byte (unsigned) | no equivalent; byte is signed | Numbers, money and time |
| CancellationToken | Thread.interrupt, or a scope | Structured concurrency |
| checked / unchecked | no equivalent; Math.addExact | Week-one gotchas |
| class | class | Classes and members |
| const | static final | Fields and properties |
| ConcurrentDictionary | ConcurrentHashMap | Locks and atomics |
| ConfigureAwait | no equivalent; not needed | Threads are cheap now |
D to F#
| C# | Java | Chapter |
|---|---|---|
| DataAnnotations | Jakarta Bean Validation | Validation and errors |
| DateTime | LocalDateTime / Instant | Numbers, money and time |
| DateTimeOffset | OffsetDateTime / Instant | Numbers, money and time |
| DbContext | EntityManager / repository | Data access |
| decimal | BigDecimal | Numbers, money and time |
| default(T) | null, or a primitive default | Generics |
| delegate | a functional interface | Methods and parameters |
| deps.json | the classpath, or the manifest Class-Path | How Java runs your code |
| Dictionary<K,V> | HashMap<K,V> | Collections |
| dotnet CLI | mvn / gradle | Maven vs csproj |
| dynamic | no equivalent | Generics |
| Entity Framework | Hibernate / Spring Data JPA | Data access |
| enum | enum: far more powerful | Enums |
| Environment.GetEnvironmentVariable | System.getenv | How Java runs your code |
| event | a listener list, or a functional interface | Interfaces and inheritance |
| Expression<T> | no equivalent; no expression trees | Streams vs LINQ |
| extension method | a static utility, or a default method | Methods and parameters |
| [Flags] | EnumSet | Enums |
| FluentAssertions | AssertJ | Testing |
| FluentValidation | Bean Validation | Validation and errors |
| Func<T,R> | Function<T,R> | Methods and parameters |
G to L#
| C# | Java | Chapter |
|---|---|---|
| GetHashCode | hashCode | Equality and hashing |
| GetType() | getClass() | Classes and members |
| global using | no equivalent | Access and packages |
| goto | labelled break / continue | Switch expressions |
| HttpClient | RestClient / java.net.http.HttpClient | HTTP clients |
| IAsyncDisposable | no equivalent | Exceptions and resources |
| IAsyncEnumerable<T> | no equivalent; a BlockingQueue | Streams vs LINQ |
| IComparable<T> | Comparable<T> | Equality and hashing |
| IDisposable | AutoCloseable | Exceptions and resources |
| IEnumerable<T> | Iterable<E> | Collections |
| IEnumerator<T> | Iterator<E> | Collections |
| ILogger<T> | SLF4J Logger | Actuator and observability |
| in parameter | not needed | Methods and parameters |
| init | final field, or a record | Fields and properties |
| internal | package-private, or a module | Access and packages |
| IOptions<T> | @ConfigurationProperties | DI and configuration |
| is T x | instanceof T x | Sealed types and patterns |
| lock | synchronized / ReentrantLock | Locks and atomics |
| LINQ | Streams | Streams vs LINQ |
| List<T> | ArrayList<E> | Collections |
M to R#
| C# | Java | Chapter |
|---|---|---|
| MediatR | Spring events / Axon | Ecosystem |
| Moq | Mockito | Testing |
| namespace | package | Access and packages |
| NativeAOT | GraalVM native-image | Packaging |
| Newtonsoft.Json | Jackson | Ecosystem |
| NodaTime | java.time | Numbers, money and time |
| NuGet | Maven Central | Maven vs csproj |
| NuGet global packages folder | ~/.m2/repository | How Java runs your code |
| nameof | no equivalent | Annotations |
| Nullable<T> / T? | the wrapper type; Optional for returns | Nullability |
| null-forgiving ! | no equivalent | Nullability |
| null-conditional ?. | Optional.map, or an explicit check | Nullability |
| null-coalescing ?? | Objects.requireNonNullElse | Nullability |
| operator overloading | no equivalent | Methods and parameters |
| out parameter | return a record, or Optional | Methods and parameters |
| override | @Override; an annotation, not a keyword | Interfaces and inheritance |
| params | varargs, Object... | Methods and parameters |
| partial class | no equivalent | Classes and members |
| Polly | Resilience4j, or @Retryable on Boot 4 | HTTP clients |
| Predicate<T> | Predicate<T> | Methods and parameters |
| ProblemDetails | ProblemDetail | Validation and errors |
| property | getX / setX, or a record component | Fields and properties |
| readonly | final | Fields and properties |
| record | record | Records |
| record struct | no equivalent | Records |
| ref parameter | no equivalent | Methods and parameters |
| Refit | @HttpExchange interfaces | HTTP clients |
S to Z#
| C# | Java | Chapter |
|---|---|---|
| sealed class | final class | Interfaces and inheritance |
| Serilog | Logback via SLF4J | Ecosystem |
| SignalR | WebSocket + STOMP | Ecosystem |
| Span<T> | ByteBuffer / MemorySegment | Generics |
| static class | final class, private constructor | Classes and members |
| string | String | var, strings and text blocks |
| string interpolation | none: concatenation or formatted() | var, strings and text blocks |
| struct | no equivalent, use a record | Classes and members |
| Swashbuckle | springdoc-openapi | Controllers and binding |
| switch expression | switch expression | Switch expressions |
| System.Text.Json | Jackson | Ecosystem |
| Task<T> | CompletableFuture<T> | CompletableFuture |
| Task.Run | executor.submit | Threads are cheap now |
| Task.WhenAll | StructuredTaskScope, or futures | Structured concurrency |
| Testcontainers | Testcontainers, same project | Testing |
| this() constructor chaining | this(...) in the body | Classes and members |
| ThreadPool | ExecutorService | Legacy concurrency |
| ToString | toString | Classes and members |
| TryParse | catch NumberFormatException | Numbers, money and time |
| typeof(T) | T.class, or Class<T> | Generics |
| uint / ulong | no equivalent | Numbers, money and time |
| using directive | import | Access and packages |
| using statement | try-with-resources | Exceptions and resources |
| var | var | var, strings and text blocks |
| virtual | the default; use final to prevent | Interfaces and inheritance |
| volatile | volatile, stronger in Java | Locks and atomics |
| where T : X | <T extends X> | Generics |
| with expression | no equivalent | Records |
| xUnit | JUnit 5 | Testing |
| yield return | no equivalent; Stream.iterate | Streams vs LINQ |
The rows worth committing to memory, because they are the ones that change how you design rather than merely how you type:
| C# | Java | Why it matters |
|---|---|---|
| async / await | virtual threads | no function colouring; write blocking code |
| IEnumerable<T> | Iterable<E> | not Iterator; the wrong one gives single-use APIs |
| decimal | BigDecimal | method-call arithmetic; the top money-bug source |
| sealed | final | Java's sealed is a different, better feature |
| protected | wider than C#'s | package access comes with it |
| no modifier | package-private | inverted from C# |
| Expression<T> | nothing | no LINQ-to-SQL is possible |
Appendix C: The Java you'll inherit#
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#
| Era | Years | House style | .NET contemporary |
|---|---|---|---|
| Applets and J2EE | 1995-2005 | XML descriptors, heavyweight app servers | .NET Framework 1.x |
| Spring and annotations | 2005-2014 | Spring XML, then annotations; Maven | .NET 2.0-4.5, WebForms |
| Boot and microservices | 2014-2020 | Spring Boot, embedded servers, Docker | ASP.NET Core 1-3 |
| Modern Java | 2020 to | records, virtual threads, Jakarta | .NET 6-10 |
Dating a codebase at a glance#
| If you see | It was written around | Era |
|---|---|---|
| import javax.servlet | before 2020 | pre-Jakarta |
| Struts action classes | 2001-2008 | J2EE |
| EJB with Home interfaces | 1999-2006 | J2EE |
| build.xml (Ant) | before 2008 | J2EE |
| applicationContext.xml | 2004-2013 | Spring XML |
| Hibernate .hbm.xml mappings | 2003-2010 | Spring XML |
| @Autowired on fields | 2007-2016 | annotation era |
| new StringBuilder() everywhere | any era; a habit from Java 6 | - |
| Anonymous inner classes as callbacks | before 2014 | pre-lambda |
| Guava for collections | 2010-2018 | pre-Java-9 |
| @SpringBootApplication | 2014 onward | Boot |
| records and switch expressions | 2021 onward | modern |
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.
| Before | After | Affects |
|---|---|---|
| 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) |
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.
list.Sort((a, b) => a.Age - b.Age);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 call | Modern JDK equivalent | Since |
|---|---|---|
| Lists.newArrayList() | new ArrayList<>() | always; the helper predated the diamond |
| ImmutableList.of(a, b) | List.of(a, b) | Java 9 |
| Optional (Guava) | java.util.Optional | Java 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.
Actually removed from the platform#
| Removed | When | Note |
|---|---|---|
| Applet API | Java 17 | browsers dropped plugins |
| Java Web Start | Java 11 | use jpackage |
| CORBA and Java EE modules | Java 11 | JAXB, JAX-WS now separate dependencies |
| Nashorn JavaScript engine | Java 15 | use GraalJS |
| Security Manager | Java 24 | permanently disabled |
| Thread.stop and suspend | Java 20 | never safe; now throws |
| 32-bit x86 port | Java 25 | 64-bit only |
| Non-generational ZGC | Java 24 | generational is the only mode |
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#
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#
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 seeing | What explains it | Where |
|---|---|---|
| An annotation silently does nothing | proxying and self-invocation | How Spring actually works |
| A bean is null inside @PostConstruct | bean lifecycle ordering | How Spring actually works |
| Data committed despite an exception | checked exceptions do not roll back | Transactions in depth |
| Deadlocks that only appear under load | REQUIRES_NEW, or connection pool sizing | Transactions in depth |
| Correct on MySQL, wrong on PostgreSQL | isolation level defaults differ | Transactions in depth |
| p99 is spiky, the mean is fine | GC pauses, or JIT deoptimisation | GC internals, JIT |
| Works in tests, fails in the app server | classloader hierarchy | Classloaders |
| ClassCastException naming the same class twice | two loaders loaded it | Classloaders |
| Slow build, unexplained generated sources | annotation processing | Annotation processing |
| A concurrency bug you cannot reproduce | the Java Memory Model | happens-before |
| Memory grows but the heap looks flat | metaspace, direct buffers, thread stacks | The JVM at runtime |
| Startup is slow and you have no idea why | auto-configuration report, class loading | Spring Boot orientation, AOT |
| You need to add behaviour to a class you do not own | bytecode manipulation | ByteBuddy and ASM |
| Your own starter is not auto-configuring | registration file and conditions | Writing an auto-configuration |
| Native image compiles but fails at runtime | reflection metadata | Spring AOT, Packaging |
Spring internals#
Writing an auto-configuration and a starter#
| Row | Detail |
|---|---|
| What it is | A configuration class that registers beans only when conditions hold, discovered from a registration file rather than by component scanning. |
| You need it when | You are packaging shared infrastructure for several services and want it to work by adding one dependency. |
| The C# instinct | An 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.RatesAutoConfigurationTwo 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.
Read: Spring Boot reference, "Creating Your Own Auto-configuration"; the source of any spring-boot-autoconfigure module; they are unusually readable
BeanFactoryPostProcessor and BeanPostProcessor#
| Row | Detail |
|---|---|
| What it is | Two 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 when | You are registering beans from an external source, rewriting definitions, or applying your own wrapper across many beans. |
| The C# instinct | There 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); }
});
}
}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.
Read: Spring Framework reference, "Container Extension Points"
Spring AOT and native processing#
| Row | Detail |
|---|---|
| What it is | A 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 when | You are targeting native images, or startup time matters (serverless, scale-to-zero). |
| The C# instinct | NativeAOT plus source generators, doing the same job for the same reason: closed-world analysis needs everything decided before runtime. |
Read: Spring Boot reference, "Ahead-of-Time Processing"; then Packaging and native images in this book
JVM internals#
Classloaders and the classpath#
| Row | Detail |
|---|---|
| What it is | Java 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 when | ClassCastException 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# instinct | AssemblyLoadContext 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. |
| Read | Java Language Specification 12.2; Oaks, "Java Performance"; the Tomcat classloader documentation for the app-server case |
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#
| Row | Detail |
|---|---|
| What it is | HotSpot 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 when | Throughput changes shape minutes after startup; a microbenchmark reports impossible numbers; p99 spikes that GC logs do not explain. |
| The C# instinct | RyuJIT 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. |
| Read | Aleksey 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#
| Row | Detail |
|---|---|
| What it is | The 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 when | A concurrency bug you cannot reproduce; you are writing a lock-free structure; you need to know whether a field really needs volatile. |
| The C# instinct | The 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. |
| Read | Goetz, "Java Concurrency in Practice", still the definitive treatment; JSR-133 and its FAQ for the specification |
MethodHandle, VarHandle and reflection performance#
| Row | Detail |
|---|---|
| What it is | Faster, 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 when | Reflection is measurably hot; you are writing a framework or serialiser; you need ordering weaker than volatile but stronger than plain. |
| The C# instinct | Expression trees compiled to delegates, or Unsafe.As. MethodHandle is closer to a compiled delegate than to reflection. |
| Read | java.lang.invoke package documentation; JEP 193 for VarHandle |
Bytecode manipulation#
| Row | Detail |
|---|---|
| What it is | Generating 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 when | You are writing an agent, a mocking framework, or an APM tool; you must add behaviour to a class you cannot change. |
| The C# instinct | IL 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. |
| Read | ByteBuddy tutorial; JEP 484 for the Class-File API; java.lang.instrument for agents |
NIO channels, selectors and memory-mapped files#
| Row | Detail |
|---|---|
| What it is | The 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 when | You are writing a protocol implementation, a high-throughput file processor, or debugging why a driver behaves oddly under load. |
| The C# instinct | Span, 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. |
| Read | java.nio package documentation; Netty's source for how it is really used |
Platform#
| Topic | You need it when | Read |
|---|---|---|
| JMX and MBeans | you must expose or change a runtime value on a live JVM, or an ops tool demands it | javax.management docs; Actuator exposes endpoints over JMX too |
| ServiceLoader and the SPI pattern | you are building pluggable implementations discovered from the classpath; it is how JDBC drivers and Java's own crypto providers are found | java.util.ServiceLoader; the provides directive in Modules, JPMS and internal |
| JCA, JCE, keystores and TLS | you must configure mutual TLS, load a keystore, or pick a cipher suite | Java Security Standard Algorithm Names; keytool documentation |
| Foreign Function and Memory API | you need to call native code, or manage off-heap memory precisely | JEP 454; it replaces JNI and, for off-heap, ByteBuffer |
| Vector API | you have measurable SIMD-shaped numeric work | JEP 508: still an incubator in Java 25, so not for production |
| Locale, charset and i18n | text is corrupted, or sorting differs between environments | always set charset explicitly; ICU4J for real i18n |
Ecosystem, beyond one service#
| Topic | What it is | You need it when |
|---|---|---|
| Spring Cloud | config server, service discovery, gateway, distributed tracing | you run many services and need shared configuration and routing |
| Spring Batch | chunk-oriented batch processing with restartability | you have long-running jobs that must resume after failure, not restart |
| Spring Integration and Camel | enterprise integration patterns as a DSL | you are routing and transforming between many systems |
| Kafka patterns | consumer groups, exactly-once, the transactional outbox | you are building event-driven services and need delivery guarantees |
| Testcontainers at scale | shared containers, reuse, parallel suites | your integration suite has become the slowest part of CI |
| Mutation testing (PIT) | mutates your code to check the tests notice | coverage is high and you do not trust it |
| ArchUnit | architecture rules enforced as unit tests | layering keeps eroding and review is not catching it |
The short list#
If you read only a few things after this handbook:
| For | Read |
|---|---|
| Concurrency, properly | Goetz, "Java Concurrency in Practice" |
| Everyday idiom and API design | Bloch, "Effective Java" |
| Performance and the JVM | Oaks, "Java Performance"; Shipilev's blog for depth |
| Spring | the Spring Boot and Spring Framework reference documentation: genuinely good, and better than most books about them |
| What is coming | the JEP index at openjdk.org/jeps |
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#
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# feature | Java | Kotlin |
|---|---|---|
| Properties | getX() / setX() | val / var, real properties |
| Nullable reference types | Optional plus annotations | String? in the type system |
| Extension methods | static utility classes | extension functions |
| String interpolation | none | "$name has ${x.size}" |
| Operator overloading | none | operator fun plus |
| Records | record | data class |
| with expression | none | copy() |
| Named and optional arguments | none | full support |
| Top-level functions | none, everything in a class | supported |
| switch expression | switch expression | when expression |
| async/await | virtual threads | coroutines, plus virtual threads |
public record Customer(string Name, int Age)
{
public string Label => $"{Name} ({Age})";
}
var c2 = c1 with { Age = 41 };
string? maybe = Find(id)?.Name;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)?.nameThe 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)
}| Concern | What to know |
|---|---|
| Constructor injection | primary constructor parameters, no annotation needed |
| Final by default | Kotlin classes are final, which breaks CGLIB proxies |
| kotlin-spring plugin | opens Spring-annotated classes automatically; essential |
| kotlin-jpa plugin | generates the no-arg constructor JPA needs on entities |
| Null safety across the boundary | Spring's JSpecify annotations map to Kotlin nullability |
| Coroutines in controllers | supported on WebFlux; suspend functions map to Mono |
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#
| Direction | What happens |
|---|---|
| Kotlin calls Java | Java types arrive as "platform types", where nullability is unknown and unchecked |
| Java calls Kotlin | data class becomes a normal class; properties become getX()/setX() |
| Kotlin null safety at the boundary | a Java method returning null into a non-null Kotlin val throws at the assignment |
| Default arguments from Java | not visible unless annotated @JvmOverloads |
| Top-level functions from Java | appear as static methods on FileNameKt |
| Companion object members | need @JvmStatic to look static from Java |
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# | Kotlin | Java 21+ |
|---|---|---|
| async Task<T> | suspend fun | a plain blocking method |
| await | just call the suspend function | just call the method |
| Task.WhenAll | awaitAll / coroutineScope | StructuredTaskScope, still preview |
| CancellationToken | the coroutine Job hierarchy | thread interrupt, or a scope |
| Function colouring | yes: suspend infects callers | no |
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 it | Reason to stay on Java |
|---|---|
| Genuinely less ceremony, particularly for data and null handling | The Java talent pool is far larger |
| Null safety enforced by the compiler | Records, patterns and virtual threads closed much of the gap |
| Excellent Spring and Gradle support | One language means one set of build and tooling problems |
| Your team already knows it | Kotlin 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#
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#
| Message | What it means | Fix |
|---|---|---|
| Port 8080 was already in use | another process holds the port | kill it, or set server.port |
| NoSuchBeanDefinitionException | nothing satisfies a constructor parameter | the class is missing @Service, or it lives outside the scanned package |
| UnsatisfiedDependencyException | a bean could not be built because one of its dependencies could not | read the "Caused by" at the bottom; that is the real error |
| BeanCurrentlyInCreationException | two beans depend on each other | break the cycle, see How Spring actually works |
| Failed to configure a DataSource: 'url' is not specified | the JPA starter is present but no database is configured | set spring.datasource.url, or exclude the auto-configuration |
| Parameter 0 of constructor required a bean of type X | same as NoSuchBeanDefinition, with the location named | check the package, and that X is annotated |
| Consider defining a bean of type X | Spring's own suggestion, usually correct | add @Component to X, or an @Bean method |
| No qualifying bean of type X: expected single matching bean but found 2 | two implementations, no tie-break | @Primary on one, or @Qualifier at the injection point |
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#
| Exception | What it means | Where to look |
|---|---|---|
| NullPointerException | a null reference was dereferenced | Java 15+ names the exact expression; see Nullability |
| ClassCastException | a cast failed at runtime | often erasure, or two classloaders; see Generics |
| ConcurrentModificationException | a collection changed while being iterated | use removeIf or an explicit Iterator; see Collections |
| UnsupportedOperationException | you mutated an immutable collection | List.of and Arrays.asList are not mutable; see Collections |
| NumberFormatException | a string would not parse as a number | Integer.parseInt has no TryParse; catch it |
| ArrayStoreException | wrong element type stored into an array | array covariance; see Week-one gotchas |
| IllegalStateException: stream has already been operated upon | a Stream was consumed twice | streams are single-use; see Streams vs LINQ |
| ArithmeticException: / by zero | integer division by zero | only integers throw; doubles give Infinity |
| DateTimeParseException | a date string did not match the formatter | check the pattern letters, they differ from .NET |
Persistence#
| Exception | What it means | Fix |
|---|---|---|
| LazyInitializationException | a lazy association was touched after the session closed | fetch it inside the transaction, or map to a DTO there; see Data access |
| TransactionRequiredException | a write was attempted with no active transaction | add @Transactional to the service method |
| DataIntegrityViolationException | a database constraint was violated | read the cause for the constraint name |
| ObjectOptimisticLockingFailureException | a @Version check failed; someone else changed the row | retry, or surface a conflict to the user |
| detached entity passed to persist | you called persist on something that already has an id | use merge, or save from the repository |
| No identifier specified for entity | the class has no @Id | add one |
| could not initialize proxy: no Session | the same as LazyInitializationException, from a different path | same fix |
Web and JSON#
| Exception | What it means | Fix |
|---|---|---|
| HttpMessageNotReadableException | the request body would not deserialise | check the JSON shape against the record |
| MethodArgumentNotValidException | @Valid failed | this is the 400 you want; handle it in @ControllerAdvice |
| InvalidDefinitionException: no serializer found | Jackson cannot serialise a type | usually a missing getter, or a lazy JPA proxy |
| UnrecognizedPropertyException | the JSON has a field the type does not | Jackson fails by default, unlike System.Text.Json |
| HttpMediaTypeNotSupportedException | Content-Type does not match what the endpoint consumes | send application/json |
| MissingServletRequestParameterException | a required @RequestParam was absent | required is true by default |
| 403 with no message on a POST | CSRF protection | see Security |
Errors, not exceptions#
| Error | What it means | Fix |
|---|---|---|
| UnsupportedClassVersionError: class file version 69.0 | compiled by a newer JDK than the one running it | class file 69 is Java 25, 65 is 21, 61 is 17; align the versions |
| NoClassDefFoundError | the class was present when compiled and is missing now | a dependency scope problem, or a missing runtime jar |
| ClassNotFoundException | the class was never found, at load time | usually the same cause; this one is a checked exception |
| NoSuchMethodError | the class is present but the method signature changed | two versions of a library on the classpath; run mvn dependency:tree |
| IncompatibleClassChangeError | a class changed shape since its callers were compiled | same cause: a version conflict |
| StackOverflowError | runaway recursion | often mutual toString or equals between two entities |
| OutOfMemoryError: Java heap space | the heap is genuinely full | raise -Xmx, or find the leak with a heap dump |
| OutOfMemoryError: Metaspace | class metadata exhausted | a classloader leak; set -XX:MaxMetaspaceSize and investigate |
| OutOfMemoryError: unable to create native thread | too many platform threads | use virtual threads, or bound the pool |
| ExceptionInInitializerError | a static initialiser threw | read the cause; the class is now permanently unusable |
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.
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 says | What it means | Fix |
|---|---|---|
| cannot find symbol | an unknown name | a missing import, a typo, or a module not on the path |
| unreported exception X; must be caught or declared | a checked exception is unhandled | catch it or add throws; see Exceptions and resources |
| incompatible types: possible lossy conversion from long to int | Java will not narrow silently | cast explicitly, and consider whether it is safe |
| variable x might not have been initialized | a local was read on some path before assignment | Java is stricter than C# about definite assignment |
| local variables referenced from a lambda must be final or effectively final | you reassigned a captured local | use an AtomicReference, or restructure |
| non-static variable cannot be referenced from a static context | instance state used from main or a static method | the classic first-day error |
| class X is public, should be declared in a file named X.java | filename must match the public class | rename the file |
| bad operand types for binary operator | usually == on objects, or arithmetic on boxed types | use equals; see Equality and hashing |
| reached end of file while parsing | an unbalanced brace | the 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| Convention | Meaning |
|---|---|
| Top frame | where the exception was thrown, not where it was caught |
| Caused by | the underlying exception; the last one is usually the real cause |
| ... N more | frames shared with the enclosing trace, elided |
| Suppressed | an exception from close() that did not replace the primary one |
| java.base/ | the module the class came from, since Java 9 |
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#
Everything you need in week one, on one side of paper. Print it and put it next to the keyboard.
| C# | Java |
|---|---|
| var x = ... | var x = ... |
| sealed class | final class |
| : Base | extends Base |
| : IFoo | implements Foo |
| override | @Override |
| readonly | final |
| const | static final |
| namespace | package |
| using X; | import X; |
| string | String |
| bool | boolean |
| x => x + 1 | x -> x + 1 |
| Foo.Bar | Foo::bar |
| new Foo() | Foo::new |
| nameof(x) | no equivalent |
| $"{a} b" | a + " b" |
| 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 / Stack | ArrayDeque<E> |
| ImmutableList | List.of(...) |
| ConcurrentDictionary | ConcurrentHashMap |
| LINQ | Stream |
|---|---|
| Where | filter |
| Select | map |
| SelectMany | flatMap |
| OrderBy | sorted |
| First() | findFirst().orElseThrow() |
| Any(p) / All(p) | anyMatch / allMatch |
| ToList() | toList() |
| GroupBy | collect(groupingBy(f)) |
| Sum() | mapToInt(f).sum() |
| Chunk(n) | gather(windowFixed(n)) |
| Looks right | Actually |
|---|---|
| a == b on String | compares references; use equals |
| Integer 128 == 128 | false; cache stops at 127 |
| no access modifier | package-private, not private |
| protected | also grants package access |
| methods | virtual unless final |
| map.get(missing) | null, then NPE on unboxing |
| switch (nullRef) | throws; add case null |
| stream reused | IllegalStateException |
| List.of(..).add(..) | UnsupportedOperationException |
| double for money | use BigDecimal |
| new BigDecimal(0.1) | imprecise; use the String form |
| 1.0.equals(1.00) | false; use compareTo |
| nested class | inner unless static |
| checked exception in lambda | does not compile |
| return in finally | swallows the exception |
| ASP.NET Core | Spring |
|---|---|
| AddScoped | @Service, singleton by default |
| [ApiController] | @RestController |
| [HttpGet("x")] | @GetMapping("/x") |
| [FromBody] | @RequestBody |
| [FromQuery] | @RequestParam |
| [FromRoute] | @PathVariable |
| IOptions<T> | @ConfigurationProperties |
| appsettings.json | application.yml |
| Environments | profiles |
| ILogger<T> | SLF4J Logger |
| [Authorize] | @PreAuthorize |
| Polly | Resilience4j, or @Retryable |
| .NET | Maven |
|---|---|
| dotnet build | ./mvnw compile |
| dotnet test | ./mvnw test |
| dotnet run | ./mvnw spring-boot:run |
| dotnet publish | ./mvnw package |
| dotnet add package | edit pom.xml by hand |
| list --include-transitive | dependency:tree |
| Symptom | Look at |
|---|---|
| Could not find or load main class | classpath, or package and folder mismatch |
| Annotation does nothing | self-invocation, private, or final |
| Bean not found | package outside the scan root |
| LazyInitializationException | fetch inside the transaction |
| class file version 69 | built on 25, run on something older |
| Container OOMKilled | metaspace, stacks, direct buffers |
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