Java for C# Developers#
A working handbook for .NET engineers moving to Java and Spring Boot
September 2026 edition
Facts verified 9 to 11 September 2026. This copy was built 13 September 2026; the edition, not the build date, says how current the content is.
Preface#
This book exists because the syntax is the easy part.
If you know C#, you can read Java code on your first afternoon. Both languages have curly braces, classes, interfaces, generics and lambdas. Both compile your code to an intermediate form, bytecode in Java and IL in .NET, and both free unused memory with a garbage collector. C# borrowed much of its shape from Java, so the resemblance is deliberate.
What costs you weeks is everything around the language:
- which Java Development Kit (JDK) to install;
- why the build tool works so differently from MSBuild;
- why an annotation, Java's version of a C# attribute, silently did nothing;
- why there is no
await; - why a method you did not mark
virtualwas overridden anyway.
Those are the things this book is about.
Who it is for#
You write C# today, on .NET 8 or later, and you are moving to Java, most likely to work on a
Spring Boot service. You are comfortable with classes, LINQ, async and
await, and ASP.NET Core controllers. You do not need to know any Java. Every Java
word is explained the first time a chapter uses it, and the glossary at the back explains each
one in plain English.
What you need is to know where your C# habits still work in Java, and where they lead you to the wrong answer. So each idea starts with a small example, often something going wrong, then gives the rule, then shows the Java code with every unfamiliar piece explained. Almost every page has a side-by-side comparison, with C# on the left and Java on the right.
Each chapter ends with What to remember, a few sentences that sum up what it taught. After that comes a Quick reference with its tables and short forms, for when you come back later.
How it is organised#
The book has two reading modes and one view for refreshing later. Switch between them with the buttons in the sidebar, or press m.
| Mode | Shows | Takes | Leaves you |
|---|---|---|---|
| Everyday | what you will use in your first months, fully explained | about 5 hours 5 minutes | writing Java productively |
| Complete | everything, including rarely needed material such as legacy APIs and JVM internals | about 7 hours 5 minutes | able to work on anything in a Java codebase |
| Reference | only each chapter's summary, "What to remember" and "Quick reference" | a quick pass | refreshing what you have already learned |
In Everyday mode, rarely needed material folds into one-line headings. Nothing is hidden from you: open any single one that looks relevant. Complete opens all of it, including the asides marked Legacy, which explain code you will inherit but should not write. Reference is for coming back later: it hides the explanations and keeps each chapter's short forms. Earlier copies of this book called the two modes Fast and Full.
There is another way to read it: press ⌘K and type a C# name, or paste an
error message. Every table row, C# refresher, error entry, callout, diagram and glossary word
is indexed. So IEnumerable, IOptions, ConfigureAwait or
the first line of a stack trace takes you straight to its answer.
Where a comparison leans on a C# feature you may not have used recently, a one-line C# refresher sits beside it. It stays closed until you open it, so it costs nothing if you remember the feature.
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 |
Many of the Java features people write about online are still preview features:
finished, but not final. You switch one on with --enable-preview, and it can still
change between releases. So every preview feature in this book is marked as one, because
copying a preview API into production code is an easy way to lose an afternoon.
On accuracy#
Java is thirty years old, and search results do not sort themselves by 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 biggest danger in learning Java from the internet.
So every claim here that depends on a version was checked against the original sources:
- the OpenJDK project pages, for the features in JDK 21, 22, 24 and 25;
- the JDK Enhancement Proposals (JEPs), the documents that describe each change to Java, for the exact shape of each API;
- the Spring release notes and migration guides, for the differences between Spring Boot 3 and Spring Boot 4.
Where something could not be verified, it was either marked as uncertain or left out.
When it was written#
This is the September 2026 edition. Its version-sensitive facts were checked against those sources between 9 to 11 September 2026. Java, .NET and Spring all release on a fixed schedule. So anything this book calls current, latest or new was true on those dates, and the sentences that say so carry the date.
After that, treat version numbers, support dates and preview status as claims to check against the current documentation. When this book and the reference documentation disagree, the documentation is right. The date a copy was built is shown in the sidebar, and it is not the edition: a copy rebuilt later from the same text is still the September 2026 edition.
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 | the plugin went in Java 11, the Applet API in Java 26 | 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 the internals of the Java Virtual Machine (JVM), the program that runs your bytecode. Nor does it cover the memory model in depth, or reactive programming beyond the decision of whether to use it. Kotlin, a language that also runs on the JVM, gets an appendix rather than a chapter, because the platform facts in this book apply unchanged to a Kotlin codebase.
Appendix D maps the expert territory: what each topic is, the symptom that means you now need it, and where to read properly. It points the way rather than teaching, deliberately. Appendix E covers Kotlin, and Appendix H explains every Java word the book uses.
A note on sources#
The structure of this book was informed by an older, freely circulated PDF that compared the
two ecosystems. It was written for C# 5 and Java 8, and is now well 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# and Java look alike: curly braces, classes, interfaces, generics, lambdas and garbage collection all work much the same way. This chapter shows how close they are with one small class written in both languages.
It then names the six places where your C# habits will mislead you, and says which chapter explains each one.
What transfers unchanged#
Your first job on a Java team is often to read code, not to write it. Here is the service that places orders in an online shop, first in C# and then in Java. The C# version is the kind of class you write every week.
// The parameter list after the class name is a primary constructor.
public sealed class OrderService(IOrderRepository repository)
{
public async Task<Order> PlaceAsync(Customer customer, List<OrderLine> lines)
{
if (lines.Count == 0)
throw new ArgumentException("An order needs at least one line.");
var order = new Order(customer, lines);
await repository.SaveAsync(order);
return order;
}
}public final class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public Order place(Customer customer, List<OrderLine> lines) {
if (lines.isEmpty())
throw new IllegalArgumentException("An order needs at least one line.");
var order = new Order(customer, lines);
repository.save(order);
return order;
}
}public final class OrderServicefinalon a class means whatsealedmeans in C#: no other class may extend it.private final OrderRepository repository;A final field is assigned once, in the constructor, and never again, like a
readonlyfield in C#.public OrderService(OrderRepository repository)Java classes have no primary constructors, so the constructor is written out and stores its parameter in the field. Spring, the framework most Java services use, passes the repository in, exactly as the ASP.NET Core container would.
throw new IllegalArgumentExceptionIllegalArgumentExceptionis Java'sArgumentException. Throwing works the same way.var order = new Order(customer, lines);varworks as it does in C#: the compiler works out the type,Order, from the right-hand side.repository.save(order);There is no
await, and the method returnsOrder, notTask<Order>. The call simply waits until the save has finished. Threads are cheap now explains why that is the right way to write Java today.
Classes, constructors, exceptions, generics such as List<OrderLine>, and
var all carry over one to one. That resemblance is real, and it is also the danger:
it makes it easy to write Java as if it were C#. The next section shows where that goes
wrong.
The six things that will actually cost you time#
Here is a small invoice class that shows four of the six at once. It loads a template from
disk and saves itself through a repository field, left out to keep it short. Read
the C# side first, then compare it line by line with the Java side.
public class Invoice
{
public Money Total { get; set; }
// No modifier, so this is private by default.
string LoadTemplate(string path) =>
File.ReadAllText(path);
public async Task SaveAsync() =>
await repository.SaveAsync(this);
}public class Invoice {
private Money total;
public Money getTotal() { return total; }
String loadTemplate(Path path) throws IOException {
return Files.readString(path);
}
public void save() { repository.save(this); }
}public Money getTotal() { return total; }Java has no properties. The value lives in a private field, and a method called
getTotalreads it. Fields, properties and immutability shows the shorter forms.String loadTemplate(Path path)No access modifier means private in C#, but package-private in Java: every class in the same package can call it.
throws IOExceptionFiles.readStringcan fail with anIOException, which is a checked exception. Java makes you either catch it or list it afterthrows, as here. C# lets you ignore it.public void save() { repository.save(this); }No
asyncand noTask. The call blocks until the save has finished, which costs almost nothing on a virtual thread, the lightweight kind of thread Java uses for this.
The other two traps do not fit in a class. Java removes generic type arguments when it compiles, a step called type erasure, so at run time a list does not know what it holds. And the build is run by a separate tool, Maven or Gradle, rather than by the IDE.
C# refresherReified generics: the type argument exists at run time
In .NET a generic type keeps its type argument at run time, so code can ask for
typeof(T) or test whether an object is a List<string>.
static string NameOf<T>() => typeof(T).Name;
NameOf<int>(); // "Int32": T is known at run time
object xs = new List<int>();
Console.WriteLine(xs is List<string>); // False: the type argument is checkedHere are all six, each with the chapter that explains it:
| 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 |
One more false friend deserves a warning of its own, because it looks identical in both languages.
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 every other class in the same package”. Nothing warns you. Here
is the same method in both languages:
public class Invoice
{
protected Money Discount() => Money.Zero;
}package com.acme.billing;
public class Invoice {
protected Money discount() { return Money.ZERO; }
}In C#, only Invoice and its subclasses can call Discount(). In
Java, any class in com.acme.billing can call discount() as well, even
one that has nothing to do with invoices.
A mental model for the platform#
.NET is one product. Microsoft ships the runtime, the base class library, the web framework, the object-relational mapper (ORM), the dependency injection (DI) container and the test runner, all versioned together.
Java is a specification plus a market. Oracle, Eclipse, Amazon, Azul and Red Hat all ship a Java Development Kit (JDK) built to the same specification. The web framework, the DI container and the ORM come from Spring or Quarkus, not from the JDK.
Each .NET piece has a Java counterpart, though rarely from the same vendor:
| .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 a third-party dependency; the JDK ships no ORM |
| 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 official web framework. Spring Boot is overwhelmingly the default in enterprise Java, and this book gives it its largest part, but it remains a third-party choice. Quarkus and Micronaut are credible alternatives.
So a Java web service begins by choosing its framework, as dependencies in the build file. Where ASP.NET Core arrives with the .NET SDK, these are downloaded like any other library:
<!-- pom.xml: none of these ship with the JDK -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <!-- the ASP.NET Core role -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <!-- the EF Core role -->
</dependency>Each <dependency> names a library by its group and its artifact, the two
parts of a Maven name.
spring-boot-starter-web is a starter: one
dependency that brings in Spring's web framework and a web server.
spring-boot-starter-data-jpa brings in Hibernate,
the most widely used ORM. No versions appear, because the Spring Boot parent file chooses them
for you.
Conventions that differ#
Java code also looks different on the page, because the two communities settled on different conventions:
| 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 should match folder | the build tools and the JVM expect it |
| One public type per file, loosely | One public type per file, strictly | filename must match |
| Properties | getX() / setX() | or a record component |
Here is one interface and one class in each language, laid out the way each community writes them:
// The namespace need not match the folder.
namespace Acme.Billing;
// Interfaces start with I, methods use PascalCase.
public interface IOrderRepository
{
Order? FindById(long id);
}
public class OrderService(IOrderRepository repository)
{
// Private fields use _camelCase, with a leading underscore.
private readonly IOrderRepository _repository = repository;
}// The package matches the folder com/acme/billing/.
package com.acme.billing;
// No I prefix, and methods use camelCase.
public interface OrderRepository {
Order findById(long id);
}
public class OrderService {
// Fields have no underscore.
private final OrderRepository repository;
public OrderService(OrderRepository repository) { this.repository = repository; }
}The Java file starts with package com.acme.billing; and sits in the folder
com/acme/billing/, and the file name matches the public class inside it. The
interface loses its I, method and field names start with a lower-case letter, and
the field has no underscore.
LegacyThe Impl suffix
Older Java codebases pair every interface with a single implementation named
OrderServiceImpl. You will see it often, 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 at all. If you do need it, name the implementation for what makes it
different, such as JdbcOrderRepository or InMemoryOrderRepository.
How to read this book#
The book has two reading modes and one view for refreshing later. The buttons are in the sidebar, and m moves from one to the next.
| Mode | Shows | Takes | Leaves you |
|---|---|---|---|
| Everyday | what you will use in your first months, fully explained | about 5 hours 5 minutes | able to write Java productively |
| Complete | everything, including rarely needed material such as legacy APIs and JVM internals | about 7 hours 5 minutes | able to work on anything in a Java codebase |
| Reference | only each chapter's summary, "What to remember" and "Quick reference" | a quick pass | refreshing what you have already learned |
Everyday keeps everything you will meet in a typical Spring Boot service in your first months. That means each chapter's summary, its examples and their explanations, the mapping tables, and the traps you will actually run into. Rarely needed material is folded into a one-line heading you can click if a particular topic matters to you right now.
Complete 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.
Reference is for later. It hides the explanations and shows only each chapter's summary, what to remember and its quick reference, so you can refresh one topic, or the whole book, in one pass.
There is one more way to use it. Press ⌘K and type a C# name, or paste an
error message. Mapping-table rows, C# refreshers, error entries, callouts, diagrams and glossary
words are all indexed, so IEnumerable, IOptions and
ConfigureAwait 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 Everyday mode. Then skip to Spring Boot orientation and use the rest as a reference.
The order service reads almost the same in both languages. Classes, constructors,
exceptions and var carry over, sealed becomes final, and
the async disappears.
The invoice class showed four of the six traps at once. It used a getter instead of a
property, package-private instead of private, a checked exception that must be declared, and a
blocking call instead of await.
The other two are generics that lose their type arguments when compiled, and a build tool, Maven or Gradle, that runs outside the IDE.
Java's protected opens a member to the whole package, not only to
subclasses.
Quick reference#
| C# | Java | Note |
|---|---|---|
| sealed class | final class | nothing may extend the class; the keyword differs, the meaning does not |
| readonly field | final field | assigned once, in the declaration or in the constructor |
| OrderService(IOrderRepository repository) | a written-out constructor | Java classes have no primary constructors |
| ArgumentException | IllegalArgumentException | the standard exception for a bad argument value |
| async Task<Order> PlaceAsync() | Order place(), run on a virtual thread | blocking is cheap on a virtual thread, so there is no async |
| Total { get; set; } | getTotal() and setTotal() | Java has no properties; a record gives you accessors instead |
| no modifier: private | no modifier: package-private | visible to the whole package, which surprises C# developers |
| protected | protected | also visible to every other class in the same package |
| IOrderRepository | OrderRepository | Java code drops the I prefix on interface names |
| namespace Acme.Billing | package com.acme.billing | the package should match the folder the file sits in |
Getting a JDK, and the release train#
To write Java you install a Java Development Kit (JDK): the compiler, the runtime and the tools, like the .NET SDK. There is no single Java to download. You choose a version and a vendor, and this chapter shows which to choose, how to keep several side by side, and the commands you will use every day.
Five minutes to a running service#
It is your first morning, and you want a service running before you read anything else. These three steps install a JDK, generate a Spring Boot project and start it:
# 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.0.4-tem # the exact identifier comes from "sdk list java"
# 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:runcurl -s "https://get.sdkman.io" | bashInstalls SDKMAN, a tool that installs Java versions and switches between them, much like the dotnet-install script.
sdk install java 25.0.4-temInstalls Temurin 25, a free build of the JDK. The identifier includes the exact patch release, and
sdk list javashows the current ones.curl https://start.spring.io/starter.tgzAsks the Spring Initializr, a project generator, for a new Maven project with the web starter, and unpacks it into a folder called
demo../mvnw spring-boot:runBuilds and starts the service with the Maven wrapper, a script inside the project that downloads the right Maven version. You never install Maven yourself.
Then add one file, src/main/java/com/example/demo/Hello.java:
package com.example.demo;
import org.springframework.web.bind.annotation.*;
@RestController
public class Hello {
@GetMapping("/hello")
public String hello(@RequestParam(defaultValue = "world") String name) {
return "hello " + name;
}
}package com.example.demo;Names the package, Java's version of a namespace. It matches the folder the file sits in.
import org.springframework.web.bind.annotation.*;Imports every class in that package, like a C#
usingdirective.@RestControllerAn annotation, Java's version of a C# attribute. This one tells Spring that the class handles HTTP requests, like
[ApiController]on a controller.@GetMapping("/hello")Sends
GET /helloto this method, like[HttpGet("/hello")].@RequestParam(defaultValue = "world") String nameReads
namefrom the query string, and uses"world"when it is missing, like[FromQuery]with a default value.
Call it from another terminal:
curl "http://localhost:8080/hello?name=java"
# hello javaThe response is the text hello java. Spring Boot listens on port 8080 unless
you configure another.
That is the equivalent of dotnet new webapi plus dotnet run. The
Java archive (JAR) that the build produces contains its own
web server, so there is nothing to deploy into, just as a Kestrel app runs on its own.
The Spring Initializr at start.spring.io is the same generator with a web interface, and IntelliJ has it built in under New Project.
That service ran on Java 25. The next section explains what the version numbers mean, and which one to choose.
The release train#
You will see Java 8, 11, 17, 21 and 25 in job adverts and in existing code, but rarely 22 or 23. The reason is the release schedule.
Java ships a new version every six months, in March and September. Since Java 17, every fourth release is a long-term support (LTS) release, so one arrives every two years. Earlier LTS releases were further apart, which is why the table below is not evenly spaced. The releases in between get six months of updates and then nothing. They exist so that new features can mature 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 usual default in September 2026 | .NET 8 |
| Java 25 | 2025 | LTS, the newest in September 2026 | .NET 10 |
Each change to Java is described in a numbered JDK Enhancement Proposal (JEP), such as JEP 444, which added virtual threads in Java 21. The book gives these numbers where they help you look something up.
Target Java 21 unless you control the whole deployment. As of September 2026 it is where most production Java runs. It has virtual threads and pattern matching, and every library supports it. Java 25 is the right choice for a new project. A release that is not LTS is for experiments.
The JDK that runs the build and the Java version you target are separate settings, as they are in .NET. You can build on JDK 25 and still produce bytecode, the compiled form of your code, that runs on Java 21:
<!-- pom.xml: compile for Java 21, whichever JDK runs the build -->
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<!-- under the Spring Boot parent, set this instead; the parent copies it across -->
<properties>
<java.version>21</java.version>
</properties>maven.compiler.release tells the compiler which Java version the output must
run on, much as <TargetFramework> does in a .csproj file. Under the Spring
Boot parent you set java.version instead, and the parent copies it into the
compiler setting.
Java 8 is not a historical curiosity the way .NET Framework 4.8 is becoming. In
2026 a large amount of running enterprise Java is still on 8. It lacks almost everything in
Part 2 of this book: no var, no records, no
switch expressions and no
text blocks. If you inherit a Java 8 codebase, check
before you reach for a modern feature.
Which distribution#
Several vendors build and ship the JDK, each from the same OpenJDK source code:
| 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 |
Each of them passes the Technology Compatibility Kit (TCK), the test suite that proves a JDK behaves like Java. The differences are support, licensing and a few extras. So do not spend a week choosing; take Temurin.
Installing and switching#
You will soon need more than one JDK: Java 21 for one service and Java 25 for another, just as you keep several .NET SDKs. SDKMAN installs them side by side and switches between them. These are the commands you will use:
# install SDKMAN once
curl -s "https://get.sdkman.io" | bash
sdk list java # every version and vendor, with its identifier
sdk install java 25.0.4-tem # Temurin 25
sdk install java 21.0.12+1.1-tem # Temurin 21 alongside it
sdk use java 21.0.12+1.1-tem # this shell only
sdk default java 25.0.4-tem # new shells
java -version # what am I actually runningsdk list javaLists every JDK SDKMAN can install, with the identifier to use, and marks the ones you already have.
sdk use java 21.0.12+1.1-temSwitches only the current terminal to that JDK, which is handy for trying one project on an older version.
sdk default java 25.0.4-temSets the JDK that every new terminal starts with.
java -versionPrints the version of the
javacommand on your path, so you can check which JDK is really in use.
SDKMAN identifiers are exact. sdk install java 25-tem fails,
because the identifier includes the patch release, as in 25.0.4-tem, and it
changes with every quarterly update. Copy it from sdk list java; the ones above
were current in September 2026.
In .NET, a global.json file pins the SDK a folder uses.
.NET refresherglobal.json: pinning the .NET SDK for a folder
A global.json file makes every dotnet command in its folder, and
below it, use the SDK version it names.
{
"sdk": { "version": "10.0.100", "rollForward": "latestFeature" }
}SDKMAN's version of it is a .sdkmanrc file in the project folder:
sdk env init # writes .sdkmanrc naming the current JDK, such as java=25.0.4-tem
sdk env # switches this shell to the JDK that .sdkmanrc namessdk env init writes the file once. Commit it, and anyone who runs
sdk env in the folder gets the same JDK.
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. They compile against the JDK set in the project settings, 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.
With a JDK installed and chosen for each project, the last thing you need on day one is a way to add a library.
jshell: the REPL#
Java has had a read-eval-print loop (REPL), a prompt where you type code and see the result at once, since Java 9 Java 9. It is called JShell, it is the equivalent of C# Interactive, and it is the fastest way to check how a library behaves:
$ 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> /exitEach line is run as soon as you press Enter. xs ==> [1, 2, 3, 4] is jshell
showing the value it stored, and filter(x -> x % 2 == 0) keeps the even numbers,
with -> as Java's lambda arrow, the
=> of C#. /exit leaves.
Running source directly#
Since Java 11 you can run a single .java file with no separate compile step, and
since Java 22 a program can span several files. Java 25 finalised
compact source files and instance main methods Java 25, which
remove the ceremony that made Java's hello world a joke:
// Program.cs
Console.WriteLine("Hello");
// dotnet run// Hello.java
void main() {
IO.println("Hello");
}
// java Hello.javaThe whole program is void main(): no class around it, no
String[] args and no static. IO.println writes a line,
like Console.WriteLine. The feature is final in Java 25, as JEP 512, so it is safe
in scripts and teaching. Most real code still lives in an ordinary class with a
public static void main(String[] args).
LegacyThe full main method
What you will see in almost 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 Java 25.
Adding a library#
Your service needs input validation. In .NET you would run
dotnet add package. The Java build tools have no such command.
There is no mvn add. You edit pom.xml in your editor and paste
the library's name. Every Java developer does this, IntelliJ autocompletes it, and nobody finds
it strange. See Maven vs csproj.
Adding the validation library looks like this:
<!-- pom.xml: the whole of "adding a package" -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<!-- no version: the Spring Boot parent chooses it -->
</dependency>The groupId and artifactId together name the library, like a NuGet
package id. There is no version, because the Spring Boot parent file chooses a compatible one.
The next ./mvnw command downloads it.
One script installed a JDK with SDKMAN, generated a Spring Boot project and started it with the Maven wrapper. One annotated class then answered HTTP requests on port 8080.
Java ships every six months. The long-term support releases, 21 and 25, are the ones to run in production.
You can build with a newer JDK and target an older release with
maven.compiler.release, or with java.version under the Spring Boot
parent.
SDKMAN keeps several JDKs side by side, and a .sdkmanrc file pins one per
project, like global.json. There is no mvn add: you add a library by
editing pom.xml.
Quick reference#
| .NET | Java | Note |
|---|---|---|
| dotnet --list-sdks | sdk list java | marks each JDK that SDKMAN has installed on this machine |
| global.json | .sdkmanrc, or Maven toolchains | pins which JDK this project builds with |
| dotnet --version | java -version | prints to stderr; java --version, with two dashes, prints to stdout |
| DOTNET_ROOT | JAVA_HOME | the JDK folder that mvn and gradle use when set; IDEs ignore it |
| 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 |
How Java runs your code#
In .NET you rarely think about how the runtime finds your code: the .dll
describes itself, 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 the ordered list of folders and Java archive (JAR) files, zip files of compiled classes, that the Java Virtual Machine (JVM) searches for each class it needs. The first match wins. This chapter follows one small program from source code to a running process, so that each startup error has a cause you have already seen.
From source to a running program#
Java takes the same route as .NET in more visible steps. The compiler turns source into
bytecode, Java's IL, and writes one class file per class.
Class files are zipped into a JAR, and the java command starts
a JVM to run them.
.NET refresherWhat dotnet build writes: the .dll, deps.json and runtimeconfig.json
The C# compiler (Roslyn, run as csc) writes IL straight into an assembly, and
the runtime reads deps.json at startup to find every dependency. You never
assemble a search list yourself.
dotnet build
ls bin/Debug/net10.0/
# MyApp.dll the IL, compiled by Roslyn
# MyApp.deps.json every dependency, and where to find it
# MyApp.runtimeconfig.json which .NET runtime to start| .NET | Java | Meaning |
|---|---|---|
| .cs file | .java file | source code; a public class must sit in a file named after it |
| Roslyn (csc) | javac | the compiler; it writes bytecode, not machine code |
| IL | bytecode | the portable instruction set; the runtime compiles it to machine code as it runs |
| IL inside the .dll | class file (.class) | the bytecode of one class; javac writes one per class, nested classes too |
| assembly (.dll) | JAR (.jar) | a zip of class files plus a manifest; the unit you ship and depend on |
| assembly manifest | JAR manifest | a text file inside a JAR that can name its main class and other JARs |
| CLR | JVM | the runtime: loads classes, checks them, and compiles hot code to machine code |
| .NET SDK | JDK | the compiler, the tools and a runtime together; install this to write Java |
| .NET runtime | JRE | a runtime with no compiler, for running programs only; see the note below |
| namespace plus type name | fully qualified name | package plus class, as in com.acme.App, which is also its file's path |
| deps.json and assembly probing | classpath | the ordered list of folders and JARs the JVM searches for classes |
| no single equivalent | class loader | the part of the JVM that finds a class on the classpath and loads it |
| dotnet MyApp.dll | java -jar myapp.jar | runs a packaged application from the command line |
For development, install a Java Development Kit (JDK). It does everything a Java Runtime Environment (JRE) does, and adds the compiler. Oracle stopped offering a separate JRE with Java 11, and recommends jlink to build a small runtime instead. Some other vendors still ship one for deployment.
Every class file stores the Java release it was compiled for, and a runtime refuses bytecode newer than itself:
java.lang.UnsupportedClassVersionError: com/acme/App has been compiled by a more recent version of the Java Runtime (class file version 69.0), this version of the Java Runtime only recognizes class file versions up to 65.0#javac --release 21. In Maven, set
maven.compiler.release.What triggers it
Compile for Java 25 and run on Java 21. The launcher prints Error: LinkageError
occurred while loading main class com.acme.App just before this line.
javac --release 25 -d out src/com/acme/*.java
/opt/java21/bin/java -cp out com.acme.AppOne program, start to finish#
Maven and Gradle,
Java's build tools, hide every step below, as dotnet build hides the compiler.
Doing it by hand once makes every classpath error readable. Take two classes in the package
com.acme. A package is Java's namespace,
with one extra rule: the class files must sit in folders that match the package
name.
// Program.cs, top-level statements
Console.WriteLine(new Greeter().Greet("java"));
// the entry point is recorded in the
// assembly, so this is all it takes:
// dotnet runpackage com.acme;
public class App {
public static void main(String[] args) {
System.out.println(new Greeter().greet("java"));
}
}
// java is told where to look and what to start:
// java -cp out com.acme.Apppackage com.acme;Puts
Appin the packagecom.acme, so its full name iscom.acme.App.public static void main(String[] args)The entry point. A normal Java class has no top-level statements, so the program starts in a static method called
main, like C#'sstatic void Main.System.out.printlnWrites a line to the console, like
Console.WriteLine.java -cp out com.acme.AppThe command that runs it: the classpath after
-cp, then the full name of the class that hasmain.
App uses a second class, Greeter, in a file of its own:
// src/com/acme/Greeter.java
package com.acme;
public class Greeter {
public String greet(String name) { return "hello " + name; }
}Greeter is in the same package as App, so App can use
it without an import. Compile both. The -d option says where the
class files go:
javac -d out src/com/acme/*.javajavac writes one class file per class, and turns the package name into
folders: com.acme becomes com/acme.
out/
└── com/
└── acme/
├── App.class
└── Greeter.classThat mapping is the whole trick. A class's fully qualified name, its
package plus its name, is also the path of its class file: com.acme.App is the file
App.class in the folder com/acme. Run it with -cp, the
classpath, followed by the class whose main method starts the program:
java -cp out com.acme.App
# hello javaHere is what java did:
- Started a JVM whose classpath has one entry, the folder
out. - Turned the name
com.acme.Appinto the pathcom/acme/App.class. - Found
out/com/acme/App.class, loaded it, and called itsmainmethod. - When
mainfirst usedGreeter, looked upcom/acme/Greeter.classthe same way.
To ship it, zip the classes into a JAR and write the main class into the JAR's manifest, a small text file inside the archive:
jar --create --file app.jar --main-class com.acme.App -C out .
java -jar app.jar
# hello javajar --create --file app.jarCreates a JAR called
app.jar, withjar, the JDK's archiving tool.--main-class com.acme.AppWrites
Main-Class: com.acme.Appinto the JAR's manifest, so thatjava -jarknows where to start.-C out .Adds everything in the folder
out, keeping the package folders.java -jar app.jarRuns the JAR, taking the main class from its manifest.
The name you pass to java is the package plus the class, not a file
path. java -cp out com.acme.App works; java -cp out App and
java out/com/acme/App.class both fail with Could not find or load main
class. So does -cp out/com/acme, because a package's top folder must sit
directly under a classpath entry.
The classpath is a search list#
After an upgrade, your service has two copies of the billing library on its classpath: version 2.1, and an old version 1.9 that nobody removed. Which one does Java use?
The first time your code uses a class, the JVM turns its fully qualified name into a path:
com.acme.billing.Invoice becomes com/acme/billing/Invoice.class. It
then checks each classpath entry, a folder or a JAR, in the order listed.
The first entry that has the file wins, and the search stops.
Think of a row of shelves that you search in order for a book. You take the first copy you find, even if a newer edition sits on a later shelf. The comparison stops at the shelves themselves: a JAR is a zip file, not a folder, and the JVM looks inside it.
When the list is wrong, you get one of four errors:
Error: Could not find or load main class com.acme.App#com/acme/App.class. The line after it says
why: ClassNotFoundException means the file is on no entry, and
NoClassDefFoundError ... (wrong name: com/acme/App) means the classpath points
inside the package folder.-cp at the folder or JAR that
holds the package's top folder: out, not out/com/acme.What triggers it
java -cp target/classes com.acme.App # the classes are in out, not target/classes
java -cp out App # the package is missing from the name
java -cp out/com/acme App # the entry points inside the packagejava.lang.ClassNotFoundException: org.postgresql.Driver#compile, or runtime.What triggers it
Class.forName("org.postgresql.Driver"); // the driver's JAR is not on the classpathjava.lang.NoClassDefFoundError: com/acme/Greeter#provided and
test dependencies compile but are left out at run time.What triggers it
rm out/com/acme/Greeter.class
java -cp out com.acme.Appjava.lang.NoSuchMethodError: 'java.lang.String com.acme.Greeter.greet(java.lang.String)'#mvn dependency:tree shows which version of each library Maven chose.What triggers it
Rename greet to hello in Greeter.java, then
recompile only that file, so the old App.class still calls
greet:
javac -d out src/com/acme/Greeter.java
java -cp out com.acme.AppTwo JARs holding the same class do not conflict. One silently wins. In
the diagram, billing-core-1.9.jar is never read. If code compiled against 1.9
calls a method that 2.1 removed, the result is a NoSuchMethodError at run time,
far from its cause. This is “JAR hell”, and why Maven's dependency resolution,
covered in Maven vs csproj, matters so much.
Running with -cp or -jar#
There are two ways to start a Java program, and the JVM reads different things in each:
| Question | java -cp out com.acme.App | java -jar app.jar |
|---|---|---|
| Where do classes come from? | the entries listed after -cp, in order | the JAR itself, plus any JARs its manifest names |
| Which class starts? | the name typed after the classpath | the Main-Class line in the JAR's manifest |
| Are -cp and CLASSPATH used? | yes | no, both are silently ignored |
| Typical use | running from a build folder, or with a classpath you choose | a packaged application, including a Spring Boot JAR |
In practice, the two look like this:
# 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.jartarget/classes:lib/* is a classpath with two entries: the compiled classes,
then every JAR in the lib folder. The second command names neither, because the
JAR's manifest supplies both.
java -jar ignores -cp, and the
CLASSPATH variable too; the JDK documentation says so. java -cp lib -jar
app.jar fails with NoClassDefFoundError even when the class is in
lib. List the dependency in the JAR's manifest, bundle it inside the JAR as
Spring Boot does, or drop -jar: java -cp "app.jar:lib"
com.acme.App.
Entries are separated by : on macOS and Linux and ; on Windows.
An entry ending in /* means every JAR in that folder; quote it so the shell
leaves it alone. With no -cp and no CLASSPATH, the classpath is the
current directory.
A Spring Boot JAR is built for -jar. Its manifest names
Spring Boot's own launcher as the Main-Class and names your class as
Start-Class. The launcher then loads your dependencies from inside the JAR, from
BOOT-INF/lib, which is why a Boot JAR runs with no classpath at all.
LegacyThe CLASSPATH environment variable
Old instructions often tell you to set a CLASSPATH environment variable. It
works, and it applies to every Java program started from that shell. That is almost never
what you want: it mixes JAR versions across unrelated programs, which is exactly how the
NoSuchMethodError above happens. java -jar ignores it. Pass
-cp explicitly, or let Maven build the classpath. 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, the manifest: a short text file
that java -jar reads to decide what to run.
Manifest-Version: 1.0
Main-Class: com.acme.App
Class-Path: lib/jackson-databind.jar lib/slf4j-api.jarMain-Class names the class to start, and Class-Path lists two more
JARs to put on the classpath. The table says what each line does:
| Attribute | Does | Note |
|---|---|---|
| Main-Class | names the class whose main method starts the program | a fully qualified name, written without the .class suffix |
| Class-Path | adds more JARs to the classpath | space-separated, and resolved from the JAR's own folder, not from where you run |
no main manifest attribute, in app.jar#Main-Class line, so java -jar
does not know which class to start.jar --main-class, the Spring Boot
plugin, or the Maven JAR plugin's configuration. Or run it with
java -cp app.jar com.acme.App.What triggers it
jar --create --file app.jar -C out . # no --main-class
java -jar app.jarClass-Path entries resolve relative to the JAR's own location,
not to the directory you run from. 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 uses it, by a class loader: the part of the JVM that finds a class and reads its bytecode. There are three loaders, stacked so that the JDK's own classes always come first.
That is why putting your own java.lang.String on the classpath achieves
nothing: the bootstrap loader finds the real one first. It also explains the two “not
found” errors above. ClassNotFoundException is thrown when code asks a
loader for a class by name and no loader finds it. NoClassDefFoundError is thrown
when a class your compiled code refers to cannot be loaded at run time. A third error means
the class was found but could not be set up:
java.lang.ExceptionInInitializerError#TypeInitializationException. The
Caused by line shows the real exception.NoClassDefFoundError:
Could not initialize class, so search the log for the first failure, not the
last.What triggers it
public class Rates {
// NumberFormatException the first time Rates is used
static final int DEFAULT = Integer.parseInt("12%");
}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.
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);
}Files.readString(Path.of("src/main/resources/rates.json"))Reads a file from disk. That path exists in your project folder, so it works in the IDE, but there is no such folder next to a packaged JAR.
getClass().getResourceAsStream("/rates.json")Asks the classpath for the resource instead, which works inside a JAR too. The leading slash means “from the root of the classpath”.
try (var in =try-with-resources, Java's
usingstatement: the stream opened in the brackets is closed when the block ends.new String(in.readAllBytes(), UTF_8)Reads every byte and turns them into text.
UTF_8isStandardCharsets.UTF_8, brought in by a static import.
java.nio.file.NoSuchFileException: src/main/resources/rates.json#getResourceAsStream, as above.Cannot invoke "java.io.InputStream.readAllBytes()" because "in" is null#getResourceAsStream found nothing at that name and returned null, rather
than throwing. The null surfaces on the next line."/rates.json", is looked up from the
root of the classpath; without one, it is looked up beside the calling class.What triggers it
A typo in the name is enough. If the class was compiled without debug information, the
message says "<local1>" instead of the variable's name.
try (var in = getClass().getResourceAsStream("/rates.jsn")) {
String cfg = new String(in.readAllBytes(), UTF_8);
}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");System.getenv reads an environment variable, exactly as in .NET.
System.getProperty reads a system property, which you pass to the JVM with
-D when it starts. Spring Boot reads both, and command-line arguments too:
| 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: your program receives the text -Dx=1 in
args.
Where your dependencies actually live#
.NET refresherNuGet's cache: ~/.nuget/packages, nuget.config and project.assets.json
Restore downloads each package version once into a shared cache, reads its feeds from
nuget.config, and writes the resolved dependency graph into the
obj folder.
dotnet restore
ls ~/.nuget/packages/newtonsoft.json/
# 13.0.3/ one folder per version, shared by every project
cat nuget.config # the package feeds, and their credentials
cat obj/project.assets.json # the resolved graph, written by restore| .NET | Maven | Note |
|---|---|---|
| ~/.nuget/packages | ~/.m2/repository | a shared cache on your machine, holding one copy of each version |
| nuget.config sources | ~/.m2/settings.xml | where packages are downloaded from: repositories, mirrors and credentials |
| obj/project.assets.json | the resolved dependency tree | computed on every build, not saved; 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: ~/.m2/repository/org/slf4j/slf4j-api/2.0.16/, for
example. It then 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 usemvn dependency:build-classpath prints every JAR Maven will put on the
classpath, in order: the very list this chapter has been about.
error: error reading ~/.m2/repository/org/slf4j/slf4j-api/2.0.16/slf4j-api-2.0.16.jar; zip END header not found#invalid LOC header and, from java -jar, Invalid or
corrupt jarfile mean the same thing.~/.m2/repository and build again.
mvn clean does not help, because it only empties target/. It is the
Java version 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. Tuning is covered in
The JVM at runtime.
Unrecognized VM option 'MaxRAMPercentag=70'#-XX: option, usually because of a typo, and it
refuses to start rather than ignore it. It may suggest the closest option it knows.java -XX:+PrintFlagsFinal -version lists every
option the JVM accepts.What triggers it
java -XX:MaxRAMPercentag=70 -jar app.jarjavac compiled App and Greeter into class files, in folders that
match their package, and java -cp out com.acme.App ran them. The name you pass is
the package plus the class, never a file path.
The classpath is a search list. The JVM checks each entry in order and takes the first copy of a class it finds, so an old JAR earlier in the list silently wins.
java -jar reads the main class and any extra JARs from the JAR's manifest, and
ignores both -cp and CLASSPATH.
Resources are read through the classpath, not from disk, and Maven keeps every JAR it
downloads in ~/.m2/repository.
Quick reference#
| Task | Command | Note |
|---|---|---|
| Compile | javac -d out src/com/acme/*.java | writes one class file per class, in folders that match each package |
| Run from classes | java -cp out com.acme.App | the classpath first, then the fully qualified name of the class with main |
| Package | jar --create --file app.jar --main-class com.acme.App -C out . | zips the classes and records the main class in the manifest |
| Run a JAR | java -jar app.jar | takes everything from the manifest, and ignores -cp and CLASSPATH |
| Pass a setting | java -Drates.url=... -jar app.jar | a system property; the -D must come before -jar |
| Read a setting | System.getProperty("rates.url") | environment variables are read with System.getenv instead |
| Read a resource | getClass().getResourceAsStream("/rates.json") | goes through the classpath, so it also works inside a JAR |
| See the classpath | mvn dependency:build-classpath | prints every JAR Maven puts on the classpath, in order |
IntelliJ for Visual Studio users#
Most Java developers use IntelliJ IDEA, from JetBrains, the company behind ReSharper and Rider. This chapter maps what you know from Visual Studio onto it. It covers how IntelliJ organises a project, the key to learn first, how to debug, and the tools that check your code.
IntelliJ comes in two editions. The free Community Edition covers core Java, Maven, Gradle, Git and JUnit. Ultimate adds Spring, Jakarta Persistence (JPA) and database tooling, and it is what most Java teams pay for. VS Code with the Red Hat Java and Spring Boot extension packs is a lighter option, and you will meet Eclipse in older organisations.
The project model is different#
You open the team's repository and IntelliJ shows one project with three modules. Visual Studio would have shown a solution with three projects. The names moved, and so did the owner: in Java, the build tool decides how the code is organised, and the IDE reads its files.
| Visual Studio | IntelliJ IDEA | Note |
|---|---|---|
| Solution (.sln) | Project | the top-level thing you open; it holds every module in the build |
| Project (.csproj) | Module | each has its own pom.xml and produces one JAR |
| Assembly output | JAR | built by Maven or Gradle; the IDE asks them to build it |
| Solution Explorer | Project tool window | the file tree; Cmd+1 on macOS, Alt+1 on Windows and Linux |
| 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 Java answer to a solution file is a parent pom.xml that lists its
modules. IntelliJ opens it and shows each module the way Solution Explorer shows a
project:
<!-- billing/pom.xml: the parent, doing the job of Billing.sln -->
<packaging>pom</packaging>
<modules>
<module>billing-core</module> <!-- a folder with its own pom.xml -->
<module>billing-api</module> <!-- each module builds one JAR -->
</modules><packaging>pom</packaging> says this file builds nothing itself; it
only lists its modules. Each <module> names a folder with its own
pom.xml, which builds one Java archive (JAR).
The build tool is the source of truth, not the IDE. When you add a
dependency to pom.xml, IntelliJ re-imports the project before it can use it. That
is usually automatic, and the refresh button in the Maven panel does it by hand. A build that
works in the IDE but fails on mvn clean package almost always means the IDE's view
has drifted. Trust the command line.
With the project open, the next thing to get used to is the keyboard.
The key to learn first#
IntelliJ's shortcuts differ enough to be frustrating for a week. There is a Visual Studio keymap under Settings → Keymap, but most people adapt, and the muscle memory transfers faster than you expect. The full mapping is in the quick reference at the end of this chapter. One key matters more than all the others.
Alt+Enter is the one to learn first. It is Visual Studio's Ctrl+.,
but broader. It fixes errors, adds imports, adds a missing dependency and generates a missing
method. It also offers language upgrades, such as turning an
anonymous class into a
lambda, or a chain of if statements into a
switch expression.
Here is the first of those upgrades. A list of names is sorted by length, first with an anonymous class, then with the lambda that Alt+Enter offers instead:
List<String> names = new ArrayList<>(List.of("Grace", "Ada", "Linus"));
// Before: put the cursor on new Comparator, press Alt+Enter, and choose Replace with lambda.
names.sort(new Comparator<String>() {
public int compare(String a, String b) { return a.length() - b.length(); }
});
// After the fix:
names.sort((a, b) -> a.length() - b.length());new Comparator<String>() {An anonymous class: a class with no name, defined and created in one expression. It implements Comparator, the interface Java uses to compare two values, like
IComparer<T>in C#.public int compare(String a, String b)The one method a
Comparatormust have. It returns a negative number whenacomes first and a positive number whenbdoes.(a, b) -> a.length() - b.length()The same comparison as a lambda.
->is Java's lambda arrow, where C# writes=>; Lambdas and method references covers them properly.
Generating the boilerplate Java expects#
Java has no properties, so getters and setters are real methods that someone has to write.
Nobody writes them by hand: Cmd+N generates constructors, getters, setters,
equals, hashCode, toString and delegating methods. Here
is a customer class in both languages:
public class Customer
{
public string Name { get; set; }
public string Email { get; set; }
}public class Customer {
private String name;
private String email;
// Generated with Cmd+N, then Getter and Setter.
public String getName() { return name; }
public void setName(String n) { this.name = n; }
public String getEmail() { return email; }
public void setEmail(String e) { this.email = e; }
}getName and setName are ordinary methods over a private field.
IntelliJ writes them for you, but they are still code you read and maintain. Most of that
ceremony disappears when the type can be a record
Java 16; see Records.
Debugging#
Debugging works much as it does in Visual Studio, under different names:
| 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 |
A conditional breakpoint's condition is a Java expression, evaluated where the breakpoint sits, so it can use any variable in scope:
// Right-click the breakpoint, then type the condition:
order.customer().id() == 1042 && order.lines().size() > 10The condition is ordinary Java: == compares the customer's numeric id, and
&& joins the two tests. The debugger stops only when both are true.
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 restart, and the JetBrains Runtime and 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 your build server 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 |
Error Prone runs inside the compiler, so a bug like this one fails the build instead of reaching code review:
if (amount < 0) {
// Error Prone reports DeadException: the exception is created but never thrown.
new IllegalArgumentException("negative amount");
}The new IllegalArgumentException(...) line creates an exception and then
drops it, because the throw is missing. The compiler accepts it; Error Prone
stops the build.
1You have three Java Development Kits (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's view has drifted.5You run java -jar app.jar -cp lib/extra.jar and a class from extra.jar is not found. Why?
IntelliJ's project holds modules, as a solution holds projects, and a parent
pom.xml lists them. The build tool, not the IDE, is the source of truth.
Alt+Enter is the key to learn first: it turned an anonymous
Comparator class into a one-line lambda.
IntelliJ generates getters, setters, equals and hashCode with
Cmd+N, and a record removes most of that code.
A conditional breakpoint takes any Java expression, and Error Prone catches bugs such as an exception that is created but never thrown.
Quick reference#
| 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 |
Classes, constructors and initialisation#
C# and Java classes are nearly identical. This chapter shows the few differences:
extends and implements instead of a colon, final instead
of sealed, constructor chaining inside the constructor body, and no static
classes.
It also shows initialiser blocks, a Java feature C# does not have, which you will meet in older code.
Declaration#
The team's order service extends a base class the team wrote, ServiceBase, and
must be closed when the application stops. In C# one colon introduces both the base class and
the interface. Here is that declaration in both languages, with three smaller ones below
it:
public class OrderService : ServiceBase, IDisposable
{
}
public sealed class Invoice { }
public abstract class Discount { }
public static class MoneyMath { }public class OrderService extends ServiceBase
implements AutoCloseable {
}
public final class Invoice { }
public abstract class Discount { }
// Java has no static classes; see below.extends ServiceBaseextendsnames the one superclass, where C# uses a colon. A class still has only one superclass, as in C#.implements AutoCloseableimplementslists the interfaces.AutoCloseableis Java'sIDisposable: itsclose()method is what try-with-resources, Java'susing, calls at the end.public final class Invoicefinalmeans no class may extendInvoice, like C#'ssealed.public abstract class DiscountThe same keyword and meaning as in C#: nobody can create a plain
Discount, only a subclass of it.
The rest of the declaration rules map like this:
| C# | Java | Note |
|---|---|---|
| : Base | extends Base | one superclass only, same as C# |
| : IFoo | implements Foo | a class may implement many interfaces, as in C# |
| sealed class | final class | cannot be subclassed; Java's sealed means something else |
| abstract class | abstract class | same keyword and meaning: it cannot be instantiated |
| static class | final class + private constructor | Java has no static classes; this is the idiom |
| partial class | no equivalent | one class cannot span files; 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 | a non-static nested class also holds a hidden reference to its outer object |
C# refresherPartial classes: one class split across files
A C# partial class is declared in several files and compiled as one, usually
so generated code and hand-written code can live apart.
// Customer.g.cs, generated
public partial class Customer { public string Name { get; set; } = ""; }
// Customer.cs, written by hand
public partial class Customer { public bool IsVip => Name.StartsWith("VIP"); }Java has no static classes. The usual way to write a class that only holds static helper
methods is a final class with a private constructor. That is what
java.util.Objects and java.util.Collections do:
public final class MoneyMath {
private MoneyMath() { throw new AssertionError("no instances"); }
public static BigDecimal round(BigDecimal v) {
return v.setScale(2, RoundingMode.HALF_EVEN);
}
}The private constructor stops other code creating a MoneyMath, and the
AssertionError stops the class itself from doing so by mistake. An interface with
only static methods is sometimes used instead, but it says less clearly what you
mean: interfaces are there to be implemented.
Java has no structs either: every type you declare is a class, and a variable holds a reference to an object. When you want a small value that compares by its contents, the closest thing is a record. Here is a discount percentage in both languages:
public readonly record struct Percentage(decimal Value);public record Percentage(BigDecimal value) { }Both compare by value, and neither can be changed once created. The Java
record is still an object, though: it lives on the
heap and is passed by reference, where the C# struct is
copied. Records covers it in full.
With a class declared, the next difference is in how its constructors call each other.
Constructors and chaining#
An express order is an order that ships faster. It has two constructors: one that takes only
the customer, and one that also takes the order lines. The short one should call the full one,
and the full one should call Order's constructor. Both languages can do this, but
in Java the call is the first statement of the body, not part of the signature:
public class ExpressOrder : Order
{
public ExpressOrder(Customer customer)
: this(customer, []) { }
public ExpressOrder(Customer customer, List<OrderLine> lines)
: base(customer, lines) { }
}public class ExpressOrder extends Order {
public ExpressOrder(Customer customer) {
this(customer, List.of());
}
public ExpressOrder(Customer customer, List<OrderLine> lines) {
super(customer, lines);
}
}this(customer, List.of());Calls the other constructor of the same class, like C#'s
: this(...).List.of()makes an empty list, like the[]on the C# side.super(customer, lines);Calls the constructor of the superclass,
Order, like C#'s: base(...).
For most of Java's history, this(...) or super(...) had to be the
very first statement, so you could not check an argument before passing it on. Java 25
finalised flexible constructor bodies Java 25. They allow
statements before the call, as long as those statements do not use the object being
built:
public ExpressOrder(Customer customer, List<OrderLine> lines) {
if (lines.isEmpty()) throw new IllegalArgumentException("no lines"); // legal since Java 25
super(customer, lines);
}On Java 21 and earlier, the if line before super(customer, lines)
does not compile. The workaround is a private static helper called inside the argument, as in
super(customer, requireLines(lines)), 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. Here a registry maps a
file format to the object that handles it: Handler is an interface the team wrote,
and JsonHandler is one implementation.
public class Registry {
private static final Map<String, Handler> HANDLERS;
static {
HANDLERS = new HashMap<>();
HANDLERS.put("json", new JsonHandler());
}
private final long created;
{
created = System.nanoTime();
}
public Registry() { }
}private static final Map<String, Handler> HANDLERS;A class-level constant:
staticbecause it belongs to the class, andfinalbecause it is assigned once, in the block below.static {A static initialiser. It runs once, the first time the class is used, like a C# static constructor. A class can have several, and they run from top to bottom.
created = System.nanoTime();Inside an instance initialiser, the bare
{ }block. It runs before every constructor body, just aftersuper(). It is rare: a constructor is usually clearer.
Class initialisation is lazy and thread-safe, as it is in C#. The Java Virtual Machine (JVM) guarantees that a class's static initialiser runs exactly once, before any static member is read. That is why a static field set in a static initialiser is a safe way to create one shared object, even when many threads start at once.
Every class extends Object#
As in C#, every class inherits from a root class, Object, and overrides a few of
its methods. The method names start with a lower-case letter, and the rules about them are kept
by convention rather than checked by the compiler. Here is Money, an amount in a
currency, overriding the three you will write most:
public class Money(decimal amount, string currency)
{
public decimal Amount { get; } = amount;
public string Currency { get; } = currency;
public override string ToString() => $"{Amount} {Currency}";
public override bool Equals(object? o) =>
o is Money m && Amount == m.Amount && Currency == m.Currency;
public override int GetHashCode() => HashCode.Combine(Amount, Currency);
}
string kind = money.GetType().Name; // "Money"public class Money {
private final BigDecimal amount;
private final String currency;
public Money(BigDecimal amount, String currency) {
this.amount = amount;
this.currency = currency;
}
@Override public String toString() { return amount + " " + currency; }
@Override public boolean equals(Object o) {
return o instanceof Money m
&& amount.equals(m.amount) && currency.equals(m.currency);
}
@Override public int hashCode() { return Objects.hash(amount, currency); }
}
String kind = money.getClass().getSimpleName(); // "Money"@Override public String toString()@Override is an annotation that marks a method as replacing one it inherits, and the compiler checks that it really does. It plays the part of C#'s
overridekeyword, though Java does not require it.o instanceof Money mTests the type and names the result in one step, like C#'s
o is Money m. This is a type pattern.amount.equals(m.amount)Compares values. In Java,
==on two objects compares references, even forBigDecimal.equalson aBigDecimalalso compares the number of decimal places; Equality, hashing and comparison explains why that matters for money.Objects.hash(amount, currency)Combines the fields into one hash code, like
HashCode.Combine.money.getClass().getSimpleName()getClass()returns the object's class, Java'sSystem.Type, andgetSimpleName()gives the name without the package.
In practice you rarely write these three by hand: a record generates all of them from its fields. See Records.
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 skips constructors. You will meet it. Prefer a copy constructor, a static
factory, or a record with a with-style method.
OrderService extends ServiceBase and implements
AutoCloseable, where C# writes one colon for both, and final is
Java's sealed.
ExpressOrder chained its constructors with this(...) and
super(...) as the first statement of the body. Since Java 25, a check may come
before super.
Java has no static classes: MoneyMath is a final class with a
private constructor. Nor does it have structs: a record is the nearest thing.
Money overrode toString, equals and
hashCode, each marked with @Override, and a record would generate all
three. A static initialiser block runs once, when the class is first used.
Quick reference#
| C# | Java | Note |
|---|---|---|
| : this(...) | this(...) as the first statement | calls another constructor of the same class |
| : base(...) | super(...) as the first statement | calls the superclass constructor; checks may come first since Java 25 |
| override | @Override | an annotation the compiler checks; optional, but always written |
| C# | Java | Note |
|---|---|---|
| ToString() | toString() | called by string concatenation and by loggers to print the object |
| Equals(object) | equals(Object) | value comparison; == compares references instead |
| GetHashCode() | hashCode() | must return equal values for objects that equals calls equal |
| GetType() | getClass() | returns a Class object, Java's counterpart of System.Type |
| MemberwiseClone() | clone() | a shallow copy that skips constructors; use a copy constructor instead |
| Finalize() | finalize() | deprecated for removal since Java 18; use try-with-resources |
| n/a | wait() / notify() | every object has a built-in lock; a 1990s condition-variable API |
| 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() |
Access modifiers and packages#
Java's access keywords look like C#'s, but two of them behave differently. Java's
protected also opens a member to every class in the same package. And writing no
keyword at all does not make a member private: it makes it package-private.
Java also has no internal. This chapter shows what each keyword means, how
packages differ from namespaces, and how imports work.
The mapping#
A teammate's invoice class has a total field with no access keyword. In C#
that field would be private. In Java, any class in the same package can read it and change
it:
public class Invoice {
Money total; // No keyword: package-private. Any class in this package can change it.
private Money paid; // This is what was meant.
}Money total; has no keyword, so it is package-private: visible
to every class in the same package, and nowhere else. C# has no such level. To hide the field,
write private, as on the paid line.
Here are Java's four levels, from widest to narrowest, with the nearest C# keyword:
| 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 |
And the other way round, starting from the C# keywords you know:
| 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 |
C# refresherC# access modifiers: internal, protected internal and private protected
internal opens a member to the whole assembly; the two combined modifiers
widen or narrow protected by the assembly boundary.
public class Account
{
internal decimal Limit; // any code in this assembly
protected internal decimal Fee; // subclasses anywhere, or any code in this assembly
private protected decimal Rate; // subclasses, but only those in this assembly
}protected is not a narrowing of public in Java the way
you expect. It also grants package access, so any class placed in the same package,
including one written later by someone else, can read a protected member without extending the
class. If you want “subclasses only”, Java cannot express it. Keep the member
private and offer a protected method whose behaviour you control, or make the class
sealed, so that it lists exactly which classes may
extend it.
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 your habit is exactly the wrong way round.
Package-private access only makes sense once you know what a package is, and that is where Java differs most from C#.
Packages are not namespaces#
A C# namespace is a logical grouping: it need not match the folder layout, and one file can declare several. A Java package is tied to folders. Every build tool and IDE expects the package to match the folder the file sits in. The compiled class files must sit in matching folders, because that is how the Java runtime finds them, as How Java runs your code shows.
// Anywhere/AtAll/OrderService.cs
namespace Acme.Orders;
public class OrderService { }// src/main/java/com/acme/orders/OrderService.java
package com.acme.orders;
public class OrderService { }The C# file can sit in any folder and declare any namespace. The Java file declares
package com.acme.orders; and sits in the folders com/acme/orders/,
under src/main/java, which is where Maven looks
for source code.
Packages do not nest. This is the second half of the
protected trap. com.acme.orders and
com.acme.orders.internal look nested in the IDE's tree, but to the compiler they
are two unrelated packages. A package-private member in the first is not visible to
the second.
In C#, Acme.Orders.Internal can see internal members of
Acme.Orders because they share an assembly. In Java they share nothing.
Imports#
Imports work almost like using directives, with three differences: Java imports
types rather than namespaces, it has no aliases, and nothing is imported for the whole
project.
| 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 | write one of two clashing types fully qualified instead |
| using static Math; | import static java.lang.Math.max; | imports one member, not the whole type |
| global using | no equivalent | each file lists its own imports; the IDE adds them for you |
| implicit usings | java.lang is always imported | java.lang is imported everywhere: String, Object, Integer |
Here are the common forms side by side:
global using System.Linq; // every file in the project
using System.Text.RegularExpressions; // a namespace
using static System.Math; // a type's static members
using Rx = System.Text.RegularExpressions.Regex;
var r = new Rx("a+");
var m = Max(1, 2);import java.util.*; // every type in java.util
import java.util.regex.Pattern; // one type
import static java.lang.Math.max; // one static member
var p = Pattern.compile("a+");
int m = max(1, 2);
java.sql.Date d = null; // no alias: a clashing name is written in fullimport java.util.*;Imports every type in
java.util, but not the types in the packages below it, such asjava.util.regex, because packages do not nest.import static java.lang.Math.max;A static import: it brings in one static method, so
max(1, 2)needs no class name, like C#'susing static.java.sql.Date d = null;Java has no import aliases, so when two types share a name, such as
java.util.Dateandjava.sql.Date, one of them is written with its full package name every time.
Java 25 added module import declarations Java 25, such as
import module java.base;, which import every exported package of a module at
once. They are handy for scripts, but usually too broad for production code.
There is no internal#
This is a real gap, and it shapes how Java libraries are designed. C# gives you an
assembly-wide level, plus InternalsVisibleTo for tests. Java's options are weaker.
The strongest, the Java Platform Module System (JPMS), hides
whole packages rather than single classes:
| 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 remember: Java tests normally 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 need for anything
like InternalsVisibleTo. See the modules chapter
for the module system.
C# refresherInternalsVisibleTo: letting a test project see internal members
// in the production project
[assembly: InternalsVisibleTo("Billing.Tests")]
internal static class InvoiceNumbers { ... } // Billing.Tests may now call it// src/main/java/com/acme/billing/InvoiceNumbers.java
package com.acme.billing;
class InvoiceNumbers { // no modifier: package-private
static String next() { ... }
}
// src/test/java/com/acme/billing/InvoiceNumbersTest.java
package com.acme.billing; // same package, so the test can see it
class InvoiceNumbersTest {
@Test void startsWithInv() { assertTrue(InvoiceNumbers.next().startsWith("INV")); }
}InvoiceNumbers has no access keyword, so only code in
com.acme.billing can use it. The test declares the same package, even though it
lives under src/test/java, so it can call InvoiceNumbers.next()
directly.
Package naming#
Package names are a reversed domain name, all lower-case, with no underscores:
com.acme.billing.api. The convention is universal and the tools assume it. Unlike
in C#, the company prefix is not decoration: it exists so that
Java archive (JAR) files from different vendors never
collide on the classpath.
// src/main/java/com/acme/billing/api/InvoiceController.java
package com.acme.billing.api; // reverse domain, lowercase, and it matches the foldersThe folders under src/main/java spell out the package:
com/acme/billing/api, one folder for each part of the name.
The invoice's total field had no keyword, so it was package-private: visible
to every class in its package. In C# it would have been private.
Java's protected also opens a member to its whole package, so “subclasses
only” cannot be expressed.
A package should match its folders, and packages do not nest:
com.acme.orders.internal cannot see the package-private members of
com.acme.orders.
Imports bring in types or single static members, with no aliases. There is no
internal, so tests share the package of the code they test.
Quick reference#
| C# | Java | Note |
|---|---|---|
| private Money total; | private Money total; | the same, but in Java you must write private to get it |
| decimal fee; with no keyword | BigDecimal fee; with no keyword | private in C#, but package-private in Java, so the whole package can change it |
| [InternalsVisibleTo] | tests in the same package | tests under src/test/java declare the package they test |
| using static System.Math; | import static java.lang.Math.max; | a Java static import brings in one member at a time |
| namespace Acme.Orders; | package com.acme.orders; | lower-case, a reversed domain, and matching the folders |
Interfaces, abstract classes and sealing#
Java interfaces work much like C#'s: they can have default method bodies, static methods and
private helpers, and their names have no I prefix. Java methods can also be
overridden unless you forbid it, the opposite of C#.
The big difference is sealed. In Java it means “only these named types may
extend me”, which lets a switch cover every case, and C# has nothing like it.
Interfaces#
The order service looks up prices through an interface, so that tests can swap in a fake.
In C# you would call it IPriceLookup; in Java it is PriceLookup, with no
I. Both versions below also use EmptyLookup, a class that finds
nothing, which is left out:
public interface IPriceLookup
{
Money? Find(string productId);
// This is a default interface method.
// Its default method body is used unless a class overrides it.
bool Has(string productId) => Find(productId) is not null;
// Interfaces may have static members.
static IPriceLookup Empty => new EmptyLookup();
// And private helpers.
private static void Log(string m) { }
}public interface PriceLookup {
Money find(String productId);
// This method has a default body.
default boolean has(String productId) {
return find(productId) != null;
}
// Interfaces may have static methods.
static PriceLookup empty() {
return new EmptyLookup();
}
// And private helpers.
private void log(String m) { }
}Money find(String productId);No
?on the return type: Java has no nullable reference types, so any reference may be null. Nullability: Optional and JSpecify covers what to do about it.default boolean has(String productId)defaultgives an interface method a body, like a C# default interface method. Every class that implementsPriceLookupgets it, and may override it.static PriceLookup empty()A static method on the interface, called as
PriceLookup.empty().private void log(String m)A private helper that only the interface's own methods can call.
Here is when each capability arrived, and what C# has that Java lacks:
| 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 |
C# refresherGeneric variance on the interface: in and out
A C# interface can promise it only produces T (out) or only
consumes it (in). The compiler then lets an IEnumerable<string>
stand in for an IEnumerable<object>.
interface IProducer<out T> { T Next(); }
interface IConsumer<in T> { void Take(T item); }
IEnumerable<object> items = new List<string>(); // allowed by IEnumerable<out T>
Action<object> print = o => Console.WriteLine(o);
Action<string> printText = print; // allowed by Action<in T>Java has no variance on the declaration. It says the same thing where the type is used,
with a wildcard: a parameter of type
List<? extends Number> accepts a List<Integer>. See
Variance is at the use site.
Every field in an interface is implicitly public static final.
There is no way to declare instance state. Older code sometimes uses a “constant
interface” that classes implement only to inherit its constants. That is a known mistake:
put constants in a final class or an
enum instead.
C# refresherExplicit interface implementation
A member implemented for one interface only: callable through that interface, and not through the class.
interface ILogger { void Log(string m); }
class FileLog : ILogger
{
void ILogger.Log(string m) { } // no access modifier
}
new FileLog().Log("x"); // does not compile
((ILogger)new FileLog()).Log("x"); // compilesC# 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:
interface Printer { void close(); }
interface Channel { void close(); }
class Device implements Printer, Channel {
public void close() { } // one method serves both, and it must be public
}One close() method serves both interfaces. If two interfaces, say
A and B, both give the same method a default body, the class must
override it and choose one explicitly:
public class Both implements A, B {
@Override public String name() {
return A.super.name(); // pick a specific default
}
}A.super.name() calls interface A's default version of
name(), much as base.Name() calls a base class's version in C#.
C# has events: a class declares one, and other code subscribes with +=. Java
has no event keyword. The usual replacement is a list of listeners, each an object
with one method to call. In both versions below, service is an
OrderService:
public class OrderService
{
public event Action<Order>? Placed;
public void Place(Order order)
{
Placed?.Invoke(order);
}
}
service.Placed += order => Console.WriteLine(order.Id);public class OrderService {
private final List<Consumer<Order>> placedListeners = new ArrayList<>();
public void onPlaced(Consumer<Order> listener) { placedListeners.add(listener); }
public void place(Order order) {
placedListeners.forEach(l -> l.accept(order));
}
}
service.onPlaced(order -> System.out.println(order.id()));private final List<Consumer<Order>> placedListenersA list of listeners takes the place of the event.
Consumer<Order>is a built-in functional interface, an interface with one method,accept, which takes anOrderand returns nothing. It is Java'sAction<Order>.placedListeners.forEach(l -> l.accept(order));Calls every listener in turn, as
Placed?.Invoke(order)does.service.onPlaced(order -> System.out.println(order.id()));Subscribes with a lambda, where C# writes
+=. Spring applications often use application events for this instead; see Application events.
So far, interfaces have behaved much as they do in C#. The next feature has no C# counterpart at all.
Sealed types: Java's discriminated unions#
An order can be paid by card, by bank transfer or with a voucher, and in no other way. The
code that charges a payment must handle all three. In C# you would write a base type and hope
that nobody adds a fourth kind without updating every switch. Java can make the
compiler enforce it.
Be careful with the word first. sealed means a different 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 interface lists its complete set of subtypes. Here are the three payment kinds:
public sealed interface Payment
permits Card, BankTransfer, Voucher {
}
public record Card(String last4) implements Payment { }
public record BankTransfer(String iban) implements Payment { }
public record Voucher(String code) implements Payment { }sealed interface Paymentsealedmeans only the types listed afterpermitsmay implementPayment.permits Card, BankTransfer, VoucherThe complete list. The compiler rejects any other class that tries to implement
Payment; permits has a few more rules, listed at the end of this chapter.public record Card(String last4) implements Payment { }Each kind is a record, a short class that only holds data. A card keeps the last four digits of its number.
Because the set is closed, a switch over a payment can be complete without a default arm.
In the C# version, assume the same three kinds exist as records that derive from an abstract
Payment record:
// The compiler cannot know the set is closed,
// so it warns unless you add a discard arm.
decimal Fee(Payment p) => p switch
{
Card c => 0.30m,
BankTransfer b => 0m,
Voucher v => 0m,
_ => throw new ArgumentException()
};// No default needed: add a fourth Payment and this
// stops compiling until you handle it.
BigDecimal fee(Payment p) {
return switch (p) {
case Card c -> new BigDecimal("0.30");
case BankTransfer b -> BigDecimal.ZERO;
case Voucher v -> BigDecimal.ZERO;
};
}return switch (p) {A switch expression, like C#'s
p switch { ... }, written with the value in brackets afterswitch.case Card c -> new BigDecimal("0.30");A type pattern: if
pis aCard, call itcand use this arm. The arrow->separates the pattern from the result, where C# writes=>.stops compiling until you handle it.There is no default arm, because the compiler knows
Paymenthas exactly three kinds. Add a fourth topermits, and this method stops compiling until you handle it.
This is one of the few places where Java is ahead of C#. Sealed interfaces, from Java 17,
and pattern matching in switch, from Java 21, give you a closed set of types that
the compiler checks. C# developers usually need a library, such as OneOf, or a visitor pattern
to get the same safety. See Sealed types and pattern
matching.
Sealed types limit who may extend a type. The last difference runs the other way: an ordinary Java class is more open than you expect.
Abstract classes, and methods that are virtual by default#
Abstract classes are essentially identical to C#'s. The choice between an abstract class and an interface with default methods follows the same reasoning: state and constructors force an abstract class, and otherwise you 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 |
Here is a discount with one method of each kind: one that subclasses must override, one they may override, and one they may not:
public abstract class Discount
{
public abstract decimal Amount(decimal total); // must be overridden
public virtual string Describe() => "discount"; // may be overridden
public string Code() => "D"; // cannot be overridden
}
public class TenPercent : Discount
{
public override decimal Amount(decimal total) => total * 0.10m;
}public abstract class Discount {
public abstract BigDecimal amount(BigDecimal total); // must be overridden
public String describe() { return "discount"; } // may be: no keyword needed
public final String code() { return "D"; } // final: cannot be overridden
}
public class TenPercent extends Discount {
@Override public BigDecimal amount(BigDecimal total) {
return total.multiply(new BigDecimal("0.10"));
}
}public String describe() { return "discount"; }No keyword, and yet any subclass may override it: every Java method is virtual unless it is marked
final. C# makes you writevirtual.public final String code()finalon a method forbids overriding, like a C# method withoutvirtual.@Override public BigDecimal amountThe
@Overrideannotation asks the compiler to check that this method really overrides one from the superclass.
Java methods are virtual by default. C# requires virtual to
allow overriding; Java requires final to forbid it. So a method you did not intend
as an extension point is one, unless you say otherwise. @Override enables nothing,
but always write it: it catches a mistyped signature that would otherwise quietly create a new
method instead of overriding.
The rules for permits#
A sealed type's permitted subtypes follow four rules:
| 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 |
Here a sealed interface keeps one branch closed and leaves another open:
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 branchUserEvent is final, so nothing may extend it.
SystemEvent is non-sealed, so any class may extend it, and a switch
case for SystemEvent also covers those subclasses.
PriceLookup showed that Java interfaces have default bodies, static methods and
private helpers, and drop the I prefix. There is no explicit implementation: one
public method serves every interface that declares it.
Java has no events: a list of listeners, each a Consumer<Order>, does the
job.
Payment is a sealed interface whose permits names its only three
kinds. So a switch over it needs no default, and a fourth kind breaks every switch until it is
handled.
Java methods are virtual unless marked final, and @Override asks
the compiler to check each override.
Quick reference#
| C# | Java | Note |
|---|---|---|
| interface IPriceLookup | interface PriceLookup | Java interface names have no I prefix |
| default interface method | default boolean has(...) { ... } | an interface method with a body; implementing classes inherit it |
| event Action<Order> Placed | List<Consumer<Order>> plus onPlaced(...) | Java has no events, so keep a list of listeners instead |
| abstract record Payment plus derived records | sealed interface Payment permits Card, BankTransfer, Voucher | a closed set: the compiler knows every kind |
| virtual | no keyword: every method is virtual | write final to stop a method being overridden |
| override | @Override | an annotation the compiler checks; optional, but always write it |
Methods and parameters#
Java methods look like C# methods, but with fewer parameter features. There are no optional
parameters, no named arguments, no ref or out, no extension methods
and no operator overloading.
This chapter shows what Java code does instead, and how lambdas and method references take the place of C# delegates.
Signatures#
The order service needs a method that counts orders matching a filter, with an optional case-sensitive flag and an optional limit. In C# you write one method with default values, and call it with a named argument. Java has neither, so the Java version is an overload ladder: each shorter version calls the next with the default filled in.
public int CountOrders(
string filter,
bool caseSensitive = false,
int limit = 100)
{
...
}
var n = CountOrders("paid", limit: 50);public int countOrders(String filter) {
return countOrders(filter, false, 100);
}
public int countOrders(String filter, boolean caseSensitive) {
return countOrders(filter, caseSensitive, 100);
}
public int countOrders(String filter, boolean caseSensitive, int limit) {
...
}
int n = countOrders("paid", false, 50); // no named argumentsreturn countOrders(filter, false, 100);Each shorter overload fills in the missing values and calls the full version, which does the real work.
int n = countOrders("paid", false, 50);With no named arguments, you pass every value up to the one you want, in order. Here
falsehas to be written just to reach the limit.
Java has neither. The three replacements, in order of preference:
- Overload ladder. Each shorter overload delegates to the fullest one. Fine for two or three parameters.
- Builder. The standard answer beyond that. Verbose to write, pleasant to call, and what most libraries offer.
- A parameter object, usually a
record, when the arguments belong together.
// A builder is the usual replacement for named and optional arguments.
var req = HttpRequest.newBuilder()
.uri(URI.create("https://example.com"))
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json")
.GET()
.build();HttpRequest.newBuilder()Starts a builder: an object you set up one value at a time before it creates the real thing.
HttpRequestbelongs to the HTTP client built into the Java Development Kit (JDK)..timeout(Duration.ofSeconds(10))Each method sets one named value, which is how a builder stands in for named arguments. Values you do not set keep their defaults.
.build();Creates the finished
HttpRequestfrom everything set so far.
Parameter names are not in the compiled output by default. Reflection
sees arg0, arg1, unless the class was compiled with the
-parameters flag. That matters when a framework matches values to parameters by
name, as Spring does for some constructor
injection and request binding. The Spring Boot
plugins for Maven and
Gradle switch the flag on for you; a build set up by hand
may not.
Default parameter values are one gap. The next is bigger: how arguments are passed at all.
No ref, no out#
A customer types a quantity into a form, and you need to parse it. In C# you write
int.TryParse(s, out var value). Java has no out parameters, because
Java passes every argument by value. An object reference is passed by value too: the method can
change the object it points at, but it 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) { ... }
void Print(in BigStruct s) { ... } // read-only, by reference// Return a value that can be empty, instead of an out parameter.
OptionalInt parsed = parseQuantity(s);
if (parsed.isPresent()) {
use(parsed.getAsInt());
}
// Return a record when there are several results.
record Pair(int a, int b) { }
Pair swapped = swap(a, b);OptionalInt parsed = parseQuantity(s);parseQuantityis a small helper you would write. It returns an emptyOptionalIntwhen the text is not a number, where Java's ownInteger.parseIntthrows aNumberFormatException.OptionalIntis the int version of Optional: a box that holds a value or nothing.parsed.getAsInt()Takes the number out, once
isPresent()has confirmed there is one.Useandusestand for whatever you do next.record Pair(int a, int b) { }A record, a short class that only holds data, carries several results back, where C# would use
refparameters or a tuple.Pair swapped = swap(a, b);swapreturns a newPairwith the two values the other way round. The caller's own variables are not changed.
C# refresherTuples: multiple return values
A C# method can return several values at once as a tuple, and the caller can take them apart into separate variables.
(int Min, int Max) Range(int[] xs) => (xs.Min(), xs.Max());
var (lo, hi) = Range([3, 1, 4]); // lo is 1, hi is 4High-performance C# also passes Span<T>, a view over memory that avoids
copying. Java's nearest tools are ByteBuffer and MemorySegment, which
you will rarely need in a web service.
C# refresherSpan and stackalloc: a view over memory, with no copy
Span<byte> buffer = stackalloc byte[256]; // on the stack: no heap allocation
ReadOnlySpan<char> head = text.AsSpan(0, 3); // a view of text, not a copyVarargs#
Varargs are the same idea as params, with the same rule that it must be the
last parameter:
void Log(string fmt, params object[] args) { }
Log("a {0}", 1);void log(String fmt, Object... args) { }
log("a %s", 1);Object... args is Java's params object[] args: the caller passes
any number of values, and the method receives an array. The %s in the format
string is Java's {0}.
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#
You want an IsBlank() check on every string, where name is a string
that may be null. In C# an extension method adds it to string. Java cannot add a
method to a type it does not own, so the helper lives in a class of its own:
public static class StringExtensions
{
public static bool IsBlank(this string? s) =>
string.IsNullOrWhiteSpace(s);
}
if (name.IsBlank()) { ... } // reads as if String had itpublic final class Strings {
private Strings() { }
public static boolean isBlank(String s) {
return s == null || s.isBlank();
}
}
if (Strings.isBlank(name)) { ... } // the helper's class comes firstpublic final class StringsA utility class:
final, with a private constructor, holding only static methods, as Classes, constructors and initialisation showed.s == null || s.isBlank()Stringhas had its ownisBlank()since Java 11, but calling it on null throws, so the helper checks for null first.Strings.isBlank(name)The call names the helper's class first. C# reads as if
stringhad the method.
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 class that wraps the original object, when the extension is substantial.
The practical consequence: Java code often reads inside-out where C# reads left to right,
as in Collections.unmodifiableList(new ArrayList<>(xs)) instead of a
chain. Streams are the notable exception, and part of why they feel so different from the
rest of the language.
No operator overloading#
C# refresherOperator overloading: defining + for your own type
public readonly record struct Money(decimal Amount)
{
public static Money operator +(Money a, Money b) => new(a.Amount + b.Amount);
}
var total = price + shipping; // calls the operator aboveJava overloads exactly one operator, + for strings, and does not let you add
more. That matters most in money code. With price, quantity,
shipping and limit as decimal in C# and
BigDecimal in Java:
decimal total = price * quantity + shipping;
if (total > limit) { ... }BigDecimal total = price.multiply(quantity).add(shipping);
if (total.compareTo(limit) > 0) { ... }multiply and add replace * and +, and
compareTo(limit) > 0 replaces >, because
BigDecimal cannot define operators. See
Numbers, money and time: this is the most common source of
quiet mistakes when a C# developer writes their first Java money code.
Methods cannot be added to other types, but they can be passed around as values, and that is where Java and C# look most alike again.
Lambdas and method references#
The shop applies pricing rules: each rule takes an order and returns a price. In C# a rule is a delegate. Java has no delegates. Instead, a rule is an interface with exactly one method to implement, called a functional interface, or a single abstract method (SAM) type. Any lambda with the right shape can stand in for it. Here are the common delegate types and their Java equivalents:
delegate decimal PriceRule(Order o); // a named delegate type
Func<int, string> show = n => n.ToString();
Action<string> print = Console.WriteLine; // method group
Predicate<string> empty = string.IsNullOrEmpty;
Func<List<int>> make = () => new List<int>();
names.ForEach(print);interface PriceRule { BigDecimal apply(Order o); } // a functional interface
Function<Integer, String> show = n -> n.toString();
Consumer<String> print = System.out::println; // method reference
Predicate<String> empty = String::isEmpty;
Supplier<List<Integer>> make = ArrayList::new; // constructor reference
names.forEach(print);interface PriceRule { BigDecimal apply(Order o); }The delegate type becomes an interface with one method. A lambda such as
o -> BigDecimal.TENcan be used wherever aPriceRuleis expected.Function<Integer, String> show = n -> n.toString();Function<Integer, String>is Java'sFunc<int, string>. A generic type cannot hold a plainint, so the wrapper typeIntegeris used. The arrow is->where C# writes=>.Consumer<String> print = System.out::println;A method reference:
Type::methodpasses an existing method as a lambda, like a C# method group.Consumer<String>is Java'sAction<string>.Supplier<List<Integer>> make = ArrayList::new;A constructor reference:
ArrayList::newis a function that creates a new list.Supplier<T>is Java'sFunc<T>.names.forEach(print);namesis a list of strings.forEachcallsprintonce for each element.
Captured variables must be effectively final. A Java lambda may use a local
variable only if that variable is never reassigned after it is set. C# captures the variable
itself and lets you change it. Here list is any list:
int count = 0;
list.forEach(x -> count++); // does not compile
var counter = new AtomicInteger(); // the usual workaround
list.forEach(x -> counter.incrementAndGet());count++ changes a local variable, which a lambda may not do.
AtomicInteger is an object whose value can change, so the lambda only reads the
variable that points to it. Awkward, but it removes a whole class of surprises, and it is what
makes lambdas safe to hand to another thread.
countOrders used an overload ladder in place of optional parameters. With more
than two or three options, a builder is the usual answer.
Java passes everything by value, so there is no ref or out: return
an OptionalInt or a record instead.
There are no extension methods. Strings.isBlank(name) is a static helper in a
utility class.
A delegate becomes a functional interface such as PriceRule, filled by a lambda
or a method reference such as System.out::println. A lambda can only use local
variables that never change.
Quick reference#
| 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 |
| C# | Java | Note |
|---|---|---|
| x => x + 1 | x -> x + 1 | the same lambda; Java writes the arrow as -> instead of => |
| (x, y) => x + y | (x, y) -> x + y | identical apart from the arrow, which is -> in Java |
| () => Foo() | () -> foo() | a lambda with no parameters; Java names methods in camelCase |
| 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> | a function that takes a T and returns nothing |
| Predicate<T> | Predicate<T> | same name and meaning: takes a T and returns a boolean |
Fields, properties and immutability#
Java has no properties. A property is a naming convention: a private field, a
getX() method that reads it and a setX() method that changes it.
Frameworks find properties by that convention, so the exact names matter.
For data that never changes, a record writes all of this
for you. This chapter shows both, and how final and static final map
to readonly and const.
The property gap#
The order service keeps customers. In C#, a customer's id is fixed when it is created, its name can change, and a label is worked out from the two. Each is a property. Here is the same class in Java, where each property becomes a field plus methods:
public class Customer
{
public long Id { get; init; }
public string Name { get; set; }
public string Label => $"{Name} ({Id})";
}public class Customer {
private final long id;
private String name;
public Customer(long id) { this.id = id; }
public long getId() { return id; }
public String getName() { return name; }
public void setName(String v) { this.name = v; }
public String getLabel() {
return name + " (" + id + ")";
}
}private final long id;The field that holds the id.
finalmeans it is set once, in the constructor, which does the job of C#'s{ get; init; }.public String getName()A getter:
getplus the property name, starting with a capital letter.public void setName(String v)A setter:
setplus the name. A private field with a getter and a setter is what Java calls a property.public String getLabel()A computed value is an ordinary method. The
getprefix lets frameworks treat it as a read-only property.
A class like this, with private fields, getters and setters and no framework base class, is
often called a plain old Java object (POJO). When it follows the naming rules exactly, it is also
called a JavaBean. C# also has required and init, and since C# 14 the
field keyword. Java has none of them: a final field set in the
constructor does their job.
C# refresherrequired, init-only and the field keyword
required forces the caller to set a property when creating the object,
init allows setting it only then, and C# 14's field names the
hidden backing field inside an accessor.
public class Customer
{
public required string Id { get; init; } // must be set at creation, then fixed
public string Name
{
get;
set => field = value.Trim(); // field: the compiler's backing field
}
}
var c = new Customer { Id = "C1", Name = " Ada " }; // leave out Id: compile errorThe naming rule is exact, and tools depend on it: getName() for a
name property, isActive() for a boolean, and
setName(v). Get the prefix wrong and Jackson,
the JSON library, will not write the field, and Hibernate,
the database mapper, will not store it. There is no error, just a missing value.
Writing all these methods for every class is tedious, and for one common kind of class you no longer have to.
Records make most of this disappear#
Most customer objects in the order service never change once they are created: they are
data. A record suits immutable data, such as a data transfer
object (DTO), a value object or a query result. It replaces the whole ceremony, including
equals, hashCode and toString. Below,
customer is an existing Customer:
public record Customer(long Id, string Name, string Email)
{
public string Label => $"{Name} ({Id})";
}
var renamed = customer with { Name = "Ada" };public record Customer(long id, String name, String email) {
public String label() {
return name + " (" + id + ")";
}
}
// No with expression: build a new one. See the Records chapter.
var renamed = new Customer(customer.id(), "Ada", customer.email());public record Customer(long id, String name, String email)One line declares the fields, a constructor, the accessors, and
equals,hashCodeandtoString.public String label()A record can have methods too. Note the name:
label(), notgetLabel().new Customer(customer.id(), "Ada", customer.email())Java records have no
withexpression, so a changed copy is built by hand. The accessors areid()andemail(), with nogetprefix.
Record accessors have no get prefix. A record component
name is read with c.name(), not c.getName(). This breaks
the JavaBean naming rule on purpose, and older framework versions that expected
getX() could not read records. Modern Jackson and Spring handle records, but a
library that has not been updated since about 2021 may not.
final is not readonly, quite#
Java has one keyword, final, where C# has readonly and
const:
| 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 | as of Java 25 there are no user-defined value types; use a record |
| init-only | final + constructor | assign once in the constructor; records do it automatically |
final makes the reference unchangeable, 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 referencenames.add changes the list the field points to, which final
allows. names = new ArrayList<>() points the field at a new list, which
final forbids. For a list that truly cannot change, use List.of(...)
Java 9, which returns a list that throws on any change.
static final constants#
Constants look like this in each language:
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);static final covers both const and static readonly.
MAX_RETRIES is written in capitals with underscores, the one place Java does not
use camelCase. The rule is universal.
A static final primitive or String set to a compile-time constant
is copied into the code that uses it, exactly like C#'s const. If you
publish a library, change such a constant, and a user of the library does not recompile, they
keep the old value. Use a static method, or a value computed at run time, for anything that
might change.
A word on Lombok#
LegacyProject Lombok
Lombok is a tool that reads annotations such as
@Getter while your code compiles, and writes getters, setters, constructors,
equals, hashCode and builders for you. It is an
annotation processor. You will meet it in a
great many Java codebases, and it removed a lot of pain before records existed.
@Getter @Setter @Builder
public class Customer {
private String name;
private String email;
}@Getter and @Setter generate the accessors for every field, and
@Builder generates a builder, all while the class compiles.
Lombok is not deprecated and remains widely used, but it has a cost. It relies on the compiler's internal workings, which has caused breakage on several Java Development Kit (JDK) upgrades. For immutable data, a record is now the better answer and needs no dependency. For mutable entities, the classes Hibernate stores in the database, many teams still use 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.Customer showed that a Java property is a private field plus
getName and setName methods, found by an exact naming rule.
A final field set in the constructor does the job of init and
required, and a computed value is an ordinary method.
For data that never changes, a record writes the fields, constructor, accessors,
equals, hashCode and toString, with accessors named
name() rather than getName().
final fixes the reference, not the object, and static final is
Java's const and static readonly.
Quick reference#
| C# | Java | Note |
|---|---|---|
| { get; set; } | getX() / setX() | frameworks find them by this exact naming rule |
| { get; } | final field + getX() | read-only after construction; assign it in the constructor |
| { get; init; } | final field + constructor | set once in the constructor; a record does this for every field |
| => expression | a plain method | computed each time it is called; there is no backing field |
| required | no equivalent | make the field final and demand it in the constructor |
| field keyword | no equivalent | declare the backing field explicitly; the getter and setter use it |
Records#
C# and Java records solve the same problem and look almost identical: one line gives you a
constructor, accessors, equals, hashCode and toString.
Java 16
Java records differ in three ways: they can never be changed, they have no with
expression, and their accessors have no get prefix.
The basics#
Every line on an order carries a product id, a quantity and a unit price, and it never
changes once it is on the order. That is exactly what a record is for. Here is
OrderLine in both languages, where Money is itself a small record of an
amount and a currency:
public record OrderLine(string ProductId, int Quantity, Money UnitPrice);
var line = new OrderLine("mug", 2, new Money(9.50m, "EUR"));
Console.WriteLine(line);
Console.WriteLine(line.Quantity);
var more = line with { Quantity = 3 };public record OrderLine(String productId, int quantity, Money unitPrice) { }
var line = new OrderLine("mug", 2, new Money(new BigDecimal("9.50"), "EUR"));
System.out.println(line);
System.out.println(line.quantity()); // note the ()
// no with expression: see belowpublic record OrderLine(String productId, int quantity, Money unitPrice) { }The header lists the record's components. From it, Java generates a private final field for each, a constructor that takes them in order, an accessor for each, and
equals,hashCodeandtoString. The braces are required, even when the body is empty.line.quantity()The accessor is a method called
quantity(), with brackets and nogetprefix. C# gives you a property,Quantity.System.out.println(line);Prints
OrderLine[productId=mug, quantity=2, unitPrice=Money[amount=9.50, currency=EUR]]: square brackets, where C#'sToStringuses braces.
Most of what you know about C# records carries over. These are the differences:
| 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 |
C# refresherC# records: positional, nominal, inheritance, record struct and with
A C# record can be declared by its parameters or by a body of properties, can inherit another record, can be a value type, and compares by value.
public record Point(int X, int Y); // positional
public record Customer { public string Name { get; init; } = ""; } // nominal: a body
public record Shape(string Name);
public record Circle(string Name, double R) : Shape(Name); // inherits a record
public readonly record struct Money(decimal Amount); // a value-type record
var p = new Point(1, 2);
Console.WriteLine(p == new Point(1, 2)); // True: value equality
var q = p with { Y = 5 }; // a copy with one changeA record takes whatever values it is given. The next step is to check them.
Checking values: the compact constructor#
An amount of money must never be negative, and it should always have two decimal places, so that 9.5 and 9.50 compare as equal. Java records have a special constructor for this, the compact constructor, which C# does not have. It checks or adjusts the values without restating the parameter list or the assignments:
public record Money(BigDecimal amount, String currency) {
// A compact constructor: no parameter list, and no assignments to fields.
public Money {
if (amount.signum() < 0) throw new IllegalArgumentException("negative amount");
amount = amount.setScale(2, RoundingMode.HALF_EVEN);
}
// Any extra constructor must call the main one first.
public Money(String amount) {
this(new BigDecimal(amount), "EUR");
}
}public Money {The compact constructor. It has no parameter list, because its parameters are the record's components. It runs before the fields are set.
amount = amount.setScale(2, RoundingMode.HALF_EVEN);Assigns to the parameter, not the field. When the body ends, Java copies each parameter into its field, so writing
this.amount = amounthere is not allowed.setScalerounds to two decimal places, using banker's rounding.this(new BigDecimal(amount), "EUR");An extra constructor must call the main one first, with
this(...), so every record passes through the checks.
With values checked on the way in, the next question is how to change one. The answer is that you do not: you make a new record.
The missing with expression#
As of Java 25, Java has no with expression and nothing that replaces it. Here
are the options, using line from the first example:
// 1. Just construct it: fine for small records.
var more = new OrderLine(line.productId(), 3, line.unitPrice());
// 2. A hand-written "wither": common in domain code.
public record OrderLine(String productId, int quantity, Money unitPrice) {
public OrderLine withQuantity(int q) { return new OrderLine(productId, q, unitPrice); }
}
// 3. A builder, for records with many components.new OrderLine(line.productId(), 3, line.unitPrice())Repeats every component and changes one. It is fine for three components, and tedious for eight.
public OrderLine withQuantity(int q)Writes that once, inside the record, so callers can write
line.withQuantity(3). Methods like this are called withers.
Derived record creation, a with-like feature, has been discussed for a future
release but is not in Java 25. For a record with more than about four components, a builder,
as shown in Methods and parameters, is the practical answer.
Records are not the right tool for every class. The next section says where they fit.
Where records fit#
Records suit data that is created once and then only read. Here is where they fit, and where they do not:
| Use | Suitable | Why |
|---|---|---|
| DTO / API request or response | yes | Jackson reads and writes records with no extra setup |
| Value object (Money, Range) | yes | a value that never changes is safe to share and to use as a map key |
| Query result / projection | yes | Spring Data supports record projections |
| Pattern-matching payload | yes | a switch can take a record apart into its components |
| JPA entity | no | Hibernate needs a no-arg constructor and mutability |
| Mutable domain object | no | every field is final, so an object that changes state cannot be a record |
| Needs inheritance | no | a record is final and cannot extend another class; use an interface |
Records are shallowly immutable. A record holding a
List<OrderLine> hands the same changeable list to every caller. Copying it is
your job:
public record Order(long id, List<OrderLine> lines) {
public Order {
lines = List.copyOf(lines); // now genuinely immutable
}
}List.copyOf makes an unmodifiable copy. A caller who keeps the original list can
no longer change the order's lines, and the order's own list throws if anyone tries. C# has the
same problem with a record that holds a List<T>, but Java makes the fix one
line in the compact constructor.
Records and pattern matching#
Records are what make record patterns work.
Because a record's components are part of its type, a switch can take a record apart by
position. Using the sealed Payment type from
Interfaces, abstract classes and sealing:
String describe(Payment p) {
return switch (p) {
case Card(String last4) when last4.equals("0000") -> "a test card";
case Card(String last4) -> "card ending " + last4;
case BankTransfer(String iban) -> "transfer from " + iban;
case Voucher(String code) -> "voucher " + code;
};
}case Card(String last4)A record pattern: if
pis aCard, take its component out intolast4. Components are matched by position.when last4.equals("0000")A guard, like C#'s
when: the arm matches only if the condition is also true.
C# can pattern-match on positional records too. But without sealed types it cannot know the set of cases is complete, so it warns unless you add a discard arm.
OrderLine showed a whole record in one line: its components became final
fields, a constructor, accessors such as quantity(), and equals,
hashCode and toString.
A compact constructor checks or adjusts the values: Money rejected negative
amounts and rounded to two places before its fields were set.
There is no with expression. Build a new record, or write a wither such as
withQuantity.
Records are only shallowly immutable, so copy lists with List.copyOf. Record
patterns take records apart inside a switch.
Quick reference#
| C# | Java | Note |
|---|---|---|
| record OrderLine(string ProductId, ...) | record OrderLine(String productId, ...) { } | the braces are required, even when the body is empty |
| line.Quantity | line.quantity() | an accessor method, with brackets and no get prefix |
| line with { Quantity = 3 } | line.withQuantity(3) | a wither you write yourself; Java has no with expression |
| no equivalent | public Money { ... } | a compact constructor, which checks values before they are stored |
| record struct | no equivalent | a Java record is always a reference type, never a struct |
Sealed types and pattern matching#
Pattern matching tests what kind of object you have and gives you a typed variable for it
in one step. Java's patterns work like C#'s. instanceof is Java's is
Java 16, and a switch can match types, null and extra
conditions Java 21.
Over a sealed type Java 17, the compiler checks that a switch handles every case, which C# 14 cannot do. Record patterns take a record apart as they match it. Java lacks C#'s property, list and relational patterns.
Type patterns#
A customer asks for a refund. Card payments are refunded to the same card, so the refund
code must first ask: is this payment a card? If it is, the code needs the card's last four
digits. In C# you write is, a type and a variable name. Java writes
instanceof in the same place:
void Refund(Payment payment, decimal amount)
{
if (payment is Card card)
{
RefundToCard(card.Last4, amount);
}
}void refund(Payment payment, BigDecimal amount) {
if (payment instanceof Card card) {
refundToCard(card.last4(), amount);
}
}payment instanceof Card cardTests whether
paymentis aCard. If it is, the test is true andcardis a new variable of typeCard, already cast. This is a type pattern, the same idea as C#'sis Card card.card.last4()Because
cardhas the typeCard, you can call its accessor straight away.last4()has brackets becauseCardis a record.
The variable exists only where the test is known to have passed. Usually that is inside the
if. It can also be the code after the if, when the if
leaves the method on failure. Here, only card payments may be refunded at all:
void RefundCardOnly(Payment payment, decimal amount)
{
if (payment is not Card card)
{
throw new ArgumentException("only card payments can be refunded");
}
RefundToCard(card.Last4, amount); // card can be used here
}void refundCardOnly(Payment payment, BigDecimal amount) {
if (!(payment instanceof Card card)) {
throw new IllegalArgumentException("only card payments can be refunded");
}
refundToCard(card.last4(), amount); // card can be used here
}!(payment instanceof Card card)Java has no
is not. You put the whole test in brackets and negate it with!.refundToCard(card.last4(), amount);The
ifthrows for anything that is not a card. So on this line the compiler knows thatpaymentis aCard, andcardis in scope. C# follows the same rule.
LegacyCast after instanceof
Before Java 16, a type test and a cast were two separate steps. Every Java codebase older than about 2021 is full of this:
if (payment instanceof Card) {
Card card = (Card) payment;
refundToCard(card.last4(), amount);
}The first line only tests the type. The second line declares the variable and casts,
naming Card twice more. It still compiles, but there is no reason to write it
now.
One test suits one kind of payment. When every kind needs its own handling, a
switch reads better than a chain of if statements.
Pattern matching in switch#
The receipt describes how the customer paid, and each kind of payment is described
differently. A payment can also be missing, when the order is not paid yet, and test cards,
whose last four digits are 0000, get their own wording. In
Interfaces, abstract classes and sealing, the fee method
switched over a payment's type. This switch also handles null and adds a
condition to one arm:
string Describe(Payment? payment) => payment switch
{
null => "not paid yet",
Card c when c.Last4 == "0000" => "a test card",
Card c => $"card ending {c.Last4}",
BankTransfer t => $"transfer from {t.Iban}",
Voucher v => $"voucher {v.Code}",
_ => throw new ArgumentException("unknown payment")
};String describe(Payment payment) {
return switch (payment) {
case null -> "not paid yet";
case Card c when c.last4().equals("0000") -> "a test card";
case Card c -> "card ending " + c.last4();
case BankTransfer t -> "transfer from " + t.iban();
case Voucher v -> "voucher " + v.code();
};
}case nullHandles a missing payment. Without this arm, a
nullpayment makes the switch throwNullPointerException, as the warning below explains.case Card c when c.last4().equals("0000")A type pattern with a when guard: the arm matches only if
paymentis aCardand the condition is true, as in C#. Strings are compared withequals, because Java's==only checks whether two variables point to the same object.case Card c -> "card ending " + c.last4();Arms are tried from top to bottom, so the guarded
Cardarm must come first. Put it after this one and the compiler reports that it can never match. C# reports the same error.
The two versions differ at the end. C# needs the _ arm, because nothing stops
another assembly from adding a fourth kind of payment. Java needs no default,
because Payment is sealed and lists exactly three kinds. If someone adds a fourth,
every switch over Payment stops compiling until it handles the new kind.
A switch on a reference type throws
NullPointerException unless you write case null. Java kept
this old behaviour so that existing code would not change meaning. A C#
switch expression lets a null arm or
the _ arm handle null, and throws only if no arm matches. In Java, decide for each
switch whether null can arrive. If it can, write case null, or
case null, default -> to send it to the fallback arm.
Java's patterns cover types, records and extra conditions. C# has three more kinds of pattern that Java does not.
Patterns Java does not have#
C# can match on an object's properties, on the shape of a list, and on a comparison such as
> 10. Java has none of these patterns, so you test the same things in a when
guard. Here the shop labels an order by its number of lines:
string Size(Order order) => order switch
{
{ Lines.Count: 0 } => "empty", // a property pattern
{ Lines: [_] } => "one line", // a list pattern: exactly one element
{ Lines.Count: > 10 } => "large", // a relational pattern (> 10)
_ => "normal"
};String size(Order order) {
return switch (order) {
case Order o when o.lines().isEmpty() -> "empty";
case Order o when o.lines().size() == 1 -> "one line";
case Order o when o.lines().size() > 10 -> "large";
case Order o -> "normal";
};
}case Order o when o.lines().isEmpty()The type pattern
Order omatches every order, so the when guard does all the testing. It reads the list witho.lines()and asks it directly.case Order o -> "normal";With no guard, this arm matches every order the others let through. That makes the switch complete, so it needs no
default.
When every arm is a guard like this, plain if statements work just as well, and
many Java developers would write them.
C# refresherC# positional, property, list and relational patterns
C# patterns can take a record apart by position, test properties by name, match the
shape of a list, and compare with < or > directly.
var where = point switch
{
(0, 0) => "origin", // positional pattern
{ X: 0 } => "on the y axis", // property pattern
_ => "elsewhere"
};
bool framed = numbers is [1, .., 9]; // list pattern: starts with 1, ends with 9
var feel = celsius switch { > 30 => "hot", < 0 => "freezing", _ => "mild" }; // relationalType patterns and when guards are enough for most code. The rest of this chapter covers record patterns, nested patterns, and one pattern that is still a preview.
Record patterns#
A type pattern gives you the whole object, and you then call its accessors. A record pattern Java 21 takes the record apart as it matches, putting each component into its own variable. Here is the receipt description again, written with record patterns:
string Describe(Payment payment) => payment switch
{
Card(var last4) => $"card ending {last4}", // a positional pattern
BankTransfer(var iban) => $"transfer from {iban}",
Voucher(var code) => $"voucher {code}",
_ => throw new ArgumentException("unknown payment")
};String describe(Payment payment) {
return switch (payment) {
case Card(var last4) -> "card ending " + last4;
case BankTransfer(var iban) -> "transfer from " + iban;
case Voucher(var code) -> "voucher " + code;
};
}case Card(var last4)Matches a
Cardand puts its one component into a new variable,last4.varlets the compiler work out the type, hereString, and you may writeString last4instead.case Voucher(var code) -> "voucher " + code;Components are matched by position, in the order the record declares them, as in C#'s positional pattern. The switch still needs no
default, becausePaymentis still sealed.
Nested patterns and unnamed variables#
A record pattern can hold other record patterns. The shop sells in euros, and a price check
warns about any order line priced in another currency. An order line holds a
Money, which is itself a record, so one pattern can reach inside both:
if (line instanceof OrderLine(var productId, _, Money(_, var currency))
&& !currency.equals("EUR")) {
System.out.println(productId + " is priced in " + currency);
}OrderLine(var productId, _, Money(_, var currency))Matches an
OrderLineand takes out itsproductId. It skips the quantity, then takes the unit price apart, skipping the amount and taking out thecurrency._An unnamed pattern Java 22: it matches any value and gives it no name, like C#'s discard. Use it for a component you do not need.
C# nests positional patterns in the same way:
line is OrderLine(var productId, _, Money(_, var currency)).
A nested record pattern never matches null. An order line whose
unitPrice is null does not match Money(_, var currency),
so the whole pattern fails and the check silently skips that line. A var
component is different: it accepts null, so productId can be null. If a null
matters, test for it separately.
What is still preview#
One more kind of pattern exists, but only as a
preview feature. A preview feature is finished
but not yet final, and it is switched off unless you ask for it.
Primitive types in patterns, instanceof and switch Java 25
preview lets a pattern test a number's value. For example,
instanceof int on a double is true only if the value converts to an
int with nothing lost.
Imagine quantities imported from a spreadsheet, where every number arrives as a
double:
// Preview in Java 25: compile and run with --enable-preview.
double quantity = 3.0; // for example, read from a spreadsheet
if (quantity instanceof int whole) { // matches: 3.0 converts to an int with no loss
System.out.println(whole + " items");
}
boolean exact = 2.5 instanceof int; // false: the conversion would lose the .5quantity instanceof int wholeTrue, because 3.0 fits in an
intexactly, sowholebecomes 3.2.5 instanceof intFalse, because converting 2.5 to an
intwould lose the .5.
This is the feature's third preview in Java 25. Do not use it in code you ship.
Anything marked preview in this book needs --enable-preview when
you compile and again when you run. Preview features are not covered by the usual
compatibility promises, and they can change or disappear in the next release.
Class files compiled with them refuse to load on any
other Java Development Kit (JDK) version.
The refund showed a type pattern: payment instanceof Card card tests the type
and gives you a typed variable in one step, like C#'s is.
The receipt's describe method switched over a payment with
case null, a when guard and no default. Because Payment
is sealed, the compiler checks that every kind is handled.
Java has no property, list or relational patterns, so the order-size check used when guards instead.
Record patterns take a record apart as they match, can be nested, and skip components with
_.
Quick reference#
| C# | Java | Note |
|---|---|---|
| is T x | instanceof T x | the same type test with a binding, spelled instanceof in Java |
| is not T x | !(o instanceof T x) | Java has no is not; put the test in brackets and negate it |
| switch expression arms | case T x -> | each arm is written case pattern -> result, not pattern => result |
| when clause | when clause | the same keyword for an extra condition, added in Java 21 |
| _ discard | default | Java uses default, or _ for unnamed variables |
| case null | case null | Java 21; before that, a switch on null always threw NullPointerException |
| positional pattern | record pattern | added in Java 21; works on records, taking them apart by component |
| property pattern { X: 1 } | no equivalent | test the property in a when clause instead |
| list pattern [1, .., 2] | no equivalent | no Java form; check the size and elements in ordinary code |
| relational pattern > 5 | only inside when | write case Integer i when i > 5 instead of a bare > 5 |
Switch expressions and statements#
Java has two kinds of switch. The newer arrow form Java 14 works like C#'s switch expression, and it is the one to write. The old colon form works like C#'s switch statement, except that one case can run on into the next.
The arrow form never runs on into the next case, can return a value, and must handle every possible value when it does.
The arrow form#
The warehouse packs orders on weekdays, and the order confirmation tells the customer when
their order will be packed. That depends on the day they placed it. Here day is a
DayOfWeek, an enum that both .NET and Java
provide:
DayOfWeek day = DayOfWeek.Friday;
var packing = day switch
{
DayOfWeek.Saturday or DayOfWeek.Sunday => "packed on Monday",
DayOfWeek.Friday => "packed today, delivered next week",
_ => "packed today"
};import java.time.DayOfWeek;
DayOfWeek day = DayOfWeek.FRIDAY;
var packing = switch (day) {
case SATURDAY, SUNDAY -> "packed on Monday";
case FRIDAY -> "packed today, delivered next week";
default -> "packed today";
};switch (day) {The value being tested goes in brackets after
switch. C# puts it before the keyword:day switch.case SATURDAY, SUNDAY -> "packed on Monday";Each arm starts with
case, and an arrow->separates the labels from the result, where C# writes=>. Two labels share this arm, separated by a comma where C# writesor. Enum constants are usually written without the type name:SATURDAY, notDayOfWeek.SATURDAY.default -> "packed today";The fallback arm, like C#'s
_.
The whole switch produces a value, so the statement ends with a semicolon after the closing brace, as in C#. Some arms need more than one step to work out their value, and that needs a block.
Multi-statement arms#
In Interfaces, abstract classes and sealing, a card payment cost a
flat fee of 0.30. Suppose the fee changes to 1.4 percent of the order total, but never less
than 0.30. The card arm now needs two steps. C# switch expression arms cannot hold statements,
so C# moves the steps into a method. Java puts them in a block that ends with
yield:
decimal Fee(Payment payment, decimal total) => payment switch
{
Card => CardFee(total),
BankTransfer or Voucher => 0m,
_ => throw new ArgumentException("unknown payment")
};
decimal CardFee(decimal total) => Math.Max(total * 0.014m, 0.30m);BigDecimal fee(Payment payment, BigDecimal total) {
return switch (payment) {
case Card c -> {
BigDecimal percentFee = total.multiply(new BigDecimal("0.014"));
yield percentFee.max(new BigDecimal("0.30"));
}
case BankTransfer t -> BigDecimal.ZERO;
case Voucher v -> BigDecimal.ZERO;
};
}case Card c -> {The arm's result is a block in braces instead of a single expression.
yield percentFee.max(new BigDecimal("0.30"));yieldends the block and gives the arm its value, here the larger of the two fees. Areturnhere would try to leave the whole method, and the compiler rejects it.
Java's yield only hands a value out of a switch block. It has nothing to do
with C#'s yield return, which produces a sequence one value at a time. Java has no
yield return at all: see Streams vs LINQ for what
replaces it.
C# refresheryield return: C# iterators
A C# method with yield return produces a sequence lazily: each value is
handed back when the caller asks for it, and the method pauses until the next request.
IEnumerable<int> Evens(int max)
{
for (var i = 0; i <= max; i += 2)
yield return i; // hand back one value, then pause here
}
foreach (var n in Evens(6)) Console.Write(n); // 0246A switch that returns a value must handle every possible value. When the value is an enum or a sealed type, the compiler can check that for you.
Exhaustiveness#
Every order status needs a label on the order page, and a new status must never slip
through without one. OrderStatus has four constants, and this switch handles each
of them:
enum OrderStatus { NEW, PAID, SHIPPED, CANCELLED }
// No default: all four constants are covered.
// Add a fifth constant and this method stops compiling.
String label(OrderStatus status) {
return switch (status) {
case NEW -> "waiting for payment";
case PAID -> "being packed";
case SHIPPED -> "on its way";
case CANCELLED -> "cancelled";
};
}enum OrderStatus { NEW, PAID, SHIPPED, CANCELLED }An enum with four constants. It works like a C# enum; Enums covers the differences.
case CANCELLED -> "cancelled";The last of the four constants. With all four covered, the switch needs no
default.
A sealed type follows the same rule: every permitted type needs an arm, as the payment switches in Sealed types and pattern matching showed.
Leaving out default on purpose is a useful habit. If someone adds a constant
and forgets this switch, the build fails, instead of the program misbehaving at run time. C#
cannot do this. Its switch expression over an enum always wants a discard arm to avoid a
warning, so adding an enum member never breaks the build.
The arrow form is the one to write. Older code uses the colon form, which you still need to be able to read.
The old statement form#
LegacyColon switch with fall-through
The colon form is still legal and still common, and it causes a classic bug: a missing
break. Learn to recognise it, but do not write it:
switch (status) {
case NEW:
sendPaymentReminder();
// no break: falls through into PAID. Intended? Nobody can tell.
case PAID:
reserveStock();
break;
default:
throw new IllegalStateException("unexpected status " + status);
}case NEW:A colon label. The statements after it run until a
break.falls through into PAIDThere is no
breakaftersendPaymentReminder(), so a new order also runsreserveStock(). C# reports an error for this, but Java compiles it without a word.break;Leaves the switch. Forget it, and execution carries on into the next label.
The arrow form cannot fall through at all, which removes this bug entirely. When several
labels need the same action, list them: case NEW, PAID ->.
Java has no goto. C# code mostly uses goto to leave nested loops,
and Java does that with a labelled break. Here, orders is a
List<Order>, and the scan stops at the first order line with a quantity of
zero:
foreach (var order in orders)
foreach (var line in order.Lines)
if (line.Quantity == 0)
goto done;
done:
Console.WriteLine("scan finished");scan:
for (var order : orders)
for (var line : order.lines())
if (line.quantity() == 0)
break scan; // leaves both loops
System.out.println("scan finished");scan:A label: a name followed by a colon, placed just before the outer loop.
for (var order : orders)Java's
foreach. It is spelledfor, with a colon where C# writesin.break scan;Leaves the loop labelled
scan, and the inner loop with it. A plainbreakwould leave only the inner loop.continue scan;also works, and moves on to the next order.
What you can switch on#
The arrow form works on more types than the old switch did, but not on every type. Here is what a switch accepts:
| Type | Java | Note |
|---|---|---|
| int, short, char, byte | yes | the original switch types, allowed since Java 1.0 |
| String | yes | allowed since Java 7; labels are compared with equals, not == |
| enum | yes | allowed since enums arrived in Java 5; write the bare constant name |
| sealed interface / class | yes | since Java 21, with a type pattern for each permitted subtype |
| any Object | yes | since Java 21, with type patterns and usually a default arm |
| long, float, double, boolean | no | use if/else, or preview primitive patterns |
Strings work as labels. The shop sorts uploaded product files by their extension, where
extension holds text such as "png":
String kind = switch (extension) {
case "jpg", "png" -> "image"; // strings are compared with equals()
case "pdf" -> "document";
default -> "other";
};Each label is compared with equals, so "png" matches any string
with those three letters. Unlike ==, it does not matter whether the two strings are
the same object.
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.
The packing message showed the arrow form: the value goes in brackets after
switch, each arm is case label -> result, and labels that share an
arm are separated by commas.
The card fee showed a block arm, which hands back its value with yield, a
keyword unrelated to C#'s yield return.
The order-status labels covered every OrderStatus constant with no
default, so adding a constant breaks the build instead of the program.
The old colon form falls through when a break is missing, so read it but do not
write it. A labelled break leaves nested loops where C# would use
goto.
Quick reference#
| C# | Java | Note |
|---|---|---|
| value switch { ... } | switch (value) { ... } | the value goes in brackets after the keyword |
| pattern => result | case pattern -> result | the word case, and an arrow instead of => |
| or between patterns | comma between labels | case SATURDAY, SUNDAY -> "packed on Monday"; |
| _ | default | the fallback arm, used when no other arm matches |
| throw in an arm | throw in an arm | the same in both languages: an arm can throw instead of giving a value |
| must be exhaustive | must be exhaustive | over an enum or a sealed type, Java checks it and needs no default |
| Card => CardFee(total) | case Card c -> { ...; yield fee; } | C# arms cannot hold statements; a Java block arm ends with yield |
| goto done; | break scan; | leaves the labelled loop and every loop inside it |
var, strings and text blocks#
var Java 10 works as it does in C#: the compiler works out a
local variable's type from its value. Text blocks Java 15 are Java's version of
C#'s raw string literals.
The real loss is string interpolation. Java has none, so you join strings
with + or fill in a template with formatted. And == on
strings compares objects rather than text, so you compare strings with
equals.
var#
The order service often creates a list of order lines and then loops over them. Writing the
type on both sides of = is repetitive, and var removes one of them, in
both languages:
var lines = new List<OrderLine>();
var count = 0;
foreach (var line in lines) { count += line.Quantity; }var lines = new ArrayList<OrderLine>();
var count = 0;
for (var line : lines) { count += line.quantity(); }var lines = new ArrayList<OrderLine>();The compiler reads the value on the right and gives
linesthe typeArrayList<OrderLine>. The type is fixed from then on, exactly as in C#.ArrayListis Java'sList<T>.for (var line : lines)varworks in loops too. Java's for-each loop is spelledfor, with a colon where C# writesin.
Java allows var in the same places as C#, with one small difference for lambda
parameters:
| 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 with an empty diamond gives you a list of Object.
The diamond, <>, asks the compiler to work out the type argument from the
variable's declared type. With var there is no declared type to work from, so the
compiler picks Object:
var mixed = new ArrayList<>(); // ArrayList<Object>: almost never what you meant
var lines = new ArrayList<OrderLine>(); // what you meantThe first line compiles, and then lets you add anything at all to mixed. When
you use var, write the type argument. Or declare the type and keep the diamond:
List<OrderLine> lines = new ArrayList<>();.
With variables settled, the next step is building text from them, and that is where Java differs most from C#.
No string interpolation#
The order confirmation greets the customer and states the total. In C# you build that
sentence with string interpolation. Java has none, so you join the pieces with +,
or fill in a template. Here name is a String, orderId a
long and total a BigDecimal:
var greeting = $"Hello {name}, your order {orderId} comes to {total:F2} EUR";var greeting = "Hello " + name + ", your order " + orderId + " comes to " + total + " EUR";
var formatted = "Hello %s, your order %d comes to %.2f EUR".formatted(name, orderId, total);"Hello " + name + ", your order "Plain joining with
+. It turnsorderIdandtotalinto text for you, and the compiler makes it efficient..formatted(name, orderId, total)Fills in a template, like C#'s
string.Format.%stakes any value as text,%dtakes a whole number, and%.2fa decimal number with two places. The values fill the placeholders in order.
There is no $"..." in Java, and none is planned for 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. Code from tutorials that used them no longer compiles.
Here are the replacements, and when to use each one:
// Joining with +: fine for a few pieces, and the compiler makes it efficient.
String opening = "Hello " + name + ", your order " + orderId;
// formatted or String.format: when you need a fixed number of decimal places.
String totalLine = "Total: %.2f EUR".formatted(total);
String amount = String.format("%,.2f", total); // 1,234.50 with an English locale
// StringBuilder: in a loop, or when building something large.
var sb = new StringBuilder();
for (var line : lines) sb.append(line.productId()).append(',');
// A text block plus formatted: for text that spans several lines.
String email = """
Hello %s,
Your order %d has shipped.
""".formatted(name, orderId);String.format("%,.2f", total)The same template method in its older form, which every Java version has. The comma in
%,.2fadds thousands separators.sb.append(line.productId()).append(',');appendreturns the builder, so calls chain, just as they do on C#'sStringBuilder.""".formatted(name, orderId);A text block is an ordinary
String, soformattedworks on it too. Text blocks are explained later in this chapter.
Most everyday string methods keep their C# names, starting with a lower-case letter. Here
are the ones you will reach for first. productId, note and
total are single values, and productIds is a list of strings:
var label = string.Format("{0} x {1}", productId, quantity);
var csv = string.Join(", ", productIds);
var noNote = string.IsNullOrWhiteSpace(note);
var isGift = note.Contains("gift");
var shown = $"{total:F2}";var label = String.format("%s x %s", productId, quantity);
var csv = String.join(", ", productIds);
var noNote = note == null || note.isBlank();
var isGift = note.contains("gift");
var shown = "%.2f".formatted(total);String.format("%s x %s", productId, quantity)Java's placeholders are filled in order, where C# numbers them
{0}and{1}.note == null || note.isBlank()Java has no
IsNullOrWhiteSpace.isBlank()checks for empty or whitespace-only text, but calling it onnullthrows, so the null check comes first.
One more everyday operation behaves differently: checking whether two strings are equal.
Comparing strings#
A customer types a voucher code, and the code upper-cases it before checking it against
"SPRING". In C# you compare the two with ==. In Java,
== compiles too, but it asks a different question:
string typed = "spring".ToUpper(); // what the customer typed, upper-cased
bool equal = typed == "SPRING"; // True: string's == compares the textimport java.util.Objects;
String typed = "spring".toUpperCase(); // what the customer typed, upper-cased
boolean same = typed == "SPRING"; // false: two different objects
boolean equal = typed.equals("SPRING"); // true: the same text
boolean safe = Objects.equals(typed, "SPRING"); // true, and no exception if typed is nulltyped == "SPRING"In Java,
==on objects asks whether both sides are the same object.typedwas built while the program ran, so it is a different object from the literal"SPRING", even though the text matches.typed.equals("SPRING")Compares the text. This is what C#'s
==does for strings.Objects.equals(typed, "SPRING")Also compares the text, and returns
falseinstead of throwing whentypedis null.
C# refresherString == in C#: an operator overload that compares contents
string a = "abc";
string b = new string("abc".ToCharArray()); // a different object, same characters
Console.WriteLine(a == b); // True: string overloads == to compare text== on strings compares references, not contents. It often
appears to work, because the Java Virtual Machine (JVM) reuses
one object for identical string literals, so "abc" == "abc" is true. A string built
while the program runs compares false against the same literal. This is the most common
beginner bug in Java, and there is no operator overload to rescue you as there is in C#.
Short strings are now covered. Longer text, such as a JSON document, needs a different kind of literal.
Text blocks#
The shop sends orders to a partner as JSON, and a test needs a sample document. Written as an ordinary string, every quote would need a backslash. Both languages have a multi-line literal for this:
var json = """
{
"orderId": 1042,
"status": "PAID"
}
""";String json = """
{
"orderId": 1042,
"status": "PAID"
}
""";String json = """Three quotes followed by a line break open the text block. Java requires the line break, so the text starts on the next line.
""";The closing quotes. The indentation that every line shares, here four spaces, is removed, so the text starts at the brace. The closing quotes count as a line too: move them further left, and the indentation is kept.
The syntax is the same three quotes, and both languages remove the shared indentation. The differences are in the details:
| 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 such as \n. That matters
for regular expressions 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 makes them better for regular expressions. In Java, use a text block
for SQL, JSON and HTML, and accept the doubled backslashes in a regular expression.
Regular expressions are where the missing raw string hurts most, as the next section shows.
Regular expressions#
Java's Pattern and Matcher replace .NET's Regex. The
pattern syntax is almost the same, but the work is split between two objects, and every
backslash is doubled. Here the shop splits a phone number such as 555-0199 into its first three
digits and its last four. phone holds one number, and notes holds
text that may contain several:
var re = new Regex(@"(?<area>\d{3})-(?<num>\d{4})");
var m = re.Match(phone);
if (m.Success)
{
var area = m.Groups["area"].Value;
}
var all = re.Matches(notes);
var hidden = re.Replace(phone, "$1-XXXX");import java.util.regex.*;
Pattern re = Pattern.compile("(?<area>\\d{3})-(?<num>\\d{4})");
Matcher m = re.matcher(phone);
if (m.find()) {
String area = m.group("area");
}
List<MatchResult> all = re.matcher(notes).results().toList();
String hidden = re.matcher(phone).replaceAll("$1-XXXX");Pattern.compile("(?<area>\\d{3})-(?<num>\\d{4})")Compiles the pattern once. Every backslash is written twice, because in a Java string
\\stands for one backslash. Named groups use the same(?<name>...)syntax as .NET.re.matcher(phone)A
Matcherapplies the pattern to one piece of text and remembers how far it got, so you make a new one for each input.m.find()Searches for the pattern anywhere in the text, like C#'s
Matchfollowed bySuccess.m.group("area")Reads a named group, like
Groups["area"].Value.results().toList()Every match, like C#'s
Matches.results()returns a stream, Java's version of a LINQ sequence, andtoList()collects it into a list. Streams vs LINQ covers streams.
Options and the other everyday methods look like this. A customer's note may
mention product codes such as SKU-1042, typed in any mix of upper and lower case:
var sku = new Regex(@"sku-\d+",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
bool mentionsProduct = sku.IsMatch(note);
string[] pieces = sku.Split(note); // the text between the codesPattern sku = Pattern.compile("sku-\\d+",
Pattern.CASE_INSENSITIVE); // always compiled
boolean mentionsProduct = sku.matcher(note).find(); // not matches()
String[] pieces = sku.split(note); // the text between the codesPattern.CASE_INSENSITIVEOptions are constants on
Pattern, passed as the second argument. There is no option for compiling, because everyPatternis compiled.sku.matcher(note).find()Java's
IsMatch. Usefind(): the similar-lookingmatches()succeeds only if the whole text matches.
Here is the full mapping from .NET's Regex:
| .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() | added in Java 9; returns a Stream of MatchResult, one per match |
| m.Groups["name"] | m.group("name") | named groups use the same (?<name>...) syntax in both |
| m.Groups[1] | m.group(1) | group(0) is the whole match in both |
| re.Replace(s, r) | m.replaceAll(r) | the replacement refers to a group as $1 in both languages |
| re.Split(s) | p.split(s) | or String.split, which compiles each call |
| RegexOptions.IgnoreCase | Pattern.CASE_INSENSITIVE | pass it as the second argument to Pattern.compile |
| RegexOptions.Compiled | not needed | every Pattern is compiled once, so there is no option to set |
| @"verbatim" | no equivalent | every backslash must be doubled |
Two traps, both caused by the missing verbatim string.
Every backslash is doubled. \d in a .NET verbatim string is
"\\d" in Java. A text block does not help, because it still processes escapes.
This is the one place where C# raw strings are clearly better, and there is no way round
it.
matches() is not IsMatch.
Matcher.matches() requires the entire text to match, while find()
searches for the first occurrence. Reaching for the familiar-looking name is a common bug, and
it fails silently.
A Pattern never changes and is safe to share between threads, so compile it
once into a private static final field. A Matcher is neither, so
create one each time. String.matches, String.split and
String.replaceAll compile the pattern again on every call. That is fine now and
then, and expensive in a loop.
Concatenation performance#
Do not reach for StringBuilder out of habit. Since Java 9, the compiler turns
a + b + c into a single call that works out the final length and builds the string
once. StringBuilder is still the right tool inside a loop, where the compiler
cannot join the pieces for you. For a fixed number of pieces, plain + is both
clearer and faster.
Here are the two cases side by side, using the order's lines:
// A fixed number of pieces: plain + builds the string once.
String summary = "Order " + orderId + " has " + count + " lines";
// A loop: use a StringBuilder, because += would build a whole new string each time round.
var sb = new StringBuilder();
for (var line : lines) sb.append(line.productId()).append('\n');In the loop, text += line.productId() would copy everything built so far on
every pass, so the work grows much faster than the number of lines. sb.append adds
to the same builder in place, as it does in C#.
var lines = new ArrayList<OrderLine>() took its type from the value,
exactly as in C#, but var with an empty diamond gives a list of
Object.
Java has no string interpolation, so the order confirmation was built with +, or
with formatted and placeholders such as %s.
The voucher code showed that strings are compared with equals, because
== asks whether two strings are the same object.
Text blocks are Java's raw string literals, but they still process escapes, so a regular expression still needs doubled backslashes.
Quick reference#
| C# | Java | Note |
|---|---|---|
| $"{a} and {b}" | a + " and " + b | or "%s and %s".formatted(a, b) when there are many values |
| $"{x:F2}" | "%.2f".formatted(x) | Java uses printf-style specifiers such as %.2f, %d and %s |
| string.Format | String.format | positional %s placeholders instead of {0} and {1} |
| StringBuilder | StringBuilder | the same class and methods, used the same way |
| string.Join(",", xs) | String.join(",", xs) | same arguments: a separator, then the items |
| string.IsNullOrEmpty(s) | s == null || s.isEmpty() | the null check is yours; isEmpty() alone throws on null |
| string.IsNullOrWhiteSpace(s) | s == null || s.isBlank() | isBlank() arrived in Java 11 and ignores whitespace, like the C# method |
| s.Contains(t) | s.contains(t) | same name and meaning; both throw if t is null |
| s == t | s.equals(t) | == compares references and silently fails on runtime strings |
Enums#
A C# enum is a named number. A Java enum is a class with a fixed set of objects, one for each constant, so a constant can carry fields and methods of its own.
You lose three things: choosing the numbers, [Flags] enums, and casting a
number to an enum. Java gives you EnumSet and EnumMap in their
place.
The shape of the difference#
An order moves through four statuses: new, paid, shipped and cancelled. In C#, each status is a name for a number, and you can choose the numbers. In Java, each status is an object:
public enum OrderStatus
{
New = 1,
Paid = 2,
Shipped = 3,
Cancelled = 4
}
var status = (OrderStatus) 2; // Paid
int number = (int) OrderStatus.Paid; // 2public enum OrderStatus {
NEW, PAID, SHIPPED, CANCELLED
}
OrderStatus status = OrderStatus.valueOf("PAID");
int position = OrderStatus.PAID.ordinal(); // 1, but see the warning belowNEW, PAID, SHIPPED, CANCELLEDThe constants, written in capitals by convention. There are no numbers to choose: each constant is a single object of type
OrderStatus, created when the enum is first used.OrderStatus.valueOf("PAID")Looks a constant up by its name, like C#'s
Enum.Parse. Java has no cast from a number to an enum.OrderStatus.PAID.ordinal()The constant's position in the list, counting from 0. It is not a value you chose, so do not rely on it.
Never persist ordinal(). It is the position in the
declaration, so adding a constant quietly renumbers every constant after it. Suppose someone
adds ON_HOLD between NEW and PAID: every order stored as
1 turns from paid into on hold. Store name() instead, or better, a code field you
control, as the next section shows.
Because each constant is an object, it can carry data. That is where Java enums leave C# behind.
Enums with state and behaviour#
The order page shows a label for each status, and the database stores a one-letter code. In
C# you would keep the labels in a separate switch or dictionary, or attach them
with a [Description] attribute. A Java enum holds them itself:
public enum OrderStatus {
NEW("N", "waiting for payment"),
PAID("P", "being packed"),
SHIPPED("S", "on its way"),
CANCELLED("C", "cancelled");
private final String code;
private final String label;
OrderStatus(String code, String label) { // an enum constructor is always private
this.code = code;
this.label = label;
}
public String code() { return code; }
public String label() { return label; }
}
String shown = OrderStatus.SHIPPED.label(); // "on its way"NEW("N", "waiting for payment"),Each constant passes arguments to the constructor. The list of constants ends with a semicolon, because more code follows.
private final String code;An ordinary field. It is a final field, set once in the constructor, so a constant's code never changes.
OrderStatus(String code, String label)The constructor. It is always private, even without the keyword, so no code outside the enum can create a fifth status.
OrderStatus.SHIPPED.label()Calls a method on a constant, as on any object.
Storing code() in the database fixes the ordinal() problem: a new
constant gets a new code, and the existing codes stay as they are.
Constants with their own behaviour#
A constant can go one step further and supply its own version of a method. The shop offers two delivery options. Standard delivery costs 4.95, or nothing for orders of 50 or more, and express delivery always costs 9.95:
public enum Delivery {
STANDARD {
public BigDecimal price(BigDecimal total) {
return total.compareTo(new BigDecimal("50")) >= 0 ? BigDecimal.ZERO : new BigDecimal("4.95");
}
},
EXPRESS {
public BigDecimal price(BigDecimal total) {
return new BigDecimal("9.95");
}
};
public abstract BigDecimal price(BigDecimal total);
}
BigDecimal cost = Delivery.STANDARD.price(new BigDecimal("62.00")); // 0: free from 50public abstract BigDecimal price(BigDecimal total);Declares a method with no body, so every constant must supply one.
STANDARD {A constant with a body of its own, which supplies
pricefor standard delivery.total.compareTo(new BigDecimal("50")) >= 0BigDecimalhas no>=operator.compareToreturns a negative number, zero or a positive number, so>= 0means the total is at least 50.
C# enums cannot hold methods, so this usually becomes a switch, or a small class
for each option. A Java enum keeps the whole closed set of options, and each option's rule, in
one place.
Everyday work with enums is parsing them from text, listing them, and using them as keys. That looks different in each language.
Everyday enum operations#
An order status can arrive as text in a request, such as "PAID". Here
text holds that string, and counts will count orders by status:
var status = Enum.Parse<OrderStatus>("Paid");
bool ok = Enum.TryParse<OrderStatus>(text, out var parsed);
OrderStatus[] all = Enum.GetValues<OrderStatus>();
string name = OrderStatus.Paid.ToString(); // "Paid"
var counts = new Dictionary<OrderStatus, int>();import java.util.EnumMap;
var status = OrderStatus.valueOf("PAID"); // throws if unknown
OrderStatus parsed;
try {
parsed = OrderStatus.valueOf(text);
} catch (IllegalArgumentException e) {
parsed = null; // not a known status
}
OrderStatus[] all = OrderStatus.values(); // a new array each call
String name = OrderStatus.PAID.name(); // "PAID"
var counts = new EnumMap<OrderStatus, Integer>(OrderStatus.class);OrderStatus.valueOf("PAID")Finds the constant by its exact name. There is no option to ignore case, and an unknown name throws
IllegalArgumentException.catch (IllegalArgumentException e)Java has no
TryParse, so you catch the exception thatvalueOfthrows.OrderStatus.values()Every constant in declaration order, like
Enum.GetValues. It returns a new array on every call, so keep it in a field if you call it often.new EnumMap<OrderStatus, Integer>(OrderStatus.class)A map whose keys are enum constants. It keeps its values in an array indexed by position, so it is faster than an ordinary map.
OrderStatus.classis the type itself as a value, like C#'stypeof(OrderStatus), andEnumMapneeds it.
Java has no flags enums and no bitwise arithmetic on enums. An order can need gift wrapping,
express handling and careful packing, in any combination. In C# that is a
[Flags] enum. In Java it is an EnumSet, which stores one bit per
constant inside, so it is just as fast, and easier to read:
[Flags]
enum Handling { None = 0, GiftWrap = 1, Express = 2, Fragile = 4 }
var handling = Handling.GiftWrap | Handling.Fragile;
if (handling.HasFlag(Handling.Fragile)) { }import java.util.EnumSet;
enum Handling { GIFT_WRAP, EXPRESS, FRAGILE }
var handling = EnumSet.of(Handling.GIFT_WRAP, Handling.FRAGILE);
if (handling.contains(Handling.FRAGILE)) { }EnumSet.of(Handling.GIFT_WRAP, Handling.FRAGILE)A set holding two constants, where C# combines two flags with
|.EnumSet.noneOf(Handling.class)is the empty set, C#'sNone.handling.contains(Handling.FRAGILE)Java's
HasFlag.
Enums and switch#
Switch expressions showed a switch over
OrderStatus that needs no default, because it covers every constant.
Here it is again, to show how the labels are written:
String label = switch (status) {
case NEW -> "waiting for payment";
case PAID -> "being packed";
case SHIPPED -> "on its way";
case CANCELLED -> "cancelled";
};The four labels, NEW, PAID, SHIPPED and
CANCELLED, cover every constant, so the switch needs no default.
Inside a case label you write the bare constant, NEW, not
OrderStatus.NEW. Before Java 21 the qualified form was a compile error. Since Java
21 both compile, and the bare name is still the convention you will see.
The enum singleton#
An enum with one constant has exactly one object. The Java Virtual Machine (JVM) guarantees that, even when objects are saved and loaded again or created through reflection. That makes a one-constant enum the most robust way to write a singleton in Java. Here the shop keeps one shared table of exchange rates:
public enum ExchangeRates {
INSTANCE;
private final Map<String, BigDecimal> rates = new ConcurrentHashMap<>();
public void put(String currency, BigDecimal rate) { rates.put(currency, rate); }
public BigDecimal get(String currency) { return rates.get(currency); }
}
ExchangeRates.INSTANCE.put("USD", new BigDecimal("1.08"));INSTANCE;The only constant, so there is exactly one
ExchangeRatesobject in the program.new ConcurrentHashMap<>()A map that several threads can use at once, like C#'s
ConcurrentDictionary.
In an application that uses Spring you would make this a bean instead: an object that Spring creates once and hands to every class that needs it. The enum idiom is still worth recognising in library code.
OrderStatus showed that a Java enum constant is an object, not a number: you
look one up with valueOf, and ordinal() is only its position, so
never store it.
Because constants are objects, OrderStatus carried a code and a label in final
fields set by a private constructor, and each Delivery constant supplied its own
price.
Java has no TryParse, no cast from a number and no [Flags], so you
catch the exception from valueOf and use EnumSet and
EnumMap.
Quick reference#
| C# | Java | Note |
|---|---|---|
| Enum.Parse<T>(s) | Status.valueOf(s) | throws IllegalArgumentException if unknown |
| Enum.TryParse | no equivalent | catch IllegalArgumentException from valueOf, or keep a Map of names |
| Enum.GetValues<T>() | Status.values() | returns a new copy of the array on every call, so cache it if hot |
| (int) e | e.ordinal() | the declaration position; never store it, it shifts |
| (Status) 2 | no equivalent | no cast from int; index into values() if you really must |
| e.ToString() | e.name() | name() is always the constant as declared; toString() can be overridden |
| [Flags] | EnumSet | no bitwise enums; EnumSet is a bit vector underneath |
| [Description] attribute | a field on the enum | give the enum a constructor argument, such as a label |
| Dictionary keyed by enum | EnumMap | a map backed by an array indexed by ordinal, so lookups are fast |
Generics and type erasure#
Java generics look like C#'s, with one decisive difference: Java erases type
arguments when it compiles. While the program runs, List<String> and
List<Integer> are the same class. C#'s runtime, by contrast, knows every
type argument.
So Java has no typeof(T), no new T(), no type test against
List<String>, and no List<int>. Variance is written where
a type is used, not where it is declared.
Declaration#
Everything the shop stores has an id, and a simple in-memory repository can store any type
that has one. In both languages the class takes a type parameter, T, with a
constraint that says T must have an id:
public interface IHasId { long Id { get; } }
public class InMemoryRepository<T> where T : IHasId
{
private readonly Dictionary<long, T> items = new();
public void Save(T item) => items[item.Id] = item;
public T? Find(long id) => items.GetValueOrDefault(id);
}public interface HasId { long id(); }
public class InMemoryRepository<T extends HasId> {
private final Map<Long, T> items = new HashMap<>();
public void save(T item) { items.put(item.id(), item); }
public T find(long id) { return items.get(id); }
}public class InMemoryRepository<T extends HasId>The type parameter follows the class name, as in C#. The constraint goes inside the angle brackets with
extends, where C# writeswhere T : IHasIdafter the class. Java usesextendsfor interfaces too.Map<Long, T>The key type is
Long, notlong, because a type argument cannot be a primitive type. The last sections of this chapter explain why.items.put(item.id(), item);Because
T extends HasId, the compiler lets you callid()on anyT.return items.get(id);getreturnsnullwhen the id is missing, likeGetValueOrDefault.
So far the two languages match. The difference appears when the program runs.
What erasure takes away#
Java checks type arguments when it compiles, and then removes them. This is called
type erasure. While the program runs, an InMemoryRepository<Customer>
is a plain InMemoryRepository, and inside it T is treated as
HasId, its constraint. So anything that needs to know T at run time
does not compile. Here x is any object:
None of these compile in Java, and all of them work in C#:
class Box<T> {
void tryThings(Object x) {
T t = new T(); // no: T is unknown at run time
T[] a = new T[10]; // no: nor can an array of T be created
boolean b = x instanceof List<String>; // no: a list does not know its element type
Class<T> c = T.class; // no: T has no class literal
}
static int count; // one count shared by every Box, whatever T is
}T t = new T();C# allows this with a
new()constraint. Java has noTat run time, so it has nothing to create.x instanceof List<String>At run time a
List<String>is just aList, so this test cannot be made.x instanceof List<?>compiles, because it only asks whetherxis a list.static int count;A static field belongs to the class, and after erasure there is only one class. In C#,
Box<int>andBox<string>each get their owncount.
The workaround is to pass the type in yourself. That is why Java APIs are full of
Class<T> parameters where C# would use typeof(T). Here
mapper is the ObjectMapper of Jackson,
the JSON library most Java services use, and json holds one customer:
// typeof(T) is available here: T exists at run time
T Read<T>(string json) =>
JsonSerializer.Deserialize<T>(json)!;
var customer = Read<Customer>(json);<T> T read(String json, Class<T> type) {
return mapper.readValue(json, type);
}
var customer = read(json, Customer.class);Class<T> typeThe caller passes the type as a value.
Class<T>is Java's version of C#'sType, but it carries its type argument, so the compiler knows that the method returns aT.read(json, Customer.class)Customer.classis the type as a value, liketypeof(Customer).mapper.readValue(json, type)Jackson needs the class to know what to create. With Jackson 2, which Spring Boot 3 uses,
readValuealso declares a checked exception, so this method must addthrows JsonProcessingException. Jackson 3, which Spring Boot 4 uses, does not.
This is why getBean(OrderService.class) in Spring,
readValue(json, Customer.class) in Jackson and
mock(OrderRepository.class) in Mockito all take a .class value. Once
you see erasure as the reason, the style of the whole ecosystem makes sense.
C# refresherdefault(T) and dynamic
default gives a type parameter's zero value; dynamic defers member
lookup to run time.
T FirstOrZero<T>(List<T> xs) => xs.Count > 0 ? xs[0] : default; // null, 0 or false
dynamic x = "hello";
int n = x.Length; // resolved at run time, with no compile-time checkJava has neither. A type parameter always stands for a reference type, so the only default
it can have is null. For late binding, Java uses reflection, or a
Map<String, Object>.
Erasure also explains the last big difference: how Java expresses variance.
Variance is at the use site#
A receipt printer should accept any list of payments, including a list that holds only
cards. C# declares once, on the interface, that an IEnumerable of cards may be
used as an IEnumerable of payments. Java says it at the point of use, with a
wildcard:
// Declared once, on the interface: IEnumerable<out T>.
// Contravariance is declared the same way: IComparer<in T>.
void PrintAll(IEnumerable<Payment> payments)
{
foreach (var p in payments) Console.WriteLine(p);
}
List<Card> cards = new() { new Card("4242") };
PrintAll(cards); // fine: IEnumerable<Card> converts to IEnumerable<Payment>// Written where the type is used: List<? extends Payment>.
void printAll(List<? extends Payment> payments) {
for (var p : payments) System.out.println(p);
}
List<Card> cards = List.of(new Card("4242"));
printAll(cards); // fine: a List<Card> is a List<? extends Payment>List<? extends Payment> paymentsA list of
Payment, or of any type that implements it. Reading gives you aPayment. Adding is not allowed, because the list might really be aList<Card>, and aVoucherdoes not belong in it.printAll(cards);Without the wildcard,
printAll(List<Payment>)would reject aList<Card>. A JavaList, like a C#List<T>, does not convert on its own, and the wildcard is how you allow it.
The other direction, C#'s in, is ? super. This method adds test
cards to any list that can hold cards:
void addTestCards(List<? super Card> target) {
target.add(new Card("0000"));
}
List<Payment> payments = new ArrayList<>();
addTestCards(payments); // fine: a List<Payment> can hold cardsList<? super Card> targetA list of
Card, or of any type above it, such asPaymentorObject. You can add aCard, but reading gives you only anObject, because the list might hold other payments too.
Here are the four forms a list type can take:
| Wildcard | Means | You can | Mnemonic |
|---|---|---|---|
| List<String> | exactly String; a List<Object> is not accepted | read and write String | invariant |
| List<? extends Number> | a list of Number or of any subtype, such as Integer | read as Number, cannot add | producer |
| List<? super Integer> | a list of Integer or of any supertype, such as Number | add Integer, read as Object | consumer |
| List<?> | a list of something; you only know each element is an Object | read as Object, cannot add | any |
Java developers remember this as PECS: Producer Extends, Consumer Super. If
a parameter produces values for you to read, use ? extends. If it consumes values
you supply, use ? super. It is exactly C#'s out and in,
moved to where the type is used.
The rest of this chapter covers what erasure costs for numbers, and the tricks libraries use to get type arguments back.
No primitive type arguments#
A type argument must be a class, so List<int> is not legal Java. You
write List<Integer>, and each int is wrapped in an
Integer object, which is called boxing:
List<Integer> quantities = new ArrayList<>();
quantities.add(42); // boxed: stored as an Integer object
int[] raw = new int[1_000_000]; // one block of ints, no objects
int total = IntStream.of(raw).sum(); // a stream of plain ints: nothing is boxedquantities.add(42);42is anint, so Java wraps it in anIntegerbefore storing it, as C# boxes a value it stores as anobject.IntStream.of(raw).sum()IntStreamis a stream built forint, so the numbers stay plain. Streams vs LINQ covers streams.
Boxing has a real cost in memory and speed. A List<Integer> of a million
entries holds a million separate objects on the heap, plus
an array that points to them. C#'s List<int> is one block of ints.
The workarounds are int[] for storage, and the streams built for numbers,
IntStream, LongStream and DoubleStream, for processing.
Project Valhalla aims to fix this properly, but it is not part of Java 25.
Getting type information back#
Erasure removes the type argument from an object, but a compiled class still keeps the type
arguments written in its field types, its method signatures and its superclass. Libraries such
as Jackson and Spring read them back to recover a full generic type. Here json
holds a JSON array of customers:
// The { } creates a subclass whose superclass keeps List<Customer>.
List<Customer> customers = mapper.readValue(json, new TypeReference<List<Customer>>() { });
// Spring's version of the same trick, when calling another service.
List<Customer> body = restClient.get().uri("/customers").retrieve()
.body(new ParameterizedTypeReference<List<Customer>>() { });new TypeReference<List<Customer>>() { }Creates an object of an anonymous subclass: a class with no name, declared on the spot by the braces. Its superclass is
TypeReference<List<Customer>>, which the compiled class keeps, so Jackson can read it back.new ParameterizedTypeReference<List<Customer>>() { }The same idea in Spring's
RestClient.Customer.classalone would lose theListaround it.
This trick, a subclass whose only job is to keep a type argument, looks strange until you
know why it exists. When you see new TypeReference<...>() { } with the
trailing braces, that is what is happening. Leave out the braces and it will not compile,
because TypeReference is abstract.
Raw types#
LegacyRaw types
Generics arrived in Java 5, in 2004. So that older code kept compiling, you can still write
List with no type argument. That is a raw type: everything in it is an
Object, type checking is off, and the compiler only warns you.
List raw = new ArrayList(); // legal, but the compiler warns
raw.add("mug");
raw.add(1);
String s = (String) raw.get(1); // compiles, then throws ClassCastException when it runsList raw = new ArrayList();No type argument, so the list accepts anything: a string, and then a number.
(String) raw.get(1)The cast compiles, because the compiler no longer knows what the list holds. When it runs, the element is an
Integer, and the cast throwsClassCastException.
Treat every unchecked warning as an error. If you must silence one, put
@SuppressWarnings("unchecked") on the smallest declaration you can, with a comment
that says why it is safe.
InMemoryRepository<T extends HasId> showed that a Java constraint goes
inside the angle brackets with extends, where C# writes
where T : IHasId.
Java erases type arguments when it compiles, so new T(), typeof(T)
and a type test against List<String> are impossible. APIs take a
Class<T>, such as Customer.class, instead.
printAll accepted a List<Card> only because its parameter
said List<? extends Payment>: Java writes variance where a type is used.
Type arguments cannot be primitive, so a List<Integer> boxes every
number.
Quick reference#
| C# | Java | Note |
|---|---|---|
| where T : Base | <T extends Base> | classes and interfaces both use extends |
| where T : IFoo, IBar | <T extends Foo & Bar> | join bounds with &; a class bound, if any, must come first |
| where T : class | no equivalent | everything is a reference type anyway |
| where T : struct | no equivalent | Java has no user-defined value types to constrain to |
| where T : new() | no equivalent | no new T(); pass a Supplier<T> such as Customer::new instead |
| where T : unmanaged | no equivalent | Java has no unmanaged or pointer types |
| typeof(T) | a Class<T> parameter | the caller passes Customer.class, because T is erased |
| default(T) | null | a type parameter is always a reference type, so null is its only default |
| IEnumerable<out T> | List<? extends T> | variance is written at each use, with a wildcard |
| IComparer<in T> | Comparator<? super T> | the consumer side: ? super where C# declares in |
| List<int> | List<Integer> or int[] | each element becomes a heap object |
| Dictionary<int,V> | Map<Integer,V> | every int key is boxed into an Integer object |
| IEnumerable<int> | IntStream | a stream of plain ints, so nothing is boxed |
| Nullable<int> / int? | Integer | an Integer can be null, which plays the part of int? |
Annotations and attributes#
Annotations are Java's attributes: @Deprecated where C# writes
[Obsolete]. The syntax differs, but you use them the same way, to attach
information that the compiler, a framework or a tool reads.
Two differences matter. Every annotation declares how long it survives, and one you write yourself is invisible to frameworks unless it says otherwise. And Java has a standard way to run code generators while it compiles, so tools such as Lombok and MapStruct write code for you where .NET long used reflection.
Syntax#
The order API has a controller: a class that answers web requests. Its annotations tell the
web framework which web address each method handles. Here is the same controller in ASP.NET
Core and in Spring, where orders is the controller's
OrderRepository:
[Route("/orders")]
public class OrderController : ControllerBase
{
[HttpGet("{id}")]
public Order Get(long id, [FromQuery] bool withLines) => orders.Find(id);
}@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/{id}")
public Order get(@PathVariable long id, @RequestParam boolean withLines) {
return orders.find(id);
}
}@RestControllerAn annotation: an
@followed by a name, where C# puts the name in square brackets. This one tells Spring that the class answers web requests.@RequestMapping("/orders")An annotation with an argument, written in brackets like a method call, as in C#.
@RequestParam boolean withLinesAnnotations go on parameters too. This one reads
withLinesfrom the query string, like[FromQuery].
Arguments can be named. Here a method is marked as out of date, with the version that deprecated it and a warning that it will be removed:
[Obsolete("Use FindById instead", DiagnosticId = "SHOP001")]
public Order Find(long id) => FindById(id);/** @deprecated Use {@link #findById(long)} instead. */
@Deprecated(since = "2.0", forRemoval = true)
public Order find(long id) { return findById(id); }@Deprecated(since = "2.0", forRemoval = true)Named arguments are written
name = value, as in C#. Java annotations take no positional arguments, except for a member calledvalue, as shown later./** @deprecated Use {@link #findById(long)} instead. */A Javadoc comment, Java's version of an XML doc comment. Its
@deprecatedtag, in lower case, tells readers what to use instead. The annotation, with a capital letter, tells the compiler.
C# refreshernameof: a member's name as a checked string
throw new ArgumentNullException(nameof(order)); // "order", and a rename updates itJava has no nameof. Names inside annotations and messages are plain strings, so
a rename can leave them out of date. IntelliJ's rename updates most of them, and a test is the
only real guarantee.
The syntax is the easy part. The next difference is one C# has no word for.
Retention: the concept C# lacks#
The shop wants a log entry every time an order changes. You write your own annotation,
@Audited, put it on the methods that change orders, and have code look for it
while the program runs. Frameworks such as Spring, Jackson,
JUnit and Jakarta Persistence
(JPA) find annotations the same way. Whether that code can see an annotation depends on its
retention, which says how long it survives:
| 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 |
C# attributes are always kept in the compiled assembly and can always be read by reflection. A Java annotation can disappear before the program runs.
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. Here method is a Method object,
Java's version of C#'s MethodInfo:
@Retention(RetentionPolicy.RUNTIME) // the line people forget
public @interface Audited { }
Audited a = method.getAnnotation(Audited.class); // null without that line@Retention(RetentionPolicy.RUNTIME)Keeps the annotation in the compiled class, where reflection can find it while the program runs.
method.getAnnotation(Audited.class)Asks the method for its
@Auditedannotation. Without the retention line, the answer is alwaysnull.
That is all you need to use annotations. The next sections show how to declare one in full, and how the compiler can act on them.
Declaring one#
Here is @Audited in full. It holds the action being audited and who performs
it. In C# an attribute is a class that derives from Attribute. In Java it is
declared with @interface:
[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";
}@Target(ElementType.METHOD)Like
AttributeUsage: the annotation may only go on methods.public @interface Audited {@interfacedeclares an annotation type, not a class.String action();A member is declared like a method with no body. Using the annotation sets it:
@Audited(action = "cancel").String actor() default "system";defaultgives a member a value to use when it is left out.
A member named value is special: it can be set without its name.
@SuppressWarnings("unchecked") is shorthand for
@SuppressWarnings(value = "unchecked"), and the @Tag("billing") below
works the same way. That is why so many annotations call their main member
value.
To use the same annotation twice on one declaration, Java needs a second “container” annotation to hold the repeats, where C# needs one flag. Here a test method gets two tags:
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class TagAttribute(string name) : Attribute { }
[Tag("billing"), Tag("slow")]
void Run() { }@Repeatable(Tags.class)
public @interface Tag { String value(); }
public @interface Tags { Tag[] value(); } // the container
@Tag("billing") @Tag("slow")
void run() { }@Repeatable(Tags.class)Allows
@Tagmore than once on a declaration, and names the container that holds the repeats.public @interface Tags { Tag[] value(); }The container: an annotation whose
valueis an array ofTag. You never write it on a method yourself. The compiler wraps repeated tags in it.
Ones you will see constantly#
A handful of annotations appear in almost every Java codebase. The first six come with Java itself. The rest come from libraries that later chapters cover, including Spring, whose objects are called beans:
| 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: maps a class to a table, and a field to a column |
| @Test, @ParameterizedTest | JUnit: a test method, or a test run once per set of inputs |
| @Service, @Component, @Bean | Spring: a class that is a bean, or a method that makes one |
| @JsonProperty, @JsonIgnore | Jackson: rename a JSON property, or leave a field out of JSON |
Here are three of them at work. Pricing works out the price of an order, and
Cart keeps a small cache:
@FunctionalInterface
interface Pricing { BigDecimal price(Order order); } // a second abstract method will not compile
class Cart {
private final Map<String, Object> cache = new HashMap<>();
@SuppressWarnings("unchecked") // silence one known warning
List<String> names() { return (List<String>) cache.get("names"); }
@Override
public String toString() { return "Cart"; } // checked: it must override something
}@FunctionalInterfacePromises that the interface has exactly one abstract method, so that it can be written as a lambda. Add a second one and the compiler reports an error.
@SuppressWarnings("unchecked")Because of erasure, the cast from
ObjecttoList<String>cannot be checked while the program runs, so the compiler warns. This annotation silences that one warning, for this one method.@OverrideAsks the compiler to check that
toStringreally overrides a method from a supertype, hereObject.
Always write @Override. It is optional, but without it a small mistake in the
signature turns an intended override into a new method, with no error. The mistake might be a
capital letter, a boxed parameter or an extra argument. C#'s override keyword is
required, so this bug cannot happen there.
Annotations also drive code generation while Java compiles, which is where Java and .NET differ most in practice.
Annotation processing vs source generators#
An annotation processor is a plugin for
javac that runs while your code compiles. It reads annotations and can write new
source files, which are 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.
C# refresherSource generators: C# code written at compile time
A source generator reads your code during compilation and adds more C# to it. The best-known one writes System.Text.Json's serialisation code, so no reflection runs.
[JsonSerializable(typeof(Order))]
internal partial class AppJson : JsonSerializerContext { } // the generator fills it in
string json = JsonSerializer.Serialize(order, AppJson.Default.Order);MapStruct is a processor that writes mapping code. You declare an interface, and MapStruct
writes the class that implements it. Here OrderDto is a record holding the fields
the API returns:
@Mapper(componentModel = "spring")
public interface OrderMapper {
OrderDto toDto(Order order); // MapStruct writes the implementation at compile time
}@Mapper(componentModel = "spring")Marks the interface for MapStruct.
componentModel = "spring"makes the generated class a Spring bean, so other classes can have it injected.OrderDto toDto(Order order);A method with no body. MapStruct writes the body, copying each field of
Orderto the field ofOrderDtowith the same name.
Each .NET way of producing code has a Java counterpart:
| .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 |
These are the processors you are most likely to meet. Some of them write what a record now gives you for free:
| 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 a closer look. AutoMapper works out its mappings by reflection while the program runs, and a missing property fails only then. MapStruct writes a plain Java class when you compile, so mapping problems surface in the build. A field it cannot convert is an error. A field with no match is a warning, or an error if you ask for one. The generated mapper is as fast as code written by hand.
Reading annotations at runtime#
Reading an annotation while the program runs is the same idea in both languages: find the
method through reflection, then ask it for the annotation. Here the audit code checks
cancel on OrderService:
var attr = typeof(OrderService)
.GetMethod("Cancel")!
.GetCustomAttribute<AuditedAttribute>();
if (attr is not null) Log(attr.Action);Method cancel = OrderService.class.getMethod("cancel", long.class);
Audited a = cancel.getAnnotation(Audited.class);
if (a != null) log(a.action());OrderService.class.getMethod("cancel", long.class)Finds the public method
cancelthat takes along. Java needs the parameter types, because several methods can share a name.cancel.getAnnotation(Audited.class)Like
GetCustomAttribute. It returnsnullwhen the method has no@Audited, or when the annotation's retention is notRUNTIME.
This only works for RUNTIME retention. Spring, Jackson and JUnit all rely on it.
That is why Java frameworks have long 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?
The order controller showed that an annotation is an @ and a name, where C#
uses square brackets, and that named arguments are written name = value.
Every annotation has a retention, and your own @Audited stayed invisible to
reflection until it said @Retention(RetentionPolicy.RUNTIME).
An annotation type is declared with @interface, its members look like methods
with no body, and a member called value can be set without its name.
Always write @Override, and let annotation processors such as MapStruct write
code for you while Java compiles.
Quick reference#
| C# | Java | Note |
|---|---|---|
| [Attr] | @Attr | an @ prefix instead of square brackets |
| [Attr(1, Name = "x")] | @Attr(value = 1, name = "x") | named arguments work the same; a member called value may go unnamed |
| [Attr] on its own line | @Attr on its own line | stacked one per line above the declaration, as in C# |
| AttributeUsage | @Target | which declarations it may be attached to |
| n/a | @Retention | how long it survives; only RUNTIME is visible to reflection |
| [Obsolete] | @Deprecated | pair it with a @deprecated javadoc tag that names the replacement |
| [Conditional] | no equivalent | no way to strip calls at compile time |
| Multiple same attribute | @Repeatable | added in Java 8; it needs a second, container annotation |
| GetCustomAttribute<T>() | getAnnotation(T.class) | returns null when the annotation is missing or not kept at run time |
| nameof(x) | no equivalent | write the name as a string, and let IntelliJ's rename or a test keep it right |
Nullability: Optional and JSpecify#
Java has no string?. The language cannot say that a value may be null, so the
compiler never warns you before you use one. It also lacks C#'s !,
?. and ?? operators.
There are two partial answers. Optional<T>
Java 8 is for a method's return value that may be missing. JSpecify
annotations, checked by a separate tool, cover
everything else. Spring Framework 7 and Spring Boot 4 use JSpecify throughout, which is making
it the standard.
The gap, stated plainly#
Looking up a customer's nickname can come back empty, because some customers never set one.
In C#, the type string? says so, and the compiler makes you check before you use
the value. In Java, the type is just String. Here id is the customer's
id:
string? name = FindNickname(id); // may be null
int a = name!.Length; // "trust me"
int? b = name?.Length; // null if name is null
string shown = name ?? "none"; // a fallback
name ??= "none"; // assign only if nullString name = findNickname(id); // nothing says it may be null
int a = name.length(); // throws if name is null
Integer b = name == null ? null : name.length();
String shown = Objects.requireNonNullElse(name, "none");
if (name == null) name = "none";String name = findNickname(id);Nothing in the type says the value may be null. You find out from the method's documentation, or when the program fails.
int a = name.length();Java has no
!operator, because there is no warning to silence. Ifnameis null, this line throwsNullPointerException.name == null ? null : name.length()Java has no
?.operator, so you write the null check yourself. The result is anInteger, not anint, because anintcannot be null.Objects.requireNonNullElse(name, "none")Java's
??: the first argument if it is not null, otherwise the second. It arrived in Java 9.
C# refresherNullable reference types: string? and the compiler's warnings
With nullable reference types on, a plain string promises non-null,
string? may be null, and the compiler warns when you use a maybe-null value
without checking it first.
// MyApp.csproj: <Nullable>enable</Nullable>
string? name = Find();
Console.WriteLine(name.Length); // warning CS8602: possibly null
if (name is not null)
Console.WriteLine(name.Length); // fine: the compiler saw the checkFor a method that may have nothing to return, Java offers a better tool than null.
Optional is for return values#
Finding a customer by id may find nobody. Instead of returning null, the Java repository
method can return an Optional<Customer>: a box that holds one customer or is
empty. The caller has to open the box, so it cannot forget the empty case:
Customer? Find(long id);
var customer = Find(7);
var name = customer?.Name ?? "unknown";Optional<Customer> find(long id);
String name = find(7)
.map(Customer::name)
.orElse("unknown");Optional<Customer> find(long id);The return type says that the customer may be missing, which a plain
Customerreturn type cannot say..map(Customer::name)If a customer was found,
mapapplies a function to it and keeps the result, here the name. If not, the result stays empty.Customer::nameis a method reference, a short way of writing the lambdac -> c.name()..orElse("unknown")Opens the box: the name if there is one, otherwise
"unknown". It plays the part of C#'s??.
Here are Optional's other methods, with the nearest C#:
| 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 | n/a; added in Java 9 |
Optional is not a general-purpose nullable wrapper. Its
designers meant it for return values, where finding nothing is a normal outcome. Do not use it
for:
- Fields: it cannot be serialised, and it adds an extra object to every instance.
- Method parameters: callers have to wrap their argument, and can still pass
nullanyway. Offer two overloads instead. - Collections: an empty list already means that nothing was found, so never
return
Optional<List<T>>.
And a variable of type Optional can itself be null. Never return null from a
method whose return type is Optional.
orElse works out its argument first, even when a value is present. If the
default is expensive or has side effects, use orElseGet. Here
loadGuestCustomer() reads from the database:
find(id).orElse(loadGuestCustomer()); // always calls loadGuestCustomer()
find(id).orElseGet(() -> loadGuestCustomer()); // calls it only when nobody was foundfind(id).orElse(loadGuestCustomer());Java works out the argument before it calls
orElse, so the database is read even when a customer was found.find(id).orElseGet(() -> loadGuestCustomer());Passes a lambda instead: code that
orElseGetruns only when theOptionalis empty.
Optional covers return values. For parameters and fields, Java needs
annotations, and a tool that checks them.
JSpecify: the annotation standard#
Java has had a dozen competing @Nullable annotations, from
javax.annotation, JetBrains, Spring, the Checker Framework and Android.
JSpecify is the industry's effort to agree on one set, and Spring Framework 7
moved its whole API to it. First, add the library to your
Maven pom.xml:
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.0</version> <!-- stable since 2024 -->
</dependency>This is Maven's version of a PackageReference: the groupId, the
artifactId and the version together name the library.
Next, declare that everything in a package is non-null
unless it is marked otherwise. That goes in a special file, package-info.java, in
the package's folder:
// src/main/java/com/acme/billing/package-info.java
@NullMarked // everything in this package is non-null by default
package com.acme.billing;
import org.jspecify.annotations.NullMarked;@NullMarkedMakes every type in the package non-null unless it is marked
@Nullable. It works like<Nullable>enable</Nullable>in a C# project file.package com.acme.billing;The file holds only the package declaration, so the annotation applies to the package as a whole.
Inside the package, you mark only the exceptions. Here is the Invoices class,
which keeps invoices by id:
// src/main/java/com/acme/billing/Invoices.java, in the same package
import org.jspecify.annotations.Nullable;
public class Invoices {
private final Map<String, Invoice> byId = new HashMap<>();
public Invoice load(String id) { // id and the result are never null
return Objects.requireNonNull(byId.get(id), "no invoice " + id);
}
public @Nullable Invoice find(String id) { // the result may be null
return byId.get(id);
}
}public Invoice load(String id)No annotation, so under
@NullMarkedboth the parameter and the result are non-null. A checker reports any caller that passes null.public @Nullable Invoice find(String id)@Nullablemarks the exception: this method may return null, so a checker makes callers test the result before they use it.
@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
Java Development Kit (JDK) enforces it, so you need a
tool.
These are the tools that check the annotations:
| 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 adopts them, add a checker to the build at the same time, or the annotations will stop telling the truth within months.
Defensive checks#
Public constructors and methods often check their arguments straight away, so that a null fails loudly at the door instead of deep inside. Here an order refuses to be created without a customer:
public Order(Customer customer)
{
ArgumentNullException.ThrowIfNull(customer);
_customer = customer;
}public Order(Customer customer) {
this.customer =
Objects.requireNonNull(customer, "customer");
}Objects.requireNonNull(customer, "customer")Throws
NullPointerExceptionwith the messagecustomerifcustomeris null, and otherwise returns it, so the check and the assignment fit in one statement. The second argument names the parameter, which C#'sThrowIfNullfills in for you.
Java 14 added helpful NullPointerException messages behind a flag, and they
have been on by default since Java 15. The Java Virtual Machine
(JVM) tells you which expression was null, for example
Cannot invoke "Customer.name()" because "order.customer" is null. That removes
most of the pain of finding the null in a chain of calls. Developers who left Java before 2020
often do not know about it.
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?
The nickname lookup showed that a Java String can always be null. Java has no
!, ?. or ??, so you write the null checks yourself, or
call Objects.requireNonNullElse.
Optional<Customer> told callers that find may come back
empty, and map and orElse opened it safely.
Use Optional only for return values, and prefer orElseGet when the
default is expensive.
For parameters and fields, JSpecify's @NullMarked and @Nullable
describe what may be null, but only a checker such as NullAway turns them into build
errors.
Quick reference#
| C# | Java | Note |
|---|---|---|
| string? name | String name | no way to say it in the language |
| string name (non-null) | String name | the same declaration, but nothing enforces non-null in Java |
| name!.Length | name.length() | no ! operator; the call simply throws if name is null |
| 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 ??= operator; write the if statement out |
| Nullable reference types warnings | JSpecify + NullAway or IntelliJ | opt in with annotations; only a checker tool turns them into errors |
| ArgumentNullException.ThrowIfNull(x) | Objects.requireNonNull(x) | throws NullPointerException, and returns x so it can be assigned |
| customer?.Name ?? "unknown" | find(id).map(Customer::name).orElse("unknown") | return Optional from a method that may find nothing |
Collections#
Java has the same data structures as .NET under different names: List<T>
is ArrayList, and Dictionary is HashMap.
Two habits differ. IEnumerable<T> maps to
Iterable<E>, not Iterator. And Java code
declares the interface and creates the class: you declare a List and
create an ArrayList, almost always.
The interface hierarchy#
Every Java collection type fits into one family tree, and almost every one has a .NET counterpart. The picture shows the interfaces at the top and the classes you create at the bottom:
IEnumerable<T> is Iterable<E>, not
Iterator<E>. The match is exact. An Iterable can be
looped over again and again, and it is what for (var x : xs) accepts. An
Iterator is a cursor that makes one pass, like IEnumerator<T>.
Mix them up, and you write methods whose results can only be read once.
Here are an order's product ids, read through each of the two types:
IEnumerable<string> ids = ["mug", "cup"];
foreach (var id in ids) { } // as often as you like
IEnumerator<string> e = ids.GetEnumerator(); // one pass
while (e.MoveNext()) Console.WriteLine(e.Current);Iterable<String> ids = List.of("mug", "cup");
for (var id : ids) { } // as often as you like
Iterator<String> it = ids.iterator(); // one pass
while (it.hasNext()) System.out.println(it.next());Iterable<String> idsAnything you can loop over with
for, likeIEnumerable<T>. You can loop over it as many times as you like.ids.iterator()Hands out an
Iterator: a cursor that goes through the elements once, likeIEnumerator<T>.while (it.hasNext()) System.out.println(it.next());hasNext()asks whether another element is left, andnext()moves on and returns it. C# splits the same job intoMoveNext()andCurrent.
With the family tree in mind, here is how the everyday types are used.
Everyday collections#
The everyday calls translate method by method. Here are a queue of order numbers waiting to be packed, an undo stack, a price list sorted by product, and a cache of stock levels:
var queue = new Queue<long>();
queue.Enqueue(1042); var next = queue.Dequeue();
var stack = new Stack<string>();
stack.Push("add mug"); var undo = stack.Pop();
var prices = new SortedDictionary<string, decimal>
{ ["mug"] = 9.50m, ["cup"] = 4.00m };
KeyValuePair<string, decimal> first = prices.First();
var cache = new ConcurrentDictionary<string, int>();
cache.GetOrAdd("mug", _ => 12);Deque<Long> queue = new ArrayDeque<>();
queue.offer(1042L); var next = queue.poll();
Deque<String> stack = new ArrayDeque<>();
stack.push("add mug"); var undo = stack.pop();
var prices = new TreeMap<>(Map.of(
"mug", new BigDecimal("9.50"), "cup", new BigDecimal("4.00")));
Map.Entry<String, BigDecimal> first = prices.firstEntry();
var cache = new ConcurrentHashMap<String, Integer>();
cache.computeIfAbsent("mug", k -> 12);Deque<Long> queue = new ArrayDeque<>();Java has no queue class to create.
ArrayDeque, a double-ended queue, does the job of bothQueue<T>andStack<T>.queue.offer(1042L); var next = queue.poll();offeradds to the back andpolltakes from the front, likeEnqueueandDequeue. On an empty queue,pollreturnsnullwhereDequeuethrows.stack.push("add mug"); var undo = stack.pop();pushandpopwork at the front, so the same class also serves as a stack.prices.firstEntry()A
TreeMapkeeps its keys sorted, likeSortedDictionary, so its first entry has the smallest key:cup.Map.Entryis Java'sKeyValuePair.cache.computeIfAbsent("mug", k -> 12);Java's
GetOrAdd. It runs the lambda only when the key is missing, and stores the result.
C# refresherOrderedDictionary (.NET 9): the insertion-ordered dictionary
A generic dictionary, new in .NET 9, that keeps keys in the order they were added and can also be read by position.
var od = new OrderedDictionary<string, int> { ["b"] = 2, ["a"] = 1 };
foreach (var (key, value) in od) Console.Write(key); // "ba": insertion order
int second = od.GetAt(1).Value; // 1: read by positionThe quick reference at the end of this chapter lists every .NET collection type with its Java counterpart. Next comes creating collections, where Java has a trap.
Creating collections#
C# has collection expressions and initialisers. Java has factory methods such as
List.of, and they return collections that cannot be changed. Here are an order's
product ids, its quantities and its tags:
// collection expression
List<string> a = ["mug", "cup"];
var b = new List<string> { "mug", "cup" };
var c = new Dictionary<string, int> { ["mug"] = 2 };
IReadOnlyList<string> d = ["mug"];
var tags = new HashSet<string> { "gift" };
var frozen = ImmutableList.Create("mug", "cup");var a = new ArrayList<>(List.of("mug", "cup"));
var b = List.of("mug", "cup"); // fixed
var c = Map.of("mug", 2); // fixed
List<String> d = List.of("mug");
var tags = new HashSet<>(Set.of("gift"));
var frozen = List.of("mug", "cup"); // fixednew ArrayList<>(List.of("mug", "cup"))List.ofmakes a fixed list, andnew ArrayList<>(...)copies it into one you can add to. This is Java's closest match tonew List<string> { ... }.var b = List.of("mug", "cup");A fixed list: adding, removing or replacing an element throws
UnsupportedOperationException.Map.of("mug", 2)Keys and values in turn, here a fixed map with one entry.
List.of(...) and Map.of(...) Java 9 return
immutable collections. They throw UnsupportedOperationException
on any change, and they reject null elements. They are not a replacement for
new ArrayList<>(). If you need to add to one later, copy it:
new ArrayList<>(List.of(...)).
Map.of also takes at most 10 key-value pairs. For more, use
Map.ofEntries(Map.entry(k, v), ...).
The last everyday trap is changing a collection while a loop is reading it.
Modifying while iterating#
Cleaning up an order's tags means removing the blank ones. Removing from a list while a
for loop walks over it fails in both languages. C# offers
RemoveAll on a List<T>. Java has the same idea,
removeIf, and its Iterator can also remove:
Here tags is a List<String> that can change:
// usually throws ConcurrentModificationException
for (var tag : tags) if (tag.isBlank()) tags.remove(tag);
// correct: removeIf is the usual answer
tags.removeIf(String::isBlank);
// correct: remove through the iterator itself
var it = tags.iterator();
while (it.hasNext()) if (it.next().isBlank()) it.remove();tags.remove(tag)Changes the list while the loop's hidden iterator is walking it. The loop usually fails on its next step with
ConcurrentModificationException, Java's version of C#'sInvalidOperationExceptionfor the same mistake.tags.removeIf(String::isBlank);Removes every element for which the test is true, in one call.
String::isBlankis a method reference, short fortag -> tag.isBlank().it.remove();Removes the element that
next()just returned. That is safe, because the iterator itself makes the change.
The remaining sections cover a naming habit, the Java 21 methods for first and last elements, and the old collections you will meet in legacy code.
Declare the interface, instantiate the class#
Java convention is stricter than C#'s here, and it is worth adopting straight away. Declare variables, fields, parameters and return types with the interface, and name the class only where you create the object:
List<String> productIds = new ArrayList<>(); // yes
Map<String, Integer> stock = new HashMap<>(); // yes
ArrayList<String> overSpecified = new ArrayList<>(); // avoidDeclaring List lets you switch to another implementation later by changing one
line. The diamond, <>, takes the type argument from the declared type, so
nothing is repeated.
Sequenced collections#
Java 21 added sequenced collections Java 21: methods for the first and last elements of any collection with a defined order. Before them, each type had its own way, or none. Here are an order's product ids and a stock list that remembers the order of insertion:
var ids = new ArrayList<>(List.of("mug", "cup", "pot"));
ids.getFirst(); // "mug", was ids.get(0)
ids.getLast(); // "pot", was ids.get(ids.size() - 1)
ids.addFirst("tray");
ids.reversed(); // a reversed view, not a copy
var stock = new LinkedHashMap<String, Integer>();
stock.put("mug", 12);
stock.put("cup", 3);
stock.firstEntry(); // mug=12, the first one addedids.getFirst();The first element, from any ordered collection, like C#'s
ids[0].ids.reversed();A reversed view: it shows the same list back to front, and a change to either one shows in the other. C#'s
List<T>.Reverse()turns the list itself around.stock.firstEntry();A
LinkedHashMapremembers the order in which keys were added, so its first entry is the one added first.
subList returns a view, not a copy. Changing the sublist changes the
list behind it. Adding to or removing from the list behind it makes the sublist throw
ConcurrentModificationException the next time you use it. C#'s range operator
copies. If you want a copy, make one: new ArrayList<>(ids.subList(1, 3)).
Legacy collections#
LegacyVector, Hashtable, Stack, Enumeration
These collections date from Java 1.0. They were later fitted to the modern interfaces, but every method takes a lock. So you pay for locking you almost never need, and they are still not safe for operations that take several calls. 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 |
Replacing them is usually a one-line change:
Vector<String> old = new Vector<>(); // locks on every call
List<String> list = new ArrayList<>(); // write this instead
Stack<Integer> oldStack = new Stack<>(); // extends Vector, and iterates bottom first
Deque<Integer> stack = new ArrayDeque<>(); // push, pop and peek, with no lockingVector<String> old = new Vector<>();Every call locks the vector, even when only one thread ever uses it.
Stack<Integer> oldStack = new Stack<>();StackextendsVector, so it locks too, and a loop over it visits the bottom of the stack first.
The product ids showed that Iterable is IEnumerable, which you can
loop over many times, and Iterator is the one-pass cursor, like
IEnumerator.
ArrayDeque served as both the packing queue and the undo stack,
TreeMap kept the prices sorted, and computeIfAbsent played the part of
GetOrAdd.
List.of and Map.of make collections that cannot change, so copy
them into new ArrayList<>(...) when you need to add.
Declare the interface and create the class, and remove while looping with
removeIf or the iterator's remove.
Quick reference#
| .NET | Java | Note |
|---|---|---|
| IEnumerable<T> | Iterable<E> | can be iterated more than once |
| IEnumerator<T> | Iterator<E> | a one-pass cursor, like IEnumerator, that can also remove() |
| ICollection<T> | Collection<E> | adds size, add, remove and contains on top of iteration |
| IList<T> | List<E> | an ordered collection you can index by position with get(i) |
| List<T> | ArrayList<E> | the everyday list, backed by an array, like List<T> |
| LinkedList<T> | LinkedList<E> | almost always slower than ArrayList; avoid |
| T[] | E[] | fixed size, and covariant in both languages, which is unsafe |
| ISet<T> | Set<E> | the set interface; declare this and pick an implementation |
| HashSet<T> | HashSet<E> | no guaranteed iteration order; use LinkedHashSet to keep insertion order |
| SortedSet<T> | TreeSet<E> | kept sorted by natural order, or by a Comparator you pass |
| n/a | LinkedHashSet<E> | a HashSet that remembers insertion order; .NET has no equivalent |
| IDictionary<K,V> | Map<K,V> | a Map is not a Collection; iterate entrySet(), keySet() or values() |
| Dictionary<K,V> | HashMap<K,V> | no guaranteed iteration order; use LinkedHashMap to keep insertion order |
| SortedDictionary<K,V> | TreeMap<K,V> | kept sorted by key, like SortedDictionary; firstKey() is the smallest |
| OrderedDictionary<K,V> | LinkedHashMap<K,V> | insertion-ordered; .NET 9+; LinkedHashMap is also LRU-capable |
| Queue<T> | ArrayDeque<E> | use offer() and poll(); ArrayDeque is the fast general-purpose queue |
| Stack<T> | ArrayDeque<E> | use push() and pop(); avoid the legacy java.util.Stack class |
| ConcurrentDictionary<K,V> | ConcurrentHashMap<K,V> | a thread-safe map; computeIfAbsent plays the part of GetOrAdd |
| BlockingCollection<T> | BlockingQueue<E> | a producer-consumer queue; put() and take() wait when needed |
| ImmutableList<T> | List.of(...) | an unmodifiable list, added in Java 9; it rejects nulls |
| ReadOnlyCollection<T> | Collections.unmodifiableList | a read-only window; changes to the source show through |
| KeyValuePair<K,V> | Map.Entry<K,V> | one key and its value, as returned by entrySet() |
| Comparer<T> | Comparator<E> | compares two objects; build one with Comparator.comparing(...) |
| IComparable<T> | Comparable<E> | the natural order of a type, through its compareTo() method |
| list[0] | list.getFirst() | Java 21 and later; before that, write list.get(0) |
| list[^1] | list.getLast() | Java 21 and later; before that, list.get(list.size() - 1) |
| list.Reverse() | list.reversed() | a reversed view; Collections.reverse turns the list itself around |
| list[1..3] | list.subList(1, 3) | a view of the same list, not a copy as in C# |
Equality, hashing and comparison#
In Java, == on two objects asks whether they are the same object. It never
compares values, and no operator overloading can change that. So == on
String, Integer, BigDecimal or LocalDate is a
bug waiting for the right input.
Compare values with equals, or with 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#
An incoming order names its product by id, and the shop checks that id against the
catalogue. Here received was read from the request, so it is a different string
object from the literal "mug", even when the text matches:
// == is overloaded for string, and
// record/struct equality is value-based
var wanted = "mug";
var received = ReadProductId();
if (wanted == received) { } // compares the text
bool same = ReferenceEquals(wanted, received);String wanted = "mug";
String received = readProductId();
if (wanted == received) { } // a bug
if (wanted.equals(received)) { } // compares the text
if (Objects.equals(wanted, received)) { }if (wanted == received) { }In Java,
==on two objects asks whether they are the same object.receivedwas built while the program ran, so this is false even when both saymug. Comparing strings showed the same trap with a voucher code.wanted.equals(received)Compares the text, which is what C#'s
==does for strings. It throws ifwantedis null.Objects.equals(wanted, received)Also compares the text, and is safe when either side is null: two nulls count as equal.
In short, Java turns C#'s habits around: == means identity, and
equals means value.
| 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> |
Order quantities are often stored as Integer, the object form of
int, for example in a List<Integer>. Comparing two of them with
== works in tests with small numbers and fails in production with larger ones:
The boxed-integer cache. The
Java Virtual Machine (JVM) keeps one shared
Integer object for each value from −128 to 127. So == appears to
work on small numbers, and silently stops working above 127. This is Java's most notorious
trap:
Integer a = 127, b = 127;
boolean small = a == b; // true: the same cached object
Integer c = 128, d = 128;
boolean large = c == d; // false: two different objects
boolean same = c.equals(d); // true: always compare with equalsInteger a = 127, b = 127;Assigning 127 to an
Integerwraps the number in an object. For values from −128 to 127, Java hands out the same cached object every time.boolean large = c == d;128 is outside the cache, so each assignment creates a new object, and
==compares two different objects.
The same trap exists for Long, Short, Byte and
Character. A test that passes with small numbers and fails in production is worth
checking for this.
Comparing with equals is half of the story. Hash-based collections also need
hashCode, and the two must agree.
The equals and hashCode contract#
HashMap and HashSet, Java's Dictionary and
HashSet, find an object in two steps. First hashCode picks a bucket,
then equals checks the objects in it. So the two methods must agree, and nothing
enforces it. If you override one, override the other. Here are the rules they must follow:
| Rule | Meaning |
|---|---|
| Reflexive | an object always equals itself: a.equals(a) is true |
| Symmetric | a.equals(b) implies b.equals(a) |
| Transitive | if a equals b and b equals c, then a equals 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. Suppose the shop keys its stock levels by a
small ProductKey class that overrides equals but not
hashCode:
class ProductKey {
final String id;
ProductKey(String id) { this.id = id; }
@Override public boolean equals(Object o) { return o instanceof ProductKey k && id.equals(k.id); }
// no hashCode(): equal keys still hash differently
}
var stock = new HashMap<ProductKey, Integer>();
stock.put(new ProductKey("mug"), 12);
stock.get(new ProductKey("mug")); // null: the lookup searched the wrong bucket@Override public boolean equals(Object o)Two keys with the same id are equal.
o instanceof ProductKey kchecks the type and names itkin one step.// no hashCode(): equal keys still hash differentlyWithout an override,
hashCodecomes fromObjectand is different for every object, so the two equal keys land in different buckets.stock.get(new ProductKey("mug"))Looks in the bucket for the new key's hash, finds nothing there, and returns
null, even though an equal key is in the map.
Two objects that are equal but hash differently can both be stored in a
HashSet, and a lookup with an equal key returns null. The failure is silent, and
it depends on the data.
The rest of this chapter shows how to write both methods correctly, and how to sort.
Writing them#
Money is a classic value type: two amounts of money are equal when the amount
and the currency match. Here is equality written by hand in both languages:
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;
public Money(BigDecimal amount, String currency) {
this.amount = amount;
this.currency = 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.stripTrailingZeros(), currency);
}
}if (this == o) return true;A quick exit: an object always equals itself.
if (!(o instanceof Money m)) return false;equalstakes anObject, so first check thatois aMoney, and call itm. This also returns false for null.amount.compareTo(m.amount) == 0Compares the amounts by value.
equalson aBigDecimalwould also compare the number of decimal places, as the warning below explains.Objects.hash(amount.stripTrailingZeros(), currency)Java's
HashCode.Combine.stripTrailingZeros()makes 1.0 and 1.00 hash the same, becauseequalstreats them as equal, and equal objects must have equal hashes.
Objects.hash(...) takes varargs, so it
creates an array for its arguments on every call. For a type that is hashed very often, the
classic 31 * result + field.hashCode() form is faster, 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) { }Two Money records are equal when both components are equal by
equals, and their hashes then match automatically.
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 that
behaviour, which is usually wrong for money. Fix the scale in the
compact constructor, as
Records showed.
Ordering#
Sorting works the same way in both languages: you supply the comparison. C# usually passes a
lambda or uses LINQ's OrderBy, and Java builds a Comparator. Here
orders is a List<Order>, and each order has an id, a status and
the time it was placed:
orders.Sort((a, b) => a.Id.CompareTo(b.Id));
var sorted = orders
.OrderBy(o => o.Status)
.ThenByDescending(o => o.PlacedAt)
.ToList();import static java.util.Comparator.reverseOrder;
orders.sort(Comparator.comparingLong(Order::id));
var sorted = orders.stream()
.sorted(Comparator.comparing(Order::status)
.thenComparing(Order::placedAt, reverseOrder()))
.toList();Comparator.comparingLong(Order::id)Builds a comparison from a key: compare orders by their id.
comparingLongavoids boxing eachlong, ascomparingIntdoes for anint.orders.stream()A stream, Java's version of a LINQ query. Streams vs LINQ, the next chapter, covers streams.
Comparator.comparing(Order::status)Compares by status, in the order the enum declares its constants.
.thenComparing(Order::placedAt, reverseOrder())Breaks ties by the time the order was placed, newest first, like
ThenByDescending.
The rest of LINQ's ordering maps like this:
| 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) |
A type's own natural order is Comparable, as it is IComparable in
C#. Versions of the shop's API are ordered by major number, then minor number:
public record Version(int Major, int Minor) : IComparable<Version>
{
public int CompareTo(Version? o) =>
(Major, Minor).CompareTo((o!.Major, o.Minor));
}public record Version(int major, int minor)
implements Comparable<Version> {
private static final Comparator<Version> ORDER =
Comparator.comparingInt(Version::major)
.thenComparingInt(Version::minor);
public int compareTo(Version o) {
return ORDER.compare(this, o);
}
}implements Comparable<Version>Gives
Versiona natural order, whichCollections.sort,TreeSetandTreeMapuse without being handed a comparator.private static final Comparator<Version> ORDER =The comparison is built once and kept in a constant, rather than rebuilt on every call.
Comparable.compareTo should be consistent with equals: when
a.compareTo(b) == 0, a.equals(b) should be true. When it is 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: 1.0 and 1.00 are
equal by compareTo but not by equals, so a TreeSet keeps
one of them and a HashSet keeps both.
The product id showed that == on objects compares identity. Strings,
Integers, BigDecimals and dates are compared with
equals, or with Objects.equals when either may be null.
Integer quantities above 127 fail with ==, because only small
values share a cached object.
Override equals and hashCode together, using the same fields, or let
a record write both: ProductKey without hashCode lost its entry.
Money compared amounts with compareTo, because
BigDecimal.equals also compares the number of decimal places.
Comparator.comparing and thenComparing replace
OrderBy and ThenBy, and Comparable gives a type its
natural order.
Quick reference#
| C# | Java | Note |
|---|---|---|
| a == b on strings | a.equals(b) | Java's == compares objects, so strings need equals |
| a == b, either may be null | Objects.equals(a, b) | two nulls count as equal, and nothing throws |
| ReferenceEquals(a, b) | a == b | the only thing == means for objects in Java |
| GetHashCode() | hashCode() | override it whenever you override equals |
| HashCode.Combine(x, y) | Objects.hash(x, y) | hash the same fields that equals compares |
| IEquatable<T> | equals(Object) | there is no typed version; check the type with instanceof |
| IComparable<T> | Comparable<T> | the natural order, through compareTo |
| OrderBy(o => o.Status) | Comparator.comparing(Order::status) | ThenBy becomes thenComparing, and OrderByDescending adds reversed() |
Streams vs LINQ#
Streams are Java's LINQ to Objects: the same idea, with different names. One difference
matters every day: a stream can be used only once. A LINQ query runs again each
time you enumerate it, but a used-up stream throws IllegalStateException.
Java has no LINQ to SQL, because it has no expression trees. A Java lambda compiles to ordinary code, which no library can read and turn into SQL. So Java database code uses query strings, a criteria API, or a library such as jOOQ.
The shape#
The shop wants the names of all customers whose name ends in "Doe", in alphabetical order.
In C# that is a LINQ query over customers, a List<Customer>. In
Java it is a stream:
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();customers.stream()A list is not a stream, so you ask it for one first. C# lets you call
Whereon the list itself..filter(c -> c.name().endsWith("Doe"))Wherebecomesfilter. The lambda uses an arrow,->, where C# writes=>..map(Customer::name)Selectbecomesmap.Customer::nameis a method reference, short forc -> c.name()..sorted()OrderBywith the natural order. Pass aComparatorto sort by anything else..toList();Runs the pipeline and collects the result. Nothing runs until this last step, just as a LINQ query does nothing until you enumerate it.
That is the whole pattern: start a stream, chain operations, and finish with one step that produces a result. Most LINQ operators simply have a different name.
Operator translation#
Here are several operators at once. Each order has a list of lines, and each line has a product id. The query finds product ids from orders with more than two lines, and then asks three single questions of the orders:
var productIds = orders
.Where(o => o.Lines.Count > 2)
.SelectMany(o => o.Lines)
.Select(l => l.ProductId)
.Distinct()
.Skip(10).Take(5)
.ToList();
bool anyEmpty = orders.Any(o => o.Lines.Count == 0);
var first = orders.FirstOrDefault();
int lineCount = orders.Sum(o => o.Lines.Count);var productIds = orders.stream()
.filter(o -> o.lines().size() > 2)
.flatMap(o -> o.lines().stream())
.map(OrderLine::productId)
.distinct()
.skip(10).limit(5)
.toList();
boolean anyEmpty = orders.stream()
.anyMatch(o -> o.lines().isEmpty());
var first = orders.stream().findFirst().orElse(null);
int lineCount = orders.stream()
.mapToInt(o -> o.lines().size()).sum();.flatMap(o -> o.lines().stream())SelectManybecomesflatMap. Its lambda must return a stream, so each order's list of lines is turned into one..skip(10).limit(5)SkipandTakebecomeskipandlimit..anyMatch(o -> o.lines().isEmpty());Anywith a test becomesanyMatch.findFirst().orElse(null)findFirstreturns anOptional, soorElse(null)givesFirstOrDefault's behaviour..mapToInt(o -> o.lines().size()).sum();sumlives onIntStream, a stream of plainints, so you map tointfirst.
The quick reference at the end of this chapter lists every LINQ operator with its stream counterpart. If some of the rarer LINQ operators are hazy, here they are:
C# refresherLINQ operators: TakeWhile, SkipWhile, Concat, All, Single, Aggregate, Zip, Chunk and more
The rest of the table's LINQ column, one line each, with what it returns.
IEnumerable<int> xs = [1, 2, 3, 12], ys = [4, 5];
xs.TakeWhile(x => x < 10); // 1, 2, 3: stops at the first failure
xs.SkipWhile(x => x < 10); // 12: drops items while the test holds
xs.Concat(ys); // xs followed by ys
xs.Reverse(); // last to first
xs.All(x => x > 0); // true if every item matches
xs.Single(x => x == 12); // the only match; throws on none or several
xs.Average(); xs.Min(); xs.Max(); // aggregates
xs.Aggregate(1, (acc, x) => acc * x); // a fold with a starting value: 72
xs.Zip(ys); // (1, 4), (2, 5): pairs, up to the shorter
xs.Chunk(2); // [1, 2], [3, 12]
xs.DefaultIfEmpty(0); // xs, or a single 0 if xs is empty
xs.AsParallel().Select(Score); // PLINQ: the query runs on several threadsThe names are the easy part. The next difference changes how you write code.
Single use#
A stream is a pipeline over a source, and it can be used exactly once. This is the difference that bites when you translate LINQ. Here the same filtered customers are read twice:
var does = customers.stream().filter(c -> c.name().endsWith("Doe"));
var a = does.toList();
var b = does.toList(); // IllegalStateException: stream has already been operated uponvar a = does.toList();The first finishing step runs the pipeline and uses the stream up.
var b = does.toList();The second one throws
IllegalStateException, with the message "stream has already been operated upon or closed".
In C#, the same IEnumerable would simply run the query again. In Java, either
create the stream again, or collect it once into a List and reuse the list.
Finishing a stream with toList is the simplest case. Everything else goes
through collect.
Collectors#
collect is the general way to finish a stream, and the Collectors
class is where LINQ's conveniences live. Here the shop counts its orders by status, and joins
customer names into one line of text:
var byStatus = orders
.GroupBy(o => o.Status)
.ToDictionary(g => g.Key, g => g.Count());
var csv = string.Join(", ", names);import static java.util.stream.Collectors.*;
var byStatus = orders.stream()
.collect(groupingBy(Order::status, counting()));
var csv = names.stream().collect(joining(", "));
// or simply: String.join(", ", names)import static java.util.stream.Collectors.*;Imports the static methods of
Collectors, sogroupingByandcountingneed no class name in front.groupingBy(Order::status, counting())Groups the orders by status, and counts each group instead of listing it. The result is a
Map<OrderStatus, Long>.joining(", ")Joins strings with a separator, like
string.Join.
These are the collectors you will use most:
| 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; added in Java 12 |
Collectors.toMap throws IllegalStateException on a duplicate key,
as LINQ's ToDictionary throws on one. But toMap also throws
NullPointerException when a value is null, which
ToDictionary accepts. Whenever duplicates are possible, use the three-argument
form, whose third argument says how to merge two values.
Parallel streams are the last everyday topic. The deeper sections after them cover newer and rarer tools.
Parallel streams#
parallelStream() looks like AsParallel(), and it is more dangerous.
It runs on a pool of threads shared by the whole program, the common
ForkJoinPool. So one slow parallel stream can hold up every other one in the
Java Virtual Machine (JVM), including ones inside libraries. It
also pays off only for large amounts of pure calculation that split evenly.
Rules of thumb: do not use it for I/O. Use
virtual threads for that instead, as
Threads are cheap now shows. Do not use it on a
LinkedList, or on a source backed by an Iterator. Measure before and
after, and for anything with a deadline, run the work on your own pool.
// I/O on the shared pool: one slow call holds up every parallel stream in the JVM
long up = urls.parallelStream().filter(this::isReachable).count(); // do not do this
// large, CPU-bound and easy to split: the case where it can pay off
double total = IntStream.range(0, 50_000_000).parallel().mapToDouble(Math::sqrt).sum();urls.parallelStream().filter(this::isReachable)Each
isReachablecall waits on the network while it holds one of the shared pool's few threads.IntStream.range(0, 50_000_000).parallel()Fifty million square roots: pure calculation, split evenly across the processor's cores.
Gatherers#
Streams long had no way to add your own intermediate operation. Stream
gatherers Java 24 fixed that, and came with built-in ones that cover
gaps LINQ fills. Here productIds is a list of strings, prices a list of
BigDecimal and quantities a list of Integer:
// Fixed-size batches, like LINQ's Chunk(n).
var batches = productIds.stream()
.gather(Gatherers.windowFixed(100))
.toList(); // List<List<String>>
// A sliding window of three neighbours.
var windows = prices.stream().gather(Gatherers.windowSliding(3)).toList();
// A running total, like a fold that keeps every step.
var running = quantities.stream().gather(Gatherers.scan(() -> 0, Integer::sum)).toList();Gatherers.windowFixed(100)Groups the elements into lists of 100, with a shorter last list, like
Chunk(100).Gatherers.windowSliding(3)Lists of three neighbours: the first three elements, then the second to the fourth, and so on.
Gatherers.scan(() -> 0, Integer::sum)A running total. It starts from 0, adds each element in turn, and passes on every total along the way.
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 a preview in Java 22 and 23.
Primitive streams#
Because generics cannot hold primitives, Java has IntStream,
LongStream and DoubleStream, which avoid
boxing every element. Here each order line has a
quantity:
int total = lines.stream()
.mapToInt(OrderLine::quantity) // Stream<OrderLine> to 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();.mapToInt(OrderLine::quantity)Turns a stream of order lines into a stream of plain
ints.IntStream.range(0, 10)The numbers 0 to 9. Unlike
Enumerable.Range, the second argument is where to stop, not how many to produce;rangeClosed(0, 10)would include 10..boxed()Wraps each
intin anInteger, so that the numbers can go into aList<Integer>.
| 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 |
No expression trees#
C# refresherExpression trees and IQueryable: a lambda as data
Assigned to an Expression<...>, a C# lambda becomes a data structure a
library can read. That is how EF Core turns a Where into SQL.
Expression<Func<Customer, bool>> adult = c => c.Age > 18; // data, not code
Console.WriteLine(adult.Body); // (c.Age > 18)
IQueryable<Customer> q = db.Customers.Where(c => c.Age > 18);
string sql = q.ToQueryString(); // ... WHERE [c].[Age] > 18C# 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
object-relational mapper (ORM) can do this. Instead, Jakarta Persistence (JPA), Java's standard
ORM, has you write queries as strings in the
Jakarta Persistence Query Language (JPQL), or build them with
an API:
| 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 domain-specific language (DSL) from your actual database schema, so a renamed column breaks the build. See Data access.
C# refresheryield return: a lazy sequence in C#
IEnumerable<int> Powers()
{
for (var n = 1; ; n *= 2) yield return n; // endless, computed on demand
}
var first10 = Powers().Take(10).ToList(); // 1, 2, 4 ... 512Java has no generators. To produce a lazy sequence, use Stream.iterate or
Stream.generate with a limit, or write an Iterator by
hand. Here are the powers of two, and a run of order numbers:
List<Integer> powers = Stream.iterate(1, n -> n * 2).limit(10).toList(); // 1, 2, 4 ... 512
List<Integer> ids = Stream.iterate(1000, n -> n < 1010, n -> n + 1).toList(); // 1000 ... 1009Stream.iterate(1, n -> n * 2).limit(10)An endless stream that starts at 1 and doubles each time.
limit(10)takes the first ten, likeTake(10)on an endlessyield return.Stream.iterate(1000, n -> n < 1010, n -> n + 1)The three-argument form, from Java 9, stops by itself when the test fails, like a
forloop.
The customer names showed the shape: stream(), then filter,
map and sorted in place of Where, Select and
OrderBy, ending with toList().
A stream can be used only once, so collect it into a List when you need the
results twice.
Collectors such as groupingBy, counting and joining do
the work of GroupBy, Count and string.Join.
Leave parallelStream alone unless the work is large, pure calculation, and
measured.
Java has no expression trees, so database queries are written in JPQL, with a criteria API or with jOOQ, not as lambdas.
Quick reference#
| LINQ | Stream | Note |
|---|---|---|
| Where | filter | |
| Select | map | |
| SelectMany | flatMap | |
| OrderBy | sorted(comparator) | |
| Take(n) | limit(n) | |
| Skip(n) | skip(n) | |
| TakeWhile | takeWhile | added in Java 9: items from the start while the test holds |
| SkipWhile | dropWhile | added in Java 9, named dropWhile: skips items while the test holds |
| Distinct | distinct() | compares with equals and hashCode, so implement both |
| Reverse | no direct equal | collect to a list, then call reversed(); a stream cannot reverse |
| Concat | Stream.concat(a, b) | a static method taking exactly two streams; nest it for more |
| Any() | findAny().isPresent() | for a collection, simply !list.isEmpty() |
| Any(p) | anyMatch(p) | |
| All(p) | allMatch(p) | |
| Count() | count() | returns a long, not an int, so a cast may be needed |
| First() | findFirst().orElseThrow() | throws NoSuchElementException when the stream is empty |
| FirstOrDefault() | findFirst().orElse(null) | Java has no default(T); orElse supplies the fallback |
| Single() | no direct equal | collect to a list and check its size is exactly one |
| Sum() | mapToInt(f).sum() | sum() lives on IntStream, so map to int first |
| Average() | mapToInt(f).average() | returns an OptionalDouble, empty when there are no items |
| Min / Max | min(cmp) / max(cmp) | return an Optional, empty for an empty stream, instead of throwing |
| Aggregate | reduce | reduce(seed, (acc, x) -> ...) is the form with a starting value |
| ToList() | toList() | added in Java 16; the list it returns cannot be modified |
| ToArray() | toArray(String[]::new) | pass String[]::new, or you get an Object[] |
| ToDictionary | collect(toMap(k, v)) | throws on a duplicate key, and on a null value |
| GroupBy | collect(groupingBy(f)) | gives a Map of lists; add a second collector to count instead |
| Zip | no equivalent | use IntStream.range over indices |
| Chunk(n) | Stream.gather(Gatherers.windowFixed(n)) | Java 24 gatherers; on Java 21, use Guava's Lists.partition |
| DefaultIfEmpty | no equivalent | no operator; test for empty and supply the default yourself |
| AsParallel() | parallelStream() | shares one JVM-wide pool; riskier than it looks |
Files and I/O#
Java's Files class is System.IO.File, and Path is the
path type. Almost everything you need is a static method on Files.
Two habits matter. Anything that returns a Stream must be
closed, so it goes in a try-with-resources
block. And always pass a character set when you turn bytes into text, because a
machine's default has garbled text in every language that has one.
The everyday operations#
The shop exports each day's orders to data/orders.csv, and an import job reads
them back. Here are the everyday file operations in both languages. src,
dst and dir are more paths, and more is extra text to
add:
string path = Path.Combine("data", "orders.csv");
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);Path p = Path.of("data", "orders.csv");A
Pathjoins its parts with the right separator for the operating system, likePath.Combine. It is an object, not a string.Files.readString(p)Reads the whole file as UTF-8 text. It arrived in Java 11, and
readAllLinesgives you a list of lines instead.StandardOpenOption.APPENDwriteStringreplaces the file unless you pass this option, which adds to the end instead, likeAppendAllText.Files.deleteIfExists(p);Files.deletethrowsNoSuchFileExceptionif the file is missing, whereFile.Deletequietly does nothing.deleteIfExistsbehaves like the C# method.StandardCopyOption.REPLACE_EXISTINGWithout this option,
copythrows if the target already exists.
Names, lazy reading and folder listings look like this. Notice that every lazy Java call sits
in a try block:
string name = Path.GetFileName(path); // "orders.csv"
string ext = Path.GetExtension(path); // ".csv"
foreach (var line in File.ReadLines(path)) { } // lazy
string[] files = Directory.GetFiles("data");
var all = Directory.EnumerateFiles(
"data", "*", SearchOption.AllDirectories);
string tmp = Path.GetTempFileName();
using var reader = new StreamReader(path);String name = p.getFileName().toString();
String ext = name.substring(name.lastIndexOf('.'));
try (var lines = Files.lines(p)) { } // lazy
try (var files = Files.list(Path.of("data"))) { }
try (var all = Files.walk(Path.of("data"))) { }
Path tmp = Files.createTempFile("orders", ".csv");
try (var reader = Files.newBufferedReader(p)) { }p.getFileName().toString()getFileNamereturns aPath, andtoStringturns it into text:orders.csv.name.substring(name.lastIndexOf('.'))Java has no
GetExtension, so you take the text from the last dot:.csv.try (var lines = Files.lines(p)) { }A try-with-resources block, Java's
using: whatever is declared in the brackets is closed when the block ends.Files.linesreads lazily, likeFile.ReadLines, but holds the file open until it is closed.Files.list(Path.of("data"))The entries of one folder, like
Directory.GetFiles, as a stream.Files.walk(Path.of("data"))Every file and folder below
data, likeEnumerateFileswithAllDirectories.Files.createTempFile("orders", ".csv")Creates an empty temporary file with that prefix and suffix, and returns its path.
Files.lines, Files.walk and Files.list
return a Stream that holds an open file handle. Unlike
File.ReadLines in .NET, the stream does not let go of the file when it reaches the
end, and on Windows the file stays locked. So they must go in a try-with-resources. Here the
import job counts the lines that are not blank:
// leaks a file handle
long leaky = Files.lines(p).filter(l -> !l.isBlank()).count();
// correct
try (var lines = Files.lines(p)) {
long count = lines.filter(l -> !l.isBlank()).count();
}long leaky = Files.lines(p).filter(l -> !l.isBlank()).count();count()finishes the stream, but nothing closes it, so the file can stay open for as long as the program runs.try (var lines = Files.lines(p)) {The stream is closed when the block ends, even if an exception is thrown inside it.
This is the most common Java file-handling bug. It usually shows up as “too many open files” under load, rather than as an obvious failure.
That is all most code needs. The deeper sections cover character sets, the layers of Java's older stream classes, walking folder trees, and files packaged inside your application.
Charsets, and why to always pass one#
Java's older I/O classes use the machine's default character set, or charset, when
you do not pass one. So the same code can produce different text on a developer's Mac and in a
Linux container. This is the classic cause of garbled accented letters in Java systems. Here
bytes holds text received from another system, and file is a
File:
// 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, safenew String(bytes);Turns the bytes into text with the machine's default charset, whatever that happens to be.
new String(bytes, StandardCharsets.UTF_8);Names the charset, so the result is the same on every machine.
Java 18 changed the default charset to UTF-8 for most APIs, which removes most of this hazard
on a modern Java Development Kit (JDK). On Java 17 and earlier
it is still real, and some APIs, notably System.out, still follow the console's
encoding. Passing the charset 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 |
Reading the first line of a file through these layers, and copying one file to another, look like this:
import static java.nio.charset.StandardCharsets.UTF_8;
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
}new BufferedReader(new InputStreamReader(in, UTF_8))Three layers: the file's bytes, an
InputStreamReaderthat turns bytes into characters with UTF-8, and aBufferedReaderthat reads ahead in large blocks. AStreamReaderdoes all three jobs in .NET.try (var in = Files.newInputStream(p);A try-with-resources can open several things, separated by semicolons. They are closed in reverse order when the block ends.
in.transferTo(out);Copies everything from one stream to the other, like C#'s
CopyTo. It arrived in Java 9.
Reading one byte or character at a time without a buffer is dramatically slower in Java
than the same .NET code. In .NET, FileStream buffers by default, and Java's
streams do not. Wrap them in a Buffered* class unless you have a reason not
to.
Walking a directory tree#
The import job looks for export files in a folder tree. Files.walk visits every
entry below a folder, and newDirectoryStream matches names in one folder against a
pattern. Here dir is the folder that holds the exports:
// every .csv file under data, as a list
try (var paths = Files.walk(Path.of("data"))) {
List<Path> exports = paths
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".csv"))
.toList();
}
// at most two levels deep, following symbolic links
try (var paths = Files.walk(dir, 2, FileVisitOption.FOLLOW_LINKS)) {
paths.forEach(System.out::println);
}
// names that match a pattern, in one folder
try (var found = Files.newDirectoryStream(dir, "*.{csv,tsv}")) {
for (Path p : found) System.out.println(p.getFileName());
}.filter(Files::isRegularFile)Keeps the files and drops the folders.
Files.walk(dir, 2, FileVisitOption.FOLLOW_LINKS)Goes at most two levels down, and follows symbolic links, which
walkotherwise does not.Files.newDirectoryStream(dir, "*.{csv,tsv}")Lists one folder, keeping the names that match the pattern: here, files ending in
.csvor.tsv.
| .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
Java archive (JAR) and read through the
classpath. When the program runs, it is not a file
on disk. Opening 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. Read it as a resource instead, as you would read an embedded resource in .NET:
// 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);
}.getResourceAsStream("/config.json")Finds the resource on the classpath and opens it as a stream of bytes, or returns
nullif there is none.in.readAllBytes()Reads every byte, and
new String(..., UTF_8)turns them into text.
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.The order export showed that Files holds the everyday operations as static
methods that take a Path, and that readString and
writeString use UTF-8.
Files.lines, walk and list hold the file open, so they
always go in a try-with-resources.
Pass a charset whenever you turn bytes into text, and wrap raw streams in a buffered one.
Files packaged in the JAR are resources, read with getResourceAsStream, not files
on disk.
Quick reference#
| System.IO | java.nio.file | Note |
|---|---|---|
| File.ReadAllText | Files.readString(p) | added in Java 11; reads UTF-8 unless you pass a charset |
| File.ReadAllLines | Files.readAllLines(p) | reads the whole file into memory; use Files.lines for big files |
| File.ReadLines (lazy) | Files.lines(p) | returns a Stream, so it must be closed |
| File.WriteAllText | Files.writeString(p, s) | added in Java 11; writes UTF-8 and replaces the file |
| File.AppendAllText | Files.writeString(p, s, APPEND) | pass StandardOpenOption.APPEND to add to the end |
| File.Exists | Files.exists(p) | |
| File.Delete | Files.delete(p) / deleteIfExists(p) | delete throws NoSuchFileException if the file is missing |
| File.Copy / Move | Files.copy / Files.move | pass REPLACE_EXISTING to overwrite |
| Directory.CreateDirectory | Files.createDirectories(p) | creates any missing parent folders too, like CreateDirectory |
| Directory.GetFiles | Files.list(dir) | one folder only, as a Stream that must be closed |
| Directory.EnumerateFiles(recursive) | Files.walk(dir) | the whole tree, as a Stream that must be closed |
| Path.Combine("a","b") | Path.of("a","b") | or dir.resolve("b") to add one part to an existing Path |
| Path.GetFileName | p.getFileName() | returns a Path; call toString() for the name as text |
| Path.GetExtension | no equivalent | no method; take the text after the last dot yourself |
| FileStream | Files.newInputStream / newOutputStream | byte streams; wrap them in a Buffered stream for speed |
| StreamReader | Files.newBufferedReader(p) | a buffered text reader, UTF-8 unless you say otherwise |
| MemoryStream | ByteArrayInputStream / ByteArrayOutputStream | in-memory byte streams, one class for each direction |
| Path.GetTempFileName | Files.createTempFile(prefix, suffix) | creates an empty temporary file and returns its Path |
| FileSystemWatcher | WatchService | much lower level than the .NET one |
Threads are cheap now#
Stop looking for await. There isn't one, and you don't need one.
C# made waiting on I/O cheap by making methods asynchronous. Java made it cheap by making
threads almost free. Since Java 21 you write ordinary blocking code, run it on a virtual
thread, and the runtime does the work that await does in C#.
So Java has no function colouring: no async spreading to every caller, no
Task<T> in your signatures, no deadlocks from blocking on async code, and no
ConfigureAwait.
The colour problem, and how Java sidestepped it#
Building an order report needs the customer and their orders, each fetched from another
service. In C#, the moment a method awaits, its signature changes to async Task,
and so does every caller's, all the way up. That is called function colouring. Java
never introduced the colour, so the same method works everywhere:
// 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));async Task<Report> BuildAsync(int id)The C# method is
async, so it returns aTask, and every caller must await it or block on it.Customer c = api.getCustomer(id); // blocksThe Java call simply waits for the answer. The thread is blocked until the customer arrives.
Thread.startVirtualThread(() -> build(7));Runs
buildon a new virtual thread. While it waits, the thread costs almost nothing.
The Java version blocks twice. On an ordinary thread, that would tie up a whole
operating-system thread while it waits. On a virtual thread it costs almost nothing. When
getCustomer waits for the network, the
Java Virtual Machine (JVM) takes the virtual thread off the
real thread it was running on, called its carrier. It keeps the virtual thread's state
on the heap. The carrier goes off to run something else. That is
what await does in .NET, except that no compiler rewrote your method, and its
signature never changed.
C# refresherConfigureAwait(false): not resuming on the caller's context
By default await resumes on the context it started on, such as a UI thread.
Library code adds ConfigureAwait(false) to resume on any pool thread, which
avoids a class of deadlocks.
var json = await http.GetStringAsync(url).ConfigureAwait(false);
// continues on a thread-pool thread, not the caller's synchronisation contextJava has no async or await keywords, and is not getting them. The
equivalent is where you run the code, not how you write it. If you find
yourself hunting for Java's await, the answer is always the same: put the code on
a virtual thread, and write it straight.
So the question becomes how to start virtual threads.
Creating and running them#
There are three ways, in rough order of how often you will want them. Here
ids is a list of order ids, and handle(id) processes one order:
// 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();Executors.newVirtualThreadPerTaskExecutor()An executor that starts a new virtual thread for every task you submit. It is not a pool: nothing is reused.
exec.submit(() -> handle(id));Submits one task, like
Task.Run. It returns aFuture, Java'sTask.} // close() waits for every task to finishThe try-with-resources block closes the executor at the end, and closing it waits for every submitted task.
Thread.startVirtualThread(() -> handle(7));Starts one virtual thread straight away.
join()waits for it to finish, likeWait()on a task..name("import-", 0)Names the threads
import-0,import-1and so on, which helps when you read logs or thread dumps.
Here is everyday C# task code with its Java version. In C#, ct is a
CancellationToken. Java cancels by interrupting the thread instead, as the next
chapter explains:
var a = Task.Run(() => Handle(7));
var b = Task.Run(() => Handle(8));
await Task.WhenAll(a, b);
await Task.Delay(TimeSpan.FromSeconds(1), ct);
using var gate = new SemaphoreSlim(20);
await gate.WaitAsync(ct);
try { await CallDbAsync(ct); } finally { gate.Release(); }try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
var a = exec.submit(() -> handle(7));
var b = exec.submit(() -> handle(8));
a.get(); b.get(); // wait for both
}
Thread.sleep(Duration.ofSeconds(1)); // interrupt() cancels it
var gate = new Semaphore(20);
gate.acquire();
try { callDb(); } finally { gate.release(); }a.get(); b.get();getwaits for a task and returns its result, like awaiting aTask. Together the two lines do the job ofTask.WhenAll.Thread.sleep(Duration.ofSeconds(1));Blocks only the virtual thread, so sleeping is cheap. Calling
interrupt()on the thread ends the sleep early with anInterruptedException.var gate = new Semaphore(20);Lets at most 20 threads past
acquireat once, likeSemaphoreSlim.releaselets the next one in.
C# refresherIAsyncEnumerable and await foreach: an async stream
An async iterator hands back each value when it is ready, and the caller consumes them
with await foreach.
async IAsyncEnumerable<int> TicksAsync()
{
for (var i = 0; i < 3; i++)
{
await Task.Delay(1000);
yield return i;
}
}
await foreach (var t in TicksAsync()) Console.WriteLine(t);ExecutorService has been AutoCloseable since Java 19, which is why
try (var exec = ...) works above. The block does not exit until every submitted
task has finished, so you get a tidy join for free.
What a virtual thread actually isdeep dive
A virtual thread is a Thread whose stack lives on the Java heap, rather than in
a fixed stack reserved by the operating system. Two numbers explain the whole design:
- A platform thread, the ordinary kind, 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 as needed. Millions is routine.
Virtual threads run on a small pool of carrier threads, sized by default to the number of
processors. Most blocking operations in the
Java Development Kit (JDK) know about virtual threads, such as
network calls, Thread.sleep and most locks. When a virtual thread blocks in one of
them, the runtime copies its stack to the heap and frees the carrier. When the operation
completes, the virtual thread carries on, on whichever carrier is free.
You can size the carrier pool with
-Djdk.virtualThreadScheduler.parallelism=N, but the default is nearly always
right. The .NET thread pool adds threads slowly when all of them are blocked. The carrier pool
rarely needs to grow, because a blocked virtual thread gives its carrier back.
Virtual threads make waiting cheap. They are not the answer to everything, and three mistakes are common.
When virtual threads are the wrong tool#
Virtual threads do nothing for CPU-bound work. They make waiting cheap, not calculating. A million virtual threads multiplying matrices run exactly as fast as the carrier threads allow, plus a little overhead.
For work that keeps the processor busy, keep a pool of ordinary threads sized close to the
number of processor cores, the same instinct you already have from
Parallel.For.
Never pool virtual threads. Pools exist to spread the cost of creating a
thread. Virtual threads are so cheap to create that pooling them only adds contention, and
brings back the queueing the design removes.
newVirtualThreadPerTaskExecutor() creates a new one for every task on purpose.
To limit concurrency, for example to protect a database that allows 20 connections, use a
Semaphore, not a pool. Limit the resource, not the number of
threads.
ThreadLocal still works, but scales
badly. One ThreadLocal value per thread was fine with 200 threads. With
500,000 it is a memory problem. Use ScopedValue Java 25 instead. It
is also the closer match to AsyncLocal<T>: it cannot be changed, and it
lasts only for a block of code, rather than until the thread ends.
C# refresherAsyncLocal: a value that flows with the async call
static readonly AsyncLocal<string?> User = new();
User.Value = "ada";
await Task.Run(() => Console.WriteLine(User.Value)); // "ada": it flows into the taskPinning: the one performance trapdeep dive
A virtual thread that cannot be taken off its carrier while it is blocked is
pinned: it holds the carrier hostage. On Java 21 to 23 there were two causes. One was blocking
inside a synchronized block, Java's version of C#'s
lock statement. The other was blocking inside native code called through the Java
Native Interface (JNI).
JDK Enhancement Proposal (JEP) 491 fixed the
synchronized case in Java 24 Java 24. Virtual threads now
come off their carrier correctly while blocked inside a synchronized block. So the
old advice to rewrite every one of them as a ReentrantLock no longer applies, and
on Java 24 and later only native code pins.
If you are on Java 21, the old workaround still matters. Here monitor is any
object used as a lock, lock is a ReentrantLock, and
db.query waits for the database:
// pinned the carrier on Java 21-23 while waiting on I/O
synchronized (monitor) {
var row = db.query(sql);
}
// comes off the carrier correctly on every version
lock.lock();
try {
var row = db.query(sql);
} finally {
lock.unlock();
}synchronized (monitor) {Holds
monitoras a lock, like C#'slock (monitor). On Java 21 to 23, waiting on I/O inside it pinned the carrier. Mutual exclusion coverssynchronizedin full.lock.lock();Takes a
ReentrantLockexplicitly, andunlockin thefinallyblock releases it. Waiting while holding it never pinned.
To find pinning on Java 21 to 23, run with -Djdk.tracePinnedThreads=full, which
prints a stack trace whenever a virtual thread blocks while pinned. Current Java versions no
longer have that option. Use the JDK Flight Recorder event jdk.VirtualThreadPinned
instead.
Blocking becomes the normal style again#
Once blocking is cheap, patterns that felt expensive in .NET become ordinary. Retrying a
blocking call is the clearest example. It is also one of the places where Spring Boot 4
changed: retry support moved from an add-on library into Spring Framework itself. Here a
client fetches an exchange rate from another service, where restClient is a Spring
RestClient pointed at that service:
// Boot 3.5: needs the Spring Retry dependency,
// or Resilience4j, as an explicit add-on.
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.retry.annotation.Retryable;
@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 is part of Spring Framework 7 itself.
// No extra dependency.
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.resilience.annotation.Retryable;
@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);
}
}In both versions, @Retryable wraps fetch so that a failed call is
tried again, and fetch itself stays a plain blocking method. Watch the number,
though. In Boot 3, maxAttempts = 3 means three calls in total. In Boot 4,
maxRetries = 3 means three retries after the first call, so up to four calls.
@EnableRetry and @EnableResilientMethods switch the feature on.
Take Boot 4's annotation from org.springframework.resilience.annotation, as the
imports show. Spring Framework 7 also has an interface called Retryable, in
org.springframework.core.retry, and an IDE may offer that one first.
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: trueWith spring.threads.virtual.enabled set to true, the embedded web
server handles each request on a new virtual thread instead of a thread from a fixed
pool.
What you'll still meet in existing code#
LegacyExecutors.newFixedThreadPool(n)
Before Java 21, this was the default for I/O work: a pool whose size was guessed, then tuned
after each incident. You will see it everywhere, and it still works. For I/O work on Java 21
and later, replace it with Executors.newVirtualThreadPerTaskExecutor() and delete
the size. Keep a fixed pool only for work that keeps the processor busy.
LegacyCompletableFuture chains as the async style
Before virtual threads, non-blocking Java meant chaining thenApply,
thenCompose and thenCombine. It is 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();The two supplyAsync calls start the fetches, thenCombine merges
the results when both arrive, and join waits for the merged value.
CompletableFuture is still the right tool when you need a value that
completes later, as the next chapters show. It is no longer the right tool just for running two
blocking calls at once.
LegacyThread.stop() and Thread.suspend()
Both have been unsafe since Java 1.2. suspend() was removed in Java 23.
stop() still compiles against Java 25, but it throws instead of stopping anything,
and Java 26 removes it. There has never been a safe way to kill a thread from outside, in Java
or in .NET. Cancellation is cooperative: interrupt the thread, and let it 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 |
Put together, the report from the start of this chapter runs like this:
// no async signature, no tuned pool, and nothing left running unwatched
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Report> report = exec.submit(() -> build(7)); // build() stays blocking
publish(report.get());
} // close() waits for anything still runningbuild is the same blocking method as before. submit runs it on a
virtual thread, and report.get() waits for the result.
The next chapter covers what this one left out: running several blocking calls at once and
joining them, with proper cancellation. It is Java's answer to Task.WhenAll plus a
CancellationToken.
The order report showed that Java has no function colouring: build stays an
ordinary blocking method, and running it on a virtual thread makes the waiting cheap.
newVirtualThreadPerTaskExecutor starts a virtual thread for each task, and
closing it in a try-with-resources block waits for them all.
Virtual threads help only with waiting: keep a sized pool for calculation, limit resources
with a Semaphore, and prefer ScopedValue to
ThreadLocal.
Since Java 24, blocking inside synchronized no longer pins a virtual
thread.
Quick reference#
| .NET | Java 21+ | Notes |
|---|---|---|
| async Task<T> BuildAsync() | T build() | write it blocking, and run it on a virtual thread |
| 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) | blocks only the virtual thread, so sleeping is cheap |
| CancellationToken | Thread.interrupt() | cooperative in both: the target must check, or be blocked in a call that does |
| IAsyncEnumerable<T> | no direct equal | no async streams; a producer thread feeding a BlockingQueue is the usual shape |
| AsyncLocal<T> | ScopedValue | final in Java 25; immutable, and bound to one block rather than ambient |
| SemaphoreSlim | Semaphore | still needed to limit concurrency |
| ThreadPool.QueueUserWorkItem | ExecutorService.submit | a platform-thread pool, for CPU-bound work |
Structured concurrency and cancellation#
Structured concurrency is Java's answer to Task.WhenAll plus a
CancellationToken. You start several subtasks inside a scope and wait for them
together, and the scope guarantees that none of them outlives it. If one fails, the others are
cancelled automatically.
It is still a preview feature in Java
25 Java 25 preview: the fifth preview, and its API has
changed between previews. For production code on Java 21 or 25, use the
ExecutorService pattern at the end of this chapter instead.
Structured concurrency needs --enable-preview when you compile and again when
you run. Class files built with preview features will
not load on any other Java Development Kit (JDK) version. Blog
posts from 2023 or 2024 use an older API shape, with
new StructuredTaskScope<>() and ShutdownOnFailure, which no
longer matches Java 25. Java 26, the sixth preview, changes it again: for example,
anySuccessfulResultOrThrow() is renamed anySuccessfulOrThrow().
The problem it solves#
The order report needs the customer and their orders, from two different services. The two calls should run at the same time. If either fails, the report should fail at once, and nothing may be left running afterwards. Here is that 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());
}
}StructuredTaskScope.open()Opens a scope. By default it waits for every subtask, and fails as soon as one of them fails.
scope.fork(() -> fetchCustomer(id));Starts a subtask on its own virtual thread, and returns a handle to its future result.
scope.join();Waits for the subtasks. If one failed,
jointhrows, and the other subtask is cancelled.merge(a.get(), b.get())Reads each subtask's result, which is ready once
joinhas returned.
The try block is the cancellation scope. Leaving it, whether normally, by an
exception or by an interrupt, cancels every subtask still running. There is no token to pass
down through your calls, because the scope is the token, and the block bounds it.
C# refresherCancellationTokenSource, CancelAfter and Task.WhenAny
A token source cancels every task that holds its token, by hand or after a timeout, and
WhenAny finishes as soon as the first task does.
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(2)); // cancel automatically after 2 s
try
{
var first = await Task.WhenAny(PrimaryAsync(cts.Token), BackupAsync(cts.Token));
cts.Cancel(); // stop the one still running
Use(await first);
}
catch (OperationCanceledException) { } // the 2 s ran out
void Work(CancellationToken ct) => ct.ThrowIfCancellationRequested();Cancellation itself works differently in Java, and that is the next section. First, the policies a scope can follow.
Completion policies#
open() with no argument waits for every subtask and fails fast on the first
failure. Passing a Joiner chooses a different policy, and this is where
WhenAll and WhenAny differ. Here the shop asks two exchange-rate
services for the same rate, and takes whichever answers first:
// 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
}StructuredTaskScope.Joiner.<Rate>anySuccessfulResultOrThrow()A policy that finishes with the first successful result and cancels the rest, like
Task.WhenAnyfollowed by cancelling the losers.<Rate>names the result type, which Java cannot work out here on its own.return scope.join();With this policy,
joinreturns the winning value.
These are the policies Java 25 offers:
| 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 |
Why a scope rather than a token#
The idea is the same one behind structured programming. A task started with
executor.submit() is like a goto: its lifetime has nothing to do with
the code that started it, so tasks can leak or be orphaned. A scope ties each subtask's
lifetime to the block of code that started it. A subtask cannot outlive that block, and thread
dumps show which task started which.
C#'s CancellationToken gives you cancellation, but not containment: nothing
stops you forgetting to await a task, and nothing makes it stop when its caller returns.
In code, the difference looks like this. refreshCache reloads the shop's
product cache:
// unstructured: the task can outlive the method that started it
void start() { executor.submit(this::refreshCache); } // who waits for it? nobody
// structured (preview): the subtask cannot outlive the block
void refresh() throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
scope.fork(this::refreshCache);
scope.join(); // finished before the block ends
}
}void start() { executor.submit(this::refreshCache); }Starts the refresh and returns at once. Nothing waits for it, and nothing stops it if the caller is cancelled.
scope.fork(this::refreshCache);The refresh runs inside the scope, so the block cannot end until the refresh has finished.
Cancellation is interruption#
Java cancels a thread by interrupting it with Thread.interrupt(). Like a
CancellationToken, it is cooperative: it sets a flag on the thread, and blocking
calls in the JDK throw InterruptedException when they see the flag. Here a long
import works through the order file in chunks, and must stop when it is cancelled:
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;
}
}
}while (!Thread.currentThread().isInterrupted())Checks the interrupt flag between chunks, like checking
ct.IsCancellationRequested.Thread.sleep(100);A blocking call such as
sleepthrowsInterruptedExceptionwhen the thread is interrupted, instead of waiting out its time.Thread.currentThread().interrupt();Catching the exception clears the flag, so this line sets it again for the code above, as the warning below explains.
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 Java's version 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.
Cancellation travels down the thread. Context such as the current user needs to travel too, and Java 25 has a final API for that.
ScopedValue replaces AsyncLocal#
Scoped values Java 25 are final in Java 25, so unlike structured concurrency they are ready for production. They carry context, such as the current user, down a chain of calls without passing it as a parameter, and forked subtasks inherit them:
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 blockScopedValue.newInstance()Creates the key. It usually lives in a
static finalfield, like the staticAsyncLocalin C#.ScopedValue.where(USER, "ada").run(() -> {Binds
USERto"ada"while the lambda runs. Code called from inside reads it withUSER.get().
When run returns, USER is unbound again, so the value cannot leak
into later work. Here is how the two compare:
| 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 on
virtual threads. A ThreadLocal holds one value per thread, so a million virtual
threads means a million values.
What to write on Java 25#
Until structured concurrency is final, this is the production-safe version of the order report, using only final APIs. It gives you the first failure 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
}Executors.newVirtualThreadPerTaskExecutor()The executor from the previous chapter: one virtual thread per task.
merge(a.get(), b.get())getwaits for each result. If a task failed,getthrowsExecutionException, which wraps the task's own exception.
This pattern does not cancel the other task when one fails. close()
waits for it to finish rather than interrupting it. If cancelling matters, call
b.cancel(true) in a catch block, or use invokeAny or
invokeAll, which follow their own rules. This gap is exactly what structured
concurrency exists to close.
The order report showed the pattern: open a scope, fork the customer and order calls, join, then read the results. Leaving the block cancels anything still running.
A Joiner such as anySuccessfulResultOrThrow gives
WhenAny behaviour: the first success wins, and the rest are cancelled.
Cancellation is interruption: check the flag, and when you catch
InterruptedException, restore it with
Thread.currentThread().interrupt().
ScopedValue is final in Java 25, but structured concurrency is still a preview,
so production code uses a virtual-thread executor and cancels the other task by hand.
Quick reference#
| .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 |
CompletableFuture and Task#
CompletableFuture<T> is Java's Task<T>: a value that
arrives later, with methods to chain more work onto it. C# hid the chaining behind
await, and Java never did. So code that uses CompletableFuture reads
like C# from before await, full of ContinueWith.
Since virtual threads, you need it far less. Use it when you genuinely need a value that completes later, such as the reply from a callback-based API, not just to run two blocking calls at once.
Translation#
The shop's payment provider ships a client library that reports each reply through a
callback, not a return value. Turning that into a value you can wait for is the classic job for
CompletableFuture. Here is the everyday API in both languages.
compute() is some calculation, client is the payment client, and
a and b are two tasks already running:
Task<int> ready = Task.FromResult(42);
Task<int> work = Task.Run(() => Compute());
var tcs = new TaskCompletionSource<string>();
client.OnReply += r => tcs.SetResult(r); // completed by hand
string reply = await tcs.Task;
await Task.WhenAll(a, b);
var first = await Task.WhenAny(a, b);var ready = CompletableFuture.completedFuture(42);
var work = CompletableFuture.supplyAsync(() -> compute());
var reply = new CompletableFuture<String>();
client.onReply(reply::complete); // completed by hand
String text = reply.join();
CompletableFuture.allOf(a, b).join();
Object first = CompletableFuture.anyOf(a, b).join();CompletableFuture.completedFuture(42)A future that is already complete, like
Task.FromResult.CompletableFuture.supplyAsync(() -> compute())Runs the lambda on another thread, and completes the future with its result, like
Task.Run.new CompletableFuture<String>()An empty future, like a
TaskCompletionSource. Nothing completes it until something callscomplete.client.onReply(reply::complete);When the reply arrives, the client calls
complete, which fills in the future.reply.join()Waits for the value, like
.Result. On a virtual thread, waiting is cheap.Object first = CompletableFuture.anyOf(a, b).join();anyOf's result has the typeObject, so you have to cast it back to the type you expect.
The quick reference at the end of this chapter lists every Task operation with
its CompletableFuture version. If IProgress is hazy, here it is:
C# refresherIProgress: reporting progress from async work
var progress = new Progress<int>(pct => bar.Value = pct); // runs on the caller's context
await ImportAsync(file, progress);
async Task ImportAsync(string file, IProgress<int> progress)
{
progress.Report(50); // half way
}Chaining is where CompletableFuture differs most from await, and
the deeper sections start there.
Chaining#
Suppose fetching a customer returns a future, decorating the customer is a plain
calculation, and enriching it returns another future. In C# you would await each step. With
CompletableFuture you chain the steps:
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();.thenApply(this::decorate)Runs
decorateon the result when it arrives, likeContinueWithwith a plain function..thenCompose(this::enrichAsync);Use
thenComposewhen the next step returns a future itself. It flattens the result, likeUnwrapafterContinueWith.Report report = f.join();Waits for the whole chain to finish.
These are the chaining methods, and where each one runs. An executor is an object that supplies the threads:
| 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. Task.WhenAll gives you a
Task<T[]>, but after allOf you go back and read each future
yourself. Here ids is a list of order ids, and load(id) fetches one
order:
import static java.util.concurrent.CompletableFuture.supplyAsync;
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();.map(id -> supplyAsync(() -> load(id)))Starts one future for each id.
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();allOftakes an array, so the list is converted first, andjoinwaits for every future..map(CompletableFuture::join)Collects each result.
joinreturns at once here, because every future has already completed.
Which thread does the work#
The *Async methods without an executor argument run on
ForkJoinPool.commonPool(). That pool has one thread fewer than your processor has
cores, and it is shared with parallel streams and everything else in the
Java Virtual Machine (JVM). Blocking on it starves
everything.
Always pass an executor for
anything that blocks. Here httpCall waits for another service:
var exec = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture.supplyAsync(() -> httpCall(), exec) // blocking, but on a virtual thread
.thenApplyAsync(this::transform, exec);Executors.newVirtualThreadPerTaskExecutor()The executor from Threads are cheap now: each task gets its own virtual thread.
.thenApplyAsync(this::transform, exec);Every
*Asyncstep takes the executor as its last argument. Leave it out, and that step moves back to the common pool.
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.
C# refresherAggregateException: several failures wrapped in one
try { Task.WaitAll(a, b); }
catch (AggregateException ex)
{
foreach (var inner in ex.InnerExceptions) Log(inner); // the real exceptions
}Here future is a CompletableFuture<Report>, and
log is the class's logger:
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);} catch (CompletionException e) {joinwraps the task's exception in aCompletionException.Throwable cause = e.getCause();The exception the task actually threw.
future.exceptionally(ex -> {Handles a failure inside the chain. The lambda receives the exception and returns a replacement value, here an empty report, so
publishstill runs.
get() throws the checked ExecutionException and
InterruptedException, while join() throws the 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#
With virtual threads, most concurrent code can simply block. These are the cases where
CompletableFuture is still the right tool:
| 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(...) |
The callback case looks like this. Here client is a messaging client that
reports each reply, or each error, through a callback:
// adapting a callback API: the future is completed by hand
CompletableFuture<Reply> send(Request request) {
var done = new CompletableFuture<Reply>();
client.send(request, done::complete, done::completeExceptionally);
return done;
}var done = new CompletableFuture<Reply>();An empty future, to be completed later.
client.send(request, done::complete, done::completeExceptionally);The client calls
completewith the reply, orcompleteExceptionallywith an error. Either call finishes the future.return done;The caller gets a value it can wait for or chain onto, like the
Taskof aTaskCompletionSource.
LegacyFuture, the Java 5 interface
The original Future from Java 5 has no chaining methods at all. You can only
get(), which blocks, cancel() and isDone(). It is what
ExecutorService.submit returns, and it is perfectly good when you intend to block
on a virtual thread anyway. CompletableFuture implements Future, so
it does everything Future does, and more.
CompletableFuture is Task: completedFuture,
supplyAsync and new CompletableFuture<>() play the parts of
FromResult, Task.Run and TaskCompletionSource.
thenApply and thenCompose play ContinueWith and
Unwrap, and allOf carries no results, so you read each future
afterwards.
Pass an executor to every *Async method that may block, or the work lands on
the shared common pool.
join wraps failures in CompletionException, and with virtual
threads most code can simply block instead.
Quick reference#
| .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<>() | create it empty, then call complete(value) when the result arrives |
| .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) | completes when all do, but carries no results; read each future |
| Task.WhenAny(a, b) | CompletableFuture.anyOf(a, b) | the result is typed Object, so you must cast it back |
| combine two results | .thenCombine(other, fn) | waits for both, then merges; .NET has no single call |
| .Result / .GetAwaiter().GetResult() | .join() | join() throws an unchecked CompletionException wrapping the cause |
| await task | .join(), or just block on a virtual thread | |
| try/catch around await | .exceptionally(fn) or .handle(fn) | |
| Task.Delay | CompletableFuture.delayedExecutor(...) | an executor that runs a task after a delay; added in Java 9 |
| IProgress<T> | no equivalent | pass a Consumer<Integer> and call it as the work progresses |
Locks, atomics and the memory model#
The basic tools line up closely: synchronized is lock,
AtomicInteger is Interlocked, and ConcurrentHashMap is
ConcurrentDictionary.
The keyword to be careful with is volatile. It exists in both languages, but
Java's is stronger: every volatile read and write happens in one order that all threads agree
on. For the everyday uses, a stop flag and lazy initialisation, the two behave the same.
Mutual exclusion#
The shop keeps a running total of today's sales, and several threads add to it at once.
Without a lock, two threads can read the same old total, and one of the additions is lost. In
C# you wrap the update in lock. In Java you wrap it in
synchronized:
private readonly object _gate = new();
private long _total;
public void Add(int x)
{
lock (_gate)
{
_total += x;
}
}private final Object gate = new Object();
private long total;
public void add(int x) {
synchronized (gate) {
total += x;
}
}private final Object gate = new Object();A private object used only as the lock, like C#'s
_gate. Any object can serve as a lock in Java.synchronized (gate) {Only one thread at a time can be inside a block synchronized on
gate. The others wait, as they would atlock (_gate).
Java also allows synchronized as a method modifier, which locks on
this, or on the class itself for a static method:
public synchronized void add(int x) { total += x; } // locks on thisEvery call to add now takes the lock of the object itself, as if the whole body
were inside synchronized (this). That is convenient, and it is also the trap in the
next warning.
Never synchronized on this or on a public field.
Any other code that holds a reference to your object can lock on it too, and then your class
only works if strangers behave. Use a private final lock object. The same advice applies to
lock (this) in C#, 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, a wait that can be interrupted, fairness, or several conditions to
wait on, 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) |
Here a stock update gives up after 200 milliseconds, instead of waiting for the lock forever:
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();
}lock.tryLock(200, TimeUnit.MILLISECONDS)Waits at most 200 milliseconds for the lock, like
Monitor.TryEnterwith a timeout, and returnsfalseif the lock stayed busy.lock.unlock();A
ReentrantLockis not released automatically, so theunlockalways goes in afinallyblock.throw new TimeoutException();What to do when the lock never came free is up to you.
On Java 21 to 23, ReentrantLock was the better choice on
virtual threads, because waiting inside
synchronized pinned the thread to its
carrier. Since Java 24 both are fine; see Threads are cheap
now.
A lock protects a block of code. For a single number, there is something lighter.
Atomics#
For one number, such as a count of the orders placed today, a lock is heavier than you need. Both languages have atomic operations, which update a value in one step that no other thread can interrupt:
private int _count;
Interlocked.Increment(ref _count);
Interlocked.Add(ref _count, 10);
Interlocked.Exchange(ref _count, 0); // set, returning the old value
Interlocked.CompareExchange(ref _count, 5, 4); // set to 5 only if it is 4private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();
count.addAndGet(10);
count.getAndSet(0); // set, returning the old value
count.compareAndSet(4, 5); // set to 5 only if it is 4new AtomicInteger()Java has no
refparameters, so the atomic operations live on an object that holds the number, rather than on a static class likeInterlocked.count.incrementAndGet();Adds one and returns the new value, like
Interlocked.Increment.count.compareAndSet(4, 5);Sets the value to 5 only if it is currently 4, and returns whether it did. Note the order: the expected value comes first, where
CompareExchangeputs the new value first.
LongAdder has no .NET equivalent, and it is worth knowing. When many threads
update one counter at once, it spreads the count over several cells and adds them up when you
read it. Under that load it beats AtomicLong by a wide margin. Use it for metrics and counters
that are written constantly and read now and then.
Atomics and locks also make changes visible to other threads. Plain fields do not, and that
is where volatile comes in.
volatile means more in Java#
C# refresherVolatile.Read and Volatile.Write
Explicit reads and writes that are not cached or reordered, so another thread sees the latest value.
private bool _stop;
void Run() { while (!Volatile.Read(ref _stop)) Work(); }
void Stop() => Volatile.Write(ref _stop, true); // Run sees it on its next checkBoth languages have the keyword, but its guarantees differ:
| C# volatile | Java volatile | |
|---|---|---|
| Reordering | acquire on read, release on write | sequentially consistent: one order that every thread agrees on |
| Visibility | the field, plus writes made before a volatile write | the field, plus writes made before a volatile write |
| Atomicity of 64-bit | not allowed: volatile cannot be applied to long or double | guaranteed for volatile long and double |
| Use for a flag | yes | yes |
| Use for double-checked locking | sufficient | sufficient |
In practice, both keywords do the everyday jobs: a stop flag, and double-checked locking. The difference shows only when a thread writes one volatile field and then reads a different one, which Java keeps in order and C# may not. Java 5 repaired Java's memory model in 2004, so older articles that call double-checked locking broken in Java describe a Java Virtual Machine (JVM) that no longer exists.
Here the shop's configuration is loaded once, on first use, by whichever thread gets there first:
// 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;
}private volatile Config config;volatileguarantees that a thread which sees the newConfigalso sees it fully built.var c = config;Reads the field once into a local variable, so the usual path costs a single read.
if (c == null) config = c = load();The second check, inside the lock, stops two threads that both saw
nullfrom loading the configuration twice.
Better still, avoid the idiom. A static holder class gives you lazy, thread-safe initialisation with no locking at all:
private static class Holder {
static final Config INSTANCE = load();
}
public static Config get() { return Holder.INSTANCE; }Holder is not initialised until get first touches
Holder.INSTANCE, and the JVM runs that initialisation exactly once, however many
threads arrive together.
Java 25 also previews stable values Java 25
preview, which are essentially Lazy<T>. After initialisation,
the just-in-time (JIT) compiler can treat them as
constants.
C# refresherLazy: create a value on first use
private static readonly Lazy<Config> _config = new(() => Load()); // thread-safe by default
public static Config Config => _config.Value; // Load() runs on first readMost concurrent code needs neither locks nor atomics directly, because a concurrent collection already does the work.
Concurrent collections#
The shop caches exchange rates, counts page hits, and hands jobs from web requests to a
worker thread. Here pair names a currency pair, and job is a job
waiting to be processed:
var rates = new ConcurrentDictionary<string, Rate>();
Rate r = rates.GetOrAdd(pair, p => Fetch(p)); // may call Fetch twice
var hits = new ConcurrentDictionary<string, int>();
hits.AddOrUpdate("home", 1, (_, n) => n + 1);
var jobs = new BlockingCollection<Job>(boundedCapacity: 100);
jobs.Add(job); // waits while full
Job next = jobs.Take(); // waits while emptyvar rates = new ConcurrentHashMap<String, Rate>();
Rate r = rates.computeIfAbsent(pair, p -> fetch(p)); // at most once
var hits = new ConcurrentHashMap<String, Integer>();
hits.merge("home", 1, Integer::sum);
BlockingQueue<Job> jobs = new ArrayBlockingQueue<>(100);
jobs.put(job); // waits while full
Job next = jobs.take(); // waits while emptyrates.computeIfAbsent(pair, p -> fetch(p))Returns the cached rate, or calls
fetchand stores the result. Java runs the function at most once per key.GetOrAddmay callFetchtwice when two threads race.hits.merge("home", 1, Integer::sum);Stores 1 if the key is new, and otherwise combines the old value with 1 using
Integer::sum, likeAddOrUpdate.new ArrayBlockingQueue<>(100)A queue that holds at most 100 jobs, like a
BlockingCollectionwith a bounded capacity.jobs.put(job);putwaits while the queue is full, andtakewaits while it is empty.
C# refresherChannel, CountdownEvent, ManualResetEventSlim and Barrier
var ch = Channel.CreateBounded<Job>(100); // an async producer-consumer queue
await ch.Writer.WriteAsync(job);
Job next = await ch.Reader.ReadAsync();
using var done = new CountdownEvent(3); // three Signal() calls release Wait()
using var gate = new ManualResetEventSlim(); // Set() opens it for every waiter
using var round = new Barrier(4); // four threads meet, then all continuecomputeIfAbsent holds a lock on part of the map while the 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.
The sales total showed that synchronized is lock: keep the lock
object private and final, and never lock on this.
ReentrantLock adds timeouts, interruption and fairness, and must be unlocked in a
finally block.
AtomicInteger and LongAdder replace Interlocked, and
computeIfAbsent runs its function at most once per key.
Java's volatile is sequentially consistent, stronger than C#'s acquire and
release, and both are enough for a stop flag or double-checked locking.
Quick reference#
| .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 |
| .NET | Java | Note |
|---|---|---|
| ConcurrentDictionary<K,V> | ConcurrentHashMap<K,V> | the thread-safe HashMap; reads never lock |
| GetOrAdd(k, factory) | computeIfAbsent(k, fn) | atomic: the function runs at most once per key, unlike GetOrAdd |
| AddOrUpdate | compute(k, fn) / merge(k, v, fn) | compute and merge update a value atomically |
| ConcurrentQueue<T> | ConcurrentLinkedQueue<E> | an unbounded queue whose operations never wait |
| BlockingCollection<T> | LinkedBlockingQueue<E> | optionally bounded; take() waits for an item, put() for space |
| BlockingCollection with cap | ArrayBlockingQueue<E> | a bounded queue with a fixed capacity set when it is created |
| ConcurrentBag<T> | no direct equal | no unordered bag; a ConcurrentLinkedQueue usually serves |
| ImmutableList + swap | CopyOnWriteArrayList<E> | every write copies the array; only for rare writes |
| Channel<T> | BlockingQueue, or SubmissionPublisher | no async channel; a BlockingQueue on virtual threads is the usual answer |
| 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 | a one-shot gate is a CountDownLatch(1); a resettable one needs a Condition |
| SemaphoreSlim | Semaphore | a counting semaphore: acquire() takes a permit, release() returns it |
Legacy concurrency you will still meet#
Everything in this chapter still works, and all of it is common in production code. None of it is what you should write on Java 21 or later. It is here so that you can read existing code and know what to replace it with.
What older code looks like#
The order service's older code handles each incoming order on a thread pool. .NET has always pooled threads for you. Java before version 21 made you create the pool yourself, and choose its size:
// .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();Executors.newFixedThreadPool(50)A pool of exactly 50 platform threads, the ordinary kind, each backed by an operating-system thread. The number 50 is a guess you had to make.
Future<?> f = pool.submit(() -> handle(id));Submits a task, like
Task.Run, and returns aFuture, Java'sTask.f.get()waits for it to finish.pool.shutdown();A pool must be shut down when you have finished with it. Otherwise its threads keep the program running.
The sections below take each older tool in turn: what it does, and what to write instead.
Raw threads#
Legacynew Thread(...).start()
This is the Java 1.0 API. Each platform thread reserves about 1 MB of memory for its stack,
and creating one needs a call into the operating system, which is why thread pools exist. Here
doWork is the job to run:
Thread t = new Thread(() -> doWork());
t.setName("worker-1");
t.setDaemon(true);
t.start();
t.join();t.setDaemon(true);A daemon thread does not keep the Java Virtual Machine (JVM) running: when only daemon threads are left, the program ends. It is .NET's
IsBackground = true.t.join();Waits for the thread to finish, like
Join().
Replace with: Thread.ofVirtual().start(...) for work that
waits, or an executor. The Thread class itself is not deprecated:
virtual threads are Thread objects
too.
Here is the same worker thread in both languages:
var t = new Thread(DoWork) { IsBackground = true, Name = "worker-1" };
t.Start();
t.Join();
// t.Abort() throws PlatformNotSupportedException on .NET Core and laterThread t = new Thread(this::doWork, "worker-1");
t.setDaemon(true); // the JVM will not wait for it
t.start();
t.join();
// t.stop() cannot kill it either: interrupt() and let it noticenew Thread(this::doWork, "worker-1")The second argument names the thread, as
Namedoes in C#.interrupt() and let it noticeNeither platform can kill a thread safely from outside. Ask it to stop with
interrupt(), as Cancellation is interruption showed.
| Concept | .NET | Java | Note |
|---|---|---|---|
| Background thread | IsBackground = true | setDaemon(true) | the JVM exits without waiting for it to finish |
| Foreground thread | default | default | the JVM will not exit until this thread finishes |
| Name | Thread.Name | setName() | shows up in thread dumps and logs, so name your threads |
| Priority | Thread.Priority | setPriority() | only a hint that the OS may ignore; do not rely on it |
| Wait for completion | Join() | join() | blocks until the thread ends, exactly like Join() |
| Kill it | Abort(), removed | stop(), which only throws | cannot be done safely on either platform; ask it to stop instead |
Fixed thread pools#
LegacyExecutors.newFixedThreadPool(n)
Before Java 21, this was the standard way to run work that waits. The size trades
throughput against memory, and getting it wrong is the classic production incident: too small
and requests queue, too large and memory runs out. Here tasks is a list of jobs
that each load one order:
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);
}pool.invokeAll(tasks)Runs every task and waits for all of them, returning one
Futureper task.pool.shutdown();Stops the pool accepting new work, and
awaitTerminationthen waits up to 30 seconds for running tasks to finish.
Replace with: Executors.newVirtualThreadPerTaskExecutor() for
work that waits, and delete the size. Keep a fixed pool of platform threads only for work that
keeps the processor busy.
The Executors class has several factories, and they age differently:
| 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 has been AutoCloseable only since Java 19. In older
code you will see shutdown(), awaitTermination() and
shutdownNow() in a finally block instead. Forget them, and the pool's
threads keep running, so the JVM never exits. That is a common cause of a build or a
command-line tool that hangs after finishing its work.
wait, notify and notifyAll#
LegacyObject.wait() / notify()
The original way to wait for a condition, built into every object. It must be called while
holding the object's lock, and it is famously easy to get wrong. A missed notify
leaves a thread waiting forever. notify instead of notifyAll can wake
the wrong waiter. And a waiting thread can wake up for no reason, so the wait must always sit in
a loop. Here a worker takes the next order from a shared queue, waiting while it is empty:
synchronized (queue) {
while (queue.isEmpty()) { // while, never if, spurious wakeups are real
queue.wait();
}
return queue.poll();
}synchronized (queue) {waitandnotifyonly work while you hold the lock of the object you wait on.while (queue.isEmpty()) {A loop, never an
if: when the thread wakes, the queue may still be empty.queue.wait();Releases the lock and sleeps until another thread calls
queue.notify()ornotifyAll(), then takes the lock again.
Replace with: a BlockingQueue, which does exactly this
correctly, or a ReentrantLock with a Condition.
Synchronized wrappers#
LegacyCollections.synchronizedMap and friends
These wrappers put a lock around every method. That makes each single call safe, but does
nothing for a check followed by an update, so this is still a race. Here stock
counts items per product, and productId names one product:
Map<String, Integer> stock = Collections.synchronizedMap(new HashMap<>());
if (!stock.containsKey(productId)) { // another thread can insert here
stock.put(productId, 0);
}Collections.synchronizedMap(new HashMap<>())Every single call, such as
containsKeyorput, takes the map's lock.if (!stock.containsKey(productId)) {The check and the
putare two separate calls. Between them, another thread can insert the same key, and one thread's value then overwrites the other's.
Replace with: ConcurrentHashMap and its atomic compound
operations, putIfAbsent, computeIfAbsent and merge.
java.util.Timer#
LegacyTimer and TimerTask
A Timer runs all its tasks on one thread, and a single task that throws an
unchecked exception kills that thread,
silently cancelling every other scheduled task. Replace with:
ScheduledExecutorService, or Spring's @Scheduled. Here
poll checks the payment provider for updates every 30 seconds:
var sched = Executors.newScheduledThreadPool(1);
sched.scheduleAtFixedRate(this::poll, 0, 30, TimeUnit.SECONDS);Executors.newScheduledThreadPool(1)A scheduler with one thread. Unlike
Timer, it survives a task that throws.sched.scheduleAtFixedRate(this::poll, 0, 30, TimeUnit.SECONDS);Runs
pollstraight away, and then every 30 seconds.
Even with ScheduledExecutorService, an uncaught exception cancels that one
repeating task. Wrap the body in a try/catch if it must keep
running.
ThreadLocal#
LegacyThreadLocal
Java's ThreadLocal is .NET's ThreadLocal<T>: one value per
thread. It is still correct for platform threads, but it has two problems on modern Java. With
virtual threads you may have a million threads, and so a million values. And in a pool, a value
you forget to remove leaks into the next task that reuses the thread. Here the current user is
kept while one request is handled:
private static final ThreadLocal<User> CURRENT = new ThreadLocal<>();
CURRENT.set(user);
try {
doWork();
} finally {
CURRENT.remove(); // essential in a pooled thread
}CURRENT.set(user);Stores the user for this thread only. Code called later on the same thread reads it with
CURRENT.get().CURRENT.remove();Clears it. In a pool, the thread goes on to serve another request, which would otherwise see this user.
Replace with: ScopedValue Java 25, which cannot
be changed, lasts for one block, and cannot leak. It is the closer match to C#'s
AsyncLocal. See Structured
concurrency.
Before and after#
Most of these changes are small. The most common one, replacing a fixed pool with virtual threads, looks like this:
// before: a pool sized by guesswork, shut down by hand
ExecutorService pool = Executors.newFixedThreadPool(50);
// after: one virtual thread per task, closed and joined by try-with-resources
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
exec.submit(task);
}ExecutorService pool = Executors.newFixedThreadPool(50);The old pool: a guessed size, and a shutdown you must remember.
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {The new executor: no size to guess, and closing it at the end of the block waits for every task.
Older code sizes its pools by hand with newFixedThreadPool. On Java 21 and
later, use newVirtualThreadPerTaskExecutor for work that waits, and keep a fixed
pool only for calculation.
wait and notify become a BlockingQueue, synchronized
wrappers become ConcurrentHashMap, and Timer becomes a
ScheduledExecutorService.
ThreadLocal needs remove() in a pool, and ScopedValue
replaces it on virtual threads.
No thread can be killed safely from outside: interrupt it, and let it stop.
Quick reference#
| 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: suspend is gone, and stop only throws |
| CompletableFuture merely to parallelise blocking calls | virtual threads |
Reactive: WebFlux and Reactor#
Reactor is Java's Rx.NET, and WebFlux is a Spring web stack that never blocks, like an
ASP.NET Core pipeline written entirely with callbacks. 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 real but much narrower case: streaming, backpressure, and very large fan-out.
The model#
Reactor is the library behind WebFlux. Its two types map onto .NET types you already know.
Here order is an Order, and repo is a repository that can
stream orders:
Task<Order> one = Task.FromResult(order);
Task none = Task.CompletedTask;
IAsyncEnumerable<Order> many = repo.StreamAsync();
IObservable<int> evens = Observable.Interval(TimeSpan.FromSeconds(1))
.Where(n => n % 2 == 0)
.Select(n => (int)n);Mono<Order> one = Mono.just(order);
Mono<Void> none = Mono.empty();
Flux<Order> many = repo.findAll();
Flux<Integer> evens = Flux.interval(Duration.ofSeconds(1))
.filter(n -> n % 2 == 0)
.map(Long::intValue);Mono<Order> one = Mono.just(order);A
Monoholds zero or one value that arrives later, like aTask<T>.justwraps a value you already have, likeTask.FromResult.Flux<Order> many = repo.findAll();A
Fluxis a stream of any number of values arriving over time, likeIAsyncEnumerable<T>orIObservable<T>.Flux.interval(Duration.ofSeconds(1))Produces 0, 1, 2 and so on, one each second, like
Observable.Interval..map(Long::intValue);maptransforms each value, likeSelect.intervalcounts inlongvalues, so this turns each one into anint.
Combining two results shows the difference in style. C# awaits each call; Reactor combines
two Monos into one:
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()));
}Mono.zip(Waits for both
Monos and pairs their values. The two calls run at the same time..map(t -> merge(t.getT1(), t.getT2()));zippairs the two values into one object, andgetT1()andgetT2()read them back out.
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 library of combinators instead. That is why reactive Java
reads like C# would if await had never been added, and everyone used
ContinueWith.
Reactive Java was the standard answer for years. The next section explains why that changed.
Why the case for it collapsed#
The reactive pitch was never really about elegance. It was arithmetic:
| Quantity | Before Java 21 | Java 21 and later |
|---|---|---|
| One request | one platform thread | one virtual thread |
| One thread's stack | about 1 MB | a few hundred bytes to start |
| 10,000 concurrent requests | about 10 GB of stacks: impossible | a few MB: unremarkable |
| So the advice was | never block; restructure everything into callbacks | block; it is fine |
So on Java 21 and later, the same code can simply block:
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);var c = api.getCustomer(id);Blocks a virtual thread while it waits, which costs almost nothing.
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, many of the libraries that block, and a much steeper learning curve for every future maintainer. In exchange you get a scalability property that Spring's ordinary web framework, Spring Model-View-Controller (MVC), already has on virtual threads.
That leaves a smaller set of cases where reactive code still earns its place.
When it is still the right answer#
Reactive code still fits when the work really is a stream. It also fits when a library you depend on is reactive, such as the reactive database driver, Reactive Relational Database Connectivity (R2DBC):
| 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 |
Server-sent events are the clearest case. Here priceFeed publishes price
changes, and every connected browser receives each new price as it happens:
// server-sent events: many values over one long-lived response
@GetMapping(value = "/prices", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Price> prices() {
return priceFeed.updates()
.onBackpressureLatest(); // a slow client gets the latest price, not a backlog
}produces = MediaType.TEXT_EVENT_STREAM_VALUETells Spring to send server-sent events: a response that stays open and delivers many values over time.
.onBackpressureLatest();If a client reads more slowly than prices change, it gets the latest price, rather than a growing backlog of old ones.
Backpressure is the concept with no equivalent in the virtual-thread model. It is the ability of a slow consumer to tell a fast producer to send less. A blocking pipeline gets this for free, because if you stop reading, the producer blocks, but only within one process. Across a network 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 Netty, a network server with a small number of event-loop threads, roughly one per processor core. Each serves many connections, switching between them instead of waiting. 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
}Order o = jdbcRepo.findById(id);A blocking database call. It holds the event-loop thread until the database answers.
This is why WebFlux is all or nothing: one blocking library anywhere in the request path undoes the whole model. If you must block, move the call to a separate pool meant for blocking work:
return Mono.fromCallable(() -> jdbcRepo.findById(id))
.subscribeOn(Schedulers.boundedElastic());Mono.fromCallable(() -> jdbcRepo.findById(id))Wraps the blocking call, so that it runs only when something subscribes.
.subscribeOn(Schedulers.boundedElastic());Runs it on Reactor's pool for blocking work, which keeps the event loop free.
Add the BlockHound agent to your tests, and it fails loudly on any blocking call that reaches an event loop.
WebFlux against MVC#
Here is how Spring's two web stacks compare. Spring MVC usually reads a database through Java Database Connectivity (JDBC) or Jakarta Persistence (JPA), and WebFlux through R2DBC:
| 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 |
The same endpoint looks like this in each style:
// Spring MVC on virtual threads: plain types, and blocking is fine
@GetMapping("/orders/{id}")
Order get(@PathVariable long id) { return repo.findById(id).orElseThrow(); }
// Spring WebFlux: reactive types all the way down
@GetMapping("/orders/{id}")
Mono<Order> get(@PathVariable long id) { return reactiveRepo.findById(id); }Order get(@PathVariable long id) { return repo.findById(id).orElseThrow(); }Spring MVC: a plain return type. The call blocks a virtual thread while the database answers.
Mono<Order> get(@PathVariable long id) { return reactiveRepo.findById(id); }WebFlux: the method returns a
Monoat once, and Spring subscribes to it and writes the order when it arrives.
There is no reactive JPA. R2DBC is a different driver, with a different API and no object-relational mapper (ORM) at all: no entity graph, no lazy loading, and no Hibernate. Choosing WebFlux for a service that mostly reads and writes a database means giving up Spring Data JPA, which usually costs far more than the thread cost 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. Here is a typical method,
which streams a customer's recent active orders. log is the class's logger:
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()));
}repo.findByCustomer(customerId)A
Fluxof the customer's orders, delivered as the database returns them..flatMap(this::enrich)Turns each order into a
Mono<OrderDto>and merges the results as they complete, so they may arrive in a different order..onErrorResume(e -> Flux.empty())If anything fails, sends nothing more instead of an error.
.timeout(Duration.ofSeconds(3))Fails the stream if three seconds pass without a new element.
These operators come up most often:
| 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 throwing it away does
nothing at all: no request is sent, and no row is written. In Spring, the framework subscribes
for you when you return a publisher from a controller, so forgetting to return one is a
silent no-op rather than an error:
repo.save(order); // does nothing, never subscribed
return repo.save(order); // correctrepo.save(order); // does nothing, never subscribedBuilds a
Monothat would save the order, then throws it away. Nothing subscribes, so nothing is saved.return repo.save(order);Returning it lets Spring subscribe, which runs the save.
LegacyRxJava
Before Reactor, the reactive library in Java was RxJava, a direct port of Rx.NET that is
still common on Android. Spring standardised on Reactor, but both implement the Reactive Streams
specification, so their Publisher types work together. 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?
Reactor's Mono and Flux are Task and
IAsyncEnumerable: values that arrive later, transformed with map,
flatMap and filter.
Virtual threads removed the main reason for reactive Java, so a new service that mostly talks to a database should use Spring MVC and block.
WebFlux still fits streaming, backpressure and very large fan-out, but one blocking call on an event loop stalls unrelated requests.
Nothing happens until something subscribes, so a Mono you build and do not
return never runs.
Quick reference#
| .NET | Reactor | Means |
|---|---|---|
| Task<T> | Mono<T> | zero or one value that arrives later, like a Task |
| IAsyncEnumerable<T> | Flux<T> | a stream of any number of values that arrive over time |
| IObservable<T> | Flux<T> | one type covers both the pull and the push styles |
| Task.FromResult(v) | Mono.just(v) | a Mono that already holds its value |
| Task.CompletedTask | Mono.empty() | a Mono that completes with no value |
| .Select | .map | transform each value as it arrives |
| .SelectMany | .flatMap | each value becomes a publisher, and the results merge |
| .Where | .filter | keep only the values that match |
| await | .block(), almost always wrong | block() waits for the value; in reactive code it stalls a shared thread |
| IAsyncEnumerable + Channel | Flux + backpressure | Reactor lets a slow consumer tell the producer to send less |
The week-one gotchas#
Every item here is something a competent C# developer gets wrong in their first two weeks of Java, because the C# instinct is right in C# and wrong in Java. Skim this once now, and come back when something behaves impossibly.
1. == on objects#
An incoming order names a product, and the code checks it against the catalogue. In C#,
== on two strings compares their text. In Java it asks whether they are the same
object:
var a = "mug";
var b = ReadProductId(); // also "mug"
bool same = a == b; // True: string == compares the text
bool equal = a.Equals(b); // TrueString a = "mug";
String b = readProductId(); // also "mug"
boolean same = a == b; // false: compares references
boolean equal = a.equals(b); // true
boolean safe = Objects.equals(a, b); // true, and null-safeboolean same = a == b;bwas built while the program ran, so it is a different object from the literal, and==is false even though both saymug.Objects.equals(a, b)Compares the text, and is safe if either side is null.
== compares references for every object type, including String. It
often appears to work, because Java reuses one object for identical string literals, and then
fails on a string built while the program runs. Use equals, or
Objects.equals(a, b) when either side may be null. See
Equality and hashing.
2. The Integer cache#
With Integer a = 127, b = 127;, a == b is true.
With 128 it is false. Java shares one object for each boxed integer from
−128 to 127, so tests with small numbers pass and production fails. Here two order lines
store their quantities as Integer:
Integer a = 127, b = 127;
System.out.println(a == b); // true: both come from the cache
Integer c = 128, d = 128;
System.out.println(c == d); // false: two separate objects
System.out.println(c.equals(d)); // true: compare values with equalsSystem.out.println(a == b);127 is inside the cache, so
aandbare the same object.System.out.println(c == d);128 is outside it, so
canddare two objects, and==compares objects.
3. Auto-unboxing throws NullPointerException#
The shop counts orders per product in a map, and one product has no orders yet. Reading a missing key fails in both languages, but in different ways:
var counts = new Dictionary<string, int>();
int n = counts["missing"]; // KeyNotFoundExceptionMap<String, Integer> counts = new HashMap<>();
int n = counts.get("missing"); // NullPointerException, not 0int n = counts.get("missing");getreturnsnullfor a missing key. Assigning it to anintunboxes it, which throwsNullPointerException.
A null Integer turns into a NullPointerException when it is
unboxed, on a line with no visible method call. C#'s KeyNotFoundException is a
clearer failure. In Java, use getOrDefault(key, 0).
4. No modifier means package-private#
In C#, a member with no access modifier is private. In Java it is package-private: visible to the whole package. Your habit is the wrong way round, and nothing warns you. See Access modifiers and packages.
public class Account {
BigDecimal balance; // package-private: any class in the package can write it
private BigDecimal fee; // what a C# field with no modifier means
}BigDecimal balance;No modifier, so any class in the same package can read and change it.
private BigDecimal fee;What a C# field with no modifier means.
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 say C#'s subclass-only protected:
// com/acme/billing/Invoice.java
public class Invoice { protected BigDecimal total; }
// com/acme/billing/Auditor.java: same package, not a subclass
class Auditor {
void reset(Invoice i) { i.total = BigDecimal.ZERO; } // compiles
}protected BigDecimal total;Visible to subclasses, and also to every class in the same package.
void reset(Invoice i) { i.total = BigDecimal.ZERO; }Auditoris not a subclass, but it is in the same package, so it can changetotal.
6. Methods are virtual by default#
C# opts in with virtual; Java opts out with final. Any public method
you write can be overridden unless you say otherwise. That matters most when a constructor
calls an overridable method, because the subclass's override then runs before the subclass's
fields are set:
class Discount {
Discount() { describe(); } // calls the override...
void describe() { }
}
class SeasonalDiscount extends Discount {
private String season = "winter sale";
@Override void describe() {
System.out.println(season); // prints null: season is not set yet
}
}Discount() { describe(); }The base constructor calls
describe, whichSeasonalDiscountoverrides.System.out.println(season);Runs while
Discount's constructor is still going, beforeSeasonalDiscounthas setseason, so it printsnull.
A final field set to a constant, such as
private final String season = "winter sale", would print the text instead, because
the compiler copies constants into the code that uses them. That hides the bug rather than
fixing it.
7. Arrays are covariant and it is unsound#
Both languages made this mistake, and both catch it only while the program runs, not when it compiles:
Object[] objects = new String[2];
objects[0] = 42; // compiles; throws ArrayStoreException at runtimeObject[] objects = new String[2];An array of
Stringcan be used as an array ofObject. That compiles in both languages.objects[0] = 42;Storing an
Integerin what is really aStringarray is caught only when the line runs, withArrayStoreException.
Generics are invariant precisely to avoid this, which is why a
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 independent only if you
write static. A class nested without it, an inner class, holds a hidden
reference to the object that created it. So it cannot be created without one, and it keeps
that 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(); // normalclass Inner { }An inner class. Each
Innerobject holds a hidden reference to theOuterobject that created it.new Outer().new Inner();So creating one needs an
Outerfirst, with this unusual syntax.static class Nested { }staticmakes it independent, like every nested class in C#.
Default to static on every nested class unless you want the
outer reference on purpose. Forgetting it is a common cause of memory leaks, especially in
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 you cannot switch on throwing. Use
Math.addExact, multiplyExact and friends when overflow would be a
bug; they throw ArithmeticException.
Java also has no unsigned types: no uint and no ulong. Use the
static helpers, such as Integer.divideUnsigned and
Long.compareUnsigned.
int big = Integer.MAX_VALUE;
int wrapped = big + 1; // -2147483648: wraps silently
int safe = Math.addExact(big, 1); // throws ArithmeticException: integer overflowint wrapped = big + 1;Wraps round to the most negative
int, with no error at all.Math.addExact(big, 1)Adds, and throws
ArithmeticExceptionif the result does not fit, like C#'schecked.
C# refresherchecked and unchecked: C#'s overflow control
int big = int.MaxValue;
int wrapped = unchecked(big + 1); // -2147483648
int safe = checked(big + 1); // throws OverflowException10. Checked exceptions do not fit in lambdas#
This is the gotcha Java developers complain about most. A
lambda passed to map cannot throw a
checked exception, because the interface
map expects declares none. Here files is a
List<Path>, and Files.readString, from java.nio.file,
reads a whole file as text:
List<String> texts = files.stream()
.map(f -> Files.readString(f)) // does not compile: IOException
.toList();.map(f -> Files.readString(f))readStringcan throwIOException, a checked exception, which the lambda is not allowed to throw.
You must catch it and throw an unchecked exception instead, which is ugly and universal:
List<String> texts = files.stream()
.map(f -> {
try { return Files.readString(f); }
catch (IOException e) { throw new UncheckedIOException(e); }
})
.toList();catch (IOException e) { throw new UncheckedIOException(e); }Wraps the
IOExceptionin anUncheckedIOException, which a lambda may throw.
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:
String s = null;
switch (s) { // NullPointerException here
case "a" -> run();
default -> skip();
}
switch (s) {
case null -> skip(); // Java 21: handle null as a case
case "a" -> run();
default -> skip();
}switch (s) { // NullPointerException hereThe first switch throws as soon as it sees the null, before it looks at any case.
case null -> skip();From Java 21, null can be a case of its own, and then the switch no longer throws.
12. Date and SimpleDateFormat are traps#
java.util.Date can be changed after it is created, Calendar numbers
months from zero, and SimpleDateFormat is not thread-safe. A
shared static instance produces silently wrong dates when several threads use it. Use
java.time only. See Numbers, money and
time.
// shared across request threads: wrong dates under load
static final SimpleDateFormat OLD = new SimpleDateFormat("yyyy-MM-dd");
// immutable and thread-safe: share it freely
static final DateTimeFormatter ISO = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String today = LocalDate.now().format(ISO);static final SimpleDateFormat OLDOne formatter shared by every thread.
SimpleDateFormatkeeps working state inside it, so two threads formatting at once corrupt each other's dates.DateTimeFormatter.ofPattern("yyyy-MM-dd")The
java.timeformatter cannot change, so one shared instance is safe.
13. There is no decimal#
Java has no built-in decimal type. An order total adds up exactly with C#'s
decimal, and goes wrong with Java's double. Here
limit is a BigDecimal spending limit:
decimal total = 0.1m + 0.2m; // 0.3 exactly
bool over = total > limit;double wrong = 0.1 + 0.2; // 0.30000000000000004
BigDecimal right = new BigDecimal("0.1").add(new BigDecimal("0.2")); // 0.3
boolean over = right.compareTo(limit) > 0; // no > operator for BigDecimaldouble wrong = 0.1 + 0.2;A
doublecannot hold 0.1 exactly, so the sum is slightly off.new BigDecimal("0.1").add(new BigDecimal("0.2"))BigDecimalstores decimal digits exactly. Build it from a string, and useaddinstead of+.right.compareTo(limit) > 0BigDecimalhas no>operator.compareToreturns a negative number, zero or a positive number.
Money in Java is BigDecimal, an object with no operator overloading, so
arithmetic becomes method calls and comparison becomes compareTo. Using
double for money is the most common correctness bug a C# developer brings to
Java.
14. Views are not copies#
subList, keySet, values, entrySet,
Arrays.asList and reversed() all return views of the
original. Changing the view changes the source, and changing the source can break the
view:
var fixed = Arrays.asList(1, 2, 3);
fixed.set(0, 9); // fine, writes through to the array
fixed.add(4); // UnsupportedOperationException, fixed sizefixed.set(0, 9);Arrays.asListis a view of an array, sosetwrites through to it.fixed.add(4);An array cannot grow, so neither can the view:
addthrowsUnsupportedOperationException.
15. finally can swallow exceptions#
A return or throw inside finally silently discards
whatever was on its way out of try or catch. C# refuses to compile a
return from a finally block. Java allows it, and warns only when you
compile with -Xlint:finally. Never return from finally.
int load() {
try {
throw new IllegalStateException("real problem");
} finally {
return 0; // compiles; the exception is silently discarded
}
}throw new IllegalStateException("real problem");The real failure starts on its way out of the method.
return 0;finallyruns next, and itsreturnreplaces the exception with 0. The caller never learns that anything went wrong.
Compare objects with equals: == compares references, even for
strings, and for Integers above 127.
A null Integer throws when it is unboxed, and a switch on null throws unless it
has case null.
No modifier means package-private, protected includes the package, and every
method can be overridden unless it is final.
Use BigDecimal for money, java.time for dates, and
Math.addExact where overflow matters.
Checked exceptions do not fit in lambdas, and a return in finally
swallows exceptions.
Quick reference#
| C# | Java | Note |
|---|---|---|
| a == b on strings | a.equals(b) | == compares references in Java, even for String |
| counts["missing"] | counts.getOrDefault("missing", 0) | get returns null, which throws when unboxed to int |
| a field with no modifier | a private field | Java's default is package-private, not private |
| checked(a + b) | Math.addExact(a, b) | Java overflow wraps silently unless you ask it to throw |
| decimal | BigDecimal | no operators: use add, multiply and compareTo instead |
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 far better than the old Date
and Calendar classes, which are a trap.
Primitive types#
Most number types carry over with the same names. The differences are at the edges: Java's
byte is signed, there are no unsigned types apart from char, and
there is no decimal. Here are the same values in both languages:
byte b = 200; // unsigned: 0 to 255
sbyte sb = -100; // signed
uint u = 3_000_000_000;
ulong max = ulong.MaxValue;
decimal price = 19.99m;
bool ok = true;byte b = (byte) 200; // signed: stored as -56
int unsigned = b & 0xFF; // 200 again
long u = 3_000_000_000L; // no uint: use a wider type
String max = Long.toUnsignedString(-1L); // "18446744073709551615"
BigDecimal price = new BigDecimal("19.99");
boolean ok = true;byte b = (byte) 200;200 does not fit in Java's signed
byte, so the cast wraps it round to −56.int unsigned = b & 0xFF;Masking with
0xFFreads the same eight bits as an unsigned number: 200 again.long u = 3_000_000_000L;Java has no
uint, so a value above about two billion needs along. TheLmarks alongliteral.Long.toUnsignedString(-1L)Reads the 64 bits of a
longas an unsigned number, which is how Java handles values the size of aulong.BigDecimal price = new BigDecimal("19.99");There is no
decimal. Money is aBigDecimal, covered below.
Java's byte is signed, from −128 to 127. C#'s
byte is unsigned, and sbyte is the signed one. So when Java code reads
binary data written by C# and prints a byte, it can show a negative number. Mask with
b & 0xFF to get the unsigned value.
The quick reference at the end of this chapter lists every C# number type with its Java equivalent.
Wrapper types#
Each primitive type has an object version, called a wrapper type, which Java uses
wherever an object is needed, such as in a List<Integer>. The wrapper for
int is Integer, and for char it is
Character; the others are the type's name with a capital letter, such as
Long and Boolean. The quick reference lists them all.
Autoboxing converts between the two implicitly. That is convenient, and it is the source of two bugs already covered: the Integer cache and the exception on unboxing a null.
Integer boxed = 42; // autoboxing: Integer.valueOf(42)
int plain = boxed; // unboxing: boxed.intValue()
Integer missing = null;
int oops = missing; // NullPointerException: nothing to unboxInteger boxed = 42;Autoboxing: the compiler writes
Integer.valueOf(42)for you.int plain = boxed;Unboxing: the compiler writes
boxed.intValue().int oops = missing;Unboxing
nullhas no number to take, so it throwsNullPointerException.
Money is where the missing decimal hurts, so it comes next.
Money is BigDecimal#
An order of three mugs at 19.99 each, plus shipping, must add up to the exact amount, and
then be checked against a spending limit. In C# that is decimal arithmetic. In
Java it is BigDecimal, with methods instead of operators. Here
shipping and limit are amounts of the same type:
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);new BigDecimal("19.99")Creates the price from the text
"19.99". Building it from text keeps the value exact; the traps below show what goes wrong with adouble.price.multiply(BigDecimal.valueOf(3))Java has no
*for objects, so you callmultiply. It accepts only anotherBigDecimal, soBigDecimal.valueOf(3)turns the whole number 3 into one first..add(shipping)Adds the shipping charge, which is also a
BigDecimal. Each call returns a new value, because aBigDecimalnever changes once it is created.total.compareTo(limit) > 0Java has no
>for objects either.compareToreturns a negative number, zero or a positive number, so> 0means the total is larger than the limit.NumberFormat.getCurrencyInstance().format(total)Formats the amount as money for the computer's current locale, as
ToString("C")does in C#.NumberFormatlives in thejava.textpackage.
Every operator you would use on a decimal has a method:
| 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. Thedouble0.1 is already imprecise, so you get0.1000000000000000055511151231257827021181583404541015625. Always use theStringconstructor, orBigDecimal.valueOf(double), which goes throughDouble.toString.dividewithout a scale throwsArithmeticExceptionwhen the result never ends, such as 1/3. Always give 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
count of the smallest unit, such as cents, and formatting it only for display. That is a
legitimate design, avoids all three traps, and is what many payment systems do.
Dates and times are the other everyday value that Java handles differently.
java.time#
An invoice is due 30 days after it is issued, and the shop keeps the exact moment each
payment arrives. Those are different kinds of time, and java.time
Java 8 gives each its own type. If you know Noda Time, you know this API: Jon
Skeet based Noda Time on Joda-Time, and java.time is Joda-Time's successor, led by
the same author. It separates the ideas that DateTime mixes together.
C# refresherNodaTime: separate types for instants and dates
Instant now = SystemClock.Instance.GetCurrentInstant();
LocalDate due = LocalDate.FromDateTime(DateTime.Today).PlusDays(30);| 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 |
Here are the common operations side by side:
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);var now = Instant.now();An exact point on the timeline, in UTC, like
DateTimeOffset.UtcNow.now.plus(30, ChronoUnit.DAYS)Adds 30 days. An
Instanthas no calendar or time zone, so each day is exactly 24 hours.LocalDate.now()Today's date, with no time and no time zone, like
DateOnly.Duration.between(now, due)The time between two instants, like subtracting two
DateTimeOffsets to get aTimeSpan.
All java.time types are immutable and thread-safe, including
DateTimeFormatter. Every method that changes a value returns a new one:
plusDays, withYear, truncatedTo. That is the same model
as .NET's DateTime, so it should feel natural.
Duration and Period are not interchangeable.
Duration is exact time, in seconds and nanoseconds. Period is calendar
time, in years, months and days. The difference shows across a daylight-saving change. In
London, the clocks go forward on 29 March 2026:
ZoneId london = ZoneId.of("Europe/London");
ZonedDateTime noon = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, london);
noon.plus(Period.ofDays(1)); // 2026-03-29T12:00+01:00: noon the next day
noon.plus(Duration.ofHours(24)); // 2026-03-29T13:00+01:00: exactly 24 hours laternoon.plus(Period.ofDays(1));A
Periodcounts calendar days, so the result is noon the next day, even though that day is only 23 hours long.noon.plus(Duration.ofHours(24));A
Durationcounts exact time, so 24 hours later is 1 pm, because the clocks went forward in between.
The rest of this chapter covers the old date classes you will still meet, and formatting numbers.
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.
Convert at the boundary, where your code meets an old library. Here oldDate is a
java.util.Date from that library, and instant is an
Instant:
Instant i = oldDate.toInstant();
Date d = Date.from(instant);
LocalDate ld = oldDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();oldDate.toInstant()A
Dateis really an instant, so it converts directly.Date.from(instant)The other direction, for an old API that still wants a
Date..atZone(ZoneId.systemDefault()).toLocalDate()A date only makes sense in a time zone, so you choose one before asking for the
LocalDate.
Formatting numbers#
Formatting and parsing numbers look like this, where text holds a number typed
by a customer:
var s1 = 1234.5.ToString("N2"); // "1,234.50" in en-US
var s2 = 0.25.ToString("P0"); // "25%" in en-US
var s3 = 255.ToString("X"); // "FF"
int n = int.Parse("42");
bool ok = int.TryParse(text, out var v);var s1 = String.format(Locale.ROOT, "%,.2f", 1234.5); // "1,234.50"
var s2 = NumberFormat.getPercentInstance(Locale.UK).format(0.25); // "25%"
var s3 = Integer.toHexString(255).toUpperCase(); // "FF"
int n = Integer.parseInt("42");
int v;
try { v = Integer.parseInt(text); } catch (NumberFormatException e) { v = 0; }String.format(Locale.ROOT, "%,.2f", 1234.5)Locale.ROOTfixes the format, a comma between thousands and a dot before the decimals, whatever the server's settings.NumberFormat.getPercentInstance(Locale.UK).format(0.25)A percentage, in British format.
try { v = Integer.parseInt(text); } catch (NumberFormatException e) { v = 0; }Java has no
TryParse, so you catch the exception thatparseIntthrows for bad input.
Integer.parseInt does not depend on the locale, but NumberFormat
and String.format do: they use the default locale unless you pass one. A server
set to a locale that uses a comma as the decimal separator formats 1.5 as
1,5. Pass Locale.ROOT explicitly for anything another program will
read.
Java's number types match C#'s, except that byte is signed, there are no
unsigned types apart from char, and there is no decimal.
The order total used BigDecimal: build it from a string, use add,
multiply and compareTo, and always give divide a scale and
a rounding mode.
java.time separates an Instant, a LocalDate and a
ZonedDateTime, and a Period differs from a Duration across
a daylight-saving change.
Avoid Date, Calendar and SimpleDateFormat, and pass a
Locale when formatting for other programs.
Quick reference#
| C# | Java | Size | Note |
|---|---|---|---|
| sbyte | byte | 8-bit | Java's byte is signed, from -128 to 127, like C#'s sbyte |
| byte | no equivalent | no unsigned byte; widen to int with b & 0xFF when reading bytes | |
| 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 | no ulong; Long.toUnsignedString and friends treat a long as unsigned | |
| float | float | 32-bit | |
| double | double | 64-bit | |
| decimal | no equivalent | no decimal type; money is a BigDecimal object, covered below | |
| bool | boolean | the same true or false, spelled boolean in Java | |
| char | char | 16-bit | a UTF-16 code unit in both, so an emoji takes two chars |
| nint / nuint | no equivalent | native-sized integers; Java has none, so use int or long |
| Primitive | Wrapper | C# analogy |
|---|---|---|
| int | Integer | int? (roughly) |
| long | Long | long? |
| double | Double | double? |
| boolean | Boolean | bool? |
| char | Character | char? |
| 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 |
Exceptions and resources#
One structural difference: checked exceptions. Java's compiler makes you declare or catch certain exception types. C# has nothing like it; its designers looked at the feature and chose not to copy it.
Everything else lines up. try-with-resources is using, and
AutoCloseable is IDisposable. Never use
finalize: it is deprecated for removal.
The hierarchy#
Every exception type sits in one family tree, and where a type sits decides whether it is checked:
Throwable
├─ Error unchecked. JVM problems, do not catch
│ ├─ OutOfMemoryError
│ └─ StackOverflowError
└─ Exception CHECKED by default
├─ IOException checked
├─ SQLException checked
└─ RuntimeException unchecked
├─ NullPointerException
├─ IllegalArgumentException
├─ IllegalStateException
└─ ...Anything under Error or RuntimeException is unchecked, like every C#
exception. Everything else under Exception, such as IOException and
SQLException, is checked: the compiler makes you declare it or catch it.
| Rule | Meaning |
|---|---|
| Extends RuntimeException or Error | unchecked: like every C# exception |
| Extends Exception, not RuntimeException | checked, must be declared or caught |
Checked exceptions#
Reading the day's order file can fail, because the file may be missing. In C#, nothing in the
method's signature says so. In Java, IOException is a checked exception, so the
method must declare it with throws, or catch it:
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));
}throws IOExceptionDeclares that
readmay throwIOException. Every caller must now catch it, or declare it in turn.// may throw; nothing to declareC# has no such clause: any method may throw anything, and the compiler does not check.
You have two options, and each has a cost. Here p is the path to the order
file:
// 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
}
}public String read(Path p) throws IOException {Option 1 passes the obligation up: every caller now has to deal with
IOException.throw new UncheckedIOException(e);Option 2 wraps it in an unchecked exception, so the obligation stops here. The original exception travels inside it, as its 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. Their reasoning is that most callers cannot
recover from an IOException, so making every layer declare it is just noise.
Keep checked exceptions for expected conditions that a caller can really recover from, in a library API. Propagate them one or two layers at most.
Always pass the cause when wrapping. Write
throw new RuntimeException(e), not
throw new RuntimeException(e.getMessage()). Dropping the cause throws away the
original stack trace, and the resulting production incident cannot be diagnosed from the logs.
This is the same as passing the inner exception in 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 most complained-about corner of modern Java.
Here paths is a list of files to read:
// 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();paths.stream().map(Files::readString).toList();Files::readStringcan throwIOException, which the function behindmapdoes not allow, so this line does not compile.catch (IOException e) { throw new UncheckedIOException(e); }The usual workaround: catch it inside the lambda, and rethrow it unchecked.
Teams often write a small Unchecked.wrap(...) helper, or use a library such as
Vavr. There is no language-level fix in Java 25.
Most everyday exceptions have a close match in the other language, so throwing and catching them is mostly a matter of names.
Exception mapping#
Guard clauses translate almost line for line. Here order is an order,
qty a quantity, and closed says whether the order is already
closed:
ArgumentNullException.ThrowIfNull(order);
if (qty <= 0) throw new ArgumentOutOfRangeException(nameof(qty));
if (closed) throw new InvalidOperationException("already closed");
throw new NotSupportedException("read-only");
var v = dict[key]; // KeyNotFoundException if missingObjects.requireNonNull(order, "order");
if (qty <= 0) throw new IllegalArgumentException("qty must be positive");
if (closed) throw new IllegalStateException("already closed");
throw new UnsupportedOperationException("read-only");
var v = map.get(key); // null if missing: no exceptionObjects.requireNonNull(order, "order");Java's
ThrowIfNull. It throwsNullPointerException, not an argument exception, which is the Java convention.throw new IllegalArgumentException("qty must be positive");Java has no
ArgumentOutOfRangeException.IllegalArgumentExceptioncovers every bad argument.var v = map.get(key);A missing key is not an exception in Java:
getreturnsnull.
The quick reference at the end of this chapter maps every common .NET exception to its Java equivalent.
try, catch, finally#
Here is a try block that logs a failure, rethrows it, and always cleans up.
doIt can throw either an IOException or an SQLException:
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();
}catch (IOException | SQLException e)One
catchfor two types, joined with|where C# writesor.throw e;Rethrows the same exception object, so its stack trace is kept, like C#'s bare
throw.
C# refresherException filters: catch ... when
A filter decides whether a catch block applies at all. An exception it rejects is never caught, so it keeps propagating with its stack intact.
try { await CallAsync(); }
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null; // only a 404 is caught; anything else propagates
}Java has no exception filters. You catch, test, and rethrow whatever you did not want. Here
insert saves an order, and duplicate handles an order number that
already exists:
try { insert(order); }
catch (SQLException e) {
if (!"23505".equals(e.getSQLState())) throw e; // no filter: rethrow the rest
return duplicate(order); // only a unique-key violation
}if (!"23505".equals(e.getSQLState())) throw e;23505 is the standard SQL code for a unique-key violation. Anything else is rethrown unchanged.
Never return from a finally block. It silently
discards any exception on its way out of try or catch. C# makes this a
compile error; Java allows it.
int load() {
try {
throw new IllegalStateException("real problem");
} finally {
return 0; // the exception vanishes
}
}return 0;Replaces the exception that was on its way out, so the caller gets 0 and no sign of the failure.
try-with-resources#
Closing a database connection and a statement when you have finished with them works like
using. Here dataSource hands out connections, and sql is
an update statement:
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)) {
stmt.executeUpdate();
} // closed in reverse order, even on exceptiontry (var conn = dataSource.getConnection();Whatever is declared in the brackets is closed when the block ends, even if an exception is thrown.
var stmt = conn.prepareStatement(sql)) {A second resource, after a semicolon. The statement is closed first, then the connection: the reverse of the order they were opened in.
C# refresherIDisposable and IAsyncDisposable
public sealed class Conn : IDisposable, IAsyncDisposable
{
public void Dispose() { /* release now */ }
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
using var c = new Conn(); // Dispose() at the end of the scope
await using var d = new Conn(); // DisposeAsync() at the end of the scopeYour own class takes part by implementing AutoCloseable, Java's
IDisposable:
public final class Conn implements AutoCloseable {
@Override public void close() { /* release now */ } // no throws: callers need no catch
}
try (var c = new Conn()) {
use(c);
} // close() runs here, even on an exceptionpublic final class Conn implements AutoCloseable {AutoCloseablehas one method,close, likeDispose.try (var c = new Conn()) {close()runs at the end of the block, even ifusethrows.
AutoCloseable.close() declares throws Exception, and
Closeable narrows it to IOException. When your cleanup cannot fail,
implement AutoCloseable and override close() without a
throws clause. That spares every caller a catch block.
If the body throws and close() throws, Java keeps the body's exception
as the main one and attaches the close failure to it as a suppressed
exception, which getSuppressed() returns. C# loses the original exception in the
same situation. It is worth knowing when a stack trace shows a "Suppressed:" section.
Cleanup without a using block#
Legacyfinalize()
Object.finalize() has been deprecated for removal
deprecated since Java 18. It is unpredictable, can bring dead objects back to
life, delays garbage
collection, and may never run at all. Do not override it, and do not
follow older guides that recommend it for releasing native resources.
The supported mechanism for a last-resort cleanup is Cleaner
Java 9, roughly a SafeHandle plus a finalizer queue. As in .NET,
the normal path should still be closing the object explicitly. Here allocate and
free stand for native calls that reserve and release memory:
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
}CLEANER.register(this, state)Asks the
Cleanerto runstateonce thisBufferis no longer reachable, if nobody has closed it by then.private record State(long handle) implements Runnable {The cleanup action. A nested record is never an inner class, so it holds no hidden reference to the
Buffer. Such a reference would keep theBufferreachable forever.@Override public void close() { cleanable.clean(); }The normal path:
closeruns the cleanup now, and it will not run again later.
The rule is the same as in .NET: the Cleaner is a safety net for callers who
forget, not the main 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.Java's compiler makes you declare or catch checked exceptions such as
IOException. Application code usually wraps them in an unchecked exception, and
always keeps the cause.
Checked exceptions do not fit in lambdas, so catch them inside the lambda and rethrow them unchecked.
Most C# exceptions have a close Java match: ArgumentException is
IllegalArgumentException, and InvalidOperationException is
IllegalStateException.
Try-with-resources is using, AutoCloseable is
IDisposable, and a failing close is attached to the main exception as a
suppressed one.
Never override finalize. Close objects with try-with-resources, and use a
Cleaner only as a safety net.
Quick reference#
| 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 |
| 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 | identical: runs whether or not the try block threw |
| using | try-with-resources | the block form of using, shown in the next section |
| C# | Java |
|---|---|
| IDisposable | AutoCloseable |
| IAsyncDisposable | no equivalent |
| Dispose() | close() |
| using statement | try-with-resources |
| using declaration | no equivalent, always a block |
Maven vs csproj#
Maven is not MSBuild. MSBuild is a general build engine that you script. Maven is a fixed sequence of build steps, driven by conventions, that you configure: you tell it what your project is, not how to build it.
The other shock: there is no mvn add. You edit pom.xml by hand.
Everyone does.
The standard layout#
The shop's billing service is a typical Maven project. Maven expects every project to use the same folders, and its conventions are the point: go against them, and you pay for it in configuration everywhere. Here is the standard layout:
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/src/main/java holds the code that ships, and src/test/java holds the
tests. src/main/resources holds files such as application.yml, which
are packaged into the Java archive (JAR) alongside the
compiled classes. mvnw is the Maven Wrapper, explained later in this chapter.
Tests live under src/test/java, but in the same
package as the code they test. That is how they
get package-private access, and it is Java's
replacement for InternalsVisibleTo. A test in a different package cannot see
package-private members.
The file that describes the project sits at the top of that tree.
pom.xml against csproj#
Both files below describe the same kind of project: its identity, the platform version it
targets, and one logging library it depends on. The Maven file is the Project Object Model
(POM), pom.xml:
<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><groupId>com.acme</groupId>Who publishes the project, written as a reversed domain name, like a package name.
<artifactId>billing</artifactId>The project's own name.
groupIdandartifactIdtogether play the part of aPackageId.<version>1.0.0-SNAPSHOT</version>SNAPSHOTmarks a version that is still in development. Maven may fetch a newer build of a snapshot, but never of a released version.<maven.compiler.release>25</maven.compiler.release>The Java release to compile for, like
TargetFramework.<artifactId>slf4j-api</artifactId>A dependency, named by its group, artifact and version, like a
PackageReference.
The quick reference at the end of this chapter maps each csproj setting to its place in
pom.xml.
Coordinates#
A NuGet package has one name: Serilog. A Maven artifact has two, plus a
version, and together they are called its coordinates:
<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><groupId>org.springframework.boot</groupId>The publisher, here the Spring Boot project.
<scope>compile</scope>When the dependency is available.
compile, the default, means everywhere, like an ordinaryPackageReference. Theimportscope is only for importing a bill of materials (BOM), explained later in this chapter.
| 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 idea MSBuild has no equivalent for, and once it clicks, the command line makes sense.
So each command below does everything the one above it does, and more:
./mvnw test # validate, compile, test
./mvnw package # all of that, then builds the JAR in target/
./mvnw install # all of that, then copies the JAR into ~/.m2/repository./mvnw packageRuns validate, compile and test, then builds the JAR in the
targetfolder../mvnw installAll of that, then copies the JAR into the local repository in
~/.m2, where other projects on the same machine can use it.
mvn clean package is the command you will type a thousand times. It is Java's
full rebuild, and running clean is far more common than with
dotnet build, because Maven does less incremental work.
Here are three .NET commands you will reach for on day one, with their Maven versions:
dotnet test # ./mvnw test
dotnet add package Serilog # no command: edit pom.xml by hand
dotnet list package --include-transitive # ./mvnw dependency:treeThere is no command to add a dependency: you add a dependency element to
pom.xml by hand, and Maven downloads it on the next build. The quick reference
lists the other everyday commands.
The wrapper#
Commit mvnw, mvnw.cmd and the .mvn/ folder to your
repository. Together they fix which Maven version the project uses, and download it on first
use. So the build server and every developer build with the same Maven. The nearest .NET
equivalent is a global.json that fixes the SDK version.
./mvnw clean package # always use the wrapper, not a system mvn
./mvnw -q test # quiet
./mvnw -o package # offline
./mvnw -pl billing-api -am package # one module and what it depends on./mvnw -o package-oworks offline, using only what is already in~/.m2../mvnw -pl billing-api -am package-plpicks one module of a multi-module build, and-amalso builds the modules it depends on.
Most Java services are Spring Boot applications, and Boot changes what a pom looks like.
A Spring Boot pom#
Spring Boot supplies a parent POM that sets compatible versions for several hundred
libraries, so your dependencies mostly leave out <version>. It is the
biggest single convenience in the Java build world. Here is the start of a Boot web service's
pom.xml:
<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>In both versions, the parent element makes Boot's POM the parent of yours, so
the two starters need no version. A starter is a dependency that brings a
whole feature with it: the web starter brings everything a Spring web application needs, and
spring-boot-starter-test brings the test libraries. The only difference between
the tabs is the web starter's name.
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 you follow a Boot 3 tutorial on Boot 4. See Migrating Boot 3 to
4.
Profiles and BOMs#
Two everyday Maven mechanisms have 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 the build server, for a GraalVM native image, or for a particular database. Here a profile runs the integration tests:
<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><id>integration</id>The profile's name, used with
-Pon the command line.<property><name>env.CI</name></property>Switches the profile on automatically when the
CIenvironment variable is set, as it is on most build servers.<artifactId>maven-failsafe-plugin</artifactId>The plugin that runs integration tests, as part of the
verifyphase.
You can also switch a profile on by hand, and check which ones are on:
./mvnw -Pintegration verify # activate explicitly
./mvnw help:active-profiles # which are actually on./mvnw -Pintegration verify-Pswitches a profile on by name../mvnw help:active-profilesLists the profiles that are really on, which is the first thing to check when a profile seems to be ignored.
Do not use profiles to build a different artifact for each environment. The point of a deployable JAR is that the same bytes go to every environment, and only the configuration differs, 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 sets the versions of 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><type>pom</type>This dependency is a POM, not a JAR.
<scope>import</scope>Copies that POM's managed versions into yours, so your own dependencies can leave out their versions.
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#
When two libraries need different versions of a third, Maven picks the declaration
nearest to your project in the dependency tree, not the highest version. Faced
with two requested versions, NuGet takes the higher one, so this surprises people. The
dependency:tree goal shows who brings in what. Here it is for the whole project,
and then for one library:
./mvnw dependency:tree
./mvnw dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind./mvnw dependency:treePrints every dependency, and the dependencies they bring in, as a tree.
-Dincludes=com.fasterxml.jackson.core:jackson-databindShows only the paths that lead to
jackson-databind, which is how you find who brought in an unexpected version.
To force a version, declare the library directly in your own
<dependencies>, because a direct declaration is always nearest, or set it in
<dependencyManagement>.
“Nearest wins” means that adding an unrelated dependency can silently
downgrade one of its neighbours' dependencies. If something breaks after you add a
library, run dependency:tree before anything else.
The billing service showed Maven's fixed layout: code in src/main/java, and
tests in the same packages under src/test/java.
pom.xml names the project with groupId, artifactId and
version, and lists dependencies by the same three coordinates.
Running a lifecycle phase runs every phase before it, so ./mvnw clean package
compiles, tests and builds the JAR.
Commit the wrapper, let Spring Boot's parent POM or a BOM supply versions, and run
dependency:tree when a version surprises you, because the nearest declaration
wins.
Quick reference#
| 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 Java release to compile for, as TargetFramework names the .NET one |
| PackageReference | dependency | a dependency element, identified by group, artifact and version |
| ProjectReference | dependency on a sibling module | written exactly like any other dependency, by coordinates |
| Directory.Build.props | a parent POM | children inherit from it; it is not textually included |
| Directory.Packages.props | dependencyManagement | sets versions centrally, so child modules can leave them out |
| nuget.config | settings.xml | lives in ~/.m2; lists repositories, mirrors and credentials |
| dotnet restore | no separate step | Maven downloads what it needs during the build |
| 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 |
Gradle and multi-module builds#
Gradle is Java's other build tool. Its build files are programs, written in Kotlin or Groovy, rather than an XML document like Maven's. That makes it more powerful, and easier to make a mess with. It is also much faster on large builds, because it redoes only the work that changed and can reuse results from a shared cache.
A multi-module build, called a reactor build in Maven and a multi-project build in Gradle, is your solution file: several modules built together, with dependencies between them.
Gradle against Maven#
The two tools make different trade-offs:
| 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 |
“Write code inline” is meant literally. A custom build step in Gradle is a few lines of Kotlin, where Maven needs a plugin:
// build.gradle.kts: a custom task is ordinary code
tasks.register("printVersion") {
doLast { println("version ${project.version}") }
}tasks.register("printVersion")Adds a new build step, called a task, named
printVersion. You run it with./gradlew printVersion.doLast { println("version ${project.version}") }The code the task runs: it prints the project's version.
If you are choosing: pick Maven. Its rigidity is a strength on a team, the Spring documentation assumes it, and a Maven build written by someone else is always readable. Choose Gradle when build times really hurt, or when the project is for Android.
Whichever tool a project uses, the build file says the same things in a different shape.
The same project, both ways#
Here is one project in both tools. It compiles for Java 25, uses the Simple Logging Facade for Java (SLF4J) logging library, and has JUnit tests:
<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>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
</dependencies>plugins {
java
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
dependencies {
implementation("org.slf4j:slf4j-api:2.0.16")
testImplementation("org.junit.jupiter:junit-jupiter:6.0.3")
}plugins {Plugins give a build its abilities. The
javaplugin compiles Java code, runs the tests and builds a Java archive (JAR).languageVersion = JavaLanguageVersion.of(25)Tells Gradle which Java version to build with, like
maven.compiler.release.implementation("org.slf4j:slf4j-api:2.0.16")A dependency, written as one string: group, artifact and version, separated by colons.
testImplementation("org.junit.jupiter:junit-jupiter:6.0.3")Available to the tests only, like Maven's
testscope.
Maven's scopes become Gradle configurations:
| Maven scope | Gradle configuration | Meaning |
|---|---|---|
| compile | implementation | used internally; NOT exposed to consumers |
| compile (exported) | api | exposed to consumers transitively |
| provided | compileOnly | needed to compile, but supplied at run time by the server |
| runtime | runtimeOnly | needed only at run time, such as a JDBC driver |
| test | testImplementation | on the test classpath only; never shipped |
implementation versus api has no Maven equivalent, and it is
Gradle's best feature. An implementation dependency does not leak onto the
compile classpath of the projects that use yours. That
speeds up builds and stops accidental coupling. In Maven, every compile dependency
is visible to your users too, which is how projects end up depending on things they never
declared.
Most real services are several modules built together, which is where both tools earn their keep.
Multi-module: your solution file#
The billing service grows into three modules: the domain classes, the database code, and the
web API that is deployed. Maven lists the modules in an aggregator
Project Object Model (POM), and Gradle lists them in
settings.gradle.kts. The shape is 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 deployablebilling-domain depends on nothing, billing-persistence uses the
domain, and billing-api uses both. Only billing-api is deployed. The
aggregator's own pom.xml lists the modules, and declares their versions once:
<!-- 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><packaging>pom</packaging>This project builds no JAR of its own. It only groups the modules.
<module>billing-domain</module>Each module is a folder with its own
pom.xml, like a project in a solution.<version>${project.version}</version>The modules share the aggregator's version, so a dependency on a sibling always matches.
.NET refresherDirectory.Packages.props: NuGet central package management
One file at the solution root sets the version of every NuGet dependency; each project then references them without a version.
<!-- Directory.Packages.props -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Serilog" Version="4.1.0" />
</ItemGroup>
</Project>
<!-- each .csproj: <PackageReference Include="Serilog" /> -->-am means “also make”: build the dependencies of the named module
too. Without it, Maven expects the siblings to be installed already in your
local repository, and you get a confusing
“could not resolve” error for your own code. -pl module -am is the
combination worth memorising.
The quick reference lists the solution-level operations in both tools.
Command mapping#
The everyday commands are listed in the quick reference. These three are the ones you will use most on a multi-module Gradle build:
./gradlew build # compile, test and package everything
./gradlew :billing-api:bootRun # run one module's Boot app
./gradlew dependencies --configuration runtimeClasspath # what actually ships./gradlew buildCompiles, tests and builds every module, like
mvn packageat the root../gradlew :billing-api:bootRunThe colon path names one module,
billing-api, andbootRunstarts its Spring Boot application../gradlew dependencies --configuration runtimeClasspathLists the dependencies that actually ship with the application.
Where artifacts come from#
In a company, every download usually goes through a company repository such as Nexus. You
set that up once, in settings.xml:
<!-- ~/.m2/settings.xml: send every download through the company repository -->
<mirrors>
<mirror>
<id>company</id>
<mirrorOf>*</mirrorOf>
<url>https://nexus.acme.com/repository/maven-public/</url>
</mirror>
</mirrors><mirrorOf>*</mirrorOf>Sends requests for every repository through this mirror.
<url>https://nexus.acme.com/repository/maven-public/</url>The company repository's address. It fetches from Maven Central on your behalf, and keeps a copy.
.NET refresherpackages.lock.json: NuGet's lock file
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<!-- restore writes packages.lock.json; CI runs: dotnet restore --locked-mode -->Maven has no lockfile by default. A repeatable build comes from fixing exact
versions and never using version ranges. SNAPSHOT versions are allowed to change:
1.0.0-SNAPSHOT is downloaded again, and can change under you. Never depend on
someone else's snapshot from a release build.
Gradle build files are Kotlin programs: faster on big builds, and more rope to hang yourself with. If you are choosing, pick Maven.
Maven's scopes become Gradle configurations, and implementation keeps a
dependency off your users' classpath, where api exposes it.
A multi-module build is your solution file: an aggregator POM or
settings.gradle.kts lists the modules, and -pl with -am
builds one module and what it depends on.
Maven has no lockfile by default, so fix exact versions and never depend on someone else's snapshot.
Quick reference#
| 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 |
| .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 |
| .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; set exact versions explicitly |
Modules, JPMS and the missing internal#
The Java Platform Module System (JPMS) Java 9 is the nearest thing Java has to an assembly boundary. A module declares which packages it exports and which modules it requires, and the Java Virtual Machine (JVM) enforces it.
Most applications do not use it. It matters for the
Java Development Kit (JDK) itself, for libraries, and for
jlink, and it is optional for a Spring Boot service. Know what
module-info.java means when you see one; you need not write one.
The problem it solves#
Before Java 9, the classpath was one flat namespace.
Any class could reach any public class in any
Java archive (JAR), so public meant public to the
whole world. And when two JARs contained a class with the same name, one silently hid the other:
the notorious “JAR hell”. C# never had this, because assemblies are real boundaries,
with internal inside them.
A module fixes that. Here the billing module lists what it needs and what it offers, in a
file called module-info.java at the root of its source folder:
// 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 com.acme.billing.api.PaymentProvider
with com.acme.billing.internal.StripeProvider; // service loading
}requires java.sql;The module needs
java.sql, one of the modules the JDK itself is split into.requires transitive com.acme.domain;Needs the domain module, and passes it on: any module that requires billing gets the domain module too.
exports com.acme.billing.api;The public types in this package can be used outside the module.
to com.acme.plugins;This package is visible only to the one module named here.
provides com.acme.billing.api.PaymentProviderDeclares an implementation of a service interface, which other modules can find while the program runs, through
ServiceLoader: a small built-in form of dependency injection.
Here is the full set of directives, including opens, which frameworks such as
Spring, Hibernate and
Jackson need:
| Directive | Means | .NET analogy |
|---|---|---|
| requires X | this module needs module X to compile and to run | assembly reference |
| requires transitive X | needs X, and any module that requires this one gets X too | a public dependency |
| exports p | the public types in package p are usable outside the module | public types in the assembly |
| exports p to M | package p is visible only to M | InternalsVisibleTo, inverted |
| opens p | lets frameworks reflect into p, private fields included | needed by Spring, Hibernate, Jackson |
| uses / provides | declares a service it looks up, or an implementation it supplies | DI at the platform level |
Here is the closest you can get to C#'s internal, side by side:
// 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
}internal class StripeGateway { }C# hides one type from other assemblies.
// com.acme.billing.internal stays hiddenJava hides a whole package, by not exporting it. Its public classes cannot be reached from other modules.
This is the closest thing to internal#
So a package that is not exported is the closest thing Java has to internal. The
quick reference at the end of this chapter maps each C# visibility to its Java form.
The granularity differs, and that matters. C#'s internal works per type or
member. A JPMS export works per package, all or nothing. You cannot export a
package and hide one class in it. So a module-based design forces you to put the API and the
implementation in different packages. That is good discipline, but it can mean real
restructuring.
Reaching into a package that is not exported fails when you compile, even though the class
itself is public:
// in module com.acme.app, which requires com.acme.billing
import com.acme.billing.internal.StripeGateway;
// error: package com.acme.billing.internal is not visibleimport com.acme.billing.internal.StripeGateway;The package exists and its class is public, but the billing module does not export it, so another module cannot import it.
The deeper sections cover how modules are found, why frameworks need opens, and
when to bother at all.
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 comes from the file name, and it exports everything and reads
everything. That is the bridge for migrating, and it is where most of the ecosystem still
sits.
Here is the same application started both ways:
# classpath: one flat namespace, nothing enforced
java -cp "app.jar:lib/*" com.acme.App
# module path: module boundaries enforced; -m names module/main class
java -p mods -m com.acme.billing/com.acme.Appjava -cp "app.jar:lib/*" com.acme.AppThe classpath: every JAR in one flat namespace, with nothing enforced.
java -p mods -m com.acme.billing/com.acme.App-pgives the module path, and-mnames the module and its main class, so the module boundaries are enforced.
Why frameworks need opens#
Spring, Hibernate and Jackson all read and set private fields through reflection. Under
JPMS that is blocked unless the package is open. This friction is the main reason
ordinary applications skip JPMS entirely. If you put an application that uses them into modules,
expect this:
java.lang.reflect.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 @3af49f1c#opens com.acme to com.fasterxml.jackson.databind; to
module-info.java, or declare an open module. For a JDK package, pass
--add-opens on the command line instead.What triggers it
// com.acme is exported by com.acme.billing, but not opened
var field = com.acme.Customer.class.getDeclaredField("name");
field.setAccessible(true); // throws InaccessibleObjectExceptionFlags 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 |
The one you are most likely to type lets an old library reach inside the JDK:
# let an old library reflect into java.lang, for code on the classpath
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar--add-opens java.base/java.lang=ALL-UNNAMEDOpens the
java.langpackage of thejava.basemodule to all code on the classpath, which the JDK calls the unnamed module.
Strong encapsulation of JDK internals is on by default since Java 17.
Libraries that reached into private parts of the JDK, such as private fields of
java.lang classes, now fail with InaccessibleObjectException instead of
printing a warning. sun.misc.Unsafe is the exception: it is still reachable, but
since Java 24 its memory methods print a warning that they will be removed. If an old library
fails this way after an upgrade, the short-term fix is --add-opens; the real fix is
upgrading the library.
When to bother#
Whether modules are worth it depends on what you build. A library published to Maven Central is the clearest case for them:
| 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's manifest, the file of settings inside
every JAR, when you publish a library. It costs one line, and gives your users a stable module
name if they do move to modules.
With Maven, the JAR plugin writes the entry for you:
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Automatic-Module-Name>com.acme.billing</Automatic-Module-Name>
</manifestEntries>
</archive>
</configuration>
</plugin><Automatic-Module-Name>com.acme.billing</Automatic-Module-Name>The module name your users will refer to if they move to modules, even though the library has no
module-info.java.
The billing module's module-info.java declared what it requires and what it
exports, and the JVM enforces those boundaries.
A package that is not exported is Java's internal, but it works per package, not
per type.
Frameworks that read private fields need their packages opened, which is the main reason most applications skip modules.
Since Java 17, JDK internals are strongly encapsulated: --add-opens is the
short-term fix, and upgrading the library is the real one.
Quick reference#
| 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 |
Testing#
JUnit is xUnit, Mockito is Moq, and AssertJ is FluentAssertions. Testcontainers and WireMock are literally the same projects; the .NET libraries are ports.
None of it ships with the Java Development Kit (JDK), so it
comes from dependencies, but Spring Boot's spring-boot-starter-test brings all of it
in one line.
The stack#
Almost every .NET testing tool has a Java counterpart, often with the same name. The quick reference at the end of this chapter lists them all. The first difference you meet is where the set-up code goes. Integration tests for orders need a database that starts once for the whole class, and a clean state before each test. Here are the lifecycle hooks, side by side:
public class OrderTests : IClassFixture<DbFixture>, IDisposable
{
public OrderTests(DbFixture db) { } // before each test
public void Dispose() { } // after each test
[Fact, Trait("Category", "slow")]
public void Saves() { }
}class OrderTest {
@BeforeAll static void startDb() { } // once per class: IClassFixture
@BeforeEach void setUp() { } // before each test
@AfterEach void tearDown() { } // after each test
@Test @Tag("slow")
void saves() { }
}@BeforeAll static void startDb() { }Runs once, before any test in the class, like an
IClassFixture. It must bestatic, because it runs before any test object exists.@BeforeEach void setUp() { }Runs before every test, as the test class's constructor does in xUnit.
@Test @Tag("slow")@Testmarks a test method, like[Fact], and@Taglabels it, like[Trait], so that a build can include or exclude it.
With the hooks in place, a test itself looks very like its C# twin.
A test#
Adding two amounts of money must give the right total. Here is the same small test in both frameworks:
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");
}
}class MoneyTest {No attribute on the class, and no
public: JUnit finds the test methods by their@Testannotation.void add_sumsAmounts() {A test method, marked
@Testlike[Fact]. It returnsvoidand takes no arguments..isEqualByComparingTo("15");An AssertJ assertion, like FluentAssertions'
Should().Be(...). It comparesBigDecimalvalues withcompareTo, so 15 and 15.00 count as equal.
Test classes and methods should be package-private, not public. JUnit 5 and later do not
require public, and the convention is to leave the modifier out, which also keeps
them out of your published API. JUnit 4 required public, so you will see it in
older code.
assertEquals(expected, actual) takes the expected value first,
the opposite of some .NET habits. Get it the wrong way round and the test still passes or fails
correctly, but the failure message reads backwards. AssertJ's
assertThat(actual).isEqualTo(expected) removes the doubt, which is one reason most
Java teams prefer it to plain JUnit assertions.
One test often needs to run with many sets of data.
Parameterised tests#
Adding should work for many pairs of numbers, so one test runs once for each row of data, like
xUnit's [Theory]:
[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);
}@ParameterizedTestMarks a test that runs once for each set of arguments, like
[Theory].@CsvSource({Each string is one row, split at the commas into the method's parameters, like one
[InlineData].
Data can come from other sources too:
| 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 |
Most unit tests also need to replace a dependency with a fake one.
Mocking#
OrderService saves each order it processes through an OrderRepo. The
test replaces the repository with a mock, tells it what to return, and checks that
save was called once:
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));OrderRepo repo = mock(OrderRepo.class);Creates the mock. It is itself an
OrderRepo, so there is no.Object..thenReturn(new Order(7));Stubs
find(7), likeSetup(...).Returns(...).verify(repo, times(1)).save(any(Order.class));Checks that
savewas called exactly once, with any order, likeVerifywithTimes.Once.
C# refresherMoq Callback and MockBehavior.Strict
var repo = new Mock<IOrderRepo>(MockBehavior.Strict); // any call without a Setup throws
Order? saved = null;
repo.Setup(r => r.Save(It.IsAny<Order>()))
.Callback<Order>(o => saved = o); // capture the argumentCapturing an argument, and failing on any call you did not expect, look like this in Mockito:
// capture what was passed, the usual job of a Moq Callback
var captor = ArgumentCaptor.forClass(Order.class);
verify(repo).save(captor.capture());
Order saved = captor.getValue();
// fail on any call you did not stub, like MockBehavior.Strict
OrderRepo strict = mock(OrderRepo.class, call -> { throw new AssertionError("unexpected " + call); });ArgumentCaptor.forClass(Order.class)Keeps the argument of the verified call, the usual job of a Moq
Callback.Order saved = captor.getValue();The order that was passed to
save.mock(OrderRepo.class, call -> { throw new AssertionError("unexpected " + call); })A default answer that throws for every call you have not stubbed, like
MockBehavior.Strict.
Older Mockito could not mock final classes or static methods, the
same limitation Moq has, for the same reason. Since Mockito 5 the inline mock maker is the
default, so final classes can be mocked, and mockStatic exists but should be a last
resort. If a class is hard to mock, that is usually a sign that its design needs work.
Mocks replace a dependency. Sometimes you want the real thing, started just for the test.
Testcontainers#
If you have used Testcontainers in .NET, this is the same library, and the Java Virtual Machine (JVM) version is the original. It runs real dependencies in Docker for integration tests. Here the repository test runs against a real PostgreSQL database, started for the test:
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> db =
new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("billing");
@Test
void findsSavedOrder() {
// db.getJdbcUrl() points at a real Postgres
}
}@TestcontainersStarts and stops the containers declared in the class.
static PostgreSQLContainer<?> db =A PostgreSQL 16 container. Because the field is
static, one container serves every test in the class.// db.getJdbcUrl() points at a real PostgresThe container reports where it is listening, so the code under test connects to a real database.
Spring Boot integrates directly: @ServiceConnection on a container field sets up
the data source settings automatically, with no properties to override by hand. See
Testing Spring Boot.
WireMock: faking an HTTP dependency#
WireMock is the same project as WireMock.Net, and the JVM original. It starts a real HTTP server, so your client code runs end to end instead of being mocked away. Here the exchange-rate client is tested against a fake rates service:
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);
}
}@WireMockTestStarts a real HTTP server on a free port for the test class, and passes its details into each test.
stubFor(get("/rates/GBPUSD")Tells the server how to answer a
GETfor/rates/GBPUSD.new RatesClient(wm.getHttpBaseUrl())Points the real client at the fake server.
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 an injected fault.
The deeper sections cover benchmarking, architecture tests, coverage, and the JUnit 6 change.
JMH against BenchmarkDotNet#
You cannot benchmark Java with a stopwatch loop. The just-in-time (JIT) compiler compiles busy code only after thousands of calls, and it removes work whose result is never used. So a naive loop measures the optimiser, not your code. The Java Microbenchmark Harness (JMH) handles warmup, separate runs and dead-code removal for you:
[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();
}
}@Warmup(iterations = 5)Runs the benchmark five times before measuring, so the JIT has compiled it by then.
@Fork(2)Runs everything in two fresh JVMs, so that one run's optimisations do not colour the other's.
return IntStream.range(0, n).sum();Returns the result. JMH consumes it, so the JIT cannot remove the work.
Return the result, or JMH's Blackhole must consume it. A
benchmark whose value is never used can be deleted entirely by the JIT, and you will measure an
empty loop at an impossible speed. The JVM does this far more aggressively than .NET, which is
the main reason hand-written Java benchmarks are usually wrong.
ArchUnit: architecture rules as tests#
ArchUnit is the same project as ArchUnitNET: layering rules become ordinary failing tests, rather than review comments. Here the billing code forbids the domain from depending on the web or database layers, keeps controllers away from persistence, and bans field injection:
@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);
}@AnalyzeClasses(packages = "com.acme.billing")Loads every class in
com.acme.billing, and runs each rule against them.noClasses().that().resideInAPackage("..domain..")A rule that reads almost as English: no class in a domain package may depend on the api or persistence packages. The two dots match any part of a package name.
noFields().should().beAnnotatedWith(Autowired.class);Bans
@Autowiredon fields, so dependencies come in through constructors.
JaCoCo against coverlet#
Coverage is a Maven plugin rather than a separate tool, and it can fail the build below a threshold. Here the build fails if less than 80% of lines are covered:
<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><goal>prepare-agent</goal>Attaches the JaCoCo agent to the test run, so that it can note which lines run.
<minimum>0.80</minimum>The threshold: the
checkgoal fails the build if line coverage drops below 80%.
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 carries straight over:
@Test, @ParameterizedTest and the extension API work as before.
Two things do change. JUnit 6 needs Java 17 or later. It also
unifies the version numbers: the Platform, Jupiter and Vintage parts now share
one number. Before, the Platform was on 1.x while Jupiter was on 5.x. If you set 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.
Only if you set JUnit's version yourself, import its bill of materials:
<!-- only when you pin JUnit yourself; the Spring Boot parent already does it -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit.version}</version> <!-- JUnit 6: one number for Platform and Jupiter -->
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement><artifactId>junit-bom</artifactId>JUnit's bill of materials: it sets matching versions for all of JUnit's parts.
<version>${junit.version}</version>One property for the version, because JUnit 6 uses one number for every part.
LegacyJUnit 4
Recognisable by org.junit.Test rather than
org.junit.jupiter.api.Test, public test classes, @Before and
@After instead of @BeforeEach and @AfterEach, and
@RunWith instead of @ExtendWith. The
junit-vintage-engine runs JUnit 4 tests inside newer JUnit, which is how large
codebases migrate a little at a time.
Naming#
Java has no single naming convention for tests, but two styles dominate:
methodUnderTest_condition_expectedResult, or a readable sentence in
@DisplayName. The second style looks like this:
@Test
@DisplayName("rejects an order when the cart is empty")
void rejectsEmptyCart() {
assertThatThrownBy(() -> service.checkout(emptyCart))
.isInstanceOf(IllegalStateException.class);
}@DisplayName("rejects an order when the cart is empty")The text that test reports show instead of the method name.
assertThatThrownBy(() -> service.checkout(emptyCart))An AssertJ check that the code throws, like FluentAssertions'
Should().Throw<T>().
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.JUnit, Mockito and AssertJ play the parts of xUnit, Moq and FluentAssertions, and
spring-boot-starter-test brings them all.
Test methods are package-private methods marked @Test, with
@BeforeEach and @BeforeAll for set-up and
@ParameterizedTest for data-driven tests.
A Mockito mock is the object itself: stub it with when(...).thenReturn(...), and
check calls with verify.
Testcontainers and WireMock are the original projects. Benchmark only with JMH, and measure coverage with JaCoCo.
Quick reference#
| .NET | Java | Note |
|---|---|---|
| xUnit / NUnit / MSTest | JUnit 5 (Jupiter) | the test framework nearly every Java project uses |
| [Fact] | @Test | marks a test method; the class needs no attribute |
| [Theory] + [InlineData] | @ParameterizedTest + @ValueSource | one test run for each row of inline data |
| [Trait] | @Tag | labels a test so a build can include or exclude it |
| Constructor / IDisposable | @BeforeEach / @AfterEach | methods that run before and after every test |
| 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 | fluent assertions that start assertThat(actual), then isEqualTo and friends |
| Moq | Mockito | the standard; there is no .Object, because the mock is the object |
| NSubstitute | Mockito | Mockito covers this style too; there is no separate favourite |
| AutoFixture | Instancio, EasyRandom | fill objects with generated test data |
| Bogus | Java Faker, Datafaker | fake but realistic names, addresses and so on |
| Testcontainers | Testcontainers | the same project; the .NET library is a port of this one |
| WireMock.Net | WireMock | the same project; the .NET library is a port of this one |
| BenchmarkDotNet | JMH | handles warm-up and dead-code elimination for you |
| FsCheck | jqwik | property-based testing: many generated inputs checked against one rule |
| ArchUnitNET | ArchUnit | architecture rules, such as layer boundaries, written as tests |
| coverlet | JaCoCo | code coverage, collected by an agent while the tests run |
| 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 -> ...), or an ArgumentCaptor |
| MockBehavior.Strict | no exact equal: a default answer that throws, or STRICT_STUBS |
| .NET | Java | Note |
|---|---|---|
| coverlet | JaCoCo | runs as a java agent during the test phase |
| ReportGenerator | jacoco:report | writes an HTML report into target/site/jacoco |
| threshold gate in CI | jacoco:check | fails the build itself when coverage drops below the limit |
| Stryker.NET | PIT | mutation testing; PIT is the JVM original |
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 Java Virtual Machine (JVM).
Web and API#
| .NET | Java | Note |
|---|---|---|
| ASP.NET Core | Spring Boot | the default for Java web services by a wide margin |
| Minimal APIs | Spring Boot, Javalin, Helidon | Javalin is the closest in spirit: routes as lambdas, little ceremony |
| Kestrel | embedded Tomcat, Netty, Jetty | Tomcat is what Spring Boot embeds unless you choose another |
| IIS hosting | a JAR with an embedded server | the JAR carries its own web server, so there is nothing to deploy into |
| Swashbuckle / NSwag | springdoc-openapi | generates OpenAPI from controllers |
| SignalR | Spring WebSocket + STOMP | no direct equivalent; WebSocket plus the STOMP protocol is closest |
| gRPC for .NET | grpc-java | |
| YARP | Spring Cloud Gateway | a reverse proxy and API gateway built on Spring |
| Blazor | Vaadin, Thymeleaf, JTE | no equivalent model; Vaadin is closest for server-driven UI |
| Razor / Razor Pages | Thymeleaf, JTE, Freemarker | server-side HTML templates; Thymeleaf is the Spring default |
Here is the smallest web endpoint in each: a greeting that takes a name from the path.
var app = WebApplication.Create(args);
app.MapGet("/hello/{name}",
(string name) => $"hello {name}");
app.Run();@RestController
class HelloController {
@GetMapping("/hello/{name}")
String hello(@PathVariable String name) {
return "hello " + name;
}
}@GetMapping("/hello/{name}")Maps
GET /hello/{name}to this method, likeMapGet.@PathVariable String nameTakes
namefrom the path. Minimal APIs do the same by matching the parameter's name.
Data access#
| .NET | Java | Note |
|---|---|---|
| Entity Framework Core | Hibernate / Spring Data JPA | the default ORM; Spring Data adds repositories on top of Hibernate |
| Dapper | JdbcClient, 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 low-level database API that every Java driver implements |
| EF Migrations | Flyway, Liquibase | Flyway runs numbered plain-SQL files; Liquibase uses changelogs |
| DbContext | EntityManager, or a Spring Data repository | the unit of work that tracks the entities you loaded |
| IDbConnection | DataSource, Connection | a DataSource hands out pooled connections; HikariCP is Boot's default pool |
| Npgsql / SqlClient | the PostgreSQL / MSSQL JDBC driver | a driver JAR on the classpath, chosen by its JDBC URL |
| StackExchange.Redis | Lettuce, Jedis | Lettuce is the client Spring Data Redis uses by default |
| MongoDB.Driver | mongodb-driver-sync | |
| Elasticsearch.Net | co.elastic.clients |
Here each loads a customer's orders with hand-written SQL. customerId holds the
customer's id:
var orders = conn.Query<Order>(
"select id, total from orders where customer_id = @Id",
new { Id = customerId });List<Order> orders = jdbcClient
.sql("select id, total from orders where customer_id = :id")
.param("id", customerId)
.query(Order.class) // columns mapped to the record's components
.list();.param("id", customerId)Fills in
:idin the SQL, like Dapper's anonymous-object parameter..query(Order.class)Maps each row to an
Order, matching columns to the record's components by name..list();Runs the query and returns the rows as a
List<Order>.
Serialisation#
| .NET | Java | Note |
|---|---|---|
| System.Text.Json | Jackson | the default in Spring Boot, which configures it for you |
| Newtonsoft.Json | Jackson, Gson | Gson is simpler but less capable; Jackson is the usual choice |
| JsonSerializer.Serialize | objectMapper.writeValueAsString | |
| [JsonPropertyName] | @JsonProperty | |
| [JsonIgnore] | @JsonIgnore | |
| JsonSerializerOptions | ObjectMapper configuration | set once on the mapper; Spring Boot builds it from properties |
| protobuf-net | protobuf-java | code generated from .proto files, where protobuf-net uses attributes |
| 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, and
@JsonMixin became @JacksonMixin. See
Migrating Boot 3 to 4.
DI, logging, config#
| .NET | Java | Note |
|---|---|---|
| Microsoft.Extensions.DependencyInjection | Spring, or Jakarta CDI | the container is part of the framework, not a separate package |
| Autofac | Spring | |
| ILogger<T> | SLF4J Logger | the interface you code against; Logback implements it |
| Serilog | Logback, Log4j2 | Logback is what Spring Boot logs through unless you switch |
| NLog | Log4j2 | |
| structured logging | Logstash encoder, or Boot structured logging | JSON log lines; Spring Boot 3.4 and later writes them with no extra library |
| IConfiguration | Spring Environment | |
| appsettings.json | application.yml / application.properties | |
| IOptions<T> | @ConfigurationProperties | binds a configuration section to a typed class or record |
| User Secrets | a local profile, or Vault | keep secrets out of the repository: an ignored local file, 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 | RestClient is the modern client; Boot configures its builder for you |
| Refit | @HttpExchange interfaces | declare an interface, and Spring generates the HTTP client |
| MediatR | Spring ApplicationEvents, Axon | no single library covers the same ground |
| MassTransit / NServiceBus | Spring Integration, Camel, Axon | |
| RabbitMQ.Client | Spring AMQP | RabbitTemplate to send, @RabbitListener to receive |
| Confluent.Kafka | Spring Kafka | KafkaTemplate to send, @KafkaListener to receive |
| Hangfire | Quartz, Spring @Scheduled | @Scheduled for simple timers; Quartz for persistent, clustered jobs |
| Quartz.NET | Quartz | the original; Quartz.NET is a port of this library |
| 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 | the original; the .NET library is a port of this one |
| WireMock.Net | WireMock | the original; WireMock.Net is a port of this one |
| AutoFixture | Instancio | |
| Bogus | Datafaker | |
| BenchmarkDotNet | JMH | |
| coverlet + ReportGenerator | JaCoCo | |
| SonarAnalyzer | SonarQube | the same Sonar product, with its Java rules |
| Roslyn analyzers | Error Prone | finds bug patterns at compile time, inside javac |
| StyleCop | Checkstyle | |
| dotnet format | Spotless | |
| ArchUnitNET | ArchUnit | the original; ArchUnitNET is a port of this one |
Observability and diagnostics#
| .NET | Java | Note |
|---|---|---|
| dotnet-counters | JFR, Micrometer | JFR records JVM events; Micrometer exposes application metrics |
| dotnet-trace / PerfView | JFR + JDK Mission Control | JFR records inside the JVM; Mission Control opens the recording |
| dotnet-dump | jmap, jcmd | heap and thread dumps taken from a running JVM |
| OpenTelemetry .NET | OpenTelemetry Java agent | an agent added at startup instruments common libraries with no code |
| App Insights / Prometheus | Micrometer + Prometheus | Micrometer is the metrics interface; the backend plugs in |
| HealthChecks | Spring Boot Actuator | health, metrics and info endpoints, switched on by one starter |
| Visual Studio Profiler | async-profiler, JMC | async-profiler draws CPU and allocation flame graphs cheaply |
Here are three commands against a running JVM whose process id is 4242:
jcmd 4242 JFR.start duration=60s filename=app.jfr # record a running JVM for a minute
jcmd 4242 Thread.print # a thread dump
jmap -histo 4242 | head # which classes take the most heapjcmd 4242 JFR.start duration=60s filename=app.jfrStarts a one-minute flight recording, written to
app.jfr, without restarting the application.jcmd 4242 Thread.printPrints what every thread is doing, like a thread dump from
dotnet-dump.jmap -histo 4242 | headCounts the objects of each class on the heap, with the most memory first.
JFR, Java Flight Recorder, has no direct .NET
equivalent and is worth learning early. It is a production-grade profiler that can
stay switched on, built into the
Java Development Kit (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 into the JDK; see Streams vs LINQ |
| Humanizer | no direct equivalent | no popular equivalent; format such text by hand |
| AutoMapper | MapStruct | compile-time, so mapping problems show up in the build |
| FluentValidation | Jakarta Bean Validation | @NotNull, @Size, custom validators |
| System.Collections.Immutable | List.of, Guava immutables | List.of and Map.of are built in; Guava adds richer immutable types |
| Nito.AsyncEx | virtual threads | the problems it solves largely disappear |
| CommandLineParser | picocli | annotation-driven command-line parsing, with generated help |
| Scriban / Handlebars | Thymeleaf, Mustache | text templates; Mustache is the closest to Handlebars |
| NodaTime | java.time | built into the JDK; both descend from Joda-Time |
| Guard clauses libraries | Objects.requireNonNull, Guava Preconditions | argument checks that throw with a clear message |
Jackson against System.Text.Json#
Jackson is the most-used library in Java after the JDK itself. Spring Boot configures it for you, so most of the time you only meet its annotations. When you do build a mapper yourself, here is how the options translate:
var opts = new JsonSerializerOptions {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull
};
string json = JsonSerializer.Serialize(order, opts);
var back = JsonSerializer.Deserialize<Order>(json, opts);JsonMapper mapper = JsonMapper.builder() // tools.jackson.databind.json
.changeDefaultPropertyInclusion(
incl -> incl.withValueInclusion(Include.NON_NULL))
.build(); // java.time works with no module
String json = mapper.writeValueAsString(order);
Order back = mapper.readValue(json, Order.class);JsonMapper mapper = JsonMapper.builder()Jackson 3 builds its mapper once, with a builder, instead of filling in an options object.
incl -> incl.withValueInclusion(Include.NON_NULL)Leaves null values out of the JSON, like
WhenWritingNull.mapper.readValue(json, Order.class)Passes the class, because erasure removes the type argument; see Generics and type erasure.
C# refresherNewtonsoft.Json: JsonConvert
string json = JsonConvert.SerializeObject(order);
var back = JsonConvert.DeserializeObject<Order>(json);On Boot 3, with Jackson 2, the same code imports from
com.fasterxml.jackson.databind, needs .addModule(new
JavaTimeModule()) for java.time types, and usually sets nulls aside with
.serializationInclusion(Include.NON_NULL). The annotations below are the same in
both versions.
Settings for single fields are attributes in C#, and annotations in Java. Here an order renames its id, hides a secret, and formats its total with a custom serializer:
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) { }@JsonProperty("order_id") long idWrites the component as
order_id, likeJsonPropertyName.@JsonIgnore String secretLeaves the component out of the JSON entirely.
@JsonSerialize(using = MoneySerializer.class)Uses a custom serializer, like a
JsonConverter.MoneySerializeris your own class.
| System.Text.Json | Jackson | Note |
|---|---|---|
| JsonSerializer.Serialize | mapper.writeValueAsString | |
| JsonSerializer.Deserialize<T> | mapper.readValue(s, T.class) | pass the class, because erasure removes the type argument |
| Deserialize a generic type | new TypeReference<List<T>>() { } | the trailing braces make a subclass that keeps List<T> at run time |
| [JsonPropertyName] | @JsonProperty | |
| [JsonIgnore] | @JsonIgnore | |
| [JsonConverter] | @JsonSerialize / @JsonDeserialize | |
| JsonSerializerOptions | JsonMapper.builder() | |
| CamelCase policy | PropertyNamingStrategies.LOWER_CAMEL_CASE | rarely needed: Java field names are already camelCase |
| DateTime support | built in (Jackson 3), JavaTimeModule (Jackson 2) | Jackson 3 handles java.time itself; Jackson 2 needs the module |
| Unknown members ignored | FAIL_ON_UNKNOWN_PROPERTIES | Jackson 2 fails on them by default, Jackson 3 ignores them |
Two Jackson defaults changed between Boot 3 and Boot 4. Jackson 2, which
Boot 3 uses, throws on an unknown JSON property unless you disable
FAIL_ON_UNKNOWN_PROPERTIES, and writes java.time types as unreadable
objects unless JavaTimeModule is registered. Jackson 3, which Boot 4 uses, ignores
unknown properties and handles java.time itself, as System.Text.Json
does. Spring Boot configures the mapper it builds to ignore unknown properties in both, so this
bites only when you build a mapper by hand.
MapStruct against AutoMapper#
The difference is when the mapping is worked out. AutoMapper matches properties by reflection while the program runs. MapStruct writes a plain Java class when you compile, so mapping problems show up in the build rather than in production. Here an order is mapped to a data transfer object that flattens the customer's name:
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);source = "customer.name")Fills
customerNamefrom the order's customer's name, likeMapFrom. Fields with the same name on both sides need no line at all.OrderDto toDto(Order order);A method with no body. MapStruct writes the body when you compile.
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. Here an order is written to a file, where p is the file's
path:
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
}class Order implements Serializable {Serializablehas no methods. It only marks the class as allowed to be written out.private transient String secret;transientleaves a field out of the bytes.out.writeObject(order);Writes the whole object, and every object it refers to, in Java's own binary format.
Deserializing untrusted data is remote code execution. The format encodes which classes to create, and the reader creates them before you can inspect anything. Chains of ordinary library classes, called gadget chains, turn a byte array into arbitrary code. This is the most exploited kind of Java vulnerability, and it is the reason so many Java security advisories look alike.
If you must read serialized data you do not control, use a deserialization filter. Java 9 added them, as JDK Enhancement Proposal (JEP) 290. A filter lists the classes that are allowed to appear:
java -Djdk.serialFilter='com.acme.**;java.base/*;!*' -jar app.jar-Djdk.serialFilter='com.acme.**;java.base/*;!*'Allows classes under
com.acmeand in thejava.basemodule, and rejects everything else, before any object is built.
| 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 and language-neutral, and it does not create arbitrary classes. Where you really 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 code that uses Remote Method
Invocation (RMI) or Jakarta Messaging (JMS). Implementing the
interface is harmless. Calling readObject on bytes from outside your system is
not.
SLF4J and Logback against ILogger and Serilog#
The Simple Logging Facade for Java (SLF4J) is the interface, and
Logback is the implementation. You always code against
SLF4J, exactly as you code against ILogger<T> rather than against Serilog.
Here the order service logs a placed order, and a warning tagged with a correlation id,
cid:
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");
}LoggerFactory.getLogger(OrderService.class)Gets a logger named after the class, like
ILogger<OrderService>. It isstatic, so one logger serves every instance.log.info("Placed {} for {}", id, total);The placeholders are
{}, filled in order. They have no names.MDC.put("correlationId", cid);Adds a value to the mapped diagnostic context (MDC), which every log line on this thread carries until it is removed, like
BeginScope.
SLF4J placeholders are positional {}, not named. There is no
{OrderId}, so structured logging needs the MDC or a structured encoder. This is a
real step backwards from Serilog, and it is why the MDC matters more in Java than logging scopes
do in .NET.
Log levels and the line format go in application.yml, which covers most of what
a Serilog configuration file does:
# 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"com.acme.billing: DEBUGLogs everything from
DEBUGup for the billing code, while everything else stays atINFO.[%X{correlationId}]%Xreads a value from the MDC, so each line shows its correlation id.
Lombok against source generators#
Lombok writes boilerplate code through annotations, while the code compiles. Here is the C# that makes most of it unnecessary, and the Lombok version:
// 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;
}@Getter @SetterGenerates a getter and a setter for every field.
@RequiredArgsConstructorGenerates a constructor that takes every
finalfield, which is how Spring passes in dependencies.
For immutable data, a record now beats Lombok and needs no dependency. Lombok
still earns its place for mutable Jakarta Persistence
(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 | the build tool; Maven is the common default |
| NuGet | Maven Central | the public package repository, addressed by group and artifact |
| dotnet publish | mvn package | with the Boot plugin, builds one JAR that holds every dependency |
| Self-contained deployment | fat JAR, or jlink | jlink builds a trimmed runtime to ship with the app |
| NativeAOT | GraalVM native-image | compiles ahead of time to a native binary; see the packaging chapter |
| ReadyToRun | AOT cache, CDS | startup caches; Java 24 and 25 improved them substantially |
| dotnet tool | JBang, or a shaded JAR | JBang runs a Java program straight from a file or a URL |
| global.json | .sdkmanrc, Maven toolchains | pins the JDK that a project builds with |
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 Boot works out most of its wiring from what is on the classpath. Add the Jakarta Persistence (JPA) starter, and it configures a data source, an entity manager and transactions without you writing a line.
Which version#
Two lines of Spring Boot are in use. Here is how they differ:
| 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 version is supported for at least 12 months. A major version is supported for at least three years from its release, as long as 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, and by September 2026 the line had moved on to 4.1.
So check spring.io/projects/spring-boot for the current release, rather than
copying the version from any tutorial, including this one.
In your pom.xml, one line decides which line you are on:
<!-- pom.xml: this one line decides which Boot line you are on -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.0</version> <!-- or a 3.5.x release for the Boot 3 line -->
</parent><artifactId>spring-boot-starter-parent</artifactId>Boot's parent Project Object Model (POM): a
pom.xmlthat yours inherits from. It sets compatible versions for hundreds of libraries.<version>4.0.0</version>The Boot version. Use a 3.5.x release instead for the Boot 3 line.
This book shows both lines. Where an API differs you get a tabbed block, and where something is new in Boot 4 it carries a Boot 4 badge.
The entry point#
The billing service starts from one class, where an ASP.NET Core service starts from
Program.cs:
// 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 here@SpringBootApplicationSwitches on auto-configuration, and searches this class's package, and every package below it, for your classes. That search is called component scanning.
SpringApplication.run(BillingApplication.class, args);Starts Boot. It creates the beans, the objects Spring manages for you, starts the embedded web server, and runs until it is stopped, like
app.Run().// services are discovered by annotation, not registered hereThere is no
builder.Serviceslist. Spring finds your classes by their annotations, such as@Service.
There is no central registration list. @SpringBootApplication implies
@ComponentScan over the package
containing the class and everything below it. A component in a sibling package is
invisible.
Put the application class in your root package, com.acme.billing, and
everything under it is scanned. This one convention causes more “bean not found”
confusion than anything else in Spring.
Every capability you add, such as a database or security, arrives the same way: as a starter.
Starters#
A starter is a dependency that pulls in a coherent set of libraries and switches on their auto-configuration. It is the unit of “I want this capability”. Here are the starters you will meet first:
| Capability | Boot 3 starter | Boot 4 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 |
Here one starter brings everything the billing service needs for database access:
<!-- one starter: Hibernate, Spring Data JPA, a connection pool, and their configuration -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency><artifactId>spring-boot-starter-data-jpa</artifactId>Brings Hibernate, the library that maps objects to database tables, along with Spring Data JPA and a connection pool, and switches on their auto-configuration. It has no version, because the parent POM sets it.
Boot 4 renamed its 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, the
first dependency in every tutorial you will read.
A starter only adds libraries. The part that sets them up is auto-configuration.
Auto-configuration#
This is the concept with no ASP.NET Core equivalent. Each starter ships configuration classes that switch themselves on only when certain conditions hold. Here, simplified, is how Boot sets up a database connection:
// 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) { ... }
}@ConditionalOnClass(DataSource.class)Applies only if the Java Database Connectivity (JDBC) classes, Java's standard database interface, are on the classpath.
@ConditionalOnMissingBean(DataSource.class)And only if you have not defined a
DataSourceyourself.@BeanThe method's return value becomes a bean that other classes can have injected.
How does Boot find these classes? Each library lists its auto-configuration classes in a file
inside its Java archive (JAR), in the
META-INF/spring folder, whose name ends in
AutoConfiguration.imports. Boot reads every such file on the classpath at startup.
This is Spring's version of a pattern called a service provider interface (SPI). A library
defines a place where other code can plug in, and finds the pieces at run time from files in
META-INF. Java's own ServiceLoader, from
the modules chapter, works the same way.
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.
When you want to know what Boot configured, and why, ask it:
# what did Boot configure, and why?
./mvnw spring-boot:run -Dspring-boot.run.arguments=--debug
# prints a "CONDITIONS EVALUATION REPORT": matched and unmatched, with reasons-Dspring-boot.run.arguments=--debugPasses
--debugto the application. Boot then prints its conditions evaluation report: every auto-configuration that matched or did not, with the reason.java -jartakes the same--debugflag.
The deeper sections cover where the files go, how to run the application, and the alternatives to Boot.
Project layout#
A typical Boot service is laid out like this:
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 SQLBillingApplication sits in the root package, com.acme.billing, so
component scanning finds everything in the folders below it. application.yml plays
the part of appsettings.json, and application-dev.yml overrides it when
the dev profile is active.
Running it#
./mvnw package on a Boot project produces a single
JAR that you can run: a
fat JAR, holding your code, every dependency and an
embedded Tomcat. java -jar runs it anywhere a
Java Virtual Machine (JVM) exists. It is the closest match to a
self-contained dotnet publish, and it is why Java web applications no longer need
an application server.
Here are both ways of running the billing service:
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev # run from source with a profile
java -jar target/billing-1.0.0.jar --server.port=9090 # run the packaged JAR on another port./mvnw spring-boot:run -Dspring-boot.run.profiles=devRuns from source, with the
devprofile active.java -jar target/billing-1.0.0.jar --server.port=9090Runs the packaged JAR. Any setting can be given on the command line after
--, here the port.
The quick reference lists the other everyday commands, with their .NET equivalents.
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 while the code compiles, through annotation processors, which removes Spring's classpath scanning and reflection at startup. That gives startup times in the tens of milliseconds, and much better GraalVM native image support: the same trade NativeAOT makes. Spring Boot has closed much of this gap with ahead-of-time (AOT) processing, but not all of it.
Here is a Quarkus endpoint, which uses the Jakarta REST annotations instead of Spring's:
// Quarkus: Jakarta REST annotations instead of Spring's
@Path("/hello")
public class HelloResource {
@GET
public String hello() { return "hello"; }
}@Path("/hello")The path this class answers, like Spring's
@RequestMapping.@GETHandles
GETrequests, like@GetMapping.
Spring Boot plays the part of ASP.NET Core: one @SpringBootApplication class
starts it, and components are found by scanning that class's package and everything below
it.
Starters bring a whole capability in one dependency, and auto-configuration sets it up, unless you define the bean yourself.
Boot releases a version every six months, so label code as Boot 3 or Boot 4, and check spring.io for the current release.
To see what Boot configured and why, run with --debug and read the conditions
evaluation report.
Quick reference#
| 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 |
Dependency injection and configuration#
Spring's container plays the part of ASP.NET Core's IServiceCollection, with one
difference: you do not register classes in a list. You mark a class with an
annotation such as @Service, Spring finds
it, and when another class asks for it as a constructor parameter, Spring passes it in. That is
dependency injection, the same idea you use in ASP.NET
Core.
Configuration also works as you expect, under new names: application.yml
instead of appsettings.json, profiles instead of environments, and
@ConfigurationProperties instead of IOptions<T>.
Registration by annotation#
The billing service has an OrderService that needs an
OrderRepository to load and save orders. In ASP.NET Core you register both classes
in Program.cs, and the service takes the repository as a constructor parameter. In
Spring you put an annotation on each class instead:
// Program.cs: each class is registered by hand
builder.Services.AddScoped<OrderRepository>();
builder.Services.AddScoped<OrderService>();
// OrderService.cs: the repository arrives
// through the primary constructor
public class OrderService(OrderRepository repository)
{
}// OrderRepository.java
import org.springframework.stereotype.Repository;
@Repository
public class OrderRepository {
// loads and saves orders; chapter 37 fills it in
}
// OrderService.java: there is no registration line,
// because the annotation is the registration
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final OrderRepository repository;
// Spring passes in the OrderRepository
// when it creates the OrderService.
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}@RepositoryMarks
OrderRepositoryas a class Spring should create.@Repositoryis for classes that talk to the database.@ServiceDoes the same for
OrderService. This one line replaces thebuilder.Servicesline in C#.private final OrderRepository repository;finalmeans the field is set once, in the constructor, and never changes, like a C#readonlyfield.public OrderService(OrderRepository repository) {Spring sees that the constructor needs an
OrderRepository, finds the one it created, and passes it in.
An object that Spring creates and hands to other objects is called a bean. At startup, Spring searches the application class's package, and every package below it, for classes with these annotations, and creates one bean for each. That search is called component scanning. Handing the job of creating objects to the framework like this is called inversion of control (IoC): your class no longer creates what it needs, it receives it.
These are the annotations that make a class a bean:
| Annotation | Means | Note |
|---|---|---|
| @Component | any class Spring should create and inject | "bean" is Spring's word for an object it creates and injects |
| @Service | a component that holds business logic | the same as @Component; the name documents intent |
| @Repository | a component that does data access | also converts database errors into Spring's own exception types |
| @Controller / @RestController | a component that handles web requests | |
| @Configuration | a class that declares @Bean methods | |
| @Bean | a method whose return value becomes a bean | use it for classes you cannot annotate, such as library types |
A class with one constructor needs no @Autowired annotation; Spring uses that
constructor. This style, called constructor injection, with final fields, is the
recommended one, and it is what you already do in ASP.NET Core.
Older code often uses field injection instead, so you should recognise it:
import org.springframework.beans.factory.annotation.Autowired;
// the older style: avoid it in new code
@Service
public class OrderService {
@Autowired
private OrderRepository repository;
}@AutowiredTells Spring to fill the field in after it has created the object. It works, but it hides what the class depends on, and a unit test cannot pass in a fake repository without starting Spring.
Every bean also has a lifetime, and Spring's default lifetime is the opposite of the one you are used to choosing.
Lifetimes#
In ASP.NET Core you choose a lifetime for every service: singleton, scoped or transient. Spring calls a lifetime a bean scope. It has the same three, under different names, and two more for web applications:
| ASP.NET Core | Spring | Note |
|---|---|---|
| AddSingleton | singleton | the default in Spring: one shared instance for the whole application |
| AddScoped | request | one per HTTP request; declare it with @RequestScope |
| AddTransient | prototype | a new instance each time the bean is injected |
| n/a | session | one instance per user's HTTP session |
| n/a | application | one per deployed web application |
Here are the first three side by side: a clock the whole application shares, a shopping cart for each request, and an id generator that is new every time:
// one SystemTime for the whole application
builder.Services.AddSingleton<SystemTime>();
// one Cart for each HTTP request
builder.Services.AddScoped<Cart>();
// a new IdGenerator every time one is injected
builder.Services.AddTransient<IdGenerator>();import org.springframework.context.annotation.Scope;
import org.springframework.web.context.annotation.RequestScope;
// one SystemTime for the whole application
@Service
class SystemTime { }
// one Cart for each HTTP request
@Service
@RequestScope
class Cart { }
// a new IdGenerator every time one is injected
@Service
@Scope("prototype")
class IdGenerator { }class SystemTime { }There is no scope annotation, so this is a singleton: Spring creates one
SystemTimeand injects that same object everywhere.@RequestScopeOne
Cartfor each HTTP request, likeAddScoped. A singleton can still take aCartin its constructor. Spring injects a stand-in object, called a proxy, that finds the current request's cart each time you call it.@Scope("prototype")A new
IdGeneratorevery time one is injected, likeAddTransient. Spring's word for transient is prototype.
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. So a bean that keeps data in a field shares that data with every request, on
every thread, and nothing warns you.
Here is that mistake in its most common form. The payment service keeps the order it is working on in a field:
@Service
public class PaymentService {
private Order current; // one field, shared by every request
public void pay(Order order) {
current = order; // another request can replace it here
charge(current);
}
private void charge(Order order) { }
}private Order current;There is only one
PaymentService, so there is only onecurrentfield, and every request reads and writes it.current = order;If two customers pay at the same moment, the second request can overwrite the field before the first one reaches
charge. Then both requests charge the second customer's order.
The fix is to keep per-request data in local variables and parameters, here by calling
charge(order) directly. Keep beans stateless. @RequestScope exists, but
stateless is nearly always the right answer.
Some objects cannot carry an annotation at all, because their class comes from a library. For those, you write a method that builds the object.
Beans for types you do not own#
The billing service calls an exchange-rate service over HTTP, with Spring's
RestClient. You cannot put @Service on RestClient, because
it is not your class. In ASP.NET Core you would register a factory function. In Spring you write
a @Bean method inside a @Configuration class:
var url = builder.Configuration["Rates:Url"]!;
builder.Services.AddSingleton(sp =>
new HttpClient { BaseAddress = new Uri(url) });import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class HttpConfig {
@Bean
RestClient ratesRestClient(
@Value("${rates.url}") String url) {
return RestClient.create(url);
}
}@ConfigurationMarks a class that holds
@Beanmethods. Spring calls them at startup.@BeanWhatever the method returns becomes a bean, named after the method:
ratesRestClient. Any class can now take aRestClientin its constructor. Bean names must be unique: two beans with the same name stop Boot from starting.@Value("${rates.url}") String urlParameters of a
@Beanmethod are injected, like constructor parameters. This one is a single setting from the configuration; Single values and precedence explains the${…}form.return RestClient.create(url);Builds a client whose requests all start with that address.
Multiple implementations#
The shop takes payment by card and by bank transfer. Each has its own gateway class, and both
implement one interface, PaymentGateway. When CheckoutService asks for
a PaymentGateway, two beans fit, and Spring must be told which one to use. C# solves
this with keyed services; Spring uses bean names:
// CardGateway and BankGateway both
// implement IPaymentGateway
builder.Services
.AddKeyedScoped<IPaymentGateway, CardGateway>("card")
.AddKeyedScoped<IPaymentGateway, BankGateway>("bank");
public class CheckoutService(
[FromKeyedServices("card")] IPaymentGateway gateway)
{
}import org.springframework.beans.factory.annotation.Qualifier;
public interface PaymentGateway {
void charge(Order order);
}
@Service("card")
class CardGateway implements PaymentGateway {
public void charge(Order order) { }
}
@Service("bank")
class BankGateway implements PaymentGateway {
public void charge(Order order) { }
}
@Service
public class CheckoutService {
private final PaymentGateway gateway;
CheckoutService(
@Qualifier("card") PaymentGateway gateway) {
this.gateway = gateway;
}
}@Service("card")Registers the bean under the name
card. Without a name, Spring names a bean after its class, with a lower-case first letter:cardGateway.@Qualifier("card") PaymentGateway gatewayAsks for the bean named
card, like[FromKeyedServices("card")]. Without it, Spring finds two matching beans and fails at startup.
@Qualifier is one of several ways to choose:
| Need | Spring |
|---|---|
| Pick one by name | @Qualifier("name") |
| Prefer one by default | @Primary on that bean |
| Inject all of them | List<PaymentGateway> or Map<String, PaymentGateway> parameter |
| Conditional registration | @ConditionalOnProperty, @Profile |
The List form is like injecting IEnumerable<T> in ASP.NET
Core, with one difference: it includes named beans. The .NET version leaves out keyed
registrations unless you ask for KeyedService.AnyKey. The Map form has
no ASP.NET Core equivalent: it gives you the beans keyed by name. Here the shop uses it to pick
a gateway from the payment method the customer chose:
import java.util.Map;
@Service
public class PaymentRouter {
private final Map<String, PaymentGateway> gateways;
// Spring fills the map with every PaymentGateway bean, keyed by bean name.
PaymentRouter(Map<String, PaymentGateway> gateways) {
this.gateways = gateways;
}
void pay(String method, Order order) {
gateways.get(method).charge(order); // method is "card" or "bank"
}
}PaymentRouter(Map<String, PaymentGateway> gateways) {A
Mapparameter withStringkeys receives every bean of that type, keyed by bean name:cardandbank. AList<PaymentGateway>parameter would receive the same beans, without the names.gateways.get(method).charge(order);Looks up the gateway for the customer's choice and charges the order. Adding a third gateway, for vouchers, needs a new class and nothing else.
So far every bean has been one of your classes or a library's. The rest of the chapter is about the settings those beans read.
Configuration files#
The billing service needs three kinds of setting: the database address, the address and
timeout of the exchange-rate service, and log levels. In ASP.NET Core they live in
appsettings.json. In Spring Boot they live in
src/main/resources/application.yml, written in YAML, a format where indentation
shows nesting instead of braces:
{
"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: DEBUGurl: jdbc:postgresql://localhost/billingThe setting
spring.datasource.url: each level of indentation adds one part to the name. Settings underspring:belong to Spring Boot itself. This one is the database address, in the form used by Java Database Connectivity (JDBC), Java's standard database interface.timeout-seconds: 5Your own settings can have any name. Boot's convention is lower case with hyphens.
com.acme.billing: DEBUGThe log level for one package and everything below it, like a category under
LogLevelin .NET.
Many projects use application.properties instead: the same settings, one per
line, each with its full name, such as rates.timeout-seconds=5. Boot reads either
file.
ASP.NET Core loads extra settings for each environment, such as Development. Spring does the same with profiles.
Profiles are environments#
On a developer's machine the shop must not send real emails. In ASP.NET Core you check the
environment in Program.cs. In Spring you mark the fake email sender with the profile
it belongs to. A Spring profile is a named set of settings and beans, such as
dev or prod, that you switch on at startup:
// run with ASPNETCORE_ENVIRONMENT=Development;
// .NET then also loads appsettings.Development.json
if (builder.Environment.IsDevelopment())
builder.Services
.AddSingleton<IEmailSender, FakeEmailSender>();import org.springframework.context.annotation.Profile;
interface EmailSender {
void send(String to, String text);
}
// run with SPRING_PROFILES_ACTIVE=dev;
// Boot then also loads application-dev.yml
@Service
@Profile("dev")
class FakeEmailSender implements EmailSender {
public void send(String to, String text) { }
}SPRING_PROFILES_ACTIVE=devAn environment variable that switches on the
devprofile, likeASPNETCORE_ENVIRONMENT. Boot then readsapplication-dev.ymlon top ofapplication.yml.@Profile("dev")Spring creates this bean only when the
devprofile is active. With any other profile, the bean does not exist.
A profile can also be excluded. This fake sender exists everywhere except in production, so no developer or test server has to remember to switch it on:
@Service
@Profile("!prod") // active in every profile except prod
class FakeEmailSender implements EmailSender {
public void send(String to, String text) { }
}@Profile("!prod")!means not. The bean is created unless theprodprofile is active, so production needs a real sender of its own.
Several profiles can be active at once, separated by commas, as in
SPRING_PROFILES_ACTIVE=dev,local. The quick reference lists the .NET form of each
profile setting.
Reading settings one at a time gets tedious. ASP.NET Core binds a whole section to a class
with IOptions<T>; Spring binds it to a
record.
IOptions becomes @ConfigurationProperties#
The exchange-rate client needs both of its settings, rates.url and
rates.timeout-seconds. Rather than read them one by one, you describe the whole
rates section as one type, and Spring fills it in:
public class RatesOptions
{
public string Url { get; set; } = "";
public int TimeoutSeconds { get; set; }
}
builder.Services.Configure<RatesOptions>(
builder.Configuration.GetSection("Rates"));
public class RatesClient(IOptions<RatesOptions> options)
{
}import org.springframework.boot.context.properties.*;
@ConfigurationProperties(prefix = "rates")
public record RatesProperties(
String url,
int timeoutSeconds) { }
@Configuration
@EnableConfigurationProperties(RatesProperties.class)
class RatesConfig { }
// the record is injected itself, with no wrapper
@Service
class RatesClient {
RatesClient(RatesProperties rates) { }
}@ConfigurationProperties(prefix = "rates")Binds the
ratessection ofapplication.ymlto this type.int timeoutSeconds) { }A record is an immutable data class (see Records). Each component is filled from the setting with the matching name, so
timeoutSecondsgetstimeout-seconds.@EnableConfigurationProperties(RatesProperties.class)Tells Spring to create a
RatesPropertiesbean. On a larger service, put@ConfigurationPropertiesScanon the application class instead, and Spring finds every such type itself.RatesClient(RatesProperties rates) { }The record is injected like any other bean.
This improves on IOptions<T> in two ways. You inject the settings type
itself, with no wrapper, and it can be an immutable record. Binding is also relaxed:
rates.timeout-seconds, rates.timeoutSeconds and
RATES_TIMEOUT_SECONDS all fill the same component. This is called relaxed
binding, and it lets an environment variable set any property with no extra
configuration.
Single values and precedence#
For one setting on its own, @Value injects it directly. It is handy in a
@Bean method or a small class:
@Service
class RatesClient {
RatesClient(@Value("${rates.url}") String url,
@Value("${rates.timeout-seconds:5}") int timeoutSeconds) {
}
}@Value("${rates.url}") String url${rates.url}is a placeholder: Spring replaces it with the value of therates.urlsetting, and fails at startup if there is none.${rates.timeout-seconds:5}The text after the colon is a default, used when the setting is missing.
The same setting can come from several places. Here the container platform sets the
exchange-rate address as an environment variable, and a startup script passes it again with
-D:
export RATES_URL=https://rates.staging.example.com
java -Drates.url=https://rates.example.com -jar billing.jarexport RATES_URL=https://rates.staging.example.comSets the environment variable
RATES_URL, which relaxed binding reads asrates.url.java -Drates.url=https://rates.example.com -jar billing.jarStarts the service with a Java system property of the same name. The system property wins, because it ranks higher in the list below. An argument placed after the name of the Java archive (JAR), such as
--rates.url=…, would beat both.
When the same setting is given in several places, the source higher in this list wins:
| 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 |
SPRING_APPLICATION_JSON is one environment variable that holds many settings,
written as JSON. Two more sources sit between it and system properties. One is the
servlet container. The other is the Java Naming and
Directory Interface (JNDI), a lookup service provided by an old-style
application server. They rarely matter in a
Boot service. See
How Java runs your code for what a system property
is.
Environment variables are written in upper case with underscores:
RATES_TIMEOUT_SECONDS sets rates.timeout-seconds. ASP.NET Core marks
each level of nesting with a double underscore, as in Rates__TimeoutSeconds. Spring
needs no such convention: a single underscore does the job.
Secrets#
The billing service's database password must never be committed to the repository. Here is where each .NET option for secrets lands in Spring:
| .NET | Spring |
|---|---|
| User Secrets | application-local.yml, gitignored |
| Azure Key Vault | Spring Cloud Azure, or HashiCorp Vault through Spring Cloud Vault |
| AWS Secrets Manager | Spring Cloud AWS |
| Environment variables | environment variables |
.NET refresherdotnet user-secrets
dotnet user-secrets init
dotnet user-secrets set "Db:Password" "dev-only-secret" # stored per user, outside the repoThere is no built-in equivalent of dotnet user-secrets. The common pattern is a
file called application-local.yml, listed in .gitignore and switched on
with a local profile. Set this up on day one, because the alternative, a committed
password, is the most common accident in Spring repositories.
Here is the local file for the billing service:
# src/main/resources/application-local.yml, listed in .gitignore
spring:
datasource:
password: dev-only-secret
# activate it with: ./mvnw spring-boot:run -Dspring-boot.run.profiles=localpassword: dev-only-secretThe development password. The file is listed in
.gitignore, so git never commits it.-Dspring-boot.run.profiles=localRuns the service with the
localprofile, so Boot loadsapplication-local.ymlon top ofapplication.yml.
OrderService became a bean through @Service, and received its
OrderRepository through its constructor, with no registration list.
Beans are singletons by default, so keep them stateless: an order kept in a field is shared by every request.
When two beans implement PaymentGateway, @Qualifier picks one by
name, and a Map parameter collects them all.
Settings live in application.yml, profiles such as dev replace
environments, and a @ConfigurationProperties record replaces
IOptions<T>.
Quick reference#
| ASP.NET Core | Spring Boot |
|---|---|
| builder.Services.AddScoped<OrderService>() | @Service on the class |
| AddKeyedScoped<IPaymentGateway, CardGateway>("card") | @Service("card"), then @Qualifier("card") |
| IOptions<RatesOptions> | a @ConfigurationProperties record, injected directly |
| .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 | several profiles at once, comma-separated: dev,local |
How Spring actually works#
Later in this part, @Transactional, @PreAuthorize and
@Retryable each come with the same warning: calling the method from inside its own
class silently does nothing. This chapter explains why, once, so the warning makes sense.
The short version: Spring does not change your class. It wraps it. The bean that other classes receive is often a stand-in object, a proxy, that does the annotation's work, such as starting a transaction, and then calls your object. A call from inside your object never leaves it, so it never passes through the stand-in.
The bean lifecycle#
When the billing service starts, it should load today's exchange rates into memory before the
first request arrives. In ASP.NET Core you would write an IHostedService. In Spring,
the right place depends on the steps Spring takes with every bean.
The object that creates and holds all the beans is called the application context, Spring's version of the ASP.NET Core service provider. At startup it finds the beans, from component scanning and from @Bean methods. It then creates each one through its constructor, which is where constructor injection happens, and runs it through the rest of these steps:
ASP.NET Core builds an object and hands it to you. Spring builds it and then shows it to every
BeanPostProcessor: a class that can decorate, replace or reject a bean before anyone
else sees it. The framework extends itself this way, and the step after your initialisation code
is where proxies are made.
Here is where each step lands, next to its nearest ASP.NET Core equivalent:
| 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 |
C# refresherIHostedService: code that runs when the host starts
public class Warmup : IHostedService
{
public Task StartAsync(CancellationToken ct) => LoadCachesAsync(ct); // at startup
public Task StopAsync(CancellationToken ct) => Task.CompletedTask; // at shutdown
}
builder.Services.AddHostedService<Warmup>();In Spring, the startup job is a bean that implements ApplicationRunner. Boot calls
it once the application context is fully started:
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
class Warmup implements ApplicationRunner {
// Boot calls this once, after every bean is ready.
@Override
public void run(ApplicationArguments args) {
loadExchangeRates();
}
private void loadExchangeRates() {
// read today's rates into memory
}
}class Warmup implements ApplicationRunner {Boot finds every bean that implements
ApplicationRunnerand calls itsrunmethod after startup, likeStartAsync.public void run(ApplicationArguments args) {argsholds the command-line arguments the application was started with.
Do not use injected dependencies in a constructor body for work that needs the
whole context. While your constructor runs, the application is still starting: other
beans may not exist yet, and your own bean has no proxy yet. Work that needs the whole
application, such as loading caches, belongs in an ApplicationRunner, as above.
Work that needs only this bean's own dependencies can go in a method marked
@PostConstruct, from the jakarta.annotation
package, which runs after
injection. But calling one of the bean's own @Transactional methods from there
starts no transaction, for the reason the next section shows.
The step that matters most is the one after your initialisation code, where proxies are made.
What a proxy actually is#
The OrderService's place method is marked
@Transactional: it must run inside a database transaction. Spring cannot add code to
your method. Instead, when it creates the OrderService, a
BeanPostProcessor returns a different object in its place: a subclass that Spring
generates while the program runs. That object is a proxy. It starts the transaction,
calls your place method, and commits. Every class that has the
OrderService injected receives the proxy, not your object.
Think of a receptionist at an office. Visitors speak to the receptionist, who signs them in and passes the message on. People already inside the office speak to each other directly, and never pass the desk.
C# has nothing that does this for you. You write the decorator class and register it, so the wrapping is visible in your code. In Spring, the annotation is all you write:
// no built-in support: use a DispatchProxy,
// or the Scrutor library's Decorate method
services.AddScoped<IOrderService, OrderService>();
services.Decorate<IOrderService, TransactionalDecorator>();
// the decoration is visible in your codeimport org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
@Transactional
public void place(Order order) {
// save the order
}
}
// nothing here shows that a wrapper existsservices.Decorate<IOrderService, TransactionalDecorator>();Scrutor wraps
OrderServicein your ownTransactionalDecoratorclass, so the wrapping appears inProgram.cs.@TransactionalAsks Spring to run the method inside a transaction. Spring does it by handing everyone a proxy instead of your object. Data access explains the transaction itself.
Spring can build that proxy in two ways.
Two kinds of proxy#
Java has built-in dynamic proxies, but they only work through an interface. For everything
else, Spring uses the Code Generation Library (CGLIB), which generates a subclass of your class
while the program runs. Which one you get depends on the bean and on one setting,
proxyTargetClass:
| 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, even for a bean that implements an interface. That is why the
bean's class name in a stack trace looks like OrderService$$SpringCGLIB$$0. It is
also why your class needs a constructor that is not private: the generated subclass must be able
to see one.
You can see the proxy for yourself by printing the class of an injected bean:
@Service
public class OrderService { // no interface, so Spring subclasses it
@Transactional public void place(Order order) { }
}
// in any bean that has the OrderService injected:
System.out.println(orderService.getClass().getName());
// prints com.acme.billing.OrderService$$SpringCGLIB$$0System.out.println(orderService.getClass().getName());getClass()returns the class of the object you actually hold, which is the proxy.// prints com.acme.billing.OrderService$$SpringCGLIB$$0A subclass of
OrderServicethat Spring generated.$$SpringCGLIB$$marks it as Spring's, and the number keeps generated names unique.
A CGLIB proxy is a subclass, so final defeats it silently. A
subclass cannot override a final method, so the proxy cannot wrap it. The method runs
with no transaction, and the only sign is a warning in the startup log saying the method
“cannot get proxied via CGLIB”. Unlike C#, Java methods are virtual by default, so a
final added for safety quietly turns your transaction off.
A final class fails loudly instead: the application
stops at startup with “Cannot subclass final class”. This is also why
Kotlin needs a Spring plugin. Kotlin classes and methods are
final by default, and the kotlin-spring plugin opens the classes that carry Spring
annotations.
The same picture explains the warning this chapter began with.
Why self-invocation fails#
The billing service places a batch of orders by calling place once for each. The
loop sits in the same class as place:
@Service
public class OrderService {
public void placeAll(List<Order> orders) {
for (var order : orders) {
place(order); // a plain call on this: no proxy, no transaction
}
}
@Transactional
public void place(Order order) {
// save the order
}
}public void placeAll(List<Order> orders) {A caller outside the class reaches
placeAllthrough the proxy. ButplaceAllhas no@Transactional, so the proxy simply passes the call on.place(order);This is really
this.place(order): your object calling itself. The call never reaches the proxy, so no transaction starts, and nothing warns you.
A method calling another method on the same object is called self-invocation. In the office picture, it is one colleague speaking to another: the receptionist never hears it. The fix is to make the call come from outside, through the proxy. Here the loop moves to its own bean:
@Service
public class BatchService {
private final OrderService orders;
BatchService(OrderService orders) {
this.orders = orders;
}
public void placeAll(List<Order> batch) {
for (var order : batch) {
orders.place(order); // through the proxy: one transaction per order
}
}
}orders.place(order);ordersis the injected proxy, so this call goes through it, and each order is placed in its own transaction.
Moving the method is the best of four ways out:
| Fix | How | Cost |
|---|---|---|
| Move the method to another bean | inject it and call it | best; usually better design anyway |
| Inject yourself | @Autowired @Lazy OrderService self; then self.place(order) | works, reads oddly; without @Lazy, Boot rejects it as a cycle |
| 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 |
AspectJ weaving changes your compiled classes themselves instead of wrapping them, so it covers internal calls too, at the cost of an extra build or startup step.
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.
The deeper sections cover writing your own wrapping behaviour, Spring's expression language, dependency cycles, and the order of beans.
AOP: writing your own cross-cutting behaviour#
Aspect-oriented programming (AOP) means adding the same behaviour around many methods without
editing each one, such as timing every service call. Proxies are how Spring does it, and AOP is
the API for adding your own. It is Spring's answer to ASP.NET Core middleware, an action filter or
a DispatchProxy decorator, but it works on any bean method, not only on
controllers.
The class that holds the behaviour is called an aspect. Here the billing service logs how long every service method takes:
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class TimingAspect {
private static final Logger log = LoggerFactory.getLogger(TimingAspect.class);
// every public method in a class marked @Service
@Around("@within(org.springframework.stereotype.Service) && execution(public * *(..))")
public Object time(ProceedingJoinPoint call) throws Throwable {
long start = System.nanoTime();
try {
return call.proceed(); // run the real method
} finally {
log.debug("{} took {} ms", call.getSignature(),
(System.nanoTime() - start) / 1_000_000);
}
}
}@AspectMarks the class as an aspect.
@Componentmakes it a bean, so Spring finds it.@Around("@within(org.springframework.stereotype.Service) && execution(public * *(..))")Which methods to wrap.
execution(public * *(..))means any public method, with any return type, name and parameters.@within(…Service)limits it to classes marked@Service.return call.proceed();Runs the real method and returns its result. Without this line, the method never runs.
} finally {Runs whether the method returned or threw, as in C#, so every call is timed. Each
{}in the log message is replaced by the next argument.
@Around is one kind of advice, the name for code an aspect runs. Here are
all five:
| 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 |
The expression in @Around is a pointcut: it picks the methods to wrap.
These are the common forms:
| 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.
Self-invocation skips them, final methods defeat them, and objects you create
yourself with new are never wrapped. An aspect that seems not to run is almost always
one of those three.
SpEL#
Spring Expression Language (SpEL) is a small expression language that runs inside annotation
values, written #{...}. You will meet it in @PreAuthorize for security
and @Cacheable for caching, and it also works in @Value. Here it picks a
pricing region from a system property:
@Service
class RegionalPricing {
private final String region;
// SpEL: the user.region system property, or "eu" when it is not set
RegionalPricing(@Value("#{systemProperties['user.region'] ?: 'eu'}") String region) {
this.region = region;
}
}#{systemProperties['user.region'] ?: 'eu'}#{...}marks SpEL.systemProperties['user.region']reads a Java system property, and?:supplies'eu'when it is missing, like C#'s??.
In the other annotations, SpEL reads the method's parameters by name, with a
# in front:
// in a service with method security: only the order's owner may cancel it
@PreAuthorize("#order.owner == authentication.name")
public void cancel(Order order) { }
// in a service with caching: one cache entry per currency pair and date
@Cacheable(value = "rates", key = "#pair + ':' + #date")
public BigDecimal lookup(String pair, LocalDate date) { ... }@PreAuthorize("#order.owner == authentication.name")#orderis theorderparameter, andauthenticationis the logged-in user. The method runs only when the order's owner is the person calling it.key = "#pair + ':' + #date"Builds the cache key from both parameters, such as
EURUSD:2026-09-13.
${...} 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#
Say the OrderService needs the BillingService to raise an invoice,
and the BillingService needs the OrderService to look up the order.
Neither can be created first:
@Service
class OrderService {
OrderService(BillingService billing) { }
}
@Service
class BillingService {
BillingService(OrderService orders) { }
}OrderService(BillingService billing) { }To create an
OrderService, Spring needs aBillingService, which in turn needs anOrderService.
ASP.NET Core throws on a dependency cycle. Spring used to resolve some cycles by injecting a half-built object, which caused hard-to-find bugs. Since Boot 2.6, cycles are rejected by default, and Boot stops at startup with this report:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| billingService defined in file [.../BillingService.class]
↑ ↓
| orderService defined in file [.../OrderService.class]
└─────┘┌─────┐The box draws the loop: each bean in it needs the next one, and the last needs the first.
There are four ways out, and only the first is a real fix:
| Fix | Note |
|---|---|
| Extract the shared logic into a third bean | the usual fix: a cycle often hides a missing third responsibility |
| @Lazy on one of the injection points | Spring injects a proxy and creates the real bean on first use; it hides a design problem |
| spring.main.allow-circular-references=true | re-enables the old behaviour; avoid |
| Setter injection | no longer a way out: since Boot 2.6 a cycle through setters or fields fails too, unless circular references are allowed |
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#
Two questions come up once a service has many beans: in what order they run, and whether a bean exists at all. These annotations answer both:
| 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") |
Here checkout runs a list of steps, and the audit step must come before pricing:
import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
interface CheckoutStep { }
@Component @Order(1) class AuditStep implements CheckoutStep { }
@Component @Order(2) class PricingStep implements CheckoutStep { }
@Service
class Checkout {
Checkout(List<CheckoutStep> steps) { } // AuditStep first, then PricingStep
}@Component @Order(1) class AuditStep implements CheckoutStep { }Lower numbers come first in any injected
Listof this type.Checkout(List<CheckoutStep> steps) { }Receives every
CheckoutStepbean, sorted by@Order.
@Order sets the order of beans within an injected collection, and the
order of aspects and filters. It does not set the order in which beans are
created. Dependencies decide that, and @DependsOn is the only way to add a
dependency that the code itself does not have.
Spring runs every bean through the same lifecycle, so startup work such as loading exchange
rates belongs in an ApplicationRunner, not in a constructor.
The bean other classes receive is often a proxy, a generated subclass such as
OrderService$$SpringCGLIB$$0, that starts the transaction and then calls your
object.
When placeAll calls place in the same class, the call skips the
proxy, so move the loop into another bean such as BatchService.
A final method silently loses its annotation, and a final class stops
the application at startup.
Quick reference#
| Symptom | Cause | Fix |
|---|---|---|
| @Transactional does nothing | the call comes from the same class | call it from another bean |
| One annotated method is ignored | the method is final | remove final |
| Cannot subclass final class | a final class cannot be proxied | remove final |
| The beans form a cycle at startup | two beans need each other | extract a third bean |
| C# | Spring |
|---|---|
| IHostedService | a bean that implements ApplicationRunner |
| services.Decorate<IOrderService, TransactionalDecorator>() | an annotation such as @Transactional; Spring makes the proxy |
Controllers, routing and binding#
Spring's web framework is Spring MVC, named after the model-view-controller (MVC) pattern, and
it plays the part of ASP.NET Core MVC. @RestController is
[ApiController], @GetMapping is [HttpGet], and the binding
annotations map almost one to one.
The main difference is what a method returns. Spring returns the value itself, or wraps it in
ResponseEntity to choose the status and headers, instead of using an
IActionResult hierarchy.
A controller#
The shop's API needs two endpoints: one returns an order by its id, and one creates an order. In ASP.NET Core you write a controller class. In Spring you write almost the same class:
[ApiController]
[Route("api/orders")]
public class OrdersController(IOrderService orders)
: ControllerBase
{
[HttpGet("{id:int}")]
public async Task<ActionResult<OrderDto>> Get(int id)
{
var order = await orders.FindAsync(id);
return order is null ? NotFound() : Ok(order);
}
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrder request)
{
var created = await orders.CreateAsync(request);
return CreatedAtAction(nameof(Get),
new { id = created.Id }, created);
}
}import java.net.URI;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orders;
public OrderController(OrderService orders) {
this.orders = orders;
}
@GetMapping("/{id}")
public ResponseEntity<OrderDto> get(
@PathVariable long id) {
return orders.find(id)
.map(ResponseEntity::ok)
.orElseGet(() ->
ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<OrderDto> create(
@RequestBody CreateOrder request) {
var created = orders.create(request);
var location =
URI.create("/api/orders/" + created.id());
return ResponseEntity.created(location)
.body(created);
}
}@RestControllerMarks a controller whose return values are written straight into the response body. Spring turns them into JSON with Jackson, the JSON library it uses by default. It is
[ApiController]andControllerBasetogether.@RequestMapping("/api/orders")The path prefix for every method in the class, like
[Route].public OrderController(OrderService orders) {Spring passes in the
OrderServicebean, as Dependency injection and configuration showed.@PathVariable long id) {Takes
idfrom the{id}part of the path. Spring converts the text to along; if it cannot, the request fails with 400 Bad Request..map(ResponseEntity::ok)findreturns anOptional, as in the Optional chapter. If an order is there, it becomes a 200 OK response.ResponseEntity::okis a method reference, short fororder -> ResponseEntity.ok(order).ResponseEntity.notFound().build());Otherwise, a 404 Not Found with no body.
@RequestBody CreateOrder request) {Reads the JSON request body into a
CreateOrder, like[FromBody].return ResponseEntity.created(location)A 201 Created response, with a
Locationheader pointing at the new order, likeCreatedAtAction.
Each method in a controller is tied to one HTTP verb and one path. That is the next thing to look at.
Routing and verbs#
Each HTTP verb has its own mapping annotation, and the class-level
@RequestMapping supplies the common prefix. Here are three more endpoints on the
same controller, with their method bodies left out:
@RestController
@RequestMapping(path = "/api/orders", produces = "application/json")
class OrderController {
@GetMapping("/{id:\\d+}") // only digits match
OrderDto get(@PathVariable long id) { ... }
@PutMapping("/{id}")
OrderDto replace(@PathVariable long id, @RequestBody OrderDto order) { ... }
@DeleteMapping("/{id}")
void delete(@PathVariable long id) { ... }
}@RequestMapping(path = "/api/orders", produces = "application/json")The prefix for every path in the class.
producessays every method returns JSON, like[Produces]; in Spring it is an attribute of the mapping, not a separate annotation.@GetMapping("/{id:\\d+}")The part after the colon is a regular expression, here one or more digits, so it works like the route constraint
{id:int}. A request for/api/orders/abcgets 404 instead of reaching the method. Java strings double the backslash.@PutMapping("/{id}")There is one annotation per verb:
@GetMapping,@PostMapping,@PutMapping,@PatchMappingand@DeleteMapping. The quick reference lists each one next to its ASP.NET Core attribute.
The path is only one place a value can come from. The next section covers the others.
Model binding#
The shop's search endpoint takes its values from three places: the region from the path, the search text and paging from the query string, and the tenant from a header. Each parameter carries an annotation that says where its value comes from:
[HttpGet("search/{region}")]
public List<OrderDto> Search(
[FromRoute] string region,
[FromQuery] string q,
[FromQuery] int page = 0,
[FromQuery] string? status = null,
[FromHeader(Name = "X-Tenant")] string tenant = "")
{
...
}@GetMapping("/search/{region}")
public List<OrderDto> search(
@PathVariable String region,
@RequestParam String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(required = false) String status,
@RequestHeader(value = "X-Tenant",
required = false) String tenant) {
...
}@PathVariable String region,From the
{region}segment of the path, like[FromRoute].@RequestParam String q,From the query string, as in
?q=tea, like[FromQuery]. It is required, so a request withoutqgets 400 Bad Request.@RequestParam(defaultValue = "0") int page,Optional: a missing
pagebecomes 0.@RequestParam(required = false) String status,Optional: a missing
statusbecomesnull.@RequestHeader(value = "X-Tenant",From the
X-Tenantheader, like[FromHeader]. Headers are required by default too, hencerequired = false.
Spring Data adds one more kind of parameter, a Pageable, filled from the
page, size and sort query parameters.
Data access shows it.
@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.
None of these annotations names its parameter: Spring reads the name, such as
q, from the compiled class. That only works when the compiler keeps parameter names,
and a build that does not gives this error:
java.lang.IllegalArgumentException: Name for argument of type [long] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.#id, to know which part of the request
to bind. The class was compiled without the setting that keeps parameter names in the
class file.-parameters flag for you, so
this appears only in builds that do not use it. Add -parameters to the compiler
options, or name the value: @PathVariable("id").What triggers it
// compiled without -parameters
@GetMapping("/{id}")
public ResponseEntity<OrderDto> get(@PathVariable long id) { ... }The request body binds to a type of its own, and records are the natural choice.
Records as DTOs#
The create endpoint receives a new order as JSON and returns the saved order. Types that only carry data in and out of an API are called data transfer objects (DTOs). In Java they should be records, because Jackson reads and writes records directly:
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.math.BigDecimal;
import java.util.List;
public record CreateOrder(
@NotBlank String customerId,
@NotEmpty List<OrderLine> lines) { }
public record OrderLine(String sku, int quantity) { }
public record OrderDto(long id, String status, BigDecimal total) { }public record CreateOrder(The request body. Jackson fills each component from the JSON property with the same name, as in
{"customerId": "c1", "lines": [{"sku": "A-1", "quantity": 2}]}.@NotBlank String customerId,Bean Validation annotations: rules that Spring checks when the controller parameter is marked
@Valid. The next chapter covers them.public record OrderDto(long id, String status, BigDecimal total) { }The response. It is written as
{"id":1,"status":"PAID","total":19.99}.
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 fails 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 a record gives a 200. For any other status, you wrap the result.
Returning results#
The invoice endpoint answers in four ways: 400 when the request is invalid, 204 after a
delete, an unusual status code, and a PDF file to download. In ASP.NET Core each answer is a
helper method on ControllerBase. In Spring each one is a
ResponseEntity builder:
// valid, deleted, teapot, errors and pdf
// come from earlier in the method
if (!valid) return BadRequest(errors);
if (deleted) return NoContent();
if (teapot) return StatusCode(418);
return File(pdf, "application/pdf", "invoice.pdf");import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
// valid, deleted, teapot, errors and pdf
// come from earlier in the method
if (!valid) return ResponseEntity.badRequest().body(errors);
if (deleted) return ResponseEntity.noContent().build();
if (teapot) return ResponseEntity.status(418).build();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=invoice.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(new ByteArrayResource(pdf));ResponseEntity.badRequest().body(errors);A 400 with the errors as the JSON body, like
BadRequest(errors).ResponseEntity.noContent().build();build()finishes a response that has no body: here 204 No Content.ResponseEntity.status(418).build();Any status code, by number.
.body(new ByteArrayResource(pdf));A file download: the bytes wrapped as a
Resource, with theContent-Dispositionheader naming the file, likeFile(...).
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 status other than 200.
The deeper sections cover code that runs around every request, cross-origin calls from a browser, API versions and OpenAPI documents.
Middleware#
C# refresherASP.NET Core middleware and action filters
Middleware sees every request on its way through the pipeline; a filter wraps only controller actions, with access to the action's arguments and result.
app.Use(async (ctx, next) => // middleware: every request
{
ctx.Response.Headers["X-Trace"] = "1";
await next();
});
public class TimingFilter : IActionFilter // filter: around controller actions only
{
public void OnActionExecuting(ActionExecutingContext c) { }
public void OnActionExecuted(ActionExecutedContext c) { }
}Java web servers are built on the Jakarta Servlet standard. A servlet is a class that
handles HTTP requests, and Spring MVC itself runs as one, the DispatcherServlet,
which routes each request to a controller method. Code that must see every request runs before
it, as a servlet filter. Code that wraps only controller methods is a
HandlerInterceptor:
| 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 |
The shop tags every request with a correlation id, so that all the log lines for one request can be found together. A filter reads the id from a header, or makes a new one:
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
@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();
}
}
}public class CorrelationIdFilter implements Filter {A servlet filter. Because it is also a
@Component, Spring Boot registers it to run for every request.MDC.put("correlationId", id);The mapped diagnostic context (MDC) belongs to the Simple Logging Facade for Java (SLF4J), the logging API most Java code uses. It holds values for the current thread that the log format can print on every line, like a logging scope in Serilog.
chain.doFilter(req, res);Passes the request on to the next filter, and finally to Spring MVC, like
await next().MDC.clear();Runs in
finally, so the id never leaks into the next request that this thread handles.
The equivalent of an action filter is a HandlerInterceptor, which runs around
controller methods only:
@Component
class TimingInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
req.setAttribute("t0", System.nanoTime());
return true; // false stops the request here
}
}public boolean preHandle(Runs before the controller method, like
OnActionExecuting.return true;Lets the request continue. Returning
falsestops it here.
Unlike a filter, an interceptor must be registered, in a configuration class that implements
WebMvcConfigurer. That class is also where cross-origin rules go.
The shop's web front end runs at https://shop.example.com and calls the API at
another address. Browsers block such cross-origin calls unless the API says the front end's origin
is allowed. That permission system is called cross-origin resource sharing (CORS):
builder.Services.AddCors(o => o.AddDefaultPolicy(p => p
.WithOrigins("https://shop.example.com")
.WithMethods("GET", "POST")));
app.UseCors();import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;
@Configuration
class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://shop.example.com")
.allowedMethods("GET", "POST");
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TimingInterceptor());
}
}registry.addMapping("/api/**")Applies the rule to every path under
/api.**matches any number of path segments..allowedOrigins("https://shop.example.com")Only pages from this origin may call the API. The browser first sends a check request, called a preflight, and Spring answers it. A preflight from any other origin gets 403.
registry.addInterceptor(new TimingInterceptor());Registers the interceptor from above, for every controller method.
Do not put @EnableWebMvc on this class in a Boot application. It tells Spring
you are configuring the web layer entirely yourself, which switches off Boot's own web setup.
Implementing WebMvcConfigurer is enough. For a single controller,
@CrossOrigin on the class sets the same rule.
API versioning#
The shop's order endpoint changed shape, and old mobile apps still call the old one. Spring Framework 7 added built-in API versioning Boot 4. On Boot 3 you roll your own, usually with separate paths:
// Boot 3: versioning by path, the common convention
@RestController
@RequestMapping("/api/v1/orders")
class OrderControllerV1 { }
@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 { }// Boot 4: the version is part of the mapping
@RestController
@RequestMapping("/api/orders")
class OrderController {
@GetMapping(version = "1.0")
OrderDtoV1 getV1() { ... }
@GetMapping(version = "1.1")
OrderDtoV2 getV11() { ... }
}@GetMapping(version = "1.0")This method answers requests for version 1.0, and the next one answers 1.1. Spring picks the method from the version the request asks for.
On Boot 4, one setting says where the request carries its version, here in a header:
spring:
mvc:
apiversion:
use:
header: X-API-Versionheader: X-API-VersionRead the version from the
X-API-Versionheader. A query parameter, a path segment or a media type parameter work too, through the otherusesettings.
Unless you set a default version, a request that sends no version is rejected with 400 Bad Request. So is a request for a version that no method supports.
Boot 4 also carries the version to the client side: RestClient,
WebClient and HTTP interface clients can all send one. Tests can send one too,
through WebTestClient and MockMvc.
OpenAPI#
C# refresherSwashbuckle: OpenAPI for ASP.NET Core
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
app.UseSwagger(); // the document, at /swagger/v1/swagger.json
app.UseSwaggerUI(); // the browsable UI, at /swagger| .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. You
add annotations only to say more than the code does:
@Operation(summary = "Fetch one order") // [SwaggerOperation]
@ApiResponse(responseCode = "404", description = "no such order") // [ProducesResponseType]
@GetMapping("/{id}")
OrderDto get(@PathVariable long id) { ... }@Operation(summary = "Fetch one order")The operation's one-line summary in the generated document.
@ApiResponse(responseCode = "404", description = "no such order")Documents a response the method can give besides the normal 200.
OrderController is a @RestController: @GetMapping and
@PostMapping tie methods to verbs, and the record a method returns is written as
JSON.
Each parameter says where its value comes from, with @PathVariable,
@RequestParam, @RequestHeader or @RequestBody, and
@RequestParam is required unless you say otherwise.
Return the value for a 200, or a ResponseEntity to choose the status and
headers, as create does with 201 Created.
Request and response types are records, such as CreateOrder and
OrderDto, which Jackson reads and writes directly.
Quick reference#
| ASP.NET Core | Spring | Note |
|---|---|---|
| [Route("api/x")] | @RequestMapping("/api/x") | on the class, as a prefix for every method's path |
| [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 | a regular expression: {id:\d+} | without one, a value that cannot be converted produces a 400 |
| [Produces("application/json")] | produces = "application/json" | an attribute of the mapping annotation, not a separate one |
| [Consumes(...)] | consumes = "..." |
| 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 |
| 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 |
Validation and error responses#
Jakarta Validation, the standard also called Bean Validation, plays the part of
DataAnnotations and much of FluentValidation. You put constraint
annotations on a
record, mark the controller parameter @Valid,
and Spring rejects bad input with 400 before your method runs.
For error bodies, Spring supports ProblemDetail, which follows the same internet
standard, RFC 9457, as ASP.NET Core's ProblemDetails.
Constraint annotations#
An order sent to the API must name the customer, ask for between 1 and 100 items, and give a valid email address. In C# you put DataAnnotations attributes on the record. In Java you put constraint annotations on the record's components:
// System.ComponentModel.DataAnnotations
public record CreateOrder(
[Required, StringLength(50)] string CustomerId,
[Range(1, 100)] int Quantity,
[EmailAddress] string Email);import jakarta.validation.constraints.*;
public record CreateOrder(
@NotBlank @Size(max = 50) String customerId,
@Min(1) @Max(100) int quantity,
@Email String email) { }@NotBlank @Size(max = 50) String customerId,At least one character that is not whitespace, and at most 50 characters. A component can carry several constraints, like
[Required, StringLength(50)].@Min(1) @Max(100) int quantity,A number from 1 to 100, like
[Range(1, 100)].@Email String emailMust look like an email address. Like most constraints,
@Emailtreats a missing value as valid; add@NotBlankas well to require one.
Spring Boot checks these annotations with Hibernate Validator, the usual implementation of the
standard. The quick reference lists the DataAnnotations attribute for each one, along with
constraints that .NET lacks, such as @Positive and @Past.
@NotNull is not [Required] for strings.
@NotNull accepts "", so {"name": ""} passes and only
{"name": null} is rejected. Use @NotBlank for a string that must contain
something other than whitespace, and @NotEmpty for a collection that must have
elements. Getting this wrong is the most common validation mistake.
The annotations only describe the rules. Nothing checks them until you ask.
Triggering validation#
The create endpoint from the previous chapter should turn away a bad order before any of its code runs. One annotation on the parameter does it:
import jakarta.validation.Valid;
@PostMapping
public ResponseEntity<OrderDto> create(
@Valid @RequestBody CreateOrder request) {
// runs only when the request passed every constraint
...
}@Valid @RequestBody CreateOrder request) {@Validtells Spring to check theCreateOrderafter reading it from the body. If any constraint fails, Spring throwsMethodArgumentNotValidException, the client gets 400 Bad Request, and your method never runs.
A constraint can also sit directly on a query parameter or path variable. Spring checks those
too, with no @Valid:
@GetMapping
public List<OrderDto> list(@RequestParam @Min(0) int page) {
...
}@RequestParam @Min(0) int pageA request for page -1 is rejected with 400 Bad Request. This failure arrives as a
HandlerMethodValidationException, a different exception from the one@Validraises, so an error handler should cover both.
Older code, and tutorials written before Spring Framework 6.1, put @Validated on
the controller class to get this. Spring's documentation now says to remove it. With it, the
check runs through a proxy instead of through the validation
built into Spring MVC, Spring's model-view-controller (MVC) web
framework.
Forgetting @Valid silently turns validation off for that parameter. The
annotations are still on the record, but nothing checks them, and there is no warning. An order
with a blank customer and a quantity of 0 goes straight into your method. If invalid input is reaching
your code, check for the missing @Valid first.
The built-in constraints cover most rules. For the rest, you write your own.
Custom constraints#
The shop accepts prices only in currencies that exist, and no built-in constraint knows the list. This is where Jakarta Validation covers FluentValidation's territory: a custom constraint is an annotation, plus a class that does the check.
C# refresherFluentValidation: rules in a validator class
public class CreateOrderValidator : AbstractValidator<CreateOrder>
{
public CreateOrderValidator()
{
RuleFor(x => x.Currency).Must(c => Currencies.Contains(c));
}
}import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.*;
import java.math.BigDecimal;
import java.util.Currency;
@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));
}
}
// used like any built-in constraint
public record PriceQuote(@ValidCurrency String currency, BigDecimal amount) { }@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})Where the annotation may be used: on fields, and on record components such as
currency.@Constraint(validatedBy = CurrencyValidator.class)Connects the annotation to the class that does the check.
public @interface ValidCurrency {@interfacedeclares a new annotation type, as the annotations chapter showed.String message() default "unknown currency";The message used when the check fails. The other two members,
groupsandpayload, are required on every constraint annotation. Copy them as they are.return value == null || Currency.getAvailableCurrencies()A missing value counts as valid, as with the built-in constraints. Otherwise the code must be a real currency code, such as
EUR;ABCis rejected.
When a check fails, the client needs an error body it can read. Both frameworks use the same standard for it.
ProblemDetail#
RFC 9457 defines one shape for error bodies, called problem details, and both frameworks produce it. ASP.NET Core adds it with one call:
C# refresherProblemDetails in ASP.NET Core
builder.Services.AddProblemDetails(); // RFC 9457 bodies for unhandled errors
app.MapGet("/orders/{id}", (int id) =>
Results.Problem(title: "Order not found", statusCode: 404));Spring Boot has it too, switched off by default. One setting turns it on:
# application.yml
spring:
mvc:
problemdetails:
enabled: trueenabled: trueBoot then registers a handler, a subclass of Spring's
ResponseEntityExceptionHandler, that turns Spring MVC's own exceptions, such as a failed@Valid, into problem details.
Here is the body each framework sends when the order above fails validation:
// ASP.NET Core, for an [ApiController]
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": ["The Email field is not a valid e-mail address."],
"Quantity": ["The field Quantity must be between 1 and 100."]
},
"traceId": "0HNOHL49BIAAL:00000001"
}// Spring Boot, with problemdetails enabled
{
"detail": "Invalid request content.",
"instance": "/api/orders",
"status": 400,
"title": "Bad Request"
}"detail": "Invalid request content.",Spring's default body says what went wrong in general, but does not list the fields. The section on field-level errors adds them.
"instance": "/api/orders",The path of the request that failed.
"title": "Bad Request"There is no
typemember. Spring leaves it out when it is the default,about:blank, which means the status code says it all.
The deeper sections cover problem details for your own exceptions and bodies that name each failing field.
Global exception handling#
Your own exceptions need a problem details body too. When an order does not exist, the service
throws an OrderNotFoundException that carries the missing id:
public class OrderNotFoundException extends RuntimeException {
private final long orderId;
public OrderNotFoundException(long orderId) {
super("No order with id " + orderId);
this.orderId = orderId;
}
public long orderId() { return orderId; }
}super("No order with id " + orderId);The exception's message, which becomes the
detailof the response.
One class then turns that exception into a 404 for every controller. In ASP.NET Core this is exception-handling middleware or a filter:
app.UseExceptionHandler(...);
// or a filter
public class ApiExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext ctx) { ... }
}@RestControllerAdvice
public class ApiExceptionHandler
extends ResponseEntityExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail handleNotFound(OrderNotFoundException e) {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, e.getMessage());
problem.setTitle("Order not found");
problem.setType(URI.create(
"https://acme.com/errors/not-found"));
problem.setProperty("orderId", e.orderId());
return problem;
}
}@RestControllerAdviceApplies the handlers in this class to every controller. Such a class is called a controller advice.
extends ResponseEntityExceptionHandler {Inherits Spring's problem details for its own exceptions. Because your class is now the
ResponseEntityExceptionHandler, Boot does not register its own.@ExceptionHandler(OrderNotFoundException.class)Handles one exception type, thrown from any controller.
problem.setProperty("orderId", e.orderId());Adds a member of your own, at the top level of the body.
The response for a missing order 99 is:
{
"detail": "No order with id 99",
"instance": "/api/orders/99",
"status": 404,
"title": "Order not found",
"type": "https://acme.com/errors/not-found",
"orderId": 99
}"type": "https://acme.com/errors/not-found",A web address that names the kind of problem, so clients can tell errors apart without reading the text.
These are the pieces you combine:
| 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 body for a validation failure does not name the fields, where ASP.NET Core's
does. Most teams add them, by overriding one method in the same
ApiExceptionHandler:
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException e, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
var errors = e.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
f -> Objects.requireNonNullElse(f.getDefaultMessage(), "invalid"),
(first, second) -> first));
ProblemDetail problem = e.getBody();
problem.setTitle("Validation failed");
problem.setProperty("errors", errors);
return ResponseEntity.status(status).headers(headers).body(problem);
}protected ResponseEntity<Object> handleMethodArgumentNotValid(Spring calls this method when
@Validfails. Overriding it replaces the default body..collect(Collectors.toMap(Builds a map from each field's name to its message, as in Streams.
(first, second) -> first));Keeps the first message when one field has two errors.
ProblemDetail problem = e.getBody();The problem details Spring had already built for this exception, which you add to.
The response now names each field that failed:
{
"detail": "Invalid request content.",
"instance": "/api/orders",
"status": 400,
"title": "Validation failed",
"errors": {
"quantity": "must be greater than or equal to 1",
"customerId": "must not be blank",
"email": "must be a well-formed email address"
}
}"customerId": "must not be blank",The default messages come from Hibernate Validator. A constraint's
messageattribute replaces one, as in@NotBlank(message = "customer is required").
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.
Do not handle the exception in a second advice class. A separate
@RestControllerAdvice with
@ExceptionHandler(MethodArgumentNotValidException.class) works on its own. But when
problem details are switched on, Boot's handler is registered at @Order(0), ahead of
yours, so it answers and yours never runs. Override the method in your
ResponseEntityExceptionHandler subclass instead, as above.
A failed constraint on a query parameter goes to a sibling method,
handleHandlerMethodValidationException, which you override the same way.
CreateOrder's rules sit on its components, as @NotBlank,
@Min, @Max and @Email, and @NotNull alone still
accepts an empty string.
@Valid on the controller parameter makes Spring check them; without it, nothing is
checked and nothing warns you.
A constraint directly on a query parameter, such as @Min(0) on
page, is checked without @Valid.
With spring.mvc.problemdetails.enabled, a failed check returns a problem details
body with status 400, as ASP.NET Core does.
Quick reference#
| DataAnnotations | Jakarta Validation | Note |
|---|---|---|
| [Required] | @NotNull | rejects null only, so an empty string still passes |
| [Required] on a string | @NotBlank | rejects null, empty and whitespace-only strings |
| n/a | @NotEmpty | null or empty; works on collections too |
| [StringLength(n)] | @Size(max = n) | a length limit; on a collection or map it limits the size |
| [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 | dates in the past or future; works on java.time types |
| n/a | @Valid on a nested field | validates the nested object's own constraints too |
| [CreditCard] | @CreditCardNumber | from Hibernate Validator, not the Jakarta standard set |
| ASP.NET Core | Spring |
|---|---|
| [ApiController] automatic 400 | @Valid on the parameter |
| AddProblemDetails() | spring.mvc.problemdetails.enabled: true |
| IExceptionFilter | @RestControllerAdvice with @ExceptionHandler |
Data access, JPA and migrations#
Spring Data JPA, built on Jakarta Persistence (JPA), is EF Core with a different philosophy. EF Core builds queries from LINQ expression trees. Java has no expression trees, so Spring Data builds queries from method names, from query strings, or from a criteria API.
Migrations are not part of the object-relational mapper (ORM). A separate tool, Flyway or Liquibase, owns the database schema, and a migration is a plain SQL file rather than generated C#.
The landscape#
EF Core is one product. Its Java equivalent is three layers, each of which you will see named in stack traces:
- Java Database Connectivity (JDBC) is Java's ADO.NET: the low-level API that sends SQL and reads rows.
- Jakarta Persistence (JPA) is a standard for an ORM: the annotations and interfaces. Hibernate is the implementation Spring Boot uses, so JPA is the contract and Hibernate does the work.
- Spring Data JPA sits on top, and writes your repository classes for you.
Where EF Core configures the model in OnModelCreating, JPA uses annotations on
the entity classes themselves:
C# refresherOnModelCreating: configuring the model in code
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Order>().ToTable("orders");
b.Entity<Order>().Property(o => o.CustomerId).IsRequired();
}Here are the everyday EF Core calls, and where each one went in Spring Data JPA. The quick reference lists the rest of the EF Core vocabulary:
// db is your DbContext; db.Orders is a DbSet<Order>
var order = await db.Orders
.Include(o => o.Lines)
.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == id);
var big = await db.Orders
.FromSqlRaw(
"select * from orders where total > {0}", 100)
.ToListAsync();
db.Orders.Add(newOrder);
await db.SaveChangesAsync();// in the repository interface
@EntityGraph(attributePaths = "lines") // Include()
Optional<Order> findWithLinesById(Long id);
@Query(value = "select * from orders where total > :min",
nativeQuery = true) // FromSqlRaw
List<Order> findBig(@Param("min") BigDecimal min);
// in a service
@Transactional(readOnly = true) // like AsNoTracking()
Order load(Long id) {
return orders.findWithLinesById(id).orElseThrow();
}
orders.save(newOrder); // written by the commit at the latest@EntityGraph(attributePaths = "lines")Loads the order's lines in the same query, like
Include(). Without it, the lines load later, one query at a time.Optional<Order> findWithLinesById(Long id);A method in a repository interface. Spring Data reads the name and writes the query for you; the repositories section explains how.
nativeQuery = true)Plain SQL for your database, like
FromSqlRaw.:minis a named parameter, filled from the argument marked@Param("min").@Transactional(readOnly = true)Runs the method in a read-only transaction. Hibernate then skips watching the loaded objects for changes, which is the nearest thing to
AsNoTracking().orders.save(newOrder);Adds the order, like
Add(). Hibernate sends the insert when it needs to, at the latest when the transaction commits, the waySaveChangeswrites pending changes.
All of this rests on the entity: the class that maps to a table.
An entity#
An order in the database has an id, a customer, a total, a status, a creation time and a list of lines. Here is the same class in both frameworks:
public class Order
{
public long Id { get; set; }
public string CustomerId { get; set; } = "";
public decimal Total { get; set; }
public OrderStatus Status { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public List<OrderLine> Lines { get; set; } = [];
}import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String customerId;
private BigDecimal total;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private Instant createdAt;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL,
orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
protected Order() { } // JPA requires a no-arg constructor
}@EntityMarks a class that Hibernate maps to a table: an entity. Each object is one row.
@Table(name = "orders")The table's name.
ORDERis a reserved word in SQL, so the table isorders.@GeneratedValue(strategy = GenerationType.IDENTITY)The database generates the id, from an identity or serial column, like EF Core's default for a numeric key.
@Column(nullable = false)Column details, like
[Column]and[Required]. A field without@Columnstill maps to a column, and Boot's default naming turnscustomerIdintocustomer_id.@Enumerated(EnumType.STRING)Stores the enum by its name, such as
PAID. The second gotcha below explains why that matters.@OneToMany(mappedBy = "order", cascade = CascadeType.ALL,One order has many lines.
mappedBynames the field onOrderLinethat holds the foreign key, anorderfield marked@ManyToOne.cascadeandorphanRemovalmake saving or removing the order do the same to its lines.protected Order() { }JPA creates objects through a no-argument constructor and then fills in the fields.
protectedkeeps your own code from calling it. A record cannot work this way, because its fields never change after construction.
Entities cannot be records. JPA needs a no-argument constructor and mutable fields so it can create objects and fill them in, and Hibernate generates subclasses of entities, called proxies, for lazy loading. Records are final and immutable. Use a class for entities and a record for the data transfer object (DTO) you expose, which is better design anyway.
@Enumerated defaults to ORDINAL, which stores the enum's
declaration position as an integer. The entity above avoids that with
EnumType.STRING. With the default, inserting or reordering a constant later makes
every stored row silently mean 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, PAID=1, SHIPPED=2
private OrderStatus status;
@Enumerated(EnumType.STRING) // always do this: stores "NEW", "PAID", ...
private OrderStatus 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 three specific ways. The last involves
Lombok, a library that generates code such as
equals while compiling:
| 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 |
Here is an equals and hashCode for the Order entity
that survives all three:
@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();
}
}if (!(o instanceof Order other)) return false;A proxy is a subclass of
Order, soinstanceofaccepts it, where agetClass()comparison would not.return id != null && id.equals(other.id);Two unsaved orders are never equal, and saved ones are equal when their ids match.
return getClass().hashCode();The same hash for every order, so an order stays findable in a set when it gets its id.
A constant hashCode looks wrong and is deliberate: it keeps the object findable
in a HashSet across the change 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 saved.
Never put @Data or @EqualsAndHashCode from Lombok on a
JPA entity. These two annotations make Lombok generate equals over
every field. That is the most common
cause of unexpected lazy loading, and of StackOverflowError from two entities that
refer to each other.
With the entity in place, the repository is where the queries live.
Repositories#
The billing service needs orders by customer, by customer and status, and above an amount. In EF Core you would write a LINQ query each time. In Spring Data you declare an interface, and Spring writes the class at startup, working out each query from the method's name:
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface OrderRepository extends JpaRepository<Order, Long> {
// where customer_id = ?
List<Order> findByCustomerId(String customerId);
// where customer_id = ? and status = ? order by created_at desc
List<Order> findByCustomerIdAndStatusOrderByCreatedAtDesc(
String customerId, OrderStatus status);
// where total > ?
List<Order> findByTotalGreaterThan(BigDecimal amount);
boolean existsByCustomerId(String customerId);
long countByStatus(OrderStatus status);
// when the name would get silly, write the query
@Query("select o from Order o join fetch o.lines where o.id = :id")
Optional<Order> findWithLines(@Param("id") Long id);
}public interface OrderRepository extends JpaRepository<Order, Long> {An interface with no implementation: Spring Data creates the class at startup.
<Order, Long>gives the entity type and the type of its id.List<Order> findByCustomerId(String customerId);A derived query. Spring Data parses the name:
findBystarts it,CustomerIdnames the field, and the parameter supplies the value.findByCustomerIdAndStatusOrderByCreatedAtDesc(Longer names combine conditions with
AndandOr, and sort withOrderBy. The table below lists the keywords.@Query("select o from Order o join fetch o.lines where o.id = :id")A query written by hand. It names the entity
Orderand its fieldlines, not the tables, andjoin fetchloads the lines in the same query.
That query string is written in the Jakarta Persistence Query Language (JPQL), which reads like SQL but works on entities and their fields. Hibernate accepts a larger language of its own, the Hibernate Query Language (HQL), of which JPQL is a subset. Here are the keywords a derived query name can use:
| 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 need to write a repository class by hand unless
you want one.
Queries that load an entity's collection one row at a time lead to the most common JPA problem in production.
The N+1 problem#
A report lists every order with its number of lines. The obvious loop looks harmless:
// one query for the orders, then one more for each order's lines
for (var order : orders.findAll()) {
order.getLines().size(); // runs a query, every time round the loop
}order.getLines().size();The lines are lazy: they load only when first touched. Touching them here runs one query per order, so 100 orders cost 101 queries, hence the name N+1.
It is worse than in EF Core, because lazy loading is the default for collections, and
there is no Include() to reach for out of habit. Here are the fixes, in order of
preference:
// 1. JOIN FETCH in the query
@Query("select o from Order o join fetch o.lines")
List<Order> findAllWithLines();
// 2. @EntityGraph: declarative, and it works with derived queries
@EntityGraph(attributePaths = "lines")
List<Order> findByStatus(OrderStatus status);
// 3. a projection: fetch only the columns you need
interface OrderSummary { Long getId(); BigDecimal getTotal(); }
List<OrderSummary> findSummariesByStatus(OrderStatus status);@Query("select o from Order o join fetch o.lines")Loads every order with its lines in one query.
@EntityGraph(attributePaths = "lines")Asks for the lines too, on a derived query, without writing JPQL.
List<OrderSummary> findSummariesByStatus(OrderStatus status);Returns only the id and total, through an interface of getters. No entity is loaded, so no lazy collection can fire. The word between
findandByis only a label.
In development, set spring.jpa.properties.hibernate.generate_statistics=true, or
add the datasource-proxy or Hypersistence Utils libraries. An N+1 then shows up as a
count of queries, rather than as slow pages in production.
Lazy loading has a second trap, which appears once the transaction has ended.
Lazy loading and detached entities#
Inside a transaction, Hibernate keeps every entity it loads in a persistence
context, as EF Core's DbContext tracks what it loaded. When the transaction
ends, the persistence context closes, and its entities become detached: a lazy field that
was never loaded can no longer be fetched. The defaults decide which fields are lazy:
| 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 |
Here an order refers to its customer, and a controller reads the customer's name after the service has returned:
@ManyToOne(fetch = FetchType.LAZY) // EAGER by default: override it every time
private Customer customer;
// later, in a controller, after the service's transaction has closed:
order.getCustomer().getName(); // LazyInitializationException@ManyToOne(fetch = FetchType.LAZY)Many orders share one customer.
LAZYloads the customer only when it is first used, instead of with every order.order.getCustomer().getName();The customer was never loaded, and the persistence context that could load it has closed, so Hibernate throws.
LazyInitializationException is the JPA rite of passage. Touching a lazy
association after the transaction has closed throws it, typically in a controller after the
service method has returned. 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. Boot switches it on by default in
web applications, and it papers over the problem by keeping the persistence context open for the
whole request. That hides N+1 queries and holds database connections longer than necessary. Most
experienced teams set it to false and fix the resulting exceptions properly.
Transactions#
Saving an order must succeed or fail as a whole. In EF Core you open a transaction yourself;
in Spring you mark the service method @Transactional:
using var tx = db.Database.BeginTransaction();
db.Orders.Add(order);
await db.SaveChangesAsync();
tx.Commit();@Transactional
public Order place(CreateOrder request) {
var order = new Order(request);
orders.save(order);
// committed when the method returns
return order;
}@TransactionalSpring starts a transaction before the method runs, commits it when the method returns, and rolls it back when the method throws an unchecked exception.
@Transactional works through a proxy around
the bean. Two consequences catch everyone:
- Self-invocation does not
work. Calling
this.other()from inside the same class bypasses the proxy, so@Transactionalonother()has no effect. How Spring actually works explains why, and Transactions in depth covers propagation and isolation. - Only unchecked exceptions roll back by default. A
checked exception commits. Use
@Transactional(rollbackFor = Exception.class)if you throw checked exceptions, or throw an unchecked exception instead.
Also put it on the service, not the repository: the transaction boundary is the use case.
Entities describe tables, but they do not create or change them. Migrations do.
Migrations#
The shop needs a status column on the orders table. In EF Core you change the model and let
the tool generate a migration. With Flyway you write the SQL yourself, in a numbered file.
Flyway runs each file once, in order, and records it in a table called
flyway_schema_history:
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-- src/main/resources/db/migration/V3__add_order_status.sqlThe file's name is the migration:
V3is its version, and the words after the two underscores describe it. Boot looks indb/migrationby default.ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'NEW';Plain SQL for your database. Existing orders get the value
NEW.-- applied automatically at startupWith Flyway on the classpath, Boot runs any new migrations before the application starts serving.
| 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, even to add a comment, and startup fails. This is a feature: it guarantees that every environment ran identical SQL. But it means the fix for a mistake is always a new migration, never an edit.
Migration checksum mismatch for migration version 3#V4. Only when the edit is
harmless, such as a comment, run flyway repair to store the new checksum.What triggers it
-- V3__add_order_status.sql, edited after it had been applied
ALTER TABLE orders
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'NEW';
-- a comment added laterAn existing database with tables but no Flyway history fails too, the first time Flyway runs against it:
Found non-empty schema(s) "public" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.#spring.flyway.baseline-on-migrate=true. Flyway then records the existing
schema as version 1 and applies only the migrations after it.What triggers it
-- the database already has an orders table, and db/migration holds V1 and V2Set spring.jpa.hibernate.ddl-auto=validate in every environment, so that
Hibernate only checks that the mapping agrees with the schema Flyway built. Boot's default is
none for a real database. For an embedded one such as H2, with no Flyway present,
it is create-drop, which builds the schema at startup and drops it at shutdown.
Tutorials often set update, which lets Hibernate alter your schema: convenient in a
demo, catastrophic in production.
When not to use JPA#
JPA suits an object model you load, change and save. Other jobs suit other tools:
| 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 |
For Dapper-style code, Spring's JdbcClient runs your SQL and maps each row to a
record, with no entities and no persistence context:
record OrderRow(long id, String customerId, BigDecimal total) { }
List<OrderRow> big = jdbc.sql("select id, customer_id, total from orders where total > :min")
.param("min", new BigDecimal("100"))
.query(OrderRow.class)
.list();jdbc.sql("select id, customer_id, total from orders where total > :min")jdbcis aJdbcClient, which Boot creates for you. The SQL is yours, with a named parameter..query(OrderRow.class)Maps each row to an
OrderRow. The columncustomer_idfills the componentcustomerId.
Spring Data JDBC sits between the two: repositories as in Spring Data JPA, but no lazy loading and no proxies. An aggregate is loaded and saved whole:
// Spring Data JDBC: no lazy loading, no proxies, no session to close
record Order(@Id Long id, String customerId, BigDecimal total) { }
interface OrderRepository extends ListCrudRepository<Order, Long> {
List<Order> findByCustomerId(String customerId);
}record Order(@Id Long id, String customerId, BigDecimal total) { }Unlike JPA, Spring Data JDBC can map a record, because it never needs to subclass or fill in an object after creating it.
jOOQ deserves a look if you miss LINQ-to-SQL. It generates a typed
domain-specific language (DSL) from your real database schema,
so DSL.selectFrom(ORDERS).where(ORDERS.TOTAL.gt(x)) is checked when you compile, 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.
The Order entity is a mutable class with a no-argument constructor, mapped with
@Entity and @Id; records are for the DTOs you return.
OrderRepository is an interface, and Spring Data writes each query from the
method's name, such as findByCustomerId, or from a JPQL @Query.
Collections load lazily, so looping over orders and touching their lines runs one query per
order; join fetch or @EntityGraph loads them in one.
Flyway owns the schema: numbered SQL files, each applied once at startup, and an edited file stops the application.
Quick reference#
| 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 | JdbcClient, JdbcTemplate, JDBI | |
| LINQ to SQL | jOOQ | generates typed Java from your real schema, so renames break the build |
| Setting | Use it to |
|---|---|
| spring.jpa.hibernate.ddl-auto=validate | check the mapping against the schema Flyway built |
| spring.jpa.open-in-view=false | close the persistence context when the service returns |
| spring.flyway.baseline-on-migrate=true | adopt a database that already has tables |
| spring.jpa.properties.hibernate.generate_statistics=true | count queries while developing |
Transactions, propagation and isolation#
@Transactional looks like a checkbox, but it is really a policy: seven
propagation modes, four isolation levels, and a rollback rule that surprises people. Getting it
wrong produces the worst kind of bug: data that is quietly, occasionally wrong.
Hold on to three facts. Only
unchecked exceptions roll back by
default. Self-invocation bypasses
it entirely. And REQUIRES_NEW is a separate transaction,
which can wait forever on its own caller's locks.
The default#
Placing an order writes the order and its lines. If anything fails halfway, none of it may
stay. In EF Core you open a transaction, commit it, and roll it back in a catch
block. In Spring, one annotation does all three:
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 request) {
var order = new Order(request);
orders.save(order);
return order;
}
// commit on normal return,
// rollback on an unchecked exception@TransactionalSpring starts a transaction before the method runs, commits it when the method returns, and rolls it back when an unchecked exception escapes. It does this through a proxy around the service.
orders.save(order);Every database call made while the method runs joins the same transaction, even calls inside other beans.
Put it on the service, not the repository. The transaction boundary is the use case: everything inside one business operation should commit or fail together.
The phrase “rolls back on an unchecked exception” hides the most surprising rule in the chapter, about checked exceptions.
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.
Here a transfer takes money from one account, then finds the other account closed and throws a checked exception:
@Transactional
public void transfer(Account from, Account to, BigDecimal amount)
throws AccountClosed {
from.debit(amount);
if (to.isClosed()) throw new AccountClosed(); // the debit COMMITS
}
@Transactional(rollbackFor = Exception.class) // the fix
public void transfer(Account from, Account to, BigDecimal amount)
throws AccountClosed { ... }if (to.isClosed()) throw new AccountClosed();AccountClosedextendsException, notRuntimeException, so it is checked. Spring commits as it passes: the money leaves one account and arrives nowhere.@Transactional(rollbackFor = Exception.class)Rolls back for every exception, checked or not.
C# has no checked exceptions, so a .NET developer has no instinct for this at all. If your
code throws checked exceptions from service methods, set rollbackFor, or convert
them to unchecked ones at the boundary. The quick reference lists the other settings.
readOnly = true is more than a hint on PostgreSQL. Spring marks the connection
read-only, so a write inside such a method fails with “cannot execute UPDATE in a read-only
transaction”. With Hibernate it also skips checking loaded
entities for changes.
Rolling back is one policy. The other is what happens when one transactional method calls another.
Propagation#
Propagation answers one question: what should happen if a transaction is already running when this method is called? Think of a meeting already in progress. You can join it, book a separate room, or refuse to start without one:
| 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 |
When a payment fails, the order must disappear, but the audit trail must still show that someone tried. So the audit service runs in a transaction of its own:
@Transactional // REQUIRED
public void placeOrder(CreateOrder request) {
orders.save(new Order(request));
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) {
auditRows.save(new AuditRow(what));
}
}@Transactional // REQUIREDREQUIRED, the default:placeOrderstarts a transaction, or joins one that is already running.audit.record("order placed");A call to another bean, so it goes through that bean's proxy, which sees
REQUIRES_NEW.throw new PaymentFailed();PaymentFailedis unchecked, so the order's transaction rolls back.@Transactional(propagation = Propagation.REQUIRES_NEW)Suspends the caller's transaction and runs
recordin a new one, which commits whenrecordreturns. The audit row stays, although the order is rolled back.
REQUIRES_NEW can deadlock against its own caller. The outer
transaction is suspended but still holds its locks. If the inner transaction writes a row the
outer one has already written, it waits for a lock that the outer transaction will not release
until the inner one returns.
The database cannot see this as a deadlock, because the outer transaction is simply idle. So
the thread waits until a lock timeout fires, if you have set one; on PostgreSQL the error is
“canceling statement due to lock timeout”. Without one it waits forever. Use
REQUIRES_NEW for independent writes, such as audit trails, outbox rows and failure
logs, and never for the same rows the caller is changing.
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 run dry at a load the arithmetic did not predict. With a pool of one, the
inner call cannot get a connection at all:
HikariPool-1 - Connection is not available, request timed out after 1006ms (total=1, active=1, idle=0, waiting=0)#REQUIRES_NEW transaction waited for one until the pool's timeout.REQUIRES_NEW call from the hot path.What triggers it
// maximumPoolSize = 1
@Transactional
public void placeOrder(CreateOrder request) {
audit.record("order placed"); // REQUIRES_NEW needs a second connection
}NESTED is not a second transaction: it is a savepoint inside the existing one.
Rolling it back undoes only the inner work, but a rollback of the outer transaction still
discards everything. It works with Spring's Java Database
Connectivity (JDBC) transaction manager. The
Jakarta Persistence (JPA) transaction manager allows it only once
you switch on its nestedTransactionAllowed flag. Even then, the savepoint does not
undo changes to entities already in memory.
How much one transaction sees of another's work is the last policy, and the deepest.
Isolation#
Two customers buy the last item at the same moment. Whether each transaction sees the other's writes is set by its isolation level. The levels have the same names as in .NET:
| 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 | depends on the database | Unspecified |
Each level is defined by the anomalies it prevents:
| 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, and 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 field on the
entity makes Hibernate
check that the row has not changed since you read it. It scales far better than
SERIALIZABLE, and makes the conflict explicit.
Here the order carries a version number:
@Entity
public class Order {
@Id private Long id;
@Version private int version; // Hibernate manages this
}@Version private int version;Hibernate adds
where version = ?to every update and increases the number. If another transaction changed the row first, no row matches, and Hibernate throws anOptimisticLockException, which Spring passes on as anOptimisticLockingFailureExceptionor a subclass of it.
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 method is
final. How Spring actually works explains the
mechanism.
This is the shape it usually takes: a batch method calling the transactional method next to it.
@Service
public class OrderService {
public void placeAll(List<CreateOrder> requests) {
requests.forEach(this::place); // no transaction, every time
}
@Transactional
public void place(CreateOrder request) { ... }
}requests.forEach(this::place);this::placecalls the method on the object itself, not on the proxy, so no transaction starts.
Doing something after the commit#
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. The fix is to send it after the commit:
@Transactional
public void place(CreateOrder request) {
var order = orders.save(new Order(request));
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);
}
}events.publishEvent(new OrderPlaced(order.getId()));Publishes an application event.
eventsis Spring'sApplicationEventPublisher, injected like any other bean.@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)Holds the event until the publishing transaction commits, then calls the method. If the transaction rolls back, the method never runs.
An event published when no transaction is running is dropped: the listener does not run at
all. Set fallbackExecution = true on the annotation if the same event can come from
code outside a transaction.
The phase decides when the listener runs:
| 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, because the process can die between the commit and the 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. It is the same solution as in .NET.
Checklist#
When a transaction misbehaves, the symptom usually points at one of these causes:
| 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 holding one for the whole request |
| 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 |
These are the three fixes you will reach for most often:
@Transactional(rollbackFor = Exception.class) // checked exceptions roll back too
public void transfer(Account from, Account to, BigDecimal amount) throws AccountClosed { ... }
@Version private int version; // a lost update becomes an exception
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void on(OrderPlaced e) { messaging.send(e); } // sent only once the data is committed@Transactional(rollbackFor = Exception.class)For the transfer that committed half its work.
@Version private int version;For two users saving the same order.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)For the message about an order that was rolled back.
@Transactional on place commits when it returns and rolls back on an
unchecked exception, but a checked exception such as AccountClosed commits unless
you add rollbackFor.
The audit row survived the order's rollback because AuditService.record ran in
its own REQUIRES_NEW transaction, on a second connection.
A call from placeAll to place in the same class skips the proxy, so
no transaction starts.
An inner REQUIRES_NEW that writes a row its caller has already written waits for a
lock that is never released.
Quick reference#
| Setting | Effect |
|---|---|
| rollbackFor = Exception.class | roll back for checked exceptions too |
| noRollbackFor = NotFound.class | commit even though this was thrown |
| readOnly = true | on PostgreSQL, writes fail; with Hibernate, no checking for changes |
| timeout = 5 | seconds before a running statement is cancelled and the transaction rolled back |
| Spring | .NET |
|---|---|
| @Transactional | BeginTransaction, SaveChanges and Commit around the method |
| Propagation.REQUIRES_NEW | TransactionScopeOption.RequiresNew |
| Propagation.NESTED | a savepoint, as with CreateSavepoint in EF Core |
| Isolation.READ_COMMITTED | IsolationLevel.ReadCommitted |
| @TransactionalEventListener | work done after SaveChanges and Commit succeed |
Security#
Spring Security is a chain of servlet filters that every request passes through before it reaches your controllers. It does the work of ASP.NET Core's authentication and authorisation, and it is more powerful but less approachable. The defaults lock everything down, and the configuration style takes some getting used to.
The mental model: the filters work out who is calling, and store the answer as an
Authentication in the SecurityContext. Then authorisation rules decide
what that caller may do.
Concept mapping#
The shop's API has two kinds of caller: customers, who read their own orders, and staff with the admin role, who can delete them. Callers prove who they are with a token from an identity provider, using OAuth 2.0 and OpenID Connect (OIDC), the same standards you use in .NET. The token is a JSON Web Token (JWT): a signed piece of JSON that names the user and lists what they may do.
In ASP.NET Core the signed-in user is a ClaimsPrincipal. In Spring Security it is
an Authentication, and its authorities play the part of claims:
// ClaimsPrincipal: the signed-in user and their claims
string? name = User.Identity?.Name;
bool admin = User.IsInRole("Admin");
builder.Services.AddAuthorization(o =>
o.AddPolicy("ReadsOrders",
p => p.RequireClaim("scope", "orders")));// Authentication: the signed-in user and their authorities
Authentication auth =
SecurityContextHolder.getContext().getAuthentication();
String name = auth.getName();
boolean admin = auth.getAuthorities()
.contains(new SimpleGrantedAuthority("ROLE_ADMIN"));
@PreAuthorize("hasAuthority('SCOPE_orders')") // a policy
public Order get(long id) { ... }SecurityContextHolder.getContext().getAuthentication();Where Spring Security keeps the caller of the request being handled.
auth.getName();The caller's name; for a JWT, its subject.
.contains(new SimpleGrantedAuthority("ROLE_ADMIN"));An authority is a plain string, such as
ROLE_ADMIN. A role is simply an authority whose name starts withROLE_.@PreAuthorize("hasAuthority('SCOPE_orders')")A rule written as an expression on the method, where ASP.NET Core would use a named policy. A JWT's scopes become authorities with the prefix
SCOPE_, so theordersscope isSCOPE_orders.
The quick reference maps every other ASP.NET Core security type to its Spring Security counterpart. All of this is set up in one place, the filter chain.
The filter chain#
In ASP.NET Core you add authentication and authorisation services, then middleware. In Spring
Security you declare one bean of type
SecurityFilterChain. You configure it through a domain-specific language (DSL): a
chain of method calls on HttpSecurity, each taking a
lambda that sets up one part. Here the shop's API accepts JWTs and keeps admin paths
for admins:
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(
SessionCreationPolicy.STATELESS))
.build();
}
}.authorizeHttpRequests(auth -> authThe URL rules, like
AddAuthorizationandRequireAuthorizationtogether. Rules are checked in order, and the first one that matches decides..requestMatchers("/actuator/health").permitAll()Anyone may call the health check, like
[AllowAnonymous]..hasRole("ADMIN")Paths under
/api/adminneed the authorityROLE_ADMIN..anyRequest().authenticated())Every other request needs a signed-in caller of any kind.
o.jwt(Customizer.withDefaults()))Accepts JWT bearer tokens, like
AddJwtBearer.withDefaults()takes its settings fromapplication.yml, shown below..csrf(csrf -> csrf.disable())Switches off protection against cross-site request forgery (CSRF). That is safe here only because the API uses tokens, not cookies; the second gotcha below explains.
SessionCreationPolicy.STATELESS))Never creates an HTTP session: every request carries its own token.
One setting tells Spring where the tokens come from. Spring then fetches the provider's signing keys, and checks each token's signature, issuer and expiry:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://login.acme.example/realms/shopissuer-uri: https://login.acme.example/realms/shopThe identity provider's address, like
Authorityin the JwtBearer options.
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. The attack it stops is another site making the user's browser send a request with the user's cookies. If your first POST returns 403 with no useful message, this is why. Disable it only for an API that browsers do not call with cookies, as here; keep it for anything cookie-based.
URL rules suit whole areas of the API. Rules about one method come next.
Method-level authorisation#
When a rule depends on the method or its arguments, such as “only the order's customer may read it”, put it on the method instead:
[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) { ... }@PreAuthorize("hasRole('ADMIN')")Checked before the method runs, like
[Authorize(Roles = "Admin")]. If it fails, Spring refuses the call, with 403 Forbidden for a signed-in caller.@PreAuthorize("@orderGuard.owns(#id, authentication)")The rule calls a bean.
@orderGuardis the bean namedorderGuard,#idis the method'sidparameter, andauthenticationis the caller. This is the Spring form of a custom policy.
The guard is an ordinary bean, so it can use the repository:
@Component
class OrderGuard {
private final OrderRepository orders;
OrderGuard(OrderRepository orders) {
this.orders = orders;
}
boolean owns(long orderId, Authentication caller) {
return orders.findById(orderId)
.map(order -> order.getCustomerId().equals(caller.getName()))
.orElse(false);
}
}class OrderGuard {Its bean name,
orderGuard, comes from the class name with a lower-case first letter..map(order -> order.getCustomerId().equals(caller.getName()))The caller owns the order when the order's customer id is the caller's name, the token's subject.
Method annotations do nothing until one configuration class switches them on:
@Configuration
@EnableMethodSecurity // required, method annotations do nothing without it
public class MethodSecurityConfig { }@EnableMethodSecurityMakes Spring wrap beans that carry
@PreAuthorizein proxies that check the rule. Without it, the annotations are silently ignored.
These are the expressions you will use most:
| Expression | Means |
|---|---|
| hasRole('ADMIN') | requires the authority ROLE_ADMIN; the prefix is added for you |
| hasAuthority('orders:write') | requires exactly that authority string, with no prefix added |
| hasAnyRole('A','B') | requires at least one of the roles: ROLE_A or ROLE_B |
| isAuthenticated() | any signed-in user; anonymous requests are refused |
| permitAll() / denyAll() | always allowed, or always refused, whoever is calling |
| #id | refers to the method parameter named id |
| authentication | the current user's Authentication object |
| @beanName.method(...) | call a bean, arbitrary policy logic |
hasRole('ADMIN') checks for the authority
ROLE_ADMIN. Spring adds the prefix for you in hasRole but not
in hasAuthority, and mixing them up produces rules that silently never match. A JWT
adds a second trap: by default only its scope claim becomes authorities, as
SCOPE_…. A "roles": ["ADMIN"] claim is ignored until you map it.
This bean tells Spring to read the roles claim, and to give each role the
ROLE_ prefix so that hasRole('ADMIN') matches:
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles");
roles.setAuthorityPrefix("ROLE_");
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(roles);
return converter;
}roles.setAuthoritiesClaimName("roles");Reads authorities from the
rolesclaim instead ofscope. It replaces the default, soSCOPE_authorities are no longer produced.roles.setAuthorityPrefix("ROLE_");Turns
ADMINintoROLE_ADMIN, the formhasRolechecks.
@PreAuthorize is proxy-based, exactly like @Transactional. A call
from inside the same class bypasses it entirely, so a security check that relies on
self-invocation is no check at all.
How Spring actually works explains the mechanism.
The deeper sections cover reading the current user, storing passwords, and Spring's null-safety annotations.
The current user#
An endpoint that returns “my orders” needs the caller's id. In ASP.NET Core you read
a claim from User. In Spring, a controller parameter receives the token:
public IActionResult Get()
{
var id = User.FindFirst("sub")?.Value;
...
}@GetMapping
public OrderDto get(@AuthenticationPrincipal Jwt jwt) {
String id = jwt.getSubject();
...
}
// or anywhere, without passing it along
var auth = SecurityContextHolder.getContext()
.getAuthentication();@AuthenticationPrincipal Jwt jwtSpring passes in the caller's validated token.
getSubject()reads itssubclaim, the user's id.var auth = SecurityContextHolder.getContext()Works in any bean, but hides the dependency. Prefer the parameter in controllers.
SecurityContextHolder keeps the caller in a ThreadLocal, a value that
belongs to one thread. So it does not follow work to a thread you start yourself, including
tasks you submit to an ExecutorService. Wrap the
executor in a DelegatingSecurityContextExecutor to carry the caller across whenever
you fan work out.
Passwords#
If the shop stores its own users' passwords, it must hash them. One bean chooses the algorithm:
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}PasswordEncoderFactories.createDelegatingPasswordEncoder();Hashes new passwords with bcrypt, and can still check hashes made with older algorithms.
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 moved its whole API from the Java Specification Request (JSR) 305 annotations to JSpecify Boot 4. If you use a null checker, Spring's own signatures now say exactly where null is possible, generics included. That makes the checker much more useful against Spring than it was on Boot 3. See Nullability.
Here a configuration lookup may return null, and the checker says so. env is
Spring's Environment, injected like any bean:
String url = env.getProperty("rates.url"); // declared @Nullable in Spring Framework 7
int length = url.length(); // a null checker such as NullAway flags this lineString url = env.getProperty("rates.url");getPropertyreturns null when the setting is missing, and Spring 7 declares that with JSpecify's@Nullable.int length = url.length();Using
urlwithout a null check could throw, so a checker such as NullAway reports this line when you build.
One SecurityFilterChain bean holds the URL rules, checked in order with
anyRequest() last, and accepts the shop's JSON Web Tokens (JWTs) through
oauth2ResourceServer.
Cross-site request forgery (CSRF) protection is on by default, so a POST without a token gets 403. Switch it off only for a token-based API that browsers do not call with cookies.
@PreAuthorize("hasRole('ADMIN')") on delete needs
@EnableMethodSecurity, checks for ROLE_ADMIN, and is skipped by calls
from the same class.
A JWT's scopes become SCOPE_ authorities; a roles claim counts only
once a JwtAuthenticationConverter maps it.
Quick reference#
| ASP.NET Core | Spring Security | Note |
|---|---|---|
| AddAuthentication | SecurityFilterChain bean | one bean declares how requests are authenticated |
| AddAuthorization | authorizeHttpRequests(...) | the URL rules, matched in order inside the same bean |
| [Authorize] | @PreAuthorize, or a chain rule | @PreAuthorize on methods, or a URL rule in the chain |
| [Authorize(Roles = "Admin")] | @PreAuthorize("hasRole('ADMIN')") | hasRole adds the ROLE_ prefix for you |
| [AllowAnonymous] | permitAll() in the chain | usually a path rule in the chain, not an annotation |
| ClaimsPrincipal | Authentication / Principal | the signed-in user; its authorities play the part of claims |
| User.Identity.Name | authentication.getName() | the principal's name, such as the JWT subject |
| Policy-based authorisation | SpEL in @PreAuthorize, or an AuthorizationManager | write the rule as an expression, or as a small bean |
| IdentityUser | UserDetails | the user record Spring Security loads to check a password |
| UserManager | UserDetailsService | loads users by name; account management is yours to build |
| JwtBearer | oauth2ResourceServer().jwt() | validates bearer JWTs; set the issuer in application.yml |
| Cookie authentication | formLogin() + session | form login backed by an HTTP session |
| Data protection | no direct equivalent | no built-in key ring; use a KMS or Spring Vault |
| Antiforgery token | CSRF protection, on by default | on by default: a POST without the token gets a 403 |
HTTP clients and resilience#
RestClient is HttpClient with a fluent API. Declarative interface
clients, marked @HttpExchange, are Refit.
The Polly answer changes between Boot versions. Boot 3 uses the Resilience4j
library. Boot 4 adds @Retryable and @ConcurrencyLimit to Spring
Framework itself, which covers the two cases most services need; for a circuit breaker you still
add Resilience4j.
The client options#
The billing service asks a rates service for today's exchange rate, over HTTP. Spring offers several clients for the job:
| 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 |
The last row is the client built into the Java Development Kit (JDK). Here is the everyday call, in both frameworks:
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);.uri("/rates/{pair}", pair)A URI template:
{pair}is filled in, and escaped, from the next argument. The base address was set when theRestClientwas built..retrieve()Sends the request. A 4xx or 5xx response becomes an exception, like
EnsureSuccessStatusCode..body(Rate.class);Reads the JSON body into a
Rate, here a record,record Rate(String pair, double value).
That call blocks, and on a virtual thread that is
exactly right. You no longer need WebClient to scale:
reactive types are for streaming and backpressure, not for
avoiding thread starvation. See Threads are cheap now.
Writing the URI and the body type at every call gets repetitive. Refit's answer is an interface, and Spring has the same idea.
Declarative clients#
You describe the rates service as an interface, one method per endpoint, and Spring writes the class that makes the calls:
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 request);
}@GetExchange("/rates/{pair}")This method sends a GET to that path, like Refit's
[Get]. The same@PathVariableand@RequestBodyannotations as in a controller say where each argument goes.Quote createQuote(@RequestBody QuoteRequest request);Sends the
QuoteRequestas the JSON body, and reads the reply into aQuote.
Registering it is where the versions differ. Boot 4 added
auto-configuration for
@HttpExchange interfaces; on Boot 3 you build the client yourself:
// Boot 3: 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: declare the group; Spring registers the client bean
@Configuration
@ImportHttpServices(group = "rates", types = RatesApi.class)
class ClientConfig { }
// configured in application.yml under the group name,
// then injected like any other beanreturn factory.createClient(RatesApi.class);On Boot 3,
HttpServiceProxyFactorycreates a proxy: an object that implementsRatesApiby turning each method call into an HTTP request through theRestClient.@ImportHttpServices(group = "rates", types = RatesApi.class)On Boot 4, one annotation does the same, and puts the interface in a group called
rateswhose settings live inapplication.yml.
On Boot 4, the group's settings sit under its name:
spring:
http:
serviceclient:
rates:
base-url: https://rates.example.combase-url: https://rates.example.comThe address every
RatesApicall starts from, likeBaseAddressin .NET.
A remote service fails sometimes, and a client should cope. That is Polly's job in .NET.
Resilience: the Polly answer#
The rates service is occasionally unavailable for a moment, and a second try usually works.
This is the clearest Boot 3 versus Boot 4 difference in day-to-day code. Spring Framework 7 added
retry support to its core, so on Boot 4 @Retryable needs no separate library.
C# refresherPolly: retry, circuit breaker and timeout as a pipeline
Polly composes resilience strategies into one pipeline and runs your call through it.
var pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 })
.AddCircuitBreaker(new CircuitBreakerStrategyOptions())
.AddTimeout(TimeSpan.FromSeconds(5))
.Build();
var rate = await pipeline.ExecuteAsync(async ct => await FetchAsync(pair, ct));Here is the same rates client with a retry, on each Boot line:
// Boot 3: 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: @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);
}
}@Retry(name = "rates", fallbackMethod = "cached")Boot 3, with Resilience4j: the settings for the
ratespolicy live inapplication.yml, andcachedis called once every attempt has failed.@Retryable(includes = RatesUnavailableException.class,Boot 4, in core: retry only when this exception is thrown, at most 4 more times. The first wait is 200 ms and each later wait doubles, give or take 20 ms, but never exceeds 2 seconds.
@ConcurrencyLimit(10)At most 10 calls run at once; the rest wait. This plays the part of a bulkhead, and matters with virtual threads, which have no pool to limit them.
Here is where each Polly strategy lands on each line:
| 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 | @Retryable(timeout = ...) caps the whole retry; set a request timeout for each call |
| Fallback | fallbackMethod | not in core; catch the exception once the retries are spent |
| 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 idea of the count is the bad one.
Core covers retry and concurrency limiting only. It has no circuit breaker
and no rate limiter, so Resilience4j remains the answer for those on both versions.
@Retryable's timeout limits the whole sequence of attempts, not one
call. What the core additions buy is one less dependency for the two cases most services
need.
@Retryable also works on reactive methods, adding a retry to the returned
Mono or Flux. Every failed attempt publishes a
MethodRetryEvent, which you can listen for to log retries.
@Retryable, like @Transactional and @PreAuthorize, works
through a proxy; see How Spring actually works. So
self-invocation does not retry. And be careful
retrying operations that are not idempotent: a POST that timed out may well have succeeded.
A retry helps only if a failing call ends. Without a timeout, a call may never end at all.
Timeouts#
Set timeouts explicitly. The defaults are effectively infinite. A hung
downstream service will otherwise hold a connection until the socket dies. This is true of
RestClient, RestTemplate and the JDK client alike.
For one client, set the timeouts on its request factory:
@Bean
RestClient ratesClient(RestClient.Builder builder,
@Value("${rates.url}") String url) {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(2));
factory.setReadTimeout(Duration.ofSeconds(5));
return builder.baseUrl(url).requestFactory(factory).build();
}factory.setConnectTimeout(Duration.ofSeconds(2));Give up if the connection is not open within 2 seconds.
factory.setReadTimeout(Duration.ofSeconds(5));Give up if the reply does not arrive within 5 seconds. The call then fails with a
ResourceAccessException, caused by aSocketTimeoutException.
On Boot 4, two settings apply the same limits to every client Boot builds:
spring:
http:
clients:
connect-timeout: 2s
read-timeout: 5sread-timeout: 5sApplies to whichever HTTP library Boot finds, trying Apache HttpClient, then Jetty, then Reactor Netty, then the JDK's own clients.
Error handling#
By default, a 4xx or 5xx response becomes a Spring exception. To turn them into your own exceptions instead, handle each status family:
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);.onStatus(HttpStatusCode::is4xxClientError,For any 4xx reply, run this handler instead of the default. Here it throws the shop's own
UnknownPairException.(req, res) -> { throw new RatesUnavailableException(); })For any 5xx reply, throw the exception that
@Retryableabove retries on.
Without any handler, these are the defaults:
| Situation | RestClient default |
|---|---|
| 4xx | throws HttpClientErrorException |
| 5xx | throws HttpServerErrorException |
| Want the status, not an exception | use .exchange(...) instead of .retrieve() |
Each exception has a subclass per status, such as HttpClientErrorException.NotFound
for a 404, so you can catch exactly the case you expect.
RestTemplate#
LegacyRestTemplate
The original Spring HTTP client, and still in most existing codebases. It is in maintenance
mode: not deprecated, but getting 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);restTemplate.getForObject(One method per verb and result: a GET, with the body read into a
Rate. The URI template works as inRestClient.
Migration is mostly mechanical, and a RestClient can be built from an existing
RestTemplate's configuration with
RestClient.create(restTemplate).
RestClient fetched a Rate with
get().uri(...).retrieve().body(Rate.class), and on a virtual thread a blocking call
like that is exactly right.
The RatesApi interface became a working client through
@GetExchange and @PostExchange, built by hand on Boot 3 and registered by
@ImportHttpServices on Boot 4.
On Boot 4, @Retryable with maxRetries = 4 makes up to five calls and
@ConcurrencyLimit caps how many run at once; for a circuit breaker, keep
Resilience4j.
Set connect and read timeouts yourself, because the defaults wait forever.
Quick reference#
| .NET | Spring |
|---|---|
| httpClientFactory.CreateClient("rates") | a RestClient bean, built from RestClient.Builder |
| GetFromJsonAsync<Rate>(url) | get().uri(url).retrieve().body(Rate.class) |
| Refit interface | an @HttpExchange interface |
| ConfigureHttpClient(c => c.BaseAddress = ...) | spring.http.serviceclient.<group>.base-url |
| Setting | Use it to |
|---|---|
| spring.http.clients.connect-timeout | limit the wait for a connection, for every client |
| spring.http.clients.read-timeout | limit the wait for a reply, for every client |
| spring.http.serviceclient.<group>.base-url | set the address for an @ImportHttpServices group |
Messaging and WebSockets#
Spring's messaging support maps closely to what you know. Listener
annotations, such as @KafkaListener and
@RabbitListener, are the consumer side. 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, the Simple Text Oriented Messaging Protocol (STOMP) on top of it, and server-sent events. SignalR's automatic transport fallback and typed hub clients have no counterpart.
Kafka#
When an order is placed, the billing service publishes an OrderPlaced event, and
the shipping service consumes it. With Kafka, the producer sends to a topic and the consumer
listens on it:
// 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);
}kafkaTemplate.send("orders", order.id(), order);Sends the event to the
orderstopic, keyed by the order's id. Events with the same key go to the same partition, so one order's events stay in order.@KafkaListener(topics = "orders",Spring calls this method for each record on the topic, Kafka's word for a message, and turns the JSON into an
OrderPlaced. There is no loop to write.groupId = "billing")The consumer group. Each record goes to one listener in the group, as in any Kafka client.
Boot builds the template and the listeners from settings like these:
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: allauto-offset-reset: earliestA new consumer group starts from the oldest record still on the topic, rather than only new ones.
spring.json.trusted.packages: "com.acme.billing.events"The JSON reader only creates classes from this package, so a message cannot make it build an arbitrary type.
acks: allA send counts as done only when every in-sync replica has the record.
The listener method commits the offset by returning normally. If it
throws, Spring's default error handler redelivers the record up to nine more times, with no
pause. Then it logs the record at ERROR level and moves on. So an unhandled
exception drops the message, leaving only a log line. Configure a
DefaultErrorHandler with a DeadLetterPublishingRecoverer so failures
land in a dead-letter topic rather than disappearing.
Ordering, retries and the outbox#
These settings come up once a Kafka consumer meets production traffic:
| 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 |
Here is the dead-letter setup the gotcha above recommends:
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
var toDeadLetter = new DeadLetterPublishingRecoverer(template); // failures go to orders-dlt
return new DefaultErrorHandler(toDeadLetter, new FixedBackOff(1000L, 3)); // 3 retries, 1 s apart
}var toDeadLetter = new DeadLetterPublishingRecoverer(template);Once the retries are spent, publishes the failed record to a topic named after the original with
-dltadded, on the same partition. The dead-letter topic therefore needs at least as many partitions as the original.new FixedBackOff(1000L, 3)Three retries after the first delivery, one second apart: four deliveries in all.
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. It is the same problem, and
the same solution, as in .NET; Transactions in depth covers
it.
RabbitMQ#
RabbitMQ speaks the Advanced Message Queuing Protocol (AMQP), and Spring AMQP is its client. The producer publishes to an exchange with a routing key; the consumer listens on a queue:
_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) { ... }rabbitTemplate.convertAndSend(Converts the event to a message and publishes it to the
orders.exchangeexchange with the routing keyorder.placed. MassTransit chooses the exchange for you; here you name it.@RabbitListener(queues = "orders.queue")Spring calls this method for each message on the queue, like
IConsumer.
The rest is configuration you declare yourself:
| Concept | Spring AMQP |
|---|---|
| Declare topology | @Bean Queue / TopicExchange / Binding |
| Serialisation | JacksonJsonMessageConverter, registered as a bean; Jackson2JsonMessageConverter on Boot 3 |
| 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.
JMS and JmsClient#
Java also has a standard messaging API, Jakarta Messaging (JMS), used with brokers such as
ActiveMQ Artemis and IBM MQ. Spring has long wrapped it in JmsTemplate, and Spring
Framework 7 adds a fluent JmsClient on top Boot 4:
jmsClient.destination("orders")
.send(new OrderPlaced(order.getId()));
Optional<OrderPlaced> next = jmsClient.destination("orders")
.withReceiveTimeout(1000)
.receive(OrderPlaced.class);jmsClient.destination("orders")Picks the queue or topic, then describes the operation on it.
.receive(OrderPlaced.class);Waits up to the timeout for one message, and returns it as an
Optional, empty if none arrived. For a steady stream, a method marked@JmsListenerworks like@KafkaListener.
WebSockets and the SignalR gap#
The shop's web page shows live exchange rates, pushed from the server as they change. In ASP.NET Core that is a SignalR hub:
C# refresherSignalR hubs: server push to groups of clients
public class PricesHub : Hub
{
public Task Subscribe(string pair) =>
Groups.AddToGroupAsync(Context.ConnectionId, pair);
}
app.MapHub<PricesHub>("/prices");
await hub.Clients.Group("GBPUSD").SendAsync("price", price); // push to one groupThere is no Java framework that bundles what SignalR does. SignalR gives you a hub abstraction, automatic transport negotiation and fallback, typed clients, reconnection and backplane scale-out in one library. 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 built in; WebSocket, with SockJS as an optional fallback |
| 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 |
The nearest thing to a hub is a STOMP broker over WebSocket. Clients subscribe to named
destinations, such as /topic/prices, and the server sends to them:
@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);registry.addEndpoint("/ws").setAllowedOriginPatterns("*");The address browsers open the WebSocket on, like
MapHub. Allow only your own front end's origin in production, not*.registry.enableSimpleBroker("/topic");A broker inside the application, which delivers anything sent to a
/topic/…destination to every client subscribed to it.@MessageMapping("/subscribe")Handles messages the client sends to
/app/subscribe, like a hub method.pricesis a service that returns the latest price.messagingTemplate.convertAndSend("/topic/prices", price);messagingTemplateis Spring'sSimpMessagingTemplate, injected like any bean. It pushes to every subscriber, likeClients.Group(...).SendAsync.
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 push from server to client, and not both directions, server-sent events
are far less machinery than WebSocket, and pass through most load balancers and firewalls.
ASP.NET Core 10 has them built in, and Spring MVC, Spring's
model-view-controller (MVC) web framework, has SseEmitter:
// ASP.NET Core 10; Prices(ct) is an IAsyncEnumerable<Price>
app.MapGet("/prices", (CancellationToken ct) =>
TypedResults.ServerSentEvents(Prices(ct),
eventType: "price"));@GetMapping(value = "/prices",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream() {
var emitter = new SseEmitter(0L); // no timeout
executor.submit(() -> {
try {
emitter.send(price);
emitter.complete();
} catch (IOException e) {
emitter.completeWithError(e);
}
});
return emitter;
}var emitter = new SseEmitter(0L);An open response that the method returns at once and fills in later.
0Lmeans it never times out.executor.submit(() -> {The sending happens on another thread, from an executor of virtual threads, while the request thread is free.
emitter.send(price);Writes one event,
data:{"pair":"GBPUSD","value":1.27}, followed by a blank line.sendcan throwIOException, hence thetry.emitter.complete();Ends the stream. A real price feed would keep sending instead.
On WebFlux, Spring's reactive web stack, you simply
return 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.
kafkaTemplate.send publishes OrderPlaced keyed by the order's id, and
a @KafkaListener method consumes it; returning normally commits the offset.
A listener that keeps throwing has its record logged and skipped after ten deliveries, unless
a DeadLetterPublishingRecoverer sends it to a dead-letter topic.
There is no SignalR: a STOMP broker over WebSocket plays the hub, and
messagingTemplate.convertAndSend pushes to a destination.
enableSimpleBroker works on one instance only; with more, relay STOMP through
RabbitMQ or ActiveMQ, as a SignalR backplane would.
Quick reference#
| .NET | Spring |
|---|---|
| ProduceAsync (Confluent.Kafka) | kafkaTemplate.send(topic, key, value) |
| Consume loop | a @KafkaListener method |
| IConsumer<T> (MassTransit) | a @RabbitListener method |
| _bus.Publish(event) | rabbitTemplate.convertAndSend(exchange, routingKey, event) |
| Clients.Group(...).SendAsync | messagingTemplate.convertAndSend("/topic/...", payload) |
| TypedResults.ServerSentEvents | SseEmitter, or a Flux on WebFlux |
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 library, in the part of
System.Diagnostics.Metrics. It is a facade, as the
Simple Logging Facade for Java (SLF4J) is for logging.
The tool with no close .NET equivalent is Java Flight Recorder (JFR). It is a production-grade profiler built into the Java Development Kit (JDK), and you can switch it on against a running process.
Actuator endpoints#
The billing service runs in Kubernetes, which must know whether each instance is alive and
ready for traffic, and the operations team wants metrics. One
starter, spring-boot-starter-actuator, adds a
set of HTTP endpoints under /actuator:
| 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 |
Most of them are hidden until you list them. Here the service exposes four, and shows health details to signed-in callers:
management:
endpoints:
web:
exposure:
include: health,info,prometheus,loggers
endpoint:
health:
show-details: when-authorized
probes:
enabled: true # /health/liveness and /health/readiness for Kubernetesinclude: health,info,prometheus,loggersThe endpoints reachable over HTTP. Without this line, only
healthis.show-details: when-authorizedAnonymous callers see only
UPorDOWN; signed-in ones see each check. The default isnever.enabled: trueAdds
/actuator/health/livenessand/actuator/health/readinessfor Kubernetes probes. Boot 4 adds them by default, so this line matters on Boot 3, which adds them on its own only when it detects Kubernetes.
The same endpoints can also be published over Java Management Extensions (JMX), Java's older
built-in management interface, which tools such as JConsole read. JMX is off in Boot unless you
set spring.jmx.enabled=true.
Only health is exposed over HTTP by default: everything else must be opted in. Do
not expose env, heapdump, beans or
configprops publicly; they leak configuration and memory contents. Put Actuator on a
separate port (management.server.port) that is not routed from the internet, or
secure it.
The loggers endpoint is worth knowing about. Here an engineer raises the log
level for the billing code on a running production instance:
curl -X POST localhost:8080/actuator/loggers/com.acme.billing \
-H 'Content-Type: application/json' -d '{"configuredLevel":"DEBUG"}'localhost:8080/actuator/loggers/com.acme.billingThe logger for one package, and everything below it. The service answers 204, and the new level applies at once.
-d '{"configuredLevel":"DEBUG"}'The new level. Post
nulllater to put it back.
You gather what you need and put the level back, with no restart and no redeploy. There is no ASP.NET Core equivalent.
Custom health checks#
The billing service depends on the rates service, so its health should include it. A health check is a bean that reports up or down:
builder.Services.AddHealthChecks()
.AddCheck<RatesHealthCheck>("rates");
public class RatesHealthCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken ct)
=> Task.FromResult(HealthCheckResult.Healthy());
}@Component
public class RatesHealthIndicator implements HealthIndicator {
private final RatesClient ratesClient;
RatesHealthIndicator(RatesClient ratesClient) {
this.ratesClient = ratesClient;
}
@Override
public Health health() {
try {
long ms = ratesClient.ping(); // round trip, in ms
return Health.up()
.withDetail("latencyMs", ms).build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}public class RatesHealthIndicator implements HealthIndicator {Any bean that implements
HealthIndicatorjoins/actuator/health. Its key is the bean name without the suffix:rates..withDetail("latencyMs", ms).build();Extra information, shown to callers allowed to see details, as in
"rates": {"details": {"latencyMs": 12}, "status": "UP"}.return Health.down(e).build();Reports the check as down, with the exception's message as a detail.
On Boot 4, HealthIndicator and Health are in the package
org.springframework.boot.health.contributor; on Boot 3 they were in
org.springframework.boot.actuate.health. Boot registers checks of its own for disk
space, the datasource, Redis, Kafka and most other configured infrastructure, so a great deal of
this is free.
Metrics#
The billing service counts every order it places. In .NET you create a counter from a
Meter. With Micrometer you register a counter with the
MeterRegistry bean:
var counter = meter.CreateCounter<long>("orders.placed");
counter.Add(1,
new KeyValuePair<string, object?>("status", "ok"));private final Counter placed;
OrderService(MeterRegistry registry) {
this.placed = Counter.builder("orders.placed")
.tag("status", "ok")
.register(registry);
}
placed.increment();OrderService(MeterRegistry registry) {Boot supplies the
MeterRegistry, which holds every metric and hands them to whichever backend you use, such as Prometheus..tag("status", "ok")A tag, the name for a .NET metric tag. Each distinct tag value is a separate series.
placed.increment();Adds one each time an order is placed.
Scraped from /actuator/prometheus, the counter looks like this:
orders_placed_total{status="ok"} 1.0orders_placed_total{status="ok"} 1.0Micrometer turns the dots into underscores and adds
_total, as Prometheus expects of a counter.
Micrometer's meter types match .NET's instruments:
| 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 |
For latency, an annotation can time a whole method:
// declarative timing, the equivalent of a filter
@Timed(value = "orders.place", percentiles = {0.5, 0.95, 0.99})
public Order place(CreateOrder cmd) { ... }@Timed(value = "orders.place", percentiles = {0.5, 0.95, 0.99})Measures how long each call takes, as the metric
orders.place, with the median, 95th and 99th percentiles.
@Timed on a service method does nothing by default: no metric appears. Set
management.observations.annotations.enabled=true and add
spring-boot-starter-aspectj, which brings the AspectJ library the annotation needs.
Controllers and repositories are already timed, so annotating them gives you duplicates.
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 break by accident inside a loop.
Logging#
The service logs each order it places. The logger works the way ILogger<T>
does, with one difference in the placeholders:
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);LoggerFactory.getLogger(OrderService.class);One logger per class, kept in a
static finalfield. You code against SLF4J, and Logback, the library Boot uses, does the writing.log.info("Placed order {} for {}", id, total);Each
{}is filled by the next argument, in order:Placed order 42 for 19.99. The placeholders have no names.
C# refresherILogger scopes and appsettings log levels
using (_log.BeginScope(new Dictionary<string, object> { ["OrderId"] = id }))
{
_log.LogInformation("charging"); // every line in the scope carries OrderId
}{ "Logging": { "LogLevel": { "Default": "Information", "Acme.Billing": "Debug" } } }SLF4J placeholders are {} and positional, not named. Use them rather than
concatenation: log.debug("x is " + value) builds the string even when debug is off,
and log.debug("x is {}", value) does not. Either way the arguments themselves are
evaluated, so for a genuinely expensive one, guard with if (log.isDebugEnabled()) or
pass a Supplier.
In a container, logs usually go out as JSON. Boot writes JSON itself, with one setting:
logging:
structured:
format:
console: ecsconsole: ecsWrites each line in Elastic Common Schema format, such as
{"@timestamp":"…","log":{"level":"INFO","logger":"com.acme.billing.OrderService"},"message":"Placed order 42 for 19.99"}.gelfandlogstashare the other formats.
The quick reference maps each .NET logging idea to its Java form. The deeper sections cover Logback's own configuration file, tracing and JFR.
logback-spring.xml and JSON logging#
application.yml covers levels, a console pattern and structured output. Anything
more, such as 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><springProfile name="!prod">Applies only when the
prodprofile is not active: plain console output for developers.<encoder class="net.logstash.logback.encoder.LogstashEncoder">In production, JSON through the Logstash encoder library, including the correlation id from the mapped diagnostic context (MDC).
Serilog's vocabulary maps onto Logback's:
| 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, shown in the logging section, so JSON no longer needs the Logstash encoder dependency. Prefer it on Boot 3.4 or later, and reach for the XML only when you need routing or rotation it does not cover.
Tracing#
Micrometer Tracing plus OpenTelemetry gives you distributed tracing: one trace id that follows a request across services. Boot 4 adds a dedicated starter Boot 4:
<!-- Boot 4 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency><artifactId>spring-boot-starter-opentelemetry</artifactId>Brings Micrometer Tracing with the OpenTelemetry bridge and exporter, and sets them up, so incoming and outgoing HTTP calls carry trace ids.
The alternative, often the better one, is the OpenTelemetry Java agent,
attached with -javaagent:opentelemetry-javaagent.jar. It rewrites the
bytecode of Spring, Java
Database Connectivity (JDBC), HTTP client and Kafka classes as they load, so it instruments
them with no code changes. OpenTelemetry offers a similar zero-code option for .NET, set
up through environment variables before the process starts.
JFR: the tool with no .NET equivalent#
JDK Flight Recorder is an 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.jfrjcmd <pid> JFR.start name=diag settings=profile duration=60s filename=/tmp/rec.jfrjcmdsends a command to a running Java process by its process id. This one starts a 60-second recording with the more detailedprofilesettings.jcmd <pid> JFR.dump name=diag filename=/tmp/now.jfrWrites out what the recording holds so far, without stopping it.
Open the file in JDK Mission Control and you get allocation profiles, lock contention, garbage collection (GC) pauses, I/O, exceptions thrown, and CPU sampling, all on one timeline. Java 25 added CPU-time profiling Java 25 experimental, and events that time and trace chosen methods.
For each profiling job, here is the .NET tool and its Java counterpart:
| 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* |
Only /actuator/health is exposed over HTTP by default; add
prometheus and loggers to
management.endpoints.web.exposure.include, and keep env and
heapdump private.
On Boot 4, /actuator/health/liveness and /readiness are there by
default, ready for Kubernetes probes.
The orders.placed counter, built with Counter.builder and the
MeterRegistry, is scraped as orders_placed_total; never give a metric an
unbounded tag.
Log with SLF4J's positional {} placeholders, and set
logging.structured.format.console to ecs for JSON logs.
Quick reference#
| .NET | Java | Note |
|---|---|---|
| ILogger<T> | SLF4J Logger | you code against SLF4J; Logback does the actual writing |
| Serilog | Logback | Logback is what Spring Boot uses unless you switch |
| NLog | Log4j2 | |
| {Named} placeholders | {} positional placeholders | SLF4J placeholders are positional; argument names are not kept |
| BeginScope | MDC | per-thread key/value pairs that every log line can include |
| appsettings logging levels | logging.level.* in application.yml |
| Setting | Use it to |
|---|---|
| management.endpoints.web.exposure.include | choose the endpoints reachable over HTTP |
| management.endpoint.health.show-details | show each check: never, when-authorized or always |
| management.server.port | serve Actuator on a separate port |
| management.observations.annotations.enabled | make @Timed and @Observed work on any bean |
| logging.structured.format.console | write JSON logs: ecs, gelf or logstash |
Caching, scheduling and events#
Here are three cross-cutting features, meaning features that apply across many classes, and you will meet all of them 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#
The billing service asks the rates service for the same exchange rate hundreds of times a minute, and the rate changes only every few minutes. In ASP.NET Core you check a cache, and on a miss you call the service and store the result. In Spring one annotation does all of that:
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@Cacheable("rates")Before the method runs, Spring looks in the cache named
rates, using the argument,pair, as the key. On a hit it returns the stored value and the method does not run at all; on a miss it runs the method and stores what it returns.
These are the cache annotations, with the IMemoryCache call each one
replaces:
| 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 |
Here they are together, on the billing service's rates:
@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) { ... }
}@EnableCachingSwitches caching on, once, in any configuration class.
key = "#pair + ':' + #date"A key built from both arguments, such as
EURUSD:2026-09-13, written in Spring's expression language, where#pairis the parameter.@CacheEvict(value = "rates", key = "#rate.pair()")Removes the entry for this rate's pair.
Rateis a record, so its pair is read withpair().unless = "#result == null"Runs the method, then stores the result only when it is not null. A missing rate is fetched again next time.
condition = "#pair.length() == 6"Uses the cache only for six-letter pairs, such as
EURUSD. Any other argument skips the cache entirely.
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.
The annotations work with any cache store, which you choose separately:
| 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 | one cache shared by every instance of the service |
| Hazelcast, Infinispan | their starters | a cache spread across a cluster of nodes |
Here the service uses Caffeine, an in-process cache with a size limit and expiry:
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=10000,expireAfterWrite=5mspec: maximumSize=10000,expireAfterWrite=5mAt most 10,000 entries per cache, each dropped five minutes after it was stored, like the
TimeSpan.FromMinutes(5)in the C# version.
The default cache manager is an unbounded ConcurrentHashMap. It never evicts and
never expires, so it is a memory leak wearing a cache costume. Configure Caffeine or Redis before
anything reaches production.
Caching saves work on demand. Scheduling runs work on a timetable.
Scheduling#
Every 30 minutes the billing service reconciles its payments with the bank's records. In
ASP.NET Core that is a BackgroundService with a loop and a delay. In Spring it is one
annotated method:
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() {
...
}
}@Scheduled(fixedDelay = 30, timeUnit = TimeUnit.MINUTES)Runs
reconcileonce at startup, then again 30 minutes after each run finishes, like the loop withTask.Delay. It needs@EnableSchedulingon a configuration class.
Other attributes choose other timetables:
| Attribute | Means |
|---|---|
| fixedDelay | wait this long after the previous run finishes |
| fixedRate | start this often, regardless of how long a run takes |
| initialDelay | how long to wait after startup 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, so "0 0 3 * * *" means 3:00:00 every day. Paste a five-field Unix
crontab line, such as "0 3 * * *", and the application refuses to start: “Cron
expression must consist of 6 fields”. Also, fixedRate never overlaps runs by
default: if a run overruns, the next one waits for it.
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, a library that takes a lock in a shared database or Redis.
With ShedLock, the nightly job runs on whichever instance takes the lock first:
@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "reconcile", lockAtMostFor = "30m")
public void reconcile() { ... }@SchedulerLock(name = "reconcile", lockAtMostFor = "30m")Only the instance holding the lock called
reconcileruns the job. If that instance dies mid-run, the lock expires after 30 minutes.
For anything richer, with retries, persistence and 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 or later
consider virtual threads for jobs that block.
Scheduled jobs and requests often cause side effects, such as sending an email. Events keep those side effects out of the code that causes them.
Application events#
When an order is placed, the shop sends a confirmation email, but OrderService
should not know about email. Spring's in-process publish and subscribe does the job, as MediatR's
notifications do in .NET:
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());
}
}events.publishEvent(new OrderPlaced(order.getId()));eventsis Spring'sApplicationEventPublisher, injected like any bean. The event can be any object; a record is the usual choice.@EventListenerSpring calls this method for every published
OrderPlaced, chosen by the parameter type. There is no interface to implement.
@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 listener that throws fails the
caller with its exception.
Add @Async to run it on another thread, but note that this then escapes the
transaction; see @TransactionalEventListener in
Transactions.
The annotation has variants for when a listener should run:
| 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 |
The deeper section covers running a method on another thread with @Async.
@Async#
Rebuilding the monthly report takes a minute, and the request that triggers it should not
wait. In .NET you would start a task; in Spring you mark the method @Async:
_ = Task.Run(() => _reports.Rebuild());@Async
public void rebuild() { ... } // returns immediately
@Async
public CompletableFuture<Report> build() {
return CompletableFuture.completedFuture(...);
}public void rebuild() { ... }The caller gets control back at once, while the method runs on another thread.
public CompletableFuture<Report> build() {To hand back a result, return a
CompletableFuture, Java'sTask<T>. The caller can wait on it, or chain more work onto it.
@Async is proxy-based like the rest, so a self-invocation runs
synchronously. That is a particularly nasty failure: the code still works, just on the
wrong thread, and it matters only under load.
It also needs @EnableAsync. An @Async method may return only
void or a Future, such as CompletableFuture. One returning a
plain value fails when called, with “Invalid return type for async method”.
On Java 21 or later, point the executor at
virtual threads, and @Async stops needing a tuned pool size:
spring:
threads:
virtual:
enabled: trueenabled: trueBoot then runs
@Asyncmethods and scheduled jobs on virtual threads, instead of its default pool of eight threads for@Asyncand one for scheduling.
@Cacheable("rates") on getRate runs the method once and serves later
calls from the cache, but only after @EnableCaching; without it, the annotation is
silently ignored.
@Scheduled(fixedDelay = 30, timeUnit = TimeUnit.MINUTES) runs
reconcile on a single scheduler thread, and Spring cron strings have six fields,
starting with seconds.
Every instance runs every schedule, so a job that must run once needs a lock such as ShedLock.
publishEvent(new OrderPlaced(...)) calls every @EventListener
synchronously, on the caller's thread, and a listener that throws fails the caller.
Quick reference#
| .NET | Spring |
|---|---|
| _cache.TryGetValue then _cache.Set | @Cacheable on the method |
| BackgroundService with a Task.Delay loop | @Scheduled(fixedDelay = ...) |
| INotification and INotificationHandler | an event record and an @EventListener method |
| _mediator.Publish(event) | events.publishEvent(event) |
| Task.Run(...) | an @Async method |
| Setting | Use it to |
|---|---|
| spring.cache.type | choose the cache provider, such as caffeine or redis |
| spring.task.scheduling.pool.size | run more than one scheduled job at once; the default is 1 |
| spring.threads.virtual.enabled | run @Async methods and scheduled jobs on virtual threads |
Testing Spring Boot#
@SpringBootTest is WebApplicationFactory<T>: it starts the
real application context for an integration
test. Test slices, such as @WebMvcTest and @DataJpaTest,
start only one layer, and have no ASP.NET Core equivalent.
Boot 4 breaks Boot 3 test code in three places. @MockBean is
removed. @SpringBootTest no longer provides MockMvc or
TestRestTemplate. And the slice annotations
moved to one test module per technology.
Test slices#
The billing service has controllers, repositories and plain domain classes. Each deserves a test that starts only what it needs, because starting less makes the test faster. These are the choices:
| 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 |
On Boot 4 each slice comes from its own test module, named after the technology:
@WebMvcTest from spring-boot-webmvc-test, in the
package
org.springframework.boot.webmvc.test.autoconfigure, and @DataJpaTest
from spring-boot-data-jpa-test. On Boot 3 they all came from one module, so the
imports change when you upgrade.
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
running time.
The slice you will write most often is the one for controllers.
Testing a controller#
The test checks that GET /api/orders/1 returns the order, without a database.
In ASP.NET Core you start the app in memory and call it with an HttpClient. In
Spring you start only the web layer, and replace the service behind the controller with a
mock:
public class OrdersTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrdersTests(WebApplicationFactory<Program> app)
=> _client = app.CreateClient();
[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 orders; // Boot 4 name
@Test
void get_returnsOrder() throws Exception {
when(orders.find(1L)).thenReturn(Optional.of(
new OrderDto(1, "PAID", new BigDecimal("19.99"))));
mvc.perform(get("/api/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1));
}
}@WebMvcTest(OrderController.class)Starts Spring MVC, Spring's model-view-controller (MVC) web framework, with just this controller: no services, repositories or database. It also provides a
MockMvcto call it with.@MockitoBean OrderService orders;Puts a Mockito mock in the context as the
OrderServicebean, so the controller receives the mock. An unstubbed method returns an empty value, hereOptional.empty(), which the controller turns into a 404.when(orders.find(1L)).thenReturn(Optional.of(Mockito stubbing, as in Testing: this call now returns that order.
mvc.perform(get("/api/orders/1"))Sends a request through Spring MVC without a real server or network, then checks the status and the
idin the JSON body.
@MockBean and @SpyBean are removed in Boot 4. Not
deprecated, removed. Replace them with @MockitoBean and
@MockitoSpyBean, which come from Spring Framework and have been available since
Boot 3.4, so you can migrate before upgrading.
Here is the same test class, before and after the upgrade:
// Boot 3
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderService orders;
@SpyBean AuditService audit;
}// Boot 4: @MockBean / @SpyBean no longer exist
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService orders;
@MockitoSpyBean AuditService audit;
}@MockitoSpyBean AuditService audit;A spy wraps the real bean: calls go through to it, and the test can check which calls were made, as with
@SpyBeanbefore.
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 fails to start after upgrading, with a “no qualifying
bean” error that does not obviously point at this change. TestRestTemplate is
the same: add @AutoConfigureTestRestTemplate.
Here is the difference in an integration test:
// Boot 3: MockMvc arrives automatically
@SpringBootTest
class OrderIntegrationTest {
@Autowired MockMvc mvc;
}// Boot 4: must be requested explicitly
@SpringBootTest
@AutoConfigureMockMvc
class OrderIntegrationTest {
@Autowired MockMvc mvc;
}@AutoConfigureMockMvcAdds a
MockMvcbean to the full application context, so the test can call every controller without starting a server.
A slice checks one layer. At least one test should check the whole path, from HTTP request to database row.
Full integration tests#
This test starts the whole billing service on a random port, against a real PostgreSQL database in a container, using the Testcontainers library:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
@Testcontainers
class OrderIntegrationTest {
@Container
@ServiceConnection // wires the datasource automatically
static PostgreSQLContainer db = new PostgreSQLContainer("postgres:16");
@Autowired TestRestTemplate rest;
@Test
void placesAnOrder() {
var request = new CreateOrder("c1", List.of(new OrderLine("A-1", 2)));
var res = rest.postForEntity("/api/orders", request, OrderDto.class);
assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
}@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)Starts the real application, with a real web server on a free port.
@AutoConfigureTestRestTemplateBoot 4 needs this to provide a
TestRestTemplate, which now comes from thespring-boot-resttestclientmodule. Boot 3 provided one automatically.@ContainerWith
@Testcontainerson the class, the JUnit extension starts this container before the tests and stops it afterwards.@ServiceConnectionBoot reads the container's address, user name and password, and points the datasource at it. It comes from the
spring-boot-testcontainersmodule.static PostgreSQLContainer db = new PostgreSQLContainer("postgres:16");Testcontainers 2 class, from the
org.testcontainers.postgresqlpackage. The olderPostgreSQLContainer<?>inorg.testcontainers.containersis deprecated.var res = rest.postForEntity("/api/orders", request, OrderDto.class);A real HTTP POST to the running service, with the reply read into an
OrderDto.
@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.
Four clients can call the application in a test:
| 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 Spring Framework 7; a fluent test client built on RestClient |
Repositories get a slice of their own.
Testing repositories#
This test checks that OrderRepository.findByCustomerId finds a saved order. It
starts only Jakarta Persistence (JPA), against the same kind of
container:
@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 orders;
@Test
void findsByCustomer() {
orders.save(anOrder("cust-1")); // anOrder builds an Order for that customer
assertThat(orders.findByCustomerId("cust-1")).hasSize(1);
}
}@DataJpaTestStarts JPA, the repositories and a datasource, and nothing else. Each test runs in a transaction that is rolled back at the end.
@AutoConfigureTestDatabase(replace = Replace.NONE)By default the slice swaps in an in-memory database such as H2.
Replace.NONEkeeps the container's PostgreSQL instead.assertThat(orders.findByCustomerId("cust-1")).hasSize(1);An AssertJ check that the derived query found exactly one order.
@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.
The deeper sections cover test-only configuration, and why most tests should need no Spring at all.
Test configuration#
These annotations adjust the context a test starts:
| 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 |
Here a test fixes the clock, so that code depending on today's date gives the same answer every run:
@TestConfiguration
static class TestClock {
@Bean Clock clock() {
return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
}
}@TestConfigurationA configuration class for tests only. Import it with
@Import(TestClock.class), and its beans join the test's context.return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);A
Clockthat always says midnight on 1 January 2026. Code that takes aClockinstead of callingInstant.now()can be tested this way.
@DirtiesContext discards the cached application context, so the next test class
pays the 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. That is possible precisely because
constructor injection makes your services
ordinary classes you can create with new. Reserve slices for the wiring, and full
@SpringBootTest for a handful of end-to-end paths.
Here the pricing rules are tested with no Spring at all:
class PricingTest { // no Spring at all: just construct the class
@Test
void appliesDiscount() {
var pricing = new Pricing(new FixedRates(Map.of("GBP", BigDecimal.ONE)));
assertThat(pricing.total(anOrder())).isEqualByComparingTo("90");
}
}var pricing = new Pricing(new FixedRates(Map.of("GBP", BigDecimal.ONE)));The class under test is created directly, with a simple fake in place of the real rates service. Nothing starts, so the test takes milliseconds.
.isEqualByComparingTo("90");Compares
BigDecimalvalues by amount, so90equals90.00.
@WebMvcTest(OrderController.class) starts only the web layer and provides
MockMvc, and @MockitoBean replaces OrderService with a
mock.
On Boot 4, @MockBean is gone, and @SpringBootTest needs
@AutoConfigureMockMvc for MockMvc and @AutoConfigureTestRestTemplate for
TestRestTemplate.
@DataJpaTest rolls back each test and swaps in an in-memory database unless you
set Replace.NONE; with Testcontainers and @ServiceConnection, it runs
against real PostgreSQL.
Each distinct test configuration starts a new application context, so keep the number of configurations small.
Quick reference#
| ASP.NET Core | Spring Boot |
|---|---|
| WebApplicationFactory<Program> | @SpringBootTest |
| IClassFixture<...> | the cached application context, shared automatically |
| HttpClient from app.CreateClient() | MockMvc, or TestRestTemplate and RestTestClient on a random port |
| Boot 3 | Boot 4 |
|---|---|
| @MockBean | @MockitoBean |
| @SpyBean | @MockitoSpyBean |
| MockMvc under @SpringBootTest, automatically | add @AutoConfigureMockMvc |
| TestRestTemplate under @SpringBootTest, automatically | add @AutoConfigureTestRestTemplate, or use RestTestClient |
| @WebMvcTest from spring-boot-test-autoconfigure | from spring-boot-webmvc-test |
Migrating Spring Boot 3 to 4#
Spring Boot 4 sits on Spring Framework 7 and Jakarta
EE 11. It is not the wrenching change that Boot 2 to 3 was, which renamed javax
to jakarta across the whole ecosystem.
Five things still break a build on day one. They are renamed starters, Jackson 3’s package move, removed test annotations, test slices in new modules, and the switch to JSpecify.
Baseline requirements#
The billing service runs on Boot 3 and Java 17, and the team wants Boot 4. First, check what Boot 4 needs underneath it:
| Requirement | Boot 3 | Boot 4 |
|---|---|---|
| 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 |
Java 17 is enough, so the upgrade itself is a change to the parent version in
pom.xml:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<properties>
<java.version>25</java.version> <!-- 17 is the minimum; 25 is recommended -->
</properties><version>4.1.1</version>The current Boot 4 release when this was written. Check spring.io for a newer one.
<java.version>25</java.version>The Java version the build compiles for. Boot 4 runs on 17, so change this in a separate step, as the last gotcha in this chapter explains.
The first compile errors after that change come from the starters.
1. Renamed starters#
Modules follow a uniform spring-boot-<technology> pattern, with root
packages org.springframework.boot.<technology>.
A few starters changed name as a result:
| Boot 3 | Boot 4 |
|---|---|
| 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 |
For the billing service, only the web starter changes:
<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.1.1</version>
</parent>
<dependency>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency><artifactId>spring-boot-starter-webmvc</artifactId>The same web stack under its new name. Test starters follow the same pattern, one per technology, such as
spring-boot-starter-webmvc-test.
Once the starters resolve, the next wave of errors comes from JSON.
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's own Jackson
types were renamed to match:
| Boot 3 | Boot 4 |
|---|---|
| com.fasterxml.jackson.* | tools.jackson.* |
| @JsonComponent | @JacksonComponent |
| @JsonMixin | @JacksonMixin |
| JsonObjectSerializer | ObjectValueSerializer |
| JsonValueDeserializer | ObjectValueDeserializer |
| Jackson2ObjectMapperBuilderCustomizer | JsonMapperBuilderCustomizer |
| Jackson2ObjectMapperBuilder | deprecated for removal in Spring Framework 7; use Jackson's own builders |
| spring.jackson.read.* | spring.jackson.json.read.* |
| spring.jackson.write.* | spring.jackson.json.write.* |
The shop's custom JSON for Money shows both kinds of change, the import and
Boot's annotation:
import com.fasterxml.jackson.databind.ObjectMapper;
@JsonComponent
class MoneyJson { ... }import tools.jackson.databind.ObjectMapper;
@JacksonComponent
class MoneyJson { ... }import tools.jackson.databind.ObjectMapper;The same class, under Jackson 3's new root package.
@JacksonComponentRegisters the serializers in
MoneyJsonwith Boot's JSON mapper, as@JsonComponentdid. It lives inorg.springframework.boot.jackson.
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 your dependency tree for libraries that still expose Jackson 2
types in their own APIs, because those pull Jackson 2 back in alongside Jackson 3.
Then the tests stop compiling, for reasons of their own.
3. Test annotations#
Several test annotations were removed or moved. Testing Spring Boot shows each one in code:
| Boot 3 | Boot 4 | Note |
|---|---|---|
| @MockBean | @MockitoBean | removed in Boot 4, not deprecated; switch before you upgrade |
| @SpyBean | @MockitoSpyBean | removed in Boot 4, not deprecated; switch before you upgrade |
| MockitoTestExecutionListener | Mockito's MockitoExtension | |
| @SpringBootTest gives MockMvc | add @AutoConfigureMockMvc | Boot 4 no longer adds MockMvc to @SpringBootTest by itself |
| @AutoConfigureMockMvc(htmlUnit-related) | @AutoConfigureMockMvc(htmlUnit = @HtmlUnit(...)) | |
| @PropertyMapping | moved to org.springframework.boot.test.context | the same annotation, moved to a new package |
| @WebMvcTest from spring-boot-test-autoconfigure | from spring-boot-webmvc-test | now in org.springframework.boot.webmvc.test.autoconfigure |
| @SpringBootTest gives TestRestTemplate | add @AutoConfigureTestRestTemplate | TestRestTemplate now comes from spring-boot-resttestclient |
@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. The same goes for injecting MockMvc
into a @SpringBootTest: add @AutoConfigureMockMvc now, while it is still
harmless.
The last compile-level change is to null-safety annotations.
4. JSpecify null-safety#
Spring moved its whole API from the Java Specification
Request (JSR) 305 annotations to JSpecify. If your own code used
org.springframework.lang.Nullable, switch the import:
// Boot 3: Spring's own annotation, from the JSR-305 days
import org.springframework.lang.Nullable;
// Boot 4: the JSpecify standard
import org.jspecify.annotations.Nullable;import org.springframework.lang.Nullable;Still present in Spring Framework 7, but deprecated, so it compiles with a warning.
import org.jspecify.annotations.Nullable;The replacement: the same meaning, from the JSpecify standard that null checkers understand.
The upside: Spring's signatures now say exactly where null is possible, generic type arguments included. A null checker such as NullAway therefore gives far better results against Spring APIs than it did on Boot 3. See Nullability.
Removals in Spring Framework 7#
Some older APIs are gone, or on their way out:
| Removed | Replacement |
|---|---|
| javax.annotation / javax.inject support | the jakarta.* equivalents |
| spring-jcl | Apache Commons Logging 1.3, which Spring now depends on directly |
| ListenableFuture | CompletableFuture |
| Undertow support | Tomcat, Jetty, or Netty |
| suffixPatternMatch and similar path options | explicit mappings |
| Jackson2ObjectMapperBuilder | not removed yet, but deprecated for removal; use 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.
In a Maven build, the switch means deleting this dependency:
<!-- remove this before upgrading; Tomcat comes back as the default -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency><artifactId>spring-boot-starter-undertow</artifactId>Without it, the web starter's default server, Tomcat, is used again. If you had excluded Tomcat from the web starter to make room for Undertow, remove that exclusion too.
What you gain#
The upgrade also brings new features. Each is shown in the chapter that covers it:
| Feature | Detail |
|---|---|
| @Retryable and @ConcurrencyLimit in core | org.springframework.resilience.annotation, switched on by @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 fluent test client built on RestClient |
Configuration property renames#
A few configuration settings moved. Boot ignores a setting it no longer knows, so a missed
rename fails silently rather than at startup. While you upgrade, Boot's
spring-boot-properties-migrator module can report renamed settings at startup:
| Boot 3 | Boot 4 |
|---|---|
| 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 |
Here are two of them, before and after:
# Boot 3
spring.jackson.read.allow-single-quotes=true
spring.session.redis.namespace=billing
# Boot 4
spring.jackson.json.read.allow-single-quotes=true
spring.session.data.redis.namespace=billingspring.jackson.json.read.allow-single-quotes=trueThe same Jackson setting, with
jsonadded to its name.spring.session.data.redis.namespace=billingThe Redis session settings, now under
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#
Taken together, the changes suggest an order that keeps each step small:
| 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; 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 the Code Generation Library (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, a propagation mode that starts a separate transaction. It takes a second connection, and the suspended outer transaction still holds its locks, so touching the same rows makes it wait forever.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.The billing service's upgrade starts with the parent version and the starter names:
spring-boot-starter-web becomes spring-boot-starter-webmvc.
Jackson 3 moves every import from com.fasterxml.jackson to
tools.jackson, and @JsonComponent becomes
@JacksonComponent.
@MockBean and @SpyBean are gone, @SpringBootTest needs
@AutoConfigureMockMvc, and the test slices come from new modules.
Upgrade Boot and Java in separate steps, and move off Undertow first if you use it.
Quick reference#
| Change | Boot 3 | Boot 4 |
|---|---|---|
| Parent version | 3.5.x | 4.x |
| Web starter | spring-boot-starter-web | spring-boot-starter-webmvc |
| Jackson imports | com.fasterxml.jackson | tools.jackson |
| Mock beans in tests | @MockBean | @MockitoBean |
| Nullable annotation | org.springframework.lang.Nullable | org.jspecify.annotations.Nullable |
Packaging and native images#
The Java equivalent of dotnet publish is one
Java archive (JAR) file with everything inside, called a
fat JAR. It holds your code, every dependency and an embedded web server, and
java -jar runs it. It needs
a Java Virtual Machine (JVM) installed, 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 and small memory, at the cost of build time and restrictions on reflection.
The options#
Java has five ways to ship an application, from a bare JAR to a native binary. Each has a .NET counterpart:
| 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 |
A Spring Boot service almost always starts with the second row.
The fat JAR#
The billing service is ready to deploy. One command builds it, and one runs it:
./mvnw package
java -jar target/billing-1.0.0.jar./mvnw packageCompiles, runs the tests and builds
target/billing-1.0.0.jar, with every dependency and an embedded Tomcat inside.java -jar target/billing-1.0.0.jarStarts the service on any machine with a JVM, like
dotnet billing.dll.
Spring Boot's plugin keeps each dependency's JAR intact inside the fat JAR, rather than merging
their classes. It 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. The quick reference lists the everyday
commands for running it.
Most services then go into a container.
Containers#
The naive Dockerfile copies the fat JAR in one step, so any code change makes every server download all the dependencies again. Spring Boot fixes that by splitting the JAR into layers: dependencies change rarely, and your own classes change constantly. Each layer becomes a Docker image layer:
# build: compile, then split the jar into layers
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 cp target/*.jar application.jar \
&& java -Djarmode=tools -jar application.jar extract --layers --destination extracted
# run: one image layer per Boot layer, the least-changing first
FROM eclipse-temurin:25-jre
WORKDIR /application
COPY --from=build /app/extracted/dependencies/ ./
COPY --from=build /app/extracted/spring-boot-loader/ ./
COPY --from=build /app/extracted/snapshot-dependencies/ ./
COPY --from=build /app/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]RUN ./mvnw -B dependency:go-offlineDownloads the dependencies in a step of their own, which Docker reuses until
pom.xmlchanges.java -Djarmode=tools -jar application.jar extract --layers --destination extractedSpring Boot's own tool splits the JAR into four folders under
extracted:dependencies,spring-boot-loader,snapshot-dependenciesandapplication.COPY --from=build /app/extracted/dependencies/ ./Each
COPYmakes one image layer. A code change alters only the last one, so a server pulls a few hundred kilobytes instead of the whole service.ENTRYPOINT ["java", "-jar", "application.jar"]The extracted
application.jarholds only your code and refers to the extracted libraries beside it. That layout also starts faster than the fat JAR.
Spring Boot can also build an optimised image with no Dockerfile at all, using Cloud Native Buildpacks:
./mvnw spring-boot:build-image./mvnw spring-boot:build-imageBuilds a container image in your local Docker. The buildpacks pick a Java Runtime Environment (JRE), apply the layering above, set memory flags for the container, and run the service as a non-root user.
It is the closest thing to dotnet publish /t:PublishContainer, and a good
default for a team that does not want to maintain a Dockerfile.
jlink and jpackage#
Two tools in the Java Development Kit (JDK) build a runtime, or an installer, around your application:
| 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 |
Here the billing service's runtime keeps only the modules it uses:
# 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 --add-modules java.base,java.sql,java.naming \Builds a runtime from three modules, plus the modules they depend on, such as
java.logging. On JDK 26 the result is about 36 MB, against about 380 MB for the whole JDK.--runtime-image custom-jre --type dmgjpackagewraps the JAR and that runtime in a macOS disk image;msianddebwork the same way on Windows and Linux.
jlink requires everything to be modular, or an automatic module. Many libraries
still are not, which is why fat JARs and containers dominate on the server. It is much more useful
for desktop apps and command-line tools than for Spring Boot services.
When the JVM itself is too slow to start, or too large, the answer is a native binary.
GraalVM native-image#
A billing function that runs for a few seconds on demand cannot wait for a JVM to warm up. GraalVM compiles the whole application ahead of time into one native executable, as NativeAOT does:
<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./mvnw -Pnative native:compileRuns Spring's ahead-of-time processing, then GraalVM's compiler. It needs a GraalVM JDK installed, and takes minutes rather than seconds.
./target/billingThe result is an ordinary executable, with no JVM needed to run it.
Here is what you trade:
| 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 the Java Native Interface (JNI) all need explicit registration, or they fail at run time, not at build time.
Spring's own closed-world assumption adds more. Bean
definitions are fixed at build time, so beans cannot appear or disappear while the
application runs, @Profile is restricted, and
@ConditionalOnProperty is not supported. A feature toggle that
switches a bean on through configuration works on the JVM. In a native image it silently does
not, which is a genuinely nasty way to find out.
Spring Boot's ahead-of-time processing generates most of the required metadata for you, which is why Spring works as a native image at all. A library that does its own reflection may still need a hint:
@RegisterReflectionForBinding(OrderDto.class)
@Configuration
class NativeHints { }@RegisterReflectionForBinding(OrderDto.class)Registers
OrderDto, a record, for the reflection that JSON binding uses, so it can still be read and written in the native image.
So choose by workload:
| 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#
The JVM already shortens startup with class data sharing (CDS): the JDK ships an archive of its own core classes, already parsed, and maps it into memory when it starts. Java 24 and 25 extend the idea to your application, with an ahead-of-time (AOT) cache. A training run records the classes the application loads and links, and later starts reuse them:
# 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 -XX:AOTCacheOutput=app.aot -jar app.jarRuns the application once as a training run, and writes the cache when it exits.
java -XX:AOTCache=app.aot -jar app.jarEvery later start reads the cache instead of loading and linking those classes again. Run it with the same JDK and the same JAR that made the cache.
Java 25 finalised ahead-of-time command-line ergonomics, the one-command form
above, and ahead-of-time method profiling Java 25. It also made
compact object headers a supported option Java 25, which shrinks
every object by four bytes. That option is off by default: switch it on with
-XX:+UseCompactObjectHeaders. Together these make JVM startup and footprint on 25
noticeably better than on 21, often enough to make native image unnecessary.
LegacyWAR files and application servers
Before embedded servers, you built a web application archive (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.
./mvnw package builds billing-1.0.0.jar, a fat JAR that runs
anywhere with a JVM through java -jar.
In a Dockerfile, split the JAR into layers with -Djarmode=tools, so a code change
rebuilds only the application layer; or let spring-boot:build-image do it with
buildpacks.
A GraalVM native image starts in milliseconds and uses less memory, but everything reachable
must be known at build time, and @ConditionalOnProperty is not supported.
Choose native for serverless functions and command-line tools, and the JVM for long-running services, where peak throughput and diagnostics matter.
Quick reference#
| 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 |
| .NET | Java |
|---|---|
| dotnet publish, framework-dependent | ./mvnw package, giving a fat JAR |
| dotnet publish --self-contained | jlink, a trimmed runtime around your app |
| PublishAot | GraalVM native-image |
| dotnet publish /t:PublishContainer | ./mvnw spring-boot:build-image |
The JVM at runtime: memory, GC and containers#
The Java Virtual Machine (JVM) is more configurable than the CLR, and more likely to need it. The two things to get right are heap sizing in a container and which garbage collector (GC) runs.
Modern defaults are good: the G1 collector, and awareness of container limits. So start by changing nothing, measure with Java Flight Recorder (JFR), and tune only what the data points at.
The memory model#
The billing service's container has a memory limit, and the JVM must fit inside it. A JVM
process uses memory in several regions, and -Xmx sets the size of only the
first:
| Region | Limited by | Holds |
|---|---|---|
| Heap | -Xmx, or -XX:MaxRAMPercentage | your objects: a young generation for new ones, an old one for survivors |
| Metaspace | -XX:MaxMetaspaceSize | class metadata; unbounded by default |
| Code cache | -XX:ReservedCodeCacheSize | machine code compiled while the program runs |
| Thread stacks | -Xss | about 1 MB for each platform thread |
| GC overhead | the collector itself | the bookkeeping the collector needs |
| Direct and native memory | -XX:MaxDirectMemorySize | ByteBuffers, Netty buffers and JNI allocations |
-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 out of metaspace,
the stacks of platform threads, or direct
buffers. Budget roughly 25 to 30 percent above -Xmx for
everything else, or use -XX:MaxRAMPercentage and let the JVM do the arithmetic.
C# refresherGC.Collect, generations and GCSettings.LatencyMode
GC.Collect(); // force a full collection: rarely wise
int gen = GC.GetGeneration(order); // 0, 1 or 2
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency; // avoid blocking gen 2 GCsIn .NET you tune the GC in runtimeconfig.json. On the JVM the same choices are
command-line flags. Here the service takes 70% of the container's memory for its heap:
{
"configProperties": {
"System.GC.Server": true,
"System.GC.HeapHardLimitPercent": 70
}
}java -XX:+UseG1GC \
-XX:MaxRAMPercentage=70 \
-XX:MaxMetaspaceSize=256m \
-XX:+ExitOnOutOfMemoryError \
-jar app.jar-XX:+UseG1GC \G1, the default collector; the flag only makes the choice visible.
-XX:MaxRAMPercentage=70 \The heap may grow to 70% of the memory the container is given, like
HeapHardLimitPercent.-XX:MaxMetaspaceSize=256m \Caps class metadata, which otherwise has no limit at all.
-XX:+ExitOnOutOfMemoryError \On an
OutOfMemoryErrorthe process exits, so the orchestrator restarts it, instead of limping on in a broken state.
The quick reference maps each .NET GC setting to its JVM counterpart. Containers need one more piece of care.
Containers#
The JVM has been container-aware since Java 10: it reads the container's CPU and memory limits rather than the host's. But the default heap is only a quarter of the memory limit. This shows it, then takes a deliberate share instead:
# 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.jardocker run -m 1g eclipse-temurin:25 java -XX:+PrintFlagsFinal -version | grep MaxHeapSizeStarts a JVM in a container limited to 1 GB and prints its maximum heap: 256 MB, because the default
MaxRAMPercentageis 25.java -XX:MaxRAMPercentage=70 -jar app.jarWith 70%, the same container allows a heap of about 718 MB, leaving the rest for metaspace, thread stacks and buffers.
Use -XX:MaxRAMPercentage rather than a fixed -Xmx in a container.
A hardcoded -Xmx2g in an image that later runs with a 1 GB limit gets OOMKilled; a
percentage adapts. And always set -XX:MaxMetaspaceSize: metaspace is unbounded by
default, and a class-loading leak will consume the container.
The quick reference lists the flags worth setting in every container. What remains is which collector to run.
Choosing a collector#
The JVM offers several collectors, each trading pause times against throughput:
| 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; not in every Java build; generational mode is optional since Java 25 |
A single flag picks one:
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 CPUjava -XX:+UseG1GC -jar app.jarG1 is chosen anyway, except on a machine with a single CPU or very little memory, where the JVM picks Serial by itself.
java -XX:+UseZGC -jar app.jarZGC does almost all its work while your threads keep running. Since Java 24 its generational mode is the only mode.
java -XX:+UseSerialGC -jar app.jarOne GC thread and the least overhead, for tools and tiny containers.
Old start scripts often carry -XX:+ZGenerational. On current versions of the
Java Development Kit (JDK), that flag no longer exists, and the JVM refuses to start: “Unrecognized VM option
'ZGenerational'”. Delete it; generational ZGC is what -XX:+UseZGC gives
you.
ZGC's pause times are essentially independent of heap size. A 100 GB heap pauses about as long as a 4 GB one, because the work is concurrent. There is no .NET equivalent: even Server GC with background collection has pauses that scale with the heap. If you have a large-heap, latency-sensitive service, this is a genuine reason to prefer the JVM.
The deeper sections cover diagnosing a JVM in production, and why it starts slow and gets faster.
Diagnosing#
When a service misbehaves, these commands inspect the running JVM. jcmd comes with
the JDK and finds a JVM by its process
id:
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> GC.heap_dump /tmp/heap.hprof # heap dump for Eclipse MAT
jcmd <pid> JFR.start duration=60s filename=/tmp/r.jfr
jmap -dump:live,format=b,file=heap.hprof <pid> # the older way to take a heap dump
java '-Xlog:gc*:file=gc.log:time,uptime' -jar app.jar # GC loggingjcmd <pid> VM.flagsThe flags in effect, including the ones the JVM chose for itself, such as the collector.
jcmd <pid> GC.class_histogramCounts objects by class, largest first: the quickest way to see what is filling the heap.
jcmd <pid> GC.heap_dump /tmp/heap.hprofWrites every object on the heap to a file, for analysis in a tool such as Eclipse MAT, like
dotnet-gcdump.java '-Xlog:gc*:file=gc.log:time,uptime' -jar app.jarLogs every collection to
gc.log, with the time of day and the time since start. The quotes stop shells such as zsh from treating the*as a file pattern.
Each symptom points at a first place to look:
| 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 platform thread 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 by interpreting your code. Then its just-in-time (JIT) compiler compiles the methods that run often: first quickly, with the C1 compiler, then thoroughly, with C2, for the hottest. Peak performance therefore arrives only after thousands of executions. That is why a Java benchmark that does not warm up is worthless, and why the Java Microbenchmark Harness (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 ahead-of-time (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.
You can watch the compiler work:
# watch the JIT at work: each line is a method being compiled as it gets hot
java -XX:+PrintCompilation -jar app.jar | headjava -XX:+PrintCompilation -jar app.jar | headPrints one line for each method the JIT compiles, so you can see which of your methods turn hot as the service warms up.
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.-Xmx bounds only the heap, so a container killed while the heap looks fine has
run out of metaspace, thread stacks or direct buffers.
In a container, set -XX:MaxRAMPercentage rather than a fixed -Xmx:
the default is 25% of the limit, 256 MB in a 1 GB container.
G1 is the default and right for almost everything; ZGC keeps pauses under a millisecond on large heaps.
Old start scripts with -XX:+ZGenerational no longer start, because generational
ZGC is now the only mode.
Quick reference#
| .NET | Java | Note |
|---|---|---|
| Server GC | G1 (default) | the default, except on very small machines, where Serial is chosen |
| Workstation GC | SerialGC | a single-threaded collector, for small heaps and tools |
| GCHeapHardLimit | -Xmx / -XX:MaxRAMPercentage | caps the heap, not the whole process |
| DOTNET_GCHeapCount | -XX:ParallelGCThreads | how many threads the collector uses in parallel |
| GC.Collect() | System.gc() | HotSpot runs a full collection; -XX:+DisableExplicitGC turns it off |
| 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 | pick ZGC or Shenandoah when pauses matter more than throughput |
| 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 |
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.
Releases with long-term support (LTS) are marked. Target an LTS. Every row names the chapter that shows the feature in code; search for the chapter's title to jump there.
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
rely on an exact release number.
Language features#
| Feature | Release | Status | C# analogue | Chapter |
|---|---|---|---|---|
| var for locals | 10 | final | var | var, strings and text blocks |
| Text blocks | 15 | final | raw string literals | var, strings and text blocks |
| Records | 16 | final | records | Records |
| instanceof pattern | 16 | final | is T x | Sealed types and pattern matching |
| Sealed classes and interfaces | 17 | final | no equivalent | Sealed types and pattern matching |
| Switch expressions | 14 | final | switch expressions | Switch expressions and statements |
| Pattern matching for switch | 21 | final | switch on patterns | Sealed types and pattern matching |
| Record patterns | 21 | final | positional patterns | Sealed types and pattern matching |
| Unnamed variables and patterns | 22 | final | discard _ | Sealed types and pattern matching |
| Module import declarations | 25 | final | global using | Access modifiers and packages |
| Compact source files, instance main | 25 | final | top-level statements | Getting a JDK |
| Flexible constructor bodies | 25 | final | no restriction to remove | Classes, constructors and initialisation |
| Primitive types in patterns | 25 | PREVIEW | relational patterns | Sealed types and pattern matching |
| String templates | 21, 22 | WITHDRAWN | interpolation. Java has none | var, strings and text blocks |
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#
Each feature arrived through a proposal to the Java Development Kit (JDK), called a JDK Enhancement Proposal (JEP). The biggest concurrency changes were virtual threads, now final, and structured concurrency, still a preview:
| Feature | Release | Status | Note |
|---|---|---|---|
| CompletableFuture improvements | 9 | final | timeouts and delayed executors; see CompletableFuture and Task |
| Virtual threads | 21 | final | Java's answer to async and await; see Threads are cheap now |
| Synchronize virtual threads without pinning | 24 | final | JEP 491 removes the synchronized trap; see Threads are cheap now |
| Scoped values | 25 | final | the AsyncLocal analogue; see Structured concurrency |
| Structured concurrency | 25 | PREVIEW | fifth preview, and the API has changed; see Structured concurrency |
| Stable values | 25 | PREVIEW | a Lazy<T> analogue the JIT can treat as a constant; see Locks and atomics |
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 | unmodifiable collections in one call; see Collections |
| Stream takeWhile, dropWhile, iterate | 9 | final | LINQ's TakeWhile and SkipWhile; see Streams vs LINQ |
| Optional.stream, ifPresentOrElse | 9 | final | more ways to use a value that may be absent; see Nullability |
| New HTTP client (java.net.http) | 11 | final | the JDK's own HttpClient analogue; see HTTP clients and resilience |
| String isBlank, lines, strip, repeat | 11 | final | IsNullOrWhiteSpace and friends; see var, strings and text blocks |
| Files.readString, writeString | 11 | final | File.ReadAllText and WriteAllText; see Files and I/O |
| Collectors.teeing | 12 | final | combines two collectors into one result; see Streams vs LINQ |
| Stream.toList() | 16 | final | replaces collect(toList()); see Streams vs LINQ |
| Sequenced collections | 21 | final | getFirst, getLast and reversed(); see Collections |
| Foreign Function and Memory API | 22 | final | the P/Invoke analogue; see Going deeper |
| Class-File API | 24 | final | the Reflection.Emit analogue; see Going deeper |
| Stream gatherers | 24 | final | custom intermediate operations, such as Chunk; see Streams vs LINQ |
| Ahead-of-Time Class Loading and Linking | 24 | final | faster startup from a training run; see Packaging and native images |
| Permanently disable the Security Manager | 24 | final | it was already deprecated; see The Java you'll inherit |
| ZGC: remove non-generational mode | 24 | final | generational is now the only ZGC mode; see The JVM at runtime |
| Quantum-resistant ML-KEM and ML-DSA | 24 | final | post-quantum key exchange and digital signatures |
| Key Derivation Function API | 25 | final | a standard API for deriving keys from shared secrets |
| PEM encodings | 25 | PREVIEW | reading and writing keys and certificates as PEM text |
| Vector API | 25 | INCUBATOR | tenth incubation; System.Numerics.Vector analogue |
Runtime, garbage collection (GC) and tooling#
| Feature | Release | Status | Note |
|---|---|---|---|
| jshell (REPL) | 9 | final | the Java REPL, for trying an API quickly; see Getting a JDK |
| JPMS modules | 9 | final | module-info.java; see Modules, JPMS and the missing internal |
| Single-file source launch | 11 | final | run java Foo.java with no compile step; see Getting a JDK |
| Flight Recorder open-sourced | 11 | final | JFR, free to use in production; see Actuator and observability |
| Helpful NullPointerExceptions | 14 | final | the message names the null expression; on by default since Java 15 |
| Strong encapsulation of JDK internals | 17 | final | old libraries that reach into JDK internals now fail; see Modules, JPMS |
| Deprecate finalization for removal | 18 | deprecated | never override finalize(); see Exceptions and resources |
| Generational ZGC | 21 | final | ZGC gained generations, cutting its memory overhead |
| Multi-file source launch | 22 | final | java Main.java can now use the other source files beside it |
| Generational ZGC by default | 23 | final | ZGC is generational unless you ask otherwise |
| Compact object headers | 25 | final | an option, off by default: -XX:+UseCompactObjectHeaders shrinks every object's header |
| Ahead-of-time command-line ergonomics | 25 | final | one flag creates the AOT cache; see Packaging and native images |
| Ahead-of-time method profiling | 25 | final | profiles from a training run make warm-up faster; see The JVM at runtime |
| Generational Shenandoah | 25 | final | a supported option, not the default: -XX:ShenandoahGCMode=generational |
| JFR CPU-time profiling | 25 | EXPERIMENTAL | CPU-time sampling in Flight Recorder, experimental in Java 25 |
| JFR cooperative sampling | 25 | final | safer, lower-overhead stack sampling inside Flight Recorder |
| JFR method timing and tracing | 25 | final | time or trace chosen methods without changing their code |
| Remove the 32-bit x86 port | 25 | final | the JDK builds for 64-bit x86 only from Java 25 |
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 | Ecosystem |
| 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 | Threads are cheap now |
| 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 | Caching, scheduling and events |
| 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 | Messaging and WebSockets |
| Span<T> | ByteBuffer / MemorySegment | Methods and parameters |
| 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 it almost never breaks old code: most code written for Java in 1999 still compiles today. So a real codebase grows in layers, like rock in a cliff face, with the oldest style at the bottom and each later era laid on top.
On an older team you may find three layers in one repository: an early web framework called Struts, Spring set up in XML files, and Spring Boot. This appendix is the field guide. For each layer it says what it is, roughly when it was written, and what replaced it.
Three parts matter in your first months: dating a codebase, the rename from
javax to jakarta, and the pieces Java has removed. The history of each
older technology is folded away; switch to Complete to read it.
Four eras#
Java's history falls into four eras, each with its own house style. The first was built on the Java 2 Platform, Enterprise Edition (J2EE), a set of standards for business software that ran inside large shared servers. The last column names what .NET developers were using at the same time, often the quickest way to picture an era:
| Era | Years | House style | .NET contemporary |
|---|---|---|---|
| Applets and J2EE | 1995-2005 | XML configuration files, heavyweight application 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 onward | records, virtual threads, Jakarta | .NET 5-10 |
You rarely need the exact year. What helps is looking at a file and knowing which era's style it was written in, because that tells you whether advice about it is still current.
Dating a codebase at a glance#
You can already date C# code by its style. A class full of ArrayList and
Hashtable was written before generics arrived in 2005, and one using
async and await was written after 2012. Java code gives itself away in
the same manner, and each signal in this table points to an era:
| 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; it is still right inside loops | - |
| 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 |
Here is one small class written in two eras. It is a Spring service that returns the shop's
orders, biggest total first, using an OrderRepository interface whose
findAll() method returns a List<Order>. This is how it looked
around 2010:
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderReport {
@Autowired
private OrderRepository repository;
public List<Order> biggestFirst() {
List<Order> orders = new ArrayList<Order>(repository.findAll());
Collections.sort(orders, new Comparator<Order>() {
public int compare(Order a, Order b) {
return b.getTotal().compareTo(a.getTotal());
}
});
return orders;
}
}@AutowiredField injection. Spring creates the
OrderReport, then writes the repository straight into the private field. There is no constructor, so the class can be created without a repository, for example in a test. An@Autowiredfield is typical of Spring's annotation era, about 2007 to 2016.List<Order> orders = new ArrayList<Order>(repository.findAll());The type argument
<Order>is written twice. Java 7 added the diamond,<>, in 2011, so the compiler can fill it in; code that repeats it is older, or written from habit. Copying into a new list also gives a list that can be sorted in place.Collections.sort(orders, new Comparator<Order>() {An anonymous class: a whole class written inline, only to pass one method,
compare. Java had no lambdas until Java 8, in 2014, so this was the only way.return b.getTotal().compareTo(a.getTotal());Comparing
bwitha, rather than the other way round, puts the biggest total first.getTotal()is a getter on an ordinary class, the way data was exposed before records.
The same class written today uses
constructor injection and a record. Here
Order is a record, so its total is read with total() rather than
getTotal():
import java.util.Comparator;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class OrderReport {
private final OrderRepository repository;
public OrderReport(OrderRepository repository) {
this.repository = repository;
}
public List<Order> biggestFirst() {
return repository.findAll().stream()
.sorted(Comparator.comparing(Order::total).reversed())
.toList();
}
}private final OrderRepository repository;Constructor injection: Spring passes the repository to the constructor, so the field can be
finaland the class cannot exist without it. A class with one constructor needs no@Autowiredat all..sorted(Comparator.comparing(Order::total).reversed())Order::totalis a method reference to the record'stotal()method, and it replaces the whole anonymous class.reversed()turns smallest-first into biggest-first..toList();Returns a list that cannot be changed. This method arrived in Java 16.
Neither version is wrong: the older one still runs on Java 25 and Spring 7. But a class like
the first tells you its neighbours were probably written in the same era, with the same habits.
One signal from the table, the javax import, marks the biggest break of all.
The javax to jakarta rename#
Suppose a Spring Boot 2 service has to move to Spring Boot 3. You change the version number, and nearly every entity, controller and validator stops compiling. The cause is not a bug; it is a trademark.
Java's enterprise standards, the Java Enterprise Edition (Java EE), lived under the
javax names. In 2017 Oracle handed Java EE to the Eclipse Foundation, which later
renamed it Jakarta EE. Oracle kept the Java trademarks, and the agreement did not let Eclipse
change anything under the javax names. So Jakarta EE 9, in 2020, moved every
enterprise API package from javax to
jakarta:
| 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) |
In code, the change is confined to the import lines. The classes, annotations and methods keep their names; only the package in front of them changes:
// Spring Boot 2 uses the javax names.
import javax.persistence.Entity;
import javax.validation.constraints.NotNull;
// Spring Boot 3 and 4 use the jakarta names for the same classes.
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotNull;Entity comes from Jakarta Persistence (JPA),
the standard behind Hibernate, and NotNull
from Jakarta Validation. The java.*
packages, such as java.util, were never affected: only the enterprise standards
moved.
This is a hard break, not a deprecation. Spring Boot 2 uses
javax; Spring Boot 3 and 4 use jakarta, and nothing in the framework
translates between them. Upgrading from Boot 2 to Boot 3 means changing the imports in every
entity, controller and validator, and replacing any library that still uses
javax. Tools such as OpenRewrite and the Eclipse Transformer can do the renaming,
but plan it as a project, not an afternoon.
The rename changed names. Java itself has also removed a handful of whole features, and one of those removals causes the other upgrade failure you are likely to meet.
What Java removed#
Here is the typical story. A service that exports invoices as XML has run on Java 8 for years. You move it to Java 11. It starts normally, then fails the first time it exports an invoice, with this error:
java.lang.NoClassDefFoundError: javax/xml/bind/JAXBContext#Caused by: java.lang.ClassNotFoundException.javax.xml.bind to jakarta.xml.bind.The Java EE removals in Java 11 break more upgrades than anything except the Jakarta rename. Java 9 stopped loading JAXB and the other Java EE modules by default, and Java 11 deleted them from the JDK. The error names a missing class, and gives no hint that the fix is a missing dependency.
The fix is to add JAXB back as an ordinary dependency. In a Spring Boot 3 or 4 project, Spring Boot chooses the versions, and they are the Jakarta ones:
<!-- The JAXB API: JAXBContext and the annotations, now in the package jakarta.xml.bind. -->
<dependency>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
</dependency>
<!-- The implementation that does the work at run time. -->
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
</dependency><artifactId>jakarta.xml.bind-api</artifactId>The classes your code calls. Neither dependency needs a
<version>, because Spring Boot supplies it. Spring Boot 4.1 picks version 4, which lives injakarta.xml.bind, soimport javax.xml.bind.JAXBContextmust becomeimport jakarta.xml.bind.JAXBContext.<artifactId>jaxb-runtime</artifactId>The engine behind the API. Without it the code compiles, then fails when it creates a
JAXBContext, with aJAXBExceptionsaying that no implementation of the API was found.
JAXB is the removal you are most likely to meet. The full list is short, and each entry says what to use instead:
| Removed | When | Note |
|---|---|---|
| Applet API | Java 26 | the browser plugin went in Java 11; the API, deprecated in 17, went in 26 |
| Java Web Start | Java 11 | gone; ship an installer built with jpackage instead |
| CORBA and Java EE modules | Java 11 | JAXB and JAX-WS became separate dependencies; see above |
| Nashorn JavaScript engine | Java 15 | gone; use GraalJS to run JavaScript on the JVM |
| Security Manager | Java 24 | permanently disabled; it can no longer be switched on |
| Thread.suspend and resume | Java 23 | removed outright; they were never safe to call |
| Thread.stop | Java 26 | has thrown instead of stopping since Java 20; Java 26 removes it |
| 32-bit x86 port | Java 25 | the JDK now builds for 64-bit x86 only |
| Non-generational ZGC | Java 24 | generational mode is now the only one ZGC has |
The rest of this appendix is history: what each older technology was, so you can recognise it when a codebase puts one in front of you. Each section stays folded until you open it.
Language habits from before Java 8#
LegacyAnonymous inner classes as callbacks
Before Java 8, in 2014, passing a piece of behaviour meant writing a whole class inline. C# has had lambdas since 2007, so the same sort looks very different in the two languages:
orders.Sort((a, b) => a.Total.CompareTo(b.Total));Collections.sort(orders, new Comparator<Order>() {
@Override
public int compare(Order a, Order b) {
return a.getTotal().compareTo(b.getTotal());
}
});Both sort orders smallest total first. The Java version creates an unnamed class
that implements Comparator, whose one method, compare, does the work.
Modern Java writes orders.sort(Comparator.comparing(Order::getTotal)). IntelliJ
converts the old form for you with Alt+Enter, or ⌥↩ on a
Mac.
LegacyGuava and Apache Commons
Before the JDK had collection factories, Optional and string helpers, two
libraries filled the gaps: Google's Guava and Apache Commons. Their calls are all over older
code, and most now have a JDK method that does the same job. The Quick reference at the end of
this appendix pairs each common call with its replacement.
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
freely: exceptions the compiler forces every caller to catch or declare. The resulting
try/catch blocks are a large part of Java's reputation for wordiness.
Libraries have since moved the other way. Spring, Hibernate and the AWS SDK all wrap checked
exceptions in unchecked ones before they reach your code. See
Exceptions and resources.
Configuration by XML#
LegacySpring XML configuration
In .NET you register services in code, in Program.cs. Before annotations, Spring
did the same job in an XML file, usually called applicationContext.xml, and every
Spring bean was declared there. Here the order service gets
its repository, and the repository gets its data source, the object that hands out database
connections:
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd">
<bean id="orderService" class="com.acme.orders.OrderService">
<constructor-arg ref="orderRepository"/>
<property name="auditEnabled" value="true"/>
</bean>
<bean id="orderRepository" class="com.acme.orders.JdbcOrderRepository">
<property name="dataSource" ref="dataSource"/>
</bean>
<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="url" value="jdbc:postgresql://localhost/shop"/>
</bean>
</beans><beans xmlns="http://www.springframework.org/schema/beans"The header names the XML schema, so tools can check the file. A version number in the address, here
3.0, dates the file; Spring 7 still accepts it.class="com.acme.orders.OrderService">One bean. Spring creates an
OrderServiceand registers it under the nameorderService. The class name is a plain string, which is the weakness of the whole approach.<constructor-arg ref="orderRepository"/>Passes the bean named
orderRepositoryto the constructor.refalways means another bean.<property name="auditEnabled" value="true"/>Calls
setAuditEnabled(true)after the object is created.valuepasses a literal rather than a bean.
Large systems had thousands of lines of this. Renaming a class did not break the build, only
the next start-up, because the XML held class names as plain strings. The application loaded the
file with new ClassPathXmlApplicationContext("applicationContext.xml"), or through
a setting in web.xml. Annotations arrived in Spring 2.5, in 2007, and
@Configuration classes in Spring 3, in 2009. XML still works in Spring 7, and you
will find it in older modules, often beside annotations in the same application.
LegacyHibernate hbm.xml mappings
If you have used NHibernate in .NET, you know this format already: NHibernate is a port of
Hibernate and copied it. Before annotations, Hibernate read the mapping between a class and its
table from a separate XML file per class, named like Order.hbm.xml:
<hibernate-mapping>
<class name="com.acme.orders.Order" table="ORDERS">
<id name="id" column="ORDER_ID">
<generator class="native"/>
</id>
<property name="total" column="TOTAL"/>
</class>
</hibernate-mapping>The class element maps Order to the ORDERS table.
id names the key column and lets the database choose how to generate it, and each
property maps one field. Renaming a field meant editing two files, and forgetting
the second was a failure at run time. The @Entity, @Id and
@Column annotations replaced these files in 2006; the engine underneath is the
same.
Build tools#
LegacyAnt and Ivy
Ant, from 2000, is a general-purpose task runner configured in XML, closer to MSBuild than to
Maven. It has no conventions and no dependency management,
so every project invented its own folder layout, and libraries were committed to source control
in a lib/ folder. A build.xml file lists targets, and each target is
a list of tasks:
<project name="shop" default="compile">
<property name="src" location="src"/>
<property name="build" location="build"/>
<target name="init">
<mkdir dir="${build}"/>
</target>
<target name="compile" depends="init">
<javac srcdir="${src}" destdir="${build}" classpath="lib/commons-lang-2.6.jar"/>
</target>
</project><property name="src" location="src"/>Defines a variable, used later as
${src}. Ant assumes nothing about the layout, so every project spells it out.<target name="compile" depends="init">A target, like an MSBuild target.
dependsrunsinitfirst.<javac srcdir="${src}" destdir="${build}" classpath="lib/commons-lang-2.6.jar"/>Compiles the sources against a Java archive (JAR) checked into the repository. There is no package manager: the file in
lib/is the dependency.
Ivy, a later add-on, gave Ant dependency resolution. Maven's real contribution, in 2004, was
not its XML: it was the conventions, such as src/main/java, plus a central
repository of libraries. Ant survives in old builds and inside some
Gradle scripts.
Web frameworks, in order of extinction#
LegacyApplets
Applets were Java in the browser, from 1995 to about 2015: user-interface code downloaded as a JAR and run by a browser plugin, much as Silverlight was later for .NET. Security flaws killed them, and then browsers dropped the Netscape Plugin API (NPAPI) that the plugin needed. Java 11 removed the browser plugin and Java Web Start. The Applet API itself was deprecated for removal in Java 17 and removed in Java 26.
You will not meet a working applet. You may meet the remains of one in an internal tool nobody dares delete.
LegacyServlets and JSP
A servlet is a class that handles an HTTP request,
Java's counterpart of an IHttpHandler in classic ASP.NET. Servlets date from the
late 1990s and, unlike applets, are very much alive, just hidden under Spring. This one returns
a page of HTML:
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class OrderServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/html");
response.getWriter().println("<h1>Orders</h1>");
}
}public class OrderServlet extends HttpServlet {Every servlet extends
HttpServlet. A separate file,web.xml, mapped a URL such as/ordersto this class.protected void doGet(HttpServletRequest request, HttpServletResponse response)One method per HTTP verb:
doGet,doPostand so on. The request and response objects are the whole API, asHttpContextwas in classic ASP.NET.response.getWriter().println("<h1>Orders</h1>");Writes the page by hand. Nobody does this any more; a template or a JSON library does it.
Spring MVC, Spring's model-view-controller web framework,
is built on this same API. Its DispatcherServlet is a servlet that receives every
request and hands it to your controllers. So servlets still run under every Spring Boot web
application; only writing them by hand stopped.
JavaServer Pages (JSP) are HTML pages with Java mixed in, compiled into a servlet. They are truly legacy: use Thymeleaf templates instead.
LegacyStruts 1 and 2
Struts was the leading model-view-controller framework of the early 2000s, the Java
counterpart of what ASP.NET MVC later became. Actions were configured in
struts-config.xml, and forms were ActionForm classes. Struts 1 reached
end of life in 2013. Struts 2 is still maintained but rarely chosen. It is best known for
CVE-2017-5638, the remote-code-execution flaw behind the 2017 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 (JSF) was the official Java EE answer: a component-based framework that keeps page state on the server, much like ASP.NET Web Forms, with the same eventual problems. It survives as Jakarta Faces, mostly in large companies with long-lived internal applications.
Some older guides name Java EE MVC, a later standard now called Jakarta MVC, as the equivalent of ASP.NET MVC. Few teams use it. The answer is Spring MVC, through Spring Boot.
EJB and the application server#
LegacyEJB 2.x
Enterprise JavaBeans (EJB) were the component model at the heart of J2EE: objects that lived inside a server, which gave them transactions, security and remote calls. The nearest .NET relatives are COM+ and .NET Remoting. In version 2 every bean needed four pieces: a home interface, a remote interface, an implementation class and an XML deployment descriptor. The two interfaces alone look like this:
import java.rmi.RemoteException;
import javax.ejb.CreateException;
import javax.ejb.EJBHome;
import javax.ejb.EJBObject;
// The home interface is how a client asks the server for an OrderService.
public interface OrderServiceHome extends EJBHome {
OrderService create() throws RemoteException, CreateException;
}
// The remote interface lists the methods a client may call.
public interface OrderService extends EJBObject {
Order place(Order order) throws RemoteException;
}OrderService create() throws RemoteException, CreateException;A client never used
new. It looked up the home interface on the server, then calledcreateto get anOrderService.Order place(Order order) throws RemoteException;Every call might cross the network, so every method declared the checked
RemoteException, and every caller had to handle it.
The weight of this is why Spring exists. Rod Johnson's 2002 book argued that plain objects, in a container that stayed out of the way, could have the same transactions without EJB. EJB 3, in 2006, switched to annotations and became reasonable, but by then Spring had won.
LegacyApplication servers
WebSphere, WebLogic, JBoss and GlassFish were application servers: long-running servers, shared by several applications and managed through a web console, much like IIS hosting several sites. You packaged a web application as a web application archive (WAR), or several of them as an enterprise archive (EAR), and deployed it into the server. Deployment was something done to a server, not a process you started.
Spring Boot turned this inside out. The web server is a library inside your JAR, each service is its own process, and the file you ship never changes after it is built. That is what made Java fit containers. You will still meet WebSphere in banking and insurance.
SOAP and web services#
LegacyJAX-WS, WSDL and SOAP
If you built services with ASMX or WCF in .NET, you know this world. Before REST, a service's contract was a Web Services Description Language (WSDL) document, and each message was a SOAP envelope, an XML document sent over HTTP. Tools generated client code from the WSDL, which gave real type safety across languages. REST gave that up, and OpenAPI later won much of it back.
In Java, a class became a SOAP service through annotations from JAX-WS, the Java API for XML Web Services:
import java.util.HashMap;
import java.util.Map;
import javax.jws.WebMethod;
import javax.jws.WebParam;
import javax.jws.WebService;
@WebService
public class InvoiceService {
private final Map<Long, Invoice> invoices = new HashMap<Long, Invoice>();
@WebMethod
public Invoice findInvoice(@WebParam(name = "orderId") long orderId) {
return invoices.get(orderId);
}
}@WebServicePublishes the class as a SOAP service; the server generates the WSDL from it.
public Invoice findInvoice(@WebParam(name = "orderId") long orderId) {@WebMethodmakes the method an operation, and@WebParamnames its parameter in the XML. JAXB turns theInvoiceit returns into XML.
The extra standards on top, such as WS-Security and WS-ReliableMessaging, are why
“enterprise integration” earned its reputation. SOAP is still current in banking,
government and telecoms, and Spring Boot supports it through
spring-boot-starter-webservices. In .NET the same journey ran from WCF to ASP.NET
Core.
Why any of this matters#
Knowing the history pays off in two ways. First, dating code tells you which advice to trust.
A codebase with applicationContext.xml and anonymous classes was probably written
before 2014, before virtual threads, records and
lambdas existed. Search results and forum answers from that time are just as old. Some still
recommend overriding finalize(), which Java 18 deprecated for removal.
Second, Java's promise not to break old code is real. Most of what this appendix describes still runs, so a Java codebase gains layers instead of being rewritten. That makes reading old Java a lasting skill, in a way that reading old JavaScript is not.
Old code carries its date: @Autowired on fields, an anonymous
Comparator and new ArrayList<Order>() mean around 2010, while
constructor injection, Order::total and records mean recent code.
Spring Boot 3 moved every enterprise import from javax to jakarta,
so a Boot 2 upgrade touches every entity, controller and validator.
A NoClassDefFoundError for javax/xml/bind after leaving Java 8 means
JAXB left the JDK: add jakarta.xml.bind-api and jaxb-runtime, and change
the imports.
Spring XML, hbm.xml files, Ant builds and EJBs still turn up in older systems;
new code uses annotations, Maven or Gradle, and plain Spring beans.
Quick reference#
| Old Java | Use today | The .NET counterpart of the old one |
|---|---|---|
| Applets | a web front end | Silverlight, also a browser plugin |
| Hand-written servlets | Spring MVC controllers | IHttpHandler in classic ASP.NET |
| JSP pages | Thymeleaf templates | classic ASP or .aspx pages |
| Struts | Spring MVC | ASP.NET MVC |
| JSF | Spring MVC | ASP.NET Web Forms |
| EJB 2 session beans | Spring beans | COM+ and .NET Remoting |
| Application servers | an embedded web server inside your JAR | IIS hosting several applications |
| Spring XML files | @Configuration classes and annotations | XML configuration for Unity or Castle Windsor |
| Hibernate XML mapping files | @Entity annotations | NHibernate .hbm.xml files |
| Ant build files | Maven or Gradle | NAnt, a port of Ant, or MSBuild |
| JAX-WS SOAP services | REST with OpenAPI | ASMX and WCF |
| Anonymous Comparator classes | lambdas and method references | anonymous methods in C# 2 |
| Old third-party call | Modern JDK equivalent | Since |
|---|---|---|
| Lists.newArrayList() | new ArrayList<>() | Java 7, when the diamond <> arrived |
| ImmutableList.of(a, b) | List.of(a, b) | Java 9 |
| Optional (Guava) | java.util.Optional | Java 8 |
| StringUtils.isBlank(s) | s.isBlank(), after a null check | Java 11 |
| Lists.partition(xs, n) | Gatherers.windowFixed(n) | Java 24 |
| Joiner.on(",").join(xs) | String.join(",", xs) | Java 8 |
| Short form | Stands for | What it is |
|---|---|---|
| J2EE | Java 2 Platform, Enterprise Edition | the enterprise standards until 2006, then Java EE, now Jakarta EE |
| EJB | Enterprise JavaBeans | server-managed objects with transactions and remote calls |
| JSP | JavaServer Pages | HTML with Java inside, compiled into a servlet |
| JSF | JavaServer Faces | a component framework that keeps page state on the server |
| WAR | web application archive | one web application, packaged for a server |
| EAR | enterprise archive | several WARs and JARs, deployed together |
| JAXB | Java Architecture for XML Binding | maps objects to XML and back; now Jakarta XML Binding |
| JAX-WS | Java API for XML Web Services | SOAP services from annotated classes |
| WSDL | Web Services Description Language | the XML contract of a SOAP service |
Appendix D: Going deeper#
This appendix is a map, not the territory. Each topic below needs more than a handbook section, and some need a whole book. Every topic answers four questions:
- What is it?
- What symptom tells you that you need it?
- Where will a C# habit mislead you?
- What should you read?
Spring internals get short, real explanations with code, because they are conventions you can learn in a few hundred words. The internals of the Java Virtual Machine (JVM) get signposts only: they are behaviour you have to measure, and no summary replaces that.
Start here: the symptom index#
Start from what you are seeing. Each row names a symptom, the mechanism that usually explains it, and where this book covers it:
| What you are seeing | What explains it | Where |
|---|---|---|
| An annotation silently does nothing | the method was called from inside its own class, which bypasses Spring's proxy | How Spring actually works |
| A bean is null inside @PostConstruct | the order in which Spring creates and initialises beans | How Spring actually works |
| Data committed despite an exception | by default, a checked exception does not roll the transaction back | Transactions in depth |
| Deadlocks that only appear under load | REQUIRES_NEW holds one connection while it waits for a second, and the pool runs dry | Transactions in depth |
| Correct on MySQL, wrong on PostgreSQL | the two databases have different default isolation levels | Transactions in depth |
| p99 is spiky, the mean is fine | the slowest 1% of requests hit garbage-collection pauses, or the JIT recompiling code | The JVM at runtime; the JIT entry below |
| Works in tests, fails in the app server | the server's classloaders see a different set of classes | Classloaders, below |
| ClassCastException naming the same class twice | two classloaders each loaded their own copy of the class | Classloaders, below |
| Slow build, unexplained generated sources | an annotation processor writing code at compile time | Annotation processing |
| A concurrency bug you cannot reproduce | a missing happens-before rule in the Java Memory Model | The Java Memory Model, below |
| Memory grows but the heap looks flat | memory outside the heap: metaspace, direct buffers, thread stacks | The JVM at runtime |
| Startup is slow and you have no idea why | too many auto-configurations or classes to load; the conditions report shows which ran | Spring Boot orientation; Spring AOT, below |
| You need to add behaviour to a class you do not own | rewriting the class's bytecode | Bytecode manipulation, below |
| Your own starter is not auto-configuring | a wrong registration file, or a condition that is not met | Writing an auto-configuration, below |
| Native image compiles but fails at runtime | reflection metadata the build could not work out | Spring AOT, below; Packaging and native images |
Spring internals#
These three topics explain what Spring Boot does at start-up, and each takes an afternoon to learn. The first is how Spring Boot's auto-configuration works from the library's side.
Writing an auto-configuration and a starter#
Say several of the shop's services call the same payment provider. You want to write the client once, as a library, so that each service only adds a dependency and sets a URL. The library needs an auto-configuration: a class that creates its beans, but only when certain conditions hold.
| Row | Detail |
|---|---|
| What it is | A configuration class that creates beans only when its conditions hold. Spring Boot finds it through a registration file inside the library, not by component scanning. |
| You need it when | You are packaging shared setup for several services, and want each service to get it just by adding one dependency. |
| The C# instinct | An extension method such as services.AddPaymentGateway() that every application must call. Spring turns this around: the library registers itself, and the application does nothing. |
The library holds a PaymentGatewayClient, which wraps Spring's
RestClient, and a PaymentGatewayProperties
record bound from the
payment.gateway.* settings. The auto-configuration is the first of two parts:
package com.acme.payments.autoconfigure;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestClient;
import com.acme.payments.PaymentGatewayClient;
import com.acme.payments.PaymentGatewayProperties;
@AutoConfiguration
@ConditionalOnClass(RestClient.class)
@EnableConfigurationProperties(PaymentGatewayProperties.class)
public class PaymentGatewayAutoConfiguration {
@Bean
@ConditionalOnMissingBean
PaymentGatewayClient paymentGatewayClient(PaymentGatewayProperties properties) {
return new PaymentGatewayClient(RestClient.create(properties.url()));
}
}package com.acme.payments.autoconfigure;A package of its own, which no application will scan. The gotcha below explains why that matters.
@AutoConfigurationMarks a configuration class that Spring Boot loads only through the registration file, after the application's own configuration. That order is what lets it step aside.
@ConditionalOnClass(RestClient.class)Skips the whole class unless
RestClientis on the classpath, that is, unless the service uses Spring's web client.@EnableConfigurationProperties(PaymentGatewayProperties.class)Binds
payment.gateway.urlto the record and makes the record a bean, ready to be passed to the method below.@ConditionalOnMissingBeanCreates the client only if the application has not defined its own
PaymentGatewayClient. The application always wins.
The second part is the registration file: a plain text file with a fixed folder and name, listing the auto-configuration classes one per line:
# This file lives in src/main/resources/META-INF/spring/ and is named
# org.springframework.boot.autoconfigure.AutoConfiguration.imports.
com.acme.payments.autoconfigure.PaymentGatewayAutoConfigurationAt start-up Spring Boot reads this file from every
Java archive (JAR) on the classpath, and loads each class it
names, here PaymentGatewayAutoConfiguration. Lines that start with a
# are comments.
A starter is the last, optional piece. It is a
dependency with no code of its own, which pulls in the library and everything it needs, as
spring-boot-starter-webmvc does for web applications. Call yours
payment-gateway-spring-boot-starter, because names that begin with
spring-boot are kept for Spring Boot's own modules.
Three things bite here. First, the registration file's folder and name must be exact. A typo gives no error at all: the auto-configuration is simply never loaded.
Second, keep the auto-configuration out of every package the application scans.
@SpringBootApplication skips classes named in a registration file, but a plain
@ComponentScan does not. It loads the class as ordinary configuration, before it has
seen the application's own beans, so @ConditionalOnMissingBean misses them and the
application ends up with two clients.
Third, libraries written for Spring Boot 2 often list their auto-configurations in a file
called spring.factories instead. Spring Boot 3 and 4 no longer read that file for
auto-configurations, so such a library is silently ignored.
Read: the Spring Boot reference, “Creating Your Own Auto-configuration”, then the source of any Spring Boot auto-configuration, which is unusually readable.
BeanFactoryPostProcessor and BeanPostProcessor#
Spring has two hooks into its own start-up. A BeanFactoryPostProcessor sees the
bean definitions, Spring's recipes, before any bean exists. A
BeanPostProcessor sees each bean as it is made,
and can wrap or replace it. It is how Spring puts a
proxy around a bean, and you can use it the same way.
| Row | Detail |
|---|---|
| What it is | Two extension points. A BeanFactoryPostProcessor edits bean definitions before any bean is created; a BeanPostProcessor edits or replaces each bean as it is created. Every proxy in Spring is made by the second kind. |
| You need it when | You need the same wrapper around many beans, such as timing or auditing, or you load bean definitions from somewhere other than your code. |
| The C# instinct | There is no direct equivalent. The nearest is editing the IServiceCollection before Build(), plus decorator registrations; Spring's hooks run inside the container and see every bean. |
Here a post-processor times every call to the shop's PaymentGateway, an interface
whose charge method takes an Invoice and returns a receipt number. It
wraps each gateway in a proxy made by the Proxy class that comes with the
Java Development Kit (JDK), the counterpart of .NET's
DispatchProxy:
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.stereotype.Component;
@Component
public class TimingPostProcessor implements BeanPostProcessor {
private static final Logger log = LoggerFactory.getLogger(TimingPostProcessor.class);
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (!(bean instanceof PaymentGateway gateway)) {
return bean;
}
return Proxy.newProxyInstance(
PaymentGateway.class.getClassLoader(),
new Class<?>[] { PaymentGateway.class },
(proxy, method, args) -> {
long start = System.nanoTime();
try {
return method.invoke(gateway, args);
} catch (InvocationTargetException e) {
throw e.getCause();
} finally {
long micros = (System.nanoTime() - start) / 1_000;
log.info("{}.{} took {} microseconds", beanName, method.getName(), micros);
}
});
}
}public Object postProcessAfterInitialization(Object bean, String beanName) {Spring calls this for every bean, once the bean is created and initialised. Whatever it returns is what other beans receive.
if (!(bean instanceof PaymentGateway gateway)) {Every bean that is not a gateway passes through unchanged.
return Proxy.newProxyInstance(Builds, at run time, an object that implements
PaymentGatewayand sends every call to the lambda below. The lambda receives the proxy, the method being called and its arguments.return method.invoke(gateway, args);Calls the real gateway.
throw e.getCause();invokewraps any exception the gateway throws in anInvocationTargetException. Unwrapping it lets the caller catch the original, such as anIllegalArgumentException.log.info("{}.{} took {} microseconds", beanName, method.getName(), micros);Runs whether the call succeeded or failed, and logs the bean, the method and the time.
A BeanPostProcessor is itself a bean, and Spring must create it before the beans
it processes. So anything it depends on is created very early too, before the other
post-processors are ready, and never passes through them. Keep post-processors free of
dependencies, or take an ObjectProvider and fetch the bean only when it is first
needed.
If TimingPostProcessor took an AuditLog in its constructor, to record
the timings, Spring would log this warning at start-up:
Bean 'auditLog' of type [com.acme.shop.AuditLog] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)#@Transactional on it can silently
do nothing.ObjectProvider<AuditLog> and call
getObject() only when the bean is first needed.Read: Spring Framework reference, "Container Extension Points"
Spring AOT and native processing#
Normally Spring makes all its decisions at start-up: which configuration applies, which beans to create and how to wire them. Ahead-of-time (AOT) processing makes those decisions during the build instead. It is also what makes a native image of a Spring application possible.
| Row | Detail |
|---|---|
| What it is | A build step that runs Spring's start-up decisions early and writes the result out as generated Java code and reflection metadata. Start-up then does less work, and GraalVM can see everything the application uses. |
| You need it when | You are building a native image, or start-up time matters, as on serverless platforms that scale to zero and start instances on demand. |
| The C# instinct | NativeAOT plus source generators, which do the same job for the same reason: compiling ahead of time needs everything decided before the program runs. |
Read: Spring Boot reference, "Ahead-of-Time Processing"; then Packaging and native images in this book
The rest of this appendix is signposts: what each topic is and when you need it, with no code. Each group is folded until you open it.
JVM internals#
These entries go below Spring, into the JVM itself. They cover how it finds classes, how its just-in-time (JIT) compiler turns bytecode into machine code, and the rules for sharing memory between threads.
Classloaders and the classpath#
| Row | Detail |
|---|---|
| What it is | Java finds classes through a chain of class loaders, each of which asks its parent first. A class's identity is its name plus the loader that loaded it, so one name loaded twice is two different types. |
| You need it when | A ClassCastException names the same class on both sides; a library works in tests but not in the deployed server; you are building plugins or hot reload. |
| The C# instinct | .NET has AssemblyLoadContext, and you almost never touch it. In Java the chain of loaders carries real weight, shows up in ordinary bugs, and is what "JAR hell" means. |
| Read | Java Language Specification 12.2; Oaks, "Java Performance"; the Tomcat class loader documentation for the application-server case |
The basics are in How Java runs your code: the classpath as a search list, the order in which loaders are asked, and the two class-not-found errors. This entry is for when those are not enough.
The JIT: tiers, inlining, deoptimisation#
| Row | Detail |
|---|---|
| What it is | HotSpot, the standard JVM, first interprets your code. It compiles methods that run often with a quick compiler, C1, then recompiles the busiest with an optimising one, C2, guided by what it has seen. It makes bold guesses, and throws the compiled code away when one proves wrong: that is deoptimisation. |
| You need it when | Throughput changes minutes after start-up; a microbenchmark reports impossible numbers; the slowest requests spike and the garbage-collection logs do not explain it. |
| The C# instinct | .NET also recompiles hot methods using profile data: tiered compilation, with dynamic PGO on by default since .NET 8. What .NET does not do is HotSpot's deoptimisation, which is why a Java method's speed can change long after start-up. |
| Read | Aleksey Shipilev's blog is the reference; -XX:+PrintCompilation and JITWatch show it happening; always measure with JMH, never a hand-rolled loop |
The Java Memory Model and happens-before#
| Row | Detail |
|---|---|
| What it is | The formal rules for when a write by one thread is guaranteed to be visible to another. Everything about volatile, synchronized and the atomic classes follows from them. |
| 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 .NET memory model is weak on paper but stronger in practice, especially on x86. Java's model is precisely specified, and was fixed in Java 5, in 2004; 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 reflection. A MethodHandle points at a method, and the JIT can optimise a call through it like a normal call. A VarHandle reads and writes a field with a chosen memory ordering: plain, opaque, acquire/release or volatile. |
| You need it when | Reflection shows up as hot in a profiler; 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. A 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 build time or as they load. ByteBuddy is the easy library and ASM the low-level one; since Java 24, the Class-File API does the job inside the JDK itself. |
| You need it when | You are writing a Java agent, a mocking library or a monitoring tool, or you must add behaviour to a class you cannot change. |
| The C# instinct | IL weaving with Fody or Cecil, at build time. A Java agent rewrites classes as they load, which is how the OpenTelemetry Java agent adds tracing with no code change; .NET's zero-code instrumentation does the same through the CLR profiling API. |
| 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 input and output layer under every HTTP server and database driver: buffers, channels, selectors that watch many connections at once, and files mapped straight into memory. |
| You need it when | You are implementing a network protocol or a high-throughput file processor, or a driver misbehaves under load and you need to know why. |
| The C# instinct | Span, Memory and SocketAsyncEventArgs. ByteBuffer is clumsier: its position and limit change as you use it, a frequent source of bugs. MemorySegment, from the Foreign Function and Memory API, is the modern replacement. |
| Read | java.nio package documentation; Netty's source for how it is really used |
Platform#
These are parts of the platform most services never touch, until one day they must.
| Topic | You need it when | Read |
|---|---|---|
| JMX and MBeans | you must read or change a runtime value on a live JVM, or an operations tool demands it | javax.management docs; Actuator exposes endpoints over JMX too |
| ServiceLoader and the SPI pattern | you are building plug-in implementations found on the classpath; it is how JDBC drivers 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 memory outside the heap precisely | JEP 454; the modern alternative to JNI and, for off-heap memory, to ByteBuffer |
| Vector API | you have measurable number-crunching that suits SIMD instructions | JEP 508: still an incubator in Java 25, so not for production |
| Locale, charset and i18n | text is garbled, or sorting differs between environments | always set the charset explicitly; ICU4J for serious internationalisation |
Ecosystem, beyond one service#
These are the projects you reach for once there are many services, or once one service has grown large.
| 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 tests have become the slowest part of the build |
| 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 point worth carrying: Java's stale advice problem is severe. The language
is thirty years old, and search results do not sort by the version you are on. An answer that
was right in 2011 appears beside one that is right today, both stated with confidence. So check
the date and the Java version. Prefer a
JDK Enhancement Proposal (JEP) or the reference documentation
over a blog post. Some old guides still recommend overriding finalize(), which Java
18 deprecated for removal.
Appendix E: If your team uses Kotlin#
Kotlin is a second language for the Java Virtual Machine (JVM). It calls Java code and Java code calls it, and Spring Boot supports it as fully as Java. If you join a Kotlin team, the good news is that Kotlin is much closer to C# than Java is. It has properties, null checks built into the type system, extension functions, data classes, string interpolation and operator overloading, none of which Java has.
The bad news is that you still need the Java in this book. The libraries, the JVM, Spring, Maven and every behaviour at run time are exactly the same. Kotlin changes the syntax, not the platform.
What Kotlin gives back that Java took away#
Picture your first day on a Kotlin team. Much of what you missed in Java is back: each C# feature in this table has a direct Kotlin form, where Java has a workaround or nothing:
| 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 |
Here is one small piece of code in both languages: a customer type with a computed label, a changed copy, and a search that may find nothing:
public record Customer(string Name, int Age)
{
public string Label => $"{Name} ({Age})";
}
var ann = new Customer("Ann", 40);
var older = ann with { Age = 41 };
var customers = new List<Customer> { ann, older };
Customer? found = customers.FirstOrDefault(c => c.Name == "Bob");
string? name = found?.Name;data class Customer(val name: String, val age: Int) {
val label get() = "$name ($age)"
}
val ann = Customer("Ann", 40)
val older = ann.copy(age = 41)
val customers = listOf(ann, older)
val found: Customer? = customers.firstOrNull { it.name == "Bob" }
val name: String? = found?.namedata class Customer(val name: String, val age: Int) {A data class, Kotlin's record. The compiler writes
equals,hashCode,toStringandcopyfrom the properties in the header.valmakes each property read-only, and each type comes after its name.val label get() = "$name ($age)"A computed property, like a C# property written with
=>.$nameputs a value into the string, as{Name}does in C#.val older = ann.copy(age = 41)copywith a named argument does the job of C#'swith. Kotlin has nonewkeyword:Customer("Ann", 40)creates the object.val found: Customer? = customers.firstOrNull { it.name == "Bob" }Customer?is a type that may be null. The braces are a lambda, anditis its one parameter.val name: String? = found?.nameThe same safe call as C#'s
?., sonameis null here: there is no Bob. C# only warns when you use such a value unchecked; Kotlin refuses to compile the code.
So the code looks like C#. What runs it does not change at all.
The side-by-side panes in this book show C# and Java. On a Kotlin project the right-hand side changes, but the runtime facts do not. Type erasure, the equality contract and the traps of Jakarta Persistence (JPA) are unchanged. So are annotations that work through proxies, virtual threads, the class loader, tuning garbage collection (GC) and the whole of Spring. Kotlin is a different front end to the same platform.
Kotlin with Spring Boot#
Spring supports Kotlin properly rather than tolerating it, and Spring Boot 4 needs Kotlin 2.2
or later. Here is the shop's order controller written in Kotlin. It assumes an
OrderService whose find method returns an OrderDto?, null
when there is no such order, and an OrderNotFound exception that the application
turns into a 404 response:
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@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 command: CreateOrder): OrderDto =
service.create(command)
}class OrderController(private val service: OrderService) {The constructor is written in the class header.
private valalso keeps the parameter as a private property, so this one line is both the field and the constructor. Spring injects through it with no annotation, as it does with a Java class's only constructor.fun get(@PathVariable id: Long): OrderDto =fundeclares a function, and the return type comes after the parameters. The=introduces a body that is a single expression, like C#'s=>.service.find(id) ?: throw OrderNotFound(id)?:, the Elvis operator, uses the right-hand side when the left is null, like C#'s??. In Kotlinthrowis an expression, so it can sit there, as it can in C#.fun create(@RequestBody @Valid command: CreateOrder): OrderDto =The annotations are the same as in Java, written before each parameter.
A few Kotlin rules change how Spring behaves, and the build needs two compiler plugins:
| Concern | What to know |
|---|---|
| Constructor injection | constructor parameters in the class header; no annotation needed |
| Final by default | Kotlin classes and functions are final unless marked open, which breaks CGLIB proxies |
| kotlin-spring plugin | opens classes that carry Spring annotations, and their functions; essential |
| kotlin-jpa plugin | generates the no-argument constructor JPA needs on entities, but leaves them final; Hibernate's lazy loading needs them open too |
| Null safety across the boundary | Spring 7's JSpecify annotations tell Kotlin which values may be null |
| Coroutines in controllers | suspend functions work in both Spring MVC and WebFlux controllers |
Without the kotlin-spring compiler plugin, @Transactional
and every other proxy-based annotation fails, in one of two ways. A Kotlin class is
final unless marked open, and
Code Generation Library (CGLIB) proxies work by subclassing,
so start-up fails with Cannot subclass final class. Worse, if someone opens the
class by hand but not its functions, start-up succeeds. Each final function is left out of the
proxy, so its annotation silently does nothing. The only sign is a warning that the method
“cannot get proxied via CGLIB”.
It is the same failure described in How Spring actually works, arriving by a different route. Check the plugin first on any Kotlin Spring project where an annotation seems to be ignored.
Calling Java from Kotlin and back#
Kotlin's null checks stop at the edge of Kotlin code. Suppose the shop still has a Java class,
CustomerDirectory, whose findName method returns a customer's name, or
null when there is no such customer. Kotlin cannot tell which, so it trusts whatever type you
choose:
val directory = CustomerDirectory()
// Declared nullable, so the compiler makes you check the value before you use it.
val maybe: String? = directory.findName(2)
println(maybe?.length ?: 0)
// Declared non-null, so Kotlin checks the value here and throws if Java returned null.
val name: String = directory.findName(2)val maybe: String? = directory.findName(2)The safe choice.
maybe?.length ?: 0prints 0, because there is no customer 2.val name: String = directory.findName(2)This compiles without complaint, then fails on this very line with
NullPointerException: findName(...) must not be null. Kotlin adds that check wherever a value from Java meets a non-null type.
The other direction matters too. Java code sees Kotlin code through a few conventions:
| Direction | What happens |
|---|---|
| Kotlin calls Java | Java types arrive as "platform types": Kotlin does not know whether they can be null, and does not check |
| Java calls Kotlin | a data class is an ordinary class to Java; each val becomes a getX() method, and each var adds 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 the function is annotated @JvmOverloads |
| Top-level functions from Java | appear as static methods on a class named after the file, such as 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 in Kotlin code almost always came across a Java boundary.
Spring Framework 7 helps here: its signatures now carry
JSpecify annotations, and Kotlin reads them. A Java method
marked @Nullable cannot be assigned to a non-null Kotlin type at all, because the
compiler reports a type mismatch.
Those are the parts of Kotlin you will meet in your first weeks. Two more topics are folded
below: coroutines, Kotlin's answer to C#'s async and await, and whether
to choose Kotlin at all.
Coroutines against virtual threads#
Kotlin's way of waiting on the network came before virtual threads, and it looks much like
C#'s. Say a report needs a customer from one service and their orders from another, fetched at
the same time. customerClient and orderClient are clients for those
two services, and their calls are asynchronous:
async Task<Report> BuildReportAsync(long customerId)
{
Task<Customer> customer = customerClient.FindAsync(customerId);
Task<List<Order>> orders = orderClient.FindByCustomerAsync(customerId);
return new Report(await customer, await orders);
}import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
suspend fun buildReport(customerId: Long): Report = coroutineScope {
val customer = async { customerClient.find(customerId) }
val orders = async { orderClient.findByCustomer(customerId) }
Report(customer.await(), orders.await())
}suspend fun buildReport(customerId: Long): Report = coroutineScope {suspendmarks a function that can pause without blocking its thread, asasyncdoes in C#.coroutineScopewaits for everything started inside it, which is structured concurrency.val customer = async { customerClient.find(customerId) }asyncstarts the call and returns at once with aDeferred, Kotlin's counterpart of aTask. Both calls now run at the same time.Report(customer.await(), orders.await())awaitwaits for each result. If either call fails, the scope cancels the other, a job C# needs aCancellationTokenfor.
| 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 bring back the problem that virtual threads removed. Once a function is
suspend, every function that calls it must be suspend too, exactly as
async spreads through C# code. In exchange they give you structured concurrency that
has been stable for years, while Java's StructuredTaskScope is still a 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 comfortable sooner, and Java has closed much of the distance since version 8. Neither choice is wrong, and on an existing codebase it is rarely yours to make.
Kotlin brings back what C# has and Java lacks: val properties, types such as
String? that the compiler checks for null, "$name" templates, and data
classes with copy.
The platform does not change: the JVM, Spring, JPA and every runtime fact in this book apply to Kotlin as they are.
A Kotlin Spring project needs the kotlin-spring plugin; without it Spring cannot
subclass a final OrderService, and @Transactional fails.
A value from Java has a platform type, so declare it nullable, as in
val maybe: String? = directory.findName(2), or Kotlin throws at the
assignment.
Quick reference#
| Kotlin | What it means | C# | Java |
|---|---|---|---|
| val x = 1 | a read-only variable or property | readonly, or { get; } | final |
| var x = 1 | a variable or property you can change | a field, or { get; set; } | a field that is not final |
| String? | a type that may be null, checked by the compiler | string?, with warnings only | @Nullable, checked by tools |
| a?.b | null if a is null | a?.b | Optional.map |
| a ?: b | b when a is null | a ?? b | Objects.requireNonNullElse(a, b) |
| a!! | a, or a NullPointerException if it is null | a!, which does not check | Objects.requireNonNull(a) |
| "$name, ${a.b}" | a string template | $"{name}, {a.b}" | concatenation, or formatted() |
| data class | a class with equals, hashCode, toString and copy generated | record | record |
| c.copy(age = 41) | a copy with one property changed | c with { Age = 41 } | no equivalent |
| fun f(x: Int = 0) | a default argument | an optional parameter | one overload per case |
| when (x) { ... } | a switch expression | switch expression | switch expression |
| object Registry | a single instance, created on first use | a static class | an enum with one constant, or static members |
| companion object | members that belong to the class, not to an instance | static members | static members |
| suspend fun | a function that can pause without blocking | async Task | a plain method, run on a virtual thread |
| @JvmStatic, @JvmOverloads | make Kotlin code look natural when called from Java | - | - |
Appendix F: What that error means#
The fastest way to lose confidence in a new platform is a stack trace you cannot read. Each entry here gives the message as you will see it, what it means in plain words, and the fix. Many also have a small example that triggers the error, folded underneath. Paste an error into search (⌘K) and it lands here.
One distinction first. Java separates an Exception, which your code may
reasonably catch, from an Error, which means the
Java Virtual Machine (JVM) itself is in trouble. If the class
name ends in Error, catching it is almost always wrong.
Reading a Java stack trace#
Every entry below relies on one skill, so start there. Say the shop's checkout fails because
the payment provider never answers. OrderController calls
OrderService, which calls PaymentGatewayClient, and the program prints
this:
Exception in thread "main" java.lang.IllegalStateException: payment provider did not answer
at com.acme.payments.PaymentGatewayClient.charge(PaymentGatewayClient.java:20)
at com.acme.orders.OrderService.place(OrderService.java:7)
at com.acme.orders.OrderController.create(OrderController.java:6)
at com.acme.orders.Main.main(Main.java:8)
Caused by: java.net.SocketTimeoutException: Read timed out
at java.base/sun.nio.ch.NioSocketImpl.timedRead(NioSocketImpl.java:276)
at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:302)
at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:360)
at java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:802)
at java.base/java.net.Socket$SocketInputStream.implRead(Socket.java:984)
at java.base/java.net.Socket$SocketInputStream.read(Socket.java:974)
at java.base/java.net.Socket$SocketInputStream.read(Socket.java:968)
at com.acme.payments.PaymentGatewayClient.charge(PaymentGatewayClient.java:17)
... 3 moreException in thread "main" java.lang.IllegalStateException: payment provider did not answerThe first line names the exception, its message, and the thread it happened on.
at com.acme.payments.PaymentGatewayClient.charge(PaymentGatewayClient.java:20)The top frame is where the exception was thrown, with the file and the line. Each line below it is the method that called the one above, down to
main.Caused by: java.net.SocketTimeoutException: Read timed outThe exception that the first one wrapped. The last
Caused by:in a trace is usually the real problem: here, the payment provider did not answer in time.at java.base/sun.nio.ch.NioSocketImpl.timedRead(NioSocketImpl.java:276)Frames inside Java's own libraries. You can usually skip down to the first frame from your own code, here
PaymentGatewayClient.chargeat line 17.... 3 moreThe last three frames are the same as the last three printed above, so Java leaves them out.
| 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, left out to save space |
| Suppressed | an exception from close() that did not replace the primary one |
| java.base/ | the module the class came from, printed 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.
The entries below are grouped by where you meet them: start-up, run time, the database, the web, the JVM itself and the compiler.
Spring Boot will not start#
Read the summary above the stack trace first. Spring Boot prints a short,
readable explanation under a banner reading APPLICATION FAILED TO START, and it is
usually the whole answer. In the trace itself the useful line is at the
bottom, in the last Caused by:.
Web server failed to start. Port 8080 was already in use.#server.port=8081.What triggers it
java -jar target/app.jar &
java -jar target/app.jar # The second copy finds port 8080 already taken.NoSuchBeanDefinitionException: No qualifying bean of type 'com.acme.RatesClient' available#@Component or @Service. If it already has
one, move it into the application class's package, or one
below it, so that component scanning finds
it.What triggers it
class RatesClient { } // It has no annotation, so Spring never creates one.
@Service
class Pricing { Pricing(RatesClient rates) { } }Parameter 0 of constructor in com.acme.Pricing required a bean of type 'com.acme.RatesClient' that could not be found. Action: Consider defining a bean of type 'com.acme.RatesClient' in your configuration.#@Bean method that returns
one.UnsatisfiedDependencyException: Error creating bean with name 'pricing'#Caused by: line; it names the dependency that failed.The dependencies of some of the beans in the application context form a cycle#BeanCurrentlyInCreationException.What triggers it
@Service class Orders { Orders(Billing billing) { } }
@Service class Billing { Billing(Orders orders) { } }Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.#spring.datasource.url, or remove the starter until you need a
database.No qualifying bean of type 'com.acme.Pay' available: expected single matching bean but found 2: cardPayment,bankPayment#@Primary, or name the one you want with
@Qualifier("cardPayment") at the injection point.What triggers it
@Service class CardPayment implements Pay { }
@Service class BankPayment implements Pay { }
@Service class Checkout { Checkout(Pay pay) { } } // Which one? Spring cannot tell.Common runtime exceptions#
java.lang.NullPointerException: Cannot invoke "String.length()" because "c.name" is null#<local1> instead of its name.What triggers it
var c = new Customer(); // Nothing sets c.name, so it is null.
c.name.length();java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer#instanceof before casting, and look further back for a
raw type or an unchecked warning.What triggers it
Object o = "x";
Integer i = (Integer) o;java.util.ConcurrentModificationException#What triggers it
var names = new ArrayList<>(List.of("a", "b", "c"));
for (var n : names) names.remove(n); // This throws on the second time round.java.lang.UnsupportedOperationException#List.of or Arrays.asList.new ArrayList<>(List.of(...)).What triggers it
List.of(1).add(2);java.lang.NumberFormatException: For input string: "12a"#TryParse, so the parse
throws.NumberFormatException where the input comes from a user, or validate
it first.What triggers it
Integer.parseInt("12a");java.lang.ArrayStoreException: java.lang.Integer#Object[] really holds a narrower type, and a value
of the wrong type was stored in it.List<T>; generics are checked at compile time, arrays only at
run time.What triggers it
Object[] items = new String[1];
items[0] = 1;java.lang.IllegalStateException: stream has already been operated upon or closed#IEnumerable, which you can enumerate again.List once and reuse
that.What triggers it
var s = List.of(1, 2).stream();
s.toList();
s.toList(); // The second call throws.java.lang.ArithmeticException: / by zero#double gives
Infinity instead of throwing.double if Infinity is
acceptable.java.time.format.DateTimeParseException: Text '11/09/2026' could not be parsed at index 0#LocalDate.parse expects ISO format,
such as 2026-09-11.DateTimeFormatter with the right pattern; its letters differ from
.NET's, so check them.What triggers it
LocalDate.parse("11/09/2026");
LocalDate.parse("11/09/2026", DateTimeFormatter.ofPattern("dd/MM/yyyy")); // This works.Persistence#
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.acme.Order.items: could not initialize proxy - no Session#JOIN FETCH or
@EntityGraph, or map it to a
data transfer object (DTO) there; see
Lazy loading.What triggers it
Order order = orderService.find(7); // The transaction ends when find returns.
order.getItems().size(); // Too late: the session is already closed.jakarta.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread - cannot reliably process 'persist' call#EntityManager while no transaction was
running.@Transactional on the service method that makes the change.org.springframework.dao.DataIntegrityViolationException: could not execute statement#Caused by: line for the constraint's name, then fix the data or the
mapping.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect)#@Version check failed: someone else changed the row after you read
it.detached entity passed to persist: com.acme.Order#persist was called on an object that already has an id, so Hibernate
treats it as a row that already exists.merge, or save on a
Spring Data repository, which chooses
between the two. Do not set a generated id by hand.No identifier specified for entity: com.acme.Order#@Entity has no field marked @Id.@Id field, usually with @GeneratedValue.Web and JSON#
HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `int` from String "abc"#MethodArgumentNotValidException: Validation failed for argument [0]#@Valid request body, and Spring answers with a 400.@RestControllerAdvice; see field-level
errors.InvalidDefinitionException: No serializer found for class com.acme.Empty and no properties discovered to create BeanSerializer#UnrecognizedPropertyException: Unrecognized field "extra" (class com.acme.Order), not marked as ignorable#@JsonIgnoreProperties(ignoreUnknown =
true), or disable FAIL_ON_UNKNOWN_PROPERTIES on a hand-built
mapper.HttpMediaTypeNotSupportedException: Content-Type 'text/plain;charset=UTF-8' is not supported#Content-Type does not match what the endpoint
consumes.Content-Type: application/json, or widen the mapping's
consumes.MissingServletRequestParameterException: Required request parameter 'q' for method parameter type String is not present#@RequestParam was absent, and request parameters are required by
default.required = false or a
defaultValue.403 Forbidden on a POST, with an empty response body#Errors, not exceptions#
The first five are explained with a worked example in How Java runs your code.
java.lang.UnsupportedClassVersionError: com/acme/App has been compiled by a more recent version of the Java Runtime (class file version 69.0)#java.lang.NoClassDefFoundError: com/acme/Greeter#java.lang.ClassNotFoundException: org.postgresql.Driver#Class.forName or a class named in
configuration, found nothing. It is a
checked exception, unlike the error
above.java.lang.NoSuchMethodError: 'java.lang.String com.acme.Greeter.greet(java.lang.String)'#mvn dependency:tree to find the two versions, and pin one; see
the full entry.java.lang.ExceptionInInitializerError#NoClassDefFoundError: Could not initialize
class.Caused by: line for the real exception; see
the full entry.java.lang.IncompatibleClassChangeError: Expected static method 'void Util.go()'#NoSuchMethodError: build against, and ship, one
version.What triggers it
# App calls the static Util.go(). Then Util is changed and recompiled on its own.
javac Util.java App.java
sed -i '' 's/static //' Util.java && javac Util.java
java Appjava.lang.StackOverflowError#toString, equals or hashCode.java.lang.OutOfMemoryError: Java heap space#-XX:+HeapDumpOnOutOfMemoryError and find the leak
before raising -Xmx.What triggers it
var keep = new ArrayList<long[]>();
while (true) keep.add(new long[1_000_000]); // Run with -Xmx16m to see it quickly.java.lang.OutOfMemoryError: Metaspace#-XX:MaxMetaspaceSize so it fails early, then find what keeps loading
classes.java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached#Compiler messages#
error: cannot find symbol#symbol: and location: lines under it. In IntelliJ,
Alt+Enter, or ⌥↩ on a Mac, usually adds the missing
import.What triggers it
Lst<String> names; // The compiler reports "symbol: class Lst".error: unreported exception IOException; must be caught or declared to be thrown#throws to the method, or wrap it; see
Checked exceptions.What triggers it
void load() { Files.readString(Path.of("rates.json")); }error: incompatible types: possible lossy conversion from long to int#Math.toIntExact,
which throws if it does not.What triggers it
long total = 1;
int count = total;error: variable x might not have been initialized#What triggers it
int x;
if (ready) x = 1;
System.out.println(x);error: local variables referenced from a lambda expression must be final or effectively final#AtomicInteger or AtomicReference, or restructure so the
variable is assigned once.What triggers it
int n = 0;
Runnable r = () -> n++;error: non-static variable x cannot be referenced from a static context#main.static if it truly
belongs to the class.What triggers it
class App {
int x;
public static void main(String[] args) { x = 1; }
}error: class A is public, should be declared in a file named A.java#error: bad operand types for binary operator '>'#> or
< on strings or other objects.What triggers it
boolean later = name > "m";error: reached end of file while parsing#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, above all the classpath, start with How Java runs your code.
Appendix H: Java words in plain English#
This appendix explains each Java word the book uses, in plain words, and links to the section that teaches it. You do not need to read it in order. Search for a word, or come here when a word in a chapter is new to you.
Every entry gives the word, what it means in one or two sentences, and where the book explains it properly. Where Java uses a word differently from .NET, the meaning says so. Abbreviations are listed under their short form, such as JVM (Java Virtual Machine), with the full name first in the meaning.
| Term | What it means | Where it is explained |
|---|---|---|
| Actuator | The Spring Boot module that adds health, metrics and information endpoints to a service, such as /actuator/health. | Actuator endpoints |
| AMQP | Advanced Message Queuing Protocol: the wire protocol that RabbitMQ speaks. | RabbitMQ |
| annotation | A marker written with @ before a class, method, field or parameter, such as @Override. It is Java's version of a C# attribute. | Syntax |
| annotation processor | A plug-in the Java compiler runs during a build to read annotations and generate extra source files. It fills the role of a C# source generator. | Annotation processing vs source generators |
| annotation retention | How long an annotation survives: only in the source, in the class file, or at runtime, where reflection can read it. C# attributes are always there at runtime. | Retention: the concept C# lacks |
| AOP | Aspect-oriented programming: adding behaviour, such as logging or transactions, around many methods without editing each one. Spring does it with proxies. | AOP: writing your own cross-cutting behaviour |
| AOT | Ahead-of-time: work done when the application is built rather than when it runs, such as compiling to native code or preparing a startup cache. | GraalVM native-image |
| AOT cache | A file the JVM writes during a training run and reads on later starts, so classes load and link faster. It shortens startup without a native image. | Startup without going native |
| application context | Spring's container: the object that creates your beans, wires them together and hands them out. It plays the part of IServiceProvider. | The bean lifecycle |
| application event | A message one part of a Spring application publishes and other parts receive, in the same process, much like a MediatR notification. | Application events |
| application server | A large server, such as WildFly or WebSphere, that runs many deployed applications from web application archive (WAR) or enterprise archive (EAR) files. Spring Boot services usually embed their own server instead. | EJB and the application server |
| aspect | A class, marked @Aspect, that holds behaviour Spring applies around many methods, such as timing every service call. | AOP: writing your own cross-cutting behaviour |
| AssertJ | The assertion library most Java tests use, written assertThat(actual).isEqualTo(expected), much like FluentAssertions. | A test |
| @Async | A Spring annotation that runs a method on another thread, so the caller does not wait for it. It works only when the call goes through a Spring proxy. | @Async |
| atomic variable | A class such as AtomicInteger whose operations, like incrementAndGet, are safe from many threads at once without a lock, like Interlocked in .NET. | Atomics |
| auto-configuration | Spring Boot creating sensible beans for you, such as a database connection pool, because a library is on the classpath and you have not defined one yourself. | Auto-configuration |
| autoboxing | Java converting between a primitive, such as int, and its wrapper object, such as Integer, automatically. Unboxing a null wrapper throws a NullPointerException. | Wrapper types |
| @Bean | Marks a method in a configuration class whose return value Spring manages as a bean. Use it for types you cannot annotate yourself, such as a library's client. | Beans for types you do not own |
| bean | An object that Spring creates and manages, and can inject into other objects. It is what you would register in a .NET service collection. | Registration by annotation |
| bean scope | How many instances of a bean Spring makes: one for the whole application by default, called singleton, or a new one each time it is asked for, called prototype. | Lifetimes |
| Bean Validation | The Java standard for validation annotations such as @NotBlank and @Size, the counterpart of DataAnnotations. | Constraint annotations |
| BeanPostProcessor | An object Spring shows every bean to as it is created, which can change, wrap or replace it. Spring uses one to put proxies around beans with annotations such as @Transactional. | The bean lifecycle |
| BigDecimal | The Java class for exact decimal numbers, used for money. It is a class, so you add with add() and compare with compareTo() instead of + and >. | Money is BigDecimal |
| BOM | Bill of materials: a Maven file that fixes the versions of many related libraries at once, so they stay compatible. In a text file, BOM can also mean byte order mark. | Profiles and BOMs |
| Buildpacks | A tool that turns an application into a container image without a Dockerfile. Spring Boot's build-image task uses it. | Containers |
| bytecode | The instructions the Java compiler, javac, produces from your source code. The JVM runs bytecode, the way the .NET runtime runs IL. | From source to a running program |
| @Cacheable | A Spring annotation that stores a method's result, so a second call with the same arguments returns the stored value without running the method. | Caching |
| CAS | Compare-and-set: change a value only if it still holds what you expected, as one indivisible step. Atomic classes use it instead of a lock. | Atomics |
| CDS | Class data sharing: the JVM stores already-parsed classes in a file and maps it into memory at startup, so the program starts faster. | Startup without going native |
| CGLIB | Code Generation Library: the library Spring uses to create a proxy as a subclass of your class at runtime, when there is no interface to proxy. | Two kinds of proxy |
| charset | The encoding that turns text into bytes and back, such as UTF-8. Java 18 and later use UTF-8 by default; passing one explicitly keeps older code correct too. | Charsets, and why to always pass one |
| checked exception | An exception the compiler makes you handle: you must catch it, or declare it with throws. C# has nothing like it. | Checked exceptions |
| class file | A file ending in .class that holds the bytecode of one class. The compiler writes one class file for each class. | From source to a running program |
| class loader | The part of the JVM that finds a class the first time your code uses it, and reads in its bytecode. | Class loading, at the level you need |
| classpath | The ordered list of folders and Java archive (JAR) files the JVM searches when it needs a class. The first match wins. | The classpath is a search list |
| classpath resource | A file packaged with your classes, such as a configuration file or a template, read through the classpath rather than from a path on disk. | Resources live on the classpath too |
| CME | ConcurrentModificationException: thrown when a collection is changed while a loop is still going over it, even on a single thread. | Modifying while iterating |
| compact constructor | A record constructor written without a parameter list, used to check or adjust the values before they are stored. | Checking values: the compact constructor |
| Comparator | An object that decides the order of two values, like IComparer<T>. A class's own natural order comes from Comparable, like IComparable<T>. | Ordering |
| CompletableFuture | Java's closest match to Task<T>: a result that arrives later, with methods such as thenApply that chain more work onto it. | Translation |
| component scanning | Spring searching your packages at startup for classes marked @Component, @Service and similar, and registering each one as a bean. | Registration by annotation |
| @ConfigurationProperties | Binds a group of settings from application.yml to the fields of a class or record, the way IOptions<T> binds a section in ASP.NET Core. | IOptions becomes @ConfigurationProperties |
| constructor injection | Giving a class what it needs through its constructor parameters, which Spring fills in when it creates the class. It is the recommended style, and the one ASP.NET Core uses. | Registration by annotation |
| controller advice | A class of exception handlers that apply to every controller, used to turn exceptions into error responses in one place. | Global exception handling |
| coroutine | Kotlin's lightweight concurrency: a function marked suspend that can pause without blocking a thread, close to async and await. | Coroutines against virtual threads |
| CORS | Cross-origin resource sharing: the browser rule that decides whether a web page may call an API on another domain. The server allows it with response headers. | Middleware |
| CSRF | Cross-site request forgery: another site making a user's browser send a request with their cookies. Spring Security blocks it by default for requests that change data. | The filter chain |
| dependency scope | A setting on a Maven dependency that says when it is available: compile for everywhere, test for tests only, or runtime and provided for the cases between. | Coordinates |
| detached entity | An entity loaded by a persistence context that has since closed. Changes to it are no longer tracked, and its lazy fields can no longer load. | Lazy loading and detached entities |
| DI | Dependency injection: a class receives the objects it needs, usually through its constructor, instead of creating them. Spring and ASP.NET Core both do it. | Dependency injection and configuration |
| DSL | Domain-specific language: a small, focused way of writing one kind of thing, often as a chain of method calls, such as Spring Security's configuration. | The filter chain |
| DTO | Data transfer object: a simple class, often a record, that carries data in or out of an API without exposing your entities. | Records as DTOs |
| EAR | Enterprise archive: a file that packages several WAR and JAR files for an application server. You will only meet it in older systems. | EJB and the application server |
| EJB | Enterprise JavaBeans: the component model of the old Java EE application servers. Spring replaced it in most new code. | EJB and the application server |
| entity | A class marked @Entity that Jakarta Persistence maps to a database table, like an Entity Framework entity. | An entity |
| enum | A type with a fixed set of named values. In Java each value is a full object, so an enum can have fields, constructors and methods. | The shape of the difference |
| ExecutorService | An object that runs tasks on threads it manages, such as a fixed pool, or one virtual thread per task. You submit work and get a Future back. | Fixed thread pools |
| fat JAR | One JAR file that holds your application and every library it needs, so java -jar app.jar runs it. Spring Boot builds one by default. | The fat JAR |
| final field | final means it cannot change once set: a final field or variable is assigned once, a final class cannot be extended, and a final method cannot be overridden. | final is not readonly, quite |
| Flyway | A tool that runs numbered SQL migration scripts against the database at startup, the counterpart of EF Core migrations. Liquibase is the main alternative. | Migrations |
| fully qualified name | A class name with its package in front, such as com.acme.billing.Invoice. It is how the JVM finds the class file. | The classpath is a search list |
| functional interface | An interface with exactly one method to implement, such as Runnable or Function<T, R>. A lambda can stand in for it, the way a lambda fills a C# delegate. | Lambdas and method references |
| G1 | The JVM's default garbage collector on most machines, which balances short pauses against throughput. | Choosing a collector |
| gatherer | A custom step added to a stream with gather(), for operations the built-in ones lack, such as sliding windows. It is final since Java 24. | Gatherers |
| GC | Garbage collector: the part of the JVM that frees memory no object uses any more. The JVM offers several, such as G1 and ZGC. | Choosing a collector |
| GraalVM | A Java Development Kit (JDK) that can compile a Java application ahead of time into a native executable, which starts in milliseconds but limits reflection. | GraalVM native-image |
| Gradle | A Java build tool, and Maven's main alternative, whose build files are written in a small Groovy or Kotlin language rather than XML. | Gradle against Maven |
| happens-before | The Java memory model's rule for when one thread is guaranteed to see another thread's writes, for example after a lock is released or a volatile field is written. | volatile means more in Java |
| health indicator | A small class that reports whether one dependency, such as the database, is up. Actuator combines them into /actuator/health, like ASP.NET Core health checks. | Custom health checks |
| heap | The memory where the JVM keeps objects. Its maximum size is set with -Xmx, or as a share of a container's memory. | The memory model |
| Hibernate | The most widely used implementation of Jakarta Persistence, and the one Spring Boot uses by default. | The landscape |
| HQL | Hibernate Query Language: Hibernate's query language, a superset of the Jakarta Persistence Query Language (JPQL), written against entities rather than tables. | Repositories |
| initialiser block | A block of code in a class body that runs when the class is loaded, if it is static, or when an object is created. C# has static constructors instead. | Initialiser blocks |
| inner class | A class declared inside another class without static. Each instance holds a hidden reference to the outer object, unlike a nested class in C#. | 8. Inner classes capture the outer instance |
| Integer cache | Java keeps ready-made Integer objects for -128 to 127, so == on two small Integers can be true while the same code on larger numbers is false. | 2. The Integer cache |
| IoC | Inversion of control: the framework, not your code, creates objects and calls into them. Dependency injection is the most common form. | Registration by annotation |
| isolation level | How much one transaction may see of another transaction's unfinished changes, such as READ_COMMITTED. It is the same database idea as in .NET. | Isolation |
| J2EE | Java 2 Platform, Enterprise Edition: the old name for enterprise Java, later Java EE and now Jakarta EE. | Four eras |
| Jackson | The JSON library Spring Boot uses by default, the counterpart of System.Text.Json. | Jackson against System.Text.Json |
| Jakarta EE | The set of enterprise Java standards, such as Persistence, Validation and Servlet, formerly called Java EE. Its packages moved from javax to jakarta. | The javax to jakarta rename |
| JAR | Java archive: a zip file of class files and resources, usually with a manifest. It plays the part a .dll assembly plays in .NET. | Running with -cp or -jar |
| Java module | A named group of packages, described in a module-info.java file, that states what it exports and what it needs. Most applications never write one. | The problem it solves |
| java.time | The package of date and time types added in Java 8, such as LocalDate and Instant. Use it for all new code instead of Date and Calendar. | java.time |
| JAXB | Jakarta XML Binding: the standard for turning XML into Java objects and back. It left the JDK in Java 11 and is now a separate library. | What Java removed |
| JDBC | Java Database Connectivity: the low-level standard for talking to a database, like ADO.NET. Jakarta Persistence and Spring's JdbcClient are built on it. | The landscape |
| JDK | Java Development Kit: the compiler, the JVM and the tools you need to build and run Java, like the .NET SDK. | From source to a running program |
| JEP | JDK Enhancement Proposal: the numbered document that describes one change to Java, such as JEP 444 for virtual threads. | The release train |
| JFR | JDK Flight Recorder: a low-overhead recorder built into the JVM that captures what a running program is doing, for later analysis. | JFR: the tool with no .NET equivalent |
| JIT | Just-in-time compiler: the part of the JVM that compiles frequently run bytecode to machine code while the program runs, as .NET's RyuJIT does. | The JIT and warmup |
| jlink | A JDK tool that builds a trimmed Java runtime containing only the modules your application needs. | jlink and jpackage |
| JMH | Java Microbenchmark Harness: the standard tool for measuring small pieces of Java code, the counterpart of BenchmarkDotNet. | JMH against BenchmarkDotNet |
| JMS | Jakarta Messaging: a standard Java API for sending and receiving messages through a broker such as ActiveMQ. Kafka and RabbitMQ are usually used through their own Spring clients. | Messaging and WebSockets |
| JMX | Java Management Extensions: the JVM's built-in way to expose the metrics and operations of a running program to monitoring tools. | Actuator endpoints |
| JNDI | Java Naming and Directory Interface: a lookup service that old application servers used to hand out resources such as database connections. | Single values and precedence |
| JNI | Java Native Interface: the way Java code calls native C or C++ code, like P/Invoke in .NET. | When virtual threads are the wrong tool |
| JPA | Jakarta Persistence: the Java standard for mapping objects to database tables, the counterpart of Entity Framework. Hibernate implements it. | Data access, JPA and migrations |
| jpackage | A JDK tool that wraps an application and a Java runtime into a native installer, such as an .msi or a .dmg. | jlink and jpackage |
| JPMS | Java Platform Module System: the module system added in Java 9, which lets a library hide its internal packages. | Modules, JPMS and the missing internal |
| JPQL | Jakarta Persistence Query Language: SQL-like queries written against entities and their fields rather than tables and columns. | Repositories |
| JRE | Java Runtime Environment: a JVM and its libraries without the compiler. Oracle stopped shipping a separate one in Java 11, but some vendors, such as Temurin, still offer it. | From source to a running program |
| JShell | The Java read-eval-print loop (REPL): type a line of Java and see the result at once, without a class or a main method. | jshell: the REPL |
| JSP | JavaServer Pages: an old way of writing HTML pages with Java code inside them, like classic ASP. Modern Spring uses templates or a separate front end. | Web frameworks, in order of extinction |
| JSpecify | A standard set of annotations, such as @Nullable, that mark which values may be null, so tools can warn the way C# nullable reference types do. | JSpecify: the annotation standard |
| JSR | Java Specification Request: a numbered proposal for a Java standard, such as JSR 305, an older attempt at null annotations. | JSpecify: the annotation standard |
| JUnit | The standard Java testing framework, the counterpart of xUnit. A test is a method marked @Test. | A test |
| JVM | Java Virtual Machine: the program that loads bytecode and runs it, as the CLR runs IL. | From source to a running program |
| JWT | JSON Web Token: a signed token that carries a user's identity and claims, sent in the Authorization header. It works the same way as in ASP.NET Core. | Concept mapping |
| Kotlin | A language from JetBrains that runs on the JVM and uses Java libraries directly. Many C# developers find its syntax closer to C# than Java's. | What Kotlin gives back that Java took away |
| lambda | A short unnamed function written with an arrow, such as x -> x * 2. Java writes -> where C# writes =>. | Lambdas and method references |
| lazy loading | Loading an entity's related data only when you first touch it. If the persistence context has closed by then, Hibernate throws LazyInitializationException. | Lazy loading and detached entities |
| lifecycle phase | One step of a Maven build, such as compile, test or package. Running a phase runs every phase before it first. | The lifecycle |
| local repository | The folder ~/.m2/repository, where Maven keeps every library it downloads, like the NuGet global packages folder. | Where your dependencies actually live |
| Logback | The logging library Spring Boot uses by default. Your code logs through the Simple Logging Facade for Java (SLF4J), and Logback writes the output. | logback-spring.xml and JSON logging |
| Lombok | A library that generates getters, setters, constructors and similar code from annotations when you compile. Records remove much of the need for it. | A word on Lombok |
| LTS | Long-term support: a Java release that vendors keep patching for years. Java 21 and Java 25 are LTS releases. | The release train |
| manifest | A small text file inside a JAR, META-INF/MANIFEST.MF, that can name the class holding main and list other JARs to load. | Inside a JAR: the manifest |
| MapStruct | A library that generates object-to-object mapping code when you compile, the counterpart of AutoMapper. | MapStruct against AutoMapper |
| Maven | The most common Java build tool. It reads a pom.xml file, downloads the libraries listed there, then compiles, tests and packages the project, like MSBuild with NuGet. | pom.xml against csproj |
| Maven coordinates | The three values that identify a library in Maven: groupId for the organisation, artifactId for the library, and version, like a NuGet package id and version. | Coordinates |
| Maven profile | A named set of settings in pom.xml that you switch on for some builds only, with mvn -P name. | Profiles and BOMs |
| Maven wrapper | The mvnw script in a project, which downloads and runs the right Maven version, so everyone builds with the same one. | The wrapper |
| MDC | Mapped diagnostic context: key and value pairs, such as a request id, attached to the current thread so every log line includes them, like ILogger scopes. | Logging |
| metaspace | The memory, outside the heap, where the JVM keeps class definitions. Loading a great many classes can exhaust it. | The memory model |
| method reference | A short way to pass an existing method where a lambda is expected, written Type::method, such as String::length. | Lambdas and method references |
| Micrometer | The metrics library Spring Boot uses, the counterpart of System.Diagnostics.Metrics. It sends counters and timers to Prometheus and other systems. | Metrics |
| Mockito | The standard Java library for mock objects, the counterpart of Moq. | Mocking |
| MockMvc | A Spring test helper that sends fake HTTP requests to your controllers without starting a server. | Testing a controller |
| module path | The list of modules the JVM loads when the module system is switched on: the module-aware counterpart of the classpath. | Classpath vs module path |
| MVC | Model-view-controller: the pattern behind Spring MVC, Spring's web framework for controllers, the counterpart of ASP.NET Core MVC. | Controllers, routing and binding |
| N+1 problem | Loading a list with one query, then running one more query for each item to fetch its related data. It is the same trap as in Entity Framework. | The N+1 problem |
| NPAPI | Netscape Plugin API: the old browser plug-in interface that Java applets needed. Browsers removed it, which ended applets. | Web frameworks, in order of extinction |
| NPE | NullPointerException: thrown when code uses a null reference. It is Java's NullReferenceException. | The gap, stated plainly |
| OIDC | OpenID Connect: the standard login protocol built on OAuth 2.0, used with identity providers such as Entra ID or Keycloak. | Concept mapping |
| OOM | OutOfMemoryError: thrown when the JVM cannot get more memory of some kind, such as heap or metaspace. It is an Error, not an Exception. | Errors, not exceptions |
| OpenTelemetry | The open standard for traces, metrics and logs across services. Spring Boot and .NET both support it. | Tracing |
| Optional | A container that holds a value or nothing, used as a return type to say a result may be missing. It is not a replacement for every null. | Optional is for return values |
| ORM | Object-relational mapper: a library that maps classes to database tables, such as Hibernate or Entity Framework. | The landscape |
| @Override | An annotation that says a method replaces one from a parent class or interface. The compiler reports an error if nothing is actually overridden. | Ones you will see constantly |
| package | A named group of classes, such as com.acme.billing. It is like a C# namespace, but its folders are expected to match the name, and it is also an access boundary. | Packages are not namespaces |
| package-private | Java's default access when you write no modifier: visible to every class in the same package, and nowhere else. | The mapping |
| PECS | Producer extends, consumer super: the rule for wildcards. Use ? extends T for a collection you read from, and ? super T for one you add to. | Variance is at the use site |
| permits | The clause in a sealed class or interface that lists the only types allowed to extend it. | The rules for permits |
| persistence context | The set of entities one EntityManager is tracking, like a DbContext's change tracker. It usually lasts as long as one transaction. | Lazy loading and detached entities |
| pinning | A virtual thread staying stuck to its carrier thread while it blocks, so the carrier cannot run other work. Java 24 removed the main cause, synchronized blocks. | When virtual threads are the wrong tool |
| platform thread | An ordinary Java thread backed one-to-one by an operating system thread, as every thread was before virtual threads. | The colour problem, and how Java sidestepped it |
| pointcut | An expression that picks which methods an aspect wraps, such as execution(public * *(..)) for every public method. | AOP: writing your own cross-cutting behaviour |
| POJO | Plain old Java object: an ordinary class with fields, getters and setters, and no framework base class. | The property gap |
| POM | Project Object Model: the pom.xml file that describes a Maven project, the counterpart of a .csproj file. | pom.xml against csproj |
| preview feature | A language or library feature that is finished but not yet final, and may still change. You must switch it on with --enable-preview to use it. | What it targets |
| primitive stream | A stream of int, long or double values, such as IntStream, which avoids boxing each number and adds methods like sum(). | Primitive streams |
| ProblemDetail | Spring's class for an RFC 9457 error response body, the counterpart of ASP.NET Core's ProblemDetails. | ProblemDetail |
| proxy | An object Spring puts in front of your bean that adds behaviour, such as a transaction, before calling your method. Calls from inside the bean skip it. | What a proxy actually is |
| R2DBC | Reactive Relational Database Connectivity: a non-blocking database API for reactive applications, the reactive counterpart of Java Database Connectivity (JDBC). | When it is still the right answer |
| raw type | A generic type used without its type argument, such as List instead of List<String>. It exists for old code and switches off type checking. | Raw types |
| reactive | A style where code describes a pipeline of asynchronous events, using Reactor's Mono and Flux types. Spring WebFlux is built on it. | The model |
| record | A short class that holds data, with equals, hashCode, toString and accessors generated, like a C# record. Its fields cannot be reassigned. | The basics |
| record pattern | A pattern that takes a record apart into its components while matching it, such as case Point(int x, int y). | Record patterns |
| ReentrantLock | A lock object that means the same as synchronized, with extras such as tryLock with a timeout. | ReentrantLock |
| relaxed binding | Spring Boot matching a setting to a property even when the name is written differently, so rates.timeout-seconds, rates.timeoutSeconds and the environment variable RATES_TIMEOUT_SECONDS all set the same value. | IOptions becomes @ConfigurationProperties |
| release train | Java's schedule of a new version every six months, in March and September, with an LTS release every two years. | The release train |
| REPL | Read-eval-print loop: a prompt where you type code and see the result at once. JShell is Java's. | jshell: the REPL |
| RMI | Remote Method Invocation: Java's old way of calling methods on objects in another process, like .NET Remoting. It relies on Java serialization. | Java's built-in serialization, and why to avoid it |
| rollback rule | The rule for which exceptions undo a Spring transaction: unchecked exceptions do by default, checked exceptions do not. | The rollback rule |
| SAM | Single abstract method: an interface with exactly one method to implement, which a lambda can stand in for. Also called a functional interface. | Lambdas and method references |
| @Scheduled | A Spring annotation that runs a method on a timer or a cron schedule, like a hosted background service in .NET. | Scheduling |
| ScopedValue | A value made available to a block of code and every method it calls, including threads started in a structured scope. It replaces AsyncLocal-style context. | ScopedValue replaces AsyncLocal |
| SDKMAN | A command-line tool that installs Java versions and switches between them. | Installing and switching |
| sealed type | A class or interface that lists every type allowed to extend it, so a switch over it can cover every case. | Sealed types: Java's discriminated unions |
| self-invocation | A bean calling one of its own methods. The call skips the Spring proxy, so annotations such as @Transactional on that method do nothing. | Why self-invocation fails |
| sequenced collection | A collection with a defined order and methods such as getFirst() and reversed(), added in Java 21. | Sequenced collections |
| servlet | The Jakarta standard for handling an HTTP request in Java. Spring MVC runs on top of it, and a servlet filter plays the part of ASP.NET Core middleware. | Middleware |
| SLF4J | Simple Logging Facade for Java: the logging interface Java code writes to, like ILogger, while a library such as Logback does the writing. | SLF4J and Logback against ILogger and Serilog |
| SpEL | Spring Expression Language: small expressions inside annotations and configuration, written #{...}. | SpEL |
| SPI | Service provider interface: an interface a library defines for others to implement, with the implementations found at runtime from files in META-INF. | Auto-configuration |
| Spring Data repository | An interface you declare, such as OrderRepository extends JpaRepository<Order, Long>, for which Spring generates the data-access code. | Repositories |
| Spring profile | A named set of configuration, such as dev or prod, that Spring switches on at startup, like an ASP.NET Core environment. | Profiles are environments |
| starter | A Spring Boot dependency that pulls in a group of libraries for one job, such as spring-boot-starter-web for a web API. | Starters |
| static import | An import that brings a class's static members into scope, so you can write max(a, b) instead of Math.max(a, b), like C#'s using static. | Imports |
| STOMP | Simple Text Oriented Messaging Protocol: a simple messaging protocol that Spring runs over WebSockets for publish and subscribe. | WebSockets and the SignalR gap |
| stream collector | The object that turns a stream's elements into a final result, such as a list or a map, when you call collect(). Collectors.toList() is one. | Collectors |
| stream pipeline | A chain of operations on a sequence, such as filter, map and collect, run once when the final operation is called. It is Java's counterpart of LINQ to Objects. | The shape |
| structured concurrency | Running related tasks in a scope that ends only when they all finish, and cancels the rest if one fails. It is a preview feature in Java 25. | The problem it solves |
| switch expression | A switch that returns a value, written with case X -> result, like a C# switch expression. It must cover every possible case. | The arrow form |
| synchronized | A keyword that lets only one thread at a time run a block or method for a given object, like C#'s lock statement. | Mutual exclusion |
| system property | A key and value passed to the JVM with -Dname=value, and read with System.getProperty. It is separate from environment variables. | System properties and environment variables |
| TCK | Technology Compatibility Kit: the test suite a JDK must pass to be called Java compatible, which is why the major distributions behave the same. | Which distribution |
| Temurin | A free, widely used JDK distribution from the Eclipse Adoptium project. | Which distribution |
| test slice | A Spring Boot test that starts only one layer of the application, such as the controllers or the repositories, so it runs faster. | Test slices |
| Testcontainers | A library that starts real databases and message brokers in Docker containers for a test run. It exists for .NET too. | Testcontainers |
| text block | A multi-line string written between three double quotes, much like a C# raw string literal. | Text blocks |
| ThreadLocal | A variable with a separate value for each thread. Scoped values are the modern way to pass context along. | ThreadLocal |
| transaction propagation | What a transactional method does when a transaction is already running: join it, which is the default, start a new one, or refuse to run. | Propagation |
| @Transactional | A Spring annotation that runs a method inside a database transaction: committed when it returns, rolled back when it throws an unchecked exception. | Transactions |
| try-with-resources | A try statement that opens resources in brackets after try and closes them automatically at the end, like C#'s using statement. | try-with-resources |
| type erasure | Java removing generic type arguments when it compiles, so at runtime a List<String> is just a List. C# keeps them. | What erasure takes away |
| type pattern | A type check and a cast in one step, such as if (o instanceof String s), like C#'s is string s. | Type patterns |
| unchecked exception | An exception the compiler does not make you catch: RuntimeException and its subclasses. Every C# exception behaves this way. | Checked exceptions |
| unnamed variable | The underscore _, written in place of a variable or a pattern component you do not need, like C#'s discard. Final since Java 22. | Nested patterns and unnamed variables |
| var | A keyword that lets the compiler work out a local variable's type from its value, exactly as in C#. | var |
| varargs | A last parameter written Type... name, which accepts any number of arguments, like C#'s params. | Varargs |
| virtual thread | A lightweight thread the JVM manages. You can run millions of them, so plain blocking code scales without async and await. | Creating and running them |
| volatile | A field modifier that makes every write visible to other threads at once and stops reordering around it. It does not make ++ safe. | volatile means more in Java |
| WAR | Web application archive: a JAR-like file for deploying a web application to an application server. Spring Boot services usually run as a JAR instead. | EJB and the application server |
| warmup | The first seconds or minutes after startup, while the just-in-time (JIT) compiler is still turning busy code into fast machine code. | The JIT and warmup |
| when guard | A condition added to a pattern with when, such as case Card c when c.last4().equals("0000"). The arm matches only if the pattern matches and the condition is true, as in C#. | Pattern matching in switch |
| wildcard | A ? in a generic type, such as List<? extends Number>, meaning some unknown type. It gives Java the flexibility C# gets from in and out. | Variance is at the use site |
| wither | A method such as withQuantity(3) that returns a copy of a record with one component changed. Java has no with expression, so you write withers yourself. | The missing with expression |
| wrapper type | The object version of a primitive, such as Integer for int. Collections and generics need wrappers, because they cannot hold primitives. | Wrapper types |
| yield | The keyword that returns a value from a block inside a switch expression. It has nothing to do with C#'s yield return. | Multi-statement arms |
| ZGC | A JVM garbage collector designed for very short pauses, even on large heaps. | Choosing a collector |
End of the book