Java for C# Developers#
A working handbook for .NET engineers moving to Java and Spring Boot
Every chapter gives its answer first. The first read takes about 3 hours 45 minutes. Coming back takes less time. Use the tours below, and the 163 review questions. Review asks a question again if you missed it.
Start reading Java in 19 min Spring Boot in 17 min Modern C# in 10 min The one-page card
October 2026 edition
The facts were verified 9 September to 4 October 2026. This copy was built 4 October 2026. The edition says how current the content is. The build date does not say that.
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 languages compile your code to an intermediate form. In Java, this form is called bytecode. In .NET, it is called IL. Both languages free unused memory with a garbage collector. C# took much of its design from Java, so the two languages are alike on purpose.
Everything around the language takes weeks. You have to learn these things:
- which Java Development Kit (JDK) to install
- why the build tool works so differently from MSBuild
- why an annotation did nothing and reported no error (an annotation is like an attribute in C#)
- why there is no
await - why a method you did not mark
virtualwas overridden anyway
This book is about those things.
Who it is for#
You write C#, and you have used .NET 8. You are moving to Java, most likely to work on a Spring Boot service. You do not need to know any Java, and you do not need to have used C# 13 or C# 14. Every Java word is explained where a chapter first uses it.
The book also assumes that you forget things, and that you lose interest in a long book. So it gives each answer first, and it asks you questions instead of repeating itself.
How to read it#
Every chapter starts with a table. The table lists the differences between C# and Java for the topic of the chapter. Each row has a mark:
| Mark | Means | What to do |
|---|---|---|
| same | it works as it does in C#, so nothing changes | read the row and move on |
| twist | the same idea as in C#, written differently | read the row, and open the section only if you want the detail |
| trap | your C# habit gives the wrong result | Read the section. Most traps ask you to predict the result first. |
| new | C# has nothing like it, so you have no C# habit to use | read the section |
Some traps show code with a question, such as "What does this print?". Decide on your own answer before you open the answer in the book. Being wrong for a moment helps you remember the right answer. Each chapter ends with one to three questions for the same reason.
The book has three reading modes and a Reference view. You choose one with the buttons in the sidebar:
| Mode | Shows | Takes |
|---|---|---|
| Essentials | every difference, and the explanation of the traps and the new ideas in the main chapters | about 3 hours 45 minutes |
| Everyday | also the detail behind each twist | about 7 hours 25 minutes |
| Complete | everything, including legacy code and JVM internals | about 10 hours |
| Reference | only each chapter's table of differences, with the tours and the appendices | a quick pass |
Start with Essentials. No reading mode hides anything. Folded material is shown as a one-line heading that you can open. Fourteen chapters cover things that you look up when you need them, such as Gradle, messaging and reactive code. Each of these chapters is marked as a look-up chapter, and Essentials shows only its table.
If you start work on a Spring Boot service on Monday, read the parts Day one and Language core. Then go directly to the chapter Spring Boot orientation. If your data is in Amazon DocumentDB, your data chapter is Document data: Amazon DocumentDB and the MongoDB API. That chapter depends on a few sections of the relational chapter before it. The relational chapter says which sections.
To come back later, use the Refresh links in the sidebar. The links lead to three tours, and each tour is commented code. The three tours are Java in 19 minutes, Spring Boot in 17 minutes and Modern C# in 10 minutes. Review asks you the book's 163 questions one at a time. It asks a question again if you missed it. The Java tour, the Spring Boot tour and one pass of Review take about 1 hour 30 minutes together.
To find one fact, press ⌘K or Ctrl+K. Then type a
C# name, or paste an error message. For example, IEnumerable,
IOptions or the first line of a stack trace takes you directly to its answer.
Some comparisons use a C# feature that you may have forgotten. Beside such a comparison, there is a one-line C# refresher. The
refresher is marked with the C# version of the feature.
An appendix at the back says what the book targets, how its facts were checked, and what the book does not cover. See the appendix About this book.
Why Java feels familiar#
C# and Java look alike. Curly braces, classes, interfaces, generics, lambdas and garbage collection all work in much the same way in both languages. That resemblance is real, and it is also the danger. The resemblance makes it easy to write Java as if it were C#.
| Kind | C# | Java | Note |
|---|---|---|---|
| same | var order = new Order(customer, lines) | var order = new Order(customer, lines) | classes, constructors, exceptions, generics and var all work the same way in Java |
| trap | sealed class | final class | Nothing may extend the class. Java has a sealed keyword too, and it means something else. Why |
| twist | readonly field | final field | assigned once, in the declaration or in the constructor Why |
| twist | OrderService(IOrderRepository repository) | a written-out constructor | Java classes have no primary constructors Why |
| twist | ArgumentException | IllegalArgumentException | the standard exception for a bad argument value Why |
| twist | foreach (var line in lines) | for (var line : lines) | C# writes foreach and in, but Java writes for and a colon Why |
| new | async Task<Order> PlaceAsync() | Order place(), run on a virtual thread | blocking is cheap on a virtual thread, so there is no async Why |
| twist | Total { get; set; } | getTotal() and setTotal() | Java has no properties, but a record gives you accessors instead Why |
| trap | no modifier means private | no modifier means package-private | visible to the whole package, which surprises C# developers Why |
| new | File.ReadAllText(path), with nothing declared | throws IOException | a checked exception must be caught, or listed after throws Why |
| twist | typeof(T) | no equivalent | type arguments are erased when Java compiles, so a list does not know what it holds Why |
| trap | Visual Studio's Build menu, with MSBuild hidden behind it | Maven or Gradle runs the build | the build is run by a separate tool, rather than by the IDE Why |
| trap | protected | protected | The keyword is the same, but protected in Java allows more callers than protected in C#. The next chapter asks which callers. Why |
| twist | .NET, one product from one vendor | a specification, and a JDK from any of several vendors | the web framework, the ORM and the test runner are third-party choices Why |
| twist | IOrderRepository | OrderRepository | Java code drops the I prefix on interface names Why |
| twist | namespace Acme.Billing | package com.acme.billing | the package should match the folder, and the file is named after its public class Why |
One class, side by sidetwist#
// 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.");
foreach (var line in lines)
if (line is null)
throw new ArgumentException("A line is missing.");
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.");
for (var line : lines)
if (line == null)
throw new IllegalArgumentException("A line is missing.");
var order = new Order(customer, lines);
repository.save(order);
return order;
}
}public final class OrderServicefinalon a class means the same assealedon a class in C#. No other class may extend the class. Java has asealedkeyword too, but it means something else.private final OrderRepository repository;A final field is assigned once, in the constructor, like a C#
readonlyfield.public OrderService(OrderRepository repository)Java classes have no primary constructors, so the constructor is written out. Most Java services use a framework called Spring. Spring passes the repository to the constructor, as the ASP.NET Core container would.
throw new IllegalArgumentExceptionIllegalArgumentExceptiondoes the same asArgumentExceptionin C#.for (var line : lines)This loop does the same as
foreachin C#. Java writes the keywordfor, and it writes a colon in place ofin.var order = new Order(customer, lines);varworks as it does in C#.repository.save(order);There is no
await, and the method returnsOrder, notTask<Order>. The call waits until the save has finished. For the reason, see the chapter Threads are cheap now.
The six things that will actually cost you timetrap#
This invoice class shows four of the six things. Its repository field is not
shown.
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. A private field holds the value, and the method
getTotalreads the field.String loadTemplate(Path path)In C#, a method with no modifier is private. In Java, a method with no modifier is package-private. This means that every class in the same package can call the method.
throws IOExceptionFiles.readStringcan fail withIOException.IOExceptionis a checked exception, so you must catch it or list it afterthrows.public void save() { repository.save(this); }The method has no
asyncand noTask, so the call blocks. Blocking is cheap on a virtual thread, which is a lightweight thread.
The other two things are not in the class. The first is type erasure, which removes generic type arguments when Java compiles. The second is the build, which Maven or Gradle runs.
C# refresherReified generics: the type argument exists at run timeC# 2
In .NET, a generic type keeps its type argument at run time. So code can ask for
typeof(T). Code can also 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 checkedThis table lists all six things, each with its chapter:
| 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, properties and immutability |
| Default access is private | Default access is package-private | Access modifiers 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 runs the build, outside the IDE | Maven vs csproj |
These six links go to the chapters in the table, in the same order. They are exceptions, generics, properties, access, threads and the build.
protected is not the same keywordtrap#
protected does not mean what you think. In C#,
protected means “this class and its subclasses”. In Java,
protected allows more callers, and nothing warns you.
public class Order
{
protected void Recalculate() { }
}public class Order {
protected void recalculate() { }
}In C#, only Order and its subclasses can call Recalculate(). The
Java method looks the same, but more classes can call it. To predict which classes can call
it, see the chapter The week-one gotchas.
A mental model for the platform#
.NET is one product. Microsoft ships all of these parts, and versions them together:
- the runtime
- the base class library
- the web framework
- the object-relational mapper (ORM)
- the dependency injection (DI) container
- the test runner
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, but the Java counterparts rarely come 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, and it is the unit you ship |
| Solution | Multi-module build | one parent pom lists the child modules |
| ASP.NET Core | Spring Boot | not shipped with Java, so it is a third-party dependency |
| Entity Framework Core | Hibernate / Spring Data JPA | also a third-party dependency, because the JDK ships no ORM |
| xUnit + Moq | JUnit 5 + Mockito | also not shipped with Java, and Java has 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 the largest part of this book is about Spring Boot. But Spring Boot remains a third-party choice. Quarkus and Micronaut are credible alternatives.
So a Java web service begins by choosing its framework. You list the framework as dependencies in the build file. ASP.NET Core arrives with the .NET SDK. But these dependencies 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
group and the artifact are the two parts of a
Maven name.
spring-boot-starter-web is called a
starter. It is one dependency that adds Spring's web
framework and a web server. spring-boot-starter-data-jpa adds
Hibernate, which is 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 C# community and the Java community chose 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, written in the style of each community:
// 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 the line package com.acme.billing; and it is in the
folder com/acme/billing/. The file name matches the public class inside the file.
The interface name has no I prefix. Method names and field names start with a
lower-case letter. The field name has no underscore.
LegacyThe Impl suffix
Older Java codebases pair every interface with a single implementation named
OrderServiceImpl. You will see this naming often, especially in Spring code
written before about 2015. This naming is no longer recommended. If there is exactly one
implementation, you usually do not need the interface at all. If you do need the interface,
name the implementation for what makes it different, such as JdbcOrderRepository
or InMemoryOrderRepository.
1An expert asksA C# file can be in any folder and hold several public types. What does Java
expect of the file that holds com.acme.billing.OrderService, and why?
com.acme.billing.OrderService, and why?OrderService.java.
The file is in the folder that matches its package, which is com/acme/billing/.
The build tools and the Java Virtual Machine (JVM) find
classes by that layout. So the freedom of a C# namespace does not apply in Java.2TranslateWrite this in Java:
public class OrderService(IOrderRepository repository)
{
public Order Place(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);
repository.Save(order);
return order;
}
}
public class OrderService(IOrderRepository repository)
{
public Order Place(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);
repository.Save(order);
return order;
}
}public 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;
}
}A Java class has no primary constructor, so the field and the constructor are written
out. The field is final, which does the same as readonly in C#. So
nothing can replace the repository later.
3An expert asksA Java developer tells you that Java is a specification, not a product. What
does that change for a service you build?
The week-one gotchas#
Each item here is a few lines of Java where a C# habit fails. Most items behave differently from the same lines in C#. Two items behave the same, but the C# fix does not exist in Java. You do not need to know Java yet. Read each item as C#, and predict the result before you look.
The answers, in one tableopen it after you have predicted
| Kind | C# | Java | Note |
|---|---|---|---|
| trap | a == b on strings | a.equals(b) | == compares references in Java, even for String Why |
| trap | a == b on two int or int? values | a.equals(b) on two Integer values | an Integer from -128 to 127 is a shared object, so == seems to work on small values Why |
| trap | decimal | BigDecimal | Java has no decimal type, and a double sum is inexact. Money is a BigDecimal, with methods in place of operators. Why |
| trap | counts["missing"] | counts.getOrDefault("missing", 0) | get returns null, which throws when unboxed to int Why |
| trap | expecting a copy | subList, keySet and Arrays.asList return views | a view shares the original's data, so a change to one shows in the other Why |
| trap | a field with no modifier | a private field | Java's default is package-private, not private Why |
| trap | protected | protected, which also opens the package | Java cannot say subclass-only, so the whole package gets access too Why |
| trap | virtual, to opt in | final, to opt out | every Java method can be overridden unless it is marked final Why |
| trap | a nested class | a static nested class | without static, a nested class holds a hidden reference to its outer object Why |
| trap | switch on a null string | case null -> ... | a switch on a null String or enum throws unless it has case null Why |
| trap | DateTime | java.time, never Date or SimpleDateFormat | SimpleDateFormat is not thread-safe, and a Date can be changed after it is created Why |
| trap | return in finally does not compile | return in finally compiles | it discards the exception that was leaving the try block, and reports no error Why |
| twist | checked(a + b) | Math.addExact(a, b) | Overflow wraps, as it does by default in C#. Java has no checked, so a method throws instead. Why |
| trap | a lambda that throws | a lambda that wraps the exception | a lambda passed to map cannot throw a checked exception such as IOException Why |
| same | object[] o = new string[2] | Object[] o = new String[2] | both compile, and both fail only when the wrong type is stored Why |
== on objectstrap#
The folded table before this section holds every answer. Open it after you have predicted, not before.
System.out.println does the same as Console.WriteLine in C#. Most
of the short examples in this chapter run without changes in
jshell. This is a prompt that runs each line as you type
it. To start jshell, see the next chapter.
String a = "mug";
String b = readProductId(); // also "mug", read from an incoming order
System.out.println(a == b);Show the answer
In C#, == on two strings compares their text. In Java, == asks
whether the two variables point to the same object. This is true for every object type. Here
b was built while the program ran, so it is a different object from
a. The comparison often appears to work, because Java reuses one object for
identical string literals.
Use a.equals(b), or Objects.equals(a, b) when either side may be
null. See Equality, hashing and comparison.
The Integer cachetrap#
Integer is the object version of int. It is called the
wrapper type of int. Java uses
Integer wherever an object is needed, such as in a list. For more about wrapper
types, see the chapter Numbers, money and time.
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b);
System.out.println(c == d);Show the answer
Java shares one object for each boxed integer from −128 to 127. So a and
b are the same object. But c and d are two separate
objects, and == compares objects, not numbers. Tests with small numbers pass, and
production fails. In C#, == on two int or int? values
compares the numbers.
Compare boxed values with c.equals(d), which is true.
There is no decimaltrap#
System.out.println(0.1 + 0.2);Show the answer
C# prints the same result. The trap is that Java has no decimal type to use
instead. A double cannot hold 0.1 exactly, so the sum is slightly wrong. For
money, Java uses the class BigDecimal. C# uses operators on a
decimal. Java calls methods on a BigDecimal.
decimal total = 0.1m + 0.2m; // 0.3 exactly
bool over = total > limit;BigDecimal total = new BigDecimal("0.1").add(new BigDecimal("0.2")); // 0.3
boolean over = total.compareTo(limit) > 0; // no > operator for BigDecimalBuild a BigDecimal from a string. Add with the method add. Compare
with the method compareTo, which returns a negative number, zero or a positive
number. Here limit is another amount. Using double for money is the
most common correctness bug that a C# developer brings to Java. See
Numbers, money and time.
A missing key in a maptrap#
In Java, a dictionary is called a map. The interface is Map, like
IDictionary in C#. The class is HashMap, like
Dictionary in C#.
The code below writes new HashMap<>() with empty angle brackets. The
empty brackets are called the diamond. They tell the compiler to
take the type arguments from the variable's declared type. For more about maps, see the chapter
Collections.
Map<String, Integer> counts = new HashMap<>();
int n = counts.get("missing");Show the answer
get returns null for a missing key. Assigning null to
an int unboxes it, and unboxing
null throws. The exception comes from a line that shows no method call. In C#,
counts["missing"] throws KeyNotFoundException, which is a clearer
failure.
Use counts.getOrDefault("missing", 0).
Views are not copiestrap#
Arrays.asList makes a list from the values you pass to it. It is in the
package java.util. You cannot use square brackets on a Java list. To change the first
item, C# writes numbers[0] = 9. Java writes numbers.set(0, 9). For
more about lists, see the chapter Collections.
var numbers = Arrays.asList(1, 2, 3);
numbers.set(0, 9);
numbers.add(4);Show the answer
Arrays.asList returns a view of an array, not a copy. So
set changes the array itself. And add throws, because an array
cannot grow.
These methods return views too:
subListkeySet,valuesandentrySetreversed()
Changing a view changes its source. For an example, see the section
subList and reversed. When you need a copy, make one with
new ArrayList<>(view).
No modifier means package-privatetrap#
In C#, a member with no access modifier is private. In Java, a member with no access modifier is package-private. This means that every class in the same package can use it. A package is like a namespace in C#. So your C# habit gives the wrong result, 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
}Any class in the same package can read and change balance. Only
fee is what a C# developer means by a field with no modifier.
Who can use a protected membertrap#
The two classes here are in two files of one package,
com.acme.billing. A package is like a namespace in C#, and its name matches the
folder.
// 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; }
}Show the answer
In Java, protected gives access to subclasses and also to the whole package.
Auditor is in the same package, so it can change total. In C#,
protected means subclasses only. Java has no access level with that meaning.
Only private stops the other classes in the package, and it stops subclasses
too. So the package is the boundary. Keep unrelated classes out of it. For the four access
levels, see the chapter Access modifiers and packages.
Methods are virtual by defaulttrap#
In this code, extends names the base class. C# writes a colon there.
@Override does the same as the override keyword in C#. It is an
annotation, which is like an attribute in C#. For
more about both, see the chapter
Classes, constructors and initialisation.
class Discount {
Discount() { describe(); }
void describe() { }
}
class SeasonalDiscount extends Discount {
private String season = "winter sale";
@Override void describe() { System.out.println(season); }
}
new SeasonalDiscount();Show the answer
In C#, a method can be overridden only if it is marked virtual,
abstract or override. In Java, every
method can be overridden unless it is marked final. Here the base constructor
calls describe, so the override in SeasonalDiscount runs. At that
moment season has not been set yet, so it is null.
The same program in C# prints winter sale. C# sets the fields of a subclass
before the base constructor runs. Java sets them after the base constructor runs.
If you make the field final with a constant value, the program prints
winter sale. The reason is that the compiler copies a constant into the code that
reads it. That hides the bug and does not fix it. A final field that is set from
a method call is still null here.
Do not call an overridable method from a constructor. Mark a method final when
overriding it would break the class.
Inner classes capture the outer instancetrap#
A C# nested class is always independent. In Java, a nested class is independent only if you
write static. Without static, the class is called an inner
class. Each object of an inner class holds a hidden reference to the outer object that created
it. That reference keeps the outer object alive.
class Outer {
class Inner { } // holds a hidden reference to an Outer
static class Nested { } // independent, what C# gives you by default
}
new Outer().new Inner(); // an Inner needs an Outer first
new Outer.Nested(); // normalWrite static on every nested class, unless you want the
outer reference on purpose. A missing static, as on Inner, is a
common cause of memory leaks in listeners and callbacks.
switch and nulltrap#
This is the arrow form of
switch. Each case runs the code after its ->. Here
run and skip are two methods of the class. For more about the arrow
form, see the chapter Switch expressions and statements.
String s = null;
switch (s) {
case "a" -> run();
default -> skip();
}Show the answer
A switch on a null String or enum throws as
soon as it sees the null. It throws before it looks at any case, so default does
not catch the null. In C#, default catches a null, and so does the discard
_.
From Java 21, write null as a case of its own: case null -> skip();
Java 21
Date and SimpleDateFormat are trapstrap#
Each of the old date classes has a problem. A java.util.Date can be changed
after it is created. Calendar numbers months from zero.
SimpleDateFormat is not thread-safe. When several threads use one
shared static instance, it produces wrong dates and reports no error. 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);SimpleDateFormat keeps working state inside itself, so two threads that format
at the same time corrupt each other's dates. A DateTimeFormatter cannot change, so one
shared instance is safe.
return inside finallytrap#
int load() {
try {
throw new IllegalStateException("real problem");
} finally {
return 0;
}
}
System.out.println(load());Show the answer
A return inside finally replaces the value or the exception that
was leaving the try block. The same happens for a catch block, and
for a throw inside finally. So the exception is lost, and the caller
never learns that anything went wrong. C# refuses to compile a return from a
finally block.
Never return from finally. Java warns about it only when you compile with
-Xlint:finally. See
Exceptions and resources.
Integer overflowtwist#
Integer.MAX_VALUE is the largest int, like
int.MaxValue in C#.
int big = Integer.MAX_VALUE;
System.out.println(big + 1);Show the answer
Math.addExact(big, 1), which throws
ArithmeticException: integer overflowJava has no checked block. Integer overflow always wraps and reports no error,
as it does by default in C#. You cannot make the + operator throw. Java also has
no unsigned types, so there is no uint or ulong.
If overflow would be a bug, call Math.addExact. For unsigned arithmetic,
use helpers such as Integer.divideUnsigned.
C# refresherchecked and unchecked: C#'s overflow controlC# 1
int big = int.MaxValue;
int wrapped = unchecked(big + 1); // -2147483648
int safe = checked(big + 1); // throws OverflowExceptionChecked exceptions and lambdastrap#
C# writes a lambda with =>. Java
writes it with ->. A stream is
like LINQ in C#. stream().map(...).toList() does the same as
Select(...).ToList(). For more about lambdas, see the chapter
Methods and parameters. For more about streams, see the chapter
Streams vs LINQ.
// files is a List<Path>; Files.readString reads a whole file as text
List<String> texts = files.stream()
.map(f -> Files.readString(f))
.toList();Show the answer
readString can throw IOException. IOException is a
checked exception, which means that the
compiler makes you catch it or declare it. A lambda passed to map cannot throw a
checked exception, because the interface that map expects declares none. This is
the gotcha that Java developers complain about most.
Catch the exception inside the lambda, and throw an unchecked exception in its place:
List<String> texts = files.stream()
.map(f -> {
try { return Files.readString(f); }
catch (IOException e) { throw new UncheckedIOException(e); }
})
.toList();Arrays are covariant, in C# and in Java#
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;The array is really a
Stringarray, so it cannot hold anInteger. The error is caught only when the line runs, withArrayStoreException.
Generics are invariant to avoid exactly this problem. That is why a
List<String> is not a List<Object>.
1Either of two Java strings may be null. How do you compare their text?
Objects.equals(a, b). a.equals(b) throws
NullPointerException when a is null. == asks only
whether a and b are the same object.2Why should a nested class be static unless you have a reason?
static unless you have a reason?static it is an inner class. Each object then holds a hidden
reference to the outer object that created it. The reference keeps the outer object alive,
which is a common cause of memory leaks. A static nested class behaves like a C#
nested class.3An expert asksA service keeps one static final SimpleDateFormat and formats
dates on every request. What goes wrong, and what do you use instead?
static final SimpleDateFormat and formats
dates on every request. What goes wrong, and what do you use instead?SimpleDateFormat is not thread-safe. It keeps working state inside itself,
so two request threads that format at the same time produce wrong dates, with no error. Use
java.time. A DateTimeFormatter cannot change, so one shared instance
is safe.Getting a JDK, and the release train#
To write Java, you install a Java Development Kit (JDK), which is like the .NET SDK. The JDK holds the compiler, the tools and the Java Virtual Machine (JVM). The JVM runs compiled code. A Java Runtime Environment (JRE) has no compiler. There is no single Java, so you choose a version and a vendor.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | the dotnet-install script | curl -s "https://get.sdkman.io" | bash | installs SDKMAN, the tool that installs a JDK and switches between several Why |
| twist | several .NET SDKs, side by side | sdk install java 25.0.4-tem | SDKMAN installs and switches JDKs. The identifier is exact, so copy it from sdk list java. Why |
| twist | dotnet new webapi | curl https://start.spring.io/starter.tgz -d dependencies=web | The Spring Initializr generates the project. Its web page is start.spring.io, and IntelliJ has it built in. Why |
| twist | dotnet run | ./mvnw spring-boot:run | the Maven wrapper is a script in the project that downloads Maven, so you never install it Why |
| same | a Kestrel app runs on its own | the JAR contains its own web server | there is nothing to deploy into, on either side |
| twist | C# Interactive | jshell | a prompt that runs each line as you type it, in the JDK since Java 9 Why |
| twist | [HttpGet("/hello")] in an [ApiController] class | @GetMapping("/hello") in a @RestController class | An annotation is like an attribute in C#. Spring Boot listens on port 8080. Why |
| twist | .NET 6, .NET 8, .NET 10 | Java 17, Java 21, Java 25 | These are the long-term support (LTS) releases. A Java release in between gets six months of updates, and a .NET release in between gets two years. Why |
| twist | <TargetFramework> | <maven.compiler.release>21</maven.compiler.release> | the Java version the output must run on, whichever JDK runs the build Why |
| trap | .NET Framework 4.8, fading away | Java 8, still in production in 2026 | Java 8 has no var, no records, no switch expressions and no text blocks Why |
| twist | .NET 8, or .NET 10 | Java 21, or Java 25 for a new project | as of September 2026 most production Java runs on 21, and every library supports it Why |
| trap | DOTNET_ROOT | JAVA_HOME | The mvn and gradle scripts use this JDK folder when the variable is set. An IDE compiles against the JDK in its project settings. Why |
| twist | dotnet add package X | a <dependency> pasted into pom.xml | there is no mvn add command, and the Spring Boot parent chooses the version Why |
| twist | one .NET SDK, from Microsoft | Temurin, Corretto, Zulu and other builds of the JDK | every build comes from the same OpenJDK source, so take Temurin unless you have a reason Why |
| twist | top-level statements, then dotnet run | void main() in Hello.java, then java Hello.java | The feature is final in Java 25. Most real code still has public static void main(String[] args). Why |
Five minutes to a running servicetwist#
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" | bashThis line installs SDKMAN. SDKMAN is a tool that installs Java versions and switches between them.
sdk install java 25.0.4-temThis line installs Temurin 25, which is a free build of the JDK.
curl https://start.spring.io/starter.tgzThis command asks the Spring Initializr for a new Maven project with the web starter. Then it unpacks the project into the folder
demo../mvnw spring-boot:runThis line builds and starts the service with the Maven wrapper. The Maven wrapper is a script in the project that downloads Maven. Each library is a Java archive (JAR) file. Maven puts each JAR on the classpath. The classpath is the ordered list of folders and JARs that Java searches for a class. See The classpath is a search list.
The built JAR contains its own web server, as a Kestrel app does.
jshell: run one line of Javatwist#
The JDK includes JShell. JShell is a read-eval-print loop (REPL), which is a prompt where you type code and see the result immediately. JShell arrived in Java 9 Java 9. It does the same as 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> Integer c = 128, d = 128
c ==> 128
d ==> 128
jshell> c == d
$5 ==> false
jshell> /exitEach line is run as soon as you press Enter. In the line
xs ==> [1, 2, 3, 4], jshell shows the value that it stored.
filter(x -> x % 2 == 0) keeps the even numbers. C# writes a
lambda with =>. Java writes it with
->. The last two lines are a trap from the
week-one gotchas. Most of the short examples in that chapter run in jshell.
/exit closes jshell.
One endpointtwist#
Add one file to that project, 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;This line names the package. A package is like a namespace in C#. The package name matches the folder that the file is in.
import org.springframework.web.bind.annotation.*;This line imports every class in that package, like a
usingdirective in C#.@RestControllerThis is an annotation, which is like an attribute in C#. This annotation tells Spring that the class handles HTTP requests, like
[ApiController]on a controller.@GetMapping("/hello")This annotation sends
GET /helloto this method, like[HttpGet("/hello")].@RequestParam(defaultValue = "world") String nameSpring reads
namefrom the query string, like[FromQuery]with a default value. Whennameis missing, Spring uses"world".
Call the endpoint 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 port.
The release traintwist#
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 an LTS release arrives every two years. The releases in between get six months of updates, and no updates after that.
| 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). For example, JEP 444 added virtual threads in Java 21.
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 for 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. It is like <TargetFramework> in a .csproj file.
Java 8 is still in productiontrap#
// Snippet.java
public class Snippet {
record Money(long cents) { }
public static void main(String[] args) {
var price = new Money(950);
System.out.println(price);
}
}javac --release 8 Snippet.java # compile it as a Java 8 codebase wouldShow the answer
error: records are not supported in -source 8, followed by
(use -source 16 or higher to enable records).NET Framework 4.8 is becoming part of history. Java 8 is not part of history.
In 2026 a large amount of running enterprise Java is still on Java 8. Java 8 lacks
almost everything in the Modern Java part of this book. It has no var, no
records, no
switch expressions and no
text blocks.
Records need Java 16, and var needs Java 10. Without the record, a line such as
var price = 950; fails on Java 8 with cannot find symbol. If you
inherit a Java 8 codebase, check before you use a modern feature.
Target Java 21 unless you control the whole deployment. As of September 2026, most production Java runs on Java 21. Java 21 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.
Installing and switchingtwist#
SDKMAN installs several JDKs side by side, as you keep several .NET SDKs. It also switches between the JDKs:
# 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 javaThis command lists every JDK that SDKMAN can install, with the identifier to use. It marks the JDKs that you already have.
sdk use java 21.0.12+1.1-temThis command switches only the current terminal to that JDK. This is useful when you want to try one project on an older version.
sdk default java 25.0.4-temThis command sets the JDK that every new terminal starts with.
java -versionThis command prints 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. The
identifier changes with every quarterly update. Copy the identifier from
sdk list java. The identifiers above were current in September 2026.
Which JDK a project usestrap#
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 names one SDK version. Every dotnet command
uses that version in the folder of the file, and in the folders below it.
{
"sdk": { "version": "10.0.100", "rollForward": "latestFeature" }
}With SDKMAN, a .sdkmanrc file in the project folder does the same for the
JDK:
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 the file. Then anyone who runs
sdk env in the folder gets the same JDK.
JAVA_HOME matters, but not everywhere, and this difference causes the
confusion. The mvn and gradle launcher scripts first check the
variable JAVA_HOME. If it is set, they run $JAVA_HOME/bin/java. If it
is not set, they run the first java on PATH. An IDE compiles
against the JDK set in its project settings. That JDK can be a different JDK from the
one that the scripts use.
That is why a project can build in the IDE and fail on the command line. When the versions
disagree, check both settings. Check JAVA_HOME for the terminal, and check the
project SDK for the IDE.
Adding a librarytwist#
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, and nobody finds it strange. IntelliJ
autocompletes the name. 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
version. The next ./mvnw command downloads the library.
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 | by default, because it is free and TCK-certified, with no conditions |
| 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 these distributions passes the Technology Compatibility Kit (TCK). The TCK is the test suite that proves that a JDK behaves like Java. The differences between the distributions are support, licensing and a few extras. So do not spend a week choosing. Take Temurin.
Running source directly#
Since Java 11, you can run a single .java file with no separate compile step.
Since Java 22, such a program can have several files. Java 25 finalised
compact source files and instance main methods Java 25. This
feature removes the extra code that made the Java hello world program a joke:
// Program.cs
Console.WriteLine("Hello");
// dotnet run// Hello.java
void main() {
IO.println("Hello");
}
// java Hello.javaThe whole program is the method void main(). The program has no class around
the method, 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 is still in an ordinary class with a
public static void main(String[] args) method.
LegacyThe full main method
You will see this form in almost every existing codebase, and a build tool still expects it as an entry point:
public class App {
public static void main(String[] args) {
System.out.println("Hello");
}
}System.out.println does the same as Console.WriteLine in C#, and
it is the form that you will see in real code. IO.println is new in Java
25.
1You have three JDKs installed, and a Maven build compiles against the wrong one. Where do
you look first?
JAVA_HOME. The mvn and gradle scripts use it when
it is set. An IDE compiles against the JDK set in its project settings. That is why the
terminal and the IDE can disagree.2An expert asksWhich Java version should a new production service target, and why not the
newest?
3What are the JDK, the JRE and the JVM?
Quick reference#
| 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 run Foo.cs, from .NET 10 | 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 |
Here are three more items, which you look up by their .NET name:
| .NET | Java | Note |
|---|---|---|
| dotnet --list-sdks | sdk list java | marks each JDK that SDKMAN has installed on this machine |
| dotnet --version | java -version | prints to stderr, but java --version, with two dashes, prints to stdout |
| global.json | .sdkmanrc, or Maven toolchains | pins which JDK this project builds with |
IntelliJ for Visual Studio users#
Most Java developers use IntelliJ IDEA. It is made by JetBrains, the company that also makes ReSharper and Rider. What you know from Visual Studio works in IntelliJ IDEA too, but under other names and with other keys.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | Visual Studio | IntelliJ IDEA, one download since version 2025.3 | free for core Java and Kotlin, but an Ultimate subscription adds the full Spring and database tooling Why |
| twist | VS Code | VS Code with the Red Hat Java and Spring Boot extension packs | a lighter option than IntelliJ, and you will also meet Eclipse in older organisations Why |
| twist | a solution that holds projects | a project that holds modules | The names moved. An IntelliJ project is what Visual Studio calls a solution, and an IntelliJ module is what Visual Studio calls a project. Why |
| twist | Billing.sln | a parent pom.xml that lists its modules | each module is a folder with its own pom.xml, and builds one JAR Why |
| trap | Visual Studio's Build menu, with MSBuild hidden behind it | the build tool owns the project, and the IDE reads its files | when the IDE and mvn clean package disagree, trust the command line Why |
| twist | Ctrl+. | Alt+Enter | the key to learn first, because it fixes errors, adds imports and offers language upgrades Why |
| twist | Visual Studio's keys | Settings, Keymap, Visual Studio | IntelliJ has a Visual Studio keymap, but most people adapt to its own keys Why |
| twist | auto-properties, written by the compiler | getters and setters, generated with Cmd+N | Java has no properties, so the IDE writes the methods. A record removes most of these methods. Why |
| twist | the Immediate window | Evaluate Expression, Alt+F8 | debugging works in much the same way as in Visual Studio, but under different names Why |
| trap | Edit and Continue while debugging | HotSwap | replaces method bodies only, so you must restart if you add a field or change a signature Why |
| twist | Roslyn analyzers and StyleCop | Error Prone, Checkstyle and SpotBugs | separate tools that you add to the build, and that your build server should run Why |
Which IDE, and what is freetwist#
IntelliJ IDEA is one download since version 2025.3. It is free for core Java and Kotlin development. The free download now also includes a Spring Boot project wizard, basic Spring highlighting and a database schema viewer. An Ultimate subscription adds the full Spring and database tooling (as of October 2026).
VS Code with the Red Hat Java and Spring Boot extension packs is a lighter option. You will also meet Eclipse in older organisations. Whichever IDE you choose, the build tool builds and runs the service. The build tool here is Maven. So these commands work, whichever IDE you use:
./mvnw spring-boot:run # start the service
./mvnw test # run its tests./mvnw is the Maven wrapper. These
commands do not need an IDE. For more about the Maven wrapper, see the section
Five minutes to a running service in the previous chapter.
The project model is differenttrap#
The names moved, and the owner moved too. In Java, the build tool decides how the code is organised, and the IDE reads the files of the build tool.
| Visual Studio | IntelliJ IDEA | Note |
|---|---|---|
| Solution (.sln) | Project | the top-level thing you open, and it holds every module in the build |
| Project (.csproj) | Module | each module has its own pom.xml and produces one JAR |
| Assembly output | JAR | built by Maven or Gradle, and the IDE asks the build tool to build it |
| Solution Explorer | Project tool window | shows the file tree, and it opens with Cmd+1 on macOS, or Alt+1 on Windows and Linux |
| Package Manager | Maven or Gradle tool window | shows the tree, but you add dependencies by editing pom.xml |
| Build menu | Build, or the Maven panel | the IDE delegates to the build tool |
In Java, a parent pom.xml that lists its modules does the same as a solution
file:
<!-- 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 that this file builds nothing
itself. The file only lists its modules. Each <module> names a folder with
its own pom.xml, and each module 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 the
dependency. The re-import is usually automatic. To start it yourself, use the refresh button
in the Maven panel. Sometimes a build works in the IDE but
fails on mvn clean package. This almost always means that the IDE's view of the
project is no longer the same as the build tool's view. Trust the command line.
The key to learn firsttwist#
IntelliJ has a Visual Studio keymap, under Settings, then Keymap. But most people adapt to IntelliJ's own keys. The quick reference lists those keys.
Alt+Enter is like Ctrl+. in Visual Studio, but it does more. It fixes
errors, adds imports, adds a missing dependency and generates a missing method. It also offers
language upgrades. For example, it can turn an
anonymous class into a
lambda, or a chain of if statements into a
switch expression.
Here is the first of those upgrades, in code that sorts a list of names by length:
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>() {This is called an anonymous class. It is a class with no name, and it is defined and created in one expression. Comparator does the same as
IComparer<T>in C#.(a, b) -> a.length() - b.length()This is the same comparison, written as a lambda. C# writes
=>. Java writes->. For more about lambdas, see the section Lambdas and method references.
Generating the boilerplate Java expects#
Java has no properties, so getters and setters are real methods that someone has to write.
But nobody types these methods. 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 that read and write a
private field. IntelliJ writes them for you, but they are still code that you read and
maintain. Most of that extra code disappears when the type can be a
record Java 16. See
Records.
Debugging#
Debugging works in much the same way as in Visual Studio, but 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 |
The condition of a conditional breakpoint is a Java expression. It is evaluated at the line of the breakpoint, 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 tests are
true.
Hot reload is weaker than Edit and Continue. Standard HotSwap replaces method bodies only. If you add a field or change a signature, you must restart. Spring Boot DevTools does a fast restart. The JetBrains Runtime and the paid JRebel do more than standard HotSwap. But do not expect hot reload to work as smoothly as it does in C#.
Static analysis#
In Java, a set of separate, mature tools does the same as Roslyn analyzers. You add these tools to the build. IntelliJ's own inspections are strong by default, and they do roughly the same as ReSharper. But your build server should run these tools:
| .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 line new IllegalArgumentException(...) creates an exception and then
discards it, because the throw is missing. The compiler accepts the line, but
Error Prone stops the build.
1What is the Java equivalent of the solution file, and who owns it?
pom.xml that lists the modules. The build tool is the source
of truth, not the IDE. So if a build works only in IntelliJ, the IDE's view of the project is
no longer the same as the build tool's view.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 | Ctrl+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#
A Java class works almost exactly like a C# class. It has one superclass, it can implement many interfaces, and its constructors can call each other. Most of the differences are only in how the code is written.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | class A : Base, IFoo | class A extends Base implements Foo | extends names the one superclass, and implements lists the interfaces Why |
| trap | sealed class Invoice | final class Invoice | Java has a sealed keyword too, and it means something else Why |
| same | abstract class Discount | abstract class Discount | The keyword and its meaning are the same. No code can create an object of the class directly. |
| twist | static class MoneyMath | a final class with a private constructor | Java has no static classes, so a class that only holds static methods is written this way Why |
| twist | struct | record | Java has no structs. A record compares by value, but it is still an object. Why |
| twist | : this(...) | this(...) as the first statement | calls another constructor of the same class Why |
| twist | : base(...) | super(...) as the first statement | super(...) calls the superclass constructor. Since Java 25, checks may come before the call. Why |
| twist | static Foo() { } | static { } | a static initialiser block, which runs once, the first time the class is used Why |
| twist | field initialiser | field initialiser | C# runs a field initialiser before the base constructor. Java runs it after super() returns. Why |
| twist | no equivalent | instance initialiser { } | a bare block in the class body, which runs after super() and before each constructor body Why |
| twist | override | @Override | @Override is an annotation that the compiler checks. It is optional, but Java developers always write it. Why |
| twist | ToString() | toString() | called by string concatenation and by loggers to print the object Why |
| twist | Equals(object) | equals(Object) | equals compares values, but == on two objects compares references Why |
| twist | GetHashCode() | hashCode() | must return the same value for two objects that are equal according to equals Why |
| twist | GetType() | getClass() | returns a Class object, which is like a System.Type object in C# Why |
| twist | MemberwiseClone() | clone() | a shallow copy that skips constructors, so use a copy constructor instead Why |
| twist | ~Money() | finalize() | deprecated for removal since Java 18, so release resources with try-with-resources Why |
| twist | Monitor.Wait / Monitor.Pulse | wait() / notify() | Every Java object has these methods. They are a 1990s API, so use java.util.concurrent. Why |
Declarationtwist#
ServiceBase is a base class that the team wrote.
public class OrderService : ServiceBase, IDisposable
{
public void Dispose() { }
}
public sealed class Invoice { }
public abstract class Discount { }
public static class MoneyMath { }public class OrderService extends ServiceBase
implements AutoCloseable {
@Override public void close() { }
}
public final class Invoice { }
public abstract class Discount { }
// Java has no static classes; see below.extends ServiceBaseextendsnames the superclass. C# writes a colon there. A class has only one superclass, as in C#.implements AutoCloseableimplementslists the interfaces.AutoCloseabledoes the same asIDisposablein C#. It has one method,close(). The Java form ofusingis called try-with-resources. It callsclose()at the end of the block.@Override public void close() { }@Override does the same as the
overridekeyword in C#. Java also writes@Overrideon a method that implements an interface method.public final class Invoicefinalmeans that no class may extendInvoice, likesealedin C#.
The other declaration rules are in this table:
| C# | Java | Note |
|---|---|---|
| : Base | extends Base | a class has only one superclass, as in C# |
| : IFoo | implements Foo | a class may implement many interfaces, as in C# |
| sealed class | final class | no class can extend a final class, and the Java keyword sealed means something else |
| abstract class | abstract class | The keyword and its meaning are the same. The class cannot be instantiated. |
| static class | final class + private constructor | Java has no static classes, so this form is the usual replacement |
| partial class | no equivalent | one class cannot be split across files, so use composition or generated code |
| internal class | package-private (no keyword) | visible only to the same package, not to the whole JAR |
| nested class | static nested class | a non-static nested class also holds a hidden reference to its outer object |
The row about nested classes is a trap. See Inner classes capture the outer instance.
C# refresherPartial classes: one class split across filesC# 2
A C# partial class is declared in several files and compiled as one class. The
usual reason is to keep generated code and hand-written code in separate files.
// 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"); }sealed in C# is final in Javatrap#
public sealed class Invoice { }Show the answer
sealed class must have subclassesIn Java, a sealed class says which types may extend it. No other type may
extend the class. Invoice names no types, so it does not compile. To learn what
sealed is used for, see the section Sealed types.
Write public final class Invoice { }.
No static classestwist#
Java has no static classes. A class that only holds static helper methods is usually
written as a final class with a private constructor.
java.util.Objects and java.util.Collections are written this way, and
so is this class:
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 from creating a MoneyMath. The
AssertionError stops the class itself from creating a MoneyMath by
mistake. Some code uses an interface with only static methods instead. But an
interface shows less clearly what you mean, because an interface exists to be implemented.
No structstwist#
Java has no structs either. Every type that 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 type is a record. Here is a discount percentage in both languages:
public readonly record struct Percentage(decimal Value);public record Percentage(BigDecimal value) { }Both types compare by value. You cannot change a Percentage after it is
created, in C# or in Java. But the Java record is still an object. It is on the
heap, and a variable holds a reference to it. When you pass
a Java record, the reference is copied, not the object. When you pass a C# struct, the struct
itself is copied. For more about records, see the chapter Records.
Constructors and chainingtwist#
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());This line calls the other constructor of the same class, like
: this(...)in C#.List.of()makes an empty list, like the[]on the C# side.super(customer, lines);This line calls the constructor of the superclass
Order, like: base(...)in C#.
For most of Java's history, this(...) or super(...) had to be the
first statement of a constructor. So a constructor could not check an argument before the
call. Java 25 finalised flexible constructor bodies Java 25.
Now statements may come before the call, as long as they 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);
}Before Java 25, the if line does not compile, unless you turn on
preview features in Java 22 to 24. The workaround on those versions is a private static helper method that is called inside the argument, as in
super(customer, requireLines(lines)). A lot of existing code has this
workaround.
Initialiser blocks#
Java has two kinds of initialiser block. Both kinds run at predictable points. Both kinds
appear in real code, particularly in older code and in static registries. In this example, a
registry maps a file format to the object that handles the format. Handler is an
interface that the team wrote, and JsonHandler is one implementation of
Handler.
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;This line declares a class-level constant. The constant is
staticbecause it belongs to the class. It isfinalbecause it is assigned once, in the block below.static {This block is called a static initialiser. It runs once, the first time the class is used, like a static constructor in C#. A class can have several static initialisers, and they run from top to bottom.
created = System.nanoTime();This line is inside the bare
{ }block, which is called an instance initialiser. The block runs before every constructor body, just aftersuper(). An instance initialiser is rare, because a constructor is usually clearer.
A field initialiser runs at the same point as an instance initialiser, after
super() returns. An example of a field initialiser is
private String season = "winter sale";. C# runs the field initialisers of a
subclass before the base constructor. So when a Java superclass constructor calls an overridden
method, the method sees the fields of the subclass before they are set. For an example, see
this prediction.
Class initialisation is lazy and thread-safe, as it is in C#. The Java Virtual Machine (JVM) guarantees that the static initialiser of a class runs exactly once, before any static member is read. So a static field that is set in a static initialiser is a safe way to create one shared object. It is safe even when many threads start at the same time.
Every class extends Object#
As in C#, every class inherits from a root class, and a class overrides a few methods of the
root class. In Java, the root class is Object. The method names start with a
lower-case letter. The rules about these methods are kept by convention, and the compiler does
not check them. Here is Money, which is an amount in a currency. It overrides the
three methods that you will write most often:
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. It marks a method as a replacement for an inherited method, and the compiler checks that the method really replaces one.
@Overridedoes the same as theoverridekeyword in C#, but Java does not require it.o instanceof Money mThis expression tests the type and names the result in one step, like
o is Money min C#. It is called a type pattern.amount.equals(m.amount)equalscompares values. In Java,==on two objects compares references, even forBigDecimal.equalson aBigDecimalalso compares the number of decimal places. To learn why that matters for money, see Equality, hashing and comparison.Objects.hash(amount, currency)This call combines the fields into one hash code, like
HashCode.Combinein C#.money.getClass().getSimpleName()getClass()returns the class of the object. The result is like aSystem.Typein C#.getSimpleName()gives the name of the class without the package.
In practice, you rarely write these three methods yourself. A record generates all three methods from its fields. See Records.
Object has three more methods that you should recognise and not call.
finalize() has been deprecated for removal since Java 18. To release resources,
use try-with-resources. wait()
and notify() exist on every object. The closest matches in C# are
Monitor.Wait and Monitor.Pulse. wait() and
notify() are a 1990s API, so use java.util.concurrent. See
wait, notify and notifyAll.
Legacyclone() and Cloneable
Java's cloning mechanism is widely considered a design mistake. Cloneable is a
marker interface, and it has no clone method. The default implementation of
clone makes a shallow copy of the fields and skips constructors. You will see
cloning in existing code. Prefer a copy constructor, a static factory, or a record with a
with-style method.
1TranslateWrite this in Java:
public static class OrderRules
{
public static bool IsSmall(List<OrderLine> lines) => lines.Count <= 3;
}
public static class OrderRules
{
public static bool IsSmall(List<OrderLine> lines) => lines.Count <= 3;
}public final class OrderRules {
private OrderRules() { }
public static boolean isSmall(List<OrderLine> lines) {
return lines.size() <= 3;
}
}Java has no static classes. final stops subclasses. The private constructor is
the part that is most often forgotten. Without the private constructor, any code can write
new OrderRules().
2An expert asksOlder Java code often calls a private static helper inside
super(...), as in super(customer, requireLines(lines)). Why, and what
changed in Java 25?
super(...), as in super(customer, requireLines(lines)). Why, and what
changed in Java 25?super(...) must be the first statement of a
constructor, so the only place for a check is inside the argument. Java 25 allows statements
before the call, as long as they do not use the object being built.Access modifiers and packages#
The access keywords of Java look like the access keywords of C#. A package is like a namespace in C#. It is a named group of classes, and it is tied to a folder. In C#, the assembly decides who has access. In Java, the package decides it.
| Kind | C# | Java | Note |
|---|---|---|---|
| same | private Money total; | private Money total; | the same, but in Java you must write private to get it |
| trap | 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 Why |
| trap | protected, for subclasses only | protected, which also opens the package | Java cannot say subclasses only. With protected, the whole package gets access too. Why |
| twist | namespace Acme.Orders; | package com.acme.orders; | a package name is lower-case, is a reversed domain, and matches the folders Why |
| trap | Acme.Orders.Internal, inside Acme.Orders | com.acme.orders.internal, a package of its own | packages do not nest, so it sees no package-private member of com.acme.orders Why |
| twist | using System.Text; | import java.util.List; | Java imports types, not namespaces, and has no aliases Why |
| twist | using static System.Math; | import static java.lang.Math.*; | names one member, or all of a type's static members with .* Why |
| twist | internal, for the whole assembly | no keyword, for one package | Java has no internal, and package-private is the nearest level Why |
| twist | [InternalsVisibleTo] | tests in the same package | tests under src/test/java declare the package they test Why |
| twist | Acme.Billing.Api | com.acme.billing.api | the reversed domain stops classes from different vendors colliding Why |
The mappingtrap#
One class can show all four of Java's access levels:
package com.acme.billing;
public class Invoice {
private Money total; // this class only
void attach(Payment payment) { } // no keyword: this package only
protected void recalculate() { } // this package, and subclasses anywhere
public Money total() { return total; } // everyone
}attach has no keyword, so it is package-private. This means
that every class in the same package can use it, and no other class can. C# has no such level.
To make a field private, you must write private, as the code does for
total.
Forgetting a modifier is not the same as writing private.
Your C# habit gives exactly the wrong result. See
No modifier means package-private.
The table lists the four access levels of Java, from widest to narrowest, with the nearest C# keyword for each level:
| 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 |
protected is not a narrowing of public in Java the way
you expect. protected also gives access to the whole package. For an
example, see this prediction. Java cannot express
“subclasses only”, for a field or for a method. The package is the boundary, so
keep unrelated classes out of the package.
Packages are not namespacestwist#
A C# namespace is a logical grouping. It does not have to match the folder layout, and one file can declare several namespaces. A Java package is tied to folders. Every build tool and IDE expects the package to match the folder that the file is in. The compiled class files must be in matching folders, because the Java runtime uses the folders to find the class files. See the chapter How Java runs your code.
// 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 Java file declares package com.acme.orders;. The file is in the folders
com/acme/orders/, under src/main/java.
Maven looks for source code in
src/main/java.
A package inside a packagetrap#
// com/acme/orders/Order.java
package com.acme.orders;
public class Order { long id; }
// com/acme/orders/internal/Audit.java
package com.acme.orders.internal;
import com.acme.orders.Order;
class Audit {
long idOf(Order order) { return order.id; }
}Show the answer
id is not public in Order; cannot be accessed from outside packagePackages do not nest. 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 of com.acme.orders is
not visible to com.acme.orders.internal.
In C#, Acme.Orders.Internal can see internal members of
Acme.Orders, because the two namespaces share an assembly. The two Java packages
share nothing. Code that needs a package-private member must be in the same package as that
member.
Imports#
Imports work almost like using directives in C#, with three differences. Java
imports types, not namespaces. Java has no import aliases. And Java imports nothing for the
whole project.
| C# | Java | Note |
|---|---|---|
| using System; | import java.util.List; | Java imports types, not namespaces |
| using System.Collections.Generic; | import java.util.*; | A C# using always takes a whole namespace. Java writes .* to take a whole package. |
| using RX = System.Text.RegularExpressions; | no alias imports | when two types clash, write one of them with its full package name instead |
| global using | no equivalent | each file lists its own imports, but the IDE adds them for you |
| implicit usings | java.lang is always imported | java.lang is imported everywhere. It contains types such as String, Object and 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.*;This line imports every type in
java.util. It does not import the types in the packages belowjava.util, such asjava.util.regex, because packages do not nest.import static java.lang.Math.max;This is a static import, like
using staticin C#. It imports one static method, somax(1, 2)needs no class name.import static java.lang.Math.*;imports every static member ofMath.java.sql.Date d = null;Java has no import aliases. Sometimes two types share a name, such as
java.util.Dateandjava.sql.Date. Then you write one of them with its full package name every time.
Java 25 added module import declarations Java 25. An
example is import module java.base;. A module import declaration imports every
exported package of a module at once. This is useful for scripts, but it is usually too broad
for production code.
There is no internal#
The missing internal is a real gap, and it affects how Java libraries are
designed. C# gives you an access level for the whole assembly, and
InternalsVisibleTo for tests. The options in Java are weaker. The strongest option
is the Java Platform Module System (JPMS). It hides whole
packages, not single classes. The table lists the options:
| 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 |
Remember the last row. Java tests are normally in the same package as the code
they test. The tests are under src/test/java, not under
src/main/java. So the tests get package-private access, and they need nothing like
InternalsVisibleTo. For the module system, see the chapter
Modules, JPMS and the missing internal.
C# refresherInternalsVisibleTo: letting a test project see internal membersC# 2
// 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 is under src/test/java, but it
declares the same package. So the test can call InvoiceNumbers.next()
directly.
Here is the whole mapping, starting from the C# keywords that 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 protectedC# 7
internal opens a member to the whole assembly. The combined modifiers use the
assembly boundary to change protected. protected internal makes
protected wider. private protected makes protected
narrower.
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
}Package naming#
A package name is a reversed domain name, all lower-case, with no underscores. An example
is 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 follow the package name. The folders are
com/acme/billing/api, with one folder for each part of the name.
1TranslateWrite this file in Java:
namespace Acme.Billing;
internal class InvoiceNumbers
{
int counter;
public string Next() => "INV-" + ++counter;
}
namespace Acme.Billing;
internal class InvoiceNumbers
{
int counter;
public string Next() => "INV-" + ++counter;
}package com.acme.billing;
class InvoiceNumbers {
private int counter;
public String next() { return "INV-" + ++counter; }
}You must write private on the field. With no keyword, every class in
com.acme.billing could change the field. The class has no keyword on purpose.
Package-private is the nearest level that Java has to internal.
2An expert asksWhy are Java tests in the same package as the code they test?
internal and nothing like InternalsVisibleTo. A test
under src/test/java can declare the same package as the code that it tests. Then
the test gets package-private access. So the code can stay hidden from every other
package.Interfaces, abstract classes and sealing#
Java interfaces and abstract classes work almost like interfaces and abstract classes in C#. Java adds sealed types. A sealed type is an interface or a class that names every type that may extend it. So the compiler can check that a switch covers all of these types.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | interface IPriceLookup | interface PriceLookup | Java interface names have no I prefix Why |
| twist | default interface method | default boolean has(...) { ... } | an interface method with a body, and every implementing class inherits it Why |
| twist | IEnumerable<out T> | List<? extends T> | Java has no in or out, so you write a wildcard where the type is used Why |
| twist | const int MaxLines = 50; in an interface | int MAX_LINES = 50; | no keyword is needed, because every interface field is public, static and final Why |
| same | a method that implements an interface | public Money find(String productId) | the method needs public in both languages, and C# reports CS0737 without it Why |
| twist | void ILogger.Log(string m) | no equivalent | one public method serves every interface that declares that signature Why |
| twist | event Action<Order> Placed | List<Consumer<Order>> plus onPlaced(...) | Java has no events, so keep a list of listeners instead Why |
| twist | abstract record Payment plus derived records | sealed interface Payment permits Card, BankTransfer, Voucher | a closed set of types, so the compiler knows every kind Why |
| twist | a switch ending in a discard arm | a switch with no default | over a sealed type, Java checks that every kind is handled, but C# only warns (CS8509) Why |
| new | no equivalent | final, sealed or non-sealed | each permitted type says whether its own branch stays closed Why |
| twist | no equivalent | permits left out | when every subtype is in the same file, the compiler finds them without a permits list Why |
| same | abstract class with abstract methods | abstract class with abstract methods | the same keywords and the same meaning as in C# Why |
| trap | virtual | no keyword, because every method is virtual | write final to stop a method being overridden Why |
| twist | override | @Override | @Override is an annotation that the compiler checks. It is optional, but always write it. Why |
Interfacestwist#
Both versions use a class called EmptyLookup. This class finds nothing, and its
code is not shown.
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);The return type has no
?. Java has no nullable reference types, so any reference may be null. For what to do about null, see the chapter Nullability: Optional and JSpecify.default boolean has(String productId)defaultgives an interface method a body, like a default interface method in C#. Every class that implementsPriceLookupgets this method, and may override it.static PriceLookup empty()This is a static method on the interface. You call it as
PriceLookup.empty().private void log(String m)This is a private helper. Only the methods of the interface itself can call it.
The table shows when each feature arrived, and what C# has that Java does not have:
| Feature | C# | Java |
|---|---|---|
| Default method body | C# 8 | Java 8 |
| Static method | C# 8 | Java 8 |
| Private method | C# 8 | Java 9 |
| Constant field | yes, since C# 8 | yes, implicitly public static final |
| Fields | static fields only, since C# 8 | no (constants only) |
| Explicit implementation | yes | no |
| Generic variance on the interface | yes, in/out | no, use-site only |
Variance, and constants in an interface#
C# refresherGeneric variance on the interface: in and outC# 4
A C# interface can promise that it only produces T. It says this with
out. Or it can promise that it only consumes T, with in.
Because of out, the compiler accepts an IEnumerable<string>
where an IEnumerable<object> is expected.
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. Java says the same thing in the place where the
type is used, with a wildcard. For example, a parameter
of type List<? extends Number> accepts a List<Integer>.
See Variance is at the use site.
C# refresherConstant fields and static fields in an interfaceC# 8
Since C# 8, an interface can have a constant and a static field. An instance field is still an error.
interface ILimits
{
const int MaxLines = 50; // a constant
static int Counter = 1; // a static field, which code can reassign
}
ILimits.Counter++; // compilesEvery field in an interface is implicitly public static final.
You cannot declare instance state, and you cannot declare a field that can be reassigned:
public interface Limits {
int MAX_LINES = 50; // a constant, read as Limits.MAX_LINES
}Older code sometimes uses a “constant interface”. Classes implement this
interface only to inherit constants such as MAX_LINES. That is a known mistake. Put
constants in a final class or an
enum instead.
Implementing an interface methodtwist#
This does not compile in C# or in Java. A method that implements an interface method must
be public. C# reports CS0737. The Java compiler reports the same problem in other
words.
find(String) in EmptyLookup cannot implement find(String) in PriceLookup attempting to assign weaker access privileges; was public#public Money find(String productId).What triggers it
interface PriceLookup {
Money find(String productId);
}
class EmptyLookup implements PriceLookup {
Money find(String productId) { return null; } // no public
}No explicit interface implementationtwist#
C# refresherExplicit interface implementationC# 1
An explicit implementation is a member that is implemented for one interface only. You can call it through that interface. You cannot call it 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"); // compilesIn C#, you can implement the same method signature separately for two interfaces. You can also hide a member, so that it is not among the public members of the class. You cannot do these things in Java. If two interfaces declare the same signature, one method implements the signature for both interfaces, and that method 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. Now suppose that two interfaces,
A and B, both give the same method a default body. Then the class
must override the method and choose one default body explicitly:
public class Both implements A, B {
@Override public String name() {
return A.super.name(); // pick a specific default
}
}A.super.name() calls the default version of name() in interface
A. In C#, base.Name() calls the version of a base class in a
similar way.
No eventstwist#
Java has no event keyword. The usual replacement is a list of listeners. Each
listener is 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 replaces the event.
Consumer<Order>is a built-in functional interface. This is an interface with one method. Here the method isaccept, which takes anOrderand returns nothing.Consumer<Order>does the same asAction<Order>in C#.placedListeners.forEach(l -> l.accept(order));This line calls every listener, one after another, as
Placed?.Invoke(order)does in C#.service.onPlaced(order -> System.out.println(order.id()));This line subscribes with a lambda. C# writes
+=to subscribe. Spring applications often use application events for this instead. See Application events.
Sealed types: Java's discriminated unionstwist#
sealed means a different thing in each language. For an example, see
this prediction. The table shows what each language
writes:
| Intent | C# | Java |
|---|---|---|
| Cannot be extended at all | sealed class | final class |
| Only named types may extend | no equivalent | sealed interface / sealed class |
From Java 17, an interface can be sealed. A sealed interface lists its complete set of subtypes. Here are three kinds of payment:
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 { }permits Card, BankTransfer, VoucherThe complete list comes after permits. The compiler rejects any other class that implements
Payment. Each permitted type must itself choose one of three keywords.finalends the branch.sealednames a closed set of its own.non-sealedopens the branch again. See The rules for permits.public record Card(String last4) implements Payment { }Each kind is a record. A record is a short class that only holds data. A record is final already, so it needs no keyword.
Because the set is closed, a switch over a Payment needs no
default arm. For an example, see No default over a sealed
type.
Overriding needs no keywordtrap#
class Discount {
String describe() { return "discount"; }
String label() { return "10% " + describe(); }
}
class SeasonalDiscount extends Discount {
String describe() { return "winter sale"; }
}
System.out.println(new SeasonalDiscount().label());Show the answer
Every Java method can be overridden unless it is marked final. A method in a
subclass with the same name and parameters overrides the method of the superclass. Java needs
no keyword for this, and it gives no warning.
The same classes in C#, with the methods made public, print 10% discount. In
C#, the new method only hides the old method, and the compiler warns with CS0108.
Mark a method final when overriding it would break the class. Write
@Override on every method that is meant to override another method.
Java methods are virtual by default. So a subclass can override a method
that you did not plan as an extension point, unless you mark the method final. See
An overridable method called from a constructor.
Abstract classestwist#
In everything important, abstract classes in Java are the same as abstract classes in C#. You need an abstract class when the type has state or constructors. Otherwise, prefer an interface with default methods.
| 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:
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"; }This method has no keyword, and yet any subclass may override it. In C#, you must write
virtualto allow that.public final String code()finalon a method forbids overriding. The method is then like a C# method withoutvirtual.@Override public BigDecimal amountThe
@Overrideannotation is optional, but always write it. If you mistype the method signature, the compiler then reports an error. Without@Override, the mistyped method becomes a new method, and nothing warns you.
The rules for permits#
The permitted subtypes of a sealed type 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. A switch
case for SystemEvent also covers those subclasses.
This is one of the few places where Java is ahead of C#. Sealed interfaces are available
from Java 17. Pattern matching in
switch is available from Java 21. Together they give you a closed set of types
that the compiler checks. To get the same safety, C# developers usually need a library such as
OneOf, or a visitor pattern. See Sealed types and pattern
matching.
1TranslateWrite this in Java:
public interface IPriceLookup
{
Money? Find(string productId);
bool Has(string productId) => Find(productId) is not null;
}
public class EmptyLookup : IPriceLookup
{
public Money? Find(string productId) => null;
}
public interface IPriceLookup
{
Money? Find(string productId);
bool Has(string productId) => Find(productId) is not null;
}
public class EmptyLookup : IPriceLookup
{
public Money? Find(string productId) => null;
}public interface PriceLookup {
Money find(String productId);
default boolean has(String productId) {
return find(productId) != null;
}
}
public class EmptyLookup implements PriceLookup {
@Override public Money find(String productId) { return null; }
}The word that is easiest to forget is default. Without default,
the compiler rejects the body with the message
interface abstract methods cannot have body.
2An expert asksA sealed interface permits a class, and the compiler demands that the class be
final, sealed or non-sealed. Why?
final, sealed or non-sealed. Why?final ends the branch. sealed names the next closed set.
non-sealed opens the branch again on purpose. A record needs no keyword, because a
record is final already.Methods and parameters#
A Java method looks like a C# method, but most of the parameter features of C# are missing. For a job that C# does with a keyword, Java uses an overload, a return value or a helper class. Java has lambdas too, but it uses interfaces in place of delegates.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | int limit = 100 | an overload for each shorter call | Java has no optional parameters, so a shorter overload calls the full one Why |
| twist | CountOrders("paid", limit: 50) | a builder: .timeout(...).build() | Java has no named arguments, so a builder sets one value per call Why |
| twist | a parameter name, seen by reflection | arg0, unless compiled with -parameters | Frameworks match values by parameter name. Spring Boot's parent POM and its Gradle plugin set the flag. Why |
| twist | int.TryParse(s, out var value) | OptionalInt parsed = parseQuantity(s) | everything is passed by value, even an object reference, so a method gives a result as its return value Why |
| twist | ref parameters, or a tuple | record Pair(int a, int b) { } | a method returns several results together in one record Why |
| twist | params object[] args | Object... args | varargs are the same idea as params, and they must also be the last parameter Why |
| twist | name.IsBlank() | Strings.isBlank(name) | Java has no extension methods, so a static helper takes the value Why |
| twist | calls chained left to right | calls nested inside-out | a static helper wraps its argument, but streams are the exception Why |
| twist | price * quantity + shipping | price.multiply(quantity).add(shipping) | Java has no operator overloading, so BigDecimal uses methods Why |
| twist | delegate decimal PriceRule(Order o); | interface PriceRule { BigDecimal apply(Order o); } | a functional interface has one method, and a lambda can implement that method Why |
| twist | x => x + 1 | x -> x + 1 | the lambda is the same, but Java writes the arrow as -> instead of => Why |
| twist | (x, y) => x + y | (x, y) -> x + y | several parameters go in brackets, exactly as in C# Why |
| twist | () => Foo() | () -> foo() | Empty brackets mean that there are no parameters. Supplier<T> does the same as Func<T> in C#. Why |
| twist | Foo.Bar (method group) | Foo::bar | passes the method itself as a lambda Why |
| twist | () => new Foo() | Foo::new | passes the constructor as a factory function Why |
| twist | Func<int, string> | Function<Integer, String> | primitives must be boxed in a generic Why |
| twist | Action<T> | Consumer<T> | a function that takes a T and returns nothing Why |
| twist | show(42) | show.apply(42) | Call the one method of the interface. The method is apply on a Function and accept on a Consumer. Why |
| same | Predicate<T> | Predicate<T> | The name and the meaning are the same. A Predicate takes a T and returns a boolean. Why |
| trap | a lambda that changes a local | a lambda that only reads locals | a captured local must never be reassigned, so count with an AtomicInteger Why |
Signaturestwist#
countOrders has an optional flag and an optional limit. Java writes one overload
for each shorter call. This set of overloads is called an overload ladder.
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 adds the missing values and calls the full version. The full version does the real work.
int n = countOrders("paid", false, 50);Java has no named arguments. So you pass every value in order, up to the value that you want to set. Here you must write
falseonly because the limit comes after it.
Builders stand in for named argumentstwist#
Java has no optional parameters and no named parameters. These are the three replacements, in order of preference:
- Overload ladder. Each shorter overload calls the overload with every parameter. This is fine for two or three parameters.
- Builder. This is the standard answer when there are more parameters. A builder takes more code to write, but it is pleasant to call. Most libraries offer builders.
- A parameter object. Use one object for all the arguments when the
arguments belong together. The object is usually a
record.
// 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()This call starts a builder. A builder is an object that you set up one value at a time. At the end, the builder creates the real object.
HttpRequestbelongs to the HTTP client that is built into the Java Development Kit (JDK)..timeout(Duration.ofSeconds(10))Each method sets one named value. A value that you do not set keeps its default.
.build();This call creates the finished
HttpRequestfrom all the values that were set.
Parameter names and the -parameters flag#
Parameter names are not in the compiled output by default. Reflection
sees arg0, arg1, unless the class was compiled with the
-parameters flag:
javac -parameters OrderService.javaThat matters when a framework matches values to parameters by name. Spring does this for some constructor injection and request binding.
Spring Boot sets the -parameters flag for you. With
Maven, the parent
Project Object Model (POM) file of Spring Boot sets the flag.
With Gradle, the Spring Boot plugin sets the flag. A build
that you set up yourself may not set the flag. The same is true for a build that does not use
the parent POM.
No ref, no outtwist#
Java passes every argument by value. An object reference is passed by value too. This means that a method can change the object that the reference points at. But the method cannot make the caller's variable point at another object. So a method gives its result to the caller as the return value:
if (int.TryParse(s, out var value))
{
Use(value);
}// Return a value that can be empty, instead of an out parameter.
OptionalInt parsed = parseQuantity(s);
if (parsed.isPresent()) {
use(parsed.getAsInt());
}OptionalInt parsed = parseQuantity(s);parseQuantityis a small helper that you would write yourself. It returns an emptyOptionalIntwhen the text is not a number. Java's own methodInteger.parseIntthrows aNumberFormatExceptionin that case.OptionalIntis the int version of Optional. AnOptionalIntis a box that holds a value or nothing.parsed.getAsInt()This call takes the number out, after
isPresent()has confirmed that there is a number.Useanduserepresent the code that you would write next.
Several results come back in a recordtwist#
void Swap(ref int a, ref int b) { ... }
void Print(in BigStruct s) { ... } // read-only, by reference// Return a record when there are several results.
record Pair(int a, int b) { }
Pair swapped = swap(a, b);record Pair(int a, int b) { }A record is a short class that only holds data. Here one record returns several results to the caller. C# would use
refparameters or a tuple for this.Pair swapped = swap(a, b);swapreturns a newPairwith the two values in the opposite order. The caller's own variables are not changed.
C# refresherTuples: multiple return valuesC# 7
A C# method can return several values at once as a tuple. The caller can then assign the parts of the tuple to 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 4Varargs#
Varargs are the same idea as params in C#. The same rule applies, so a varargs
parameter 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 does the same as params object[] args in C#. The
caller passes any number of values, and the method receives an array. In the format string, C#
writes {0}. Java writes %s.
Passing an array to a varargs parameter of type Object... is ambiguous. The
call log("x", myArray) passes each element of the array as a separate argument. It
does not pass the array as one argument. To pass the array as one argument, write a cast, as in
log("x", (Object) myArray). Compilers warn about this, and you should read the
warning.
No extension methodstwist#
name is a string that may be null.
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 StringsThis is a utility class. It is
final, it has a private constructor, and it holds only static methods. For more about utility classes, see the chapter Classes, constructors and initialisation.s == null || s.isBlank()Stringhas had its ownisBlank()method since Java 11. But a call toisBlank()on null throws, so the helper checks for null first.
These are the replacements for an extension method:
- A static utility class. You write
StringUtils.isBlank(s)in place ofs.IsBlank(). This is why Apache Commons and Guava exist. - A
defaultmethod. Use one if you own the interface. - A class that wraps the original object. Use one when the extension is large.
Why Java code reads inside-out#
Java has no extension methods, so a helper wraps its argument. C# code reads from left to right. Java code often reads from the inside to the outside:
var readOnly = Collections.unmodifiableList(new ArrayList<>(xs));Collections.unmodifiableList is written first and runs last. C# would write a
chain of calls here. Streams are the important exception. This is one reason why streams feel
so different from the rest of the language.
No operator overloading#
C# refresherOperator overloading: defining + for your own typeC# 1
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, which is + for strings. Java does not let
you overload any other operator. That matters most in money code. In the example below, every
variable is a decimal in C# and a BigDecimal in Java:
decimal total = price * quantity + shipping;
if (total > limit) { ... }BigDecimal total = price.multiply(quantity).add(shipping);
if (total.compareTo(limit) > 0) { ... }BigDecimal cannot define operators, so it uses methods. The method
multiply replaces *. The method add replaces
+. The test compareTo(limit) > 0 replaces >. When a
C# developer first writes money code in Java, this is the most common source of mistakes that
produce no error. See Numbers, money and time.
Lambdas and method referencestwist#
Java has no delegates. In Java, a delegate type becomes an interface with exactly one method to implement. This kind of interface is called a functional interface. It is also called a single abstract method (SAM) type. You can use any lambda with the right parameters and return type as a value of this interface.
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>();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 referenceinterface PriceRule { BigDecimal apply(Order o); }The delegate type becomes an interface with one method. A lambda such as
o -> BigDecimal.TENmatches this interface.Function<Integer, String> show = n -> n.toString();Function<Integer, String>does the same asFunc<int, string>in C#. A Java generic type cannot hold a plainint, so the code uses the wrapper typeInteger.Consumer<String> print = System.out::println;This is called a method reference. The form
Type::methodpasses an existing method as a lambda, like a method group in C#.Consumer<String>does the same asAction<string>in C#.Supplier<List<Integer>> make = ArrayList::new;This is called a constructor reference. It is a function that creates a new list.
Supplier<T>does the same asFunc<T>in C#.
Calling a lambdatwist#
names is a list of strings.
Func<int, string> show = n => n.ToString();
Action<string> print = Console.WriteLine;
print(show(42)); // prints 42
names.ForEach(print);Function<Integer, String> show = n -> n.toString();
Consumer<String> print = System.out::println;
print.accept(show.apply(42)); // prints 42
names.forEach(print);print.accept(show.apply(42));C# runs a delegate with brackets. A Java lambda is an object, so you call the one method of its interface. On a
Function, that method isapply. On aConsumer, that method isaccept.names.forEach(print);forEachcallsprintonce for each element.
A lambda and the variables around ittrap#
var lines = List.of("mug", "pen", "cup");
int count = 0;
lines.forEach(line -> count++);
System.out.println(count);Show the answer
local variables referenced from a lambda expression must be final or effectively
finalCaptured 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. If you type the same lines at the jshell prompt, they compile and print 3. The rule applies when the lines are inside a method.
The usual workaround is the class AtomicInteger from
java.util.concurrent.atomic:
var counter = new AtomicInteger();
lines.forEach(line -> counter.incrementAndGet());
System.out.println(counter.get()); // prints 3An AtomicInteger is an object whose value can change. So the lambda only reads
the variable that points to the object. The rule is awkward, but it prevents a whole group of
surprises. The rule also makes it safe to pass a lambda to another thread.
1TranslateWrite this in Java:
Func<string, int> length = s => s.Length;
Action<int> print = Console.WriteLine;
print(length("mug")); // prints 3
Func<string, int> length = s => s.Length;
Action<int> print = Console.WriteLine;
print(length("mug")); // prints 3Function<String, Integer> length = s -> s.length();
Consumer<Integer> print = System.out::println;
print.accept(length.apply("mug")); // prints 3A Java lambda is an object, so you call the one method of its interface. On a
Function, that method is apply. On a Consumer, that
method is accept. The type arguments are Integer, never
int.
2An expert asksDoes Java pass objects by reference?
ref and no
out.Quick reference#
This table lists every C# parameter pattern and its replacement in Java:
| 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, because everything is already by value |
High-performance C# code also passes Span<T>. A
Span<T> is a view over memory that avoids copying. The nearest tools in Java
are ByteBuffer and MemorySegment. You will rarely need them in a web
service.
C# refresherSpan and stackalloc: a view over memory, with no copyC# 7
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 copyFields, properties and immutability#
Java has no properties. In Java, a property is a naming convention. You write a
private field, a getX() method that reads the field, and a setX()
method that changes the field. For data that never changes, a
record writes all of this code for you.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | { get; set; } | getX() / setX() | frameworks find the getter and the setter by this exact naming rule Why |
| twist | { get; } | final field + getX() | the field is read-only after construction, so assign it in the constructor Why |
| twist | { get; init; } | final field + constructor | The field is set once in the constructor. A record does this for every field. Why |
| twist | => expression | a plain method | the method computes the value on each call, and there is no backing field Why |
| twist | required | no equivalent | make the field final and require its value in the constructor Why |
| twist | field keyword | no equivalent | declare the backing field yourself, and the getter and the setter use that field Why |
| trap | a property, whatever its name | getName(), isActive(), setName(v) | a method without the prefix is no property, so Jackson silently skips it Why |
| twist | record Customer(long Id, string Name) | record Customer(long id, String name) { } | one line replaces the fields, constructor and accessors Why |
| twist | customer.Name | customer.name() | record accessors have no get prefix, but current Jackson and Spring read them Why |
| twist | readonly | final | a final field is assignable once, but final fixes the reference, not the object it points to Why |
| twist | const, or static readonly | static final | one form for both, with a name in capitals such as MAX_RETRIES Why |
| same | a const is copied into the code that uses it | a static final constant is copied too | callers keep the old value of a changed constant until they recompile Why |
| twist | no equivalent | @Getter @Setter @Builder, from Lombok | Lombok writes the accessors while the class compiles, and it is common in older code Why |
The property gaptwist#
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;finalmeans that the field is set once, in the constructor. This does the same as{ get; init; }in C#.public String getName()This is a getter. Its name is
getplus the property name, and the property name starts with a capital letter.public void setName(String v)This is a setter. Its name is
setplus the property name.public String getLabel()A computed value is an ordinary method. The
getprefix lets frameworks treat the method as a read-only property.
This class has private fields, getters and setters, and it has no framework base class. A class like this is often called a plain old Java object (POJO). When a POJO follows the naming rules exactly, it is also called a JavaBean.
C# also has required and init. Since C# 14, it also has the
field keyword. Java has none of these three keywords. In their place, Java uses a
final field that the constructor sets.
C# refresherrequired, init-only and the field keywordC# 14
required forces the caller to set a property when the caller creates the
object. init allows the caller to set the property only at that moment. The
field keyword of C# 14 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 names are the contracttrap#
public class Customer {
private final String name = "Ada";
private final boolean active = true;
public String name() { return name; }
public boolean isActive() { return active; }
}
// ObjectMapper is Jackson's class for writing JSON.
System.out.println(new ObjectMapper().writeValueAsString(new Customer()));Show the answer
The naming rule is exact, and tools depend on it. The getter for a name
property is getName(). The getter for a boolean is
isActive(). The setter is setName(v). If the prefix is wrong, the
JSON library Jackson will not write the field. There is
no error. The value is just missing.
Name the method getName(), or make Customer a record. Jackson
reads the name() accessor of a record, although the accessor has no
get prefix.
The last line of the example is written for Jackson 3. On Jackson 2,
writeValueAsString declares a
checked exception. So the call also needs a
try or a throws clause.
Records make most of this disappeartwist#
A record is a good choice for immutable data, such as a
data transfer object (DTO), a value object or a query result.
In the code 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)This one line declares the fields, a constructor and the accessors. It also declares
equals,hashCodeandtoString.public String label()A record can have methods too.
new Customer(customer.id(), "Ada", customer.email())Java records have no
withexpression. So you create a changed copy yourself, with a call to the constructor.
Record accessors have no get prefix. You read a record
component name with c.name(), not with c.getName(). This
breaks the JavaBean naming rule on purpose. Older framework versions expected
getX(), so they could not read records. Modern Jackson and Spring support records.
But a library that has not been updated since about 2021 may not support them.
final is not readonly, quite#
C# has the keywords readonly and const. Java has one keyword,
final:
| C# | Java | Meaning |
|---|---|---|
| readonly field | final field | assignable once, in constructor or initialiser |
| const | static final | a compile-time constant, which is copied into the code that uses it |
| static readonly | static final | assigned in a static initialiser |
| readonly struct | no equivalent | as of Java 25 there are no user-defined value types, so use a record |
| init-only | final + constructor | assign once in the constructor, and a record does this automatically |
final makes the reference unchangeable, not the object. This is
identical to readonly in C#. It surprises 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 that the field points to, and final
allows this. names = new ArrayList<>() makes the field point at a new list,
and final forbids this. 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);Java uses static final for both const and
static readonly. MAX_RETRIES is written in capitals with underscores.
Constants are the one place where Java does not use camelCase. All Java code follows this
rule.
Some static final fields hold a primitive or a String that is a
compile-time constant. The value of such a field is copied into the code that uses
it, exactly like a const in C#. Suppose that you publish a library and later
change such a constant. A user of the library who does not recompile keeps the old value. For
anything that might change, use a static method or a value that is computed at run time.
A word on Lombok#
LegacyProject Lombok
Lombok is a tool that reads annotations such as
@Getter while your code compiles. It writes getters, setters, constructors,
equals, hashCode and builders for you. A tool of this kind is called
an annotation processor. You will see Lombok
in very many Java codebases. Lombok saved developers a lot of work before records existed.
@Getter @Setter @Builder
public class Customer {
private String name;
private String email;
}@Getter and @Setter generate the accessors for every field.
@Builder generates a builder. All of this happens while the class compiles.
Lombok is not deprecated, and it is still widely used. But it has a disadvantage. Lombok depends on the internal parts of the compiler. This has caused failures on several Java Development Kit (JDK) upgrades. For immutable data, a record is now the better answer, and a record needs no dependency. For mutable entities, many teams still use Lombok. Entities are the classes that Hibernate stores in the database.
1TranslateWrite this in Java:
public class Customer
{
public long Id { get; init; }
public bool Active { get; set; }
}
public class Customer
{
public long Id { get; init; }
public bool Active { get; set; }
}public class Customer {
private final long id;
private boolean active;
public Customer(long id) { this.id = id; }
public long getId() { return id; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
}init becomes a final field that the constructor sets. The getter of
a boolean starts with is, not get. Frameworks find all
three methods by their names.
2An expert asksA class declares
private final List<String> names = new ArrayList<>();. Can its code
still add a name to the list? What does final promise?
private final List<String> names = new ArrayList<>();. Can its code
still add a name to the list? What does final promise?names.add("ada") compiles, and the list grows. final fixes
the reference, not the object. The field can never point at another list, but the list itself
can change. readonly in C# has the same rule. For a list that cannot change, use
List.of(...), which returns a list that throws on any change.Records#
A record is a short class that holds data and compares by value. C# and Java records look almost the same. Java records are stricter. You can never reassign a field of a Java record. Java 16
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | record OrderLine(string ProductId, ...) | record OrderLine(String productId, ...) { } | the braces are required, even when the body is empty Why |
| twist | line.Quantity | line.quantity() | an accessor method, with brackets and no get prefix Why |
| same | value equality | value equality | two records with equal components are equal, with no code to write |
| twist | record struct | no equivalent | a Java record is always a reference type, never a struct Why |
| new | no equivalent | public Money { ... } | a compact constructor, which checks values before they are stored Why |
| twist | line with { Quantity = 3 } | line.withQuantity(3) | Java has no with expression, so you write a wither method yourself Why |
| twist | a record that holds a List<T> | lines = List.copyOf(lines) | the list inside can still change, as in C#, so copy it in the compact constructor Why |
| twist | a record as a DTO or a value object | the same, but never a JPA entity | Hibernate needs a no-argument constructor and fields it can change Why |
| twist | positional pattern on a record | case Card(String last4) | a record pattern takes a record apart by position, inside a switch Why |
The basicstwist#
OrderLine is one product on an order. Money is a small record
too. It holds 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) { }From this header, Java generates a private final field for each component. Java also generates an accessor method for each component, one constructor, and the methods
equals,hashCodeandtoString. The braces are required, even when the body is empty.line.quantity()The accessor is a method. You call it with brackets, and its name has no
getprefix. In C#,Quantityis a property.System.out.println(line);This line prints
OrderLine[productId=mug, quantity=2, unitPrice=Money[amount=9.50, currency=EUR]]. The Java output uses square brackets. In C#, the output ofToStringuses braces.
This table compares the other features of C# records and Java records:
| 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, only components |
| 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 withC# 10
You can declare a C# record by its parameters or by a body of properties. A C# record can inherit another record, and it can be a value type. It 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 changeChecking values: the compact constructornew#
An amount of money must never be negative. It should also always have two decimal places, so that 9.5 and 9.50 compare as equal. For rules like these, Java records have a form of constructor that C# does not have. It is called the compact constructor. A compact constructor checks or adjusts the values, and it does not repeat 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.
public Money(String amount) {
this(new BigDecimal(amount), "EUR");
}
}public Money {This is the compact constructor. It has no parameter list, because its parameters are the components of the record. It runs before the fields are set.
amount = amount.setScale(2, RoundingMode.HALF_EVEN);This line assigns to the parameter, not to the field. When the body ends, Java copies each parameter into its field. So you are not allowed to write
this.amount = amounthere.setScalerounds the amount to two decimal places, and it uses banker's rounding.this(new BigDecimal(amount), "EUR");An extra constructor must call the main constructor. It does that with
this(...). So the checks run for every record.
The missing with expressiontwist#
As of Java 25, Java has no with expression and nothing that replaces it. Here
are the options. In this code, line is an order line of two mugs:
var line = new OrderLine("mug", 2, new Money(new BigDecimal("9.50"), "EUR"));
// 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())This line repeats every component and changes one of them. That is fine for three components, but it is a lot to write for eight components.
public OrderLine withQuantity(int q)This method writes that construction once, inside the record. So callers can write
line.withQuantity(3). A method like this is called a wither.
A feature like with has been discussed for a future release. It is called
derived record creation, and it is not in Java 25. For a record with more than about four
components, the practical answer is a builder. For an example of a builder, see the chapter
Methods and parameters.
A record that holds a listtwist#
public record Order(long id, List<OrderLine> lines) { }
var price = new Money(new BigDecimal("9.50"), "EUR");
var lines = new ArrayList<OrderLine>();
var order = new Order(1, lines); // an order with no lines
lines.add(new OrderLine("mug", 2, price));
System.out.println(order.lines().size());Show the answer
Records are shallowly immutable. The field lines can never
point to another list. But the list that it points to can still change, and the caller kept a
reference to that list. C# has the same problem with a record that holds a
List<T>.
Copy the list in the compact constructor:
public record Order(long id, List<OrderLine> lines) {
public Order {
lines = List.copyOf(lines); // an unmodifiable copy
}
}List.copyOf makes a copy that nobody can change. A later change to the
caller's list does not change the order. And the list inside the order throws
UnsupportedOperationException if anyone tries to add to it.
Where records fittwist#
Records are a good choice for data that is created once and then only read. The usual example is a data transfer object (DTO). The usual exception is a Jakarta Persistence (JPA) entity:
// Data sent out of the service: created once, then only read. A record.
public record OrderSummary(long id, String customerName, Money total) { }
// A database entity: Hibernate creates it empty, then sets its fields. A class.
// It is a different Order from the record in the prediction.
@Entity
public class Order {
@Id private long id;
protected Order() { }
}OrderSummary only carries values, so it is a record. This Order is
not the record from the section A record that holds a list. It is
the entity that Hibernate loads and saves. Hibernate
needs the no-argument constructor protected Order() { }. Hibernate also needs
fields that it can change. So the entity stays a class. For more about entities, see the
chapter Data access, JPA and migrations.
| 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-argument constructor and fields it can change |
| 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, so use an interface instead |
Records and pattern matching#
Record patterns depend on records. The
components of a record are part of its type, so a switch can take a record apart by position.
This example uses the sealed type
Payment from the chapter
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)This is a record pattern. If
pis aCard, the pattern puts the component of the card into the variablelast4. Components are matched by position.when last4.equals("0000")This is a guard, like
whenin C#. The arm matches only if the condition is also true.
C# can also match patterns on positional records. But C# has no equivalent of a Java sealed type. So the C# compiler cannot know that the set of cases is complete, and it warns unless you add a discard arm.
1TranslateWrite this in Java, where line is an OrderLine:
public record OrderLine(string ProductId, int Quantity, Money UnitPrice);
var more = line with { Quantity = 3 };
line is an OrderLine:
public record OrderLine(string ProductId, int Quantity, Money UnitPrice);
var more = line with { Quantity = 3 };public record OrderLine(String productId, int quantity, Money unitPrice) {
public OrderLine withQuantity(int quantity) {
return new OrderLine(productId, quantity, unitPrice);
}
}
var more = line.withQuantity(3);Java has no with expression, so you write the wither yourself. The braces
after the record header are required, even on a record with no body.
2An expert asksWhy can a JPA entity not be a record?
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 patterns work like C# patterns. Over a sealed type Java 17, the Java compiler checks that a switch handles every case.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | is T x | instanceof T x | the same type test with a binding, spelled instanceof in Java Why |
| twist | is not T x | !(o instanceof T x) | Java has no is not, so put the test in brackets and negate it Why |
| twist | o is int i | o instanceof Integer i | test against the wrapper type, because a boxed int is an Integer Why |
| twist | switch expression arms | case T x -> | each arm is written case pattern -> result, not pattern => result Why |
| same | when clause | when clause | the same keyword for an extra condition, added in Java 21 Why |
| twist | _ discard | default | Java uses default, or _ for unnamed variables Why |
| twist | a _ arm, even when every kind has an arm | no default over a sealed type | Java checks that every permitted type has an arm. Without the _ arm, C# only warns (CS8509). Why |
| trap | null => | case null | default does not catch null, so without case null the switch throws NullPointerException Why |
| twist | property pattern { X: 1 } | no equivalent | test the property in a when clause instead Why |
| twist | list pattern [1, .., 2] | no equivalent | Java has no form for this, so check the size and the elements in ordinary code Why |
| twist | relational pattern > 5 | only inside when | write case Integer i when i > 5 instead of a bare > 5 Why |
| twist | positional pattern | record pattern | Java 21 added the record pattern. It works on records and takes them apart by component. Why |
| twist | _ inside a positional pattern | _ inside a record pattern | an unnamed pattern skips a component you do not need, and it was added in Java 22 Why |
| same | a nested positional pattern over a null component | Money(_, var currency) never matches null | As in C#, the whole pattern then fails. But var matches null in both languages. |
| trap | Order(_, Customer c) skips a null customer | Order(_, Customer c) matches it, and c is null | a type pattern that covers the component's whole type also matches null in Java Why |
| twist | no equivalent | quantity instanceof int whole | The test is true only when the double holds a whole number that fits an int. It is a preview in Java 25, so do not ship it. Why |
Type patterns Java 16twist#
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 cardIf
paymentis aCard, the test creates a new variable,card. The variable has the typeCardand is already cast. This is called a type pattern. It is the same idea asis Card cardin C#.
C# writes o is int i. Java writes o instanceof Integer i, because
a boxed number is tested against its
wrapper type.
Sometimes an if leaves the method when the test fails. Then the variable is
also in scope after that if, as in C#:
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. So put the whole test in brackets and negate it with!.
LegacyCast after instanceof
Before Java 16, a type test and a cast were two separate steps. Every Java codebase older than about 2021 has this code in many places:
if (payment instanceof Card) {
Card card = (Card) payment;
refundToCard(card.last4(), amount);
}(Card) payment is the cast. It still compiles, but do not write it now.
Pattern matching in switch Java 21twist#
Payment is a sealed interface with three kinds. For its declaration, see the
chapter Interfaces, abstract classes and sealing. A payment is
null until the order is paid. A test card ends in 0000.
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 nullThis arm handles a missing payment, as the
null =>arm does in C#.case Card c when c.last4().equals("0000")This is a type pattern with a condition after
when. The condition is called a when guard. The arm matches only ifpaymentis aCardand the condition is true, as in C#. Strings are compared withequals, because==in Java 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. If you put the guarded arm after this arm, the compiler reports that the guarded arm can never match. C# reports the same error.
No default over a sealed typetwist#
The two versions of describe differ at the end. Without the _ arm,
the C# compiler only warns (CS8509). The C# switch then throws at run time for a payment that
no arm handles. Java needs no default, because Payment is sealed and
lists exactly three kinds. Then a colleague adds a fourth kind:
sealed interface Payment permits Card, BankTransfer, Voucher, GiftCard { }
record Card(String last4) implements Payment { }
record BankTransfer(String iban) implements Payment { }
record Voucher(String code) implements Payment { }
record GiftCard(String number) implements Payment { } // added this week
String describe(Payment payment) {
return switch (payment) {
case Card c -> "card ending " + c.last4();
case BankTransfer t -> "transfer from " + t.iban();
case Voucher v -> "voucher " + v.code();
};
}Show the answer
the switch expression does not cover all possible input valuesA switch over a sealed type must have an arm for every permitted type.
Payment is sealed, so the compiler knows its four kinds. This switch handles only
three kinds. C# cannot check this, because nothing stops another assembly from adding a kind.
So without the _ arm, the C# compiler only warns (CS8509).
Add case GiftCard g. Do not add default. With
default, the switch would compile again, and the compiler would not report the
next new kind.
A switch on nulltrap#
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. For an example, see the prediction
a switch on a null string. In a C#
switch expression, a null arm or
the _ arm handles null. In Java, default does not handle null. If
null can arrive, write case null. To send null to the fallback arm, write
case null, default ->.
This version of describe names one kind of payment. It sends every other kind,
and also a missing payment, to one arm:
String describe(Payment payment) {
return switch (payment) {
case Card c -> "card ending " + c.last4();
case null, default -> "not a card";
};
}Suppose you write a plain default in place of
case null, default. Then describe(null) throws
NullPointerException.
Patterns Java does not havetwist#
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()Java has no property, list or relational patterns. The type pattern
Order omatches every order, so the when guard does all the testing.case Order o -> "normal";This arm has no guard, so it matches every order that no earlier arm matched. That makes the switch complete, so the switch needs no
default.
When every arm is a guard like this, plain if statements work equally well.
Many Java developers would write if statements here.
C# refresherC# positional, property, list and relational patternsC# 11
A C# pattern can take a record apart by position. It
can test properties by name, and it can match the shape of a list. It can also 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" }; // relationalRecord 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. It puts 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)This pattern matches a
Cardand puts its one component into a new variable,last4. Withvar, the compiler infers the type, which isStringhere. You may writeString last4instead.case Voucher(var code) -> "voucher " + code;Components are matched by position, in the order in which the record declares them. A positional pattern in C# works the same way. 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 that is priced in another currency. An order line holds a
Money, and Money is itself a record. So one pattern can take both
records apart:
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))This pattern matches an
OrderLineand puts itsproductIdinto a variable. It skips the quantity. Then it takes the unit price apart. There it skips the amount and puts thecurrencyinto a variable._This is called an unnamed pattern Java 22. It matches any value and gives the value no name, like the discard in C#. Use it for a component that you do not need.
C# nests positional patterns in the same way. The same test in C# is
line is OrderLine(var productId, _, Money(_, var currency)).
A nested record pattern never matches null, in Java and in C#. Suppose the
unitPrice of an order line is null. That line does not match
Money(_, var currency), so the whole pattern fails. The check then skips that
line and reports nothing. A var component accepts null in both languages, so
productId can be null. If a null matters, test for it separately. The two
languages differ for a nested type pattern. See
A null component inside a record pattern.
A null component inside a record patterntrap#
A record pattern names a record and gives one pattern
for each of its components. A C# positional pattern does the same. Here _ skips
the id, and Customer c is a type pattern for the customer:
record Customer(String name) { }
record Order(long id, Customer customer) { }
var order = new Order(2, null); // a guest order: no customer
if (order instanceof Order(_, Customer c)) {
System.out.println("placed by " + c);
} else {
System.out.println("no customer");
}Show the answer
A type pattern inside a record pattern also matches null.
Customer c covers the component's whole type, so Java matches it without a test.
That is why c is null. In C#, order is Order(_, Customer c) is false
for this order, and the else branch runs.
Take the component apart instead. The pattern Order(_, Customer(var name))
never matches a null customer, in Java or in C#. Or keep the type pattern and add
&& c != null.
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 disabled unless you ask for it. The feature here is called
Primitive types in patterns, instanceof and switch Java 25
preview. It lets a pattern test the value of a number. For example,
instanceof int on a double is true only if the value converts to an
int and nothing is lost.
Imagine quantities that are imported from a spreadsheet. 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 wholeThis test is true, because 3.0 fits in an
intexactly. Sowholebecomes 3.2.5 instanceof intThis test is false, 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. C# has no
counterpart. In C#, quantity is int whole on a double does not
compile.
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. They can change or disappear in the next release.
Class files that were compiled with preview features
do not load on any other Java Development Kit (JDK)
version.
1TranslateA C# switch over an Order has this arm. Write the arm in Java:
{ Lines.Count: > 10 } => "large"
Order has this arm. Write the arm in Java:
{ Lines.Count: > 10 } => "large"case Order o when o.lines().size() > 10 -> "large"; Java has no property
or relational patterns, so a type pattern matches the order and a when guard does the
test.2An expert asksIn a switch over a Payment, why does Java reject the arm
case Card c when c.last4().equals("0000") when it is written after a plain
case Card c arm?
Payment, why does Java reject the arm
case Card c when c.last4().equals("0000") when it is written after a plain
case Card c arm?case Card c already matches
every card, so the guarded arm could never run. The compiler reports this case label is
dominated by a preceding case label. Write the guarded arm first.Switch expressions and statements#
Java has two kinds of switch. The arrow form Java 14 works like the switch expression in C#. It is the form to write. The old colon form works like the switch statement in C#. You only need to read the colon form.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | value switch { ... } | switch (value) { ... } | the value goes in brackets after the keyword Why |
| twist | pattern => result | case pattern -> result | the word case, and an arrow instead of => Why |
| twist | or between patterns | comma between labels | labels that share an arm are separated by a comma, as in case SATURDAY, SUNDAY -> Why |
| twist | _ | default | the fallback arm, used when no other arm matches Why |
| same | throw in an arm | throw in an arm | in both languages, an arm can throw instead of giving a value |
| twist | a missing case is a warning (CS8509), and an exception at run time | a missing case is a compile error | over an enum or a sealed type, Java checks every case and needs no default Why |
| twist | Card => CardFee(total) | case Card c -> { ...; yield fee; } | C# arms cannot hold statements. A Java block arm ends with yield, because the compiler rejects return there. Why |
| twist | yield return | no equivalent | yield in Java only gives the value of a block to its switch, and it produces no sequence Why |
| trap | a case with no break is a compile error | a case with no break falls through | in the old colon form the next case runs too, and Java compiles it with no warning Why |
| twist | goto done; | break scan; | leaves the labelled loop and every loop inside it Why |
| twist | switch on a long | no equivalent | a switch takes int, String, enums and patterns, but not long, so use if and else Why |
The arrow formtwist#
Here day is a DayOfWeek. DayOfWeek is 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 that is tested goes in brackets after
switch. C# puts the value before the keyword and writesday switch.case SATURDAY, SUNDAY -> "packed on Monday";Each arm starts with
case. An arrow->separates the labels from the result. C# writes=>there. Two labels share this arm, and a comma separates them. C# writesorbetween two labels. Enum constants are usually written without the type name. So you writeSATURDAY, notDayOfWeek.SATURDAY.default -> "packed today";This is the fallback arm, like
_in C#.
In Java, this arrow form is called a switch expression. The whole switch produces a value, so the statement ends with a semicolon after the closing brace, as in C#.
Multi-statement arms#
In the chapter 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. An arm of a C# switch expression cannot
hold statements, so C# moves the steps into a method. Java puts the steps 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 value is the larger of the two fees. Areturnhere would try to leave the whole method, and the compiler rejects it.
In Java, yield only gives the value of a block to its switch. It is not related
to yield return in C#, which produces a sequence one value at a time. Java has no
yield return at all. For what Java uses in its place, see the chapter
Streams vs LINQ.
C# refresheryield return: C# iteratorsC# 2
A C# method with yield return produces a sequence lazily. The method produces
each value only when the caller asks for it. Then 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); // 0246Exhaustivenesstwist#
A switch that returns a value must handle every possible value. The compiler checks that for a sealed type. See No default over a sealed type. The compiler checks an enum in the same way:
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";
};
}OrderStatus is an enum with four constants. For how a Java enum differs from a
C# enum, see the chapter Enums. The switch covers all four constants, so
it needs no default. In C#, a missing case is only a warning, and the switch
throws SwitchExpressionException at run time.
The old colon formtrap#
Existing code often uses the older colon form. In the colon form, a label ends in a colon,
such as case NEW:, and statements follow the label. Learn to recognise the colon
form, but do not write it.
enum OrderStatus { NEW, PAID, SHIPPED, CANCELLED }
OrderStatus status = OrderStatus.NEW;
switch (status) {
case NEW:
System.out.println("send a payment reminder");
case PAID:
System.out.println("reserve stock");
break;
default:
System.out.println("nothing to do");
}Show the answer
In the colon form, a case with no break runs on into the next
case. This is called fall-through. A colon label starts a series of
statements that continues until a break. There is no break after the
first println, so a new order also reserves stock. C# reports an error for this,
but Java compiles it and reports nothing.
The arrow form cannot fall through at all, so this bug cannot happen in the arrow form.
When several labels need the same action, list the labels in one arm, as in
case NEW, PAID ->.
The old statement form#
LegacyColon switch with fall-through
The colon form is still legal and still common. It causes a well-known bug, which is a
missing break. For an example, see the section
The old colon form. Write the arrow form instead.
Java has no goto. C# code mostly uses goto to leave nested loops.
Java leaves nested loops 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:This is a label. A label is a name followed by a colon, and it is placed just before the outer loop.
for (var order : orders)This loop does the same as
foreachin C#. Java writes the keywordfor, and a colon between the variable and the collection. C# writesinthere.break scan;This statement leaves the loop labelled
scan, and it leaves the inner loop too. A plainbreakwould leave only the inner loop.continue scan;also works, and it continues with 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, and labels are compared with equals, not with == |
| enum | yes | allowed since enums arrived in Java 5, and you write the constant name without its type |
| 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. Here
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. It does not matter whether the two strings are the same object.
With ==, it would matter.
You cannot switch on long. This gap is strange, and it exists for historical
reasons. It surprises developers who switch on a timestamp or an ID. Use
if/else if, or a Map<Long, ...> lookup.
1TranslateWrite this in Java, where status is an OrderStatus:
var canCancel = status switch
{
OrderStatus.New or OrderStatus.Paid => true,
_ => false
};
status is an OrderStatus:
var canCancel = status switch
{
OrderStatus.New or OrderStatus.Paid => true,
_ => false
};var canCancel = switch (status) {
case NEW, PAID -> true;
default -> false;
};The value goes in brackets after switch. Labels that share an arm are
separated by commas. Enum constants are written without the type name.
2An expert asksAn arm of a Java switch expression can be a block of statements. Why does
the block end with yield, and not with return?
yield, and not with return?yield gives the block's value
to the switch. return would leave the whole method, so the compiler rejects it
there. In Java, yield is not related to yield return in C#. The Java
yield produces no sequence.var, strings and text blocks#
This chapter covers local variables declared with var Java 10,
and everyday work with strings. It also covers text blocks Java 15, which are
like raw string literals in C#. var works as it does in C#. The differences are in
strings.
| Kind | C# | Java | Note |
|---|---|---|---|
| same | var count = 0; | var count = 0; | the compiler infers the type of a local variable from its value, as in C# Why |
| twist | (a, b) => a + b | (var a, var b) -> a + b | Java also accepts var on the parameters of a lambda, but C# allows var on local variables only Why |
| trap | var names = new List<string>(); | var names = new ArrayList<>(); | with var, the empty diamond has no declared type to use, so the list holds Object Why |
| twist | $"{a} and {b}" | a + " and " + b | Java has no string interpolation. For many values, use "%s and %s".formatted(a, b). Why |
| twist | $"{x:F2}" | "%.2f".formatted(x) | Java uses printf-style specifiers such as %.2f, %d and %s Why |
| twist | string.Format | String.format | Java fills the %s placeholders in order, so they have no numbers like {0} and {1} Why |
| same | StringBuilder | StringBuilder | Java has the same class with the same methods, and you use it in the same way Why |
| twist | string.Join(",", xs) | String.join(",", xs) | Pass a separator and then the items. The items must be text, so a list of numbers does not compile. Why |
| twist | string.IsNullOrEmpty(s) | s == null || s.isEmpty() | you write the null check yourself, because isEmpty() alone throws on null Why |
| twist | string.IsNullOrWhiteSpace(s) | s == null || s.isBlank() | isBlank() arrived in Java 11 and ignores whitespace, like the C# method Why |
| same | s.Contains(t) | s.contains(t) | the name and the meaning are the same, and both methods throw if t is null Why |
| trap | s == t | s.equals(t) | == compares references, so it fails on strings built at run time and reports no error Why |
| twist | """ raw string literal | """ text block | the same three quotes, but Java needs a line break after the opening quotes Why |
| twist | \d in a raw string | \\d in a text block | a text block still processes escapes, so a regular expression keeps doubled backslashes Why |
| twist | regex.Match(text) | pattern.matcher(text).find() | Java splits the work between a Pattern, which is compiled once, and a Matcher for each input Why |
| trap | regex.IsMatch(text) | matcher.find(), not matcher.matches() | matches() succeeds only if the whole text matches, so it is not IsMatch Why |
| twist | StringBuilder out of habit | a + b + c | Since Java 9 the compiler builds the string once. Keep StringBuilder for loops. Why |
vartwist#
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>. After that the type cannot change, exactly as in C#.ArrayListdoes the same asList<T>in C#.for (var line : lines)varworks in loops too. For a for-each loop, C# writesforeachandin. Java writesforand a colon.
Java allows var in the same places as C#, with one small difference. In Java,
a lambda may declare its parameters with var,
as in (var a, var b) -> a + b.
| 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 and the diamondtrap#
import java.util.ArrayList;
var names = new ArrayList<>();
names.add("mug");
String first = names.get(0);Show the answer
var with an empty diamond gives you a list of Object.
The empty brackets <> are called the diamond. The diamond asks the compiler
to take the type argument from the variable's declared type. With var there is no
declared type, so the compiler uses Object. The first two lines compile, and the
list accepts any value.
When you use var, write the type argument:
var names = new ArrayList<String>();. Or declare the type and keep the
diamond: List<String> names = new ArrayList<>();.
No string interpolationtwist#
The next example uses three values. name is a String.
orderId is a long. total is 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 "Joining with
+turnsorderIdandtotalinto text for you..formatted(name, orderId, total)formattedfills in a template, likestring.Formatin C#.%stakes any value as text.%dtakes a whole number.%.2ftakes a decimal number and shows it with two decimal places. The values fill the placeholders in order.
Java has no $"...", and this is still true in Java 25.
String templates were previewed in Java 21 and 22 and then
withdrawn. They are not in Java 23, 24 or 25, and the design is being
reconsidered. Code from tutorials that used string templates no longer compiles.
To learn when to use each replacement, see the section Choosing a replacement.
Formatting and everyday string methodstwist#
Here productId, note and total are single values.
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 fills the placeholders in order. C# numbers its placeholders
{0}and{1}.note == null || note.isBlank()Java has no
IsNullOrWhiteSpace.isBlank()is true for text that is empty or holds only whitespace. But callingisBlank()onnullthrows, so the null check comes first. To do the same asIsNullOrEmpty, callisEmpty()in the same way.
Choosing a replacementtwist#
The code below shows the replacements for interpolation, and when to use each
replacement. name, orderId and total are the values
from the section No string interpolation. lines holds the
lines of the order.
// 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)String.formatis the older form of the same template method. Every Java version has it. The comma in%,.2fadds thousands separators.sb.append(line.productId()).append(',');appendreturns the builder, so you can chain the calls. AStringBuilderin C# works the same way.""".formatted(name, orderId);A text block is an ordinary
String, soformattedworks on it too.
Comparing stringstrap#
== on strings compares references, not contents. The
comparison often appears to work, because the
Java Virtual Machine (JVM) reuses one object for identical
string literals. So "abc" == "abc" is true. C# has an operator overload that makes
== compare the text of two strings. Java has no such overload. To predict the
result yourself, see == on two strings.
Compare the text with equals. Here typed is a voucher code that a
customer typed:
import java.util.Objects;
boolean equal = typed.equals("SPRING"); // what C#'s == does for strings
boolean safe = Objects.equals(typed, "SPRING"); // the same, and no exception if typed is nulltyped.equals throws when typed is null.
Objects.equals returns false instead.
C# refresherString == in C#: an operator overload that compares contentsC# 1
string a = "abc";
string b = new string("abc".ToCharArray()); // a different object, same characters
Console.WriteLine(a == b); // True: string overloads == to compare textText blockstwist#
var json = """
{
"orderId": 1042,
"status": "PAID"
}
""";String json = """
{
"orderId": 1042,
"status": "PAID"
}
""";String json = """Three quotes and a line break open the text block. Java requires the line break, so the text starts on the next line.
""";Java removes the indentation that every line shares. Here that is four spaces, so the text starts at the brace. The closing quotes count as a line too. If you move the closing quotes further left, the indentation is kept.
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, so \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. So
\d is an invalid escape and does not compile. You still write
\\d. A raw string in C# does no escape processing at all. Use a text block
for SQL, JSON and HTML, and accept the doubled backslashes in a regular expression.
IsMatch in Javatrap#
A customer's note may mention a product code such as sku-1042. In C#,
sku.IsMatch(note) tells you whether the note contains a product code. In Java,
the classes
Pattern and Matcher replace the Regex class of .NET.
Matcher has a method with a name similar to IsMatch:
import java.util.regex.Pattern;
Pattern sku = Pattern.compile("sku-\\d+"); // in a Java string, \\d is the regex \d
String note = "please wrap sku-1042 as a gift";
System.out.println(sku.matcher(note).matches());Show the answer
matches() is not IsMatch.
Matcher.matches() requires the entire text to match the pattern. The note holds
more than the product code. So the call returns false, and nothing reports an
error.
Write sku.matcher(note).find(), which searches for the pattern anywhere in the
text. For the rest of Regex, see the section
Regular expressions.
Regular expressions#
In Java, the classes Pattern and Matcher replace the
Regex class of .NET. The pattern syntax is almost the same. But Java splits the
work 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 digits. phone holds
one number, and notes holds text that may contain several numbers:
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})")This line compiles the pattern once. Every backslash is written twice, because in a Java string
\\means one backslash. Named groups use the same(?<name>...)syntax as .NET.re.matcher(phone)A
Matcherapplies the pattern to one piece of text. It remembers its position in that text, so you make a newMatcherfor each input.m.find()find()searches for the pattern anywhere in the text, likeMatchfollowed bySuccessin C#.m.group("area")This call reads a named group, like
Groups["area"].Valuein C#.results().toList()This line gets every match, like
Matchesin C#.results()returns a stream, which is like a LINQ sequence in C#.toList()collects the stream into a list. For more about streams, see the chapter Streams vs LINQ.
The next example shows options and the other everyday methods. 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); // Java has no Compiled option
boolean mentionsProduct = sku.matcher(note).find(); // not matches()
String[] pieces = sku.split(note); // the same, but no empty last piecePattern.CASE_INSENSITIVEOptions are constants on
Pattern. You pass them as the second argument. Java has no option likeRegexOptions.Compiled, so leave it out.sku.matcher(note).find()This call does the same as
IsMatchin C#. Usefind(), because the similar-lookingmatches()succeeds only if the whole text matches.sku.split(note)This call returns the text between the codes. When the note ends with a code,
Splitin .NET also returns an empty last piece.splitin Java does not return that empty piece.
Here is the full mapping from Regex in .NET:
| .NET | Java | Note |
|---|---|---|
| new Regex(p) | Pattern.compile(p) | compile the pattern once and 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, and it returns a Stream of MatchResult, one per match |
| m.Groups["name"] | m.group("name") | named groups use the same (?<name>...) syntax in both languages |
| m.Groups[1] | m.group(1) | group(0) is the whole match in both languages |
| re.Replace(s, r) | m.replaceAll(r) | the replacement refers to a group as $1 in both languages |
| re.Split(s) | p.split(s) | Java drops an empty last piece, which .NET keeps. String.split compiles the pattern on each call. |
| RegexOptions.IgnoreCase | Pattern.CASE_INSENSITIVE | pass it as the second argument to Pattern.compile |
| RegexOptions.Compiled | no equivalent | Java has no such option, so leave it out when you translate |
| @"verbatim" | no equivalent | every backslash must be doubled |
Java has no verbatim string, and that causes two traps.
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.
In this one place, C# raw strings are clearly better, and Java has no workaround.
matches() is not IsMatch.
Matcher.matches() requires the entire text to match. find()
searches for the first occurrence. Using the method with the familiar-looking name is a common
bug, and nothing reports an error. For an example, see the prediction
a product code inside a note.
A Pattern never changes and is safe to share between threads. So compile it
once into a private static final field. A Matcher does change, and it
is not safe to share between threads. So create a new Matcher each time.
String.matches, String.split and String.replaceAll
compile the pattern again on every call. That is fine for an occasional call, but it is slow in
a loop.
Concatenation performance#
Do not use StringBuilder out of habit. Since Java 9, the compiler turns
a + b + c into a single call. That call calculates the final length and builds the
string once. StringBuilder is still the right tool inside a loop, because there
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 together, 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 all the text built so far on
every pass. So the work grows much faster than the number of lines. sb.append adds
the text to the same builder, as it does in C#.
1TranslateWrite this in Java. quantity is an int.
productId is a string. unitPrice is a BigDecimal.
var label = $"{quantity} x {productId} at {unitPrice:F2} EUR";
quantity is an int.
productId is a string. unitPrice is a BigDecimal.
var label = $"{quantity} x {productId} at {unitPrice:F2} EUR";var label = "%d x %s at %.2f EUR".formatted(quantity, productId, unitPrice);Java has no string interpolation. Each placeholder names the kind of value.
%d takes a whole number, and %s takes any text. %.2f
does the same as :F2. The values fill the placeholders in order.
2An expert asksA colleague shows that "abc" == "abc" is true in Java, and
concludes that == compares strings by their text. Why is it true, and why is the
conclusion wrong?
"abc" == "abc" is true in Java, and
concludes that == compares strings by their text. Why is it true, and why is the
conclusion wrong?== is
false. Compare strings with equals.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 have fields and methods of its own.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | enum OrderStatus { New = 1, Paid = 2 } | enum OrderStatus { NEW, PAID } | each constant is an object, not a named number Why |
| twist | (Status) 2 | no equivalent | Java has no cast from int, so index into values() only if you must Why |
| trap | (int) e | e.ordinal() | ordinal() is the position in the declaration. Never store it, because a new constant shifts it. Why |
| new | [Description] attribute | a field on the enum | give the enum a constructor argument, such as a label Why |
| new | a switch, or a class for each option | a method body on each constant | each constant can supply its own version of a method Why |
| twist | Enum.Parse<T>(s) | Status.valueOf(s) | matches the exact name, and throws IllegalArgumentException if unknown Why |
| twist | Enum.TryParse | no equivalent | valueOf throws IllegalArgumentException for an unknown name and NullPointerException for null, so catch both Why |
| twist | Enum.GetValues<T>() | Status.values() | returns a new copy of the array on every call, so keep the array in a field if you need it often Why |
| twist | e.ToString() | e.name() | name() is always the constant as declared, but toString() can be overridden Why |
| twist | Dictionary keyed by enum | EnumMap | a map backed by an array indexed by ordinal, so lookups are fast Why |
| twist | [Flags] | EnumSet | Java has no bitwise enums, but an EnumSet is a bit vector inside Why |
| twist | OrderStatus.New => | case NEW -> | a case label is the bare constant, without the type name Why |
| twist | a singleton class | enum ExchangeRates { INSTANCE; } | a one-constant enum has exactly one object, but in a Spring application, use a bean Why |
The shape of the differencetwist#
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 next sectionNEW, PAID, SHIPPED, CANCELLEDThese are the constants, written in capitals by convention. There are no numbers to choose. Each constant is a single object of type
OrderStatus, and it is created when the enum is first used.OrderStatus.valueOf("PAID")This call finds a constant by its name, like
Enum.Parsein C#. Java has no cast from a number to an enum.OrderStatus.PAID.ordinal()This call returns the position of the constant in the list, counting from 0. The position is not a value that you chose.
Storing an enum as a numbertrap#
enum OrderStatus { NEW, ON_HOLD, PAID, SHIPPED, CANCELLED } // ON_HOLD was added this week
int stored = 1; // saved last month with PAID.ordinal(), when PAID came second
System.out.println(OrderStatus.values()[stored]);Show the answer
Never persist ordinal(). ordinal() is the
position in the declaration. So when you add a constant, every constant after it gets a new
number, and nothing warns you. Every order that was stored as 1 changes from paid to on
hold.
Store name() instead. A code field that you control is even better. For an
example, see the section Enums with state and behaviour.
Enums with state and behaviournew#
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 the
labels with a [Description] attribute. A Java enum holds this data 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;This is an ordinary field. It is a final field, which is set once in the constructor. So the code of a constant never changes.
OrderStatus(String code, String label)This is the constructor. It is always private, even without the keyword. So no code outside the enum can create a fifth status.
OrderStatus.SHIPPED.label()This line 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 do not change.
Constants with their own behaviournew#
A constant can also supply its own version of a method. The shop offers two delivery options. Standard delivery costs 4.95, but it is free for orders of 50 or more. 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);This line declares a method with no body, so every constant must supply a body.
STANDARD {This is a constant with a body of its own. The body supplies
pricefor standard delivery.total.compareTo(new BigDecimal("50")) >= 0BigDecimalhas no>=operator.compareToreturns a negative number, zero or a positive number. So>= 0means that the total is at least 50.
C# enums cannot hold methods. So in C#, this usually becomes a switch, or a
small class for each option. A Java enum keeps the whole closed set of options in one place,
together with the rule of each option.
Everyday enum operationstwist#
Here text holds a status that arrived in a request, such as
"PAID". The variable 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 | NullPointerException 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")This call finds the constant by its exact name. There is no option to ignore case.
catch (IllegalArgumentException | NullPointerException e)Java has no
TryParse. So you catch the exceptions thatvalueOfthrows for an unknown name and for null. The|catches either type.OrderStatus.values()This call returns every constant in declaration order, like
Enum.GetValuesin C#. It returns a new array on every call, so keep the array in a field if you need it often.new EnumMap<OrderStatus, Integer>(OrderStatus.class)This is a map whose keys are enum constants. The map is kept in an array indexed by position, so it is faster than an ordinary map.
OrderStatus.classis the type itself as a value, liketypeof(OrderStatus)in C#.
Flags become an EnumSettwist#
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. An EnumSet
stores one bit for each constant inside, so it is as fast as a flags enum, and it is 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)This is a set that holds two constants. C# combines two flags with
|. The empty set isEnumSet.noneOf(Handling.class). It does the same asNonein C#.handling.contains(Handling.FRAGILE)This call does the same as
HasFlagin C#.
Enums and switch#
You saw a switch over OrderStatus in the chapter
Switch expressions and statements. That switch needs no
default, because it covers every constant. Here is the switch 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 switch has four labels, from NEW to CANCELLED. The four labels
cover every constant, so the switch needs no default.
Inside a case label, you write the constant without the type name. So you
write NEW, not OrderStatus.NEW. Before Java 21, the form with the
type name was a compile error. Since Java 21, both forms compile, but the name without the
type is still the convention that 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 reliable 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;This is the only constant, so there is exactly one
ExchangeRatesobject in the program.new ConcurrentHashMap<>()This is a map that several threads can use at the same time, like
ConcurrentDictionaryin C#.
In an application that uses Spring, you would make this a bean instead. A bean is an object that Spring creates once and gives to every class that needs it. It is still useful to recognise the enum singleton in library code.
1TranslateWrite this C# line in Java:
bool ok = Enum.TryParse<OrderStatus>(text, out var parsed);
bool ok = Enum.TryParse<OrderStatus>(text, out var parsed);OrderStatus parsed;
try {
parsed = OrderStatus.valueOf(text);
} catch (IllegalArgumentException | NullPointerException e) {
parsed = null; // not a known status
}Java has no TryParse. Call OrderStatus.valueOf(text), and catch
the two exceptions that it can throw. It throws IllegalArgumentException for an
unknown name. It throws NullPointerException when text is
null.
2An expert asksWhy can a Java enum constant hold a code and a label, when a C# enum member
cannot?
Generics and type erasure#
Java generics look like C# generics. A class or a method takes a type parameter, with an optional constraint. The most important difference is called type erasure. Java checks type arguments when it compiles, and then removes them.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | where T : Base | <T extends Base> | classes and interfaces both use extends Why |
| twist | where T : IFoo, IBar | <T extends Foo & Bar> | Join bounds with &. If one bound is a class, it must come first. Why |
| twist | where T : class | no equivalent | every type argument is a reference type, so Java needs no such constraint Why |
| twist | where T : struct | no equivalent | Java has no user-defined value types to constrain to Why |
| twist | where T : unmanaged | no equivalent | Java has no unmanaged or pointer types Why |
| trap | List<string> and List<int> are two types | one class at run time | Java erases type arguments when it compiles, so a running program cannot see them Why |
| twist | where T : new() | no equivalent | Java has no new T(), so pass a Supplier<T> such as Customer::new instead Why |
| twist | x is List<string> | x instanceof List<?> | a list does not know its element type at run time, so only List<?> can be tested Why |
| twist | default(T) | null | a type parameter is always a reference type, so null is its only default Why |
| twist | typeof(T) | a Class<T> parameter | the caller passes Customer.class, because T is erased Why |
| twist | IEnumerable<out T> | List<? extends T> | variance is written at each use, with a wildcard Why |
| twist | IComparer<in T> | Comparator<? super T> | for the consumer side, C# declares in and Java writes ? super Why |
| twist | List<int> | List<Integer> or int[] | A type argument cannot be a primitive, so each int is boxed into an Integer. An int[] holds plain ints. Why |
| twist | Dictionary<int,V> | Map<Integer,V> | every int key is boxed into an Integer object Why |
| twist | IEnumerable<int> | IntStream | a stream of plain ints, so nothing is boxed Why |
| twist | Nullable<int> / int? | Integer | an Integer can be null, so it does the same as int? in C# Why |
| twist | JsonSerializer.Deserialize<List<Customer>>(json) | new TypeReference<List<Customer>>() { } | an anonymous subclass keeps the full generic type, so Jackson can read it back Why |
| twist | the non-generic ArrayList of System.Collections | List raw = new ArrayList(); | A raw type disables type checking. You will see it in old code, but never write it. Why |
Declarationtwist#
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#. Java writes the constraint inside the angle brackets, with
extends. 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. See No primitive type arguments.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, likeGetValueOrDefaultin C#.
Several constraints are joined with &, as in
<T extends HasId & Comparable<T>>. The C# constraints
class, struct and unmanaged have no Java form, because
every type argument in Java is a reference type.
What erasure takes awaynew#
Java checks type arguments when it compiles, and then removes them. This is called type erasure. In C#, the runtime knows every type argument.
List<String> names = new ArrayList<>();
List<Integer> quantities = new ArrayList<>();
System.out.println(names.getClass() == quantities.getClass());Show the answer
While the program runs, List<String> and
List<Integer> are the same class. Java removed both type arguments
when it compiled, so each object is a plain ArrayList. In C# the two lists have
different types, because the runtime knows every type argument.
While the program runs, no code can ask a list which type it holds. Code that needs the type takes the type as a value. See Passing the type yourself.
In the same way, the class InMemoryRepository<T extends HasId> from the
section Declaration is a plain InMemoryRepository while
the program runs. This is true for every T. Inside the class, Java treats
T as its constraint, HasId. So any code that needs to know
T at run time does not compile. Here Box<T> is any generic
class, and x is any object:
The four lines marked no do not compile in Java, and all four lines 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 only aList, so Java cannot make this test.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 have their owncount.
The workaround is to pass the type yourself. See
Passing the type yourself. That is why Java APIs have many
Class<T> parameters. C# would use typeof(T) there.
C# refresherdefault(T) and dynamicC# 4
default gives the zero value of a type parameter. dynamic delays
member lookup until 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 no default(T) and no dynamic. A type parameter is always
a reference type, so its only default is null. For late binding, Java uses
reflection, or a Map<String, Object>.
Passing the type yourselftwist#
Here mapper is the ObjectMapper of
Jackson. Jackson is the JSON library that most Java
services use. 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 likeTypein C#, butClass<T>has a type argument. So the compiler knows that the method returns aT.read(json, Customer.class)Customer.classis the type as a value, liketypeof(Customer)in C#.mapper.readValue(json, type)Spring Boot 3 uses Jackson 2. With Jackson 2,
readValuealso declares a checked exception, so this method must addthrows JsonProcessingException. Spring Boot 4 uses Jackson 3. With Jackson 3,readValuedoes not declare a checked exception.
This is why these methods all take a .class value:
getBean(OrderService.class)in SpringreadValue(json, Customer.class)in Jacksonmock(OrderRepository.class)in Mockito
Variance is at the use sitetwist#
// 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> payments? extends Paymentis called a wildcard. With the wildcard, the parameter accepts a list ofPayment, or a list of any type that implementsPayment. Reading from the list gives you aPayment. Adding to the list is not allowed, because the list might really be aList<Card>. AVoucherdoes not belong in aList<Card>.printAll(cards);Without the wildcard,
printAll(List<Payment>)would reject aList<Card>. A JavaListis not converted automatically. The same is true for aList<T>in C#.
Java developers remember this rule as PECS. PECS means
Producer Extends, Consumer Super. If a parameter produces values for you to
read, use ? extends. If a parameter consumes values that you supply, use
? super.
? extends does the same as the out keyword in C#.
? super does the same as the in keyword in C#. C# writes the keyword
once, on the interface. Java writes the wildcard where the type is used.
? super, and the four wildcard forms#
The other direction is ? super, which does the same as the in
keyword in C#.
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> targetThis parameter accepts a list of
Card, or a list of any type aboveCard. Two such types arePaymentandObject. You can add aCard. But reading gives you only anObject, because the list might hold other payments too.
Here are the four forms that a list type can have:
| Wildcard | Means | You can | Mnemonic |
|---|---|---|---|
| List<String> | exactly String, so 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, so you only know that each element is an Object | read as Object, cannot add | any |
No primitive type arguments#
A type argument must be a class, so List<int> is not legal Java. You
write List<Integer>. Each int is then wrapped in an
Integer object. This wrapping 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. C# boxes a value in the same way when it stores the value as anobject.IntStream.of(raw).sum()IntStreamis a stream built forint, so the numbers are not boxed. For more about streams, see the chapter Streams vs LINQ.
A map key is boxed in the same way. C# writes Dictionary<int, V>. Java
writes Map<Integer, V>. An Integer can also be
null, so it does the same as int? in C#.
Boxing uses more memory and makes code slower. A List<Integer> of a
million entries holds a million separate objects on the
heap, plus an array that points to them. In C#, a
List<int> is one block of ints.
There are two workarounds. For storage, use int[]. For processing, use the
streams built for numbers, which are IntStream, LongStream and
DoubleStream. Project Valhalla aims to fix this problem 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 that are written in its field types, its method signatures and its superclass.
Libraries such as Jackson and Spring read these type arguments 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>>() { }This expression creates an object of an anonymous subclass. An anonymous subclass is a class with no name. The braces declare the class at this place in the code. Its superclass is
TypeReference<List<Customer>>. The compiled class keeps this superclass, so Jackson can read the type argument from it.new ParameterizedTypeReference<List<Customer>>() { }This is the same idea, in the
RestClientof Spring.Customer.classalone would lose theListaroundCustomer.
This trick is a subclass that exists only to keep a type argument. It looks strange until
you know why it exists. When you see new TypeReference<...>() { } with the
trailing braces, the code is using this trick. Without the braces, the code does not compile,
because TypeReference is abstract.
Raw types#
LegacyRaw types
Generics arrived in Java 5, in 2004. Older code had to keep compiling, so you can still
write List with no type argument. That is called a raw type. Everything in a raw
type is an Object, type checking is off, and the compiler only warns you. The closest
match in C# is the non-generic ArrayList of System.Collections.
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();This line has no type argument, so the list accepts any value. Here the list accepts a string, and then a number.
(String) raw.get(1)The cast compiles, because the compiler no longer knows what the list holds. When the code runs, the element is an
Integer, and the cast throwsClassCastException.
Treat every unchecked warning as an error. If you must silence a warning, put
@SuppressWarnings("unchecked") on the smallest declaration you can. Add a comment
that says why the code is safe.
1TranslateWrite this in Java, with a List as the parameter.
Card is one kind of Payment:
void PrintAll(IEnumerable<Payment> payments)
{
foreach (var p in payments) Console.WriteLine(p);
}
PrintAll(new List<Card> { new("4242") });
List as the parameter.
Card is one kind of Payment:
void PrintAll(IEnumerable<Payment> payments)
{
foreach (var p in payments) Console.WriteLine(p);
}
PrintAll(new List<Card> { new("4242") });void printAll(List<? extends Payment> payments) {
for (var p : payments) System.out.println(p);
}
List<Card> cards = List.of(new Card("4242"));
printAll(cards);A plain List<Payment> parameter rejects a List<Card>.
Java writes variance where the type is used. ? extends does the same as the
out keyword in C#. Java developers remember the rule as PECS, which means producer extends,
consumer super.
2An expert asksWhy does new T() not compile in Java, when C# allows it with a
new() constraint?
new T() not compile in Java, when C# allows it with a
new() constraint?T, so there is nothing to create. Pass a
Class<T>, or a Supplier<T> such as
Customer::new.3A colleague new to Java declares List<int> quantities. Why does it not
compile, and what do you write?
List<int> quantities. Why does it not
compile, and what do you write?unexpected
type, and asks for a reference type. Write List<Integer>. In that
list, each int is boxed into an Integer object. For many numbers, an
int[] holds plain ints.Annotations and attributes#
An annotation in Java does the same as an attribute in C#. C# writes
[Obsolete]. Java writes @Deprecated. You use an annotation in the same
way as an attribute. It attaches information that the compiler, a framework or a tool
reads.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | [Attr] | @Attr | an @ prefix instead of square brackets Why |
| same | [Attr] on its own line | @Attr on its own line | stacked one per line above the declaration, as in C# |
| twist | [Attr(1, Name = "x")] | @Attr(value = 1, name = "x") | named arguments work the same, but a member called value may be set without its name Why |
| twist | [Obsolete] | @Deprecated | pair it with a @deprecated javadoc tag that names the replacement Why |
| twist | nameof(x) | no equivalent | write the name as a string, and use IntelliJ's rename or a test to keep it correct Why |
| new | no equivalent | @Retention | it says how long the annotation survives, and only RUNTIME is visible to reflection Why |
| trap | an attribute is always readable by reflection | @Retention(RetentionPolicy.RUNTIME) | without it your own annotation is invisible at run time, because the default is CLASS Why |
| twist | class AuditedAttribute : Attribute | public @interface Audited | an annotation type is declared with @interface, and its members look like methods Why |
| twist | AttributeUsage | @Target | it says which declarations the annotation may be attached to Why |
| twist | AllowMultiple = true | @Repeatable | added in Java 8, and it needs a second annotation that is the container Why |
| twist | everyday attributes, such as [Obsolete] | @Override, @Deprecated, @SuppressWarnings | a few annotations appear in almost every Java codebase Why |
| twist | [Conditional] | no equivalent | Java has no way to remove calls at compile time Why |
| trap | a misspelt override is a compile error (CS0115) | a misspelt method without @Override compiles | It is a new method that overrides nothing, and no error says so. Always write @Override. Why |
| twist | a C# source generator | an annotation processor | a javac plugin that reads annotations and writes new source files as you compile Why |
| twist | GetCustomAttribute<T>() | getAnnotation(T.class) | returns null when the annotation is missing or not kept at run time Why |
Syntaxtwist#
Here 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);
}
}@RestControllerThis is an annotation. It is an
@followed by a name. C# puts the name in square brackets. This annotation tells Spring that the class answers web requests.@RequestMapping("/orders")This annotation has an argument. The argument is written in brackets like a method call, as in C#.
@RequestParam boolean withLinesAnnotations go on parameters too. This annotation reads
withLinesfrom the query string, like[FromQuery]in C#.
Named argumentstwist#
[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. For that member, see the section Declaring one./** @deprecated Use {@link #findById(long)} instead. */This is a Javadoc comment. A Javadoc comment does the same as an XML doc comment in C#. Its
@deprecatedtag is written in lower case, and it tells readers what to use instead. The annotation is written with a capital letter, and it tells the compiler.
C# refreshernameof: a member's name as a checked stringC# 6
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. The rename in IntelliJ updates most of these strings. But
only a test really guarantees that the names are still correct.
Retention: the concept C# lacksnew#
The shop wants a log entry every time an order changes. You write your own annotation,
@Audited. You put it on the methods that change orders. Then your code looks for
the annotation while the program runs. Frameworks such as Spring, Jackson,
JUnit and Jakarta Persistence
(JPA) find annotations the same way. C# attributes are always kept in the compiled
assembly, and reflection can always read them.
import java.lang.reflect.Method;
@interface Audited { }
class OrderService {
@Audited
public void cancel(long orderId) { }
}
Method cancel = OrderService.class.getMethod("cancel", long.class);
System.out.println(cancel.getAnnotation(Audited.class));Show the answer
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. That default
is the opposite of what you want almost every time. Here cancel is a
Method object. Method does the same as MethodInfo in C#.
The Method object finds no annotation to return.
Add the line that people forget. It keeps the annotation in the compiled class, and reflection can find the annotation there:
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited { }How long an annotation survives is called its retention. Whether code can see the annotation depends on the retention:
| Retention | Survives to | Used for |
|---|---|---|
| SOURCE | compiler only | @Override, @SuppressWarnings, Lombok |
| CLASS | the .class file, not reflection | bytecode tools, and it is the default |
| RUNTIME | reflection | Spring, Jackson, JUnit, JPA |
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, an
annotation 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)@Targetis likeAttributeUsagein C#. Here it says that 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. You set the member when you use the annotation, as in
@Audited(action = "cancel").String actor() default "system";defaultgives a member a value. The annotation uses this value when you do not set the member.
A member named value is special. It can be set without its name.
@SuppressWarnings("unchecked") is a short form of
@SuppressWarnings(value = "unchecked"). The @Tag("billing") below
works the same way. That is why so many annotations call their main member
value.
To allow the same attribute twice on one declaration, C# needs one flag. Java needs a second annotation to hold the repeats. This second annotation is called the container. 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)This line allows
@Tagmore than once on a declaration. It also names the container that holds the repeats.public @interface Tags { Tag[] value(); }This is the container. It is an annotation whose
valueis an array ofTag. You never write the container on a method yourself. The compiler wraps repeated tags in the container.
Ones you will see constantlytwist#
The first five annotations in the table are part of Java itself. The other annotations come
from libraries that later chapters cover. The [Conditional] attribute in C# has no
counterpart in Java. No annotation removes calls at compile time.
| Annotation | Meaning |
|---|---|
| @Override | asserts that the method overrides a supertype method, so the compiler catches typos |
| @Deprecated | does the same as [Obsolete], and you pair it with a @deprecated tag in the Javadoc |
| @SuppressWarnings("unchecked") | silences a specific compiler warning |
| @FunctionalInterface | asserts that the interface has exactly one abstract method |
| @SafeVarargs | asserts that a generic varargs method does not leak the array |
| @Nullable / @NonNull | they say whether a value may be null, as the chapter on Optional and JSpecify explains |
| @Entity, @Column | these JPA annotations map a class to a table, and a field to a column |
| @Test, @ParameterizedTest | these JUnit annotations mark a test method, or a test that runs once per set of inputs |
| @Service, @Component, @Bean | these Spring annotations mark a class that is a bean, or a method that makes a bean |
| @JsonProperty, @JsonIgnore | these Jackson annotations rename a JSON property, or leave a field out of the JSON |
Here are three of these annotations in code:
@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
}@FunctionalInterfaceThis annotation promises that the interface has exactly one abstract method, so that it can be written as a lambda. If you add a second abstract method, 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.@OverrideThis annotation asks the compiler to check that
toStringreally overrides a method from a supertype. Here the supertype isObject.
Always write @Overridetrap#
Always write @Override. The annotation is optional. But
without @Override, a small mistake in the signature makes a new method when you
intended an override. The compiler reports no error. The mistake might be a capital letter, a
boxed parameter or an extra argument. Here the mistake is the lower-case s:
class Cart {
public String tostring() { return "Cart"; } // compiles, and overrides nothing
}
class Basket {
@Override
public String tostring() { return "Basket"; } // rejected by the compiler
}In Basket, the compiler reports that tostring() does not override
or implement a method from a supertype. The override keyword in C# is required,
so this bug cannot happen in C#.
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. These files are compiled in the same run. Annotation processing is a mature
pipeline. It is older than 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 timeC# 9
A source generator reads your code during compilation and adds more C# to it. The best-known source generator writes the serialisation code for System.Text.Json, 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 that holds the fields
that the API returns:
@Mapper(componentModel = "spring")
public interface OrderMapper {
OrderDto toDto(Order order); // MapStruct writes the implementation at compile time
}@Mapper(componentModel = "spring")This annotation marks the interface for MapStruct.
componentModel = "spring"makes the generated class a Spring bean, so it can be injected into other classes.OrderDto toDto(Order order);This is a method with no body. MapStruct writes the body. The body copies each field of
Orderto the field ofOrderDtothat has 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 that you are most likely to meet. Some of them generate what a record now gives you with no extra code:
| 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 |
Look more closely at the comparison of MapStruct with AutoMapper. AutoMapper decides its mappings by reflection while the program runs. So a missing property fails only at that time. MapStruct writes a plain Java class when you compile, so mapping problems appear in the build. A field that MapStruct 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 that you write yourself.
Reading annotations at runtime#
Reading an annotation while the program runs works the same way in both languages. You find
the method through reflection, and then you ask the method 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)This call finds the public method
cancelthat takes along. Java needs the parameter types, because several methods can share a name.cancel.getAnnotation(Audited.class)This call is like
GetCustomAttributein C#. It returnsnullwhen the method has no@Audited. It also returnsnullwhen the retention of the annotation is notRUNTIME.
This only works for RUNTIME retention. Spring, Jackson and JUnit all rely on
RUNTIME retention. That is why Java frameworks have long spent time at startup
scanning the classpath. That is also why Quarkus and
Micronaut moved that work to compile time.
1TranslateWrite this in Java:
[Obsolete("Use FindById instead")]
public Order Find(long id) => FindById(id);
[Obsolete("Use FindById instead")]
public Order Find(long id) => FindById(id);/** @deprecated Use {@link #findById(long)} instead. */
@Deprecated
public Order find(long id) { return findById(id); }@Deprecated takes no message. The replacement is named in the Javadoc comment,
with the lower-case @deprecated tag.
2An expert asksWhat does @Override actually do, given it is only an
annotation?
@Override actually do, given it is only an
annotation?@Override, a method with a slightly wrong signature becomes a new method, and the
compiler reports no error.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 a null value. Optional<T> closes
part of that gap. JSpecify annotations close another
part.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | string? name | String name | the Java language has no way to say that a value may be null Why |
| twist | string name (non-null) | String name | the same declaration, but nothing enforces non-null in Java Why |
| twist | name!.Length | name.length() | Java has no ! operator, so the call simply throws if name is null Why |
| twist | name?.Length | name == null ? null : name.length() | Java has no ?. operator, so chain with Optional.map instead Why |
| twist | a ?? b | a != null ? a : b | Objects.requireNonNullElse(a, b) also works, but it throws when a and b are both null Why |
| twist | a ??= b | if (a == null) a = b; | Java has no ??= operator, so write the if statement in full Why |
| new | customer?.Name ?? "unknown" | find(id).map(Customer::name).orElse("unknown") | return Optional from a method that may find nothing Why |
| trap | a nullable field or parameter | a plain type, never Optional | Optional is for return values only, so do not use it for fields, parameters or collections Why |
| trap | found ?? LoadGuest() | found.orElseGet(() -> loadGuest()) | orElse(loadGuest()) calls loadGuest even when a value is present Why |
| new | <Nullable>enable</Nullable> | @NullMarked on the package | everything in the package is non-null unless it is marked @Nullable Why |
| new | Nullable reference types warnings | JSpecify + NullAway or IntelliJ | you opt in with annotations, but only a checker tool turns them into errors Why |
| twist | ArgumentNullException.ThrowIfNull(x) | Objects.requireNonNull(x) | throws NullPointerException if x is null, and otherwise returns x so that you can assign it Why |
The gap, stated plainlytwist#
Some customers never set a nickname, so the lookup can find nothing. 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 that the value may be null. You learn it from the documentation of the method, 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")This method is like
??in C#. It returns the first argument if that argument is not null. Otherwise it returns the second argument. But unlike??, the method throwsNullPointerExceptionwhen both arguments are null. The method arrived in Java 9.
C# refresherNullable reference types: string? and the compiler's warningsC# 8
When nullable reference types are on, a plain string promises that the value
is not null. A string? may be null. The compiler warns when you use a value that
may be null 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 checkOptional is for return valuesnew#
A search for a customer by id may find nobody. Instead of returning null, the Java
repository method can return an Optional<Customer>. An
Optional<Customer> is a box that holds one customer or is empty. The caller
has to open the box, so the caller cannot forget the empty case.
Java 8
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");.map(Customer::name)If a customer was found,
mapapplies a function to the customer and keeps the result. Here the result is the name. If no customer was found, or if the function returns null, the result is empty.Customer::nameis a method reference. It is a short way of writing the lambdac -> c.name()..orElse("unknown")This call opens the box. It returns the name if there is one, and otherwise
"unknown".orElseis like??in C#, but Java always calculates the argument oforElse. See the section Where Optional goes wrong.
Here are the other methods of Optional, each with the nearest C# form:
| 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, and Java 9 added this method |
Where Optional goes wrongtrap#
Optional is not a general-purpose nullable wrapper. Its
designers meant Optional for return values, when finding nothing is a normal
outcome. Do not use Optional for these three things:
- Fields. An
Optionalcannot be serialised, and it adds an extra object to every instance. - Method parameters. Callers have to wrap their argument, and they 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.
static String loadGuestName() {
System.out.println("reading the database");
return "guest";
}
Optional<String> found = Optional.of("Ada");
String name = found.orElse(loadGuestName());
System.out.println(name);Show the answer
Java calculates the argument of orElse first, even when a value is present. So
Java runs loadGuestName() before it calls orElse, and the database is
read although a name was found.
If the default is slow to compute or has side effects, use orElseGet.
orElseGet takes a lambda, and it runs the lambda only when the
Optional is empty:
String name = found.orElseGet(() -> loadGuestName()); // nothing is read: a name was foundJSpecify: the annotation standardnew#
Java has had a dozen competing @Nullable annotations.
JSpecify is the industry's effort to agree on one set. Spring Framework 7 and
Spring Boot 4 use JSpecify throughout, and this is making it the standard. JSpecify is a small
library that you add to the build. For the dependency, see the section
Adding JSpecify to the build.
The default goes in a special file called package-info.java. This file is in
the folder of a package:
// 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;@NullMarked makes every type in the package non-null unless the type is marked
@Nullable. It works like <Nullable>enable</Nullable> in a
C# project file. The file package-info.java holds only the package declaration, so
the annotation applies to the whole package.
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);
}
}load has no annotation, so its parameter and its result are non-null. A
checker reports any caller that passes null. @Nullable on find makes
callers test the result before they use it.
Nothing in the Java Development Kit (JDK) enforces these annotations, so you need a tool that checks them:
| 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, but slower and harder to learn |
Without one of those tools in your build, JSpecify annotations are documentation. The annotations will not fail a build and will not warn at compile time. If your team adopts JSpecify annotations, add a checker to the build at the same time. Without a checker, the annotations will stop being true within months.
Adding JSpecify to the build, and the annotations it replaces#
The competing annotations came from javax.annotation, JetBrains, Spring, the
Checker Framework and Android. To use the JSpecify annotations instead, 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 Maven dependency does the same as a PackageReference in .NET. The
groupId, the artifactId and the version together name
the library.
Defensive checks#
Public constructors and methods often check their arguments first. Then a null argument causes a clear failure at the start of the call, and not a failure later, deep inside the code. Here an order cannot 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")This call throws
NullPointerExceptionwith the messagecustomerifcustomeris null. Otherwise it returnscustomer, so the check and the assignment fit in one statement. The second argument names the parameter. In C#,ThrowIfNulladds the name of the parameter for you.
Java 14 added helpful NullPointerException messages, which you turned on
with a flag. They have been on by default since Java 15. The
Java Virtual Machine (JVM) tells you which
expression was null. An example is
Cannot invoke "Customer.name()" because "order.customer" is null. That removes
most of the difficulty of finding the null in a chain of calls. Developers who left Java before
2020 often do not know about these messages.
1TranslateWrite this in Java. FindOrder may find no order, an order may
have no customer, and a customer may have no email:
Order? FindOrder(long id);
var email = FindOrder(42)?.Customer?.Email ?? "no email";
FindOrder may find no order, an order may
have no customer, and a customer may have no email:
Order? FindOrder(long id);
var email = FindOrder(42)?.Customer?.Email ?? "no email";Optional<Order> findOrder(long id);
String email = findOrder(42)
.map(Order::customer)
.map(Customer::email)
.orElse("no email");A method that may find nothing returns an Optional. Each ?.
becomes a map. If the function of a map returns null, the
map gives an empty Optional. So a missing customer or a missing email
also ends at orElse.
2An expert asksWhy is Optional the wrong type for a field, a method parameter or
a list?
Optional the wrong type for a field, a method parameter or
a list?Optional meant it for return values. In a field, an
Optional cannot be serialised, and it adds an object to every instance. A caller
can still pass null for an Optional parameter. An empty list already means that
nothing was found.Collections#
Java has the same data structures as .NET, under different names. ArrayList
does the same as List<T> in C#. HashMap does the same as
Dictionary in C#. Java code declares the interface and creates the class. For
example, it declares a List and creates an ArrayList.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | IEnumerable<T> | Iterable<E> | an Iterable can be iterated more than once Why |
| twist | IEnumerator<T> | Iterator<E> | an Iterator is a one-pass cursor like IEnumerator, but it can also remove() Why |
| twist | List<T> | ArrayList<E> | ArrayList is the everyday list, and it is backed by an array, like List<T> Why |
| twist | Dictionary<K,V> | HashMap<K,V> | no guaranteed iteration order, so use LinkedHashMap to keep insertion order Why |
| twist | IDictionary<K,V> | Map<K,V> | A Map is not a Collection. Iterate entrySet(), keySet() or values(). Why |
| twist | Queue<T> | ArrayDeque<E> | Use offer() and poll(). ArrayDeque is the fast general-purpose queue. Why |
| twist | Stack<T> | ArrayDeque<E> | use push() and pop(), and avoid the legacy java.util.Stack class Why |
| twist | SortedDictionary<K,V> | TreeMap<K,V> | it is kept sorted by key, like SortedDictionary, so firstKey() is the smallest key Why |
| twist | ConcurrentDictionary<K,V> | ConcurrentHashMap<K,V> | it is a thread-safe map, and its computeIfAbsent does the same as GetOrAdd Why |
| twist | KeyValuePair<K,V> | Map.Entry<K,V> | An entry is one key and its value, read with getKey() and getValue(). The method entrySet() returns entries. Why |
| twist | List<string> a = ["mug", "cup"] | new ArrayList<>(List.of("mug", "cup")) | Java has no collection expression, so copy a fixed list into a list you can add to Why |
| twist | ImmutableList<T> | List.of(...) | it makes an unmodifiable list that rejects nulls, and it was added in Java 9 Why |
| trap | new List<string> { "mug" }, then Add | List.of("mug"), then add | List.of and Map.of return collections that throw on any change Why |
| twist | tags.RemoveAll(...) | tags.removeIf(String::isBlank) | removing inside a for loop over the list fails in both languages Why |
| twist | List<string> ids = new List<string>() | List<String> ids = new ArrayList<>() | declare the interface and create the class, so another implementation is a one-line change Why |
| twist | list[0] | list.getFirst() | available from Java 21, and before that you write list.get(0) Why |
| twist | list[^1] | list.getLast() | available from Java 21, and before that you write list.get(list.size() - 1) Why |
| trap | list.Reverse() | list.reversed() | it returns a reversed view, but Collections.reverse reverses the list itself Why |
| trap | list[1..3] | list.subList(1, 3) | it returns a view of the same list, but the C# range makes a copy Why |
| twist | the non-generic System.Collections types, such as Hashtable | Vector, Hashtable, Stack, Enumeration | these legacy collections lock on every call, so write ArrayList, HashMap and ArrayDeque Why |
The interface hierarchytwist#
Collection<String> ids = new ArrayList<>(); // a List, a Set and a Queue are Collections
Map<String, Integer> stock = new HashMap<>(); // a Map is not a Collection
for (var entry : stock.entrySet()) { } // so loop over its entries, keys or valuesA Map is not a Collection. So you loop over its
entrySet(), its keySet() or its values(). Each entry
gives its key with getKey() and its value with getValue(). The
picture shows every interface. The classes that you create are beneath each interface:
Iterable or Iteratortwist#
IEnumerable<T> is Iterable<E>, not
Iterator<E>. The match is exact. You can loop over an
Iterable again and again. An Iterable is also the type that
for (var x : xs) accepts. An Iterator is a cursor that makes one
pass, like IEnumerator<T> in C#. If you confuse Iterable with
Iterator, you write methods whose results can be read only once.
The code below reads the product ids of an order 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());ids.iterator() returns the cursor. hasNext() asks whether another
element is left. next() moves to that element and returns it. C# divides the same
work between MoveNext() and Current.
Everyday collectionstwist#
The code below has a queue of order numbers, an undo stack, a sorted price list and a stock cache:
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<>();QueueandDequeare interfaces. The class that you create isArrayDeque. It is a double-ended queue. In Java it replaces bothQueue<T>andStack<T>from .NET.queue.offer(1042L); var next = queue.poll();offeradds to the back, likeEnqueuein C#.polltakes from the front, likeDequeuein C#. On an empty queue,Dequeuethrows, butpollreturnsnull.prices.firstEntry()A
TreeMapkeeps its keys sorted, likeSortedDictionaryin C#. So its first entry has the smallest key, which iscup.Map.Entrydoes the same asKeyValuePairin C#.cache.computeIfAbsent("mug", k -> 12);computeIfAbsentdoes the same asGetOrAddin C#. It runs the lambda only when the key is missing, and it stores the result. A plaingetreturnsnullfor a missing key. See reading a missing key.
C# refresherOrderedDictionary (.NET 9): the insertion-ordered dictionary.NET 9
This is a generic dictionary that is new in .NET 9. It keeps keys in the order in which they were added. It 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 positionCreating collectionstrap#
var ids = List.of("mug", "cup");
ids.add("pot");
System.out.println(ids.size());Show the answer
List.of(...) and Map.of(...) Java 9 return
immutable collections. These collections throw
UnsupportedOperationException on any change, and they reject null
elements. These methods are not a replacement for new ArrayList<>().
If you need to add to the list later, copy it with
new ArrayList<>(List.of(...)). Arrays.asList is different
again. It returns a fixed-size view of an array.
C# has collection expressions and initialisers. Java has factory methods such as
List.of. 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.new ArrayList<>(...)copies the fixed list into a list that you can add to. In Java, this is the closest match tonew List<string> { ... }in C#.var b = List.of("mug", "cup");This is a fixed list. Adding, removing or replacing an element throws
UnsupportedOperationException.Map.of("mug", 2)The arguments are keys and values, and each key is followed by its value. Here they make a fixed map with one entry.
Map.of takes at most 10 key-value pairs. For more pairs, use
Map.ofEntries(Map.entry(k, v), ...).
Modifying while iteratingtwist#
If you remove from a list while a for loop iterates over it, the code fails in
both languages. C# has RemoveAll on a List<T> for this. Java has
the same idea, and calls it removeIf. A Java Iterator can also
remove an element:
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)This call changes the list while the hidden iterator of the loop is still using it. The loop usually fails on its next step with
ConcurrentModificationException. C# throwsInvalidOperationExceptionfor the same mistake.tags.removeIf(String::isBlank);This one call removes every element for which the test is true.
String::isBlankis a method reference. It is short fortag -> tag.isBlank().it.remove();This call removes the element that
next()just returned. That is safe, because the iterator itself makes the change.
subList and reversedtrap#
In C#, the range operator takes part of a list, as in ids[1..3]. In Java,
subList(1, 3) takes the same two elements:
var ids = new ArrayList<>(List.of("mug", "cup", "pot", "tray"));
var middle = ids.subList(1, 3);
middle.clear();
System.out.println(ids);Show the answer
subList returns a view, not a copy. Changing the sublist
changes the list behind it. In C#, ids[1..3] makes a copy. So clearing the copy
leaves all four ids in the list.
If you want a copy, make one with
new ArrayList<>(ids.subList(1, 3)).
reversed() does not turn the list around. It returns a
reversed view Java 21 and leaves the list as it was. In C#,
List<T>.Reverse() reverses the list itself:
var names = new ArrayList<>(List.of("mug", "cup", "pot"));
names.reversed(); // names is still [mug, cup, pot]: the view is thrown away
Collections.reverse(names); // names is now [pot, cup, mug]Use the view that reversed() returns, or call
Collections.reverse to change the list itself.
Declare the interface, instantiate the class#
Here the Java convention is stricter than the C# convention. Follow the Java convention from the start. Declare variables, fields, parameters and return types with the interface. 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<>(); // avoidWhen you declare List, you can change to another implementation later by
changing one line. The empty <> is called the diamond. It takes the type
argument from the declared type, so nothing is repeated.
Sequenced collections#
Java 21 added sequenced collections Java 21. With this feature, any collection with a defined order has methods for its first and last elements. Before Java 21, each type had its own way to reach these elements, or no way. The code below has the product ids of an order, 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();This call returns the first element. It works on any ordered collection. It is like
ids[0]in C#.ids.reversed();This call returns a reversed view. The view shows the same list back to front. A change to the list shows in the view, and a change to the view shows in the list. In C#,
List<T>.Reverse()reverses the list itself.stock.firstEntry();A
LinkedHashMapremembers the order in which keys were added. So its first entry is the entry that was added first.
subList also returns a view. For an example, see
clearing part of a list. If you add to or remove from the
list behind the sublist, the sublist throws ConcurrentModificationException the
next time you use it.
Legacy collections#
LegacyVector, Hashtable, Stack, Enumeration
These collections are from Java 1.0. Later they were changed to implement the modern interfaces, but every method takes a lock. So every call is slowed by locking that you almost never need. And these collections are still not safe for operations that take several calls. You will see 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. A loop over aStackvisits the bottom of the stack first.
1TranslateWrite this in Java:
var stock = new Dictionary<string, int> { ["mug"] = 12, ["cup"] = 3 };
foreach (var (id, count) in stock)
Console.WriteLine($"{id}: {count}");
var stock = new Dictionary<string, int> { ["mug"] = 12, ["cup"] = 3 };
foreach (var (id, count) in stock)
Console.WriteLine($"{id}: {count}");Map<String, Integer> stock = new HashMap<>(Map.of("mug", 12, "cup", 3));
for (var entry : stock.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}A Map is not a Collection, so the loop iterates over its
entrySet(). The map that Map.of returns is fixed. So
new HashMap<> copies it into a map that you can change.
2An expert asksWhy does Java code declare a List<String> and not an
ArrayList<String>?
List<String> and not an
ArrayList<String>?Quick reference#
| .NET | Java | Note |
|---|---|---|
| ICollection<T> | Collection<E> | it has iteration, and it adds size, add, remove and contains |
| IList<T> | List<E> | an ordered collection that you can index by position with get(i) |
| LinkedList<T> | LinkedList<E> | it is almost always slower than ArrayList, so avoid it |
| T[] | E[] | an array has a fixed size, and it is covariant in both languages, which is unsafe |
| ISet<T> | Set<E> | this is the set interface, so declare it and choose an implementation |
| HashSet<T> | HashSet<E> | no guaranteed iteration order, so use LinkedHashSet to keep insertion order |
| SortedSet<T> | TreeSet<E> | it is kept sorted by natural order, or by a Comparator that you pass |
| n/a | LinkedHashSet<E> | a HashSet that remembers insertion order, and .NET has no equivalent |
| OrderedDictionary<K,V> | LinkedHashMap<K,V> | Both keep insertion order, and OrderedDictionary needs .NET 9 or later. LinkedHashMap can also work as an LRU cache. |
| BlockingCollection<T> | BlockingQueue<E> | it is a producer-consumer queue, so put() and take() wait when needed |
| ReadOnlyCollection<T> | Collections.unmodifiableList | a read-only view of a list, so a change to the source list shows in the view |
| Comparer<T> | Comparator<E> | it compares two objects, and you build one with Comparator.comparing(...) |
| IComparable<T> | Comparable<E> | the natural order of a type, through its compareTo() method |
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. Values are compared with
equals. And equals must always agree with hashCode.
| Kind | C# | Java | Note |
|---|---|---|---|
| trap | a == b on strings | a.equals(b) | in Java, == compares objects, so strings, dates and every other object need equals Why |
| trap | a == b, either may be null | Objects.equals(a, b) | two nulls count as equal, and nothing throws Why |
| twist | ReferenceEquals(a, b) | a == b | this is the only meaning that == has for objects in Java Why |
| trap | a == b on two int or int? values | a.equals(b) on two Integer values | an Integer from -128 to 127 is a shared object, so == seems to work on small values Why |
| twist | GetHashCode() | hashCode() | Override it together with equals, as in C#. By default javac does not warn when you forget. Why |
| same | changing a key while it is in a HashSet | the set can no longer find it | The bucket came from the old hash code, as in C#. So keep the fields that hashCode uses final. |
| twist | HashCode.Combine(x, y) | Objects.hash(x, y) | hash the same fields that equals compares Why |
| twist | IEquatable<T> | equals(Object) | there is no typed version, so check the type with instanceof Why |
| trap | Amount == o.Amount | amount.compareTo(m.amount) == 0 | BigDecimal.equals also compares the number of decimal places Why |
| twist | IComparable<T> | Comparable<T> | it gives a type its natural order, through the method compareTo Why |
| twist | OrderBy(o => o.Status) | Comparator.comparing(Order::status) | ThenBy becomes thenComparing, and OrderByDescending adds reversed() Why |
== versus equalstrap#
Java reverses the C# habit. In Java, == means identity, and equals
means value. For the effect on a string comparison, see
== on two strings. In the code below, a and
b are any two objects:
boolean same = a == b; // are they the same object?
boolean equal = a.equals(b); // do they have the same value? Throws if a is null.
boolean safe = Objects.equals(a, b); // the same, and safe when either side is nullObjects.equals counts two nulls as equal. Objects is in the
package java.util.
| Compare | C# | Java |
|---|---|---|
| Value equality | a == b, a.Equals(b) | a.equals(b) |
| Value equality, null-safe | a == b | Objects.equals(a, b) |
| Reference identity | ReferenceEquals(a, b) | a == b |
| Primitives | a == b | a == b |
| Ordering | a.CompareTo(b) | a.compareTo(b) |
| Custom ordering | IComparer<T> | Comparator<T> |
The boxed-integer cache. Integer is the object form of
int. The Java Virtual Machine (JVM) keeps one
shared Integer object for each value from −128 to 127. So ==
appears to work on small numbers. Above 127, == stops working, and nothing warns
you. See two Integers with the same value.
The same trap exists for Long, Short and Byte. It
also exists for Character.
The equals and hashCode contracttwist#
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); }
}
var stock = new HashMap<ProductKey, Integer>();
stock.put(new ProductKey("mug"), 12);
System.out.println(stock.get(new ProductKey("mug")));Show the answer
ProductKey does not override hashCode, so the method comes from
Object. That hashCode is different for every object. So the two
equal keys go into different buckets. The lookup finds nothing in its bucket and
returns null, although an equal key is in the map.
C# fails in the same way, but the C# compiler warns you. By default, javac
does not warn you.
Override hashCode whenever you override equals, and use the same
fields in both methods. Or make the key a record. A
record writes both methods for you.
HashMap does the same as Dictionary in C#. The Java
HashSet does the same as the HashSet in C#. Both Java classes find an
object in two steps.
First hashCode picks a bucket. Then equals checks the objects in
that bucket. So equals and hashCode must agree, and nothing enforces
this. These are the rules:
| Rule | Meaning |
|---|---|
| Reflexive | an object always equals itself, so a.equals(a) is true |
| Symmetric | if a.equals(b) is true, then b.equals(a) is true |
| 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, and it never throws |
| hashCode agreement | if a.equals(b) is true, then a.hashCode() == b.hashCode() |
The last rule is the one that breaks systems. Suppose two objects are equal but have
different hash codes. Then a HashSet can store both of them. Now suppose the hash
code of an object changes while the object is in a set. The object stays in its old bucket,
and no lookup finds it there.
Writing themtwist#
Two amounts of money are equal when the amount and the currency match. The classes below implement that equality with methods that you write yourself:
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 (!(o instanceof Money m)) return false;equalstakes anObject. So this line first checks thatois aMoney, and gives it the namem. The line also returns false whenois null.amount.compareTo(m.amount) == 0This line compares the amounts by value. On a BigDecimal,
equalsalso compares the number of decimal places. This is a trap.Objects.hash(amount.stripTrailingZeros(), currency)Objects.hashdoes the same asHashCode.Combinein C#.stripTrailingZeros()gives 1.0 and 1.00 the same hash code, because equal objects must have equal hash codes.
Or write none of this code. A record builds both methods from its components. As a record, the map key from the prediction needs one line:
public record ProductKey(String id) { }ProductKey now has both methods. Money as a record needs one
more step. For that step, see BigDecimal inside equals.
The cost of Objects.hashdeep dive
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 form 31 * result + field.hashCode() is faster. But measure first.
BigDecimal inside equalstrap#
BigDecimal.equals compares scale as well as value. The scale
is the number of decimal places. So for equals, 1.0 and 1.00 are not equal. In
C#, 1.0m == 1.00m is true:
var a = new BigDecimal("1.0");
var b = new BigDecimal("1.00");
a.equals(b); // false: the scales differ
a.compareTo(b) == 0; // true: the values matchThat is why the Money.equals method in the previous section calls
compareTo. A record compares each component with equals. So when
Money is a plain record, 1.0 EUR and 1.00 EUR are not equal. To make them equal,
give every amount the same scale in the
compact constructor. The compact constructor
was shown in the chapter Records. Here it sets the scale:
public record Money(BigDecimal amount, String currency) {
public Money {
amount = amount.setScale(2, RoundingMode.HALF_EVEN); // 1.0 becomes 1.00
}
}setScale stores every amount with two decimal places. So the generated
equals and hashCode agree for 1.0 and 1.00. For the rest, see the
section Money is BigDecimal.
Ordering#
Sorting works the same way in both languages. You supply the comparison. C# code usually
passes a lambda or uses OrderBy from LINQ.
Java code builds a Comparator. In the code below, orders is a
List<Order>. Each order has an id, a status and the time when 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)This call builds a comparison from a key. Here the key is the id, so orders are compared by their id.
comparingLongavoids boxing eachlong.comparingIntdoes the same for anint.orders.stream()This call makes a stream. A stream is like a LINQ query in C#. For more about streams, see the next chapter, Streams vs LINQ.
Comparator.comparing(Order::status)This call compares by status, in the order in which the enum declares its constants.
.thenComparing(Order::placedAt, reverseOrder())When two orders have the same status, this call compares the time when each order was placed, newest first. It is like
ThenByDescendingin C#.
The rest of the LINQ ordering methods map to Java 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 gets its own natural order by implementing Comparable.
Comparable does the same as IComparable in C#. In the example below,
versions of the shop's API are ordered by major number, and then by 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>This line gives
Versiona natural order.Collections.sort,TreeSetandTreeMapuse the natural order when you pass them no comparator.private static final Comparator<Version> ORDER =The comparison is built once and kept in a constant. It is not rebuilt on every call.
Comparable.compareTo should be consistent with equals. This means
that when a.compareTo(b) == 0, a.equals(b) should be true. When it
is not true, the sorted collections behave differently from the hashed collections. The sorted
collections are TreeSet and TreeMap, and they use
compareTo. The hashed collections are HashSet and
HashMap, and they use equals.
BigDecimal is the standard example. The values 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.
1TranslateWrite these two members of a Customer class in Java. The id is
a long and the email a string:
public override bool Equals(object? o) =>
o is Customer c && Id == c.Id && Email == c.Email;
public override int GetHashCode() => HashCode.Combine(Id, Email);
Customer class in Java. The id is
a long and the email a string:
public override bool Equals(object? o) =>
o is Customer c && Id == c.Id && Email == c.Email;
public override int GetHashCode() => HashCode.Combine(Id, Email);@Override
public boolean equals(Object o) {
return o instanceof Customer c && id == c.id && Objects.equals(email, c.email);
}
@Override
public int hashCode() {
return Objects.hash(id, email);
}The email needs Objects.equals, for two reasons. == on two objects
only asks whether they are the same object. And email.equals would throw on a
null email. The id is a primitive long, so == is right for the
id.
2An expert asksA Customer is in a HashSet, and its
hashCode uses the email. The code then changes that customer's email. Why does
customers.contains(customer) now return false?
Customer is in a HashSet, and its
hashCode uses the email. The code then changes that customer's email. Why does
customers.contains(customer) now return false?equals and
hashCode use final, or use a record.Streams vs LINQ#
Java streams do the same as LINQ to Objects in C#. The idea is the same, but the names are different. Java has no LINQ to SQL. The reason is that a Java lambda compiles to ordinary code, and no library can read that code and turn it into SQL.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | customers.Where(...).Select(...).ToList() | customers.stream().filter(...).map(...).toList() | a list is not a stream, so you ask it for one first with stream() Why |
| trap | names.OrderBy(n => n) | names.stream().sorted(String.CASE_INSENSITIVE_ORDER) | sorted() alone puts every capital before every lower-case letter, but C# uses the culture's order Why |
| twist | orders.SelectMany(o => o.Lines) | orders.stream().flatMap(o -> o.lines().stream()) | most operators only change their name, but the lambda of flatMap must return a stream Why |
| trap | enumerating one query twice | collect once, then reuse the list | a stream can be used only once, and a second use throws IllegalStateException Why |
| twist | orders.GroupBy(o => o.Status) | orders.stream().collect(groupingBy(Order::status)) | collect finishes a stream, and the Collectors class holds grouping, joining and counting Why |
| twist | orders.GroupBy(o => o.Status).ToDictionary(g => g.Key, g => g.Count()) | orders.stream().collect(groupingBy(Order::status, counting())) | a second collector, such as counting() or summingInt(f), says what to keep for each group Why |
| trap | orders.ToDictionary(o => o.Id) | orders.stream().collect(toMap(Order::id, o -> o)) | toMap throws on a duplicate key, as ToDictionary does, and also on a null value Why |
| trap | orders.AsParallel() | orders.parallelStream() | every parallel stream shares one pool of threads, so never use it for I/O Why |
| twist | productIds.Chunk(100) | productIds.stream().gather(Gatherers.windowFixed(100)) | a gatherer is an intermediate operation of your own, from Java 24 Why |
| twist | lines.Sum(l => l.Quantity) | lines.stream().mapToInt(OrderLine::quantity).sum() | IntStream, LongStream and DoubleStream hold primitives without boxing them Why |
| trap | Enumerable.Range(0, 10) | IntStream.range(0, 10) | the second argument is where to stop, not how many to produce Why |
| twist | db.Customers.Where(c => c.Age > 18) | a JPQL string, the Criteria API or jOOQ | Java has no expression trees, so no library can turn a lambda into SQL Why |
| twist | yield return n | Stream.iterate(1, n -> n * 2).limit(10) | Java has no generators, but Stream.iterate and Stream.generate build a lazy sequence Why |
The shapetwist#
Here customers is a List<Customer>:
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 first ask the list for a stream. C# lets you call
Whereon the list itself..filter(c -> c.name().endsWith("Doe"))Wherebecomesfilter. C# writes the lambda with=>. Java writes it with->..map(Customer::name)Selectbecomesmap.Customer::nameis called a method reference. It is short forc -> c.name()..sorted()This is
OrderBywith the natural order. For strings, the natural order in Java is not the order that C# uses. See Sorting strings. Pass aComparatorto sort another way..toList();This step runs the pipeline and collects the result. Nothing runs until this last step. In the same way, a LINQ query does nothing until you enumerate it.
That is the whole pattern. You start a stream, chain operations, and finish with one step that produces a result.
Sorting stringstrap#
sorted() does not order strings as OrderBy does.
sorted() puts every capital letter before every lower-case letter.
C# uses the order of the culture:
import java.text.Collator;
var names = List.of("bob Doe", "Zed Doe", "amy Doe", "Ann Doe");
names.stream().sorted().toList();
// [Ann Doe, Zed Doe, amy Doe, bob Doe]
names.stream().sorted(String.CASE_INSENSITIVE_ORDER).toList();
// [amy Doe, Ann Doe, bob Doe, Zed Doe], as C#'s OrderBy(n => n) gives
names.stream().sorted(Collator.getInstance(Locale.ENGLISH)).toList();
// [amy Doe, Ann Doe, bob Doe, Zed Doe]String.CASE_INSENSITIVE_ORDER ignores case. A Collator orders text
for the locale that you pass to it. Collator is in the
package java.text.
Operator translationtwist#
Each order has a list of lines, and each line has a product id:
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. The lambda offlatMapmust return a stream. So the lambda turns each order's list of lines into a stream..skip(10).limit(5)Skipbecomesskip.Takebecomeslimit..anyMatch(o -> o.lines().isEmpty());Anywith a test becomesanyMatch.findFirst().orElse(null)findFirstreturns anOptional. AddingorElse(null)gives the behaviour ofFirstOrDefault..mapToInt(o -> o.lines().size()).sum();sumis a method ofIntStream, which is a stream of plainintvalues. So you map tointfirst.
For every LINQ operator with its stream counterpart, see the quick reference.
C# refresherLINQ operators: TakeWhile, SkipWhile, Concat, All, Single, Aggregate, Zip, Chunk and moreC# 3
This code shows the rest of the LINQ column of the table. Each operator has one line, 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 threadsReading a stream twicetrap#
var names = List.of("Ann Doe", "Bob Ray", "Cy Doe");
var does = names.stream().filter(n -> n.endsWith("Doe"));
var a = does.toList();
var b = does.toList();
System.out.println(b);Show the answer
A stream can be used exactly once. A stream is a pipeline over a source.
The first finishing step runs the pipeline, and the stream cannot be used after that. In C#,
the same IEnumerable would simply run the query again. This difference causes
bugs when you translate LINQ.
Either create the stream again, or collect it once into a List and reuse the
list.
Collectorstwist#
collect is the general way to finish a stream. The Collectors
class has the convenient operations that LINQ has, such as grouping, joining and
counting:
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.*;This line imports the static methods of
Collectors, sogroupingByandcountingneed no class name.groupingBy(Order::status, counting())This call groups the orders by status. It counts the orders of each group and does not list them. The result is a
Map<OrderStatus, Long>.joining(", ")This collector joins strings with a separator, like
string.Joinin C#.
These are the collectors you will use most:
| Collector | Produces |
|---|---|
| toList() / toSet() | a List or Set |
| toMap(k, v) | a Map, and it 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 |
toMap and null valuestrap#
Collectors.toMap throws on a null value. toMap
throws IllegalStateException on a duplicate key. ToDictionary in LINQ
also throws on a duplicate key. But toMap also throws
NullPointerException when a value is null. ToDictionary
accepts a null value. Here one order without a customer stops the whole stream:
import static java.util.stream.Collectors.toMap;
Map<Long, Customer> customerByOrder = orders.stream()
.collect(toMap(Order::id, Order::customer)); // throws if any customer is nullRemove the null values first with a filter, or fill a HashMap in a loop. If
duplicate keys are possible, use the form of toMap with three arguments. The third
argument says how to merge two values.
Parallel streamstrap#
parallelStream() looks like AsParallel(), but it is more dangerous.
A parallel stream runs on a pool of threads that the whole program shares. This pool is called
the common ForkJoinPool. So one slow parallel stream can delay every other
parallel stream in the Java Virtual Machine (JVM), including
parallel streams inside libraries. A parallel stream is also useful only for large amounts of
pure calculation that split evenly.
Follow these simple rules. Do not use a parallel stream for I/O. Use
virtual threads for I/O instead. See the chapter
Threads are cheap now. Do not use a parallel stream on a
LinkedList, or on a source backed by an Iterator. Measure before and
after. For any work 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 for the network. While the call waits, it holds one of the few threads of the shared pool.IntStream.range(0, 50_000_000).parallel()This line calculates fifty million square roots. The work is pure calculation, and it splits evenly across the cores of the processor.
A range of numberstrap#
The second argument of IntStream.range is where
to stop, not how many to produce. In C#, Enumerable.Range(5, 8) takes a
count, and gives the eight numbers from 5 to 12. In Java, range(5, 8) stops before
the 8:
import java.util.stream.IntStream;
int[] numbers = IntStream.range(5, 8).toArray(); // 5, 6, 7
int[] closed = IntStream.rangeClosed(5, 8).toArray(); // 5, 6, 7, 8For Enumerable.Range(start, count), write
IntStream.range(start, start + count). IntStream is a stream of plain
int values. See Primitive streams.
Gatherers#
For a long time, streams had no way to add your own intermediate operation. Stream
gatherers Java 24 fixed that. The feature also includes built-in
gatherers for operations that LINQ has and streams did not have. Here productIds is a list
of strings, and prices is a list of BigDecimal.
quantities is 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)This gatherer groups the elements into lists of 100, like
Chunk(100)in LINQ. The last list can be shorter.Gatherers.windowSliding(3)This gatherer makes lists of three neighbours. The first list holds the first three elements. The next list holds the second to the fourth element, and so on.
Gatherers.scan(() -> 0, Integer::sum)This gatherer makes a running total. It starts from 0 and adds each element in turn. After each element, it sends the total so far to the next step.
Before Java 24 there was no clean way to batch a stream. Every codebase had its own
partitioning helper, or used Lists.partition from Guava. If you are on Java 21,
that is still the situation. Gatherers were a preview in Java 22 and 23.
Primitive streams#
Generics cannot hold primitives. So Java has IntStream,
LongStream and DoubleStream. These three stream types 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)This step turns a stream of order lines into a stream of plain
intvalues.IntStream.range(0, 10)This call gives the numbers 0 to 9. In
Enumerable.Range, the second argument is how many numbers to produce. Here the second argument is where to stop.rangeClosed(0, 10)would include 10..boxed()This step 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() gives count, sum, min, max and average |
No expression trees#
C# refresherExpression trees and IQueryable: a lambda as dataC# 3
When a C# lambda is assigned to an Expression<...>, it becomes a data
structure that 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 directly to
bytecode and cannot be inspected. So no Java
object-relational mapper (ORM) can do this. The standard ORM
in Java is Jakarta Persistence (JPA). With JPA, you write
queries as strings in the
Jakarta Persistence Query Language (JPQL), or you 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 most similar 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, JPA and migrations.
C# refresheryield return: a lazy sequence in C#C# 2
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
yourself. The code below produces 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)This is an endless stream that starts at 1 and doubles each time.
limit(10)takes the first ten values, likeTake(10)on an endlessyield return.Stream.iterate(1000, n -> n < 1010, n -> n + 1)This form has three arguments, and it exists from Java 9. It stops by itself when the test fails, like a
forloop.
1TranslateWrite this in Java. lines is a list of order lines, and the
result gives the number of units sold of each product:
var unitsByProduct = lines
.GroupBy(l => l.ProductId)
.ToDictionary(g => g.Key, g => g.Sum(l => l.Quantity));
lines is a list of order lines, and the
result gives the number of units sold of each product:
var unitsByProduct = lines
.GroupBy(l => l.ProductId)
.ToDictionary(g => g.Key, g => g.Sum(l => l.Quantity));import static java.util.stream.Collectors.*;
Map<String, Integer> unitsByProduct = lines.stream()
.collect(groupingBy(OrderLine::productId, summingInt(OrderLine::quantity)));groupingBy alone gives a map of lists. Its second argument is a collector that
says what to keep for each group. Here the collector is summingInt. The collector
counting() would count the lines instead.
2An expert asksWhy can no Java library turn a lambda into SQL, as EF Core does?
3An expert asksWhy should a parallel stream never call another service or a database?
ForkJoinPool. A call that waits for the network holds one of
the few threads of that pool. So one slow parallel stream can delay every other parallel stream
in the JVM. Use virtual threads for I/O, and keep parallel streams for large, pure
calculations.Quick reference#
| LINQ | Stream | Note |
|---|---|---|
| Where | filter | |
| Select | map | |
| SelectMany | flatMap | |
| OrderBy | sorted(comparator) | sorted() alone puts capitals first in strings, so pass String.CASE_INSENSITIVE_ORDER or a Collator |
| Take(n) | limit(n) | |
| Skip(n) | skip(n) | |
| TakeWhile | takeWhile | added in Java 9, and it takes items from the start while the test holds |
| SkipWhile | dropWhile | added in Java 9 with the name dropWhile, and it skips items while the test holds |
| Distinct | distinct() | compares with equals and hashCode, so implement both |
| Reverse | no direct equal | a stream cannot reverse, so collect to a list and then call reversed() |
| Concat | Stream.concat(a, b) | a static method that takes exactly two streams, so nest it for more streams |
| 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), so orElse supplies the value to use instead |
| Single() | no direct equal | collect to a list and check its size is exactly one |
| Sum() | mapToInt(f).sum() | sum() is a method of 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, and 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, but a second collector can count each group instead |
| Zip | no equivalent | use IntStream.range over indices |
| Chunk(n) | Stream.gather(Gatherers.windowFixed(n)) | uses the gatherers of Java 24, so on Java 21 use Lists.partition from Guava |
| DefaultIfEmpty | no equivalent | Java has no operator for this, so test for empty and supply the default yourself |
| AsParallel() | parallelStream() | shares one pool with the whole JVM, so it is riskier than it looks |
Files and I/O#
The Files class in Java does the same as System.IO.File in .NET.
Path is the type for a path. Almost everything you need is a static method of
Files.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | File.ReadAllText | Files.readString(p) | added in Java 11, and it reads UTF-8 unless you pass a charset Why |
| twist | File.ReadAllLines | Files.readAllLines(p) | reads the whole file into memory, so use Files.lines for big files Why |
| twist | File.WriteAllText | Files.writeString(p, s) | added in Java 11, and it writes UTF-8 and replaces the file Why |
| twist | File.AppendAllText | Files.writeString(p, s, APPEND) | pass StandardOpenOption.APPEND to add to the end Why |
| same | File.Exists | Files.exists(p) | the same check, on a Path in place of a string |
| trap | File.Delete | Files.delete(p) / deleteIfExists(p) | delete throws NoSuchFileException if the file is missing Why |
| twist | File.Copy / Move | Files.copy / Files.move | pass REPLACE_EXISTING to overwrite Why |
| twist | Directory.CreateDirectory | Files.createDirectories(p) | creates any missing parent folders too, like CreateDirectory Why |
| twist | Path.Combine("a","b") | Path.of("a","b") | or dir.resolve("b") to add one part to an existing Path Why |
| twist | Path.GetFileName | p.getFileName() | returns a Path, so call toString() for the name as text Why |
| twist | Path.GetExtension | no equivalent | Java has no method for this, so take the text after the last dot yourself Why |
| twist | Path.GetTempFileName | Files.createTempFile(prefix, suffix) | creates an empty temporary file and returns its Path Why |
| trap | File.ReadLines (lazy) | Files.lines(p) | returns a Stream, so it must be closed Why |
| trap | Directory.GetFiles | Files.list(dir).filter(Files::isRegularFile) | Files.list returns the sub-folders of the folder too, in a Stream that must be closed Why |
| trap | Directory.EnumerateFiles(recursive) | Files.walk(dir).filter(Files::isRegularFile) | Files.walk returns the folders of the tree too, in a Stream that must be closed Why |
| trap | turning bytes into text | new String(bytes, StandardCharsets.UTF_8) | always pass a charset, because before Java 18 the default was the charset of the machine Why |
| twist | FileStream | Files.newInputStream / newOutputStream | these are byte streams, so wrap them in a Buffered stream for speed Why |
| twist | StreamReader | Files.newBufferedReader(p) | a buffered text reader, UTF-8 unless you say otherwise Why |
| trap | asm.GetManifestResourceStream("MyApp.config.json") | getClass().getResourceAsStream("/config.json") | a file under src/main/resources is inside the JAR, not on disk Why |
The everyday operationstwist#
The shop exports each day's orders to data/orders.csv, and an import job reads
them back. Here src, dst and dir are more paths. The
variable 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.Combinein .NET. APathis an object, not a string.Files.readString(p)This call reads the whole file as UTF-8 text. It exists from Java 11.
readAllLinesgives a list of lines instead.StandardOpenOption.APPENDwriteStringreplaces the file unless you pass this option. With this option,writeStringadds to the end of the file, likeAppendAllText.Files.deleteIfExists(p);Files.deletethrowsNoSuchFileExceptionif the file is missing. In C#,File.Deletedoes nothing if the file is missing, and reports no error.deleteIfExistsbehaves like the C# method.StandardCopyOption.REPLACE_EXISTINGWithout this option,
copythrows if the target already exists.
Names, lazy reading and folder listingstwist#
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();
int dot = name.lastIndexOf('.');
String ext = dot < 0 ? "" : name.substring(dot);
try (var lines = Files.lines(p)) { } // lazy
try (var entries = 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.toStringturns thatPathinto text, hereorders.csv.dot < 0 ? "" : name.substring(dot)Java has no method like
GetExtension. So the code takes the text from the last dot, which is.csvhere. When the name has no dot, the code takes an empty string.try (var lines = Files.lines(p)) { }This is a try-with-resources block. It does the same as
usingin C#. Whatever is declared in the brackets is closed when the block ends.Files.linesreads lazily, likeFile.ReadLines, but it holds the file open until the stream is closed.Files.list(Path.of("data"))This call gives the entries of one folder, and the entries include sub-folders.
Directory.GetFilesreturns only files. Add.filter(Files::isRegularFile)to get the same result asDirectory.GetFiles.Files.walk(Path.of("data"))This call gives every file and folder below
data, anddataitself.EnumerateFilesreturns only files, so add the same filter here.Files.createTempFile("orders", ".csv")This call creates an empty temporary file with that prefix and suffix, and returns its path.
Close every stream of lines or filestrap#
Files.lines, Files.walk and Files.list
return a stream that holds an open file handle. In .NET,
File.ReadLines releases the file when it reaches the end. The Java stream does not
release the file when it reaches the end, and on Windows the file stays locked. So each of
these streams must be opened in a try-with-resources block. Here the import job of the shop
counts the lines of data/orders.csv 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 the stream. 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 the block.
This is the most common Java file-handling bug. It usually appears as “too many open files” under load, and not as an obvious failure.
Charsets, and why to always pass one#
Java's older I/O classes use the machine's default character set when you do not pass one.
A character set is also called a charset. So the same code can produce different text
on a developer's Mac and in a Linux container. This is the classic cause of wrong characters in
place of 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);This line turns the bytes into text with the default charset of the machine, whichever charset that is.
new String(bytes, StandardCharsets.UTF_8);This line names the charset, so the result is the same on every machine.
Java 18 changed the default charset to UTF-8 for most APIs. This removes most of the problem
on a modern Java Development Kit (JDK). On Java 17 and
earlier, the problem is still real. Some APIs still follow the encoding of the console, and
System.out is one of them. Passing the charset has no disadvantage, and it works on
every version.
Streams, readers and buffering#
Java splits I/O into byte streams and character streams. In .NET, one Stream
and a StreamReader mostly hide this split.
| 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 | these wrap the types above, and they always help |
| Bridging | InputStreamReader(in, charset) | bytes to characters |
The code below reads the first line of a file through these layers. Then it copies one file to another:
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))This line has three layers. The first layer is the bytes of the file. The second layer is an
InputStreamReader, which turns bytes into characters with UTF-8. The third layer is aBufferedReader, which reads ahead in large blocks. In .NET, aStreamReaderdoes all three jobs.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);This call copies everything from one stream to the other, like
CopyToin C#. It was added in Java 9.
Reading one byte or character at a time without a buffer is very much slower in Java than
the same .NET code. In .NET, FileStream buffers by default. Java streams do not
buffer by default. Wrap a Java stream in a Buffered* class unless you have a reason
not to.
Walking a directory tree#
The shop's import job looks for export files in a folder tree. Files.walk
visits every entry below a folder. newDirectoryStream matches the 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)This filter keeps the files and removes the folders.
Files.walk(dir, 2, FileVisitOption.FOLLOW_LINKS)This call goes at most two levels down, and it follows symbolic links. Without this option,
walkdoes not follow symbolic links.Files.newDirectoryStream(dir, "*.{csv,tsv}")This call lists one folder and keeps the names that match the pattern. Here the pattern matches files that end in
.csvor.tsv.
| .NET | Java |
|---|---|
| Directory.EnumerateFiles(dir, "*.csv") | Files.newDirectoryStream(dir, "*.csv") |
| Directory.EnumerateFiles(dir, "*", AllDirectories) | Files.walk(dir).filter(Files::isRegularFile) |
| Directory.EnumerateDirectories | Files.list(dir).filter(Files::isDirectory) |
| new DirectoryInfo(d).GetFiles() | Files.list(d).filter(Files::isRegularFile) |
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, such a file is
not a file on disk. Opening it with
Files.readString(Path.of("config.json")) works in the IDE and fails after
packaging. This failure is confusing, and it is very common on a first deployment. Read the
file 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")This call finds the resource on the classpath and opens it as a stream of bytes. It returns
nullif there is no such resource.in.readAllBytes()This call reads every byte. Then
new String(..., UTF_8)turns the bytes into text.
In Spring, a cleaner form is ClassPathResource. Another cleaner form is to
inject @Value("classpath:config.json") Resource r. A leading slash in the name
that you pass to getResourceAsStream means “from the classpath root”.
Without the leading slash, the lookup is relative to the
package of the class.
1Files.lines(p).filter(...).count(). What is the bug?
Files.lines(p).filter(...).count(). What is the bug?Quick reference#
| System.IO | java.nio.file | Note |
|---|---|---|
| MemoryStream | ByteArrayInputStream / ByteArrayOutputStream | in-memory byte streams, one class for each direction |
| FileSystemWatcher | WatchService | WatchService is much lower level than FileSystemWatcher in .NET |
Threads are cheap now#
Java has no await, and you do not need one. C# made waiting on I/O cheap by
making methods asynchronous. Java made threads cheap instead. Since Java 21, ordinary blocking
code can run on a virtual thread. A virtual thread uses almost no resources while it
waits.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | async Task<T> BuildAsync() | T build() | Java has no async or await, so write a blocking method and run it on a virtual thread Why |
| new | await SomeIoAsync() | someIo() | just call the method, because blocking is fine on a virtual thread Why |
| new | BuildAsync(7).Result, blocking on an async method | build(7) | Blocking is the normal style. Java has no Task<T> in signatures, no ConfigureAwait and no context to deadlock on. Why |
| new | no equivalent | a virtual thread | a Thread whose stack is on the heap, so it starts at a few hundred bytes, not at about 1 MB Why |
| twist | Task.Run(() => f()) | exec.submit(() -> f()) | with a virtual-thread executor, which starts a new thread for every task Why |
| twist | Task.WhenAll(a, b) | a.get(); b.get(); | This is for two plain calls on virtual threads. StructuredTaskScope does the same in a scope, but it is still a preview feature in Java 25. Why |
| twist | Task.Delay(d) | Thread.sleep(d) | blocks only the virtual thread, so sleeping is cheap Why |
| twist | CancellationToken | Thread.interrupt() | cooperative in both languages, so the target must check, or be blocked in a call that checks Why |
| twist | SemaphoreSlim | Semaphore | still needed to limit concurrency Why |
| twist | IAsyncEnumerable<T> | no direct equal | Java has no async streams. The usual form is a producer thread that feeds a BlockingQueue. Why |
| twist | Task.Run, for one task | Thread.startVirtualThread(() -> f()) | This call starts one virtual thread. To give the thread a name first, build it with Thread.ofVirtual(). Why |
| new | no equivalent | spring.threads.virtual.enabled: true | It is false by default. Set it to true, on Boot 3.2 or later, and each web request runs on a virtual thread. Why |
| trap | Parallel.For | Executors.newFixedThreadPool(cores) | virtual threads do not speed up CPU-bound work, so keep a pool of platform threads for it Why |
| trap | a pool that reuses its threads | a new virtual thread for every task | never pool virtual threads, because they are too cheap to create for a pool to help Why |
| twist | lock around a blocking call | synchronized around a blocking call | On Java 21 to 23, this pinned the carrier of the virtual thread. Since Java 24, only native code pins. Why |
| trap | ThreadLocal<T> | ScopedValue, not ThreadLocal | one value per thread is a memory problem with 500,000 virtual threads Why |
| twist | AsyncLocal<T> | ScopedValue | ScopedValue is final in Java 25. It is immutable, and it is bound to one block of code rather than ambient. Why |
| twist | your async habits | the migration checklist | six .NET instincts, each with what to do in Java instead Why |
The colour problem, and how Java sidestepped itnew#
An order report needs the customer and the customer's orders. Each of them is fetched from
another service. In C#, a method that awaits becomes async Task. Then every caller
must become async Task too, up to the top of the call chain. This is called
function colouring. Java never introduced the colour:
// 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);
}
// calling it from sync code blocks the calling thread
var r = BuildAsync(7).Result;// 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));Thread.startVirtualThread runs build on a new virtual thread. The
ordinary kind of thread is called a platform thread. On a platform thread, each
blocking call would hold a whole operating-system thread while it waits. On a virtual thread, a
blocking call uses almost no resources.
A virtual thread runs on a real thread. This real thread is called the carrier of
the virtual thread. When getCustomer waits for the network, the
Java Virtual Machine (JVM) takes the virtual thread off its
carrier. The JVM keeps the state of the virtual thread on the
heap. The carrier then runs something else.
await does the same in .NET. But in Java no compiler rewrote your method, and the
signature of the method never changed.
In C#, .Result blocks the calling thread until the task finishes. Under a
single-threaded synchronisation context, this causes a deadlock. The rest of the method waits
for the calling thread, and the calling thread waits for the method to finish. ASP.NET Core has
no synchronisation context. In ASP.NET Core, .Result holds a thread-pool thread
for the whole wait.
A platform thread reserves about 1 MB for its stack. A virtual thread is a
Thread whose stack is on the heap. The stack starts at a few hundred bytes, so a
program can run millions of virtual threads. For the mechanism, see
What a virtual thread actually is.
C# refresherConfigureAwait(false): not resuming on the caller's contextC# 5
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. This avoids
one kind of deadlock.
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 it will not get them. In
place of the keywords, you choose where you run the code, not how you write
it.
Creating and running themtwist#
Here handle(id) processes one order.
async Task RunAsync(CancellationToken ct)
{
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(); }
}void run() throws Exception {
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(); }
}void run() throws Exception {Three calls in this method throw the checked exception
InterruptedException. The calls areget,sleepandacquire.getalso throwsExecutionException. So the method declaresthrowsor catches these exceptions.try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {This is a try-with-resources block. It does the same as a
usingblock in C#. The executor starts a new virtual thread for every task, and pools nothing. The block closes the executor at the end. Closing the executor waits for every submitted task.var a = exec.submit(() -> handle(7));This line submits one task, like
Task.Run.submitreturns aFuture, which is like aTaskin C#.a.get(); b.get();Each
getwaits for the result of one task, likeawait. The twogetcalls together do the same asTask.WhenAll.Thread.sleep(Duration.ofSeconds(1));This line blocks only the virtual thread. Calling
interrupt()on the thread ends the sleep early. Java cancels by interrupting a thread.var gate = new Semaphore(20);The semaphore lets at most 20 threads pass
acquireat the same time, likeSemaphoreSlimin C#.
Async streamsdeep dive
C# refresherIAsyncEnumerable and await foreach: an async streamC# 8
An async iterator returns each value when the value is ready. The caller reads the values
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);Java has no async streams. In Java, a producer thread puts the values into a
BlockingQueue, and the consumer takes the values
from the queue.
What a virtual thread actually is#
A virtual thread is a Thread whose stack is on the Java heap. Its stack is not
a fixed stack that the operating system reserves. Two numbers explain the whole design:
- A platform thread is the ordinary kind of thread. It reserves around 1 MB of stack when it is created. Tens of thousands of platform threads are already a problem.
- A virtual thread starts at a few hundred bytes, and its stack grows as needed. Millions of virtual threads are normal.
Virtual threads run on a small pool of carrier threads. By default, the pool has as many carrier threads as the machine has processors. So a million virtual threads that multiply matrices run exactly as fast as the carrier threads allow, plus a little overhead.
Most blocking operations in the
Java Development Kit (JDK) know about virtual threads.
Examples are network calls, Thread.sleep and most locks. When a virtual thread
blocks in one of these operations, the runtime copies the stack of the thread to the heap. The
runtime then frees the carrier. When the operation completes, the virtual thread continues on any
carrier that is free.
You can set the size of the carrier pool when the JVM starts:
java -Djdk.virtualThreadScheduler.parallelism=8 -jar billing.jar-Djdk.virtualThreadScheduler.parallelism=8 gives the scheduler eight carriers.
But the default is nearly always right. The .NET thread pool adds threads slowly when all of
its threads are blocked. The carrier pool rarely needs to grow, because a blocked virtual
thread releases its carrier.
Three ways to start a virtual thread#
There are three ways to start a virtual thread. The code shows them roughly in order, with
the way that you will use most often first. 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();} // close() waits for every task to finishThe try-with-resources block closes the executor at the end. Closing the executor waits for every submitted task.
Thread.startVirtualThread(() -> handle(7));This line starts one virtual thread immediately.
join()waits for the thread to finish, likeWait()on a task in C#..name("import-", 0)This call names the threads
import-0,import-1and so on. The names help when you read logs or thread dumps.
ExecutorService has been AutoCloseable since Java 19. That is why
try (var exec = ...) works in the code above. The block does not end until every
submitted task has finished. So the block waits for all the tasks, and you write no extra code
for the wait.
Is your Spring Boot service on virtual threads?new#
A controller method blocks its thread while it waits for the database or for another service. The wait is cheap or expensive, depending on the kind of thread that the request runs on. This controller reports the kind of thread:
@RestController
class ThreadController {
@GetMapping("/thread")
String thread() {
return "virtual=" + Thread.currentThread().isVirtual();
}
}Show the answer
Spring Boot does not use virtual threads unless you ask.
The property spring.threads.virtual.enabled is false by default, on
Boot 3.2 and later and on Boot 4. So the request ran on a platform thread from the fixed pool
of the web server. On a platform thread, each blocking call holds an operating-system thread
while it waits.
If you set spring.threads.virtual.enabled to true, the same
request returns virtual=true.
A single property makes Spring Boot run your whole web tier on virtual threads. The property works from Boot 3.2 onward, on Java 21 or later:
spring:
threads:
virtual:
enabled: trueWith enabled: true, Tomcat runs each request on a new virtual thread, not on a
thread from a fixed pool. So each controller method also runs on a new virtual thread. A
blocking controller is then no longer a scalability problem. The settings that set the size of
the thread pool no longer have an effect. In a test run without the property, the thread of the
request was named http-nio-auto-1-exec-1. With the property, the thread was named
tomcat-handler-0.
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. Retry is also one of the places where Spring Boot 4
changed. Retry support moved from an add-on library into Spring Framework itself. In the code
below, a client fetches an exchange rate from another service. restClient is a
Spring RestClient that is set up to call 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. fetch itself stays a plain blocking method. But check the number. 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 enable the feature.
In Boot 4, import the annotation from
org.springframework.resilience.annotation, as the imports show. Spring Framework 7
also has an interface called Retryable. This interface is in
org.springframework.core.retry, and an IDE may offer the interface first.
When virtual threads are the wrong tooltrap#
Virtual threads do nothing for CPU-bound work. They make waiting
cheap, not calculating. For work that keeps the processor busy, keep a pool of
ordinary threads. Make the size of the pool close to the number of processor cores.
Executors.newFixedThreadPool creates such a pool. Use the pool for the work that
you would give to Parallel.For in C#.
Never pool virtual threads. A pool exists so that the cost of creating a
thread is shared among many tasks. But a virtual thread has almost no cost to create. To limit
concurrency, limit the resource, not the number of threads. Here ids is a
list of order ids, and load(id) reads one order. The database allows 20
connections:
var gate = new Semaphore(20);
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int id : ids) {
exec.submit(() -> {
gate.acquire();
try { return load(id); } finally { gate.release(); }
});
}
}exec.submit starts a virtual thread for every order.
gate.acquire() lets only 20 of these threads reach the database at the same
time.
Sometimes a virtual thread cannot leave its carrier while it is blocked. Such a virtual thread is called pinned. Since Java 24, only native code that is called through the Java Native Interface (JNI) pins a virtual thread. For the history, see the section Pinning.
Pinning: the one performance trap#
Sometimes a blocked virtual thread cannot be taken off its carrier. Such a virtual thread is
called pinned. A pinned virtual thread keeps its carrier, so the carrier cannot run
anything else. On Java 21 to 23 there were two causes. One cause was blocking inside a
synchronized block. A synchronized
block does the same as a lock statement in C#. The other cause was blocking inside
native code that is called through JNI.
JDK Enhancement Proposal (JEP) 491 fixed the
synchronized case in Java 24 Java 24. A virtual thread now
leaves its carrier correctly while it is blocked inside a synchronized block. The
old advice was to rewrite every synchronized block as a
ReentrantLock. So that advice no longer applies. On Java 24 and later, only native
code pins.
If you are on Java 21, the old workaround still matters. In the code below,
monitor is any object that is 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) {This line holds
monitoras a lock, likelock (monitor)in C#. On Java 21 to 23, waiting on I/O inside the block pinned the carrier. For more aboutsynchronized, see Mutual exclusion.lock.lock();This line takes a
ReentrantLockexplicitly. Theunlockcall in thefinallyblock releases the lock. Waiting while the thread holds aReentrantLocknever pinned.
To find pinning on Java 21 to 23, run with -Djdk.tracePinnedThreads=full. This
option prints a stack trace whenever a virtual thread blocks while it is pinned. Current Java
versions no longer have that option. Use the
JDK Flight Recorder event
jdk.VirtualThreadPinned instead.
ThreadLocal on virtual threadstrap#
C# refresherAsyncLocal: a value that flows with the async callC# 9
static readonly AsyncLocal<string?> User = new();
User.Value = "ada";
await Task.Run(() => Console.WriteLine(User.Value)); // "ada": it flows into the taskThreadLocal still works, but scales
badly. One ThreadLocal value per thread was fine with 200 threads. With
500,000 threads, it is a memory problem. Use ScopedValue Java 25
instead. ScopedValue is also the closer match to
AsyncLocal<T>. A ScopedValue cannot be changed. It lasts only
for a block of code, not until the thread ends. In the code below, the current user is kept
while handle processes one order:
// before: one value for each thread, kept until remove() or the end of the thread
static final ThreadLocal<String> USER = new ThreadLocal<>();
// after: one value for one block of code
static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
ScopedValue.where(CURRENT_USER, "ada").run(() -> handle(order));While run executes, CURRENT_USER.get() returns
"ada". When run returns, the value is gone. For a full comparison of
ScopedValue and AsyncLocal, see
ScopedValue replaces AsyncLocal.
What you'll still meet in existing code#
LegacyExecutors.newFixedThreadPool(n)
Before Java 21, this was the default for I/O work. The size of the pool was guessed, and
then tuned after each incident. You will see this call 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. Such a chain is the closest match in
Java to a Task continuation chain in C#. It is 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 results 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 if you only want to
run two blocking calls at the same time.
LegacyThread.stop() and Thread.suspend()
Both methods have been unsafe since Java 1.2. suspend() was removed in Java 23.
stop() still compiles against Java 25, but it throws and does not stop anything.
Java 26 removes stop(). There has never been a safe way to kill a thread from
outside, in Java or in .NET. Cancellation is cooperative. You interrupt the thread, and then
the thread must notice the interrupt itself.
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.
1An expert asksWhy does Java have no async and await?
async and await?await does the same
in .NET. No compiler rewrites the method, so there is no function colouring.2TranslateWrite this in Java, where both calls wait on the database:
async Task<Report> BuildAsync(long id)
{
var customer = Task.Run(() => LoadCustomer(id));
var orders = Task.Run(() => LoadOrders(id));
await Task.WhenAll(customer, orders);
return new Report(customer.Result, orders.Result);
}
async Task<Report> BuildAsync(long id)
{
var customer = Task.Run(() => LoadCustomer(id));
var orders = Task.Run(() => LoadOrders(id));
await Task.WhenAll(customer, orders);
return new Report(customer.Result, orders.Result);
}Report build(long id) throws Exception {
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Customer> customer = exec.submit(() -> loadCustomer(id));
Future<List<Order>> orders = exec.submit(() -> loadOrders(id));
return new Report(customer.get(), orders.get());
}
}The executor is not a pool. It starts a new virtual thread for every task, so there is no
size to choose. Never pool virtual threads. get throws
checked exceptions, so the method declares
throws.
3An expert asksWhat is pinning, and is it still a problem?
synchronized block pinned the virtual thread. Java 24 fixed
that, so now only native code pins.Structured concurrency and cancellation#
Structured concurrency is like Task.WhenAll plus a
CancellationToken in C#. Subtasks start inside a scope, and no subtask outlives
the scope. Structured concurrency is still a
preview feature in Java 25
Java 25 preview. So
production code on Java 21 or 25 uses an
ExecutorService instead.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | Task.WhenAll(a, b) | fork twice, then join | Structured concurrency is a preview feature in Java 25. With it, join waits for every subtask, and fails as soon as one fails. Why |
| twist | CancellationTokenSource | the scope itself | leaving the try block cancels every subtask still running, so no token is passed down Why |
| twist | try/finally to clean up | the try-with-resources block | no subtask can outlive the block that opened the scope Why |
| trap | Task.WhenAll, a finished API | StructuredTaskScope, behind --enable-preview | Java 25 has the fifth preview of StructuredTaskScope. The flag is needed to compile and to run, and the API still changes. Why |
| twist | Task.WhenAny(a, b) | a scope configured to complete on the first success | Joiner.anySuccessfulResultOrThrow() returns the first result and cancels the rest Why |
| twist | CancelAfter(timeout) | a timeout configured on the scope | C# sets the timeout on the token source, but Java sets the timeout on the scope Why |
| twist | a token passed down by hand | a scope around the subtasks | A token cancels but does not contain. A scope ties each subtask to the block that started it. Why |
| twist | Task.WhenAll(a, b), on final APIs | exec.submit twice, then get() | This is the pattern to ship on Java 25, for two plain calls on virtual threads. A failure does not cancel the other task. Why |
| twist | ct.IsCancellationRequested | Thread.currentThread().isInterrupted() | Cancellation is cooperative in both languages. In Java, interrupt() sets a flag, which the thread must check. Why |
| twist | ct.ThrowIfCancellationRequested() | Thread.interrupted() checks, mostly implicit | blocking JDK calls see the flag and throw InterruptedException for you Why |
| trap | OperationCanceledException | InterruptedException | the call that throws it clears the interrupt flag, so restore the flag or let the exception propagate Why |
| twist | AsyncLocal<T> | ScopedValue.where(USER, "ada").run(...) | ScopedValue is final in Java 25. It is immutable, bound for one block, and inherited by forked subtasks. Why |
The problem it solvestwist#
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()This call opens a scope. By default, the scope waits for every subtask, and it fails as soon as one subtask fails.
scope.fork(() -> fetchCustomer(id));This line starts a subtask on its own virtual thread.
forkreturns a handle to the future result of the subtask.scope.join();This line waits for the subtasks. If one subtask failed,
jointhrows, and the other subtask is cancelled.merge(a.get(), b.get())This line reads the result of each subtask. The results are ready after
joinhas returned.
When the code leaves the try block, every subtask that is still running is
cancelled. This happens when the block ends normally, by an exception or by an interrupt. No
token is passed down, because the scope is the token.
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.
C# refresherCancellationTokenSource, CancelAfter and Task.WhenAnyC# 8
A token source cancels every task that holds its token. It cancels when you call
Cancel(), or after a timeout. WhenAny finishes as soon as the first
task finishes.
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();Completion policies#
open() with no argument waits for every subtask, and it fails as soon as one
subtask fails. To choose a different policy, pass a Joiner. The choice of policy
is like the choice between WhenAll and WhenAny in C#. In the code
below, the shop asks two exchange-rate services for the same rate. The shop takes the answer
that arrives 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()This is a policy that finishes with the first successful result and cancels the other subtasks. It is like
Task.WhenAny, followed by code that cancels the tasks that did not finish first.<Rate>names the result type, because Java cannot infer the type here.return scope.join();With this policy,
joinreturns the first successful result.
A timeout is also set on the scope. In C#, you call CancelAfter on the token
source instead. Here joiner is any of the policies in the table below:
StructuredTaskScope.open(joiner, config -> config.withTimeout(Duration.ofSeconds(2)))The second argument configures the scope. withTimeout gives the scope two
seconds to finish.
These are the policies that Java 25 offers:
| Joiner | Behaviour | .NET analogue |
|---|---|---|
| anySuccessfulResultOrThrow() | returns the first successful result and 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, and throws on any failure | WhenAll with fail-fast |
| allUntil(Predicate) | cancels when the predicate is satisfied | no direct equivalent |
Blog posts from 2023 or 2024 use an older form of the API, with
new StructuredTaskScope<>() and ShutdownOnFailure. The older
form no longer matches Java 25. Java 26 has the sixth preview, and it changes the API again.
For example, anySuccessfulResultOrThrow() is renamed to
anySuccessfulOrThrow().
Why a scope rather than a token#
The idea is the same as the idea behind structured programming. A task started with
executor.submit() is like a goto. The lifetime of the task has
nothing to do with the code that started it. So a task can leak, or it can keep running after
its caller has returned. A scope ties the lifetime of each subtask to the block of code that
started it. A subtask cannot outlive that block, and thread dumps show which task started which
subtask.
A CancellationToken in C# gives you cancellation, but not containment. Nothing
stops you from forgetting to await a task. And nothing makes the task 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); }This method starts the refresh and returns immediately. Nothing waits for the refresh, and nothing stops the refresh 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.
What to write on Java 25twist#
Until structured concurrency is final, write the order report like this for production. The code uses only final APIs, and it needs no preview flag. It reports a failure and guarantees cleanup:
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() rethrows a task's failure as ExecutionException, a first and then b
return merge(a.get(), b.get());
}
// close() waits for all tasks; on exception the block still exits cleanly
}Executors.newVirtualThreadPerTaskExecutor()This is the executor from the chapter Threads are cheap now. It starts one virtual thread per task.
merge(a.get(), b.get())getwaits for each result, one after the other. If a task failed,getthrowsExecutionException, which wraps the task's own exception.
This pattern does not fail fast, and it does not cancel the other task. A
failure in b appears only after a.get() has returned.
close() waits for the other task and does not interrupt it. If cancelling matters,
call b.cancel(true) in a catch block. Or use invokeAny
or invokeAll, which follow their own rules. Structured concurrency exists to solve
this problem.
Cancellation is interruptiontrap#
Java cancels a thread by interrupting it with Thread.interrupt(). An interrupt
is cooperative, like a CancellationToken in C#. interrupt() sets a
flag on the thread. Blocking calls in the JDK throw InterruptedException when they
see the flag.
Thread t = Thread.startVirtualThread(() -> {
try {
Thread.sleep(Duration.ofSeconds(10));
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().isInterrupted());
}
});
t.interrupt();
t.join();Show the answer
A blocking call clears the interrupt flag when it throws
InterruptedException. In the catch block, the flag is already false.
Suppose you catch the exception, ignore it, and do not call
Thread.currentThread().interrupt(). Then every caller loses the cancellation
signal, and the thread keeps working. This mistake is like catching
OperationCanceledException in C# and ignoring it. But the Java mistake is much
easier to make by accident.
Either restore the flag and return, or let the exception propagate. It is never enough to log the exception.
Here a long import processes the order file in chunks. The import 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())This line checks the interrupt flag between chunks, like a check of
ct.IsCancellationRequestedin C#.Thread.sleep(100);When the thread is interrupted, a blocking call such as
sleepthrowsInterruptedException. It does not wait until its time has passed.Thread.currentThread().interrupt();This line sets the flag again for the callers, because
sleepcleared the flag when it threw.
A loop with no blocking call in it must check the flag itself. The statement
if (Thread.interrupted()) throw new InterruptedException(); does the same as
ThrowIfCancellationRequested in C#. Thread.interrupted() reads the
flag and clears it.
ScopedValue replaces AsyncLocal#
Scoped values Java 25 are final in Java 25. So scoped values are ready for production, unlike structured concurrency. A scoped value carries context, such as the current user, down a chain of calls. You do not pass the context as a parameter. Forked subtasks inherit the scoped values:
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()This call creates the key. The key is usually in a
static finalfield, like the staticAsyncLocalin C#.ScopedValue.where(USER, "ada").run(() -> {This line binds
USERto"ada"while the lambda runs. Code that is called from inside the lambda reads the value withUSER.get().
When run returns, USER is unbound again, so the value cannot leak
into later work. This table compares AsyncLocal and ScopedValue:
| 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, because it 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. For ThreadLocal and ScopedValue side
by side, see the chapter Threads are cheap now.
1Structured concurrency looks perfect for your fan-out. Can you ship it?
--enable-preview to compile and again to run. Its class files will not load on any
other JDK version.2An expert asksWhy does Java tie subtasks to a scope instead of passing a cancellation
token?
CancellationToken gives you cancellation, but not containment. Nothing
stops you from forgetting to await a task, and nothing makes the task stop when its caller
returns. A scope ties the lifetime of each subtask to the block that started it, so no subtask
can outlive the block.3TranslateWrite this in Java, for code that runs on virtual threads:
static readonly AsyncLocal<string> User = new();
User.Value = "ada";
DoWork(); // reads User.Value
static readonly AsyncLocal<string> User = new();
User.Value = "ada";
DoWork(); // reads User.Valuestatic final ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "ada").run(() -> doWork()); // doWork reads USER.get()Do not use a ThreadLocal. A ThreadLocal keeps one value for each
thread, and a million virtual threads means a million values. A ScopedValue has no
setter. It is bound while run executes. It is final in Java 25, so it needs no
preview flag.
CompletableFuture and Task#
CompletableFuture<T> does the same as Task<T> in C#.
It is a value that arrives later, and it has methods that chain more work onto that value. Now
that Java has virtual threads, you need
CompletableFuture far less, because most concurrent code can simply block.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | Task<T> | CompletableFuture<T> | a value that arrives later, with methods to chain more work onto it Why |
| twist | Task (void) | CompletableFuture<Void> | a future that carries no result, such as the one thenAccept returns Why |
| twist | Task.FromResult(v) | CompletableFuture.completedFuture(v) | a future that is already complete when you create it Why |
| twist | Task.Run(f) | CompletableFuture.supplyAsync(f) | runs f on another thread, and completes the future with its result Why |
| twist | Task.Run(action) | CompletableFuture.runAsync(action) | the form for work that returns no value Why |
| twist | TaskCompletionSource<T> | new CompletableFuture<>() | create it empty, then call complete(value) when the result arrives Why |
| twist | await task | .join(), or just block on a virtual thread | Java has no await, so chained code reads like C# before await Why |
| twist | .Result / .GetAwaiter().GetResult() | .join() | join() throws an unchecked CompletionException that wraps the cause Why |
| twist | .ContinueWith(t => f(t.Result)) | .thenApply(f) | runs f on the result when it arrives Why |
| twist | .ContinueWith returning Task | .thenCompose(f) | flattens a future of a future into one Why |
| twist | combine two results | .thenCombine(other, fn) | waits for both futures, then merges their two results with fn Why |
| twist | Task.WhenAll(a, b) | CompletableFuture.allOf(a, b) | Use it for futures in older code. It completes when every future completes, but it carries no results, so read each future. Why |
| twist | Task.WhenAny(a, b) | CompletableFuture.anyOf(a, b) | the result is typed Object, so you must cast it back Why |
| twist | try/catch around await | .exceptionally(fn) or .handle(fn) | exceptionally returns a replacement value when the future fails Why |
| trap | blocking inside Task.Run | supplyAsync(f, exec) | with no executor argument, the work shares the common pool, and blocking there starves it Why |
| twist | Task.Delay | CompletableFuture.delayedExecutor(...) | it returns an executor that runs a task after a delay, and it was added in Java 9 Why |
| twist | IProgress<T> | no equivalent | pass a Consumer<Integer> and call it as the work progresses Why |
| twist | Task, for all concurrent work | CompletableFuture, for a value that completes later | to run two blocking calls at once, use virtual threads and an executor instead Why |
| twist | a Task you only wait for | Future, from ExecutorService.submit | the Java 5 interface has no chaining, so you can only call get(), cancel() and isDone() Why |
Translationtwist#
A CompletableFuture is a value that arrives later. In the code below,
compute() is some calculation. client is a payment client that
reports each reply through a callback. a and b are two tasks that are
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.supplyAsync(() -> compute())This call runs the lambda on another thread, like
Task.Runin C#. It completes the future with the result of the lambda.new CompletableFuture<String>()This is an empty future, like a
TaskCompletionSourcein C#. Nothing completes the future until some code callscomplete.client.onReply(reply::complete);When the reply arrives, the client calls
complete. That call gives the future its value.reply.join()joinwaits for the value, like.Resultin C#. On a virtual thread, waiting uses almost no resources.Object first = CompletableFuture.anyOf(a, b).join();anyOfreturns anObject, so you must cast the result to the type that you expect.
Reporting progress#
C# refresherIProgress: reporting progress from async workC# 5
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
}Java has no IProgress. The method that does the work takes a
Consumer<Integer> as a parameter. Consumer is in the
package java.util.function. The method calls
the Consumer as the work progresses:
void importFile(String file, Consumer<Integer> progress) {
progress.accept(50); // half way
}
importFile("orders.csv", pct -> System.out.println(pct + "%"));accept does the same as Report in C#. The lambda runs on the thread
that calls accept.
Chaining#
C# hid the chaining behind await. Java never hid it. So code that uses
CompletableFuture reads like C# code from before await, which was
full of ContinueWith calls.
Suppose the work has three steps. Fetching a customer returns a future. Decorating the
customer is a plain calculation. Enriching the customer 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)thenApplyrunsdecorateon the result when the result arrives. It is likeContinueWithwith a plain function..thenCompose(this::enrichAsync);Use
thenComposewhen the next step itself returns a future. It flattens the future of a future into one future, likeUnwrapafterContinueWith.Report report = f.join();This line waits for the whole chain to finish.
An executor is an object that supplies the threads. These are the chaining methods, and where each method runs:
| 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, and thenCompose flattens it | 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 success or failure, but 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 must 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)))This line starts one future for each id.
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();allOftakes an array, so the list is converted to an array first. Thenjoinwaits for every future..map(CompletableFuture::join)This line collects each result. Here
joinreturns at once, 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. Parallel streams share the pool, and so does everything else in the
Java Virtual Machine (JVM). Blocking on that pool starves
everything that shares it.
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()This is the executor from the chapter Threads are cheap now. It gives each task its own virtual thread.
.thenApplyAsync(this::transform, exec);Every
*Asyncstep takes the executor as its last argument. Without that argument, the step runs on the common pool again.
An executor can also start its work after a delay. This is the nearest thing in Java to
Task.Delay followed by more work:
var later = CompletableFuture.delayedExecutor(2, TimeUnit.SECONDS, exec);
CompletableFuture.supplyAsync(() -> httpCall(), later); // starts after two secondsdelayedExecutor wraps exec. Each task that you pass to
later waits two seconds, and then runs on exec.
Exceptions#
A failed future wraps the cause in another exception. join throws the cause
inside a CompletionException. get throws the cause inside an
ExecutionException. This is the same wrapping that
AggregateException does in .NET, and it is just as annoying.
C# refresherAggregateException: several failures wrapped in oneC# 3
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 logger of the class:
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();getCausereturns the exception that the task actually threw.future.exceptionally(ex -> {exceptionallyhandles a failure inside the chain. The lambda receives the exception and returns a replacement value. Here the replacement value is an empty report, sopublishstill runs.
get() throws two checked
exceptions, ExecutionException and InterruptedException.
join() throws the unchecked CompletionException. Inside a lambda,
join() is almost always the right choice, because checked exceptions do not work
well in lambdas. See Exceptions and resources.
When to still use ittwist#
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, because 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(...) |
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;
}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 future, like the
Taskof aTaskCompletionSourcein C#. The caller can wait for the future, or chain more work onto it.
LegacyFuture, the Java 5 interface
The original Future from Java 5 has no chaining methods at all. You can only
call get(), cancel() and isDone(). The call to
get() blocks. ExecutorService.submit returns a Future.
A Future is good enough when you intend to block on a virtual thread anyway.
CompletableFuture implements Future, so it does everything that
Future does, and more.
1CompletableFuture.supplyAsync(() -> httpCall()) waits on another service. Which
threads run it, and what is the risk?
CompletableFuture.supplyAsync(() -> httpCall()) waits on another service. Which
threads run it, and what is the risk?ForkJoinPool.commonPool(). That
pool has one thread fewer than the processor has cores, and parallel streams share it.
Blocking there starves everything that shares the pool. So pass an executor as the second
argument for anything that blocks.Locks, atomics and the memory model#
Each basic tool has a close match in C#. synchronized does the same as
lock. AtomicInteger does the same as Interlocked.
ConcurrentHashMap does the same as ConcurrentDictionary. Both
languages also have volatile, and the Java keyword gives stronger guarantees than
the C# keyword.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | lock (_gate) { ... } | synchronized (gate) { ... } | the same mutual exclusion, and any object can be the lock Why |
| twist | lock (this) | a synchronized method | the method modifier locks on this, so prefer a private final lock object Why |
| twist | Monitor.TryEnter(o, ts) | lock.tryLock(t, unit) | ReentrantLock adds timeouts, interruption and fairness. Unlock it in a finally block. Why |
| twist | Interlocked.Increment(ref x) | atomicInt.incrementAndGet() | Java has no ref parameters, so the operations are methods of an object that holds the number Why |
| trap | Interlocked.CompareExchange(ref x, v, c) | atomicRef.compareAndSet(c, v) | the expected value comes first, but CompareExchange puts the new value first Why |
| twist | Interlocked for high contention | LongAdder, far better under contention | for a counter that many threads write constantly and that is read now and then Why |
| twist | volatile | volatile | It is the same for a stop flag or double-checked locking. Java also keeps all volatile reads and writes in one order. Why |
| twist | Volatile.Read / Write | volatile field, or VarHandle | mark the field volatile, so that another thread sees the latest value Why |
| twist | Lazy<T> | a static holder class | It is lazy and thread-safe with no locking. Stable values are like Lazy<T>, and they are a preview feature in Java 25. Why |
| twist | ConcurrentDictionary<K,V> | ConcurrentHashMap<K,V> | the thread-safe HashMap, and its reads never lock Why |
| twist | GetOrAdd(k, factory) | computeIfAbsent(k, fn) | it is atomic, so the function runs at most once per key, unlike GetOrAdd Why |
| trap | a GetOrAdd factory that calls GetOrAdd | a computeIfAbsent function that calls the map again | Java holds a lock while the function runs, so it can throw IllegalStateException with the message Recursive update Why |
| twist | AddOrUpdate | compute(k, fn) / merge(k, v, fn) | compute and merge update a value atomically Why |
| twist | BlockingCollection with cap | ArrayBlockingQueue<E> | a bounded queue with a fixed capacity set when it is created Why |
| twist | Channel<T> | BlockingQueue, or SubmissionPublisher | Java has no async channel, so a BlockingQueue on virtual threads is the usual answer Why |
Mutual exclusiontwist#
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();This is a private object that is used only as the lock, like
_gatein the C# code. Any object can be a lock in Java.synchronized (gate) {Only one thread at a time can be inside a block that is synchronized on
gate.lock (_gate)in C# works the same way.
synchronized is also a method modifier. On an instance method, it locks on
this. On a static method, it locks on the class itself:
public synchronized void add(int x) { total += x; } // locks on thisEvery call to add now locks the object itself.
Never synchronized on this or on a public field.
Any other code that holds a reference to your object can lock on it too. Use a private final
lock object. The same advice applies to lock (this) in C#. But the method
modifier in Java 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 | Thread.Interrupt() ends the wait for any lock | 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) |
In the interruptible row, Java is the reverse of C#. In C#, Thread.Interrupt()
ends a wait for any lock, and the waiting thread gets a
ThreadInterruptedException. In Java, a thread that waits to enter a
synchronized block ignores interrupt() until it gets the lock. So a
wait that must be cancellable needs lockInterruptibly().
Here a stock update stops waiting 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)tryLockwaits at most 200 milliseconds for the lock, likeMonitor.TryEnterwith a timeout in C#. It returnsfalseif the lock was still busy after that time.lock.unlock();A
ReentrantLockis not released automatically. So always put the call tounlockin afinallyblock.throw new TimeoutException();You decide what the code does when the lock did not become free in time.
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 ReentrantLock and synchronized are fine
on virtual threads. See Threads are cheap now.
Atomicstrap#
Without a lock, two threads can read the same old count, and then one of the additions is
lost. Marking the field volatile does not help, because count++ is
still a read followed by a write. For one number, a lock is more than you need. Both languages
have atomic operations. An atomic operation updates 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 valueprivate final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();
count.addAndGet(10);
count.getAndSet(0); // set, returning the old valuenew AtomicInteger()Java has no
refparameters. So the atomic operations are methods of an object that holds the number.count.incrementAndGet();This call adds one and returns the new value, like
Interlocked.Incrementin C#.
var count = new AtomicInteger(4);
// C#: Interlocked.CompareExchange(ref count, 5, 4) sets the value to 5 if it is 4
boolean changed = count.compareAndSet(5, 4);
System.out.println(changed + " " + count.get());Show the answer
compareAndSet takes the expected value first.
CompareExchange puts the new value first. So the C# order asks Java to set 4 if
the value is 5. The value is 4, so nothing changes, and nothing warns you.
Write count.compareAndSet(4, 5), and check the boolean that it
returns.
The other operations map as follows:
| .NET | Java |
|---|---|
| Interlocked.Add(ref x, n) | atomicInt.addAndGet(n) |
| Interlocked.Exchange(ref x, v) | atomicRef.getAndSet(v) |
| n/a | atomicInt.updateAndGet(fn) |
| n/a | atomicRef.accumulateAndGet(v, fn) |
LongAdder is for a counter that many threads update at the same time. It
spreads the count over several cells, and it sums the cells when you read the count. Under that
load, LongAdder performs far better than AtomicLong. Use
LongAdder for metrics and counters that are written constantly and read only now
and then.
volatile means more in Java#
C# refresherVolatile.Read and Volatile.WriteC# 6
Volatile.Read and Volatile.Write are explicit reads and writes.
They 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 volatile, but its guarantees differ:
| C# volatile | Java volatile | |
|---|---|---|
| Reordering | acquire on read, release on write | sequentially consistent, so every thread agrees on one order |
| 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, because 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 are enough for the two everyday uses. These uses are a stop flag and double-checked locking.
The rule in the Visibility row is called happens-before in Java. It says that a thread that sees a volatile write also sees every write made before that volatile write.
The difference between the two keywords appears only in one case. A thread writes one volatile field and then reads a different volatile field. Java keeps these two steps in order, and C# may not.
Java 5 repaired the Java memory model in 2004. So an older article that calls double-checked locking broken in Java describes a Java Virtual Machine (JVM) that no longer exists.
For a plain field, a VarHandle does the same as Volatile.Read and
Volatile.Write in C#. VarHandle is in the
package java.lang.invoke. Here
STOP is a VarHandle for the field stop. The field is a
plain boolean:
void run() { while (!(boolean) STOP.getVolatile(this)) work(); }
void stop() { STOP.setVolatile(this, true); } // run sees it on its next checkgetVolatile and setVolatile read and write the field as if it were
declared volatile. You will rarely need these two methods, because marking the
field volatile is the usual answer.
Here the shop's configuration is loaded once, on first use. The first thread that needs the configuration loads it:
// 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 that sees the newConfigalso sees it fully built.var c = config;This line reads the field once into a local variable. So the usual path needs only a single read of the field.
if (c == null) config = c = load();The second check is inside the lock. It stops two threads that both saw
nullfrom loading the configuration twice.
It is better still to avoid double-checked locking. 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 reads
Holder.INSTANCE. The JVM runs that initialisation exactly once, no matter how many
threads arrive at the same time.
Java 25 also previews stable values Java 25
preview. A stable value is essentially the same as
Lazy<T> in C#. After initialisation, the
just-in-time (JIT) compiler can treat a stable value as a
constant.
C# refresherLazy: create a value on first useC# 9
private static readonly Lazy<Config> _config = new(() => Load()); // thread-safe by default
public static Config Config => _config.Value; // Load() runs on first readConcurrent collectionstwist#
Here pair is a currency pair, and job is a job to process:
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))This call returns the cached rate. If the map has no rate for the key, the call runs
fetchand stores the result. It runsfetchat most once per key. In C#,GetOrAddmay callFetchtwice when two threads race.hits.merge("home", 1, Integer::sum);mergestores 1 if the key is new. Otherwise it combines the old value with 1 by callingInteger::sum. This is likeAddOrUpdatein C#.new ArrayBlockingQueue<>(100)This is a queue that holds at most 100 jobs, like a bounded
BlockingCollectionin C#.
C# refresherChannel, CountdownEvent, ManualResetEventSlim and BarrierC# 8
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 continueJava has no async channel. In its place, use a BlockingQueue and read it from
a virtual thread. The class SubmissionPublisher pushes each item to its
subscribers instead. It is in the package java.util.concurrent.
These are the other concurrent types:
| .NET | Java | Note |
|---|---|---|
| ConcurrentQueue<T> | ConcurrentLinkedQueue<E> | an unbounded queue whose operations never wait |
| BlockingCollection<T> | LinkedBlockingQueue<E> | optionally bounded, and take() waits for an item while put() waits for space |
| ConcurrentBag<T> | no direct equal | Java has no unordered bag, but a ConcurrentLinkedQueue is usually good enough |
| ImmutableList + swap | CopyOnWriteArrayList<E> | every write copies the array, so use it only when writes are rare |
| CountdownEvent | CountDownLatch | counts down to zero once, and 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), but a gate that can be reset needs a Condition |
| SemaphoreSlim | Semaphore | It is a counting semaphore. Call acquire() to take a permit, and release() to return it. |
A function that calls back into the maptrap#
computeIfAbsent(key, fn) is like GetOrAdd in C#. It returns the
stored value. If the map has no value for the key, it runs the function and stores the
result. A cache that fills itself often has a function that reads the same cache. Here a
Fibonacci function remembers its results. The same code with GetOrAdd prints
102334155 in C#:
static final Map<Integer, Long> cache = new ConcurrentHashMap<>();
static long fib(int n) {
if (n < 2) return n;
return cache.computeIfAbsent(n, k -> fib(k - 1) + fib(k - 2));
}
System.out.println(fib(40));Show the answer
java.lang.IllegalStateException: Recursive update.computeIfAbsent holds a lock on part of the map while the function
runs. A function that calls the same map again, even indirectly, can meet that
lock, and then the map throws. This does not happen for every key, so a test can pass.
In C#, GetOrAdd runs its factory outside any lock. That is why the factory can
run twice for one key. It is also why GetOrAdd does not have this problem.
Keep the function short and self-contained. Here, read with get, calculate the
value outside the map, and store the value with putIfAbsent.
1TranslateWrite this in Java:
var views = new ConcurrentDictionary<string, int>();
void Viewed(string productId) =>
views.AddOrUpdate(productId, 1, (_, n) => n + 1);
var views = new ConcurrentDictionary<string, int>();
void Viewed(string productId) =>
views.AddOrUpdate(productId, 1, (_, n) => n + 1);private final ConcurrentHashMap<String, Integer> views = new ConcurrentHashMap<>();
void viewed(String productId) {
views.merge(productId, 1, Integer::sum);
}merge stores 1 for a new key. For an existing key, it adds 1 to the old value.
It does this in one atomic step. A get followed by a put loses
updates when two threads race.
2An expert asksWhy is volatile not enough for count++?
volatile not enough for count++?volatile makes every write visible to other threads, but
count++ is a read followed by a write. Two threads can read the same old value,
and one of the additions is lost. incrementAndGet updates the value in one step
that no other thread can interrupt.Legacy concurrency you will still meet#
Everything in this chapter still works and is common in production code. But you should not write any of it on Java 21 or later. It is here so that you can read existing code and know what to replace it with.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | Task.Run(() => Handle(id)) | pool.submit(() -> handle(id)) | .NET has always pooled threads for you, but in Java before 21 you created and sized the pool yourself Why |
| twist | ThreadPool.QueueUserWorkItem | pool.submit(task), on a pool you create | both queue work to a pool, but in Java before 21 you made the pool, sized it and shut it down Why |
| twist | new Thread(DoWork) | new Thread(this::doWork) | This is the Java 1.0 API. Write Thread.ofVirtual().start(...) or use an executor instead. Why |
| twist | a thread pool you never size | Executors.newFixedThreadPool(50) | the size is a guess, so for work that waits, use a virtual-thread executor instead Why |
| trap | a pool with nothing to shut down | pool.shutdown() in a finally block | if code from before Java 19 forgets this call, the pool's threads keep the program running Why |
| twist | Monitor.Wait / Pulse | queue.wait() / queue.notify() | You must hold the lock and wait in a loop. A BlockingQueue does this correctly. Why |
| twist | a lock around every call | Collections.synchronizedMap(...) | a check followed by an update is still a race, so use ConcurrentHashMap Why |
| trap | a timer that runs many tasks | java.util.Timer | one task that throws kills its only thread, so use ScheduledExecutorService Why |
| trap | ThreadLocal<T> | ThreadLocal | call remove() in a pool, and prefer ScopedValue on virtual threads Why |
| twist | no pool to manage | try (var exec = Executors.newVirtualThreadPerTaskExecutor()) | This is the most common change. There is no size to guess, and closing the block waits for every task. Why |
What older code looks liketwist#
The order service's older code handles each incoming order on a thread pool:
// .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)This call creates a pool of exactly 50 platform threads. A platform thread is the ordinary kind of thread. Each platform thread is backed by an operating-system thread. The number 50 is a guess that you had to make.
Future<?> f = pool.submit(() -> handle(id));This line submits a task, like
Task.Runin .NET. It returns aFuture, which is like aTaskin .NET. Thenf.get()waits for the task to finish.pool.shutdown();You must shut down a pool when you have finished with it. If you do not, the threads of the pool keep the program running.
Raw threads#
Legacynew Thread(...).start()
This is the Java 1.0 API. Each platform thread reserves about 1 MB of memory for its
stack. Creating a platform thread also needs a call into the operating system. That 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.
setDaemon(true)does the same asIsBackground = truein .NET.t.join();This line waits for the thread to finish, like
Join()in .NET.
Replace it with Thread.ofVirtual().start(...) for work that
waits, or with 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() cannot kill it: it 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 noticeYou cannot kill a thread safely from outside, in .NET or in Java. Ask the thread to stop with
interrupt(). For how a thread notices the request, see the section Cancellation is interruption.
| 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() | the name appears in thread dumps and logs, so name your threads |
| Priority | Thread.Priority | setPriority() | only a hint that the OS may ignore, so do not rely on it |
| Wait for completion | Join() | join() | blocks until the thread ends, exactly like Join() |
| Kill it | Abort(), which only throws | stop(), which only throws | cannot be done safely on either platform, so ask the thread to stop instead |
Fixed thread pools#
LegacyExecutors.newFixedThreadPool(n)
Before Java 21, this was the standard way to run work that waits. A larger pool gives more
throughput, but it uses more memory. A wrong size causes the classic production incident. If the
pool is too small, requests queue. If the pool is too large, 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)This call runs every task and waits for all of them. It returns one
Futurefor each task.pool.shutdown();This line stops the pool from accepting new work. Then
awaitTerminationwaits up to 30 seconds for the running tasks to finish.
Replace it 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. Virtual threads replace some of
the factories, and other factories are still the right tool:
| 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. Older
code shuts the pool down in a finally block instead. There you will see
shutdown(), awaitTermination() and shutdownNow(). If the
code forgets these calls, 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 it finishes its work.
wait, notify and notifyAll#
LegacyObject.wait() / notify()
This is the original way to wait for a condition, and it is built into every object. You
must hold the lock of the object when you call these methods. They are well known for being
easy to use wrongly. A missed notify leaves a thread waiting forever. A call to
notify in place of notifyAll can wake the wrong waiter. And a waiting
thread can wake up for no reason, so the call to wait must always be in a loop.
Here a worker takes the next order from a shared queue, and waits while the queue is
empty:
synchronized (queue) {
while (queue.isEmpty()) { // while, never if, spurious wakeups are real
queue.wait();
}
return queue.poll();
}synchronized (queue) {waitandnotifywork only while you hold the lock of the object that you wait on.while (queue.isEmpty()) {This is a loop, never an
if. When the thread wakes, the queue may still be empty.queue.wait();This line releases the lock and sleeps until another thread calls
queue.notify()ornotifyAll(). Then the thread takes the lock again.
Replace it with a BlockingQueue, which does exactly this
and does it correctly. Or use a ReentrantLock with a Condition.
Synchronized wrappers#
LegacyCollections.synchronizedMap and friends
These wrappers put a lock around every method. The lock makes each single call safe. But
the lock does not protect a check that is followed by an update, so the code below 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 the two calls, another thread can insert the same key. Then the value from one thread overwrites the value from the other thread.
Replace it with ConcurrentHashMap and its atomic compound
operations. The operations are putIfAbsent, computeIfAbsent and
merge.
java.util.Timer#
LegacyTimer and TimerTask
A Timer runs all its tasks on one thread. A single task that throws an
unchecked exception kills that thread. Every
other scheduled task is then cancelled, and nothing reports it. Replace it
with ScheduledExecutorService, or with 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)This call creates a scheduler with one thread. Unlike a
Timer, the scheduler survives a task that throws.sched.scheduleAtFixedRate(this::poll, 0, 30, TimeUnit.SECONDS);This line runs
pollimmediately, and then every 30 seconds.
Even with ScheduledExecutorService, an uncaught exception cancels that one
repeating task. Wrap the body of the task in a try/catch if the task
must keep running.
ThreadLocal#
LegacyThreadLocal
ThreadLocal in Java does the same as ThreadLocal<T> in
.NET. It holds one value for each 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 that you forget to remove is still there for 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);This line stores the user for this thread only. Code that is called later on the same thread reads the user with
CURRENT.get().CURRENT.remove();This line clears the value. In a pool, the thread then serves another request. Without this line, that request would see this user.
Replace it with ScopedValue Java 25. A
ScopedValue cannot be changed, lasts for one block, and cannot leak. It is the
closer match to AsyncLocal in C#. See
Structured concurrency and cancellation. For ThreadLocal
and ScopedValue side by side, see the chapter
Threads are cheap now.
Before and aftertwist#
Most of these changes are small. The most common change replaces a fixed pool with virtual threads. It 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);This is the old pool. It has a guessed size, and a shutdown that you must remember.
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {This is the new executor. It has no size to guess. Closing the executor at the end of the block waits for every task.
1A command-line tool written before Java 19 finishes its work, but the process never exits.
What is the likely cause, and the fix?
pool.shutdown() in a finally block. Since Java 19 an
ExecutorService is AutoCloseable, so a
try-with-resources block closes the pool for
you.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, because suspend is gone and stop only throws |
| CompletableFuture merely to parallelise blocking calls | virtual threads |
Reactive: WebFlux and Reactor#
Reactor does the same as Rx.NET. WebFlux is a Spring web stack that never blocks. You probably do not need it. The main argument for reactive Java was that threads were too expensive to give one to each request. Virtual threads Java 21 removed that argument.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | Task<T> | Mono<T> | zero or one value that arrives later, like a Task Why |
| twist | IAsyncEnumerable<T> | Flux<T> | a stream of any number of values that arrive over time Why |
| twist | IObservable<T> | Flux<T> | one type covers both the pull and the push styles Why |
| twist | Task.FromResult(v) | Mono.just(v) | a Mono that already holds its value Why |
| twist | Task.CompletedTask | Mono.empty() | a Mono that completes with no value Why |
| twist | .Select | .map | transform each value as it arrives Why |
| twist | .Where | .filter | keep only the values that match Why |
| twist | .SelectMany | .flatMap | each value becomes a publisher, and the results merge Why |
| twist | two awaits, then Merge(c, o) | Mono.zip(a, b).map(...) | C# awaits each call, one after the other, but in Reactor a combinator pairs the two results Why |
| trap | async code, for throughput | Spring MVC on virtual threads | do not adopt WebFlux for throughput, because blocking code scales to the same numbers on Java 21 Why |
| twist | IAsyncEnumerable + Channel | Flux + backpressure | Reactor lets a slow consumer tell the producer to send less Why |
| twist | results that arrive over time | Flux, as server-sent events | reactive code still fits streams, backpressure and very large fan-out Why |
| trap | await | .block(), almost always wrong | block() waits for the value, but on one of Reactor's non-blocking threads it throws IllegalStateException instead Why |
| trap | a blocking call in async code | a blocking call on an event-loop thread | it stalls unrelated requests, so one blocking library undoes the whole model Why |
| trap | an ORM behind async code | R2DBC, with no ORM at all | there is no reactive JPA, so WebFlux means giving up Spring Data JPA Why |
| trap | calling a method that returns a Task | building a Mono or Flux | a Mono or Flux is a recipe, not a running computation, so nothing happens until something subscribes Why |
The modeltwist#
Reactor is the library that WebFlux uses. Its two types match .NET types that 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>in .NET. The methodjustwraps a value that you already have, likeTask.FromResult.Flux<Order> many = repo.findAll();A
Fluxis a stream of any number of values that arrive over time. It is likeIAsyncEnumerable<T>orIObservable<T>in .NET.Flux.interval(Duration.ofSeconds(1))This call produces 0, 1, 2 and so on, one value each second, like
Observable.Interval..map(Long::intValue);maptransforms each value, likeSelectin C#.intervalcounts inlongvalues, so this line turns each value into anint.
Combining results: Mono.zip#
Combining two results shows the difference in style. C# awaits each call. Reactor combines
two Mono objects 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(This call waits for both
Monoobjects and pairs their values. The callsgetCustomerandgetOrdersrun at the same time..map(t -> merge(t.getT1(), t.getT2()));zippairs the two values into one object.getT1()andgetT2()read the two values from that object.
Java had Reactor instead of async/await. C# solved
non-blocking I/O with a compiler transform. With that transform, you keep writing straight-line
code. Java could not do the same, so the Java ecosystem built a library of combinators instead.
That is why reactive Java reads like C# would read if await had never been added
and everyone used ContinueWith.
Why the case for it collapsedtrap#
The argument for reactive code 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, which is impossible | a few MB, which is not a problem |
| So the advice was | never block, and restructure everything into callbacks | block, because 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);This line blocks a virtual thread while it waits. A blocked virtual thread uses almost no resources.
The Java code in the second column needs no reactive types. You can debug it with a normal stack trace, and it scales to the same numbers. For more about blocking code on virtual threads, see the chapter Threads are cheap now.
Do not adopt WebFlux for throughput on a new Boot service. That decision made sense in 2018 and is usually wrong now. With WebFlux you lose readable stack traces, ordinary debugging and many of the libraries that block. Every future maintainer also has much more to learn. In return, you get a scalability property that the ordinary web framework of Spring already has on virtual threads. That framework is called Spring Model-View-Controller (MVC).
When it is still the right answertwist#
Reactive code still fits when the work really is a stream. It also fits when a library that you depend on is reactive. One example is the reactive database driver, which is called Reactive Relational Database Connectivity (R2DBC). These are the cases:
| 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 |
| R2DBC, RSocket | the library is reactive, and working against 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_VALUEThis setting tells Spring to send server-sent events. With server-sent events, the response stays open and delivers many values over time.
.onBackpressureLatest();If a client reads more slowly than prices change, the client gets the latest price. The client does not get a growing backlog of old prices.
Backpressure: what virtual threads do not give youdeep dive
Backpressure is the concept with no equivalent in the virtual-thread model. Backpressure is the ability of a slow consumer to tell a fast producer to send less. A blocking pipeline has backpressure with no extra code, because if you stop reading, the producer blocks. But this works only within one process. Across a network or through a queue, Reactor makes backpressure an explicit part of the contract, and you can tune it. 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. Netty is a network server with a small number of event-loop threads, roughly one for each processor core. Each event-loop thread serves many connections. It switches between the connections instead of waiting. If you block one event-loop thread, every request assigned to that thread stops. This includes requests that have nothing to do with your request:
@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);This is a blocking database call. It holds the event-loop thread until the database answers.
This is why a WebFlux service must be non-blocking everywhere. One blocking library anywhere in the request path breaks the whole model. If you must block, move the call to a separate pool that is meant for blocking work:
return Mono.fromCallable(() -> jdbcRepo.findById(id))
.subscribeOn(Schedulers.boundedElastic());Mono.fromCallable(() -> jdbcRepo.findById(id))This line wraps the blocking call, so that the blocking call runs only when something subscribes.
.subscribeOn(Schedulers.boundedElastic());This line runs the blocking call on Reactor's pool for blocking work, so the event loop stays free.
Add the BlockHound agent to your tests. Then BlockHound reports an error for any blocking call that reaches an event loop.
.block() looks like await, because it waits for the value of a
Mono. On an ordinary thread, .block() does wait. On one of Reactor's
own non-blocking threads, Reactor refuses to wait and reports this error:
java.lang.IllegalStateException: block()/blockFirst()/blockLast() are blocking, which is not supported in thread parallel-2#block().
Waiting there would stall every request that the thread serves. The name of the thread is
at the end of the message, and the name varies.Mono and let Spring subscribe to it, or chain the next step with
map or flatMap.What triggers it
Mono.just(2L)
.publishOn(Schedulers.parallel()) // moves to one of Reactor's non-blocking threads
.map(id -> findOrder(id).block()) // throws here
.block();WebFlux against MVC#
WebFlux never blocks. It is like an ASP.NET Core pipeline that is written entirely with callbacks. Spring MVC usually reads a database through Java Database Connectivity (JDBC) or Jakarta Persistence (JPA). WebFlux usually reads a database through R2DBC. Here is how the two web stacks of Spring compare:
| 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, and hard to read |
| 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(); }This is Spring MVC, with a plain return type. The call blocks a virtual thread while the database answers.
Mono<Order> get(@PathVariable long id) { return reactiveRepo.findById(id); }This is WebFlux. The method returns a
Monoat once. Spring subscribes to theMonoand writes the order when the order arrives.
There is no reactive JPA. R2DBC is a different driver, with a different API. It has no object-relational mapper (ORM) at all. So there is no entity graph, no lazy loading, and no Hibernate. If you choose WebFlux for a service that mostly reads and writes a database, you give up Spring Data JPA. That loss is usually far larger than the thread cost that WebFlux removes.
Reading it#
Even if you never write reactive code, you will read it. Spring Cloud Gateway uses reactive
code. So do some Kafka integrations and much existing service code. Here is a
typical method, which streams the recent active orders of a customer. log is the
logger of the class:
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)This call returns a
Fluxof the customer's orders. Each order is delivered as the database returns it..flatMap(this::enrich)This line turns each order into a
Mono<OrderDto>. It merges the results as they complete, so the results may arrive in a different order..onErrorResume(e -> Flux.empty())If anything fails, this line sends nothing more. It does not send an error.
.timeout(Duration.ofSeconds(3))This line fails the stream if three seconds pass without a new element.
You will see these operators most often:
| Operator | Does |
|---|---|
| map | transform each element |
| flatMap | transform each into a publisher and merge, but order is 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. If you build one and then discard it,
nothing happens 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 if you forget to
return the publisher, nothing runs, and nothing reports an error:
repo.save(order); // does nothing, never subscribed
return repo.save(order); // correctrepo.save(order); // does nothing, never subscribedThis line builds a
Monothat would save the order, and then discards theMono. Nothing subscribes, so nothing is saved.return repo.save(order);This line returns the
Mono, so Spring can subscribe to it. Subscribing runs the save.
LegacyRxJava
Before Reactor, the reactive library in Java was RxJava. RxJava is a direct port of Rx.NET,
and it is still common on Android. Spring standardised on Reactor. But Reactor and RxJava both
implement the Reactive Streams specification, so their Publisher types work
together. If you meet Observable and Single, the code uses RxJava.
Reactor code has Flux and Mono instead.
1Is WebFlux the right choice for a new database-backed service?
Numbers, money and time#
The number types are almost the same in Java as in C#. Money and dates are different. Money
is the class BigDecimal. C# uses operators on a decimal. Java calls
methods on a BigDecimal. Dates come from java.time.
| Kind | C# | Java | Note |
|---|---|---|---|
| same | int, long, short, float, double, char | int, long, short, float, double, char | the same names and the same sizes in both languages Why |
| twist | byte | no equivalent | Java has no unsigned byte, so (byte) 200 is -56, and b & 0xFF gives 200 again Why |
| twist | sbyte | byte | a Java byte is signed, from -128 to 127, like sbyte in C# Why |
| twist | ushort | char | char is the only unsigned type in Java, and it is 16 bits wide Why |
| twist | uint | no equivalent | Java has no uint, so use a long or the Integer.*Unsigned helpers Why |
| twist | ulong | no equivalent | Java has no ulong, but methods such as Long.toUnsignedString treat a long as unsigned Why |
| twist | nint / nuint | no equivalent | Java has no native-sized integer types, so use int or long Why |
| twist | bool | boolean | the same true or false, spelled boolean in Java Why |
| twist | List<int> | List<Integer> | Java uses a wrapper type such as Integer wherever an object is needed Why |
| trap | decimal | no equivalent | Java has no decimal type, so money is a BigDecimal object, with a method for each operator Why |
| trap | 0.1m | new BigDecimal("0.1") | always build it from a string, because new BigDecimal(0.1) keeps the error of the double Why |
| trap | a == b on two decimals | a.compareTo(b) == 0 | equals also compares scale, so 1.0 and 1.00 are not equal Why |
| trap | total / 3 | total.divide(three, 2, RoundingMode.HALF_UP) | divide with no scale throws when the result never ends, such as 1/3 Why |
| twist | DateOnly, DateTimeOffset, TimeSpan | LocalDate, Instant, Duration | java.time gives each kind of time its own immutable type Why |
| trap | TimeSpan | Duration, or Period for calendar days | one day as a Period and 24 hours as a Duration differ across a clock change Why |
| twist | no equivalent | Date, Calendar, SimpleDateFormat | These are the old date classes. Read them, never write them, and convert to java.time at the boundary. Why |
| twist | x.ToString("N2") | String.format("%,.2f", x) | uses the server's default locale unless you pass one, such as Locale.ROOT Why |
| twist | int.TryParse(s, out v) | catch NumberFormatException | Java has no TryParse, so catch the exception that parseInt throws Why |
Primitive typestwist#
sbyte sb = -100; // signed: -128 to 127
uint u = 3_000_000_000;
ulong max = ulong.MaxValue;
decimal price = 19.99m;
bool ok = true;byte sb = -100; // Java's byte is C#'s sbyte
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 sb = -100;A Java
byteholds −128 to 127, likesbytein C#. To see what that range does to a value of 200, see the section A byte is signed.long u = 3_000_000_000L;Java has no
uint, so a value above about two billion needs along. TheLmarks alongliteral.Long.toUnsignedString(-1L)This method reads the 64 bits of a
longas an unsigned number. Java handles values the size of aulongin this way.
These types are the same in both languages:
| C# | Java | Size | Note |
|---|---|---|---|
| short | short | 16-bit | |
| int | int | 32-bit | |
| long | long | 64-bit | |
| float | float | 32-bit | |
| double | double | 64-bit | |
| char | char | 16-bit | a UTF-16 code unit in both, so an emoji takes two chars |
Wrapper types#
Each primitive type has an object version. The object version is called a wrapper
type. Java uses a wrapper type wherever an object is needed, such as in a
List<Integer>.
Two wrappers have special names. The wrapper for int is Integer.
The wrapper for char is Character. Every other wrapper has the name
of its primitive type with a capital first letter, for example Long and
Boolean. These are the common wrappers:
| Primitive | Wrapper | C# analogy |
|---|---|---|
| int | Integer | int? (roughly) |
| long | Long | long? |
| double | Double | double? |
| boolean | Boolean | bool? |
| char | Character | char? |
Java converts between a primitive type and its wrapper type automatically. This conversion is called autoboxing. It is convenient, and it is also the source of two bugs. An earlier chapter already showed both bugs. See 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;This line is autoboxing. The compiler writes
Integer.valueOf(42)for you.int plain = boxed;This line is unboxing. The compiler writes
boxed.intValue()for you.int oops = missing;The variable
missingisnull, so there is no number to unbox. This line throwsNullPointerException.
A byte is signedtwist#
Each language reads the same eight bits as a number:
byte b = 200; // unsigned: 0 to 255
Console.WriteLine(b); // prints 200byte b = (byte) 200; // signed: -128 to 127
System.out.println(b); // prints -56
System.out.println(b & 0xFF); // prints 200byte b = (byte) 200;The number 200 does not fit in a signed Java
byte, so the cast wraps the value to −56.b & 0xFFMasking with
0xFFreads the same eight bits as an unsigned number, so the result is 200 again.
Java's byte is signed, from −128 to 127. 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.
Money is BigDecimaltrap#
This example adds the price of three mugs at 19.99 each to a shipping amount.
Then it checks the total against a limit amount:
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")The value is built from text, so it stays exact.
price.multiply(BigDecimal.valueOf(3))Java has no
*for objects. The methodmultiplyaccepts only anotherBigDecimal. SoBigDecimal.valueOf(3)makes aBigDecimalfrom the number 3..add(shipping)This call returns a new value, because a
BigDecimalnever changes.total.compareTo(limit) > 0compareToreturns a negative number, zero or a positive number. So> 0means that the total is larger than the limit.NumberFormat.getCurrencyInstance().format(total)This line formats money for the current locale, like
ToString("C")in C#.NumberFormatis injava.text.
Each operator on a C# decimal has a method on a BigDecimal:
| 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 |
System.out.println(new BigDecimal(0.1));
System.out.println(new BigDecimal("0.1"));Show the answer
new BigDecimal(0.1) is wrong. The double 0.1 is
already imprecise, and the BigDecimal keeps that imprecise value. For the same
error in a sum, see adding 0.1 and 0.2.
Always use the String constructor, or use
BigDecimal.valueOf(double). The method valueOf converts the number
with Double.toString first.
divide without a scale throws
ArithmeticException when the result never ends, such as 1/3. Always give a scale
and a RoundingMode.
equals compares scale.
new BigDecimal("1.0").equals(new BigDecimal("1.00")) is false. Use
compareTo.
Some teams store money as a long that counts the smallest unit, such as cents.
They format the amount only for display. This avoids all three traps, and many payment systems
do the same.
java.timetwist#
java.time Java 8 gives each kind of time its own type. In .NET,
DateTime mixes the kinds of time together. If you know Noda Time, you already know
this API. Noda Time was based on Joda-Time, and java.time is the successor of
Joda-Time.
C# refresherNodaTime: separate types for instants and datesC# 1
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 |
These are the common operations:
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();This is an exact point on the timeline, in UTC.
now.plus(30, ChronoUnit.DAYS)An
Instanthas no calendar and no time zone, so it moves by an exact amount of time.LocalDate.now()This is today's date, with no time and no time zone.
Duration.between(now, due)This is the time between two instants. In C#, you subtract two
DateTimeOffsetvalues and get aTimeSpan.
All java.time types are immutable and thread-safe, including
DateTimeFormatter. Every method that changes a value returns a new value, like the
methods of DateTime in .NET. Examples are plusDays,
withYear and truncatedTo.
A Period or a Durationtrap#
C# has TimeSpan for an amount of time. java.time has two types for
an amount of time, Duration and Period. You can add a
Duration or a Period to a date and time:
// In London, the clocks go forward by one hour on 29 March 2026.
ZoneId london = ZoneId.of("Europe/London");
ZonedDateTime noon = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, london);
System.out.println(noon.plus(Period.ofDays(1)));
System.out.println(noon.plus(Duration.ofHours(24)));Show the answer
Duration and Period are not interchangeable.
Period is calendar time, in years, months and days. So the result is noon on the
next day, even though that day is only 23 hours long. Duration is exact time, in
seconds and nanoseconds. So 24 hours later is 1 pm.
Add a Period when you mean calendar days. Add a Duration when you
mean exact time.
The old date API#
LegacyDate, Calendar and SimpleDateFormat
You will meet all three classes. Each class has a serious problem:
| Class | Problem |
|---|---|
| java.util.Date | mutable, and 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, so a shared instance corrupts output silently |
| TimeZone | superseded by ZoneId |
A SimpleDateFormat that is shared across request threads is a common production
bug. See Date and SimpleDateFormat are traps.
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. The variable 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)This is the other direction, for an old API that still needs a
Date..atZone(ZoneId.systemDefault()).toLocalDate()A date has a meaning only in a time zone. So you choose a time zone before you ask for the
LocalDate.
Formatting numbers#
The next example formats and parses numbers. Here 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");
double d = double.Parse("1.5", CultureInfo.InvariantCulture);
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");
double d = Double.parseDouble("1.5"); // the same in every locale
int v;
try { v = Integer.parseInt(text); } catch (NumberFormatException e) { v = 0; }String.format(Locale.ROOT, "%,.2f", 1234.5)Locale.ROOTfixes the format. The format has a comma between thousands and a dot before the decimals, and it does not depend on the settings of the server.NumberFormat.getPercentInstance(Locale.UK).format(0.25)This line formats 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 depend on the locale. They use the default locale unless you
pass a locale. Some locales use a comma as the decimal separator. A server that is set to such
a locale formats 1.5 as 1,5. Pass Locale.ROOT explicitly
for any text that another program will read.
These are the other format strings and parsers:
| C# | Java |
|---|---|
| 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) |
| double.Parse(s, CultureInfo.InvariantCulture) | Double.parseDouble(s), always invariant |
1TranslateWrite this in Java:
decimal unitPrice = 19.99m;
int quantity = 5;
decimal total = unitPrice * quantity;
decimal instalment = Math.Round(total / 3, 2, MidpointRounding.AwayFromZero);
decimal unitPrice = 19.99m;
int quantity = 5;
decimal total = unitPrice * quantity;
decimal instalment = Math.Round(total / 3, 2, MidpointRounding.AwayFromZero);BigDecimal unitPrice = new BigDecimal("19.99");
int quantity = 5;
BigDecimal total = unitPrice.multiply(BigDecimal.valueOf(quantity));
BigDecimal instalment = total.divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_UP);divide needs the scale and the rounding mode. Without them, 99.95 divided by 3
throws ArithmeticException, because the result never ends.
2An expert asksWhich type does Java use for money, and why do you compare two amounts with
compareTo and not with equals?
compareTo and not with equals?BigDecimal for money. The equals method of
BigDecimal compares the scale as well as the value. So 1.0 and
1.00 are not equal. a.compareTo(b) == 0 compares the value only.Exceptions and resources#
Throwing and catching work as they do in C#, with one addition. An exception type that the
compiler makes you declare or catch is called a checked exception. To release a
resource, Java uses try-with-resources. The try-with-resources statement does the same as
using in C#.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | no equivalent | throws IOException | a checked exception must be declared or caught, or the code does not compile Why |
| new | no equivalent | throw new UncheckedIOException(e) | application code usually wraps a checked exception, and passes it as the cause Why |
| new | no equivalent | extends Exception, or extends RuntimeException | a type under RuntimeException or Error is unchecked, and every other exception type is checked Why |
| trap | a lambda that throws IOException | a lambda that catches it and rethrows | the lambda types declare no checked exceptions, so wrap the exception inside the lambda Why |
| twist | throw new InvalidOperationException("...") | throw new IllegalStateException("...") | most .NET exceptions have a close Java match under another name Why |
| twist | throw new ArgumentException("...") | throw new IllegalArgumentException("...") | it covers every bad argument, and Java has no ArgumentOutOfRangeException Why |
| twist | ArgumentNullException.ThrowIfNull(order) | Objects.requireNonNull(order, "order") | throws NullPointerException, not an argument exception, by Java convention Why |
| twist | catch (Exception ex) when (ex is IOException or SqlException) | catch (IOException | SQLException e) | since Java 7, one catch can name several types, but C# needs a when filter for this Why |
| twist | throw; | throw e; | Java rethrows the same object, so the trace is preserved Why |
| twist | when (filter) | no equivalent | Java has no exception filters, so test inside the catch, and rethrow the rest Why |
| same | finally | finally | identical in both languages, and it runs whether or not the try block threw Why |
| twist | using | try-with-resources | does the same as the block form of using, and it closes what its brackets declare Why |
| twist | using declaration | no equivalent, always a block | Java has only the block form, so the block is the resource's scope Why |
| twist | IDisposable | AutoCloseable | implement it, and try-with-resources calls close for you Why |
| twist | Dispose() | close() | the one method of AutoCloseable, called when the block ends Why |
| twist | IAsyncDisposable | no equivalent | Java has no asynchronous form of close or of try-with-resources Why |
| twist | Dispose throws, and the first exception is lost | e.getSuppressed() | Java throws the body's exception and keeps the close() failure in getSuppressed() Why |
| twist | SafeHandle | Cleaner, never finalize() | A Cleaner is only for callers who forget to close. The finalize method is deprecated for removal. Why |
Checked exceptionsnew#
Reading the day's order file can fail, because the file may be missing. In C#, nothing in the
method's signature shows this. 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 IOExceptionThis clause declares that
readmay throwIOException. Every caller must now catch the exception, or declare it too.// may throw; nothing to declareC# has no such clause. The designers of C# looked at the feature and chose not to copy it.
An exception type is checked unless it extends RuntimeException or
Error. Those types are called unchecked, and they behave like every C#
exception. For the whole tree, see the section The hierarchy.
The other option is to wrap the checked exception in an unchecked exception. Here
p is the path to the order file:
public String read(Path p) {
try {
return Files.readString(p);
} catch (IOException e) {
throw new UncheckedIOException(e); // always keep the cause
}
}throw new UncheckedIOException(e);The checked exception stops here, so callers have nothing to declare. The original exception is stored inside the new exception, as its cause.
Most of the Java community now prefers wrapping for application code.
Spring, Hibernate and the AWS SDK all convert checked
exceptions to unchecked exceptions at their boundaries. Most callers cannot recover from an
IOException. So a declaration in every layer adds nothing useful.
Keep checked exceptions for a library API, and only for expected conditions that a caller can really recover from. Propagate a checked exception through one or two layers at most.
Always pass the cause when wrapping. Write
throw new RuntimeException(e), not
throw new RuntimeException(e.getMessage()). If you leave out the cause, the
original stack trace is lost. The same happens in C# when you leave out the inner exception in
throw new X("...", inner).
The hierarchynew#
Every exception type is in one family tree. The place of a type in the tree decides whether the type 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
└─ ...IOException and SQLException are the checked exceptions that you
will meet most often. This is the rule, in two lines:
| Rule | Meaning |
|---|---|
| Extends RuntimeException or Error | unchecked, so it behaves like every C# exception |
| Extends Exception, not RuntimeException | checked, so it must be declared or caught |
Your own exception type is checked or unchecked according to the class that it extends. The next type is unchecked, so no caller has to declare it:
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(long orderId) {
super("No order with id " + orderId);
}
}extends RuntimeException makes the type unchecked. With
extends Exception, the type would be checked. super(...) passes the
message to RuntimeException, like : base(...) in C#.
Checked exceptions and lambdastrap#
The functional interfaces in
java.util.function declare no checked exceptions. So a
lambda that you pass to map,
forEach or Optional.map cannot throw a checked exception. Here
paths is a list of files:
// does not compile
paths.stream().map(Files::readString).toList();Files::readString can throw IOException. Catch the
IOException inside the lambda, and rethrow it as an unchecked exception. For an
example, see A lambda that reads a file. Teams often write a
small Unchecked.wrap(...) helper, or use a library such as Vavr. Java 25 has no
fix for this in the language itself.
Exception mappingtwist#
Here order is an order, and qty is a quantity. The variable
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");This method is like
ThrowIfNullin C#, but it throwsNullPointerException, not an argument exception. That 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 does not cause an exception in Java. The method
getreturnsnullinstead.
This table lists every common .NET exception and its Java equivalent:
| C# | Java |
|---|---|
| Exception | Exception / RuntimeException |
| ArgumentException | IllegalArgumentException |
| ArgumentNullException | NullPointerException |
| InvalidOperationException | IllegalStateException |
| NotSupportedException | UnsupportedOperationException |
| NotImplementedException | UnsupportedOperationException |
| FormatException | NumberFormatException, DateTimeParseException |
| IndexOutOfRangeException | IndexOutOfBoundsException / ArrayIndexOutOfBoundsException |
| KeyNotFoundException | NoSuchElementException |
| NullReferenceException | NullPointerException |
| IOException | IOException |
| TimeoutException | TimeoutException |
| OperationCanceledException | InterruptedException |
| OverflowException | ArithmeticException, only from *Exact methods |
| AggregateException | CompletionException / ExecutionException |
| StackOverflowException | StackOverflowError |
| OutOfMemoryException | OutOfMemoryError |
try, catch, finally#
Here is a try block that logs a failure, rethrows it, and always cleans up. The
method doIt can throw an IOException or an
SQLException:
try
{
Do();
}
catch (Exception ex) when (ex is IOException or SqlException)
{
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)This is one
catchfor two types, joined with|. C# needs awhenfilter for this.throw e;This line rethrows the same exception object, so the stack trace is kept. In C#, a
throwwith no exception after it does the same.
C# refresherException filters: catch ... whenC# 6
A filter decides whether a catch block applies at all. An exception that the filter rejects is never caught. So the exception keeps propagating, and its stack stays 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 the exception, test it, and rethrow it if you did
not want it. 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. Any other exception is rethrown unchanged.
Never return from a finally block. The
return discards any exception that is leaving try or
catch, and nothing is reported. In C#, a return in
finally is a compile error. Java allows it. For an example, see
A return inside finally.
try-with-resourcestwist#
Here dataSource provides database 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();Everything that is declared in the brackets is closed when the block ends, even if an exception is thrown.
var stmt = conn.prepareStatement(sql)) {This is a second resource, after a semicolon. Resources close in reverse order, so the statement closes first and then the connection.
C# refresherIDisposable and IAsyncDisposableC# 8
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 scopeAutoCloseable does the same as IDisposable in C#. Your own class
works in try-with-resources when it implements AutoCloseable:
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, likeDisposein C#.try (var c = new Conn()) {close()runs at the end of the block, even ifusethrows.
AutoCloseable.close() declares throws Exception.
Closeable narrows that to IOException. When your cleanup cannot fail,
override close() without a throws clause. Then no caller needs a
catch block.
class BrokenConn implements AutoCloseable {
@Override public void close() { throw new IllegalStateException("close failed"); }
}
try (var c = new BrokenConn()) {
throw new IllegalArgumentException("body failed");
} catch (RuntimeException e) {
System.out.println(e.getMessage());
System.out.println(e.getSuppressed()[0].getMessage());
}Show the answer
When the body throws and close() throws, Java keeps the exception
from the body as the main exception. Java attaches the failure from close() to the
main exception. The attached exception is called a suppressed exception, and
getSuppressed() returns it. In the same situation, C# throws the exception from
Dispose and loses the first exception.
You do not need to write anything differently. Catch the exception as usual. When a stack
trace shows a "Suppressed:" section, that section is the failure from
close().
Cleanup without a using block#
Legacyfinalize()
Object.finalize() has been deprecated for removal
deprecated since Java 18. It is unpredictable, can make an unreachable object
reachable again, delays garbage
collection, and may never run at all. Do not override finalize(), and do not
follow older guides that recommend it for releasing native resources.
The supported mechanism for cleaning up an object that nobody closed is Cleaner
Java 9. It is roughly like a SafeHandle plus a finalizer queue in
.NET. As in .NET, the normal path should still be to close the object explicitly. Here
allocate and free represent 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)This call asks the
Cleanerto runstatewhen thisBufferis no longer reachable, if nobody has closed the buffer by then.private record State(long handle) implements Runnable {This is 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(); }This is the normal path.
closeruns the cleanup now, and the cleanup will not run again later.
The rule is the same as in .NET. The Cleaner is only for callers who forget to
close, and it is not the main mechanism. Always give the type a close()
method, and expect callers to use try-with-resources.
1TranslateWrite this in Java:
public string ReadOrderFile(string path)
{
ArgumentNullException.ThrowIfNull(path);
try
{
return File.ReadAllText(path);
}
catch (IOException ex)
{
throw new InvalidOperationException("cannot read " + path, ex);
}
}
public string ReadOrderFile(string path)
{
ArgumentNullException.ThrowIfNull(path);
try
{
return File.ReadAllText(path);
}
catch (IOException ex)
{
throw new InvalidOperationException("cannot read " + path, ex);
}
}public String readOrderFile(String path) {
Objects.requireNonNull(path, "path");
try {
return Files.readString(Path.of(path));
} catch (IOException e) {
throw new IllegalStateException("cannot read " + path, e);
}
}In Java, the method cannot ignore IOException, because it is checked. The
method must catch it or declare throws IOException. Pass e to the new
exception as the cause.
2An expert asksJava application code usually turns a checked exception into an unchecked one.
When should a method still declare a checked exception, and why only then?
How Java runs your code#
You know the language. This chapter shows how Java compiles, finds and starts your code. .NET finds your code for you. You must tell Java where to look. The Java Virtual Machine (JVM) searches an ordered list of folders and Java archive (JAR) files. This list is called the classpath. The JVM uses the first match.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | dotnet build writes MyApp.dll | javac writes .class files, and jar zips them into a JAR | Java shows the steps that dotnet build hides. The steps are compile, package and run. Why |
| twist | the .NET SDK | a JDK | a JRE is a runtime with no compiler, so install a JDK for development Why |
| twist | an app built for a newer runtime | UnsupportedClassVersionError | A runtime refuses bytecode newer than itself. The class file version is the release plus 44, so 69 is Java 25. Why |
| twist | namespace Acme, in any folder | package com.acme, in the folder com/acme | class files must be in folders that match the package name Why |
| trap | dotnet MyApp.dll, a file path | java -cp out com.acme.App | java takes the package plus the class name, never a file path Why |
| twist | the runtime finds each assembly | -cp "target/classes:lib/*" | the classpath is searched in order, and the JVM uses the first entry that has the class Why |
| trap | one copy of each dependency | two JARs with the same class | the JVM silently uses the first JAR, and a missing method fails later with NoSuchMethodError Why |
| twist | a dependency missing at run time | ClassNotFoundException, NoClassDefFoundError | no classpath entry has the class, so check the dependency and its Maven scope Why |
| twist | dotnet publish | jar --create --main-class com.acme.App | the JAR's manifest names the class to start, so java -jar app.jar can run it Why |
| trap | no equivalent | java -cp lib -jar app.jar | java -jar ignores -cp and CLASSPATH, and reads only the JAR and its manifest Why |
| twist | the entry point in the assembly | Main-Class in META-INF/MANIFEST.MF | Class-Path in the same file adds JARs, and each entry is resolved from the JAR's own folder Why |
| twist | no single equivalent | three class loaders | each loader asks the loader above it first, so the JDK's own classes always come first Why |
| trap | a file read from disk | getResourceAsStream("/rates.json") | a path under src/main/resources works in the IDE and fails inside a JAR, so read the file from the classpath Why |
| twist | Environment.GetEnvironmentVariable | System.getenv, or -Drates.url=... | -D sets a system property. Put -D before -jar, or your program receives it as text. Why |
| twist | the NuGet cache | ~/.m2/repository | Maven downloads each JAR once, and your project holds only a description of the JARs Why |
| twist | settings in runtimeconfig.json | -D, -X and -XX: | These are flags on the java command. Use -D for configuration, and tune the JVM itself with -X and -XX:. Why |
From source to a running programtwist#
Java takes the same route as .NET, but more of the steps are visible. The Java compiler writes bytecode. Bytecode does the same as IL in .NET. The compiler writes the bytecode of each class into its own file. This file is called a class file.
These are the three commands in the diagram:
javac -d out src/com/acme/*.java
jar --create --file app.jar --main-class com.acme.App -C out .
java -jar app.jarjavac compiles, jar zips, and java starts the
JVM.
.NET refresherWhat dotnet build writes: the .dll, deps.json and runtimeconfig.json
The C# compiler is Roslyn, and it runs as csc. It writes IL straight into an
assembly. At startup, the runtime reads deps.json to find every dependency. You
never build 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 | A .java file holds source code. A public class must be in a file named after the class. |
| Roslyn (csc) | javac | the compiler, which writes bytecode and not machine code |
| IL | bytecode | the portable instruction set, which the runtime compiles to machine code while the program runs |
| IL inside the .dll | class file (.class) | A class file holds the bytecode of one class. The javac compiler writes one class file for each class, including nested classes. |
| assembly (.dll) | JAR (.jar) | a zip of class files plus a manifest, and it is 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, which loads classes, checks them, and compiles hot code to machine code |
| .NET SDK | JDK | the compiler, the tools and a runtime together, so install a JDK to write Java |
| .NET runtime | JRE | a runtime with no compiler, for running programs only, so install a JDK to develop |
| namespace plus type name | fully qualified name | the package plus the class name, as in com.acme.App, which also gives the path of the class file |
| 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 |
JDK or JRE: which to install#
A Java Runtime Environment (JRE) and a Java Development Kit (JDK) both hold the runtime. Only a JDK holds the compiler:
java -version # works with a JRE or a JDK: both have the runtime
javac -version # works only with a JDK: a JRE has no compilerjavac is the compiler, so only a JDK includes it.
For development, install a Java Development Kit (JDK). A JDK does everything that a JRE does, and it also has the compiler. Oracle stopped offering a separate JRE with Java 11. Oracle recommends jlink to build a small runtime instead. Some other vendors still ship a JRE for deployment.
Bytecode newer than the runtimetwist#
Every class file stores the Java release that it was compiled for. A runtime refuses bytecode that is newer than the runtime itself, with this error:
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 finishnew#
Maven and Gradle
are the build tools for Java. They hide every step below, in the same way that
dotnet build hides the compiler. Run the steps yourself once. After that, you can
read every classpath error. Take two classes in the
package com.acme. One rule matters here.
The class files must be in folders that match the package name. For this
rule, see the section Packages are not namespaces.
// 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;This line puts
Appin the packagecom.acme, so the full name of the class iscom.acme.App.public static void main(String[] args)This is the entry point. The
javacommand starts the program in themainmethod of the class that you name.java -cp out com.acme.AppThis command runs the program. It gives the classpath after
-cp, and then the full name of the class that hasmain.
App uses a second class, Greeter, which is in its own file:
// 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 Greeter without an import. Compile both classes. The
-d option says where the class files go:
javac -d out src/com/acme/*.javajavac writes one class file for each class. It also turns the package name
into folders, so com.acme becomes com/acme.
out/
└── com/
└── acme/
├── App.class
└── Greeter.classThe package plus the class name is called the fully qualified name of
the class. The fully qualified name is also the path of the class file. So
com.acme.App is the file App.class in the folder
com/acme. Run the program:
java -cp out com.acme.App
# hello javaThe only classpath entry is out. The java command looked under
that folder for com/acme/App.class.
The name you pass to javatrap#
ls out/com/acme
# App.class Greeter.class
java out/com/acme/App.classShow the answer
Error: Could not find or load main class out.com.acme.App.class, then
Caused by: java.lang.ClassNotFoundException: out.com.acme.App.classThe name you pass to java is the package plus the class, not a file
path. Here java read the path as a class name, and no class has that
name. java -cp out App fails too. The option -cp out/com/acme also
fails, because the top folder of a package must be directly under a classpath entry.
Write java -cp out com.acme.App.
Could not find or load main class: every cause#
Error: Could not find or load main class com.acme.App#com/acme/App.class. The line after the message
says why. ClassNotFoundException means that the file is on no classpath entry.
NoClassDefFoundError ... (wrong name: com/acme/App) means that the classpath
points inside the package folder.-cp at the folder or JAR that holds
the top folder of the package. Here that is 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 packageThe classpath is a search listnew#
After an upgrade, your service has two copies of the billing library on its classpath. One copy is version 2.1. The other copy is an old version 1.9 that nobody removed. Which copy does Java use?
The first time your code uses a class, the JVM turns the fully qualified name of the class
into a path. The fully qualified name is the package plus the class name. So
com.acme.billing.Invoice becomes com/acme/billing/Invoice.class. The
JVM then checks each classpath entry in the order listed. A classpath entry
is a folder or a JAR.
The JVM uses the first entry that has the file, 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 is on a later shelf. In one way, the comparison does not fit. A JAR is a zip file, not a folder, and the JVM looks inside the zip file.
On the command line, the list is one string:
java -cp "target/classes:lib/*" com.acme.Apptarget/classes:lib/* is a classpath with two entries. The first entry is the
folder of compiled classes. The second entry is every JAR in the lib folder.
Entries are separated by : on macOS and Linux, and by ; on
Windows. An entry that ends in /* means every JAR in that folder. Put that entry
in quotes, so that the shell does not change it. With no -cp and no
CLASSPATH, the classpath is the current directory.
Two JARs, one classtrap#
Two JARs holding the same class do not conflict. The JVM uses one of them, and
reports nothing. In
the diagram, billing-core-1.9.jar is never read. Suppose that 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 problem is called “JAR hell”. It is the reason
that dependency resolution in Maven matters so much. For more about dependency resolution, see
the chapter Maven vs csproj.
java.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.AppA class that is on no classpath entrytwist#
Two errors say that no classpath entry has a class:
java.lang.ClassNotFoundException: org.postgresql.Driver#compile, or the scope
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 are used to compile, but they are left out at run time.What triggers it
rm out/com/acme/Greeter.class
java -cp out com.acme.AppRunning with -cp or -jartwist#
To ship the program, zip the classes into a JAR. Also write the name of the main class
into a small text file inside the JAR. This file is called the manifest. The
jar command does both:
jar --create --file app.jar --main-class com.acme.App -C out .
java -jar app.jar
# hello javajar --create --file app.jarThis part creates a JAR called
app.jar. Thejarcommand is the archiving tool of the JDK.--main-class com.acme.AppThis option writes
Main-Class: com.acme.Appinto the manifest of the JAR, so thatjava -jarknows where to start.-C out .This part adds everything in the folder
out, and it keeps the package folders.
A classpath with -jartrap#
# app.jar holds App.class, and its manifest names com.acme.App as the main class.
# Greeter.class is in the folder lib/com/acme, and not in the JAR.
java -cp lib -jar app.jarShow the answer
Exception in thread "main" java.lang.NoClassDefFoundError: com/acme/Greeterjava -jar ignores -cp. It ignores the
CLASSPATH variable too. The JDK documentation says so. With -jar,
classes come only from the JAR and from the JARs that its manifest names. So nothing looks in
lib. An option written after the JAR name is not read by java at
all. It is passed to your program as an argument.
There are three fixes. List the dependency in the manifest of the JAR. Or bundle the
dependency inside the JAR, as Spring Boot does. Or remove -jar and write
java -cp "app.jar:lib" com.acme.App.
Each way to start a program reads different things:
| 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 |
A Spring Boot JAR is built for -jar. Its manifest names
Spring Boot's own launcher as the Main-Class. The manifest names your class as
Start-Class. The launcher then loads your dependencies from
BOOT-INF/lib, which is inside the JAR. This is why a Boot JAR runs with no
classpath at all.
Inside a JAR: the manifest#
A JAR is a zip file. Rename a JAR to .zip and you can open it. Next to the
class files, a JAR holds the file META-INF/MANIFEST.MF. This file is the manifest.
It is 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 | the entries are separated by spaces, and each entry is resolved from the JAR's own folder, not from the directory you run from |
no main manifest attribute, in app.jar#Main-Class line, so java -jar
does not know which class to start.jar --main-class, with the Spring Boot plugin, or in the configuration of the Maven
JAR plugin. Or run the JAR 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 relative to the directory you run from. If you move the JAR without its lib
folder, the JVM can no longer find any class that those entries named.
Class loading, at the level you need#
Classes are not all loaded at startup. Each class is loaded the first time your code uses it. The part of the JVM that finds a class and reads its bytecode is called a class loader. There are three class loaders. They are stacked so that the JDK's own classes always come first.
That is why putting your own java.lang.String on the classpath has no
effect. The bootstrap loader finds the real java.lang.String first. Class loading
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 the class. NoClassDefFoundError is thrown when your compiled code
refers to a class and that class cannot be loaded at run time. A third error means that the
class was found but could not be set up:
java.lang.ExceptionInInitializerError#ExceptionInInitializerError does the same as
TypeInitializationException in .NET. The Caused by line shows the
real exception.NoClassDefFoundError:
Could not initialize class. So search the log for the first failure, not the last
failure.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 three loaders. Often there is one added loader for each application or plugin. The rarer failures happen in the added loaders. For further reading about class loaders, see the chapter Going deeper. You do not need that depth to run a Spring Boot service.
Resources live on the classpath tootrap#
Everything under src/main/resources is copied into the JAR, beside your
classes. Those files are read through the classpath, not from disk. That is why
application.yml is found wherever the JAR runs. Here the same file is read in two
ways:
// works in the IDE, where resources are files on disk
String fromDisk = 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"))This call reads a file from disk, by its path in your project folder.
getClass().getResourceAsStream("/rates.json")This call asks the classpath for the resource instead. That works inside a JAR too. The leading slash means “from the root of the classpath”.
new String(in.readAllBytes(), UTF_8)This line reads every byte and turns the bytes into text.
UTF_8isStandardCharsets.UTF_8, imported with a static import.
A path under src/main/resources works in the IDE and fails inside a
JAR. There is no such folder next to a packaged JAR. On a server or in a container,
the first line throws NoSuchFileException. Nothing warns you while you
develop.
For more about reading files and resources, see the chapter Files and I/O.
The two errors a misread resource gives#
java.nio.file.NoSuchFileException: src/main/resources/rates.json#getClass().getResourceAsStream("/rates.json").Cannot invoke "java.io.InputStream.readAllBytes()" because "in" is null#getResourceAsStream found nothing with that name. It returned null and
did not throw. The null then causes this exception on the next line."/rates.json", is
looked up from the root of the classpath. A name without a leading slash 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);
}System properties and environment variables#
A .NET process takes settings mostly from one place. A JVM process takes settings from two separate places.
// 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. You pass a system property to the JVM
with -D when the JVM starts. Spring Boot reads environment variables, system
properties and command-line arguments:
| 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 overrides the environment variable that your container
platform sets, and nothing reports it. For the full order, see the chapter
Dependency injection and configuration.
-D must come before -jar. Everything after the
JAR name is passed 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.
LegacyThe CLASSPATH environment variable
Old instructions often tell you to set a CLASSPATH environment variable. The
variable works, and it applies to every Java program started from that shell. That is almost
never what you want. The variable mixes JAR versions across unrelated programs, and this is
exactly how the NoSuchMethodError above happens. java -jar ignores
the variable. Pass -cp explicitly, or let Maven build the classpath. If a machine
behaves strangely, check that nothing has set CLASSPATH.
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. It reads its feeds from
nuget.config. It 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 | says where packages are downloaded from, so it holds repositories, mirrors and credentials |
| obj/project.assets.json | the resolved dependency tree | computed on every build and not saved, but mvn dependency:tree prints it |
When Maven builds, it downloads each dependency into the local repository. The dots of the
group id become folders, for example
~/.m2/repository/org/slf4j/slf4j-api/2.0.16/. Maven then puts those JARs on the
compile and test classpath. Your project never contains its dependencies. It contains only a
description of the dependencies.
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 that Maven will put on the
classpath, in order. This is the list that this chapter is 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. The other message is Invalid or corrupt jarfile,
which comes from java -jar.~/.m2/repository, and build again.
mvn clean does not help, because it only empties target/. Deleting
the folder 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 parts of a java command divide into three groups. -D is
configuration. -X and -XX: tune the JVM itself. Everything after the
JAR name belongs to your program. For more about tuning, see the chapter
The JVM at runtime: memory, GC and containers.
Unrecognized VM option 'MaxRAMPercentag=70'#-XX: option, usually because of a typo. The JVM
refuses to start, and does not ignore the option. It may suggest the closest option that it
knows.java -XX:+PrintFlagsFinal -version lists every
option the JVM accepts.What triggers it
java -XX:MaxRAMPercentag=70 -jar app.jar1What does UnsupportedClassVersionError: class file version 69.0 mean?
UnsupportedClassVersionError: class file version 69.0 mean?maven.compiler.release against the Java that runs the service.2An expert asksTwo JARs on the classpath hold different versions of the same class. Why is
there no error at startup, and how does the problem show itself later?
NoSuchMethodError at run time, far from its cause.Quick reference#
This table shows the commands that this chapter used:
| 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 | give 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 | this sets a system property, and 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 |
Maven vs csproj#
Maven is a Java build tool, and pom.xml is its project file. MSBuild is a
build engine that you script. Maven is a fixed sequence of build steps that you configure. You
say what your project is. You do not say how to build it.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | any folder layout | src/main/java and src/test/java | Maven expects every project to use the same folders Why |
| twist | InternalsVisibleTo | a test in the same package | a test under src/test/java, in the package of the code it tests, sees package-private members Why |
| twist | PackageId | groupId + artifactId | an org prefix plus a name, so vendors never collide Why |
| twist | Version | version | SNAPSHOT means "in development" Why |
| twist | TargetFramework | maven.compiler.release | names the Java release to compile for, as TargetFramework names the .NET release Why |
| twist | PackageReference | dependency | a dependency element, identified by group, artifact and version Why |
| twist | ProjectReference | dependency on a sibling module | written exactly like any other dependency, by coordinates Why |
| new | no equivalent | ./mvnw package | running a phase runs every phase before it, so this command compiles, tests and then packages Why |
| twist | dotnet add package Serilog | no command, so edit pom.xml | there is no mvn add command, so you add a dependency element to pom.xml yourself Why |
| twist | dotnet restore | no separate step | Maven downloads what it needs during the build, into ~/.m2/repository Why |
| twist | nuget.config | settings.xml | this file is in ~/.m2, and it lists repositories, mirrors and credentials Why |
| twist | global.json | mvnw, the Maven wrapper | commit it, because it fixes the Maven version and downloads that version on first use Why |
| twist | Directory.Build.props | a parent POM | children inherit from the parent POM, but the parent POM is not textually included Why |
| twist | a Boot 3 tutorial on Boot 4 | spring-boot-starter-webmvc | the web starter was named spring-boot-starter-web until Boot 4.0 Why |
| twist | Directory.Packages.props | dependencyManagement | sets versions centrally, so child modules can leave them out Why |
| twist | no single equivalent | a Maven profile, switched on with -P | a named block of configuration that varies the build, never the product Why |
| trap | NuGet takes the higher version | Maven takes the nearest declaration | adding a library can silently downgrade another one's dependency Why |
The standard layouttwist#
Maven expects every project to use the same folders. If you use other folders, you must write extra configuration everywhere.
my-service/
├── pom.xml
├── mvnw ← wrapper script; commit it
├── mvnw.cmd
└── src/
├── main/
│ ├── java/ ← production source
│ │ └── com/acme/billing/BillingApp.java
│ └── resources/ ← config, templates; goes into the JAR
│ └── application.yml
└── test/
├── java/ ← test source, SAME package names
└── resources/src/main/java holds the production code. src/test/java holds the
tests. src/main/resources holds files such as application.yml. Maven
puts these files into the Java archive (JAR), together
with the compiled classes. mvnw is the Maven Wrapper.
Tests are under src/test/java, but they are in the same
package as the code they test. That is how the
tests get package-private access. C# uses
InternalsVisibleTo for this. A test in a different package cannot see
package-private members.
pom.xml against csprojtwist#
Maven's project file is called the Project Object Model (POM). Its file name is
pom.xml. Both files below give the identity of a project, the platform version
that it targets, and one logging library that it depends on:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<PackageId>Acme.Billing</PackageId>
<Version>1.0.0</Version>
<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>This element says who publishes the project. It is written as a reversed domain name, like a package name.
<artifactId>billing</artifactId>This is the project's own name. Together,
groupIdandartifactIddo the same asPackageIdin a csproj file.<version>1.0.0-SNAPSHOT</version>SNAPSHOTmarks a version that is still in development. Maven may fetch a newer build of a snapshot version. Maven never fetches a newer build of a released version.<maven.compiler.release>25</maven.compiler.release>This property names the Java release to compile for, like
TargetFrameworkin a csproj file.<artifactId>slf4j-api</artifactId>This is a dependency. It is named by its group, artifact and version, like a
PackageReferencein a csproj file.
Coordinates#
A NuGet package has one name, for example Serilog. A Maven artifact has two
names and a version. Together, the two names and the version are called the
coordinates of the artifact:
<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>This is the publisher. Here the publisher is the Spring Boot project.
<scope>compile</scope>The scope says when the dependency is available. The default scope is
compile, which means everywhere, like an ordinaryPackageReference. Theimportscope is only for importing a bill of materials (BOM). For an example, see Profiles and BOMs.
| Scope | Available at | .NET analogy |
|---|---|---|
| compile | everywhere, and it is the 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 lifecyclenew#
Running a phase runs every phase before it. MSBuild has no equivalent for this idea. The idea explains the Maven command line.
Each command below does everything that the command above it does, and then 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 packageThis command runs validate, compile and test. Then it builds the JAR in the
targetfolder../mvnw installThis command does all of that. Then it copies the JAR into the local repository in
~/.m2. Other projects on the same machine can use the JAR from there. For more about that folder, see the section Where your dependencies actually live.
mvn clean package is the command you will type a thousand times. It does a full
rebuild. You run clean with Maven far more often than with
dotnet build, because Maven does less incremental work.
Here are three .NET commands that you will use 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:treeMaven has no command that adds a dependency. You add a dependency element to
pom.xml yourself, and Maven downloads the dependency on the next build. For the
other everyday commands, see the quick reference.
Maven also has no separate step like dotnet restore. A build downloads what it
needs into the local repository. The build also puts those JARs on the
classpath for you. The file ~/.m2/settings.xml says
where Maven downloads from. This file does the same as nuget.config in .NET. It
lists repositories, mirrors and credentials.
The wrapper#
Commit mvnw, mvnw.cmd and the .mvn/ folder to your
repository. Together these files fix which Maven version the project uses. They download that
version on first use. So the build server and every developer build with the same Maven
version. The nearest .NET equivalent is a global.json file 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-omakes Maven work offline. Maven then uses only what is already in~/.m2../mvnw -pl billing-api -am package-plselects one module of a multi-module build.-amalso builds the modules that this module depends on.
A Spring Boot pomtwist#
Spring Boot supplies a parent POM that sets compatible versions for several hundred libraries. This parent POM is the biggest single convenience in the Java build world:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies><parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.0</version>
</parent>
<dependencies>
<!-- RENAMED in Boot 4: web -> webmvc -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>In both versions, the parent element makes the POM of Spring Boot the parent of
your POM. So the two starters need no
version element. A dependency that brings a whole feature with it is called a
starter. For example, one starter brings everything that a Spring web application
needs.
spring-boot-starter-web became spring-boot-starter-webmvc in Boot
4.0. The change was part of a wider renaming to the pattern
spring-boot-<technology>. This starter 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.
In Maven, a named block of configuration is called a profile. You switch a profile on with a flag, a property or the environment. With profiles, 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>This is the profile's name. You use the name with
-Pon the command line.<property><name>env.CI</name></property>This element switches the profile on automatically when the
CIenvironment variable is set. Most build servers set this variable.<artifactId>maven-failsafe-plugin</artifactId>This is the plugin that runs integration tests. It runs them as part of the
verifyphase.
You can also switch a profile on yourself, and check which profiles 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-profilesThis command lists the profiles that are really on. Check this list first when a profile seems to be ignored.
Do not use profiles to build a different artifact for each environment. The purpose of a deployable JAR is that the same bytes go to every environment. Only the configuration differs. You change the configuration with Spring profiles and environment variables. Maven profiles are for varying the build, not the product.
A pom that publishes only a set of compatible versions is called a BOM, or
bill of materials. When you import a BOM, it sets the versions of a whole family of libraries
at once. spring-boot-starter-parent does this for you.
Directory.Packages.props does the same in .NET. This pom imports a BOM:
<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>This scope copies the managed versions of that POM into your POM. So your own dependencies can leave out their versions.
scope=import with type=pom is the form to remember. Use this form
when you cannot inherit from spring-boot-starter-parent. That is common in a
multi-module build that already has its own parent. The import gives you the managed versions,
but not the plugin configuration of spring-boot-starter-parent.
Two versions of one librarytrap#
Two of your dependencies each bring in jackson-databind, which is a JSON
library. Each dependency brings in a different version. Maven puts only one of the two versions
on the classpath:
billing
├── reports-client
│ └── jackson-databind 2.15.0
└── pdf-export
└── templating
└── jackson-databind 2.17.0Show the answer
When two libraries need different versions of a third library, Maven picks the declaration nearest to your project in the dependency tree. Maven does not pick the highest version. When NuGet gets two requested versions, it takes the higher version. So the Maven rule surprises people.
You can force a version in two ways. Declare the library directly in your own
<dependencies>, because a direct declaration is always nearest. Or set the
version in <dependencyManagement>.
“Nearest wins” means that adding an unrelated dependency can
downgrade a dependency of one of its neighbours, and nothing warns you. If something
breaks after you add a library, run dependency:tree before anything else. If code
calls a method that the older version does not have, the code fails with
NoSuchMethodError. For more about this error, see the section
Two JARs, one class.
Dependency conflicts#
The dependency:tree goal shows which dependency brings in which library. Here
the goal runs for the whole project, and then for one library:
./mvnw dependency:tree
./mvnw dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind./mvnw dependency:treeThis command prints every dependency as a tree. The tree also shows the dependencies that each dependency brings in.
-Dincludes=com.fasterxml.jackson.core:jackson-databindThis option shows only the paths that lead to
jackson-databind. Use it to find which dependency brought in an unexpected version.
1What does ./mvnw package do before it builds the JAR?
./mvnw package do before it builds the JAR?2An expert asksA Spring Boot pom lists spring-boot-starter-test with no
<version>. Where does the version come from?
spring-boot-starter-test with no
<version>. Where does the version come from?scope=import and
type=pom.Quick reference#
| 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 list package --outdated | mvn versions:display-dependency-updates |
Gradle and multi-module builds#
Gradle is the other build tool for Java. A Gradle build file is a program, written in Kotlin or Groovy. A Maven build file is an XML document. In both tools, a build of several modules together does the same as a solution file in .NET.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | a .csproj file | build.gradle.kts | a Gradle build file is a Kotlin or Groovy program, not an XML document Why |
| new | no equivalent | Maven or Gradle | If you are choosing, pick Maven. Choose Gradle when build times are a real problem. Why |
| twist | PackageReference | implementation("org.slf4j:slf4j-api:2.0.16") | a dependency is one string that holds the group, artifact and version, separated by colons Why |
| twist | PrivateAssets="compile" on a PackageReference | implementation against api | an implementation dependency stays off the compile classpath of projects that use yours Why |
| twist | Solution (.sln) | aggregator pom with <modules>, or settings.gradle.kts | one file at the root lists the modules that are built together Why |
| twist | ProjectReference | a normal dependency on the sibling, or project(":billing-domain") | Maven names a sibling module by its coordinates, and Gradle by its path Why |
| twist | Directory.Packages.props | <dependencyManagement>, or a version catalog (libs.versions.toml) | versions are declared once at the root, and every module inherits them Why |
| twist | Build the solution | mvn package at the root, or ./gradlew build at the root | the root build compiles, tests and packages every module Why |
| trap | Build one project | mvn -pl billing-api -am package, or ./gradlew :billing-api:build | without -am, Maven expects the sibling modules to be installed already Why |
| twist | nuget.org | Maven Central (repo1.maven.org) | Maven Central is the public repository, and a company repository fetches from it for you Why |
| twist | ~/.nuget/packages | ~/.m2/repository | the local repository, where downloaded artifacts are kept on your machine Why |
| twist | nuget.config | ~/.m2/settings.xml | set it up once, to send every download through the company repository Why |
| twist | Azure Artifacts / GitHub Packages | Nexus, Artifactory, GitHub Packages | in a company, every download usually goes through one of these Why |
| trap | packages.lock.json | Maven has none, so set exact versions, but Gradle has gradle.lockfile | Maven has no lockfile by default, and a SNAPSHOT version can change when you have changed nothing Why |
Gradle against Mavennew#
A Gradle build file is a program. So Gradle is more powerful than Maven, and it is easier to write a messy build with Gradle. Gradle is also much faster on large builds, because it redoes only the work that changed and can reuse results from a shared cache. Maven and Gradle make different trade-offs:
| Aspect | Maven | Gradle |
|---|---|---|
| Format | XML, declarative | Kotlin or Groovy DSL, imperative |
| Learning curve | shallow, because conventions do the work | steeper, with more freedom to make mistakes |
| Speed on big builds | slower | much faster, because it is incremental and has a 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. In Maven, a custom build step needs a plugin. In Gradle, a custom build step is a few lines of Kotlin:
// build.gradle.kts: a custom task is ordinary code
tasks.register("printVersion") {
doLast { println("version ${project.version}") }
}tasks.register("printVersion")This line adds a new build step. In Gradle, a build step is called a task. The name of this task is
printVersion. You run the task with./gradlew printVersion.doLast { println("version ${project.version}") }This is the code that the task runs. It prints the project's version.
If you are choosing, pick Maven. Maven is rigid, and on a team that is a strength. The Spring documentation assumes Maven. A Maven build that someone else wrote is always readable. Choose Gradle when build times are a real problem, or when the project is for Android.
The same project, both waystwist#
<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)This line tells Gradle which Java version to build with, like
maven.compiler.releasein Maven.implementation("org.slf4j:slf4j-api:2.0.16")This is a dependency, written as one string. The string holds the group, the artifact and the version, separated by colons.
testImplementation("org.junit.jupiter:junit-jupiter:6.0.3")This dependency is available to the tests only, like the
testscope in Maven.
A Maven scope is called a configuration in Gradle. This table maps each scope to its configuration:
| Maven scope | Gradle configuration | Meaning |
|---|---|---|
| compile | implementation | used internally, but 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, so it is never shipped |
Maven has no equivalent for the choice between implementation and
api. This choice is the best feature of Gradle. An implementation
dependency is not added to the compile
classpath of the projects that use your project. That
speeds up builds and stops accidental coupling. In Maven, every compile dependency
is visible to your users. That is how a project comes to depend on things that it never
declared.
Multi-module: your solution filetwist#
A multi-module build is called a reactor build in Maven. It is called a
multi-project build in Gradle. Gradle lists the modules in
settings.gradle.kts. Maven lists the modules in an aggregator
Project Object Model (POM):
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 deployableOnly 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 module 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 a dependency 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" /> -->In Gradle, a module names a sibling module by its path, for example
project(":billing-domain"). A file called a version catalog holds the versions.
The name of this file is libs.versions.toml.
Command mapping#
The quick reference lists the everyday commands. You will use these three commands 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 buildThis command compiles, tests and builds every module, like
mvn packageat the root../gradlew :billing-api:bootRunThe colon path names one module, which is
billing-api.bootRunstarts the Spring Boot application of that module../gradlew dependencies --configuration runtimeClasspathThis command lists the dependencies that are really delivered with the application.
To build one module with Maven, name the module, and also ask for the modules that it depends on:
./mvnw -pl billing-api -am package-pl billing-api picks one module of the build.
-am means “also make”. It tells Maven to build the dependencies of
the named module too. Without -am, Maven expects that the sibling modules are
already installed in your local repository. If
they are not installed, you get a confusing “could not resolve” error for your own
code.
-pl module -am is the combination to remember.
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>This element sends the requests for every repository through this mirror.
<url>https://nexus.acme.com/repository/maven-public/</url>This is the address of the company repository. The company repository fetches from Maven Central for you, 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. To get a repeatable build, fix exact
versions and never use version ranges. SNAPSHOT versions are allowed to change.
For example, Maven downloads 1.0.0-SNAPSHOT again, and the new download can be
different when you have changed nothing. Never depend on someone else's snapshot from a release
build.
Gradle has dependency locking, but you must first activate it for a build. This command
writes the resolved versions into gradle.lockfile:
./gradlew dependencies --write-locks--write-locks creates or updates the lock file. A later build fails if it would
resolve a different version.
1You are choosing a build tool for a new service. Which one, and when would you choose the
other?
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 |
Modules, JPMS and the missing internal#
The Java Platform Module System (JPMS) Java 9 is the nearest thing Java has to
an assembly boundary. Most applications do not use it. You need to know what
a module-info.java file means when you see one. You do not need to write one.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | an assembly | module com.acme.billing { ... } | a module declares what it exports and what it requires, and the JVM enforces both Why |
| twist | an assembly reference | requires java.sql | the module needs java.sql to compile and to run Why |
| twist | public types in the assembly | exports com.acme.billing.api | only the public types in an exported package are usable outside the module Why |
| twist | internal | a package the module does not export | its public types cannot be used from outside, but this works per package, not per type Why |
| twist | InternalsVisibleTo("X") | exports p to X | a qualified export makes one package visible to one named module Why |
| twist | InternalsVisibleTo, for a test project | tests in the same package | a test in the same package gets package-private access Why |
| new | no equivalent | java -p mods -m com.acme.billing/com.acme.App | the module path enforces module boundaries, but the classpath enforces nothing Why |
| trap | reflection into private fields | opens com.acme to a named module | under JPMS, Spring, Hibernate and Jackson can reflect only into an opened package Why |
| new | no equivalent | --add-opens java.base/java.lang=ALL-UNNAMED | The JDK's internals are closed by default since Java 16. Since Java 17, only a flag like this reopens a package. Why |
| new | no equivalent | no module-info.java at all | a Spring Boot service skips modules, because the fat JAR is its boundary Why |
The problem it solvesnew#
Before Java 9, the classpath was one flat namespace.
Any class could use 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 class hid the other
class, and nothing reported it. This well-known problem is called
“JAR hell”. C# never had this problem, because
assemblies are real boundaries. Inside an assembly, C# has internal.
A module fixes that. A module declares which
packages it exports and which modules it
requires. The Java Virtual Machine (JVM) enforces
both declarations. Here the billing module makes these declarations 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. This is one of the modules that the Java Development Kit (JDK) itself is split into.requires transitive com.acme.domain;The billing module needs the domain module. 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.PaymentProviderThis line declares an implementation of a service interface. Other modules can find the implementation while the program runs, through
ServiceLoader. This is a small built-in form of dependency injection.
Here is the full set of directives. It includes 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 |
This is the closest thing to internaltwist#
// 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.
This table shows each C# visibility and its Java form:
| 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 |
In C#, 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 design with modules forces you to put the API and the implementation in different packages.
That is a good habit, but it can mean a lot of restructuring.
Code that uses a class from a package that is not exported does not 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 class is public, but the billing module does not export the package of the class. So another module cannot import the class.
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 |
You can place a JAR without a module-info.java on the module path. The JAR is
then called an automatic module. Its module name comes from the file name, and it
exports everything and reads everything. An automatic module is the bridge for migrating to
modules. Most of the ecosystem is still at this stage.
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.AppThis line uses the classpath. Every JAR is in one flat namespace, and nothing is enforced.
java -p mods -m com.acme.billing/com.acme.App-pgives the module path.-mnames the module and its main class. On the module path, the module boundaries are enforced.
Why frameworks need opens#
Spring, Hibernate and Jackson all read and set private fields through reflection. Under
JPMS, this reflection is blocked unless the package is open. This problem is the
main reason why ordinary applications do not use JPMS at all. If you put an application that
uses these frameworks into modules, expect this error:
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, so it is no longer available |
The flag that you are most likely to type lets an old library reflect into 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-UNNAMEDThis flag opens the
java.langpackage of thejava.basemodule to all code on the classpath. The JDK calls the code on the classpath the unnamed module.
Strong encapsulation of JDK internals is on by default since Java 16.
Java 17 removed the single option that disabled strong encapsulation for everything. Some
libraries used private parts of the JDK, such as private fields of java.lang
classes. These libraries now fail with InaccessibleObjectException instead of
printing a warning. sun.misc.Unsafe is the exception to this rule. It can still be
used, 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 to upgrade the library.
When to bother#
Modules matter for the JDK itself, for libraries, and for jlink. Modules are optional for a Spring Boot service. A library published to Maven Central is the clearest case for modules:
| Situation | Use JPMS? |
|---|---|
| Spring Boot service in a container | no, because 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 |
When you publish a library, add an Automatic-Module-Name entry to the
manifest of your JAR, even if you do not write a
module-info.java. The manifest is the file of settings inside every JAR. The entry
takes one line, and it 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>This is the module name that your users will write if they move to modules. They can use this name even though the library has no
module-info.java.
1A class is public, yet code in another module cannot import it. Why?
public, yet code in another module cannot import it. Why?internal. An export works per package.
You cannot export a package and hide one class in it.Testing#
The Java testing tools match the .NET testing tools. JUnit does the same as xUnit. Mockito
does the same as Moq. AssertJ does the same as FluentAssertions. The
Java Development Kit (JDK) includes none of these three
tools, but spring-boot-starter-test from Spring Boot adds all three in one
line.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | xUnit / NUnit / MSTest | JUnit Jupiter (JUnit 5 or 6) | the test framework nearly every Java project uses Why |
| twist | [Fact] | @Test | marks a test method, and the class needs no attribute Why |
| twist | public class MoneyTests | class MoneyTest | Test classes and methods are package-private by convention. Only JUnit 4 required public. Why |
| same | Assert.Equal(expected, actual) | assertEquals(expected, actual) | The expected value comes first, as in xUnit. The AssertJ form assertThat(actual) removes the doubt. |
| twist | FluentAssertions | AssertJ | fluent assertions that start with assertThat(actual), then isEqualTo or a similar method Why |
| twist | Constructor / IDisposable | @BeforeEach / @AfterEach | methods that run before and after every test Why |
| twist | IClassFixture | @BeforeAll / @AfterAll | runs once per class, and the method must be static Why |
| twist | [Trait] | @Tag | labels a test so a build can include or exclude it Why |
| twist | [Theory] + [InlineData] | @ParameterizedTest + @CsvSource | The test runs once for each row of inline data. Use @ValueSource for a single argument. Why |
| twist | Moq | Mockito | Mockito is the standard. There is no .Object, because the mock is the object. Why |
| twist | NSubstitute | Mockito | Mockito covers this style too, and Java has no separate favourite for it Why |
| twist | Moq Callback, and MockBehavior.Strict | ArgumentCaptor, and a default answer that throws | capture an argument, or fail on any call you did not stub Why |
| twist | Testcontainers | Testcontainers | This is the same project in another language. It runs a real database in Docker for the length of a test. Why |
| twist | WireMock.Net | WireMock | WireMock is the Java original. WireMock.Net copies its features in a separate .NET codebase. Why |
| twist | BenchmarkDotNet | JMH | A stopwatch loop measures the JIT, not your code. JMH handles warm-up and dead-code elimination. Why |
| twist | ArchUnitNET | ArchUnit | architecture rules, such as layer boundaries, written as tests Why |
| twist | coverlet | JaCoCo | code coverage, collected by an agent while the tests run Why |
| twist | ReportGenerator | jacoco:report | writes an HTML report into target/site/jacoco Why |
| twist | threshold gate in CI | jacoco:check | fails the build itself when coverage drops below the limit Why |
| twist | xunit or xunit.v3, the package you reference | JUnit 5 on Spring Boot 3, JUnit 6 on Spring Boot 4 | Boot chooses the JUnit version. The programming model stays the same, but JUnit 6 needs Java 17 or later. Why |
| twist | Method_Condition_Result | methodUnderTest_condition_expectedResult, or @DisplayName | Java has no single naming convention for tests, but these two styles are the most common Why |
The stacktwist#
Almost every .NET testing tool has a matching Java tool, often with the same name. The first difference that you meet is where the set-up code goes:
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() { }This method runs once, before any test in the class, like an
IClassFixturein xUnit. The method must bestatic, because it runs before any test object exists.@BeforeEach void setUp() { }This method runs before every test. In xUnit, the constructor of the test class does that.
@Test @Tag("slow")@Testmarks a test method, like[Fact]in xUnit.@Taglabels the test, like[Trait], so that a build can include or exclude the test.
A testtwist#
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 {The class has no attribute and no
public. JUnit finds the test methods by their@Testannotation.void add_sumsAmounts() {The test method returns
voidand takes no arguments..isEqualByComparingTo("15");This is an AssertJ assertion, like
Should().Be(...)in FluentAssertions. It comparesBigDecimalvalues withcompareTo, so 15 and 15.00 count as equal.
Test classes and methods are package-private by convention. JUnit 5 and later do not
require public. When you leave public out, the tests are not part of
your published API. JUnit 4 required public, so older code has it.
assertEquals(expected, actual) takes the expected value first,
as Assert.Equal does in xUnit. If you swap the two arguments, the failure message
shows the expected value as the actual value. The AssertJ form
assertThat(actual).isEqualTo(expected) removes the doubt. That is one reason why
most Java teams prefer AssertJ.
Parameterised teststwist#
[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);
}@ParameterizedTestThis annotation marks a test that runs once for each set of arguments, like
[Theory]in xUnit.@CsvSource({Each string is one row, like one
[InlineData]. JUnit splits the row at the commas, and each part becomes one parameter of the method.
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 |
Mockingtwist#
OrderService saves each order that it processes through an
OrderRepo. The test replaces the OrderRepo with a mock:
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);This line creates the mock. The mock is itself an
OrderRepo, so there is no.Object..thenReturn(new Order(7));This line stubs
find(7), likeSetup(...).Returns(...)in Moq.verify(repo, times(1)).save(any(Order.class));This line checks that
savewas called exactly once, with any order. Moq does this withVerifyandTimes.Once.
Here is the rest of Moq:
| Moq | Mockito |
|---|---|
| new Mock<T>() | mock(T.class) |
| mock.Object | the mock itself, so there is 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, so use a default answer that throws, or STRICT_STUBS |
Older Mockito could not mock final classes or static methods. Moq
has the same limitation, for the same reason. Since Mockito 5 the inline mock maker is the
default, so final classes can be mocked. mockStatic exists, but use it only when
nothing else works. If a class is hard to mock, that is usually a sign that its design should
be improved.
Capturing an argument, and strict mocks#
C# refresherMoq Callback and MockBehavior.StrictC# 8
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 argumentHere is how Mockito captures an argument, and how it fails on any call that you did not expect:
// 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)The captor keeps the argument of the verified call. In Moq, a
Callbackusually does this.Order saved = captor.getValue();This is the order that was passed to
save.mock(OrderRepo.class, call -> { throw new AssertionError("unexpected " + call); })The second argument is a default answer. It throws for every call that you have not stubbed, like
MockBehavior.Strictin Moq.
Stub such a mock with doReturn, not when.
when(strict.find(7)) calls find first, and the default answer throws.
doReturn(order).when(strict).find(7) stubs the method without calling it.
Testcontainerstwist#
Testcontainers for .NET is the same project in another language. It runs real dependencies in Docker for integration tests. Here the dependency is a PostgreSQL database:
import org.testcontainers.postgresql.PostgreSQLContainer;
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer db =
new PostgreSQLContainer("postgres:16")
.withDatabaseName("billing");
@Test
void findsSavedOrder() {
// db.getJdbcUrl() points at a real Postgres
}
}@TestcontainersThis annotation starts and stops the containers that are declared in the class.
import org.testcontainers.postgresql.PostgreSQLContainer;This import is from Testcontainers 2, which Spring Boot 4.1 manages. Spring Boot 3.4 manages Testcontainers 1. Testcontainers 1 has only the generic class
PostgreSQLContainer<?>, in the packageorg.testcontainers.containers. That generic class is now deprecated.static PostgreSQLContainer db =The field holds 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 works with Testcontainers directly. Put @ServiceConnection on a
container field, and Spring Boot sets the data source settings automatically. You do not
override any properties yourself. See
Testing Spring Boot.
WireMock: faking an HTTP dependencytwist#
WireMock is the Java original, and WireMock.Net copies its features in a separate codebase. WireMock starts a real HTTP server. So all of your client code really runs, and no part of it is replaced by a mock:
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);
}
}@WireMockTestThis annotation starts a real HTTP server on a free port for the test class. It passes the details of the server into each test.
stubFor(get("/rates/GBPUSD")This call tells the server how to answer a
GETfor/rates/GBPUSD.new RatesClient(wm.getHttpBaseUrl())This line gives the real client the address of the fake server.
Spring Boot has a lighter alternative for pure client tests: @RestClientTest
with MockRestServiceServer. This alternative intercepts requests at the
RestClient level and needs no port. Use WireMock when you want a real socket, a
delay, or an injected fault.
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. It also removes work whose result is never used. So a simple 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)This annotation runs the benchmark five times before it measures, so the JIT has compiled the code by then.
@Fork(2)This annotation runs everything in two fresh Java Virtual Machine (JVM) processes. So the optimisations of one run do not affect the other run.
return IntStream.range(0, n).sum();The method returns the result. JMH consumes the result, so the JIT cannot remove the work.
Return the result, or JMH's Blackhole must consume it. The
JIT can delete a benchmark entirely if its value is never used. You then measure an empty loop
at an impossible speed. The JVM does this far more aggressively than .NET. That is the main
reason why hand-written Java benchmarks are usually wrong.
ArchUnit: architecture rules as tests#
ArchUnitNET calls itself the C# fork of ArchUnit. With ArchUnit, a broken layering rule is an ordinary failing test, not a review comment. The billing code has three rules. The domain must not depend on the web layer or the database layer. Controllers must not depend on persistence. Field injection is banned. Here are the three rules as a test class:
@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")This annotation loads every class in
com.acme.billing, and runs each rule against these classes.noClasses().that().resideInAPackage("..domain..")This rule reads almost like English. No class in a domain package may depend on the api packages or the persistence packages. The two dots match any part of a package name.
noFields().should().beAnnotatedWith(Autowired.class);This rule bans
@Autowiredon fields, so dependencies come in through constructors.
JaCoCo against coverlet#
Coverage is a Maven plugin, not a separate tool. The plugin can fail the build when coverage is 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>This goal attaches the JaCoCo agent to the test run, so that the agent can note which lines run.
<minimum>0.80</minimum>This is the threshold. The
checkgoal fails the build if line coverage drops below 80%.
jacoco:report writes an HTML report into target/site/jacoco.
ReportGenerator does the same for coverlet.
JUnit 5 and JUnit 6#
Spring Framework 7 baselines JUnit 6. If you are on Spring Boot 4, you are
on JUnit 6. If you are on Spring Boot 3, you are on JUnit 5. The programming model stays the
same. @Test, @ParameterizedTest and the extension API work as
before.
Two things do change. JUnit 6 needs Java 17 or later. JUnit 6 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 the versions
yourself, and do not inherit them from the Boot parent, the version line is the line to fix.
junit-platform-runner and junit-platform-jfr have also been
removed.
Import the bill of materials of JUnit only if you set the version of JUnit yourself:
<!-- 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>This is the bill of materials of JUnit. It sets matching versions for all the parts of JUnit.
<version>${junit.version}</version>One property holds the version, because JUnit 6 uses one number for every part.
LegacyJUnit 4
You can recognise JUnit 4 code by these signs:
org.junit.Testin place oforg.junit.jupiter.api.Test- public test classes
@Beforein place of@BeforeEach@Afterin place of@AfterEach@RunWithin place of@ExtendWith
The junit-vintage-engine runs JUnit 4 tests inside newer JUnit. That is how
large codebases migrate a little at a time.
Naming#
Java has no single naming convention for tests, but two styles are the most common. The
first style is a method name of the form
methodUnderTest_condition_expectedResult. The second style is 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")This is the text that test reports show instead of the method name.
assertThatThrownBy(() -> service.checkout(emptyCart))This is an AssertJ check that the code throws, like
Should().Throw<T>()in FluentAssertions.
1A service moves from Spring Boot 3 to Spring Boot 4. Its plain JUnit tests, with no Spring
in them, use @Test and @ParameterizedTest, and nobody edits them. Do
they still run, and what do you check first?
@Test and @ParameterizedTest, and nobody edits them. Do
they still run, and what do you check first?2TranslateWrite this Moq test in Java, with Mockito. order is an
Order the test has already created:
var repo = new Mock<IOrderRepository>();
repo.Setup(r => r.FindById(7)).Returns(order);
var service = new OrderService(repo.Object);
service.Cancel(7);
repo.Verify(r => r.Save(order), Times.Once);
order is an
Order the test has already created:
var repo = new Mock<IOrderRepository>();
repo.Setup(r => r.FindById(7)).Returns(order);
var service = new OrderService(repo.Object);
service.Cancel(7);
repo.Verify(r => r.Save(order), Times.Once);OrderRepository repo = mock(OrderRepository.class);
when(repo.findById(7)).thenReturn(order);
var service = new OrderService(repo);
service.cancel(7);
verify(repo, times(1)).save(order);The mock is the repository itself, so there is no .Object.
verify takes the mock and the count. The call that you expect follows
verify.
3An expert asksWhy can a hand-rolled timing loop give a Java benchmark that is orders of
magnitude too fast?
Quick reference#
| .NET | Java | Note |
|---|---|---|
| AutoFixture | Instancio, EasyRandom | fill objects with generated test data |
| Bogus | Java Faker, Datafaker | fake but realistic names, addresses and so on |
| FsCheck | jqwik | property-based testing, which checks many generated inputs against one rule |
| Stryker.NET | PIT | mutation testing, and PIT is the usual Java tool |
Spring Boot orientation#
Spring Boot does the same as ASP.NET Core. It is an application host with dependency injection, configuration, an embedded web server and health endpoints. Spring Boot configures most of itself from the libraries that it finds on the classpath.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | the ASP.NET Core version | Boot 3.x or Boot 4.x, set by the parent in pom.xml | Existing systems and most tutorials use Boot 3. Boot 4 is for new work. Why |
| twist | Program.cs | a class marked @SpringBootApplication, with a main method | one class starts Boot, and it holds no list of registered services Why |
| trap | a service registered from any namespace | a class in the application class's package, or below it | a class in a sibling package is never scanned, so its bean does not exist Why |
| twist | a PackageReference for each library | one starter, such as spring-boot-starter-data-jpa | a starter brings a set of libraries and switches on their configuration Why |
| twist | no equivalent | spring-boot-starter-web in Boot 3, spring-boot-starter-webmvc in Boot 4 | Boot 4 renamed its modules, and this is the rename you meet first Why |
| new | builder.Services.AddDbContext<AppDb>(), written by you | auto-configuration | Boot creates the beans a library needs, unless you define your own Why |
| twist | no equivalent | --debug | prints the conditions evaluation report, which lists what Boot configured and why Why |
| twist | the folders of a .NET project | src/main/java and src/main/resources | the application class is in the root package, above every other class Why |
| twist | a framework-dependent dotnet publish | ./mvnw package, then java -jar | one runnable JAR holds your code, every dependency and an embedded Tomcat, and needs only a JVM Why |
| twist | Minimal APIs, NativeAOT | Javalin, Quarkus, Micronaut | Boot is the default. Quarkus and Micronaut start faster, and Javalin is tiny. Why |
Which versiontwist#
Two version lines of Boot are in use:
| Line | Spring Framework | Baselines | Use when |
|---|---|---|---|
| Boot 3.x | Framework 6 | Java 17, Jakarta EE 10 | existing systems and most tutorials |
| Boot 4.x | Framework 7 | Java 17 (25 recommended), Jakarta EE 11, Kotlin 2.2, GraalVM 25, JUnit 6 | greenfield |
A minor version arrives every six months, in May and November. Each 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 version. So plan one upgrade a year.
By September 2026 the 4.x line had reached 4.1. So look for the current release at
spring.io/projects/spring-boot, not in a tutorial.
The parent version in pom.xml 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>This is the parent Project Object Model (POM) of Boot. Your
pom.xmlinherits from the parent POM. The parent POM sets compatible versions for hundreds of libraries.
This book shows both lines. Where an API differs, the code has one tab for each line. A Boot 4 badge marks what Boot 4 adds.
The entry pointtwist#
// 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@SpringBootApplicationThis annotation switches on auto-configuration. It also makes Spring search the package of this class, and every package below it, for your classes. That search is called component scanning.
SpringApplication.run(BillingApplication.class, args);This line starts Boot. Boot creates the beans, which are the objects that Spring manages for you. Then Boot starts the embedded web server and runs until it is stopped, like
app.Run()in ASP.NET Core.// services are discovered by annotation, not registered hereThere is no
builder.Serviceslist. Spring finds your classes by their annotations, such as@Service.
Which packages are scannedtrap#
package com.acme.billing;
@SpringBootApplication
public class BillingApplication { } // its main method is left out here
package com.acme.billing;
@Service
public class OrderService {
OrderService(com.acme.audit.AuditService audit) { }
}
package com.acme.audit;
@Service
public class AuditService { }Show the answer
AuditService, so it cannot build the
OrderService.@SpringBootApplication includes @ComponentScan. The scan covers
the package containing the class and every package under it.
com.acme.audit is a sibling of com.acme.billing, so Spring does not
see a component in com.acme.audit. Boot reports
a bean that could not be found.
Put the application class in your root package, com.acme.billing. Then Spring
scans everything under the root package. To fix this example, move AuditService
to the package com.acme.billing.audit. This one convention causes more
“bean not found” confusion than anything else in Spring.
Starterstwist#
A starter is a dependency that brings a set of libraries that work together. It also switches on their auto-configuration. These 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 |
| Flyway migrations | flyway-core, with no starter | spring-boot-starter-flyway |
| 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 through Jakarta Persistence (JPA):
<!-- 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>This starter brings Hibernate, which is the library that maps objects to database tables. It also brings Spring Data JPA and a connection pool, and it switches on their auto-configuration. The starter has no version here, because the parent POM sets it.
Boot 4 renamed its modules, so that every module name has the form
spring-boot-<technology>. The root package of each module has the form
org.springframework.boot.<technology>. The rename that you meet first is
starter-web becoming starter-webmvc. This starter is
the first dependency in every tutorial you will read.
Auto-configurationnew#
When you add the JPA starter, Boot configures a data source, an
entity manager and transactions. You do not write a line
of code for this. ASP.NET Core has no equivalent. In ASP.NET Core, you call
builder.Services.AddDbContext<AppDb>() yourself.
Each starter contains configuration classes. A configuration class switches itself on only when certain conditions are true. This is called auto-configuration. Here is a simplified version of 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)The configuration applies only if the Java Database Connectivity (JDBC) classes are on the classpath. JDBC is the standard database interface of Java.
@ConditionalOnMissingBean(DataSource.class)And the configuration applies only if you have not defined a
DataSourceyourself.@BeanThe return value of the method becomes a bean. Spring can inject this bean into other classes.
The rule that matters is that anything you define yourself replaces what Boot would
configure. If you
declare your own DataSource bean, Boot does not create the auto-configured
DataSource bean. The reason is @ConditionalOnMissingBean. You
override an auto-configured bean by defining your own bean, not by unregistering the
auto-configured bean.
Boot finds these classes through a file. Each library lists its auto-configuration classes
in a file inside its Java archive (JAR). The file is in the
META-INF/spring folder, and its name ends in
AutoConfiguration.imports. At startup, Boot reads every such file on the
classpath.
The pattern behind the file: a service provider interfacedeep dive
This pattern is called a service provider interface (SPI). A library defines a place where
other code can add its own classes. At run time, the library finds those classes from files in
META-INF. Java itself has a class that works the same way, called
ServiceLoader. For more about ServiceLoader, see
the modules chapter.
Asking Boot what it configuredtwist#
# 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=--debugThis argument passes
--debugto the application. Boot then prints its conditions evaluation report. The report lists every auto-configuration that matched and every auto-configuration that did not match, with the reason.java -jartakes the same--debugflag.
Project layout#
A typical Boot service has this layout:
src/main/java/com/acme/billing/
├── BillingApplication.java ← must be in the root package
├── api/ ← @RestController
├── domain/ ← entities, value objects, domain services
├── persistence/ ← repositories
└── config/ ← @Configuration classes
src/main/resources/
├── application.yml ← the appsettings.json
├── application-dev.yml ← per-profile overrides
└── db/migration/ ← Flyway SQLBillingApplication is in the root package, com.acme.billing. So
component scanning finds everything in the folders below the root package.
application.yml does the same as appsettings.json in ASP.NET Core.
application-dev.yml overrides application.yml when the
dev profile is active.
Running it#
On a Boot project, ./mvnw package produces a single JAR that you can run. This
JAR is called a fat JAR. It holds your code, every
dependency and an embedded Tomcat. java -jar runs the fat JAR anywhere a
Java Virtual Machine (JVM) exists.
The closest match in .NET is a framework-dependent dotnet publish. Its output
needs the .NET runtime, in the same way that a fat JAR needs a JVM. To compare the forms, see the
chapter Packaging and native images. The fat JAR is also the reason 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=devThis command runs the service from source, with the
devprofile active.java -jar target/billing-1.0.0.jar --server.port=9090This command runs the packaged JAR. You can give any setting on the command line after
--. Here the setting is the port.
The quick reference lists the other everyday commands, with their .NET equivalents.
The alternatives#
| Framework | Pitch | Compared to |
|---|---|---|
| Spring Boot | the default, with a 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. They use annotation processors for this. So they do not need the classpath scanning and reflection that Spring does at startup. The result is a startup time in the tens of milliseconds, and much better GraalVM native image support. NativeAOT in .NET makes the same trade-off. Spring Boot has removed much of this difference with ahead-of-time (AOT) processing, but not all of the difference.
Here is a Quarkus endpoint. It uses the Jakarta REST annotations instead of the Spring annotations:
// Quarkus: Jakarta REST annotations instead of Spring's
@Path("/hello")
public class HelloResource {
@GET
public String hello() { return "hello"; }
}@Path("/hello")@Pathsets the path that this class answers, like@RequestMappingin Spring.@GET@GETmarks a method that handlesGETrequests, like@GetMappingin Spring.
1TranslateWrite this in Java, for Spring Boot:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<OrderService>();
var app = builder.Build();
app.MapControllers();
app.Run();
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<OrderService>();
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);
}
}
// OrderService.java
@Service
public class OrderService { }Nothing is registered in main. The @Service annotation on the
class replaces the AddSingleton line. A Spring bean is a singleton unless you say
otherwise.
2What does @SpringBootApplication switch on, besides marking the class that
main starts?
@SpringBootApplication switch on, besides marking the class that
main starts?@SpringBootApplication switches on two things. The first is
auto-configuration, because the annotation includes @EnableAutoConfiguration. The
second is component scanning, because the annotation includes @ComponentScan.
Component scanning searches the package of the class, and every package under it, for your
annotated classes.3An expert asksYou added one starter and wrote no configuration, yet a
DataSource bean exists. Where did it come from, and how do you replace it?
DataSource bean exists. Where did it come from, and how do you replace it?META-INF/spring. Boot applies each class whose conditions are true. One condition
is @ConditionalOnMissingBean. So when you declare your own DataSource
bean, Boot does not create its own DataSource bean.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 | 8080 |
| Change the port | --urls | --server.port=9090 |
Dependency injection and configuration#
Spring does dependency injection in the same way as ASP.NET Core, but Spring has no registration list. You mark a class with an annotation, and Spring finds the class. Configuration works as you expect, but the names are new.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | builder.Services.AddScoped<OrderService>() | @Service on the class | the annotation is the registration, so there is no registration list Why |
| new | no equivalent for services, so each is registered by hand | component scanning | at startup Spring finds every annotated class under the application's package and makes a bean of it Why |
| same | constructor injection | constructor injection | A class with one constructor needs no annotation. Older code puts @Autowired on a field instead. |
| trap | AddScoped, chosen every time | singleton, unless you say otherwise | one bean is shared by every request, so a field in it is shared too Why |
| twist | AddSingleton, AddScoped, AddTransient | singleton, @RequestScope, @Scope("prototype") | the same three lifetimes, under different names Why |
| twist | AddKeyedScoped<IPaymentGateway, CardGateway>("card") | @Service("card"), then @Qualifier("card") | when two beans fit one type, a bean name picks between them Why |
| twist | IEnumerable<IPaymentGateway> | List<PaymentGateway> or Map<String, PaymentGateway> | a List or Map parameter receives every bean of that type Why |
| twist | a factory lambda in AddSingleton | a @Bean method in a @Configuration class | for a class you cannot annotate, such as a library type Why |
| twist | appsettings.json | application.yml | the same settings in YAML, where indentation shows nesting Why |
| trap | a MongoDB connection string in appsettings.json | spring.mongodb.uri | On Boot 3 it is spring.data.mongodb.uri. Boot 4 ignores that old name and tries localhost:27017. Why |
| twist | ASPNETCORE_ENVIRONMENT | SPRING_PROFILES_ACTIVE | an environment, such as dev or prod, is called a profile in Spring Why |
| twist | appsettings.Development.json | application-dev.yml | Boot also loads it when the dev profile is active, and its settings override application.yml Why |
| twist | IHostEnvironment.IsDevelopment() | @Profile("dev") | the bean exists only while that profile is active Why |
| twist | --environment Development | --spring.profiles.active=dev | a command-line argument that switches a profile on Why |
| twist | one environment name at a time | several active profiles: dev,local | Spring switches on each name in the comma-separated list, but .NET reads the same text as one name Why |
| twist | IOptions<RatesOptions> | a @ConfigurationProperties record, injected directly | binds one section of the settings to a type, with no wrapper Why |
| twist | Rates__TimeoutSeconds | RATES_TIMEOUT_SECONDS | an environment variable sets rates.timeout-seconds, with one underscore Why |
| twist | dotnet user-secrets | application-local.yml, gitignored | Spring has no secrets tool, so keep a local profile file out of git Why |
Registration by annotationtwist#
The billing service has an OrderService that needs an
OrderRepository to load and save orders:
// 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; the Data access chapter 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;
}
}@RepositoryThis annotation marks
OrderRepositoryas a class that Spring should create.@Repositoryis for classes that talk to the database.@ServiceThis annotation does the same for
OrderService. This one line replaces thebuilder.Servicesline in C#.private final OrderRepository repository;finalmeans that the field is set once, in the constructor, and never changes. Afinalfield is like areadonlyfield in C#.public OrderService(OrderRepository repository) {Spring sees that the constructor needs an
OrderRepository. It finds theOrderRepositorythat it created, and passes this object to the constructor.
Beans and component scanningnew#
An object that Spring creates and gives to other objects is called a bean. At startup, Spring searches the package of the application class, and every package below it, for classes with these annotations. Spring creates one bean for each class that it finds. That search is called component scanning. Here the framework creates the objects, and your code does not. This is called inversion of control (IoC).
A class with one constructor needs no further annotation, because Spring uses that
constructor. This is called constructor injection, and you already do it in ASP.NET
Core. With constructor injection, the field can be final. The constructor lists
everything that the class depends on. And a unit test can create the service with
new and a fake repository.
These are the annotations that make a class a bean:
| Annotation | Means | Note |
|---|---|---|
| @Component | any class Spring should create and inject | an object that Spring creates and injects is called a "bean" |
| @Service | a component that holds business logic | works the same as @Component, but the name shows what the class is for |
| @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 |
LegacyField injection with @Autowired
Older code often injects into a field instead of a constructor, so you should be able to
recognise field injection. Use constructor injection with final fields in new
code.
import org.springframework.beans.factory.annotation.Autowired;
// the older style: avoid it in new code
@Service
public class OrderService {
@Autowired
private OrderRepository repository;
}@Autowired@Autowiredtells Spring to set the field after Spring has created the object. Field injection works, but it hides what the class depends on. Also, a unit test cannot give the class a fake repository without starting Spring.
Lifetimestrap#
@Service
public class PaymentService {
private Order current;
public void pay(Order order) {
current = order;
charge(current);
}
private void charge(Order order) { }
}Show the answer
The defaults are opposite. ASP.NET Core makes you choose a lifetime every
time. In Spring, the default lifetime is singleton. So there is one
PaymentService for the whole application, and this one object has one
current field. Every request reads and writes this one field. The second request
can overwrite the field before the first request reaches charge, and nothing warns
you.
Keep beans stateless. Hold per-request data in local variables and parameters. Here, that
means calling charge(order) directly.
Spring calls a lifetime a bean scope. Spring has the three lifetimes that you know, under different names. It also has two more scopes for web applications:
| ASP.NET Core | Spring | Note |
|---|---|---|
| AddSingleton | singleton | the default in Spring, which gives one shared instance for the whole application |
| AddScoped | request | one instance per HTTP request, declared 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 |
The three lifetimes, side by sidetwist#
// 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 bean is a singleton. Spring creates one
SystemTimeand injects that same object everywhere.@RequestScopeSpring creates one
Cartfor each HTTP request, likeAddScopedin ASP.NET Core. A singleton can still take aCartin its constructor. Spring injects a stand-in object, which is called a proxy. Each time you call the proxy, it finds the cart of the current request.@Scope("prototype")Spring creates a new
IdGeneratorevery time one is injected, likeAddTransientin ASP.NET Core. In Spring, a transient lifetime is called prototype.
Beans for types you do not own#
The billing service calls an exchange-rate service over HTTP. It uses the Spring type
RestClient for this. You cannot put @Service on
RestClient, because you did not write RestClient. 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);
}
}@Configuration@Configurationmarks a class that holds@Beanmethods. Spring calls these methods at startup.@BeanThe object that the method returns becomes a bean. The bean gets the name of the method, which is
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 urlSpring injects the parameters of a
@Beanmethod, like constructor parameters. This parameter is a single setting from the configuration. For the${…}form, see the section Single values and precedence.return RestClient.create(url);This line builds a client whose requests all start with that address.
Multiple implementationstwist#
Two gateway classes implement one interface, PaymentGateway. So you must tell
Spring which gateway CheckoutService gets. .NET 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")This annotation registers the bean under the name
card. Without a name, Spring names a bean after its class, with a lower-case first letter. Here that name would becardGateway.@Qualifier("card") PaymentGateway gateway@Qualifierasks for the bean namedcard, like[FromKeyedServices("card")]in ASP.NET Core. Without@Qualifier, 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 |
Injecting every implementation: List and Map#
A List<PaymentGateway> parameter is like injecting
IEnumerable<T> in ASP.NET Core, but the list includes named beans. The .NET
version does not include keyed registrations unless you ask for
KeyedService.AnyKey. The Map form has no equivalent in ASP.NET Core.
It gives you the beans keyed by name. Here the shop uses a Map to find the gateway
for the payment method that 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. Here the names arecardandbank. AList<PaymentGateway>parameter would receive the same beans, without the names.gateways.get(method).charge(order);This line gets the gateway for the customer's choice and charges the order. To add a third gateway, for vouchers, you write a new class and change nothing else.
Configuration filestwist#
Settings are in the file src/main/resources/application.yml. The file is
written in YAML. YAML is a format that shows nesting with indentation 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/billingThis is the setting
spring.datasource.url. Each level of indentation adds one part to the name. Settings underspring:belong to Spring Boot itself. This setting is the database address, in the form used by Java Database Connectivity (JDBC).timeout-seconds: 5Your own settings can have any name. The convention in Boot is lower case with hyphens.
com.acme.billing: DEBUGThis is the log level for one package and everything below it.
Many projects use application.properties instead. This file holds the same
settings, one per line, such as rates.timeout-seconds=5. Boot reads either file. A
document database has its own setting, spring.mongodb.uri. For this setting, see
the section Connecting to Amazon DocumentDB.
The MongoDB setting changed its nametrap#
A document database has a connection setting of its own, and Boot 4 renamed it:
spring:
data:
mongodb:
uri: mongodb://localhost:27017/billingspring:
mongodb:
uri: mongodb://localhost:27017/billinguri: mongodb://localhost:27017/billingThis is the connection string. Only the path to the setting differs. On Boot 3 the setting is
spring.data.mongodb.uri. On Boot 4 the setting isspring.mongodb.uri.
Boot 4 ignores spring.data.mongodb.uri. A service that still
sets the old name tries localhost:27017 instead. The first sign of the problem is
a MongoTimeoutException. For the full settings, see the section
Connecting to Amazon DocumentDB.
Profiles are environmentstwist#
A Spring profile is a named set of settings and beans that you switch on at
startup. Examples are dev and prod. Here a fake email sender exists
only on a developer's machine:
// 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=devThis is an environment variable that switches on the
devprofile, likeASPNETCORE_ENVIRONMENTin ASP.NET Core. Boot then also readsapplication-dev.yml. Its settings override the settings inapplication.yml.@Profile("dev")Spring creates this bean only when the
devprofile is active. With any other profile, the bean does not exist.
Several profiles can be active at the same time. You separate the names with commas, as in
SPRING_PROFILES_ACTIVE=dev,local. ASP.NET Core has one environment at a time. It
reads Development,Local as a single name.
A bean for every profile except one#
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.
IOptions becomes @ConfigurationPropertiestwist#
You describe the whole rates section of the settings as one type. Here the type
is a record. Spring fills the record with the values from
the settings:
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")This annotation binds the
ratessection ofapplication.ymlto this type.int timeoutSeconds) { }A record is an immutable data class. See Records. Spring fills each component from the setting with the matching name, so
timeoutSecondsgets the value oftimeout-seconds.@EnableConfigurationProperties(RatesProperties.class)This annotation tells Spring to create a
RatesPropertiesbean. On a larger service, put@ConfigurationPropertiesScanon the application class instead. Spring then finds every such type itself.RatesClient(RatesProperties rates) { }Spring injects the record like any other bean.
The binding accepts several spellings of a name. rates.timeout-seconds,
rates.timeoutSeconds and RATES_TIMEOUT_SECONDS all fill the same
component. This is called relaxed binding. Because of relaxed binding, an environment
variable can set any property.
Single values and precedence#
For a single setting, @Value injects the setting directly. @Value
is useful 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 the placeholder with the value of therates.urlsetting. If there is no such setting, Spring fails at startup.${rates.timeout-seconds:5}The text after the colon is a default. Spring uses the default 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.comThis line sets the environment variable
RATES_URL. Relaxed binding readsRATES_URLasrates.url.java -Drates.url=https://rates.example.com -jar billing.jarThis line starts the service with a Java system property of the same name. Spring uses the system property, because it is higher in the list below. You can also give an argument, such as
--rates.url=…, after the name of the Java archive (JAR). Spring would then use that argument, and not the environment variable or the system property.
When the same setting is given in several places, Spring uses the source that is higher in this list:
| 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. In the list, two more sources are between
SPRING_APPLICATION_JSON and system properties. One source is the
servlet container. The other source is the Java Naming
and Directory Interface (JNDI), which is a lookup service provided by an old-style
application server. These two sources rarely
matter in a Boot service. For what a system property is, see the chapter
How Java runs your code.
Environment variables are written in upper case with underscores. For example,
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. In Spring, a single underscore is enough.
Secrets#
The database password of the billing service must never be committed to the repository. Here is the Spring match for each .NET option for secrets:
| .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 repoSpring has no built-in equivalent of dotnet user-secrets. The common pattern is
a file called application-local.yml. The file is listed in
.gitignore, and a local profile switches it on. Do this on day one,
because the alternative is a committed password. 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-secretThis is the development password. The file is listed in
.gitignore, so git never commits the file.-Dspring-boot.run.profiles=localThis argument runs the service with the
localprofile. So Boot also loadsapplication-local.yml, and its settings override the settings inapplication.yml.
1TranslateWrite this in Java, for Spring Boot:
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) { }
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) { }@ConfigurationProperties(prefix = "rates")
record RatesProperties(String url, int timeoutSeconds) { }
@Configuration
@EnableConfigurationProperties(RatesProperties.class)
class RatesConfig { }
@Service
class RatesClient {
RatesClient(RatesProperties rates) { }
}Spring injects the record itself, with no IOptions wrapper. The record is a
bean only because @EnableConfigurationProperties names it. If you remove that
line, Spring has no RatesProperties to inject.
2The shop wants a fake email sender on a developer's machine and nowhere else. How do you
write that in Spring, and how is it switched on?
@Profile("dev") on the bean class. Spring then creates the bean only while
the dev profile is active. Switch the profile on with
SPRING_PROFILES_ACTIVE=dev or --spring.profiles.active=dev. Boot then
also loads application-dev.yml, and its settings override the settings in
application.yml.3An expert asksOlder Spring code puts @Autowired on a private field. Why does
new code take the dependency through the constructor instead?
@Autowired on a private field. Why does
new code take the dependency through the constructor instead?final field, and the constructor
shows everything that the class depends on. A unit test can also create the class with
new and pass a fake to the constructor. An @Autowired field hides the
dependency, and a unit test cannot set the field without starting Spring.How Spring actually works#
Spring does not change your class. It wraps it. Other classes often receive a stand-in object in place of your bean. The stand-in object is called a proxy. The proxy does what an annotation asks for, such as starting a transaction. Then the proxy calls your object.
| Kind | C# | Java | Note |
|---|---|---|---|
| new | services.Decorate<IOrderService, TransactionalDecorator>() | an annotation such as @Transactional, and Spring makes the proxy | the wrapper is generated while the program runs, and no code shows it Why |
| trap | this.Place(order) skips a decorator too, but you wrote that decorator | place(order) from inside the class skips the proxy | a call that never leaves your object starts no transaction, and no code shows why Why |
| new | no equivalent | a CGLIB subclass, OrderService$$SpringCGLIB$$0 | Boot generates a subclass as the proxy, even for a bean with an interface Why |
| trap | a method is not virtual unless you say so | a final method loses its annotation | It runs on the proxy itself, with no transaction and with null injected fields. A final class stops startup. Why |
| twist | orderService.GetType().FullName | orderService.getClass().getName() | prints the generated subclass's name, because you hold the proxy Why |
| twist | IHostedService | a bean that implements ApplicationRunner | Boot calls its run method one time, after the application context has started Why |
| twist | work in a constructor | ApplicationRunner, or a @PostConstruct method | while a constructor runs, other beans may not exist yet Why |
| twist | the service provider | the application context, with its BeanPostProcessors | a post-processor can replace a new bean, which is how proxies are made Why |
| twist | middleware or an action filter | an @Aspect class with @Around | wraps one behaviour around many bean methods, not only controllers Why |
| twist | no equivalent | #{...} in an annotation value | SpEL evaluates an expression, but ${...} only reads a setting Why |
| twist | a dependency cycle throws | a cycle stops Boot at startup | cycles are rejected by default since Boot 2.6, so extract a third bean Why |
| twist | the order of registration | @Order(1) on the bean | sets a bean's place in an injected List, not the order of creation Why |
What a proxy actually isnew#
The place method of the OrderService is marked
@Transactional. This means that the method must run inside a database transaction.
Spring cannot add code to your method. Instead, after Spring creates the
OrderService, it puts a different object in the place of the
OrderService. The different object is an instance of a subclass that Spring
generates while the program runs. This object is called
a proxy. The proxy 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. The receptionist signs them in and then gives their message to a colleague.
C# has nothing that does this for you. You write the decorator class and register it yourself. In Spring, you write only the annotation:
// 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.@Transactional@Transactional asks Spring to run the method inside a transaction. To do this, Spring gives every other class a proxy instead of your object. For more about the transaction itself, see the chapter Transactions, propagation and isolation.
Self-invocation: a call from inside the classtrap#
@Service
public class OrderService {
public void placeAll(List<Order> orders) {
for (var order : orders) {
place(order);
}
}
@Transactional
public void place(Order order) {
// save the order
}
}Show the answer
The controller reaches placeAll through the proxy. But placeAll
has no @Transactional, so the proxy only calls placeAll on your
object. Inside placeAll, place(order) means
this.place(order). Your object calls itself, so the call never reaches the
proxy.
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 is the injected proxy. So orders.place(order) goes through
the proxy, and each order is placed in its own transaction.
When a method calls another method of its own object, this is called self-invocation. In the office picture, one colleague speaks to another colleague, and the receptionist never hears it.
There are four ways to fix this:
| Fix | How | Cost |
|---|---|---|
| Move the method to another bean | inject it and call it | best, and usually better design anyway |
| Inject yourself | @Autowired @Lazy OrderService self; then self.place(order) | works but reads oddly, and without @Lazy, Boot rejects it as a cycle |
| AopContext.currentProxy() | cast the result and call through it | needs exposeProxy=true, and is obscure |
| Use AspectJ weaving instead of proxies | compile-time or load-time weaving | powerful, much heavier |
Every annotation that works through a proxy behaves this way. For examples, see the chapters
Data access, JPA and migrations, Security and
HTTP clients and resilience. A private
method fails too, because only its own class can call it. See the section
The proxy trap, again.
Two kinds of proxytrap#
Spring Boot builds a proxy with the Code Generation Library (CGLIB). CGLIB generates a subclass of your class while the program runs.
@Service
public class OrderService {
private final OrderRepository orders;
public OrderService(OrderRepository orders) {
this.orders = orders;
}
@Transactional
public final void place(Order order) {
orders.save(order);
}
}Show the answer
orders.save(order) throws
NullPointerException.A CGLIB proxy is a subclass, so final stops it, with no
error. A
subclass cannot override a final method, so the proxy cannot wrap that method. The
call runs the method on the proxy object. Spring never set that object's fields. Only your
real object has its orders. The only notice is a startup warning,
“cannot get proxied via CGLIB”. Java methods are virtual by default, and C# methods
are not. So a final added for safety breaks the method.
Remove final. A final class does not
fail silently. 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. The kotlin-spring
plugin opens the classes that carry Spring annotations.
Java also has built-in dynamic proxies, but they only work through an interface. The kind of
proxy that 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 a constructor.
Seeing the proxy for yourself#
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 that you actually hold. That object is the proxy.// prints com.acme.billing.OrderService$$SpringCGLIB$$0This is the name of a subclass of
OrderServicethat Spring generated.$$SpringCGLIB$$shows that Spring generated the class. The number at the end keeps generated names unique.
Work at startuptwist#
C# refresherIHostedService: code that runs when the host startsC# 6
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>();Boot calls this bean after 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.
// args holds the command-line arguments the application was started with.
@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
ApplicationRunner. After startup, Boot calls therunmethod of each of these beans. Therunmethod is likeStartAsyncin ASP.NET Core.
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. Put such work, for example loading
caches, in an ApplicationRunner.
Work that needs only this bean's own dependencies can go in a method marked
@PostConstruct. Spring runs this method after injection. The annotation
@PostConstruct is in the package
jakarta.annotation. But if this method calls the bean's own
@Transactional methods, no transaction starts, because the call is
self-invocation.
The bean lifecycle#
The object that creates and holds all the beans is called the application context. The application context is like the service provider in ASP.NET Core. At startup, the application context finds the beans, from component scanning and from @Bean methods. It then creates each bean through the constructor of the bean. This is the point where constructor injection happens. After that, each bean goes through the rest of these steps:
ASP.NET Core builds an object and gives it to you. Spring builds a bean and then shows the
bean to every BeanPostProcessor. A BeanPostProcessor is a class that
can decorate, replace or reject a bean before any other class sees the bean. The Spring
framework extends itself this way. Proxies are made in the step after your initialisation
code.
This table shows when each step happens, next to its nearest equivalent in ASP.NET Core:
| 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, and this is how the framework extends itself |
| IDisposable / IAsyncDisposable | @PreDestroy | container shutdown |
| IHostedService.StartAsync | ApplicationRunner, @EventListener(ApplicationReadyEvent) | after the context has started |
Here a RateCache bean needs a RatesClient. The bean uses three of
those steps:
@Service
class RateCache {
private final RatesClient client;
RateCache(RatesClient client) { // dependencies arrive
this.client = client;
}
@PostConstruct
void load() { } // after all dependencies are set
@PreDestroy
void clear() { } // at container shutdown
}@PostConstruct and @PreDestroy mark methods that Spring calls for
you. Spring calls load after injection is done. Spring calls clear
when the application shuts down.
AOP: writing your own cross-cutting behaviour#
Aspect-oriented programming (AOP) means adding the same behaviour around many methods without
editing each one. An example is timing every service call. Spring does this with proxies, and
AOP is the API for adding your own behaviour. AOP is like ASP.NET Core middleware, an action
filter or a DispatchProxy decorator. But AOP 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);
}
}
}@AspectThis annotation marks the class as an aspect.
@Componentmakes the class a bean, so Spring finds it.@Around("@within(org.springframework.stereotype.Service) && execution(public * *(..))")This expression says which methods to wrap.
execution(public * *(..))means any public method, with any return type, name and parameters.@within(…Service)limits the match to classes marked@Service.return call.proceed();This line runs the real method and returns its result. Without this line, the real method never runs.
} finally {The
finallyblock runs whether the method returned or threw, as it does in C#. So every call is timed. Each{}in the log message is replaced by the next argument.
The code that an aspect runs is called advice. @Around is one kind of
advice. Here are all five kinds:
| 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, and you invoke proceed() | middleware |
The expression in @Around is called a pointcut. A pointcut selects 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 proxies, so every limitation above applies to aspects too.
Self-invocation skips an aspect. A final method stops an aspect from running. An
object that you create yourself with new is never wrapped. When an aspect seems not
to run, the cause is almost always one of those three.
AspectJ weaving does not wrap your classes. It changes your compiled classes themselves, so it also covers calls inside a class. But AspectJ weaving needs an extra step in the build or at startup.
SpEL#
Spring Expression Language (SpEL) is a small expression language that runs inside annotation
values. It is written #{...}. You will see SpEL in @PreAuthorize for
security and in @Cacheable for caching. SpEL also works in @Value.
Here SpEL reads 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. The operator?:supplies'eu'when the property is missing.?:is like??in C#.
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 owner of the order is the user who calls the method.key = "#pair + ':' + #date"This expression builds the cache key from both parameters, for example
EURUSD:2026-09-13.
${...} and #{...} are different things. ${...} is
called a property placeholder, and it reads configuration. #{...} is
SpEL, and it evaluates an expression. If you write one in place of the other, you get a
literal string or a startup failure. The error message rarely says which mistake you made.
Circular dependencies#
Say the OrderService needs the BillingService to create an invoice.
And the BillingService needs the OrderService to find the order. Then
Spring cannot create the first bean without the second bean, and it cannot create the second
bean without the first bean:
@Service
class OrderService {
OrderService(BillingService billing) { }
}
@Service
class BillingService {
BillingService(OrderService orders) { }
}OrderService(BillingService billing) { }To create an
OrderService, Spring needs aBillingService. But to create aBillingService, Spring needs anOrderService.
ASP.NET Core throws an exception when it finds a dependency cycle. Spring used to resolve some cycles by injecting a half-built object. This caused bugs that were hard to find. 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 the box needs the next bean, and the last bean needs the first bean.
There are four things you can try, and only the first one is a real fix:
| Fix | Note |
|---|---|
| Extract the shared logic into a third bean | the usual fix, because 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, but this hides a design problem |
| spring.main.allow-circular-references=true | this setting enables the old behaviour again, so avoid it |
| Setter injection | This no longer works. Since Boot 2.6 a cycle through setters or fields fails too, unless circular references are allowed. |
A cycle is a sign of a design problem, not a limitation of the container. It nearly always means that two beans share a responsibility, and that this responsibility should have its own name. The other options in the table exist for legacy code that is being migrated, not for new work.
Ordering and conditional beans#
When a service has many beans, two questions appear. In what order do the beans run? And does a certain bean exist at all? These annotations answer both questions:
| 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 the pricing step:
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 { }A bean with a lower number comes first in any injected
Listof this type.Checkout(List<CheckoutStep> steps) { }The constructor 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 the order of creation. @DependsOn is the only way to
add a dependency that the code itself does not have.
1TranslateWrite this in Java, for Spring Boot:
public class Warmup : IHostedService
{
public Task StartAsync(CancellationToken ct) => LoadExchangeRatesAsync(ct);
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
builder.Services.AddHostedService<Warmup>();
public class Warmup : IHostedService
{
public Task StartAsync(CancellationToken ct) => LoadExchangeRatesAsync(ct);
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
builder.Services.AddHostedService<Warmup>();@Component
class Warmup implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
loadExchangeRates();
}
}Boot calls run one time, after every bean is ready. Do not do this work in the
constructor, because other beans may not exist yet while a constructor runs.
2An expert asksA stack trace names the class OrderService$$SpringCGLIB$$0,
although OrderService implements an interface. Why, and why must the class have a
constructor that is not private?
OrderService$$SpringCGLIB$$0,
although OrderService implements an interface. Why, and why must the class have a
constructor that is not private?proxyTargetClass=true by default. So the proxy is a subclass that CGLIB generates
while the program runs, even for a bean with an interface. A subclass must be able to see a
constructor.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, so it runs on the proxy itself, where injected fields are null | 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 |
Controllers, routing and binding#
The web framework of Spring is called Spring MVC. It is named after the model-view-controller (MVC) pattern. Spring MVC does the same as ASP.NET Core MVC. A controller, its routes and its parameter binding each have a direct match in Spring MVC. They are written as annotations.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | [ApiController] on a ControllerBase class | @RestController | every return value is written to the response body, as JSON Why |
| twist | [Route("api/x")] | @RequestMapping("/api/x") | on the class, as a prefix for every method's path Why |
| twist | [HttpGet], [HttpPost], [HttpPut], [HttpPatch], [HttpDelete] | @GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping | there is one annotation for each verb, and its path is added to the class prefix Why |
| twist | [HttpGet("{id:int}")] | @GetMapping("/{id}") | Spring infers the type from the parameter Why |
| twist | route constraint :int | a regular expression: {id:\d+} | A path that does not match the expression gets 404. Without an expression, a value that cannot be converted gets 400. Why |
| twist | [Produces("application/json")] | produces = "application/json" | an attribute of the mapping annotation, not a separate one Why |
| twist | [Consumes(...)] | consumes = "..." | an attribute of the mapping annotation too, as produces is Why |
| twist | [FromRoute] | @PathVariable | binds a part of the URL path, converted to the parameter's type Why |
| twist | [FromQuery] | @RequestParam | binds one value from the query string, as in ?q=tea Why |
| trap | [FromQuery] int page is 0 when missing | @RequestParam int page | required by default, whatever the type, so a request without it gets 400 Why |
| twist | [FromHeader] | @RequestHeader | binds a request header, which is also required by default Why |
| twist | [FromBody] | @RequestBody | binds the request body, which Jackson reads from JSON Why |
| twist | no equivalent | the compiler's -parameters flag | Spring reads a parameter's name from the class file, and Boot's parent POM sets the flag for you Why |
| twist | a request or response class | a record, such as CreateOrder | Jackson reads and writes records directly, so a DTO should be one Why |
| twist | Ok(x), NotFound() and the other result helpers | the value itself, or a ResponseEntity | You return the value for a plain 200. A ResponseEntity sets any other status, and build() finishes a response with no body. Why |
| twist | middleware and action filters | a servlet Filter and a HandlerInterceptor | a filter sees every request, but an interceptor wraps controller methods only Why |
| twist | AddCors and UseCors | addCorsMappings in a WebMvcConfigurer | the cross-origin rules are in one configuration class Why |
| twist | versioned routes, such as api/v1/orders | @GetMapping(version = "1.0") | built-in versioning is new in Boot 4, and Boot 3 uses separate paths instead Why |
| twist | AddSwaggerGen() | the springdoc-openapi-starter-webmvc-ui dependency | the UI appears with no configuration, generated from your controllers Why |
A controllertwist#
[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);
}
}@RestControllerThis annotation makes the class a bean. The return values of its methods are written into the response body as JSON, by Jackson.
@RestControllerdoes the same as[ApiController]andControllerBasetogether.@RequestMapping("/api/orders")This is the path prefix for every method, like
[Route]in ASP.NET Core.@PathVariable long id) {This parameter takes
idfrom the{id}part of the path. Spring converts the text to along. If Spring cannot convert the text, the request fails with 400 Bad Request..map(ResponseEntity::ok)findreturns anOptional. For more aboutOptional, see the Optional chapter. If theOptionalholds an order, the order becomes a 200 OK response.ResponseEntity::okis a method reference. It is short fororder -> ResponseEntity.ok(order).@RequestBody CreateOrder request) {This annotation reads the JSON request body into a
CreateOrder, like[FromBody]in ASP.NET Core.return ResponseEntity.created(location)This line returns 201 Created, with a
Locationheader for the new order, likeCreatedAtActionin ASP.NET Core.
Routing and verbstwist#
@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")pathis the prefix for every path in the class.producessays that every method returns JSON, like[Produces]in ASP.NET Core. In Spring,producesis an attribute of the mapping annotation, not a separate annotation.@GetMapping("/{id:\\d+}")The part after the colon is a regular expression. Here the expression means one or more digits, so it works like the route constraint
{id:int}. A request for/api/orders/abcgets 404 and does not reach the method. In a Java string, you write the backslash twice.@PutMapping("/{id}")There is one annotation for each HTTP verb.
@GetMapping,@PostMappingand@PutMappingare three of them. The other two are@PatchMappingand@DeleteMapping.
Model bindingtrap#
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/search")
public List<OrderDto> search(@RequestParam int page) {
return List.of();
}
}Show the answer
search is never called.@RequestParam is required by default, whatever its type. In an
ASP.NET Core controller, a missing int binds to 0, and a missing
string? binds to null. Only a non-nullable string is required there.
A request without it gets 400, with the message “The q field is required.”
Write required = false, or supply a defaultValue.
Each parameter has 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,This value comes from the
{region}segment of the path, like[FromRoute].@RequestParam String q,This value comes from the query string, as in
?q=tea, like[FromQuery]. The parameter is required, so a request withoutqgets 400 Bad Request. A non-nullablestringin an[ApiController]behaves the same way.@RequestParam(defaultValue = "0") int page,This parameter is optional. A missing
pagebecomes 0.@RequestParam(required = false) String status,This parameter is optional. A missing
statusbecomesnull.@RequestHeader(value = "X-Tenant",This value comes from the
X-Tenantheader, like[FromHeader]. Headers are required by default too, so this header is markedrequired = false.
Spring Data adds one more kind of parameter, a Pageable. This parameter is
filled from the query parameters page, size and sort.
This book does not show a Pageable in code.
Where Spring gets a parameter's name#
None of the binding annotations above names its parameter. Spring reads the name, such as
q, from the compiled class. This only works when the compiler keeps parameter
names. A build that does not keep parameter names 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 error appears only in builds that
do not use the parent POM. In such a build, 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) { ... }Records as DTOstwist#
Types that only carry data in and out of an API are called data transfer objects (DTOs). In Java, DTOs 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(This record is 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,These annotations are Bean Validation rules. They are checked when the parameter is marked
@Valid. See the next chapter.public record OrderDto(long id, String status, BigDecimal total) { }This record is the response. It is written as
{"id":1,"status":"PAID","total":19.99}.
A record has no no-argument constructor. Its accessors have names like id(),
not getId(). Modern Jackson handles this. Another library, or a Jackson version
older than roughly 2.12, fails to deserialise a record. The failure has a confusing message
about missing creators. Check the version before you rewrite the record as a class.
Returning resultstwist#
// 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);This line returns a 400 with the errors as the JSON body, like
BadRequest(errors).ResponseEntity.noContent().build();build()finishes a response that has no body. Here the response is 204 No Content.ResponseEntity.status(418).build();This line sets any status code, by its number.
.body(new ByteArrayResource(pdf));This line returns a file download, like
File(...). The bytes are wrapped as aResource, and theContent-Dispositionheader names the file.
If you do not need to set a status or headers, return the value directly. Spring then
serialises the value with a 200. Use ResponseEntity<T> only where you
actually change the status or the headers. A third option is the annotation
@ResponseStatus(HttpStatus.CREATED) on the method. It sets a fixed status other
than 200.
For an error, ASP.NET Core returns Problem(). Spring returns a
ProblemDetail. See the section ProblemDetail.
Middleware#
C# refresherASP.NET Core middleware and action filtersC# 5
Middleware sees every request on its way through the pipeline. A filter wraps only controller actions. A filter has access to the arguments and the result of the action.
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 class that handles HTTP
requests is called a servlet. Spring MVC itself runs as one servlet, the
DispatcherServlet. The DispatcherServlet routes each request to a
controller method. Code that must see every request runs before the
DispatcherServlet. Such code is called a servlet filter. Code that wraps
only controller methods is called a HandlerInterceptor:
| ASP.NET Core | Spring | Runs |
|---|---|---|
| app.Use(...) middleware | Servlet Filter | before the dispatcher, and sees every request |
| IActionFilter | HandlerInterceptor | around controller methods |
| IAsyncActionFilter | HandlerInterceptor | |
| IExceptionFilter | @ControllerAdvice + @ExceptionHandler | |
| IAuthorizationFilter | Spring Security filter chain | |
| Endpoint routing | DispatcherServlet |
The shop gives every request a correlation id. With this id, all the log lines for one request can be found together. A filter reads the id from a header, or creates a new id:
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 {This class is a servlet filter. Because the class 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). SLF4J is the logging API that most Java code uses. The MDC holds values for the current thread, and the log format can print these values on every line. The MDC is like a logging scope in Serilog.
chain.doFilter(req, res);This line sends the request to the next filter, and in the end to Spring MVC. It is like
await next()in ASP.NET Core middleware.MDC.clear();This line runs in
finally. So the next request that this thread handles never sees the id.
A HandlerInterceptor does the same as an action filter in ASP.NET Core. It runs
around controller methods only:
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(This method runs before the controller method, like
OnActionExecuting.return true;Returning
truelets the request continue. Returningfalsestops the request here.
Spring Boot registers a filter for you, but you must register an interceptor yourself. You
register it in a configuration class that implements WebMvcConfigurer. The
cross-origin rules go in the same class.
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 that the origin of
the front end is allowed. This 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/**")This line 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, which is called a preflight. Spring answers the preflight. A preflight from any other origin gets 403.
registry.addInterceptor(new TimingInterceptor());This line registers the interceptor from above, for every controller method.
Do not put @EnableWebMvc on this class in a Boot application.
@EnableWebMvc tells Spring that you configure the web layer entirely yourself. This
disables Boot's own web setup. Implementing WebMvcConfigurer is enough. For a
single controller, @CrossOrigin on the controller class sets the same rule.
API versioning#
The shop's order endpoint changed its format, and old mobile apps still call the old endpoint. Spring Framework 7 added built-in API versioning Boot 4. On Boot 3 you write the versioning yourself, 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 method answers requests for version 1.1. Spring selects the method from the version that 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-VersionThis setting tells Spring to read the version from the
X-API-Versionheader. The version can also come from a query parameter, a path segment or a media type parameter. The otherusesettings select these.
Unless you set a default version, a request that sends no version is rejected with 400 Bad Request. A request for a version that no method supports is rejected with 400 Bad Request too.
Boot 4 also supports the version on the client side. RestClient,
WebClient and HTTP interface clients can all send a version. Tests can send a
version too, through WebTestClient and
MockMvc.
OpenAPI#
C# refresherSwashbuckle: OpenAPI for ASP.NET CoreC# 3
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. The UI is generated from your controllers, records and Bean Validation
annotations. You add annotations only when you want to say more than the code says:
@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")This is the one-line summary of the operation in the generated document.
@ApiResponse(responseCode = "404", description = "no such order")This annotation documents a response that the method can give in addition to the normal 200.
1TranslateWrite this action in Java, for a Spring controller.
orders.delete(id) returns true when the order existed:
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var found = await orders.DeleteAsync(id);
return found ? NoContent() : NotFound();
}
orders.delete(id) returns true when the order existed:
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var found = await orders.DeleteAsync(id);
return found ? NoContent() : NotFound();
}@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable long id) {
return orders.delete(id)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}noContent() and notFound() return builders, not responses.
build() finishes a response that has no body. ResponseEntity<Void>
is the return type for such a response.
2An expert asksA controller method has a long id parameter. Why does
GET /api/orders/abc get 400 from @GetMapping("/{id}"), but 404 from
@GetMapping("/{id:\\d+}")?
long id parameter. Why does
GET /api/orders/abc get 400 from @GetMapping("/{id}"), but 404 from
@GetMapping("/{id:\\d+}")?/{id} the path matches. Spring then fails to convert
abc to a long, so the response is 400. The part after the colon is a
regular expression. With \\d+ the path does not match at all. No method handles
the request, so the response is 404.Quick reference#
C# refresherA cookie and an uploaded file in an ASP.NET Core actionC# 6
ASP.NET Core has no binding attribute for a cookie, so the action reads the cookie from the
request. An uploaded file arrives as an IFormFile parameter.
[HttpGet("theme")]
public string Theme() => Request.Cookies["theme"] ?? "none";
[HttpPost("upload")]
public string Upload(IFormFile file, [FromForm] string note)
=> $"{file.FileName}: {file.Length} bytes, {note}";| ASP.NET Core | Spring | Note |
|---|---|---|
| [FromForm] | @RequestParam / @ModelAttribute | the same annotations bind the fields of submitted form data |
| [FromServices] | just take a constructor dependency | the container supplies it, through the controller's constructor |
| Request.Cookies["theme"] | @CookieValue | It binds the value of one cookie to a method parameter. ASP.NET Core has no binding attribute for a cookie. |
| IFormFile | @RequestPart | binds one part of a multipart request, such as an uploaded file, to a method parameter |
| ASP.NET Core | Spring | Note |
|---|---|---|
| ActionResult<OrderDto> | OrderDto, or ResponseEntity<OrderDto> | Spring has no IActionResult hierarchy, so return the value, or wrap it in a ResponseEntity |
| Ok(value) | ResponseEntity.ok(value), or just return the value | a returned value is written as JSON, with status 200 |
| NotFound() | ResponseEntity.notFound().build() | build() finishes a response that has no body |
| BadRequest(x) | ResponseEntity.badRequest().body(x) | answers 400, with x written as the JSON body |
| NoContent() | ResponseEntity.noContent().build() | answers 204 No Content, for example after a delete |
| Created(uri, x) | ResponseEntity.created(uri).body(x) | answers 201 Created, with a Location header for the new resource |
| StatusCode(418) | ResponseEntity.status(418).build() | sets any status code, by its number |
| File(...) | ResponseEntity with a Resource body | for a download, the body is the bytes as a Resource, and a Content-Disposition header names the file |
| Problem() | a ProblemDetail body | It is the standard JSON error body. See the chapter Validation and error responses. |
Validation and error responses#
Jakarta Validation is a standard, and it is also called Bean Validation. It does the same as
DataAnnotations in .NET. You put constraint annotations
on a record, and they are checked before your method runs.
Error bodies follow RFC 9457. ProblemDetails in ASP.NET Core follows the same
standard.
| Kind | C# | Java | Note |
|---|---|---|---|
| trap | [Required] | @NotNull | rejects null only, so an empty string still passes Why |
| twist | [Required] on a string | @NotBlank | rejects null, empty and whitespace-only strings Why |
| twist | [MinLength(1)] on a collection | @NotEmpty | rejects null and empty values, on a string or on a collection Why |
| twist | [StringLength(n)] | @Size(max = n) | sets a length limit, and on a collection or map it limits the size Why |
| twist | [Range(a, b)] | @Min(a) @Max(b) | or use @Range, which comes from Hibernate Validator Why |
| twist | [EmailAddress] | treats a missing value as valid, so add @NotBlank to require one Why | |
| twist | [RegularExpression(p)] | @Pattern(regexp = p) | the value must match the regular expression p Why |
| twist | [Compare] | no equivalent | Jakarta Validation has none, so write a class-level constraint Why |
| new | no equivalent | @Positive, @Negative, @PositiveOrZero | constraints on the sign of a number, which .NET lacks Why |
| new | no equivalent | @Past, @Future, @PastOrPresent | constraints on dates in the past or future, which work on java.time types Why |
| trap | a nested object is validated without being asked | @Valid on the nested field | without it, the constraints inside the nested object are never checked Why |
| twist | [CreditCard] | @CreditCardNumber | from Hibernate Validator, not the Jakarta standard set Why |
| trap | [ApiController] automatic 400 | @Valid on the parameter | without @Valid nothing is checked, and nothing warns you Why |
| twist | validation of a single query parameter | @RequestParam @Min(0) int page | checked with no @Valid, and fails with a different exception Why |
| twist | AddProblemDetails() | spring.mvc.problemdetails.enabled: true | Boot's RFC 9457 error bodies are switched off by default Why |
| twist | an errors member that lists each field | no field list in the default body | Spring's default body says only "Invalid request content." Why |
| twist | the errors member, filled in for you | override handleMethodArgumentNotValid | most teams add each failing field to the body themselves Why |
| twist | IExceptionFilter | @RestControllerAdvice with @ExceptionHandler | one class turns an exception into a response for every controller Why |
| trap | a second handler class for validation errors | handleMethodArgumentNotValid, overridden in the one advice class | the one advice class already handles this exception, and which of two classes answers depends on their names Why |
| twist | a FluentValidation validator class | an annotation plus a ConstraintValidator class | a custom constraint is then used like any built-in one Why |
Constraint annotationstwist#
// 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,The value must have at least one character that is not whitespace, and at most 50 characters. A component can have several constraints, like
[Required, StringLength(50)]in C#.@Min(1) @Max(100) int quantity,The value must be a number from 1 to 100, like
[Range(1, 100)]in C#.@Email String emailThe value must look like an email address. Like most constraints,
@Emailtreats a missing value as valid. To require a value, add@NotBlankas well.
Spring Boot checks these annotations with Hibernate Validator. Hibernate Validator is the
usual implementation of the standard. The differences table lists the DataAnnotations attribute
for each annotation. The table also lists constraints that .NET does not have, such as
@Positive and @Past.
@NotNull on a stringtrap#
public record CreateCustomer(@NotNull String name) { }Show the answer
@NotNull is not [Required] for strings.
@NotNull accepts the empty string "". So {"name": ""}
passes, and only {"name": null} is rejected. This is the most common validation
mistake.
Use @NotBlank for a string that must contain something other than whitespace.
Use @NotEmpty for a collection that must have elements.
Triggering validationtrap#
public record CreateOrder(
@NotBlank String customerId,
@Min(1) @Max(100) int quantity) { }
@PostMapping
public ResponseEntity<OrderDto> create(
@RequestBody CreateOrder request) {
...
}Show the answer
If you forget @Valid, validation is off for that parameter. The annotations are
still on the record, but nothing checks them, and there is no warning. If invalid input reaches
your code, first check whether @Valid is missing.
Write @Valid @RequestBody CreateOrder request. That one annotation rejects a bad
order before any of your code runs.
This is the same method with @Valid on its parameter:
import jakarta.validation.Valid;
@PostMapping
public ResponseEntity<OrderDto> create(
@Valid @RequestBody CreateOrder request) {
// runs only when the request passed every constraint
...
}@Valid tells Spring to check the CreateOrder after Spring reads it
from the body. If any constraint fails, Spring throws
MethodArgumentNotValidException. The client gets 400 Bad Request, and your method
never runs.
You can also put a constraint directly on a query parameter or a path variable. Spring
checks these constraints too, and they need 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 failed@Validraises a different exception, so an error handler should cover both exceptions.
Legacy@Validated on the controller class
Older code puts @Validated on the controller class to get this check. Tutorials
written before Spring Framework 6.1 do the same. Spring's documentation now says to remove
@Validated from the controller class. With @Validated, the check runs
through a proxy. Without @Validated, the check
runs through the validation that is built into
Spring MVC. Spring MVC is the model-view-controller (MVC)
web framework of Spring.
A nested object needs its own @Validtrap#
Each order line holds its price as a Money. Money is a record with
its own constraints:
public record Money(
@NotNull @PositiveOrZero BigDecimal amount,
@NotBlank String currency) { }
public record OrderLine(
@NotBlank String productId,
@Min(1) int quantity,
@Valid Money unitPrice) { }@Valid Money unitPrice) { }This annotation tells the validator to check the constraints inside the
Moneyas well.
Constraints inside a nested object are skipped unless its field carries
@Valid. ASP.NET Core checks a nested object without being asked. Jakarta
Validation checks a nested object only when you ask. So without
@Valid Money unitPrice, a line with a negative amount is accepted.
For a list, put @Valid on the element type, as in
List<@Valid OrderLine>.
ProblemDetailtwist#
RFC 9457 defines one shape for error bodies. This shape is called problem details.
ASP.NET Core has the type ProblemDetails for it. Spring has the type
ProblemDetail.
C# refresherProblemDetails in ASP.NET CoreC# 10
builder.Services.AddProblemDetails(); // RFC 9457 bodies for unhandled errors
app.MapGet("/orders/{id}", (int id) =>
Results.Problem(title: "Order not found", statusCode: 404));In Spring Boot, problem details are switched off by default. One setting switches them on:
# application.yml
spring:
mvc:
problemdetails:
enabled: trueenabled: trueBoot then registers a handler. The handler is a subclass of Spring's
ResponseEntityExceptionHandler. It turns Spring MVC's own exceptions into problem details. A failed@Validis one of these exceptions.
This is the body that ASP.NET Core returns for a failed validation:
{
"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"
}The errors member of this body names each field. The body from Spring Boot
names no field. Boot 3 writes a type member, and Boot 4 leaves it out. The two
versions also write the members in different orders:
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/orders"
}{
"detail": "Invalid request content.",
"instance": "/api/orders",
"status": 400,
"title": "Bad Request"
}"detail": "Invalid request content.",This text names no field. To add the fields, see Returning field-level errors.
"type": "about:blank",This is the default type. It means that the status code alone describes the problem.
Global exception handlingtwist#
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);This is the exception's message. It becomes the
detailof the response.
One class turns this exception into a 404 with a problem details body. The class does this for every controller:
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;
}
}@RestControllerAdviceThis annotation applies the handlers in this class to every controller. Such a class is called a controller advice.
extends ResponseEntityExceptionHandler {Your class inherits the problem details that Spring builds for its own exceptions. Your class is now the
ResponseEntityExceptionHandler, so Boot does not register its own handler.problem.setProperty("orderId", e.orderId());This line adds your own member to the body.
This is the response for order 99. The members come in a different order on each version:
{
"type": "https://acme.com/errors/not-found",
"title": "Order not found",
"status": 404,
"detail": "No order with id 99",
"instance": "/api/orders/99",
"orderId": 99
}{
"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",This is a web address that names the kind of problem. So a client does not need to read the text.
These are the pieces that 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#
When validation fails, the default response of ASP.NET Core names the fields that failed.
The default response of Spring does not name them. Most teams add the fields. To do this, they
override 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 this method replaces the default body..collect(Collectors.toMap(This call builds a map from each field's name to its message. For more about
toMap, see the chapter Streams vs LINQ.(first, second) -> first));This argument keeps the first message when one field has two errors.
ProblemDetail problem = e.getBody();These are the problem details that Spring had already built for this exception. The method adds to them.
The response now names each field that failed. As before, Boot 3 adds the default
type, and the two versions write the members in different orders:
{
"type": "about:blank",
"title": "Validation failed",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/orders",
"errors": {
"quantity": "must be greater than or equal to 1",
"customerId": "must not be blank",
"email": "must be a well-formed email address"
}
}{
"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. To replace a default message, set the
messageattribute of the constraint, as in@NotBlank(message = "customer is required").
Collectors.toMap throws on duplicate keys. Two constraint violations on the same
field produce a duplicate key. So you must use the three-argument form with a merge function,
as this method does.
A failed constraint on a query parameter goes to another method of the same class,
handleHandlerMethodValidationException. You override this method in the same
way.
One advice class, not twotrap#
It is natural to write a separate handler class for validation errors, next to
ApiExceptionHandler:
@RestControllerAdvice
class ValidationAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handleInvalid(MethodArgumentNotValidException e) { ... }
}@ExceptionHandler(MethodArgumentNotValidException.class)This method handles a failed
@Validon a request body, in a separate class.
Do not handle the exception in a second advice class.
ApiExceptionHandler already handles this exception, through the
handleMethodArgumentNotValid method that it inherits. These two classes have no
@Order, so the names of the classes decide which class answers. On Boot 4.1,
ValidationAdvice never answered. The same class with the name
AaaValidationAdvice always answered.
An application with no such subclass has the same problem when problem details are switched
on. Boot's own handler is registered at @Order(0). It answered first, whatever the
name of the second class was.
Override handleMethodArgumentNotValid in the one class. For an example, see
Returning field-level errors.
Custom constraints#
The shop accepts prices only in currencies that exist, and no built-in constraint knows the list of currencies. For a rule like this, Jakarta Validation does what FluentValidation does in .NET. A custom constraint is an annotation, plus a class that does the check.
C# refresherFluentValidation: rules in a validator classC# 3
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})This line says where the annotation may be used. It may be used on fields, and on record components such as
currency.@Constraint(validatedBy = CurrencyValidator.class)This line connects the annotation to the class that does the check.
public @interface ValidCurrency {@interfacedeclares a new annotation type. See the annotations chapter.String message() default "unknown currency";This is the message that is used when the check fails. The other two members are
groupsandpayload. Every constraint annotation must have them. Copy them as they are.return value == null || Currency.getAvailableCurrencies()A missing value counts as valid, as it does with the built-in constraints. Otherwise the value must be a real currency code, such as
EUR. The valueABCis rejected.
In .NET, [Compare] checks a rule that compares two fields. In Jakarta
Validation, such a rule is a constraint on the whole class. Give the annotation
@Target(ElementType.TYPE) and put the annotation on the record. Then make the
record the type that the validator checks, as in
ConstraintValidator<SameEmail, Signup>. The isValid method then
receives the whole Signup and can compare two of its components.
1TranslateWrite this exception filter as a Spring class.
OrderNotFoundException is the shop's own exception:
public class ApiExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext ctx)
{
if (ctx.Exception is OrderNotFoundException e)
{
ctx.Result = new ObjectResult(new ProblemDetails
{
Status = 404,
Title = "Order not found",
Detail = e.Message
}) { StatusCode = 404 };
}
}
}
OrderNotFoundException is the shop's own exception:
public class ApiExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext ctx)
{
if (ctx.Exception is OrderNotFoundException e)
{
ctx.Result = new ObjectResult(new ProblemDetails
{
Status = 404,
Title = "Order not found",
Detail = e.Message
}) { StatusCode = 404 };
}
}
}@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");
return problem;
}
}extends ResponseEntityExceptionHandler is the piece that is most easily
forgotten. It makes this class the one handler for Spring's own exceptions too. So Boot does not
register a second handler next to this class.
2An expert asksWhy must an error handler cover two different exceptions for failed
validation?
@Valid on a request body raises
MethodArgumentNotValidException. A constraint directly on a query parameter or
path variable needs no @Valid. The validation built into Spring MVC checks it, and
a failure raises HandlerMethodValidationException.Data access, JPA and migrations#
Spring Data JPA does the same as EF Core. It maps classes to tables and runs the queries. It is built on Jakarta Persistence (JPA). JPA is the Java standard for an object-relational mapper (ORM). A separate tool runs the migrations, either Flyway or Liquibase.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | ADO.NET | JDBC | the low-level API that sends SQL and reads rows Why |
| twist | EF Core | Spring Data JPA, on Hibernate | Hibernate implements the JPA standard, and Spring Data writes the repositories Why |
| twist | OnModelCreating | annotations, or orm.xml | the mapping is written on the entity class itself Why |
| twist | DbContext | EntityManager | tracks loaded entities and writes their changes on commit Why |
| trap | SaveChanges | no call, because Hibernate writes on commit, which it calls a flush | in a @Transactional method a change to a loaded entity is written with no call to save Why |
| twist | Include() | @EntityGraph, or JOIN FETCH | loads an association in the same query as its owner Why |
| twist | AsNoTracking() | a read-only transaction, or a projection | Hibernate then skips watching loaded objects for changes Why |
| twist | FromSqlRaw | @Query(nativeQuery = true) | plain SQL for your database, with named parameters Why |
| twist | [Table], [Column] | @Entity, @Table, @Column | annotations map the class to a table and its fields to columns Why |
| twist | an entity class | a class, never a record | JPA creates the object empty, then fills in its fields, and a record has no empty constructor Why |
| trap | an enum property | @Enumerated(EnumType.STRING) | the default stores the position, so a reordered enum changes old rows Why |
| twist | Equals and GetHashCode | instanceof, the id, a constant hashCode | Compare by id only. The id is null before saving, and Hibernate gives you proxies. Why |
| new | DbSet<T> | a Spring Data repository | an interface whose method names, such as findByCustomerId, are the queries Why |
| trap | a loop that reads order.Lines | join fetch, @EntityGraph or a projection | collections are lazy by default, so the loop runs one query per order, which is called the N+1 problem Why |
| trap | a disposed DbContext | LazyInitializationException | a lazy field cannot be loaded once the transaction has ended Why |
| trap | order.Customer | @ManyToOne(fetch = FetchType.LAZY) | @ManyToOne and @OneToOne are EAGER unless you say LAZY Why |
| twist | BeginTransaction and Commit | @Transactional on the service method | commits on return, and rolls back on an unchecked exception Why |
| twist | Migrations | Flyway or Liquibase | these are separate tools, and the ORM does not own the schema Why |
| twist | a Down() method | a new SQL file, never an edit | Flyway only goes forward, and a changed file stops the startup Why |
| trap | Database.EnsureCreated(), called in your code | spring.jpa.hibernate.ddl-auto=validate | It is a setting, not a call. The value validate only checks the mapping, and update would alter your schema. Why |
| twist | Dapper | JdbcClient, JdbcTemplate, JDBI | runs your SQL and maps each row to a record Why |
| twist | LINQ to SQL | jOOQ | generates typed Java from your real schema, so renames break the build Why |
If your data is in Amazon DocumentDB, read these three sections of this chapter:
For the comparison with these three sections, see the chapter Document data: Amazon DocumentDB and the MongoDB API.
The landscapetwist#
EF Core is one product. Java has three layers in its place. You will see the name of each layer in stack traces:
- Java Database Connectivity (JDBC) does the same as ADO.NET. It is the low-level API that sends SQL and reads rows.
- Jakarta Persistence (JPA) is a standard for an object-relational mapper (ORM). The standard defines the annotations and interfaces. Hibernate is the implementation that Spring Boot uses. So JPA is the contract, and Hibernate does the work.
- Spring Data JPA is the top layer. It writes your repository classes for you.
EF Core configures the model in OnModelCreating. JPA uses annotations on the
entity classes themselves:
C# refresherOnModelCreating: configuring the model in codeC# 3
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Order>().ToTable("orders");
b.Entity<Order>().Property(o => o.CustomerId).IsRequired();
}@Entity
@Table(name = "orders")
public class Order {
@Column(nullable = false)
private String customerId;
}@Table does the same as ToTable in EF Core.
@Column(nullable = false) does the same as IsRequired.
Everyday EF Core callstwist#
// 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")This annotation loads the order's lines in the same query, like
Include()in EF Core. Without it, the lines load later, one query at a time.Optional<Order> findWithLinesById(Long id);This is a method in a repository interface. Spring Data reads the name and writes the query for you. To learn how, see Repositories.
nativeQuery = true)This query is plain SQL for your database, like
FromSqlRawin EF Core.:minis a named parameter. Its value comes from the argument marked@Param("min").@Transactional(readOnly = true)This annotation runs the method in a read-only transaction. Hibernate then does not watch the loaded objects for changes. This is the nearest thing to
AsNoTracking().orders.save(newOrder);This call adds the order, like
Add()in EF Core. Hibernate sends the insert when it needs to, and at the latest when the transaction commits. In EF Core,SaveChangeswrites pending changes in the same way.
An entitytwist#
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") // ORDER is a reserved word in SQL
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
// getters such as getLines(), and methods such as cancel(), are left out
}@EntityThis annotation marks a class that Hibernate maps to a table. Such a class is called an entity. Each object is one row.
@GeneratedValue(strategy = GenerationType.IDENTITY)The database generates the id. EF Core does the same by default for a numeric key.
@Column(nullable = false)This annotation is like
[Required]in C#. A field without@Columnstill maps to a column. Boot's default naming turnscustomerIdintocustomer_id. In the same way,createdAtbecomescreated_at.@Enumerated(EnumType.STRING)This annotation stores the enum by its name, such as
PAID. The default is a trap.@OneToMany(mappedBy = "order", cascade = CascadeType.ALL,An order has many lines.
mappedBynames theOrderLinefield that holds the foreign key. Because ofcascadeandorphanRemoval, saving or removing the order also saves or removes its lines.protected Order() { }JPA creates an object through this constructor, and then fills in the fields.
protectedstops your code from calling the constructor.
Two entity rules: no records, and enum columns by nametrap#
Entities cannot be records. JPA needs a no-argument constructor and fields that can change, so that it can create an object and then fill in its fields. Hibernate also generates subclasses of entities for lazy loading. These subclasses are called proxies. A record is final, and its fields cannot change. Use a class for entities. Use a record for the data transfer object (DTO) that you expose.
@Enumerated defaults to ORDINAL, which stores the enum's
declaration position as an integer. If you later insert or reorder a constant,
every stored row means something different, and nothing warns you. There is no error and no
migration. The data also cannot tell you which mapping was used 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 danger as storing ordinal(), but here the unsafe option is the
default. See the chapter Enums. Always write
EnumType.STRING. If you want short values, map an explicit code column with an
AttributeConverter instead.
equals and hashCode on entities#
The general contract for equals and hashCode applies to entities
too. See the chapter Equality, hashing and comparison. But the usual
advice fails for entities in three ways. The third way involves
Lombok. Lombok is a library that generates code such as
equals during compilation:
| Problem | Why |
|---|---|
| The id is null before persist | an entity that is put in a HashSet before it is saved gets a new hash when the id is assigned, so the set cannot find it |
| Hibernate hands you proxies | a lazy association is an object of a generated subclass, so a comparison with getClass() fails |
| Lombok @Data on an entity | it generates equals over every field, so equals triggers lazy loading and can recurse through both sides of an association |
Here are an equals and a hashCode for the Order
entity that avoid all three problems:
@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 a proxy. A comparison withgetClass()would not accept it.return id != null && id.equals(other.id);Two unsaved orders are never equal. Two saved orders are equal when their ids match.
return getClass().hashCode();This line returns the same hash for every order. So a set can still find an order after the order gets its id.
A constant hashCode looks wrong, but it is deliberate. With a constant hash, a
HashSet can still find the object after the object changes from unsaved to saved.
A constant hash makes a hash set of entities as slow as a linear scan. This is acceptable,
because entity collections are small. The alternative is to hash the id. But hashing the id
breaks the collection at the moment when 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. An equals over every field is the most common cause of unexpected
lazy loading. It is also the most common cause of a StackOverflowError from two
entities that refer to each other.
Repositoriesnew#
The billing service needs orders by customer, orders by customer and status, and orders above an amount. EF Core builds such 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. You declare an interface, and Spring writes the class at startup:
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> {This is 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);This is called a derived query. Spring Data reads the parts of the method name.
findBystarts the query.CustomerIdnames the field. The parameter gives 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")This is a query that you write yourself. The query names the entity
Orderand its fieldlines, not the tables.join fetchloads the lines in the same query.
That query string is written in the Jakarta Persistence Query Language (JPQL). JPQL looks like SQL, but it works on entities and their fields. Hibernate accepts a larger language of its own, which is called the Hibernate Query Language (HQL). JPQL is a subset of HQL. Here are the keywords that the name of a derived query 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 and delete. It
also gives you findById, findAll, paging and sorting. You declare only
the queries that are specific to your domain.
Counting the queries in a looptrap#
// orders is the OrderRepository; a report counts the lines of every order
int lines = 0;
for (var order : orders.findAll()) {
lines += order.getLines().size();
}Show the answer
join fetch in the query, or @EntityGraph on the repository
method, makes it one.Collections load lazily by default. The lines of an order load only when
code first uses them. findAll runs one query, and
order.getLines().size() runs one more query for each order. The problem is worse
than in EF Core, because lazy loading is the default for collections. Also, there is
no Include() that you add out of habit.
Load the lines in the same query as the orders. To do this, write
join fetch o.lines in a @Query, or put
@EntityGraph(attributePaths = "lines") on the method.
This is called the N+1 problem. The code runs one query for the list, and then one more query for each of the N rows in the list. 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")This one query loads every order with its lines.
@EntityGraph(attributePaths = "lines")This annotation asks for the lines too. It works on a derived query, so you write no JPQL.
List<OrderSummary> findSummariesByStatus(OrderStatus status);This method returns only the id and the total, through an interface of getters. No entity is loaded, so no lazy collection can run a query later. The word between
findandByis only a label.
Counting queries in developmentdeep dive
In development, set spring.jpa.properties.hibernate.generate_statistics=true.
Or add the datasource-proxy library or the Hypersistence Utils library. An N+1
problem then appears as a count of queries, and not as slow pages in production.
Lazy loading and detached entitiestrap#
Inside a transaction, Hibernate keeps every entity that it loads in a persistence
context. In EF Core, the DbContext tracks what it loaded in the same way. A
lazy field loads only when it is first used. The defaults decide which fields are
lazy:
| Association | JPA default | Advice |
|---|---|---|
| @OneToMany | LAZY | keep it lazy, and fetch explicitly |
| @ManyToMany | LAZY | keep it lazy |
| @ManyToOne | EAGER | set it to LAZY explicitly |
| @OneToOne | EAGER | set it to LAZY explicitly |
// application.yml sets spring.jpa.open-in-view to false
// in the Order entity, which here holds its customer as an association
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
// in OrderService
@Transactional(readOnly = true)
public Order load(Long id) {
return orders.findById(id).orElseThrow();
}
// in OrderController
Order order = service.load(id);
String name = order.getCustomer().getName();Show the answer
LazyInitializationException.Many orders share one customer, and the customer is lazy here. So nothing loaded the
customer inside load. When load returned, its transaction ended and
the persistence context closed. So Hibernate can no longer fetch the customer. EF Core behaves
in the same way when the DbContext is disposed. With JPA, this happens far more
often. The reason is that collections are lazy by default, and the usual advice is to make
every other association lazy too.
Fetch what you need inside the transaction, or map the entity to a DTO inside the transaction.
When a transaction ends, its persistence context closes. The entities that the persistence context held are then called detached.
Do not use spring.jpa.open-in-view to avoid this exception. Boot
switches this setting on by default in web applications. The setting keeps the persistence
context open for the whole request, so the exception does not happen. But the setting also
hides N+1 queries, and it holds database connections longer than necessary. Most experienced
teams set it to false, and then fix the code that causes each exception.
A change is saved with no call to savetrap#
To cancel an order, the code loads the order and changes its status. In EF Core, a third line writes the change. In Spring, that third line does not exist:
// db is your DbContext
var order = await db.Orders.FindAsync(orderId);
order!.Cancel();
await db.SaveChangesAsync();// in OrderService; orders is the OrderRepository
@Transactional
public void cancel(long orderId) {
var order = orders.findById(orderId).orElseThrow();
order.cancel();
// no call to save
}order.cancel();This call sets the order's status to
CANCELLED. Hibernate watches every entity that it loads in the transaction. It sends the update when the transaction commits.// no call to saveNothing is missing here. The
EntityManagerof JPA does the tracking. It does the same as theDbContextin EF Core. The repository uses anEntityManagerinternally, so you rarely see one.
Inside @Transactional, every change to a loaded entity is written on
commit. There is no SaveChanges call that you could leave out. So a field
that you changed only for a calculation is saved too. Without @Transactional on
the method, the same change is lost, and nothing warns you. To add a new entity, call
save, like Add() in EF Core.
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. It commits the transaction when the method returns. It rolls the transaction back when the method throws an unchecked exception.
@Transactional works through a proxy around
the bean. This has two consequences that surprise
everyone:
- Self-invocation does not
work. A call to
this.other()from inside the same class does not go through the proxy, so@Transactionalonother()has no effect. For the reason, see the chapter How Spring actually works. For propagation and isolation, see the chapter Transactions, propagation and isolation. - Only unchecked exceptions roll back by default. When the method throws a
checked exception, the transaction commits. If
you throw checked exceptions, use
@Transactional(rollbackFor = Exception.class). Or throw an unchecked exception instead.
Also put @Transactional on the service, not on the repository, because the use
case is the boundary of the transaction.
Migrationstwist#
With Flyway you write the SQL yourself, in a numbered file. Flyway runs each file once, in
order, and records it in the flyway_schema_history table:
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 name of the file identifies the migration.
V3is the version, and the words after the two underscores describe the migration. Boot looks indb/migrationby default.ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'NEW';This is plain SQL. Existing orders get the value
NEW.-- applied automatically at startupBoot runs new migrations before it serves requests. Boot 3 needs
flyway-corefor that. Boot 4 needsspring-boot-starter-flyway.spring-boot-starter-data-jpadoes not include it.
| 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, so write a new migration |
| Baseline an existing DB | possible, fiddly | flyway.baselineOnMigrate |
| Checksum enforcement | no | yes, editing an applied migration stops the startup |
Flyway checksums every applied migration. If you edit a file that has already run in any environment, startup fails. This is true even if you only add a comment. The fix for a mistake is always a new migration, never an edit.
Two Flyway startup errors#
The checksum is a feature. It guarantees that every environment ran identical SQL. An edited file stops the application with this error:
Migration checksum mismatch for migration version 3#V4. Run
flyway repair only when the edit is harmless, such as a comment. This command
stores 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 that has tables but no Flyway history fails too. It fails the first time that 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 version 1.What triggers it
-- the database already has an orders table, and db/migration holds V1 and V2ddl-auto: who changes the schematrap#
Hibernate can build or alter tables from your entity classes at startup. In EF Core, a call
to Database.EnsureCreated() builds the tables. One setting says whether Hibernate
may do this:
# application.yml
spring:
jpa:
hibernate:
ddl-auto: validateddl-auto: validateWith this value, Hibernate only checks that the mapping agrees with the schema. Hibernate creates nothing and alters nothing, so only your migrations change the schema.
Set spring.jpa.hibernate.ddl-auto=validate in every environment. Hibernate then
only checks that the mapping agrees with the schema that Flyway built. Boot's default is
none for a real database. For an embedded database such as H2, with no Flyway
present, the default is create-drop. This value builds the schema at startup and
drops the schema at shutdown. Tutorials often set update, which lets Hibernate
alter your schema. This is convenient in a demo and catastrophic in production.
When not to use JPA#
JPA is a good choice for an object model that you load, change and save. Other tools are a better choice for other jobs:
| 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, which has a much simpler model |
| Bulk operations | native SQL, because JPA is poor at bulk operations |
| Read-heavy projections | interface or record projections |
For code in the style of Dapper, use Spring's JdbcClient. It runs your SQL and
maps each row to a record. It has 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. Boot creates it for you. You write the SQL yourself, with a named parameter..query(OrderRow.class)This call maps each row to an
OrderRow. The columncustomer_idfills the componentcustomerId.
Spring Data JDBC is between JdbcClient and Spring Data JPA. It has
repositories, as Spring Data JPA has. But it has no lazy loading and no proxies. An aggregate
is loaded and saved as a 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) { }Spring Data JDBC can map a record, and JPA cannot. The reason is that Spring Data JDBC never needs to make a subclass of the type. It also never needs to fill in an object after creating it.
Look at jOOQ if you miss LINQ-to-SQL. It generates a typed
domain-specific language (DSL) from your real database
schema. So the compiler checks DSL.selectFrom(ORDERS).where(ORDERS.TOTAL.gt(x)),
and a renamed column breaks the build. In Java, jOOQ is the closest thing to what EF Core gives
you. The code that you write with jOOQ is clearly SQL, and it does not pretend to be
objects.
1TranslateWrite this in Java, as a method of a Spring service. customers
is a Spring Data repository of Customer entities:
var customer = await db.Customers.FindAsync(customerId);
customer!.Email = newEmail;
await db.SaveChangesAsync();
customers
is a Spring Data repository of Customer entities:
var customer = await db.Customers.FindAsync(customerId);
customer!.Email = newEmail;
await db.SaveChangesAsync();@Transactional
public void changeEmail(long customerId, String newEmail) {
var customer = customers.findById(customerId).orElseThrow();
customer.setEmail(newEmail);
}There is no call to save. Hibernate writes a change to a loaded entity when the
transaction commits. If you leave out @Transactional, the change is lost.
2An expert asksBoot switches spring.jpa.open-in-view on by default, and it makes
LazyInitializationException go away. Why do most experienced teams switch it
off?
spring.jpa.open-in-view on by default, and it makes
LazyInitializationException go away. Why do most experienced teams switch it
off?Quick reference#
| 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 wraps a method in a database transaction. It looks like a
checkbox, but it is a policy. It decides which exceptions roll back, and what happens when a
transaction is already running. It also decides how much one transaction sees of the work of
another transaction.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | BeginTransactionAsync, CommitAsync, RollbackAsync | @Transactional on the service method | commits on return, and rolls back on an unchecked exception Why |
| trap | session.StartTransaction() on a MongoDB session | a MongoTransactionManager bean | on a document database @Transactional does nothing until you declare a transaction manager Why |
| trap | catch { await tx.RollbackAsync(); throw; } | rollbackFor = Exception.class | by default a checked exception commits, and only an unchecked exception rolls back Why |
| trap | a call to a method of the same class | a call through another bean | @Transactional starts no transaction on a self-call, a private method or a final method Why |
| twist | TransactionScopeOption | @Transactional(propagation = ...) | It says what the method does when a transaction is already running. Spring has seven modes, and .NET has three. Why |
| twist | TransactionScopeOption.RequiresNew | Propagation.REQUIRES_NEW | a separate transaction, which commits even when the caller rolls back Why |
| trap | a second transaction on the caller's rows | REQUIRES_NEW for independent writes only | it waits for a lock that the caller holds, and it waits forever if no lock timeout is set Why |
| twist | CreateSavepoint | Propagation.NESTED | a savepoint inside the same transaction, not a second transaction Why |
| twist | IsolationLevel.ReadCommitted | Isolation.READ_COMMITTED | the level names are the same, and Isolation.DEFAULT uses the default level of the database Why |
| twist | raising the isolation level | @Version on the entity | optimistic locking turns a lost update into an exception Why |
| twist | sending a message after Commit succeeds | @TransactionalEventListener | runs after the commit, so a rolled-back change sends nothing Why |
The defaulttwist#
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, and commits the transaction when the method returns. If an unchecked exception leaves the method, Spring rolls the transaction back. Spring does all of this through a proxy around the service.
orders.save(order);Every database call that is made while the method runs joins the same transaction. This includes calls inside other beans.
Put @Transactional on the service, not on the repository. The
transaction boundary is the use case. Everything inside one business operation should commit
together or fail together.
readOnly = true is more than a hintdeep dive
The rollback ruletrap#
// AccountClosed extends Exception, not RuntimeException
@Transactional
public void transfer(Account from, Account to, BigDecimal amount)
throws AccountClosed {
from.debit(amount);
if (to.isClosed()) throw new AccountClosed();
}Show the answer
A checked exception
commits. Spring rolls back for RuntimeException and Error
only. AccountClosed is a checked exception, so Spring commits the transaction when
the exception passes. So the money leaves one account and arrives nowhere.
Tell Spring to roll back for every exception, checked or unchecked:
@Transactional(rollbackFor = Exception.class)
public void transfer(Account from, Account to, BigDecimal amount)
throws AccountClosed { ... }In Spring, this behaviour is called the rollback rule. An unchecked exception rolls
back, and a checked exception commits. C# has no checked exceptions, so a .NET developer has no
habit at all for this rule. Set rollbackFor, or convert checked exceptions to
unchecked exceptions at the boundary.
The proxy trap, againtrap#
@Transactional is applied by a proxy. So no transaction starts in three
cases:
- the method is called from inside the same class
- the method is
private - the method is
final
A final method also runs on the proxy object, and the injected fields of the
proxy object are null. For the mechanism, see the chapter
How Spring actually works.
The usual case is a batch method that calls a transactional method of the same class. This is called self-invocation. For the code, see the chapter How Spring actually works. That chapter also asks you to predict the result of the code. A private method is the same trap in another form:
@Service
public class OrderService {
public void place(CreateOrder request) {
reserveStock(request);
}
@Transactional
private void reserveStock(CreateOrder request) { ... }
}private void reserveStock(CreateOrder request) { ... }Only
OrderServicecan call this method. So every call skips the proxy, and no transaction starts.
A document database needs a transaction managertrap#
On a document database, @Transactional does nothing until you declare a
transaction manager. Boot sets up a transaction manager for a relational database.
For MongoDB, and so for Amazon DocumentDB, Boot sets up no transaction manager, on Boot 3 or
Boot 4. The method runs, and a failure rolls nothing back. For what happens without a
transaction manager, see the chapter Document data: Amazon DocumentDB and the MongoDB API.
The transaction manager for MongoDB is a MongoTransactionManager. One
@Bean method declares it. For the method, see the chapter
Document data: Amazon DocumentDB and the MongoDB API. When the transaction manager is declared, the rollback
rule and the proxy trap of this chapter apply to a document database too.
Propagationtwist#
Propagation answers one question. What should happen if a transaction is already running when this method is called? Think of a meeting that is already in progress. You can join the meeting, book a separate room, or refuse to start when no meeting is in progress:
| Mode | If a transaction exists | If none exists | Use for |
|---|---|---|---|
| REQUIRED | join it | start one | the default, and 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 audit trail must still show the attempt. So the audit service runs in its own transaction:
@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 // REQUIREDREQUIREDis the default mode. It starts a transaction, or joins the transaction that is already running.audit.record("order placed");This is a call to another bean, so the call goes through the proxy of that bean.
throw new PaymentFailed();PaymentFailedis an unchecked exception, so the transaction of the order rolls back.@Transactional(propagation = Propagation.REQUIRES_NEW)This mode suspends the transaction of the caller, and runs
recordin a new transaction. The new transaction commits whenrecordreturns. So the audit row stays, although the order is rolled back.
REQUIRES_NEW on the caller's own rowstrap#
The method below ships an order. It updates the row of the order, and then adds a note to the same row through a second bean:
@Transactional
public void ship(long orderId) {
orders.markShipped(orderId); // updates the order's row, and so locks it
notes.add(orderId, "shipped");
}
@Service
class ShippingNotes {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void add(long orderId, String note) {
orders.addNote(orderId, note); // updates the same row
}
}orders.addNote(orderId, note);This line needs the lock that
shipalready holds on the same row.
REQUIRES_NEW can deadlock against its own caller. The outer
transaction is suspended, but it still holds its locks. Suppose the inner transaction writes a
row that the outer transaction has already written. Then the inner transaction waits for a lock
on that row. The outer transaction will not release the lock until the inner transaction
returns.
The database cannot see this as a deadlock, because the outer transaction is only idle. So if
you have set a lock timeout, the thread waits until the timeout is reached. On PostgreSQL the
error is “canceling statement due to lock timeout”. If you have set no lock
timeout, the thread waits forever. Use REQUIRES_NEW for independent writes, such
as audit trails, outbox rows and failure logs. Never use it for the rows that the caller is
changing.
REQUIRES_NEW and NESTED: connections and savepoints#
REQUIRES_NEW takes a second connection from the pool. With a
pool of ten connections and a nested call, only five operations can run at the same time, not
ten. Suppose you choose the size of the pool without counting the second connection. Then the
pool has no free connection at a load that your calculation did not predict. With a pool of one
connection, 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 a connection
until the timeout of the pool.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
transaction. A rollback to the savepoint undoes only the inner work, but a rollback of the outer
transaction still discards everything. NESTED works with Spring's
Java Database Connectivity (JDBC) transaction manager. The
Jakarta Persistence (JPA) transaction manager allows
NESTED only after you switch on its nestedTransactionAllowed flag.
Even then, the savepoint does not undo changes to entities that are already in memory.
Isolation#
Two customers buy the last item at the same moment. Does each transaction see the writes of the other transaction? The isolation level of the transaction decides this. 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, but a transaction may abort | Serializable |
| DEFAULT | whatever the database default is | depends on the database | Unspecified |
Each level is defined by the anomalies that 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 uses the default level of the database, and the databases have
different defaults. PostgreSQL and Oracle default to READ_COMMITTED. MySQL
InnoDB defaults to REPEATABLE_READ. SQL Server defaults to
READ_COMMITTED. So code that is correct on MySQL can be wrong on PostgreSQL in
a way that is hard to see. If a business rule depends on isolation, set the isolation level
explicitly. Do not inherit it from the database.
Prefer optimistic locking to raising the isolation level. Put a @Version field
on the entity.
Hibernate then checks that the row has not changed
since you read it. Optimistic locking scales far better than SERIALIZABLE, and it
makes the conflict explicit.
Here the order has 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 aStaleObjectStateException. Spring converts this exception to anObjectOptimisticLockingFailureException. That class is a subclass ofOptimisticLockingFailureException.
Doing something after the commit#
Sending a message from inside a transaction is a classic bug. The message is sent, and then the transaction rolls back. A downstream system now believes something that never happened. The fix is to send the message 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()));This line publishes an application event.
eventsis anApplicationEventPublisher, which is a Spring type. It is injected like any other bean.@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)Spring holds the event until the publishing transaction commits, and then calls the method. If the transaction rolls back, the method never runs.
An event that is 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, so it can still fail the transaction |
| AFTER_COMMIT | after a successful commit, and this is the default |
| AFTER_ROLLBACK | only if it rolled back |
| AFTER_COMPLETION | either way |
For guaranteed delivery this is still not enough, because the process can stop between the commit and the send. The robust pattern is called the transactional outbox. Write the message to a table in the same transaction, and let a separate poller publish the message. This is the same solution as in .NET.
1TranslateThis C# rolls back whatever is thrown. Write it in Java, as a method of a
Spring service, so that a checked exception rolls back too. orders is the
OrderRepository:
await using var tx = await db.Database.BeginTransactionAsync();
try
{
db.Orders.Add(order);
await db.SaveChangesAsync();
await tx.CommitAsync();
}
catch
{
await tx.RollbackAsync();
throw;
}
orders is the
OrderRepository:
await using var tx = await db.Database.BeginTransactionAsync();
try
{
db.Orders.Add(order);
await db.SaveChangesAsync();
await tx.CommitAsync();
}
catch
{
await tx.RollbackAsync();
throw;
}@Transactional(rollbackFor = Exception.class)
public Order place(CreateOrder request) {
var order = new Order(request);
orders.save(order);
return order;
}Plain @Transactional matches the catch block for unchecked
exceptions only. rollbackFor = Exception.class makes a checked exception roll back
as well.
2Two users save the same order, and one change is lost with no error. What do you add to the
entity, and what happens then?
@Version. Hibernate then checks that the row has not
changed since it was read. So the second save fails with an
OptimisticLockingFailureException. Without the field, the second save overwrote the
first save. This is called optimistic locking, and it is preferred to raising the isolation
level.3An expert asksAn audit row must survive the caller's rollback, so its method is
REQUIRES_NEW. Why can that method hang when it writes a row the caller has already
written?
REQUIRES_NEW. Why can that method hang when it writes a row the caller has already
written?REQUIRES_NEW suspends the transaction of the caller and starts a separate
transaction. But the suspended transaction still holds its locks. The inner transaction waits
for a lock that the caller releases only after the inner transaction returns. The database sees
an idle transaction, not a deadlock. So the wait ends only at a lock timeout, if one is
set.Quick reference#
When a transaction does not behave as you expect, the symptom usually points to one of these causes:
| Symptom | Likely cause |
|---|---|
| Annotation seems ignored | self-invocation, private method, or final method |
| Data committed despite an exception | it was a checked exception, so set rollbackFor |
| A request hangs, or ends in a lock timeout | REQUIRES_NEW writing a row the caller has already written |
| 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 | there is no @Version, so add optimistic locking |
| Message sent for a rolled-back change | publish in AFTER_COMMIT, or use an outbox |
| Setting | Effect |
|---|---|
| rollbackFor = Exception.class | roll back for checked exceptions too |
| noRollbackFor = NotFound.class | commit even though this was thrown |
| readOnly = true | writes fail on PostgreSQL, and Hibernate does not check 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 |
Document data: Amazon DocumentDB and the MongoDB API#
Amazon DocumentDB is a database from Amazon Web Services (AWS). It stores an order as one document, with the lines of the order inside. It offers the MongoDB API, so the Java code uses Spring Data MongoDB. This chapter does not cover Azure DocumentDB or the open-source DocumentDB.
| Kind | MongoDB .NET driver | Spring Data MongoDB | Note |
|---|---|---|---|
| twist | GetCollection<Order>("orders") and [BsonId] on a property | @Document("orders") and @Id | the class is mapped by Spring Data's own annotations, not by the JPA annotations Why |
| twist | [BsonRepresentation(BsonType.ObjectId)] string Id | @Id String id | a String id is generated, but a long id like the id of the relational chapters is not generated Why |
| trap | InsertOne sets order.Id | Order saved = orders.save(order) | a document can be a record, and then only the returned copy has the id Why |
| twist | IMongoCollection<Order> | MongoRepository<Order, String> | Spring Data implements this interface. String is the type of the id. Why |
| twist | Builders<Order>.Filter.Eq(o => o.Customer.Id, id) | findByCustomerId(id) | the method name is the query, and it can refer to a field of a nested object Why |
| twist | ReplaceOneAsync(o => o.Id == id, order) | orders.save(order) | nothing tracks a loaded document, so unlike a JPA entity, a change is written only by save Why |
| twist | UpdateOne with Builders<Order>.Update.Set | MongoTemplate.updateFirst with new Update().set | changes one field in place, but save replaces the whole document Why |
| twist | a nested object or list | a nested record or list | embed what is read with its owner, but reference what exists without its owner Why |
| trap | new MongoClient(connectionString) | spring.mongodb.uri, with retryWrites=false | before engine version 8.0.2, or on an elastic cluster, retryable writes must be off (October 2026) Why |
| trap | global-bundle.p7b in X509Store(StoreName.Root) | spring.mongodb.ssl.bundle | the Java driver takes the certificate from a bundle, not from tlsCAFile Why |
| trap | readPreference=secondaryPreferred | the same parameter | a replica is eventually consistent, so a read can miss the write before it Why |
| trap | a feature MongoDB supports | the same call, which AWS lists as unsupported | Amazon DocumentDB lacks features that a MongoDB container accepts Why |
| trap | session.StartTransaction() | @Transactional and a MongoTransactionManager bean | without the bean no transaction starts, and nothing rolls back Why |
| trap | a decimal, stored as a Decimal128 | a BigDecimal, stored as a string on Boot 3 and as a Decimal128 on Boot 4 | after an upgrade one collection holds both forms, unless you set the representation to string Why |
| trap | an index created in code, with Indexes.CreateOne | @Indexed(unique = true) | the annotation alone creates no index, so nothing is enforced until an index exists Why |
| twist | a test against a MongoDB container | @DataMongoTest with a MongoDBContainer | a slice test as with JPA, except that nothing is rolled back Why |
| twist | "Status" : 0 | "status" : "NEW" | the two drivers store enums and field names differently by default Why |
| twist | [BsonElement("n")], [BsonIgnore] | @Field("n"), @Transient | these attributes and annotations rename a stored field, or leave a member out of the document Why |
| trap | MongoDBRef | @DBRef | AWS lists DBRefs as unsupported on every engine version (October 2026), so the warning applies to both drivers Why |
| twist | a property that holds the customer's id | @DocumentReference Customer customer | stores only the id, so each order that is read needs one more query, and nothing cascades Why |
| twist | a .p7b file in the Windows root store | a trust store made with keytool | AWS's Java instructions build a trust store from the PEM form of the same bundle Why |
| twist | engine 5.0, or an elastic cluster | the same cluster, the same limits | engine 5.0 and elastic clusters both lack features that engine 8.0 has on an instance-based cluster, whatever the driver Why |
| twist | IClientSessionHandle | @Transactional on Amazon DocumentDB | as of October 2026 AWS limits a transaction to one minute, and a transaction cannot create a collection Why |
How this chapter was checked. Its examples were run against a MongoDB 8.0 container on 3 and 4 October 2026. No example was run against an Amazon DocumentDB cluster. The statements of this chapter about Amazon DocumentDB come from the AWS developer guide of October 2026, for engine version 8.0 on an instance-based cluster.
A document, not a rowtwist#
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
public class Order
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public required Customer Customer { get; set; }
public List<OrderLine> Lines { get; set; } = [];
public OrderStatus Status { get; set; }
public DateTime PlacedAt { get; set; } = DateTime.UtcNow;
}import java.time.Instant;
import java.util.List;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
@Document("orders")
public class Order {
@Id
private String id;
private Customer customer;
private List<OrderLine> lines;
private OrderStatus status = OrderStatus.NEW;
private Instant placedAt = Instant.now();
public Order(Customer customer, List<OrderLine> lines) {
this.customer = customer;
this.lines = lines;
}
}@Document("orders")This annotation names the collection. A collection has the same purpose as a table in a relational database. Each order is one document in the collection. The document holds the order, with its customer and its lines inside.
@IdThis
@Idis fromorg.springframework.data.annotation, not from Jakarta Persistence (JPA). It maps the field to the_idof the document.private String id;Spring Data generates an id only for a
String, anObjectIdor aBigInteger. It stores the generated id as an ObjectId, which is the kind of id that MongoDB itself uses. So the id is aStringhere, not alongas in the relational chapters.private List<OrderLine> lines;This field needs no annotation, because a nested object or list is stored inside the document. For how each type is stored, see What the stored document holds.
A record can be a documenttrap#
In the relational chapter, an
entity could not be a
record. A document can be a record. Spring Data MongoDB
creates the object itself. The reference of Spring Data MongoDB says, “If the type is a
Java Record the canonical constructor is
used.”
@Document("orders")
public record Order(@Id String id, Customer customer, List<OrderLine> lines,
OrderStatus status, Instant placedAt) { }@Document names the collection. @Id on a component marks the id.
The rest of this chapter uses this Order record.
Saving a record does not fill in its id. A record cannot change, so
save returns a new record that has the generated id:
var order = new Order(null, customer, lines, OrderStatus.NEW, Instant.now());
Order saved = orders.save(order); // orders is the OrderRepository
System.out.println(order.id()); // prints null
System.out.println(saved.id()); // prints 6ac1845b71b478951620fb72Continue to work with saved. The .NET driver sets Id on the object
that you passed to InsertOne. That habit fails here.
Repositories and derived queriestwist#
using MongoDB.Driver;
var client = new MongoClient(connectionString);
IMongoCollection<Order> orders = client
.GetDatabase("shop")
.GetCollection<Order>("orders");
await orders.InsertOneAsync(order);
List<Order> paid = await orders
.Find(o => o.Status == OrderStatus.Paid)
.ToListAsync();
var filter = Builders<Order>.Filter
.Eq(o => o.Customer.Id, "c-42");
List<Order> hers = await orders.Find(filter).ToListAsync();
List<Order> withProduct = await orders
.Find(o => o.Lines.Any(l => l.ProductId == "A-1"))
.ToListAsync();import java.util.List;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface OrderRepository
extends MongoRepository<Order, String> {
// PAID sent the filter {"status": "PAID"}
List<Order> findByStatus(OrderStatus status);
// "c-42" sent {"customer._id": "c-42"}
List<Order> findByCustomerId(String customerId);
// "A-1" sent {"lines.productId": "A-1"}
List<Order> findByLinesProductId(String productId);
}
// in a service that was given the repository
Order saved = orders.save(order);
List<Order> paid = orders.findByStatus(OrderStatus.PAID);This is the repository of the JPA chapter again. Spring Data writes
the class, and derives each query from the name of the method. Each comment shows the filter
that the method sent in the run. A method name can refer to a field of a nested object, as
CustomerId does. It can also refer to a field of the items in a list, as
LinesProductId does.
save inserts a new document. If the document already exists, save
replaces the whole document. There is no
persistence context here, so nothing tracks a
loaded order. Unlike a loaded JPA entity, a changed order is written only when you call
save. In a run, a change that was made in a @Transactional method
without save was not stored.
You do not create a MongoClient. Boot builds one from the connection
setting.
MongoTemplate for the resttwist#
var update = Builders<Order>.Update
.Set(o => o.Status, OrderStatus.Paid);
await orders.UpdateOneAsync(o => o.Id == id, update);import static org.springframework.data.mongodb.core.query.Criteria.where;
import static org.springframework.data.mongodb.core.query.Query.query;
import org.springframework.data.mongodb.core.query.Update;
template.updateFirst(
query(where("id").is(id)),
new Update().set("status", OrderStatus.PAID),
Order.class);template.updateFirst(templateis theMongoTemplatethat Boot creates. The class is in the packageorg.springframework.data.mongodb.core. This call changes one field in place. A call tosavewould replace the whole document.query(where("id").is(id)),This line does the same as a filter builder in the .NET driver. A field is named by a string, so the compiler does not check the name. The template translates
idto_id.new Update().set("status", OrderStatus.PAID),In the run, this line sent
{"$set": {"status": "PAID"}}. The methodsinc,pushandpullwork in the same way.
Embed or referencetwist#
When you embed an object, it is stored inside the document of the order. When you reference an object, the order stores only the id of that object. Embed an object that is read with its owner. Reference an object that exists without its owner.
Lines are never read without their order, so they are embedded. In the run,
orders.save(order) stored them like this:
{
"_id": { "$oid": "6ac1845b71b478951620fb72" },
"customer": { "_id": "c-42", "name": "Ada", "email": "ada@example.com" },
"lines": [
{ "productId": "A-1", "quantity": 2,
"unitPrice": { "amount": { "$numberDecimal": "19.99" }, "currency": "EUR" } }
],
"status": "NEW",
"placedAt": { "$date": "2026-10-03T22:40:27.22Z" },
"_class": "com.acme.billing.Order"
}"lines": [The lines are an array inside the order. One read returns the whole order, with no join and no lazy loading.
"customer": { "_id": "c-42", "name": "Ada", "email": "ada@example.com" },This is a copy of the customer, made when the order was placed.
"_class": "com.acme.billing.Order"Spring Data adds the class name, so that it knows which type to create when it reads the document.
A copy does not change when its original changes. When Ada changes her email address, her old orders keep the old address. If that is the wrong behaviour, reference the customer.
Connecting to Amazon DocumentDBtrap#
One setting holds the connection string. A second setting switches on Transport Layer
Security (TLS). It names a bundle, and the bundle names the certificate. Boot 4 renamed both
settings. spring.data.mongodb.uri became spring.mongodb.uri.
spring.data.mongodb.ssl.bundle became spring.mongodb.ssl.bundle. The
bundle itself is unchanged:
spring:
data:
mongodb:
uri: mongodb://sample-user:password@sample-cluster.node.us-east-1.docdb.amazonaws.com:27017/shop?replicaSet=rs0&readPreference=secondaryPreferred&retryWrites=false
ssl:
bundle: docdb
ssl:
bundle:
pem:
docdb:
truststore:
certificate: file:/etc/billing/global-bundle.pemspring:
mongodb:
uri: mongodb://sample-user:password@sample-cluster.node.us-east-1.docdb.amazonaws.com:27017/shop?replicaSet=rs0&readPreference=secondaryPreferred&retryWrites=false
ssl:
bundle: docdb
ssl:
bundle:
pem:
docdb:
truststore:
certificate: file:/etc/billing/global-bundle.pemretryWrites=falseThe driver switches retryable writes on by default. As of October 2026, AWS states that Amazon DocumentDB does not support retryable writes before engine version 8.0.2, or on an elastic cluster. The error that AWS quotes is Retryable writes are not supported. Engine version 8.0.2 was released on 28 September 2026. From 8.0.2, you may remove the parameter, within AWS's limits.
bundle: docdbThis line switches TLS on, with the certificates of the bundle below. The connection strings that AWS gives for other languages contain
tlsCAFile. In a local run, the Java driver ignoredtlsCAFile.certificate: file:/etc/billing/global-bundle.pemThis is the certificate authority (CA) file that AWS publishes. Without this file, the error is PKIX path building failed. For the other route, see A trust store.
Two parameters that change behaviourtrap#
To a driver, a cluster is a replica set named rs0. One primary instance
takes every write, and replicas serve reads.
With readPreference=secondaryPreferred, a read can miss your own
write. This parameter sends reads to the replicas of the cluster. AWS describes the
replicas as eventually consistent:
Order saved = orders.save(order); // goes to the primary instance
Optional<Order> again = orders.findById(saved.id()); // may go to a replicaSo findById can miss the order. If a read must see the write, read from the
primary instance. To do that, leave the parameter out.
Through an SSH tunnel, leave out both replicaSet=rs0 and
readPreference. AWS states that replica set mode through a tunnel results
in an error. AWS also states that a read preference without replica set mode is undefined. So
on your laptop, remove both parameters from the connection string that works inside AWS.
Through the tunnel, the host name is localhost, and this name does not match
the certificate of the cluster. AWS's shell command adds
--sslAllowInvalidHostnames for that. The parameter of the Java driver is
tlsAllowInvalidHostnames.
What Amazon DocumentDB does not dotrap#
AWS's developer guide offers no way to run Amazon DocumentDB outside AWS. It says that the service is virtual private cloud (VPC)-only. So this book develops against a MongoDB container. That is the choice of this book, not AWS guidance. AWS's guide says nothing about it.
A MongoDB container accepts features that AWS lists as unsupported on Amazon DocumentDB. DocumentDB emulates the MongoDB API on its own engine, and it lacks some features. AWS lists these features as unsupported on every engine version, as of October 2026:
| AWS lists as unsupported | A Spring Data service reaches it through | Instead |
|---|---|---|
| DBRefs | @DBRef | use @DocumentReference, or a field that holds the id |
| $where | an @Query string that uses $where | use the query operators, such as $gt, $in and $elemMatch |
| Hashed Index, Wildcard index | @HashIndexed, @WildcardIndexed | use a single-field or a compound index |
| 2d Index | @GeoSpatialIndexed, with its default type | ask for a 2dsphere index, which AWS lists as supported |
| Capped Collections, cursor.tailable() | a capped collection, a tailable cursor | AWS names no replacement |
One difference produces no error. Amazon DocumentDB guarantees no implicit order of results. Name the order in every query that returns a list:
List<Order> findByStatusOrderByPlacedAtDesc(OrderStatus status);OrderByPlacedAtDesc sends a sort with the filter. For more differences, see the
section More differences.
Transactionstrap#
// The application declares no transaction manager, and the collection is empty.
@Service
public class OrderService {
private final OrderRepository orders;
public OrderService(OrderRepository orders) {
this.orders = orders;
}
@Transactional
public void place(Order order) {
orders.save(order);
throw new IllegalStateException("payment failed");
}
}
// in the caller
try {
service.place(order);
} catch (IllegalStateException e) { }
System.out.println(orders.count());Show the answer
@Transactional does nothing for MongoDB until you declare a
MongoTransactionManager. Spring Data's reference says that without a
MongoTransactionManager, “transaction support is DISABLED”. Boot 4.1
creates no MongoTransactionManager. So no transaction started, and the saved order
stayed.
Declare a MongoTransactionManager
bean. With this bean, the same run printed
0.
In .NET, you pass the session to each call. A call without the session compiles, and it runs outside the transaction. In Spring, declare the manager once:
// the session goes to every call
using IClientSessionHandle session = await client.StartSessionAsync();
session.StartTransaction();
await orders.InsertOneAsync(session, order);
await session.CommitTransactionAsync();import org.springframework.data.mongodb.MongoDatabaseFactory;
import org.springframework.data.mongodb.MongoTransactionManager;
@Configuration
public class MongoConfig {
@Bean
MongoTransactionManager transactionManager(
MongoDatabaseFactory factory) {
return new MongoTransactionManager(factory);
}
}MongoTransactionManager is an ordinary Spring transaction manager. So the
rollback rule and the
proxy trap apply. For both, see the chapter
Transactions, propagation and isolation. In the run,
MongoTransactionManager needed a replica set.
One point could not be tested. Spring's transaction manager opens a causally
consistent session. AWS states that Amazon DocumentDB does not support causal consistency. AWS
shows a read in such a session, and the read fails with error 303,
Feature not supported: 'causal consistency'. AWS's own examples
switch causal consistency off. Test a @Transactional method on your cluster
first. AWS also documents limits. As of October 2026, a transaction
may run for one minute, and it cannot create a collection.
Indexestrap#
@Indexed(unique = true) enforces nothing by default. Spring Data
creates no index unless auto index creation is switched on, and auto index creation is off. In
the run, two invoices for one order were both saved:
import org.springframework.data.mongodb.core.index.Indexed;
@Document("invoices")
public record Invoice(@Id String id,
@Indexed(unique = true) String orderId,
Money total, LocalDate issuedOn) { }
invoices.save(new Invoice(null, "o-1", total, today));
invoices.save(new Invoice(null, "o-1", total, today));
System.out.println(invoices.count()); // prints 2Nothing rejected the second save. One fix is to switch index creation on. The
setting has the same name on Boot 3 and Boot 4:
spring:
data:
mongodb:
auto-index-creation: trueWith auto-index-creation on, the second save threw a
DuplicateKeyException. But when the collection already held
the two invoices, switching the setting on stopped the application from starting, with the same
exception.
Spring Data's reference says, “We generally recommend explicit index creation”. AWS states, as of October 2026, that an index build on Amazon DocumentDB blocks its collection by default. So on a collection that already holds data, create the index yourself, outside the application.
Money: how a BigDecimal is storedtrap#
A BigDecimal is stored as a string on
Boot 3, and as a Decimal128 on Boot 4. In the run on Boot 4.1, Money.amount was stored as a
Decimal128. AWS lists Decimal128 as a supported type. Boot 3.5 manages Spring Data MongoDB 4.5.
Spring Data MongoDB 4.5 states that a BigDecimal “is represented as String instead of
Decimal128”. The Boot 3 side was not run.
So after an upgrade, one collection holds old amounts as strings and new amounts as Decimal128 values.
On Boot 4, state the choice:
spring:
data:
mongodb:
representation:
big-decimal: string # or decimal128big-decimal: string keeps the Boot 3 form. Spring's upgrade notes offer this
value “to retain previous behaviour”. With this value, the run stored
"19.99". For
decimal128, convert the stored amounts first. Boot 3 has no such setting.
Testing without a clustertwist#
@DataMongoTest
@Testcontainers
class OrderRepositoryTest {
@Container @ServiceConnection
static MongoDBContainer mongo = new MongoDBContainer("mongo:8.0");
@Autowired OrderRepository orders;
@BeforeEach
void empty() {
orders.deleteAll();
}
@Test
void findsByProduct() {
orders.save(anOrder("A-1")); // anOrder builds an Order with that product
assertThat(orders.findByLinesProductId("A-1")).hasSize(1);
}
}@DataMongoTestThis is the test slice for document data. Unlike @DataJpaTest, it rolls nothing back. In the run, the next test still saw the order of this test. So each test starts by emptying the collection.
static MongoDBContainer mongo = new MongoDBContainer("mongo:8.0");Pin the MongoDB version that your cluster is compatible with. To test a transaction, add
.withReplicaSet(). In the run, the plain container refused a transaction.
A passing test proves less than it seems, because the container accepts features that AWS lists as unsupported on Amazon DocumentDB. Test against a real cluster before a release.
Referencing a document instead of embedding it#
A customer exists without any one order. To store customers once, give customers a collection of their own, and let the order hold a reference:
import org.springframework.data.mongodb.core.mapping.DocumentReference;
@Document("customers")
public record Customer(@Id String id, String name, String email) { }
@Document("orders")
public record Order(@Id String id,
@DocumentReference Customer customer,
List<OrderLine> lines) { }@DocumentReference stores only the id of the customer in the order. In the run,
reading orders then needed one more query for each order, to load its customer. This is the
N+1 problem again. Nothing cascades, so you save the customer
yourself.
Do not use @DBRef on Amazon DocumentDB. @DBRef is the
other way to reference in Spring Data. It stores a value of the DBRef type. AWS lists that type as not
supported on any engine version, as of October 2026.
What the stored document holds: types, names and _class#
A stored document is in BSON. BSON is the format of MongoDB. It has types that JSON lacks, such as ObjectId, Date and Decimal128. Each row below shows what a run on 3 October 2026 stored. The run used Spring Data MongoDB 5.1.1 and the MongoDB .NET driver 3.12.0, both with their defaults:
| Member | Spring Data MongoDB stored | The .NET driver stored |
|---|---|---|
| the id, a string | "_id": an ObjectId | "_id": an ObjectId, with [BsonRepresentation] |
| status, an enum | "status": "NEW" | "Status": 0 |
| placedAt | "placedAt": a date, from an Instant | "PlacedAt": a date, from a DateTime |
| amount | a Decimal128, from a BigDecimal, but a string on Boot 3 | a Decimal128, from a decimal |
| a field's name | the name of the Java field, for example customer | the name of the C# property, for example Customer |
| the id of a nested object | "_id" | "_id" |
| the type | "_class": "com.acme.billing.Order" | nothing |
So two services that share a collection must agree on names and on enums. These attributes and annotations make the two services agree:
[BsonIgnoreExtraElements]
public class Product
{
[BsonId]
public string Id { get; set; } = "";
[BsonElement("display_name")]
public string Name { get; set; } = "";
[BsonIgnore]
public string Label { get; set; } = "";
}@Document("products")
@TypeAlias("product")
public class Product {
@Id
private String id;
@Field("display_name")
private String name;
@Transient
private String label;
}[BsonIgnoreExtraElements]This attribute lets the .NET class read a document that holds a field for which the class has no property, such as
_class. Without this attribute, the driver threw aFormatExceptionin the run.@TypeAlias("product")This annotation stores
productin_class, in place of the class name. So renaming or moving the class does not change what is stored.@Field("display_name")This annotation stores the field under another name, as
[BsonElement]does.@TransientThis annotation leaves the field out of the document, as
[BsonIgnore]does. It is fromorg.springframework.data.annotation.
A LocalDate is stored through the machine's time zone. On a
machine set to British summer time, the run stored 3 October 2026 as
2026-10-02T23:00:00Z. The date was read back correctly on the same machine. Spring
Data's reference recommends the driver's own java.time codecs. These codecs use
UTC.
Boot 4 sets no representation for a BigDecimal. In the run, the driver stored a
BigDecimal as a Decimal128. AWS lists Decimal128 as a supported type. On Boot 3, a
BigDecimal is a string. For what to set when you upgrade, see the section
Money: how a BigDecimal is stored.
A trust store made with keytool: one of AWS's two Java routes#
AWS's “Connecting programmatically” page builds a trust store with
keytool. The C# tab of that page imports global-bundle.p7b into
X509Store(StoreName.Root). AWS's Java guide also shows how to load the PEM file in
code, through an SSLContext.
The keytool route splits global-bundle.pem into its certificates.
Then it imports each certificate into a Java trust store, because keytool imports
one certificate at a time:
curl -sS "https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem" > global-bundle.pem
split -p "-----BEGIN CERTIFICATE-----" global-bundle.pem rds-ca-
for CERT in rds-ca-*; do
keytool -import -file "$CERT" -alias "$CERT" -storepass changeit \
-keystore rds-truststore.jks -noprompt
donesplit -p "-----BEGIN CERTIFICATE-----" global-bundle.pem rds-ca-This line writes one file for each certificate. This is the macOS form. AWS's Linux script uses
awk.keytool -import -file "$CERT" -alias "$CERT" -storepass changeit \keytoolis part of the Java Development Kit (JDK). Each certificate needs an alias of its own. AWS's script takes the alias from the subject of the certificate.
Then tell the Java Virtual Machine (JVM) where the trust
store is, or name the trust store in an SSL bundle of the jks kind:
java -Djavax.net.ssl.trustStore=rds-truststore.jks \
-Djavax.net.ssl.trustStorePassword=changeit -jar billing.jarThe javax.net.ssl.trustStore route and the jks bundle route both
connected in a local run against a MongoDB container that accepts only TLS. The PEM bundle of
the connection section also connected to that container, with a file
that held two certificates.
Through an SSH tunnel, the host name of the certificate does not match with either route. To find the parameter for that case, see the tunnel gotcha.
More differences: behaviour, engine 5.0 and elastic clusters#
Some features are supported on Amazon DocumentDB but behave differently. AWS states these differences, as of October 2026:
| On Amazon DocumentDB | What it means for your code |
|---|---|
| $regex | uses an index only with hint(), and best for a prefix without options |
| allowDiskUse | not supported for find, only as part of an aggregation |
| cursor.noCursorTimeout() | not supported, so leave Query.noCursorTimeout() off |
| The admin and local databases | not supported |
| Write concern | the values you send are ignored, and it always writes with majority |
| Document size | at most 16 MiB, which is the ceiling for what an order can embed |
This chapter describes engine version 8.0 on an instance-based cluster. AWS's table of features by engine version shows what engine 5.0 and elastic clusters lack:
| Feature | Engine 5.0 | Engine 8.0 | Elastic clusters |
|---|---|---|---|
| ACID transactions | Yes | Yes | No |
| Retryable writes | No | Yes (8.0.2+) | No |
| Change streams | Yes | Yes | No |
| Collation | No | Yes | No |
| Views | No | Yes | No |
| Text Index | V1 | V2 | No |
| Partial index | Yes | Yes | No |
| $facet | No | Yes (8.0.2+) | No |
| $set, $unset and $replaceWith stages | No | Yes | No |
| GridFS | Yes | Yes | No |
An elastic cluster is sharded, and it uses a wire protocol that is compatible with MongoDB
5.0. AWS does not offer elastic clusters on engine version 8.0. So on an elastic cluster,
@Transactional has no transactions to use, and retryable writes stay off:
mongodb://sample-user:password@sample-cluster.node.us-east-1.docdb.amazonaws.com:27017/shop?retryWrites=falseThe parameter retryWrites=false is required on an elastic cluster, and on every
engine version before 8.0.2.
From 8.0.2, some writes are still not retryable. AWS lists these limits, as of October 2026.
updateMany and deleteMany are not retryable. Writes inside a
transaction are not retryable. No write is retryable on a cluster that uses query planner
version 1.0.
Spring Data MongoDB 5.1 states that it is tested against MongoDB 6 to 8. Engine version 8.0 is the only engine version in that range. That is this book's reading of two sources, not a promise by either vendor.
Transactions on Amazon DocumentDB: the documented limits#
AWS documents these limits for a transaction, as of October 2026. None of these limits could be reproduced on a MongoDB container, so none was run:
| Limit | What AWS states |
|---|---|
| Engine version | transactions need engine version 4.0 or later |
| Elastic clusters | ACID transactions are not supported |
| New collections | a transaction cannot create a collection, or query one that does not exist |
| Duration | a transaction has a one minute execution limit |
| Size | the transaction log must be smaller than 32 MB |
| Cursors | cursors within a transaction are not supported |
| Causal consistency | not supported, and AWS's own examples switch it off in the session |
The collection limit conflicts with a Spring habit. The first save creates a
collection that does not exist. Inside a transaction on Amazon DocumentDB, save
cannot create a collection, so create the collection first:
// once, outside any transaction: for example when the application starts
if (!template.collectionExists(Order.class)) {
template.createCollection(Order.class);
}collectionExists and createCollection are methods of
MongoTemplate. A transaction that runs longer than one minute is aborted. AWS
quotes the error that the next command receives. The error is
Given transaction number 0 does not match any in-progress
transactions.
1TranslateWrite this in Java, with Spring Data MongoDB. Order is a record,
and orders is the OrderRepository:
Order order = await orders.Find(o => o.Id == id).SingleAsync();
order.Status = OrderStatus.Paid;
await orders.ReplaceOneAsync(o => o.Id == id, order);
Order is a record,
and orders is the OrderRepository:
Order order = await orders.Find(o => o.Id == id).SingleAsync();
order.Status = OrderStatus.Paid;
await orders.ReplaceOneAsync(o => o.Id == id, order);Order order = orders.findById(id).orElseThrow();
Order paid = new Order(order.id(), order.customer(), order.lines(),
OrderStatus.PAID, order.placedAt());
orders.save(paid);There is no persistence context. So unlike a loaded JPA entity, a changed order is written
only when you call save. A record cannot change, so you build a new record and save
the new record.
2An expert asksInvoice.orderId carries @Indexed(unique = true), yet two
invoices for one order are both saved. Why, and why is switching auto index creation on not
always the fix?
Invoice.orderId carries @Indexed(unique = true), yet two
invoices for one order are both saved. Why, and why is switching auto index creation on not
always the fix?spring.data.mongodb.auto-index-creation on stopped the application from starting,
with a DuplicateKeyException. Spring's
reference recommends creating indexes explicitly. AWS states, as of October 2026, that an index
build on Amazon DocumentDB blocks its collection by default.3An expert asksYour tests pass against a MongoDB container. Why can the same code still fail
on Amazon DocumentDB?
$where. As of October 2026, Amazon DocumentDB also
does not support retryable writes before engine version 8.0.2, and the driver sends retryable
writes by default. A container accepts all of these features.Quick reference#
| Setting | Boot 4 | Boot 3 |
|---|---|---|
| Connection string | spring.mongodb.uri | spring.data.mongodb.uri |
| Database name | spring.mongodb.database | spring.data.mongodb.database |
| SSL bundle | spring.mongodb.ssl.bundle | spring.data.mongodb.ssl.bundle |
| Auto index creation | spring.data.mongodb.auto-index-creation | spring.data.mongodb.auto-index-creation |
| BigDecimal representation | spring.data.mongodb.representation.big-decimal | no such setting |
| @DataMongoTest, the package | org.springframework.boot.data.mongodb.test.autoconfigure | org.springframework.boot.test.autoconfigure.data.mongo |
| MongoDBContainer, the package | org.testcontainers.mongodb | org.testcontainers.containers |
Security#
Spring Security is a chain of servlet filters. Every
request passes through the chain before it reaches your controllers. The filters identify the
caller, and store the result as an Authentication. Authorisation rules then decide
what that caller may do.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | ClaimsPrincipal | Authentication / Principal | the signed-in user, and its authorities do the same as claims in .NET Why |
| twist | User.Identity.Name | authentication.getName() | the principal's name, such as the JWT subject Why |
| trap | endpoints are open until you add [Authorize] | every endpoint needs a login once the security starter is present | Boot supplies a default chain until you declare your own SecurityFilterChain Why |
| twist | AddAuthentication | SecurityFilterChain bean | one bean declares how requests are authenticated Why |
| twist | AddAuthorization | authorizeHttpRequests(...) | the URL rules, matched in order inside the same bean Why |
| twist | [AllowAnonymous] | permitAll() in the chain | usually a path rule in the chain, not an annotation Why |
| twist | Cookie authentication | formLogin() + session | form login backed by an HTTP session Why |
| twist | JwtBearer | oauth2ResourceServer().jwt() | validates bearer JWTs, and you set the issuer in application.yml Why |
| trap | URL rules in any order | specific rules first, anyRequest() last | rules are checked in order, and the first one that matches decides Why |
| trap | Antiforgery token | CSRF protection, on by default | A POST without the token is refused with 403. It gets 401 if the /error page needs a login too. Why |
| twist | [Authorize] | @PreAuthorize, or a chain rule | @PreAuthorize on methods, or a URL rule in the chain Why |
| twist | [Authorize(Roles = "Admin")] | @PreAuthorize("hasRole('ADMIN')") | hasRole adds the ROLE_ prefix for you Why |
| twist | Policy-based authorisation | SpEL in @PreAuthorize, or an AuthorizationManager | write the rule as an expression, or as a small bean Why |
| trap | no equivalent | @EnableMethodSecurity | without it, every @PreAuthorize is silently ignored Why |
| trap | User.IsInRole("Admin") | hasRole('ADMIN'), which needs ROLE_ADMIN | a JWT's roles claim gives no authorities until a converter maps it Why |
| trap | a direct call to an [Authorize] action skips its check too | a call through another bean | a call from inside the same class skips the @PreAuthorize check Why |
| twist | User.FindFirst("sub") | @AuthenticationPrincipal Jwt jwt | a controller parameter receives the caller's validated token Why |
| twist | IdentityUser | UserDetails | the user record Spring Security loads to check a password Why |
| twist | UserManager | UserDetailsService | loads users by name, but you build account management yourself Why |
| twist | Data protection | no direct equivalent | there is no built-in key ring, so use a KMS or Spring Vault Why |
| twist | string? in a signature | @Nullable from JSpecify | Spring Framework 7 says in its own API where null is possible Why |
Concept mappingtwist#
Callers prove who they are with a token from an identity provider. The protocols are OAuth 2.0 and OpenID Connect (OIDC), as in .NET. The token is called a JSON Web Token (JWT). It is a signed piece of JSON that names the user and lists what the user may do.
// 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();This is where Spring Security keeps the caller of the request that is being handled.
auth.getName();This call returns the caller's name. For a JWT, the name is the subject of the token.
.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')")This is a rule written as an expression on the method. ASP.NET Core would use a named policy for this. The scopes of a JWT become authorities with the prefix
SCOPE_. So theordersscope becomes the authoritySCOPE_orders.
The starter locks every endpointtrap#
In ASP.NET Core, an endpoint is open until you add [Authorize]. Spring Boot does
the opposite. One dependency is enough to show it:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency><artifactId>spring-boot-starter-security</artifactId>This dependency adds Spring Security and its configuration. Its name is the same on Boot 3 and Boot 4.
Once the security starter is on the
classpath, every endpoint needs a login.
Boot supplies a default chain until you declare a SecurityFilterChain
bean of your own. With the default chain, a request with no
credentials gets 401 Unauthorized. A browser is redirected to /login instead. The
startup log prints “Using generated security password”. None of your code changed,
so the 401 looks like a bug. To write a chain of your own, see the section
The filter chain.
The filter chaintwist#
You declare one bean of type SecurityFilterChain. You configure the bean
through a domain-specific language (DSL). Here the DSL is a chain of method calls on
HttpSecurity. Each call takes a lambda that
sets up one part of the configuration.
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 -> authThis call holds the URL rules. It is like
AddAuthorizationandRequireAuthorizationtogether..requestMatchers("/actuator/health").permitAll()Anyone may call the health check. This rule is 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()))This call accepts JWT bearer tokens, like
AddJwtBearer.withDefaults()takes its settings fromapplication.yml..csrf(csrf -> csrf.disable())This line switches off the protection against cross-site request forgery (CSRF). That is safe here only because the API uses tokens, not cookies.
SessionCreationPolicy.STATELESS))With this policy, Spring Security never creates an HTTP session. Every request carries its own token.
Where the tokens come fromtwist#
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/shopThis is the address of the identity provider. It is like
Authorityin the JwtBearer options.
The oauth2ResourceServer call in the chain needs a starter of its own. On
Boot 3, the starter is spring-boot-starter-oauth2-resource-server. On Boot 4, the
starter is spring-boot-starter-security-oauth2-resource-server. With only the
security starter, the application fails at startup with a
NoClassDefFoundError.
Two chain traps: rule order and CSRFtrap#
Two lines of the chain each hold a trap:
// inside SecurityConfig's chain method; http is its HttpSecurity parameter
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.csrf(csrf -> csrf.disable());.anyRequest().authenticated())This is the broadest rule, so it comes last.
.csrf(csrf -> csrf.disable());This line switches CSRF protection off for the whole API.
Rules are matched in order, and Spring uses the first rule that matches.
Put anyRequest() last. If a broad rule comes early, it matches first. Then the
rules after it are never used for those requests, and nothing warns you. An endpoint is then more open than you intended.
CSRF protection is on by default, including for POST requests to a JSON
API. It stops one attack. In that attack, another site makes the user's browser send a request
with the user's cookies. If your first POST is refused with no useful message, CSRF protection
is the reason. Spring Security answers 403. On Boot 4.1, the client saw 401 instead while the
/error page of Boot needed a login too. The client saw 403 once
/error was open to everyone. Disable CSRF protection only for an API that browsers
do not call with cookies. Keep it for anything that uses cookies.
Method-level authorisationtwist#
A rule that depends on the method's arguments goes on the method:
[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')")This rule is checked before the method runs, like
[Authorize(Roles = "Admin")]. If the rule fails, Spring refuses the call. A signed-in caller gets 403 Forbidden.@PreAuthorize("@orderGuard.owns(#id, authentication)")This rule calls a bean.
@orderGuardis the bean namedorderGuard.#idis theidparameter of the method.authenticationis 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 {The bean name is the class name with a lower-case first letter, so it is
orderGuard..map(order -> order.getCustomerId().equals(caller.getName()))The caller owns the order when the customer id of the order equals the caller's name. The caller's name is the subject of the token.
These are the expressions you will use most:
| Expression | Means |
|---|---|
| hasRole('ADMIN') | requires the authority ROLE_ADMIN, because 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, which means ROLE_A or ROLE_B |
| isAuthenticated() | allows any signed-in user, so 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(...) | calls a method of a bean, so the rule can hold any policy logic |
Three ways a method rule failstrap#
@PreAuthorize puts a rule on one method.
Annotations like this one do nothing until one
configuration class switches them on:
@Configuration
@EnableMethodSecurity // required, method annotations do nothing without it
public class MethodSecurityConfig { }@EnableMethodSecurityThis annotation makes Spring wrap each bean that has
@PreAuthorizein a proxy. The proxy checks the rule. Without@EnableMethodSecurity, the annotations are ignored, and nothing warns you.
// The caller's JWT holds: "scope": "orders", "roles": ["ADMIN"]
// The application declares no JwtAuthenticationConverter bean.
@PreAuthorize("hasRole('ADMIN')")
public void delete(long id) { ... }Show the answer
hasRole('ADMIN') checks for the authority
ROLE_ADMIN. Spring adds the prefix for you in hasRole but not
in hasAuthority. If you confuse the two expressions, the rule never matches, and
nothing warns you. A JWT adds a second trap. By default, only the scope claim of a
JWT becomes authorities, with names of the form 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;
}setAuthoritiesClaimName("roles") reads authorities from the roles
claim instead of the scope claim. This setting replaces the default, so
SCOPE_ authorities are no longer produced.
setAuthorityPrefix("ROLE_") changes ADMIN into
ROLE_ADMIN. That is the form that hasRole checks.
@PreAuthorize works through a proxy, exactly like @Transactional.
A call from inside the same class is called
self-invocation. Such a call does not go through
the proxy, and the rule is not checked. So a security check that depends on self-invocation is
no check at all. To predict what happens, see the chapter
How Spring actually works.
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 the caller's validated token to this parameter.
getSubject()reads thesubclaim of the token, which is the user's id.var auth = SecurityContextHolder.getContext()This line works in any bean, but it hides the dependency. In a controller, use the parameter instead.
SecurityContextHolder keeps the caller in a ThreadLocal. A
ThreadLocal is a value that belongs to one thread. So the caller is not available
on a thread that you start yourself. The same is true for a task that you submit to an
ExecutorService. Whenever you send work to other
threads, wrap the executor in a DelegatingSecurityContextExecutor. The wrapper
passes the caller to the thread that runs each task.
Passwords#
Some sites sign users in themselves, with a form and a cookie. Such a site calls
formLogin() in the filter chain, and keeps the user in an HTTP session. If the shop
stores the passwords of its own users, it must hash them. One bean chooses the algorithm:
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}PasswordEncoderFactories.createDelegatingPasswordEncoder();This encoder hashes new passwords with bcrypt. It can still check hashes that were made with older algorithms.
The delegating encoder stores a prefix such as {bcrypt} in the hash. So you can
change the algorithm later, and the existing passwords stay valid. The delegating encoder does
the same as the versioned password hashes of ASP.NET Identity. It is the right default.
To check a password, Spring Security loads the user by name through a
UserDetailsService. The service returns a UserDetails.
UserDetailsService is like UserManager in ASP.NET Core Identity.
UserDetails is like IdentityUser. But you must build registration,
password reset and the rest of account management yourself.
The data protection of ASP.NET Core also has no direct equivalent in Spring. Spring has no built-in key ring, so use a key management service (KMS) or Spring Vault.
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, the signatures of Spring itself now say exactly where null is possible. This includes generics. So the checker is much more useful with Spring than it was on Boot 3. See Nullability: Optional and JSpecify.
In the code below, env has the Spring type Environment. It is
injected like any bean. Here a configuration lookup may return null, and the checker says
so:
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. Spring 7 declares that with the@Nullableannotation of JSpecify.int length = url.length();Using
urlwithout a null check could throw, so a checker such as NullAway reports this line when you build.
1TranslateWrite the rules for these endpoints in Java, as the
authorizeHttpRequests part of a filter chain:
app.MapControllers().RequireAuthorization();
[AllowAnonymous]
[HttpGet("/api/products")]
public IActionResult Products() => Ok("products");
[Authorize(Roles = "Support")]
[HttpGet("/api/refunds")]
public IActionResult Refunds() => Ok("refunds");
authorizeHttpRequests part of a filter chain:
app.MapControllers().RequireAuthorization();
[AllowAnonymous]
[HttpGet("/api/products")]
public IActionResult Products() => Ok("products");
[Authorize(Roles = "Support")]
[HttpGet("/api/refunds")]
public IActionResult Refunds() => Ok("refunds");http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/products/**").permitAll()
.requestMatchers("/api/refunds/**").hasRole("SUPPORT")
.anyRequest().authenticated())The two specific rules come first and anyRequest() comes last, because the
first rule that matches decides. hasRole("SUPPORT") checks for the authority
ROLE_SUPPORT.
2A service method carries @PreAuthorize("hasRole('ADMIN')"), yet every caller gets
through, whichever bean calls it. What is missing?
@PreAuthorize("hasRole('ADMIN')"), yet every caller gets
through, whichever bean calls it. What is missing?@EnableMethodSecurity is missing on a configuration class. Without it, every
@PreAuthorize in the application is ignored, and nothing warns you.3An expert asksThe shop's API authenticates callers with bearer tokens, and switches CSRF
protection off. Why is that safe there, and not for a site that signs users in with
cookies?
HTTP clients and resilience#
RestClient in Spring calls another service over HTTP, like
HttpClient in .NET. This chapter also covers interface clients, which are like
Refit. And it covers what you use in place of Polly when the other service fails.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | httpClientFactory.CreateClient("rates") | a RestClient bean, built from RestClient.Builder | RestClient is the default client on Boot 3.2 and later, and it blocks Why |
| twist | GetFromJsonAsync<Rate>(url) | get().uri(url).retrieve().body(Rate.class) | a blocking call, which is exactly right on a virtual thread Why |
| twist | Refit interface | an @HttpExchange interface | one method per endpoint, and Spring writes the class that makes the calls Why |
| twist | AddRefitClient<IRatesApi>() | Boot 3: HttpServiceProxyFactory. Boot 4: @ImportHttpServices | Boot 4 registers the client bean, but on Boot 3 you build it in a @Bean method Why |
| twist | ConfigureHttpClient(c => c.BaseAddress = ...) | spring.http.serviceclient.<group>.base-url | on Boot 4, the address of an @ImportHttpServices group is a setting Why |
| twist | Polly's AddRetry | Boot 3: @Retry, from Resilience4j. Boot 4: @Retryable | Spring Framework 7 has retry in its core, so Boot 4 needs no extra library Why |
| twist | a Polly bulkhead | Boot 3: @Bulkhead. Boot 4: @ConcurrencyLimit(10) | It limits how many calls run at once. Virtual threads have no pool that limits them. Why |
| twist | Polly's AddCircuitBreaker | @CircuitBreaker, from Resilience4j | core has no circuit breaker and no rate limiter, on Boot 3 or on Boot 4 Why |
| twist | MaxRetryAttempts = 3 | @Retryable(maxRetries = 3) | Both count only the retries, so 3 means four calls. On Boot 3, maxAttempts counted the first call too. Why |
| trap | a pipeline you call yourself | @Retryable, which works through a proxy | a call from inside the same class skips the proxy, so nothing is retried Why |
| trap | a client with no timeout set | setConnectTimeout(...) and setReadTimeout(...) | the defaults are effectively infinite, so a hung service holds the connection Why |
| twist | EnsureSuccessStatusCode() | retrieve() | a 4xx or 5xx reply becomes an exception, and onStatus replaces it with your own Why |
| twist | HttpClient | restTemplate.getForObject(...), in existing code | the original client is in maintenance mode, so write RestClient in new code Why |
The client optionstwist#
Spring offers several clients for an HTTP call:
| 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 code, as described 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)This is a URI template. The next argument is escaped and put in the place of
{pair}. The base address was set when theRestClientwas built..retrieve()This call sends the request. A 4xx or 5xx response becomes an exception, as with
EnsureSuccessStatusCodein .NET..body(Rate.class);This call reads the JSON body into a
Rate. HereRateis a record, declared asrecord 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. You do
not need them to avoid thread starvation. See
Threads are cheap now.
Declarative clientstwist#
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
[Get]in Refit. The annotations@PathVariableand@RequestBodysay where each argument goes. They are the same annotations as in a controller.Quote createQuote(@RequestBody QuoteRequest request);This method sends the
QuoteRequestas the JSON body, and reads the reply into aQuote.
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. The proxy is an object that implementsRatesApi. It turns each method call into an HTTP request through theRestClient.@ImportHttpServices(group = "rates", types = RatesApi.class)On Boot 4, one annotation does the same. It also puts the interface in a group called
rates. The settings of the group are inapplication.yml.
Boot 4: the settings of a service group#
On Boot 4, the settings of the group are under the name of the group:
spring:
http:
serviceclient:
rates:
base-url: https://rates.example.combase-url: https://rates.example.comThis is the address that every
RatesApicall starts from, likeBaseAddressin .NET.
Resilience: the Polly answertwist#
C# refresherPolly: retry, circuit breaker and timeout as a pipelineC# 5
Polly combines 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));Spring Framework 7 added retry support to its core, so on Boot 4 @Retryable
needs no separate library:
// 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")This is Boot 3, with Resilience4j. The settings for the
ratespolicy are inapplication.yml. The methodcachedis called after every attempt has failed.@Retryable(includes = RatesUnavailableException.class,This is Boot 4, with the annotation from core. It retries only when this exception is thrown, and at most 4 more times. The base wait is 200 ms, and the wait doubles for each retry. Jitter changes each wait by a random amount. The amount is at most 20 ms for the first wait, and it doubles too. A wait is never shorter than 200 ms or longer than 2 seconds.
@ConcurrencyLimit(10)At most 10 calls run at once, and the other calls wait. This does the same as a bulkhead in Polly. It is important with virtual threads, because virtual threads have no pool that limits them.
This table shows what each Polly strategy becomes on Boot 3 and on Boot 4:
| Polly | Boot 3 (Resilience4j) | Boot 4 (Spring Framework core) |
|---|---|---|
| Retry | @Retry | @Retryable |
| Bulkhead | @Bulkhead | @ConcurrencyLimit |
| Circuit breaker | @CircuitBreaker | not in core, so keep Resilience4j |
| Rate limiter | @RateLimiter | not in core, so keep Resilience4j |
| Timeout | @TimeLimiter | @Retryable(timeout = ...) limits the whole retry, so set a request timeout for each call |
| Fallback | fallbackMethod | not in core, so catch the exception after the last retry fails |
| Programmatic | RetryRegistry | RetryTemplate with RetryPolicy.builder() |
Retries: the count, the proxy and what core leaves out#
On Boot 4, the annotation needs no attributes:
@Retryable
public Rate fetch(String pair) {
return restClient.get().uri("/rates/{p}", pair).retrieve().body(Rate.class);
}@RetryableThe core defaults apply. They are 3 retries, 1 second apart, on any exception.
The attribute is maxRetries, not maxAttempts.
Spring Retry is the add-on for Boot 3. It uses maxAttempts and counts the first
call. The core annotation of Framework 7 uses maxRetries and counts only the
retries. So the default of 3 means four calls in total, one second apart, with a retry on any
exception. If you paste a Boot 3 snippet into a Boot 4 codebase, it fails to compile, and that
is the good outcome. The bad outcome is that you keep the wrong idea of the count.
MaxRetryAttempts in Polly also counts only the retries. With a value of 3, a call
that always fails runs four times. So this trap is for code that comes from Boot 3, not from
.NET.
Core covers retry and concurrency limiting only. It has no circuit breaker
and no rate limiter, so you still use Resilience4j for those on both versions. The
timeout attribute of @Retryable limits the whole sequence of attempts,
not one call. With the core additions, you need one dependency less for the two cases that
most services need.
@Retryable also works on reactive methods. It adds a retry to the returned
Mono or Flux. Every failed attempt publishes a
MethodRetryEvent. You can listen for this event to log retries.
@Retryable works through a proxy, like @Transactional and
@PreAuthorize. So self-invocation
does not retry. For the reason, see the chapter
How Spring actually works. And be careful when you
retry an operation that is not idempotent. A POST that timed out may well have succeeded.
Timeoutstrap#
Set timeouts explicitly. The defaults are effectively infinite. Without a
timeout, a downstream service that does not answer holds a connection until the socket dies.
This is true of RestClient, of RestTemplate and of the JDK client.
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));The client stops waiting if the connection is not open within 2 seconds.
factory.setReadTimeout(Duration.ofSeconds(5));The client stops waiting if the reply does not arrive within 5 seconds. The call then fails with a
ResourceAccessException. Its cause is aSocketTimeoutException.
On Boot 4, two settings apply the same limits to every client that Boot builds:
spring:
http:
clients:
connect-timeout: 2s
read-timeout: 5sread-timeout: 5sThis setting applies to whichever HTTP library Boot finds. Boot tries Apache HttpClient first, then Jetty, then Reactor Netty, and then the clients of the JDK itself.
Error handling#
By default, a 4xx or 5xx response becomes a Spring exception. To throw your own exceptions instead, add a handler for 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, Spring runs this handler instead of the default. Here the handler throws the shop's own
UnknownPairException.(req, res) -> { throw new RatesUnavailableException(); })For any 5xx reply, this handler throws
RatesUnavailableException. The@Retryableexample above retries on this exception.
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 for each status, such as
HttpClientErrorException.NotFound for a 404. So you can catch exactly the case you
expect.
RestTemplate#
LegacyRestTemplate
RestTemplate is the original Spring HTTP client, and it is still in most
existing codebases. It is in maintenance mode. This means that it is not deprecated, but it gets
no new features. The documentation recommends RestClient, which wraps the same
infrastructure with a better API.
Rate rate = restTemplate.getForObject(
"/rates/{p}", Rate.class, pair);restTemplate.getForObject(RestTemplatehas one method for each verb and kind of result. This method sends a GET and reads the body into aRate. The URI template works as it does inRestClient.
Migration is mostly mechanical. You can build a RestClient from the
configuration of an existing RestTemplate with
RestClient.create(restTemplate).
1What takes the place of Polly on Boot 3, and what on Boot 4?
@Retryable and
@ConcurrencyLimit are in Spring Framework itself. For a circuit breaker or a rate
limiter you still add Resilience4j, on both versions.Quick reference#
| 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#
Messaging between services works in the way you know from MassTransit or the raw client
libraries. A Template class sends a message. A method with a listener
annotation receives the message. Push to the browser
is different, because Spring has no single equivalent of SignalR.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | ProduceAsync (Confluent.Kafka) | kafkaTemplate.send(topic, key, value) | records with the same key go to one partition, so they stay in order Why |
| twist | Consume loop | a @KafkaListener method | Spring calls the method for each record, so there is no loop to write Why |
| trap | an exception in the consume loop | a listener method that throws | redelivered nine more times, then logged and skipped, unless you add a dead-letter topic Why |
| trap | a publish inside a database transaction | kafkaTemplate.send inside @Transactional | a rollback does not cancel the message that was sent, so use a transactional outbox, as in .NET Why |
| twist | _bus.Publish(new OrderPlaced(order.Id)) | rabbitTemplate.convertAndSend(exchange, routingKey, event) | MassTransit chooses the exchange for you, but with Spring AMQP you name the exchange Why |
| twist | IConsumer<T> (MassTransit) | a @RabbitListener method | Spring calls the method for each message on the queue Why |
| twist | MassTransit sagas and scheduling | no equivalent | Spring AMQP does not include these, so you build them yourself. The nearest Java libraries are Spring Integration, Camel and Axon. Why |
| new | no equivalent | jmsClient.destination("orders").send(event) | Jakarta Messaging is the standard messaging API in Java, and JmsClient needs Boot 4 Why |
| twist | MapHub<PricesHub>("/prices") | a STOMP broker over WebSocket, with @MessageMapping | no Java framework bundles what SignalR does, so Spring gives you the pieces Why |
| twist | Clients.Group(...).SendAsync | messagingTemplate.convertAndSend("/topic/...", payload) | pushes to every client subscribed to that destination Why |
| trap | a SignalR backplane | enableStompBrokerRelay, to RabbitMQ or ActiveMQ | enableSimpleBroker is in memory, so it works on one instance only Why |
| twist | TypedResults.ServerSentEvents | SseEmitter, or a Flux on WebFlux | The C# call needs ASP.NET Core 10. Server-sent events are simpler than WebSocket when only the server pushes. Why |
Kafkatrap#
With Kafka, the producer sends to a topic, and the consumer listens on the same topic:
// 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);This line sends the event to the
orderstopic. The key is the id of the order. Events with the same key go to the same partition, so the events of one order stay in order.@KafkaListener(topics = "orders",Spring calls this method for each record on the topic. In Kafka, a message is called a record. Spring turns the JSON of the record into an
OrderPlaced. You do not write a loop.groupId = "billing")This names the consumer group. Each record goes to one listener in the group.
The Kafka settings in application.ymldeep dive
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 that is still on the topic. So the group reads the old records too, and not only the new records.
spring.json.trusted.packages: "com.acme.billing.events"The JSON reader creates classes from this package only. So a message cannot make the JSON reader create an object of any other type.
acks: allA send is complete only when every in-sync replica has the record.
The listener method commits the offset by returning normally. If the
method throws, Spring's default error handler redelivers the record up to nine more times, with
no pause. Then the error handler logs the record at ERROR level and continues with
the next record. So an unhandled exception drops the message, and only a log line remains.
Configure a DefaultErrorHandler with a DeadLetterPublishingRecoverer.
Then a failed record goes to a dead-letter topic and is not lost.
Ordering, retries and the outbox#
These settings become important when a Kafka consumer handles production traffic:
| Concern | Spring Kafka |
|---|---|
| Ordering | guaranteed per partition only, so 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 that 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);When no retries remain, this recoverer publishes the failed record to a dead-letter topic. The name of that topic is the name of the original topic with
-dltadded. The record goes to the same partition as in the original topic. So the dead-letter topic needs at least as many partitions as the original topic.new FixedBackOff(1000L, 3)This gives three retries after the first delivery, one second apart. That is four deliveries in total.
There is no distributed transaction across Kafka and your database.
Suppose you publish inside an @Transactional method, and the method later rolls
back. The message is still sent. The standard fix is called the
transactional outbox. Write the event to a table in the same transaction. A
separate poller then publishes the event. The problem and the solution are the same as in
.NET. For more about the outbox, see the chapter
Transactions, propagation and isolation.
RabbitMQ#
RabbitMQ uses the Advanced Message Queuing Protocol (AMQP). Spring AMQP is the client library for RabbitMQ. 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(This call converts the event to a message. Then it publishes the message to the exchange
orders.exchangewith the routing keyorder.placed. MassTransit chooses the exchange for you. Here you name the exchange yourself.@RabbitListener(queues = "orders.queue")Spring calls this method for each message on the queue. This is like
IConsumerin MassTransit.
You declare the rest of the configuration yourself:
| Concept | Spring AMQP |
|---|---|
| Declare topology | @Bean Queue / TopicExchange / Binding |
| Serialisation | JacksonJsonMessageConverter, registered as a bean, but 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 much that Spring AMQP does not do, such as saga state, scheduling and conventional routing. With Spring AMQP, you build these yourself. The nearest Java equivalents are Spring Integration, Apache Camel and Axon. But none of these three is a direct replacement for MassTransit or NServiceBus.
JMS and JmsClient#
Java also has a standard messaging API. It is called Jakarta Messaging (JMS). It is used
with brokers such as ActiveMQ Artemis and IBM MQ. Spring has wrapped JMS in
JmsTemplate for a long time. Spring Framework 7 also adds a fluent
JmsClient Boot 4:
jmsClient.destination("orders")
.send(new OrderPlaced(order.getId()));
Optional<OrderPlaced> next = jmsClient.destination("orders")
.withReceiveTimeout(1000)
.receive(OrderPlaced.class);jmsClient.destination("orders")This call picks the queue or topic. The calls after it describe the operation on that queue or topic.
.receive(OrderPlaced.class);This call waits for one message, for at most the timeout. It returns the message as an
Optional. TheOptionalis empty if no message arrived. For a steady stream of messages, mark a method with@JmsListener. This annotation works like@KafkaListener.
WebSockets and the SignalR gapnew#
The web page of the shop shows live exchange rates. The server pushes each rate to the page when the rate changes. In ASP.NET Core, you write this as a SignalR hub:
C# refresherSignalR hubs: server push to groups of clientsC# 6
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 groupNo Java framework does everything that SignalR does in one library. SignalR gives you a hub abstraction, automatic transport negotiation and fallback, typed clients, reconnection and backplane scale-out. Spring gives you the separate parts:
| SignalR feature | Spring equivalent |
|---|---|
| Hub | @Controller with @MessageMapping, over STOMP |
| Strongly typed hub client | none, so you send to a destination by name |
| Transport fallback | none built in, but SockJS is an optional fallback for WebSocket |
| 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 equivalent of a hub is a broker that uses the Simple Text Oriented Messaging
Protocol (STOMP) over WebSocket. Clients subscribe to named destinations, such as
/topic/prices. The server sends messages to those destinations:
@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("*");This line sets the address on which browsers open the WebSocket, like
MapHubin SignalR. In production, allow only the origin of your own front end, not*.registry.enableSimpleBroker("/topic");This line enables a broker inside the application. The broker takes each message that is sent to a
/topic/…destination. It delivers the message to every client that is subscribed to that destination.@MessageMapping("/subscribe")This method handles the messages that a client sends to
/app/subscribe, like a hub method in SignalR. Herepricesis a service that returns the latest price.messagingTemplate.convertAndSend("/topic/prices", price);The type of
messagingTemplateisSimpMessagingTemplate, which is part of Spring. Spring injects it like any other bean. This call pushes the price to every subscriber of the destination, likeClients.Group(...).SendAsyncin SignalR.
enableSimpleBroker creates an in-memory broker. This broker
works correctly on one instance. It fails as soon as you run more than one instance. The reason
is that a message published on instance A never reaches a client connected to instance B. For
more than one instance, you need enableStompBrokerRelay, which points to RabbitMQ
or ActiveMQ. This is the equivalent of adding a SignalR backplane.
Server-sent events, the simpler option#
Sometimes you need push from server to client only, and not in both directions. Then
server-sent events are much simpler than WebSocket. Server-sent events also pass through most
load balancers and firewalls. ASP.NET Core 10 has server-sent events built in.
Spring MVC is the model-view-controller (MVC) web framework
of Spring. Spring MVC has the class SseEmitter for server-sent events:
// 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);This line creates an open response. The method returns the response at once, and the code writes to it later.
0Lmeans that the response never times out.executor.submit(() -> {The sending runs on another thread. That thread comes from an executor of virtual threads. The request thread is free during this time.
emitter.send(price);This line writes one event,
data:{"pair":"GBPUSD","value":1.27}, followed by a blank line.sendcan throwIOException, so the call is inside atryblock.emitter.complete();This line ends the stream. A real price feed would continue to send events.
WebFlux is the reactive web stack of Spring. On
WebFlux, you return a Flux<Price> with the same produces
value. On virtual threads, an
SseEmitter that a blocking loop writes to is now a reasonable design. Before
virtual threads, it was not a reasonable design. See
Threads are cheap now.
1A @KafkaListener method throws every time it receives one record. What happens
to that record by default?
@KafkaListener method throws every time it receives one record. What happens
to that record by default?ERROR level and continues with the next record. The message is dropped,
unless a DeadLetterPublishingRecoverer sends it to a dead-letter topic.Actuator and observability#
Actuator gives you health checks, metrics and diagnostics in one dependency. That is more than ASP.NET Core gives you by default. This chapter also covers metrics, logging and tracing. And it covers Java Flight Recorder (JFR), which is a profiler built into the Java Development Kit (JDK).
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | AddHealthChecks() | /actuator/health | one starter adds health, metrics and diagnostic endpoints under /actuator Why |
| trap | an endpoint is reachable once you map it | management.endpoints.web.exposure.include | only health is exposed over HTTP by default, and you must never expose env or heapdump publicly Why |
| twist | health checks for Kubernetes probes | /actuator/health/liveness and /readiness | Boot 4 adds these two endpoints by default, but Boot 3 adds them only when it detects Kubernetes Why |
| twist | editing appsettings.json, which reloads | POST /actuator/loggers/com.acme.billing | an HTTP call changes the level on one running instance, but ASP.NET Core rereads the file instead Why |
| twist | IHealthCheck | a HealthIndicator bean | Any bean that implements HealthIndicator joins /actuator/health. The package of HealthIndicator moved in Boot 4. Why |
| twist | meter.CreateCounter<long>("orders.placed") | Counter.builder("orders.placed").register(registry) | Micrometer is the metrics facade. Prometheus scrapes this counter as orders_placed_total. Why |
| trap | a filter that times each call | @Timed on a service method | does nothing until you enable observation annotations and add AspectJ Why |
| twist | ILogger<T> | SLF4J Logger | your code calls SLF4J, and Logback does the actual writing Why |
| twist | {Named} placeholders | {} positional placeholders | SLF4J placeholders are positional, and argument names are not kept Why |
| twist | BeginScope | MDC | per-thread key/value pairs that every log line can include Why |
| twist | appsettings logging levels | logging.level.* in application.yml | sets the level for one package and everything below it Why |
| twist | Serilog | Logback | Logback is what Spring Boot uses unless you switch Why |
| twist | NLog | Log4j2 | Boot also includes a default configuration for Log4j2 if you switch Why |
| twist | a JSON formatter | logging.structured.format.console: ecs | Boot 3.4 and later write JSON logs with one setting and no extra library Why |
| twist | OpenTelemetry for .NET | spring-boot-starter-opentelemetry, or the Java agent | The starter needs Boot 4. The agent instruments the application with no code changes. Why |
| twist | dotnet-trace and dotnet-counters, installed as separate tools | jcmd <pid> JFR.start | a recorder built into the JDK, started against a running process with nothing to install Why |
Actuator endpointsnew#
The billing service runs in Kubernetes. Kubernetes must know whether each instance is alive
and ready for traffic. The operations team also wants metrics. A single
starter named 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 | appsettings.json, reread when it changes |
| /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 these endpoints are hidden until you list them. Here the service exposes four endpoints, and it 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,loggersThis line lists the endpoints that are reachable over HTTP. Without this line, only
healthis reachable.show-details: when-authorizedAnonymous callers see only
UPorDOWN. Signed-in callers see each check. The default value isnever.enabled: trueThis line adds
/actuator/health/livenessand/actuator/health/readinessfor Kubernetes probes. Boot 4 adds these two endpoints by default, so this line matters on Boot 3. Without the line, Boot 3 adds the two endpoints only when it detects Kubernetes.
The same endpoints can also be published over Java Management Extensions (JMX). JMX is the
older management interface that is built into Java. Tools such as JConsole read it. JMX is off
in Boot unless you set spring.jmx.enabled=true.
Only health is exposed over HTTP by default. You must list every other endpoint
before it is exposed. Do not expose these four endpoints publicly, because
they leak configuration and memory contents:
envheapdumpbeansconfigprops
Put Actuator on a separate port that is not routed from the internet, or secure it. The
setting for the port is management.server.port.
The loggers endpoint is useful to know. Here an engineer sets the log level to
DEBUG 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.billingThis address names the logger for one package and for everything below that package. The service answers 204, and the new level applies at once.
-d '{"configuredLevel":"DEBUG"}'This is the new level. Later, post
nullto restore the earlier level.
You collect the information that you need, and then you restore the level. You do not
restart or redeploy the service. ASP.NET Core also changes a level without a restart, but it
uses the file. A running host rereads appsettings.json when the file changes.
Custom health checks#
The billing service depends on the rates service. So the health of the billing service should include the health of the rates service. 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. The key of this check is the bean name without the suffix:rates..withDetail("latencyMs", ms).build();This line adds extra information to the result. Callers who are allowed to see details see this information, as in
"rates": {"details": {"latencyMs": 12}, "status": "UP"}.return Health.down(e).build();This line reports the check as down. The message of the exception is included as a detail.
On Boot 4, HealthIndicator and Health are in the package
org.springframework.boot.health.contributor. On Boot 3, both types were in
org.springframework.boot.actuate.health. Boot registers its own checks for disk
space, the datasource, Redis, Kafka and most other configured infrastructure. So you get many
of the health checks with no code of your own.
Metricstwist#
Micrometer does the same as System.Diagnostics.Metrics in .NET:
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. The registry holds every metric. It gives the metrics to the backend that you use, such as Prometheus..tag("status", "ok")This line puts a key and a value on the metric, like the
KeyValuePairin the C# code. Each distinct value is a separate series.
The meter types of Micrometer match the instruments of .NET:
| 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 |
Never tag a metric with something unbounded, such as a user ID, an order ID or a raw URL. Each distinct combination of tags is a separate time series. A tag that has very many distinct values is called a high-cardinality tag. A high-cardinality tag will make your metrics backend fail before it makes your application fail. Prometheus has the same rule in general. But Micrometer makes the rule easy to break by accident inside a loop.
The Prometheus output, and timing a method with @Timed#
When Prometheus scrapes /actuator/prometheus, the counter looks like this:
orders_placed_total{status="ok"} 1.0orders_placed_total{status="ok"} 1.0Micrometer changes the dots into underscores and adds
_total. Prometheus expects this form for a counter.
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})This annotation measures how long each call takes. The metric is named
orders.place. It includes the median, the 95th percentile and the 99th percentile.
@Timed on a service method does nothing by default, and no metric appears. To
make it work, do two things. First, set
management.observations.annotations.enabled=true. Second, add
spring-boot-starter-aspectj, which brings the AspectJ library that the
annotation needs. On Boot 3, the same starter is named spring-boot-starter-aop.
Controllers and repositories are already timed, so annotating them gives you duplicates.
Loggingtwist#
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);Each class has one logger, kept in a
static finalfield. Your code calls the Simple Logging Facade for Java (SLF4J). Logback is the library that Boot uses, and Logback does the writing.log.info("Placed order {} for {}", id, total);Each
{}is filled by the next argument, in order. The line in the log isPlaced order 42 for 19.99. The placeholders have no names.
C# refresherILogger scopes and appsettings log levelsC# 6
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" } } }In place of BeginScope, Java uses the mapped diagnostic context (MDC). The MDC
holds key/value pairs for each thread, and every log line can include them.
SLF4J placeholders are written {}. They are positional, not named. Use
placeholders, not string concatenation. log.debug("x is " + value) builds the
string even when debug is off. log.debug("x is {}", value) does not build the
string when debug is off. In both forms, the arguments themselves are evaluated. So if an
argument is slow to calculate, put the call inside if (log.isDebugEnabled()), or
pass a Supplier.
logback-spring.xml and JSON logging#
In a container, an application usually writes its logs as JSON. Boot writes JSON itself, with one setting:
logging:
structured:
format:
console: ecsconsole: ecsThis setting writes 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"}. The other formats aregelfandlogstash.
application.yml can set levels, a console pattern and structured output. For
anything more, such as per-appender routing or file rotation, you need a Logback configuration
file. Name the file logback-spring.xml, not logback.xml. Boot loads
the file that has -spring in its name. So Spring properties and profiles work
inside that file.
<!-- 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">This block applies only when the
prodprofile is not active. It gives plain console output for developers.<encoder class="net.logstash.logback.encoder.LogstashEncoder">In production, the Logstash encoder library writes each line as JSON. Each line includes the correlation id from the mapped diagnostic context (MDC).
Each Serilog concept has a Logback equivalent:
| 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, which the start of this section shows. So JSON output no longer needs the Logstash encoder dependency. On Boot 3.4 or later, prefer the built-in structured logging. Use the XML file only when you need routing or rotation that the built-in logging does not provide.
Tracing#
Micrometer Tracing together with OpenTelemetry gives you distributed tracing. Distributed tracing means that one trace id follows a request across services. Boot 4 adds a dedicated starter for this Boot 4:
<!-- Boot 4 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency><artifactId>spring-boot-starter-opentelemetry</artifactId>This starter brings Micrometer Tracing with the OpenTelemetry bridge and exporter, and it configures them. So incoming and outgoing HTTP calls carry trace ids.
The alternative is the OpenTelemetry Java agent, and it is often the better
choice. You attach the agent with -javaagent:opentelemetry-javaagent.jar. The
agent rewrites the bytecode of classes when they load.
It does this for Spring, Java Database Connectivity (JDBC),
HTTP client and Kafka classes. So the agent instruments these classes with
no code changes. OpenTelemetry offers a similar zero-code option for .NET. You set up
that option through environment variables before the process starts.
JFR: the recorder built into the JDK#
JDK Flight Recorder is an always-on event recorder with low overhead. It is built into the JDK. You can start a recording on 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, which you name by its process id. This command starts a 60-second recording with the more detailedprofilesettings.jcmd <pid> JFR.dump name=diag filename=/tmp/now.jfrThis command writes the contents of the recording so far to a file. It does not stop the recording.
Open the file in JDK Mission Control. This tool shows allocation profiles, lock contention, garbage collection (GC) pauses, I/O, thrown exceptions and CPU sampling, all on one timeline. Java 25 added CPU-time profiling Java 25 experimental. Java 25 also added events that time and trace chosen methods.
For each profiling task, here is the .NET tool and the Java tool that matches it:
| 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* |
1Which Actuator endpoints can be reached over HTTP by default, and which should never be
public?
health can be reached by default. You expose the other endpoints with
management.endpoints.web.exposure.include. The endpoints env and
heapdump should never be public. The same is true for beans and
configprops. These four endpoints leak configuration and memory contents.Quick reference#
| Setting | Use it to |
|---|---|
| management.endpoints.web.exposure.include | choose the endpoints reachable over HTTP |
| management.endpoint.health.show-details | show each check, set to 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 in the format ecs, gelf or logstash |
Caching, scheduling and events#
You will meet three features within weeks. They are caching the result of a method, running a method on a timetable, and publishing events inside the process. All three features work through a proxy. So the rules in the chapter How Spring actually works apply.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | _cache.TryGetValue then _cache.Set | @Cacheable on the method | the proxy does the lookup, the miss handling and the storing Why |
| trap | AddMemoryCache() | @EnableCaching | Without @EnableCaching, every cache annotation does nothing and nothing warns you. @EnableScheduling and @EnableAsync work in the same way. Why |
| twist | _cache.Remove(key) | @CacheEvict(value = "rates", key = "#rate.pair()") | keys and conditions are written in Spring's expression language Why |
| trap | TimeSpan.FromMinutes(5) on each entry | spring.cache.type: caffeine, with a spec | the default store never evicts and never expires, so configure Caffeine or Redis Why |
| twist | BackgroundService with a Task.Delay loop | @Scheduled(fixedDelay = ...) | runs once at startup, then again that long after each run finishes Why |
| trap | a five-field cron line, as in Unix | cron = "0 0 3 * * *" | Spring cron has six fields with seconds first, so with five fields the application refuses to start Why |
| trap | several timers running at once | spring.task.scheduling.pool.size | the scheduler has one thread by default, so one slow job delays every other job Why |
| trap | a job that must run once | @SchedulerLock, from ShedLock | every instance runs every schedule, and there is no built-in leader election Why |
| twist | Hangfire | Quartz | the choice for jobs with retries, persistence and a dashboard Why |
| twist | INotification and INotificationHandler | an event record and an @EventListener method | Spring picks the listener by its parameter type, with no interface to implement Why |
| twist | _mediator.Publish(new OrderPlaced(order.Id)) | events.publishEvent(new OrderPlaced(order.getId())) | as with MediatR, listeners run on the caller's thread, and a listener that throws fails the caller Why |
| twist | a handler that must wait for the commit | @TransactionalEventListener | runs after the transaction commits, so it is the safe default for side effects Why |
| twist | Task.Run(...) | an @Async method | The call returns at once. The method needs @EnableAsync, and it may return only void or a Future. Why |
| trap | a method that calls another in its own class | self-invocation | The call skips the proxy, so @Async or @Cacheable does nothing. On a final method, these annotations also do nothing. Why |
Cachingtrap#
In ASP.NET Core, you check a cache yourself. On a miss, you call the service and store the result. In Spring, one annotation does all of these steps:
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. The key is the argumentpair. On a hit, Spring returns the stored value, and the method does not run at all. On a miss, Spring runs the method and stores the value that the method returns.
These are the cache annotations, with the IMemoryCache call that each annotation
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 | MemoryCache.Clear(), which IMemoryCache does not have |
| @Caching | combine several of the above | several calls |
| @EnableCaching | switch the whole mechanism on | AddMemoryCache |
Forgetting @EnableCaching makes every cache annotation do
nothing. Nothing warns you. The code still works, because it calls the method every
time. The same is true of @EnableScheduling and @EnableAsync. When a
caching layer appears to have no effect, the cause is nearly always a missing
@EnableCaching.
Cache keys, conditions and eviction#
Here the annotations are used together, on the rates of the billing service:
@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) { ... }
}@EnableCachingThis annotation enables caching. You write it once, in any configuration class.
key = "#pair + ':' + #date"This expression builds the key from both arguments, for example
EURUSD:2026-09-13. The key is written in Spring's expression language. In this language,#pairmeans the parameterpair.@CacheEvict(value = "rates", key = "#rate.pair()")This annotation removes the entry for the pair of this rate.
Rateis a record, so you read the pair withpair().unless = "#result == null"Spring runs the method. Then Spring stores the result only when the result is not null. So a missing rate is fetched again on the next call.
condition = "#pair.length() == 6"Spring uses the cache only for pairs of six letters, such as
EURUSD. With any other argument, Spring does not use the cache at all.
condition and unless are not the same. condition is
evaluated before the call. It decides whether caching applies at all.
unless is evaluated after the call. It decides whether to store the
result. Only unless can see #result.
Choosing the cache store#
The annotations work with any cache store. You choose the store separately:
| Provider | Add | Notes |
|---|---|---|
| Simple (ConcurrentHashMap) | nothing, because it is the default | no eviction and no size limit, so use it in development only |
| Caffeine | com.github.ben-manes.caffeine | the usual choice for a cache inside the 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, which is an in-process cache with a size limit and expiry:
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=10000,expireAfterWrite=5mspec: maximumSize=10000,expireAfterWrite=5mEach cache holds at most 10,000 entries. Each entry is removed 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 an
entry, and an entry never expires. So in practice it is a memory leak, not a cache. Configure
Caffeine or Redis before the service goes to production.
Schedulingtrap#
In .NET, you write a BackgroundService with a loop and a delay. In Spring, you
write 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)Spring runs
reconcileonce at startup. Then Spring runs it again 30 minutes after each run finishes, like the loop withTask.Delay. The annotation 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 * * *" | a cron expression with six fields, and the first field is seconds |
Spring cron expressions have six fields, not five. The first field is
seconds, so "0 0 3 * * *" means 3:00:00 every day. Suppose you paste a
Unix crontab line with five fields, such as "0 3 * * *". Then the application
refuses to start, with the message “Cron expression must consist of 6 fields”.
Also, fixedRate never overlaps runs by default. If a run takes longer than the
interval, the next run waits for it to finish.
The scheduler runs on a single thread by default, so one slow job delays
every other job. Set spring.task.scheduling.pool.size to change this. On Java 21 or
later, consider virtual threads for jobs that
block.
A job that must run once: ShedLock and Quartz#
Every instance runs every schedule. If you deploy three replicas, your nightly reconciliation runs three times. There is no built-in leader election. The standard answer is ShedLock. ShedLock is a library that takes a lock in a shared database or in Redis.
With ShedLock, the nightly job runs on the instance that takes the lock first:
@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "reconcile", lockAtMostFor = "30m")
public void reconcile() { ... }@SchedulerLock(name = "reconcile", lockAtMostFor = "30m")The lock is named
reconcile. Only the instance that holds this lock runs the job. If that instance fails in the middle of a run, the lock expires after 30 minutes.
Some jobs need more, such as retries, persistence and a dashboard. For these jobs, Quartz is the equivalent of Hangfire.
Application eventstwist#
Spring has publish and subscribe inside the process. It works like the notifications of MediatR 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()));The type of
eventsisApplicationEventPublisher, which is part of Spring. Spring injects it like any other bean. The event can be any object, and a record is the usual choice.@EventListenerSpring calls this method for every published
OrderPlaced. Spring chooses the method by its parameter type. You do not implement an interface.
@EventListener is synchronous by default, like a MediatR
handler. The publisher blocks until every listener returns. Every listener runs on the same
thread as the publisher, and inside the same transaction. So a slow listener slows the request,
and a listener that throws fails the caller.
Add @Async to run the listener on another thread. The listener then runs
outside the transaction. For @TransactionalEventListener, see
Transactions, propagation and isolation.
The annotation has variants:
| 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, and outside the transaction |
| @TransactionalEventListener | after the transaction commits, so it is the safe default for side effects |
@Async#
Rebuilding the monthly report takes a minute. The request that starts the rebuild should not
wait for it. In .NET, you would start a task. In Spring, you mark the method with
@Async:
_ = Task.Run(() => _reports.Rebuild());@Async
public void rebuild() { ... } // returns immediately
@Async
public CompletableFuture<Report> build() {
return CompletableFuture.completedFuture(...);
}public void rebuild() { ... }The call returns to the caller at once. The method runs on another thread.
public CompletableFuture<Report> build() {To return a result, use the return type
CompletableFuture. This type does the same asTask<T>in C#. The caller can wait for theCompletableFuture, or chain more work onto it.
@Async works through a proxy, like the other features in this chapter. So a
self-invocation runs synchronously. This
failure is very hard to find. The code still works, but on the wrong thread, and the wrong
thread matters only under load.
@Async also needs @EnableAsync. An @Async method may
return only void or a Future. CompletableFuture is one
kind of Future. A method that returns a plain value fails when it is called, with
the message “Invalid return type for async method”.
On Java 21 or later, you can make the executor
use virtual threads. Then @Async no longer needs a tuned pool size:
spring:
threads:
virtual:
enabled: trueenabled: trueBoot then runs
@Asyncmethods and scheduled jobs on virtual threads. By default, Boot uses a pool of eight threads for@Asyncand one thread for scheduling.
1A method carries @Cacheable, yet it runs on every call and nothing is cached.
What is the most likely cause?
@Cacheable, yet it runs on every call and nothing is cached.
What is the most likely cause?@EnableCaching is missing. Without it, every cache annotation is ignored, and
nothing warns you. The code still works, because it calls the method every time. The same is
true of @EnableScheduling and @EnableAsync.Quick reference#
| 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, because the default is 1 |
| spring.threads.virtual.enabled | run @Async methods and scheduled jobs on virtual threads |
Testing Spring Boot#
@SpringBootTest does the same as WebApplicationFactory<T> in
ASP.NET Core. It starts the real
application context for an integration test.
Spring also has test slices, such as @WebMvcTest and
@DataJpaTest. A test slice starts only one layer, so the test is faster.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | WebApplicationFactory<Program> | @SpringBootTest | starts the whole application context for an integration test Why |
| twist | IClassFixture<...> | the cached application context, shared automatically | Spring caches a context across test classes with the same configuration Why |
| twist | HttpClient from app.CreateClient() | MockMvc, or TestRestTemplate and RestTestClient on a random port | MockMvc needs no server, but TestRestTemplate makes a real HTTP call Why |
| new | no equivalent | @WebMvcTest, @DataJpaTest and other slices | A test slice starts only one layer. On Boot 4 each slice has its own test module. Why |
| twist | no equivalent | @DataMongoTest | It is the slice for document data. Unlike @DataJpaTest, it rolls nothing back. Why |
| new | no equivalent | @WebMvcTest(OrderController.class) | starts the web layer with this one controller, and provides a MockMvc Why |
| trap | no equivalent | @WithMockUser on a @WebMvcTest test | with Spring Security present, the slice applies Boot's default security Why |
| twist | a Moq mock in place of a service | @MockitoBean, @MockitoSpyBean | They add a Mockito mock or spy as a bean. Boot 4 removed @MockBean and @SpyBean. Why |
| twist | no equivalent | @AutoConfigureMockMvc | @SpringBootTest provides no MockMvc without it, on Boot 3 or Boot 4. Boot 4 moved its package. Why |
| twist | no equivalent | @AutoConfigureTestRestTemplate | Boot 3 gave a @SpringBootTest a TestRestTemplate by itself, but Boot 4 needs this annotation Why |
| twist | a database in a container | @Testcontainers, @ServiceConnection | Boot reads the container's address and points the datasource at it Why |
| trap | no equivalent | @DataJpaTest with a @ServiceConnection container | the slice rolls back each test, and it uses H2 unless the database comes from such a container Why |
| twist | no equivalent | @TestPropertySource, @ActiveProfiles, @TestConfiguration | they adjust the properties, profiles and beans of a test's context Why |
| twist | a unit test that starts no host | a plain JUnit test, with no Spring | constructor injection lets a test create the service with new Why |
Test slicesnew#
Test each layer with a test that starts only what the layer needs, because a test that starts less is faster. These are the choices:
| Annotation | Starts | Use for |
|---|---|---|
| @SpringBootTest | the whole context | end-to-end integration |
| @WebMvcTest(X.class) | web layer only, with 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, because the test is plain JUnit | unit tests, which should be most of them |
On Boot 4, each slice comes from its own test module. The module is named after the technology of the slice:
@WebMvcTestcomes from the modulespring-boot-webmvc-test. Its package isorg.springframework.boot.webmvc.test.autoconfigure.@DataJpaTestcomes from the modulespring-boot-data-jpa-test.
Each module has a starter that you add to the build.
The two starters are spring-boot-starter-webmvc-test and
spring-boot-starter-data-jpa-test. The starter
spring-boot-starter-test alone does not bring these two modules. On Boot 3, all
slices came from one module, so the imports change when you upgrade.
Document data has a slice of its own, called @DataMongoTest. See
Testing without a cluster.
Testing a controllernew#
The test below checks that GET /api/orders/1 returns the order, and it uses no
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. You 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)This annotation starts Spring MVC with only this controller. Spring MVC is the model-view-controller (MVC) web framework of Spring. The annotation starts no services, no repositories and no database. It also provides a
MockMvc, which the test uses to call the controller.@MockitoBean OrderService orders;This line puts a Mockito mock in the context as the
OrderServicebean, so the controller receives the mock. An unstubbed method of the mock returns an empty value. Here the empty value isOptional.empty(), and the controller turns it into a 404.when(orders.find(1L)).thenReturn(Optional.of(This is Mockito stubbing. From now on, the call
orders.find(1L)returns that order. See the chapter Testing.mvc.perform(get("/api/orders/1"))This line sends a request through Spring MVC, without a real server or network. The next two lines check the status and the
idin the JSON body.
// Spring Security is on the classpath, and the application has its own SecurityConfig.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService orders; // stubbed to return order 1
@Test
void get_returnsOrder() throws Exception {
mvc.perform(get("/api/orders/1"))
.andExpect(status().isOk());
}
}Show the answer
A @WebMvcTest slice applies Boot's default security. That
default requires a login for every endpoint. The slice does not load
the application's own SecurityConfig. So the test fails with
Status expected:<200> but was:<401>. But the same test passes when
Spring Security is not on the classpath.
The annotation @WithMockUser comes from spring-security-test. Add
it, and the test passes:
import org.springframework.security.test.context.support.WithMockUser;
@Test
@WithMockUser
void get_returnsOrder() throws Exception { ... }The test now passes against Boot's default rules, not against your rules. To test your own
rules, add @Import(SecurityConfig.class) to the test class.
Boot 4: @MockBean becomes @MockitoBeantwist#
@MockBean and @SpyBean are removed in Boot 4. They
are removed, not deprecated. Replace them with @MockitoBean and
@MockitoSpyBean. These two annotations come from Spring Framework. They have been
available since Boot 3.4, so you can migrate before you upgrade.
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. Each call still reaches the real bean, and the test can check which calls were made.
@SpyBeandid the same before Boot 4.
MockMvc and TestRestTemplate under @SpringBootTesttwist#
@SpringBootTest does not provide MockMvc by itself, on Boot 3 or on
Boot 4. Add @AutoConfigureMockMvc, or the test fails with “No
qualifying bean of type MockMvc”. Under @WebMvcTest, MockMvc is provided.
Boot 4 moved the annotation to the package
org.springframework.boot.webmvc.test.autoconfigure. The class is in the module
spring-boot-webmvc-test. The starter that you
add is spring-boot-starter-webmvc-test. But Boot 4 did stop providing
TestRestTemplate. To get one, add
@AutoConfigureTestRestTemplate.
The integration test is the same on both versions, except for the import:
// Boot 3
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class OrderIntegrationTest {
@Autowired MockMvc mvc;
}// Boot 4: the annotation moved to the spring-boot-webmvc-test module
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class OrderIntegrationTest {
@Autowired MockMvc mvc;
}@AutoConfigureMockMvcThis annotation adds a
MockMvcbean to the full application context, so the test can call every controller without starting a server.
Full integration teststwist#
This test starts the whole billing service on a random port. It uses a real PostgreSQL database in a container. The container comes from 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)This annotation starts the real application, with a real web server on a free port.
@AutoConfigureTestRestTemplateBoot 4 needs this annotation to provide a
TestRestTemplate. TheTestRestTemplatenow 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 after the tests.@ServiceConnectionBoot reads the container's address, user name and password, and points the datasource at the container. The annotation comes from the
spring-boot-testcontainersmodule.static PostgreSQLContainer db = new PostgreSQLContainer("postgres:16");This is the Testcontainers 2 class, from the
org.testcontainers.postgresqlpackage. The older classPostgreSQLContainer<?>inorg.testcontainers.containersis deprecated.
What @ServiceConnection replaceddeep dive
@ServiceConnection replaces the old @DynamicPropertySource
boilerplate code, which copied container URLs into properties. Annotate the container, and Boot
wires the datasource, the Redis connection or the Kafka bootstrap servers for you.
@ServiceConnection is one of the most helpful features in modern Boot
testing.
Four clients can call the application in a test:
| Client | Use for |
|---|---|
| MockMvc | fast, with no real server and no network |
| TestRestTemplate | a real HTTP call against a random port |
| WebTestClient | fluent, works for MVC and WebFlux |
| RestTestClient | a fluent test client built on RestClient, new in Spring Framework 7 |
Testing repositoriestrap#
This test checks that OrderRepository.findByCustomerId finds a saved order. It
starts only Jakarta Persistence (JPA). The database is a
PostgreSQL container, as in the section
Full integration tests:
@DataJpaTest
@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);
}
}@DataJpaTestThis annotation starts JPA, the repositories and a datasource, and nothing else. Each test runs in a transaction that is rolled back at the end.
@Container @ServiceConnectionHere the database comes from a
@ServiceConnectioncontainer. Since Boot 3.4, the slice keeps such a database and does not replace it.assertThat(orders.findByCustomerId("cust-1")).hasSize(1);This is an AssertJ check that the derived query found exactly one order.
@DataJpaTest has two defaults. It replaces your datasource with an in-memory H2
database, unless the datasource comes from a test container. It also 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. The rollback hides constraint
violations that appear only on commit. A database named by a URL in
application.yml is still replaced. To keep that database, write
@AutoConfigureTestDatabase(replace = Replace.NONE).
Test configuration#
Slices are fast because they start only a small part of the context. Spring also caches
contexts across test classes with the same configuration. But this also means that every
distinct configuration creates a new context. So if many classes use different
@TestPropertySource values, the suite takes many times longer to run, and nothing
warns you.
These annotations adjust the context that 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, but use it rarely because it is slow |
Here a test fixes the clock. Code that depends on today's date then gives the same answer on every run:
@TestConfiguration
static class TestClock {
@Bean Clock clock() {
return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
}
}@TestConfigurationThis is a configuration class for tests only. Import it with
@Import(TestClock.class), and its beans are added to the test's context.return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);This line returns 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
must start a full context again. One careless @DirtiesContext on a class that runs
often can add minutes to a suite. It is better to reset the state explicitly.
Keep most tests plain#
The most common mistake that a team makes with Spring is to test everything through
@SpringBootTest. Then context startup takes most of the running time, and the suite
takes minutes. The tests also tell you little about your logic.
Test domain logic with plain JUnit and no Spring at all.
This is possible because
constructor injection makes your services
ordinary classes, which you can create with new. Use slices only for the wiring.
Use a full @SpringBootTest only for a few 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");This method compares
BigDecimalvalues by amount, so90equals90.00.
1TranslateWrite this test method in Java. It is in a
@WebMvcTest(OrderController.class) class that has @Autowired MockMvc mvc
and @MockitoBean OrderService orders:
[Fact]
public async Task Get_ReturnsOrder()
{
var res = await _client.GetAsync("/api/orders/1");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
}
@WebMvcTest(OrderController.class) class that has @Autowired MockMvc mvc
and @MockitoBean OrderService orders:
[Fact]
public async Task Get_ReturnsOrder()
{
var res = await _client.GetAsync("/api/orders/1");
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
}@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());
}The slice starts no real service, so stub the mock first. An unstubbed find
returns Optional.empty(), and the controller answers 404.
2A Boot 3 test class uses @MockBean. It also injects MockMvc under
@SpringBootTest with @AutoConfigureMockMvc. What must change on
Boot 4?
@MockBean. It also injects MockMvc under
@SpringBootTest with @AutoConfigureMockMvc. What must change on
Boot 4?@MockBean is removed, so write @MockitoBean in its place.
@AutoConfigureMockMvc is still needed, as it was on Boot 3, but its import changes.
The annotation moved to the package
org.springframework.boot.webmvc.test.autoconfigure. The class is in the module
spring-boot-webmvc-test. The starter spring-boot-starter-webmvc-test
brings that module into the build.3An expert asksA @DataJpaTest passes, and the same repository fails against the
real database. Which two defaults of the slice can hide that?
@DataJpaTest passes, and the same repository fails against the
real database. Which two defaults of the slice can hide that?@ServiceConnection container. H2 does not behave like PostgreSQL for
native queries, JSON columns or sequences. The slice also rolls back each test. The rollback
hides constraint violations that appear only on commit.Quick reference#
| Boot 3 | Boot 4 |
|---|---|
| @MockBean | @MockitoBean |
| @SpyBean | @MockitoSpyBean |
| @AutoConfigureMockMvc from org.springframework.boot.test.autoconfigure.web.servlet | from org.springframework.boot.webmvc.test.autoconfigure, in spring-boot-webmvc-test |
| 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 is built on Spring Framework 7 and Jakarta
EE 11. Moving a Boot 3 service to Boot 4 is not the painful change that the move from Boot 2
to Boot 3 was. That earlier move renamed javax to jakarta across the
whole ecosystem.
| Kind | What changes | Boot 3 becomes Boot 4 | Note |
|---|---|---|---|
| twist | Parent version | 3.5.x becomes 4.x | Boot 4 needs Java 17 or later, Spring Framework 7 and Jakarta EE 11 Why |
| twist | Test framework | JUnit 5 becomes JUnit 6 | Boot 4 also needs Kotlin 2.2 and GraalVM 25 or later, if you use them Why |
| twist | Web starter | spring-boot-starter-web becomes spring-boot-starter-webmvc | modules now follow one spring-boot-<technology> naming pattern Why |
| trap | Flyway and Liquibase | flyway-core becomes spring-boot-starter-flyway | Without the starter, Boot 4 starts with no error and runs no migration. Liquibase moved in the same way. Why |
| twist | Jackson imports | com.fasterxml.jackson becomes tools.jackson | every Jackson import moves, except com.fasterxml.jackson.annotation, which stays as it is Why |
| twist | Boot's Jackson annotations | @JsonComponent becomes @JacksonComponent, @JsonMixin becomes @JacksonMixin | Boot's own Jackson types were renamed to match Jackson 3 Why |
| twist | Mock beans in tests | @MockBean becomes @MockitoBean, @SpyBean becomes @MockitoSpyBean | removed in Boot 4, not deprecated, so switch on Boot 3.4 or 3.5, before you upgrade Why |
| twist | MockMvc in a @SpringBootTest | @AutoConfigureMockMvc, from org.springframework.boot.webmvc.test.autoconfigure | It is needed on Boot 3 too. On Boot 4, add the starter spring-boot-starter-webmvc-test, which brings its module. Why |
| twist | Test slices | @WebMvcTest comes from the module spring-boot-webmvc-test | you add the starter spring-boot-starter-webmvc-test to the build, and it brings that module Why |
| twist | Nullable annotation | org.springframework.lang.Nullable becomes org.jspecify.annotations.Nullable | the old annotation is deprecated, so it still compiles, with a warning Why |
| twist | TestRestTemplate in a @SpringBootTest | add @AutoConfigureTestRestTemplate | Boot 3 provided it with a running server, but Boot 4 needs the annotation and spring-boot-resttestclient Why |
| trap | Undertow | removed, so switch to Tomcat, Jetty or Netty | a dependency swap rather than a compile error, so it is easy to miss Why |
| twist | Removed in Spring Framework 7 | ListenableFuture and javax.inject support | use CompletableFuture and the jakarta.* equivalents instead Why |
| trap | Configuration settings | spring.jackson.parser.* becomes spring.jackson.json.read.* | Boot 4 ignores the old name and reports nothing, so a missed rename fails silently Why |
| trap | MongoDB connection settings | spring.data.mongodb.uri becomes spring.mongodb.uri | Boot 4 ignores the old name and tries localhost:27017. The setting auto-index-creation keeps its old name. Why |
| trap | A BigDecimal stored in MongoDB | a string becomes a Decimal128 | to keep strings on Boot 4, set spring.data.mongodb.representation.big-decimal to string Why |
| new | New in Boot 4 | @Retryable, @ImportHttpServices, JmsClient, RestTestClient | each new feature is shown in the chapter that covers it Why |
| twist | Upgrade order | Boot 3.5 first, then the test annotations, then Boot 4 | keep each step small, and never upgrade Boot and Java in the same change Why |
Baseline requirementstwist#
First, check the versions that Boot 4 needs:
| 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>This was the current Boot 4 release when this chapter was written. Check spring.io for a newer release.
<java.version>25</java.version>This is the Java version that the build compiles for. Boot 4 also runs on Java 17, which is the minimum, so change this value in a separate step. The last gotcha in this chapter explains why.
1. Renamed starterstwist#
Modules now follow one naming pattern, spring-boot-<technology>. The root
package of a module is
org.springframework.boot.<technology>. As a result, a few
starters changed name.
Flyway and Liquibase now need a starter:
| 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-starter-aop | spring-boot-starter-aspectj |
| flyway-core, with no starter | spring-boot-starter-flyway |
| liquibase-core, with no starter | spring-boot-starter-liquibase |
| 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>This is the same web stack under its new name. Test starters follow the same pattern, with one starter for each technology. An example is
spring-boot-starter-webmvc-test.
Flyway and Liquibase need their startertrap#
On Boot 4, Flyway on the classpath no longer
runs your migrations. Boot 4 moved the
auto-configuration of Flyway out of
spring-boot-autoconfigure. The auto-configuration is now in the module
spring-boot-flyway. In a run on Boot 4.1 with only flyway-core, the
application started with no error and ran no migration.
Replace the dependency with the starter:
<!-- Boot 3 needed only flyway-core. Boot 4 needs the starter. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>The starter spring-boot-starter-flyway brings two things. It brings
spring-boot-flyway, which runs the migrations. It also brings
flyway-core itself. Liquibase moved in the same way, so for Liquibase use
spring-boot-starter-liquibase.
2. Jackson 2 to Jackson 3twist#
Boot 4 uses Jackson 3 by default. The classes of
Jackson 3 are in tools.jackson, not in com.fasterxml.jackson. Only the
Jackson annotations did not move. Boot 4 still includes
support for Jackson 2, but that support is deprecated. Boot's own Jackson types were renamed to
match Jackson 3:
| Boot 3 | Boot 4 |
|---|---|
| com.fasterxml.jackson.* | tools.jackson.*, for every package except the annotations |
| com.fasterxml.jackson.annotation.* | unchanged, because Jackson 3 still uses these annotations |
| @JsonComponent | @JacksonComponent |
| @JsonMixin | @JacksonMixin |
| JsonObjectSerializer | ObjectValueSerializer |
| JsonValueDeserializer | ObjectValueDeserializer |
| Jackson2ObjectMapperBuilderCustomizer | JsonMapperBuilderCustomizer |
| Jackson2ObjectMapperBuilder | deprecated for removal in Spring Framework 7, so use Jackson's own builders |
| spring.jackson.parser.* | spring.jackson.json.read.*, where Jackson 3 still has the feature |
| spring.jackson.generator.* | spring.jackson.json.write.*, where Jackson 3 still has the feature |
Custom JSON for Money shows both changes:
import com.fasterxml.jackson.databind.ObjectMapper;
@JsonComponent
class MoneyJson { ... }import tools.jackson.databind.ObjectMapper;
@JacksonComponent
class MoneyJson { ... }import tools.jackson.databind.ObjectMapper;This is the same class, under the new root package of Jackson 3.
@JacksonComponentThis annotation registers the serializers in
MoneyJsonwith Boot's JSON mapper, as@JsonComponentdid. The annotation is in the packageorg.springframework.boot.jackson.
Do not find-and-replace the whole com.fasterxml.jackson root.
Every import moves to tools.jackson, except the imports of one package. The
annotations stay in com.fasterxml.jackson.annotation. Examples are
@JsonProperty, @JsonIgnore and @JsonCreator.
Also check your dependency tree. A library that still exposes Jackson 2 types in its API adds Jackson 2 to the build again, next to Jackson 3.
3. Test annotationstwist#
Several test annotations were removed or moved. For each annotation in code, see the chapter Testing Spring Boot. These are the changes:
| Boot 3 | Boot 4 | Note |
|---|---|---|
| @MockBean | @MockitoBean | removed in Boot 4, not deprecated, so switch before you upgrade |
| @SpyBean | @MockitoSpyBean | removed in Boot 4, not deprecated, so switch before you upgrade |
| MockitoTestExecutionListener | Mockito's MockitoExtension | |
| @AutoConfigureMockMvc from org.springframework.boot.test.autoconfigure.web.servlet | from org.springframework.boot.webmvc.test.autoconfigure | A @SpringBootTest needs it on both versions. Only its package and module changed. |
| @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 the module spring-boot-webmvc-test | add the starter spring-boot-starter-webmvc-test to the build, because it brings that module |
| @SpringBootTest gives TestRestTemplate | add @AutoConfigureTestRestTemplate | it also needs the spring-boot-resttestclient and spring-boot-restclient modules |
@MockitoBean and @MockitoSpyBean exist from Boot 3.4 onwards. If you
are on Boot 3.4 or 3.5, migrate to these two annotations before you upgrade. Then one
whole kind of failure is gone from the upgrade commit.
@SpringBootTest does not provide
MockMvc by itself, on Boot 3 or on Boot 4. Add
@AutoConfigureMockMvc. Without it, the test has no MockMvc
bean to inject, and the test fails with “No qualifying
bean of type MockMvc”. Boot 4 changed only the place of the annotation. The annotation is
now in the module spring-boot-webmvc-test. To get that module, add the starter
spring-boot-starter-webmvc-test to the build. But Boot 4 did stop providing
TestRestTemplate.
4. JSpecify null-safetytwist#
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;This annotation is still present in Spring Framework 7, but it is deprecated, so it compiles with a warning.
import org.jspecify.annotations.Nullable;This is the replacement. It has the same meaning, and it comes from the JSpecify standard, which null checkers understand.
The move has a benefit. Spring's signatures now say exactly where null is possible, and this includes generic type arguments. So a null checker such as NullAway gives far better results against Spring APIs than it did on Boot 3. See Nullability: Optional and JSpecify.
Removals in Spring Framework 7#
Some older APIs are removed, or are deprecated and will be removed:
| 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, so 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 change of dependency and not a compile error.
In a Maven build, you make the switch by 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 this dependency, the application uses Tomcat again. Tomcat is the default server of the web starter. If you had excluded Tomcat from the web starter so that Undertow could replace it, remove that exclusion too.
What you gainnew#
The upgrade also brings new features. Each feature 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 that it no longer knows. So a
missed rename causes no error at startup, and the setting just has no effect. While you upgrade,
Boot's spring-boot-properties-migrator module can report renamed settings at
startup. These are the renames:
| Boot 3 | Boot 4 |
|---|---|
| spring.jackson.parser.* | spring.jackson.json.read.* |
| spring.jackson.generator.* | spring.jackson.json.write.* |
| spring.data.mongodb.* (driver-level) | spring.mongodb.* |
| spring.session.redis | spring.session.data.redis |
Here are two of the renames, before and after:
# Boot 3
spring.jackson.parser.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=trueThis is the same Jackson feature. It is a
parsersetting on Boot 3, and ajson.readsetting on Boot 4. In a run on Boot 4.1, the oldspring.jackson.parserline was ignored, with no error. So single quotes were rejected again.spring.session.data.redis.namespace=billingThese are the Redis session settings, which are now under
spring.session.data.redis.
spring.data.mongodb.auto-index-creation keeps its name. It is a Spring Data
setting, not a driver setting, and the split is deliberate. If a service still sets
spring.data.mongodb.uri on Boot 4, Boot ignores that line. The service then tries
localhost:27017.
On Boot 4 a BigDecimal is no longer stored in MongoDB as a
string. Boot 3.5 manages Spring Data MongoDB 4.5. That version states that a
BigDecimal “is represented as String instead of Decimal128”. Spring Data
MongoDB 5 sets no default. In a run on Boot 4.1, the amount was stored as a Decimal128. So after
the upgrade, one collection holds old amounts as strings and new amounts as Decimal128.
Spring's upgrade notes say you “may choose BigDecimalRepresentation.STRING as default to
retain previous behaviour”. To do that, set
spring.data.mongodb.representation.big-decimal to string. Or convert
the stored amounts. The statement about Boot 3 comes from Spring's documentation. It was not
checked in a run. For the setting, see the chapter Document data: Amazon DocumentDB and the MongoDB API.
A suggested ordertwist#
This order keeps each step small:
| Step | Do |
|---|---|
| 1 | Get to Boot 3.5 and Java 17+ first, and fix all deprecation warnings |
| 2 | Replace @MockBean and @SpyBean with @MockitoBean and @MockitoSpyBean |
| 3 | Replace Undertow if you use it |
| 4 | Change the parent version to Boot 4. Then fix the starter names, and add the Flyway or Liquibase starter |
| 5 | Run the build, and fix each Jackson import error |
| 6 | Fix the import of @AutoConfigureMockMvc, and add @AutoConfigureTestRestTemplate where a test injects TestRestTemplate |
| 7 | Check the property renames above against your application.yml |
| 8 | Consider replacing Resilience4j with core @Retryable where it fits |
Do not combine a Boot 4 upgrade with a Java version upgrade in the same change. Each upgrade produces its own kind of failure. If you separate the two upgrades, both kinds of failure are far easier to diagnose.
1A service moves from Boot 3 to Boot 4, and so from Jackson 2 to Jackson 3. Which Jackson
imports change, and which stay?
com.fasterxml.jackson to
tools.jackson, except the imports of one package. The annotations stay in
com.fasterxml.jackson.annotation. An example is @JsonProperty. So a
find-and-replace of the whole root package breaks the annotations.2A Boot 3 service sets spring.data.mongodb.uri. What becomes of that setting on
Boot 4?
spring.data.mongodb.uri. What becomes of that setting on
Boot 4?spring.mongodb.uri. It ignores the old name and tries
localhost:27017, so the service cannot reach its database. The settings
database and ssl.bundle moved in the same way.
spring.data.mongodb.auto-index-creation kept its name.Packaging and native images#
The Java counterpart of dotnet publish is one
Java archive (JAR) file. The file holds your code, every
dependency and a web server. It is called a fat JAR. A fat JAR needs a
Java Virtual Machine (JVM) to run it. GraalVM
native-image does the same as NativeAOT in .NET.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | dotnet publish, framework-dependent | ./mvnw package, giving a fat JAR | one JAR holds your code, every dependency and an embedded web server Why |
| twist | expecting the libraries merged into one file | each dependency's JAR, kept whole inside the fat JAR | merging JARs lets one META-INF/services file silently replace another, so Boot does not merge Why |
| twist | dotnet billing.dll | java -jar target/billing-1.0.0.jar | runs on any machine that has a JVM installed, but the fat JAR does not contain a JVM Why |
| twist | a bare dll, a publish folder, an installer, NativeAOT | plain JAR, fat JAR, jlink, jpackage, native-image | there are five ways to ship, but a Spring Boot service almost always starts with the fat JAR Why |
| twist | dotnet publish /t:PublishContainer | ./mvnw spring-boot:build-image | Buildpacks build a layered container image, with no Dockerfile to maintain Why |
| twist | a Dockerfile that copies the publish output | java -Djarmode=tools -jar application.jar extract --layers | one image layer per JAR layer, so a code change re-sends only your own classes Why |
| twist | dotnet publish --self-contained | jlink, a trimmed runtime around your app | needs modular libraries, so it suits desktop and command-line tools more than Spring Boot Why |
| twist | an MSI or dmg installer | jpackage --type dmg | wraps the JAR and a runtime in a native installer of the type msi, dmg or deb Why |
| twist | PublishAot | GraalVM native-image | starts in milliseconds on a fraction of the resources, GraalVM states, but builds in minutes with lower peak throughput Why |
| trap | a feature toggle that adds a service from configuration | @ConditionalOnProperty | This is not supported in a native image. Beans are fixed at build time, so the toggle does nothing and reports no error. Why |
| trap | reflection with no setup | @RegisterReflectionForBinding(OrderDto.class) | a native image needs reflection registered, or it fails at run time, not at build time Why |
| twist | a faster start without going native | java -XX:AOTCache=app.aot -jar app.jar | the AOT cache of Java 24 and 25 reuses the classes a training run loaded, so later starts are faster Why |
| new | smaller objects on the heap | -XX:+UseCompactObjectHeaders | This option is supported since Java 25 and is off by default. It shrinks every object by four bytes. Why |
| twist | a site deployed into IIS | a WAR deployed into an application server | This is the older model. Spring Boot produces a JAR with the server inside, and each service is one process. Why |
The optionstwist#
Java has five ways to ship an application, from a bare JAR to a native binary. Each way has a counterpart in .NET:
| 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 JARtwist#
One command builds the billing service, and a second command runs it:
./mvnw package
java -jar target/billing-1.0.0.jar./mvnw packageThis command compiles the code, runs the tests and builds
target/billing-1.0.0.jar. That file is a fat JAR. Every dependency and an embedded Tomcat are inside it.java -jar target/billing-1.0.0.jarThis command starts the service on any machine that has a JVM. It is like
dotnet billing.dllin .NET.
For the everyday commands that run the fat JAR, see the quick reference.
Why the fat JAR keeps each dependency's JAR whole#
The Spring Boot plugin does not merge the classes of the dependencies. It keeps the JAR of each dependency whole inside the fat JAR:
billing-1.0.0.jar
BOOT-INF/classes/ your own classes
BOOT-INF/lib/ each dependency, as the JAR it arrived inWhen the application starts, Spring Boot builds the
classpath from the JARs in BOOT-INF/lib/.
That avoids the classic shading problem. In that problem, two dependencies each contain a
META-INF/services file, and one file replaces the other with no warning.
Containerstwist#
Most services are packaged in a container. Spring Boot can build an optimised image with no Dockerfile at all. To do so, it uses Cloud Native Buildpacks:
./mvnw spring-boot:build-image./mvnw spring-boot:build-imageThis command builds a container image in your local Docker. The buildpacks choose a Java Runtime Environment (JRE) and split the JAR into layers. They also set memory flags for the container, and they set the service to run as a non-root user.
This command is the closest Java match for dotnet publish /t:PublishContainer.
It is a good default for a team that does not want to maintain a Dockerfile.
A Dockerfile with layerstwist#
If a Dockerfile copies the fat JAR in one step, every code change makes each server download all the dependencies again. Spring Boot can split the JAR into layers. Each JAR layer becomes one 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-offlineThis line downloads the dependencies in a separate step. Docker reuses the result of that step until
pom.xmlchanges.java -Djarmode=tools -jar application.jar extract --layers --destination extractedThis line runs a tool that is part of Spring Boot. The tool splits the JAR into four folders under
extracted. The first two folders aredependenciesandspring-boot-loader. The last two folders aresnapshot-dependenciesandapplication.COPY --from=build /app/extracted/dependencies/ ./Each
COPYline makes one image layer. A code change alters only the last layer. So a server downloads a few hundred kilobytes, not the whole service.ENTRYPOINT ["java", "-jar", "application.jar"]The extracted
application.jarholds only your code. It refers to the extracted libraries, which are next to it. This layout also starts faster than the fat JAR.
jlink and jpackage#
The Java Development Kit (JDK) has two packaging tools. One tool builds a runtime around your application, and the other tool builds an installer around it:
| Tool | Produces | Use for |
|---|---|---|
| jlink | a custom runtime image with only the modules you need | shrinking a container |
| jpackage | a native installer of the type msi, dmg or deb | desktop distribution |
Here the runtime of the billing service keeps only the modules that the service 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 \This command builds a runtime from three modules, plus the modules that they depend on, such as
java.logging. On JDK 26 the result is about 36 MB. The whole JDK is about 380 MB.--runtime-image custom-jre --type dmgjpackagewraps the JAR and that runtime in a macOS disk image. The typemsidoes the same for Windows, and the typedebdoes the same for Linux.
jlink requires everything to be modular, or to be an automatic module. Many
libraries are still not modular. This is why fat JARs and containers are the most common choice
on the server. jlink is much more useful for desktop apps and command-line tools
than for Spring Boot services.
GraalVM native-imagetrap#
GraalVM compiles the whole application ahead of time into one native executable. NativeAOT does the same in .NET:
<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:compileThis command runs Spring's ahead-of-time processing, and then the GraalVM compiler. It needs an installed GraalVM JDK. It takes minutes, not seconds.
./target/billingThis is an ordinary executable. It needs no JVM to run.
Each choice is better in some ways and worse in other ways:
| Aspect | JVM fat JAR | Native image |
|---|---|---|
| Startup | slower | "starts in milliseconds", in GraalVM's words |
| Memory at rest | larger | "a fraction of the resources", in GraalVM's words |
| Peak throughput | higher, because the JIT is faster over time | lower |
| Build time | seconds | minutes |
| Reflection | needs no registration | must be registered |
| Debugging in production | JFR, full tooling | more limited |
Native image uses closed-world analysis. This means that everything reachable must be known at build time. Reflection, dynamic proxies, resource loading and the Java Native Interface (JNI) all need explicit registration. Without registration, they fail at run time, not at build time.
Spring's own closed-world assumption adds more restrictions.
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, the
same toggle does nothing and reports no error.
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 |
Reflection hints for a native image#
Spring Boot's ahead-of-time processing generates most of the required metadata for you. This is the reason that 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)This line registers the record
OrderDtofor the reflection that JSON binding uses. So anOrderDtocan still be read and written in the native image.
Startup without going native#
The JVM already shortens startup with a feature that is called class data sharing (CDS). The JDK includes an archive of its own core classes, which are already parsed. When the JVM starts, it maps this archive into memory. Java 24 and 25 extend the idea to your application, with an ahead-of-time (AOT) cache. A training run records the classes that the application loads and links. Later starts reuse those classes:
# 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.jarThis command runs the application once as a training run. It writes the cache when the application exits.
java -XX:AOTCache=app.aot -jar app.jarWith this command, every later start reads the cache and does not load and link those classes again. Use the same JDK and the same JAR that made the cache.
Java 25 finalised two features. The first feature is
ahead-of-time command-line ergonomics, which is the one-command form above. The
second feature is ahead-of-time method profiling Java 25. Java 25
also made compact object headers a supported option Java 25.
This option shrinks every object by four bytes. It is off by default. Switch it on with
-XX:+UseCompactObjectHeaders. Together, these features make JVM startup and
footprint on Java 25 noticeably better than on Java 21. The gain is often enough to make native
image unnecessary.
LegacyWAR files and application servers
Before embedded servers existed, 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, and each service is one process. This form is what containers need.
You will still meet WARs in older enterprise environments. Spring Boot can still produce a
WAR. To do so, set <packaging>war</packaging> and extend
SpringBootServletInitializer. Do not choose a WAR for anything new.
1What is the Java equivalent of a self-contained dotnet publish?
dotnet publish?jlink. It builds a trimmed runtime around your application,
so no JVM needs to be installed. A fat JAR is the counterpart of a framework-dependent publish,
because it still needs a JVM. You build a fat JAR with ./mvnw package. A Spring Boot
service almost always ships the fat JAR.2Why does a feature toggle built on @ConditionalOnProperty stop working in a
GraalVM native image?
@ConditionalOnProperty stop working in a
GraalVM native image?@ConditionalOnProperty is not supported, and the toggle
does nothing and reports no error.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 |
The JVM at runtime: memory, GC and containers#
You tune the Java Virtual Machine (JVM) with command-line flags. The JVM needs tuning more often than the CLR does. Two choices matter. The first choice is the size of the heap in a container. The second choice is which garbage collector (GC) runs. The defaults are good, so measure with Java Flight Recorder (JFR) before you change a default.
| Kind | C# | Java | Note |
|---|---|---|---|
| trap | GCHeapHardLimit | -Xmx / -XX:MaxRAMPercentage | These flags cap the heap only. Metaspace, thread stacks and direct buffers need 25 to 30 percent more. Why |
| twist | Gen0/1/2 | Young / Old | G1 is region-based, not strictly generational Why |
| twist | LOH | humongous regions in G1 | large objects get their own regions, and collect poorly Why |
| twist | "System.GC.Server": true in runtimeconfig.json | java -XX:+UseG1GC -jar app.jar | the .NET GC settings are command-line flags on the JVM Why |
| twist | DOTNET_GCHeapCount | -XX:ParallelGCThreads | how many threads the collector uses in parallel Why |
| twist | GC.Collect() | System.gc() | HotSpot runs a full collection, but the flag -XX:+DisableExplicitGC turns this off Why |
| trap | "System.GC.HeapHardLimitPercent": 70 | -XX:MaxRAMPercentage=70 | the default is 25, so a 1 GB container gets a 256 MB heap unless you set a share Why |
| trap | a fixed heap size in the image | -XX:MaxRAMPercentage, not -Xmx2g | -Xmx2g in an image that later runs with a 1 GB limit gets OOMKilled, but a percentage adapts to the limit Why |
| trap | memory for loaded classes | -XX:MaxMetaspaceSize=256m | metaspace has no limit by default, so a class-loading leak can consume the container Why |
| twist | Server GC | G1 (default) | the default, except on very small machines, where Serial is chosen Why |
| twist | Workstation GC | SerialGC | a single-threaded collector, for small heaps and tools Why |
| twist | GCSettings.LatencyMode | choice of collector | pick ZGC or Shenandoah when pauses matter more than throughput Why |
| new | no equivalent | -XX:+UseZGC | pauses stay under 1 ms whatever the heap size, but Server GC pauses grow with the heap Why |
| trap | an old start script | -XX:+ZGenerational | The flag no longer exists, so the JVM refuses to start. ZGC is already generational with -XX:+UseZGC. Why |
| twist | dotnet-counters ps, dotnet-gcdump | jcmd -l, jcmd <pid> GC.heap_dump | jcmd inspects the flags, heap, threads and Flight Recorder of a running JVM Why |
| trap | a pool of 500 threads | 500 platform threads, at about 1 MB of stack each | the stacks are reserved outside the heap, but a virtual thread keeps its stack on the heap Why |
| twist | BenchmarkDotNet | JMH | code is interpreted first and compiled once hot, so benchmarks and new instances need warmup Why |
The memory modeltrap#
The container of the billing service has a memory limit, and the JVM must fit inside that limit. This start command looks as if it keeps the service under 512 MB:
java -Xmx512m -jar app.jarThe command does not keep the service under 512 MB. A JVM process uses memory in several
regions. -Xmx512m sets the size of the first region only:
| Region | Limited by | Holds |
|---|---|---|
| Heap | -Xmx, or -XX:MaxRAMPercentage | your objects, in a young generation for new objects and an old generation for survivors |
| Metaspace | -XX:MaxMetaspaceSize | class metadata, with no limit 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 limits the heap only.
Sometimes a container is killed with OOMKill while the heap usage looks fine. In that case, the
memory is almost always used outside the heap, by metaspace, by the stacks of
platform threads, or by direct buffers. Allow
roughly 25 to 30 percent above -Xmx for everything else. Or use
-XX:MaxRAMPercentage, and the JVM calculates the heap size for you.
GC settings are command-line flagstwist#
C# refresherGC.Collect, generations and GCSettings.LatencyModeC# 1
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 \This flag selects G1, which is the default collector. So the flag only makes the choice visible.
-XX:MaxRAMPercentage=70 \The heap may grow to 70% of the memory that the container is given. This flag is like
HeapHardLimitPercentin .NET.-XX:MaxMetaspaceSize=256m \This flag caps the memory for class metadata. Without the flag, class metadata has no limit at all.
-XX:+ExitOnOutOfMemoryError \With this flag, the process exits on an
OutOfMemoryError, so the orchestrator restarts it. Without the flag, the process continues to run in a broken state.
For the other .NET GC settings and their JVM counterparts, see the differences table at the top of this chapter.
Containerstrap#
The JVM has been container-aware since Java 10. This means that it reads the CPU and memory limits of the container, not the limits of the host. But the default heap is only a quarter of the memory limit. The first command below shows the default heap. The second command sets a larger share on purpose:
# 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 MaxHeapSizeThis command starts a JVM in a container that is limited to 1 GB, and prints the maximum heap of that JVM. The result is 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. The rest is left for metaspace, thread stacks and buffers.
In a container, use -XX:MaxRAMPercentage, not a fixed -Xmx. Suppose
an image has a hardcoded -Xmx2g and later runs with a 1 GB limit. Then the
container gets OOMKilled. A percentage adapts to the limit. And always set
-XX:MaxMetaspaceSize. Metaspace has no limit by default, so a class-loading leak
will use all the memory of the container.
It is useful to set these flags in every container:
| Flag | Does |
|---|---|
| -XX:MaxRAMPercentage=70 | heap as a share of the container limit |
| -XX:InitialRAMPercentage=70 | avoid repeated heap resizing |
| -XX:MaxMetaspaceSize=256m | limit class metadata |
| -XX:+ExitOnOutOfMemoryError | exit and let the orchestrator restart the process, not continue in a broken state |
| -XX:+HeapDumpOnOutOfMemoryError | write a dump for analysis |
| -XX:HeapDumpPath=/dumps | where to write it |
| -Xss512k | smaller platform thread stacks |
Choosing a collectornew#
The JVM offers several collectors. Each collector makes a different choice between short pauses and high throughput:
| Collector | Pause times | Throughput | Use when |
|---|---|---|---|
| SerialGC | high | fine for tiny heaps | small containers, CLI tools |
| ParallelGC | high, but efficient | highest | batch jobs, where 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, but it is not in every Java build, and generational mode is optional since Java 25 |
A single flag selects a collector:
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.jarThe JVM chooses G1 even without this flag. The exception is a machine with a single CPU or very little memory. On such a machine, the JVM chooses Serial by itself.
java -XX:+UseZGC -jar app.jarZGC does almost all its work while your threads keep running. Since Java 24, the generational mode of ZGC is the only mode.
java -XX:+UseSerialGC -jar app.jarSerial uses one GC thread and has the least overhead. It is meant for tools and tiny containers.
Old start scripts often contain -XX:+ZGenerational. On current versions of the
Java Development Kit (JDK), that flag no longer exists, and the
JVM refuses to start. The error message is “Unrecognized VM option
'ZGenerational'”. Delete the flag. -XX:+UseZGC alone already gives you
generational ZGC.
ZGC's pause times are essentially independent of heap size. A 100 GB heap pauses about as long as a 4 GB heap, because the work is concurrent. There is no equivalent in .NET. Even Server GC with background collection has pauses that grow with the heap. If you have a latency-sensitive service with a large heap, this is a real reason to prefer the JVM.
Diagnosing#
When a service misbehaves, these commands inspect the running JVM. jcmd is part
of the JDK, and it 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.flagsThis command prints the flags that are in effect. The output includes the flags that the JVM chose for itself, such as the collector.
jcmd <pid> GC.class_histogramThis command counts the objects of each class, and lists the largest class first. It is the quickest way to see what is filling the heap.
jcmd <pid> GC.heap_dump /tmp/heap.hprofThis command writes every object on the heap to a file. You analyse the file in a tool such as Eclipse MAT. The command is like
dotnet-gcdumpin .NET.java '-Xlog:gc*:file=gc.log:time,uptime' -jar app.jarThis command logs 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.
For each symptom, the table gives the first place to look:
| Symptom | Look at |
|---|---|
| Container OOMKilled, heap looks fine | metaspace, thread stacks, direct buffers |
| Long pauses | GC logs, and 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, and look for 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. So a pool of 500 threads takes 500 MB of address space before your application allocates anything. This is one more reason that virtual threads matter. The stack of a virtual thread is on the heap, and it starts 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 the C1 compiler compiles these methods quickly. Then the C2 compiler compiles the methods that run most often, and it does so thoroughly. So peak performance arrives only after thousands of executions. That is why a Java benchmark that does not warm up is worthless. It is also why the Java Microbenchmark Harness (JMH) exists. JMH does the same as BenchmarkDotNet in .NET. It handles warmup, forking and dead-code elimination for you.
In production, the practical consequence is that the first few hundred requests after a deploy are measurably slower. Java 25's ahead-of-time (AOT) method profiling reduces this slowdown. It uses the profile data from a training run. If you run a load balancer with aggressive health checks, give a new instance a moment before you send 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 | headA method that runs often is called a hot method. This command prints one line for each method that the JIT compiles. So you can see which of your methods become hot as the service warms up.
1Your container is OOMKilled but the heap graph looks flat. Where is the memory?
-Xmx limits the heap only, so allow 25 to 30 percent more for everything else.2Which garbage collector does the JVM use by default, and when do you choose ZGC instead?
-XX:+UseZGC. The
pauses of ZGC stay under 1 ms whatever the heap size. Do not add
-XX:+ZGenerational, because that flag no longer exists, and the JVM refuses to
start.End of the first read
You have read every teaching chapter. What follows is for coming back: the tours, the review questions and the lookup appendices.
Java · 19 minSpring Boot · 17 minModern C# · 10 minOne-page card
Java in 19 minutes#
This is the Java language as one listing of commented code. Read it from top to bottom to refresh your memory. The link above each stop opens the section that explains it.
// Most of C# carries over: classes, generics, exceptions, lambdas and var.
// C#: public sealed class OrderService(IOrderRepository repository)
public final class OrderService { // final class: C#'s sealed
private final OrderRepository repository; // final field: C#'s readonly
public OrderService(OrderRepository repository) { // no primary constructors
this.repository = repository;
}
// C#: public async Task<Order> PlaceAsync(Order order)
public Order place(Order order) { // camelCase, no async, no Task
if (order.lines().isEmpty())
throw new IllegalArgumentException("An order needs at least one line.");
repository.save(order); // no await: the call simply blocks
return order;
}
}// The JDK is Java's SDK: the compiler, the runtime and the tools. C#: the .NET SDK.
// Java ships every six months. Run a long-term support release: 21 or 25.
// sdk list java lists every JDK with its exact identifier
// sdk install java 25.0.4-tem SDKMAN keeps several JDKs side by side
// sdk use java 21.0.12+1.1-tem switches this shell only
// java -version shows which JDK is really in use// A package is a namespace that must match the folder: src/com/acme/orders/App.java.
package com.acme.orders; // C#: namespace Acme.Orders;
import java.math.BigDecimal; // an import names a type, not a namespace
import java.util.*; // or every type in one package
import static java.util.stream.Collectors.*; // C#: using static
// The tour leaves out its other imports.
public class App { // a public class sits in a file with its name
public static void main(String[] args) { // C#: static void Main(string[] args)
System.out.println("hello"); // prints hello
}
}
// javac -d out src/com/acme/orders/*.java writes out/com/acme/orders/App.class
// java -cp out com.acme.orders.App the classpath, then package plus class// java -jar app.jar reads the main class from the JAR, ignores -cp// The classpath is the ordered list of folders and JARs that Java searches for a class.
// A JAR is a zip of class files. C#: an assembly. The first match wins.// Two versions of one library do not conflict: the first one silently wins,
// and you get NoSuchMethodError at run time.// C#: public abstract class Gateway
public abstract class Gateway { // abstract means what it means in C#
protected final String url;
protected Gateway(String url) { this.url = url; }
}// C#: public static class MoneyMath
// Java has no static classes: write a final class with a private constructor.
// It has no structs and no partial classes either.
public final class MoneyMath {
private MoneyMath() { }
public static BigDecimal round(BigDecimal v) { return v.setScale(2, RoundingMode.HALF_EVEN); }
}// C#: public class CardGateway : Gateway, IDisposable
// extends names the one superclass. implements lists the interfaces.
public class CardGateway extends Gateway implements AutoCloseable {
public CardGateway() {
this("https://pay.example"); // C#: : this(...)
}
public CardGateway(String url) {
// A check before super(...) is legal since Java 25.
if (url.isBlank()) throw new IllegalArgumentException("no url");
super(url); // C#: : base(url)
}
@Override public void close() { } // AutoCloseable is IDisposable
}// A C# nested class is always independent. In Java, only a static one is.
public class Outer {
class Inner { } // holds a hidden reference to its Outer, and keeps it alive
static class Nested { } // independent: make this your default
}// C#: a member with no modifier is private. In Java it is package-private.
public class Account {
BigDecimal balance; // every class in the package can read and change it
private BigDecimal fee; // what a C# field with no modifier means
protected BigDecimal limit; // subclasses, and also the whole package
}// Java has no internal. Packages do not nest: com.acme.orders.internal cannot see
// the package-private members of com.acme.orders.// C#: public interface IOrderRepository. Java drops the I prefix.
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(long id);
default boolean exists(long id) { // a default method, as in C# 8
return findById(id).isPresent();
}
}// Java has no explicit interface implementation.// It has no events either: a list of listeners replaces a C# event.// A sealed interface names the only types that may implement it. C# has no equivalent.
// It is not C#'s sealed: that is Java's final.
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 { }// Every method is virtual unless you mark it final. C# opts in with virtual.
class Discount {
Discount() { describe(); } // calls a method that can be overridden
void describe() { }
}
class SeasonalDiscount extends Discount {
private String season = "winter sale";
@Override void describe() { System.out.println(season); }
}
// @Override is optional, but always write it: the compiler then checks the signature.
new SeasonalDiscount(); // prints null: the override ran before season was set// C#: string Label(Payment p, string currency = "EUR")
// Java has no optional or named parameters. Write overloads, or use a builder.
String label(Payment p) { return label(p, "EUR"); }
String label(Payment p, String currency) { return p + " in " + currency; }// No ref and no out: everything is passed by value. Return a record or an Optional.// No extension methods and no operator overloading, both unlike C#:
// call a static helper or an ordinary method.// A C# delegate is a functional interface in Java: an interface with one abstract method.
List<String> productIds = List.of("mug", "cup", "pot");
Predicate<String> isMug = id -> id.equals("mug"); // C#: Func<string, bool>, with =>
Function<String, Integer> length = String::length; // a method reference; C#: a method group
Supplier<List<String>> fresh = ArrayList::new; // a constructor reference
Consumer<String> print = System.out::println; // C#: Action<string>boolean mug = isMug.test("mug"); // true: call the interface's method by name// A lambda may use a local variable only if it is never reassigned. C# has no such rule.
int count = 0;
// productIds.forEach(id -> count++); // does not compile
var counter = new AtomicInteger(); // the usual replacement
productIds.forEach(id -> counter.incrementAndGet());
int counted = counter.get(); // 3// C#: public Money Total { get; set; }
// Java has no properties: write a private field and methods named getX and setX.
public class Invoice {
public static final int MAX_LINES = 50; // C#: const, or static readonly
private Money total;
public Money getTotal() { return total; }
public void setTotal(Money total) { this.total = total; }
}// Jackson finds a property by these exact names.
// Get the prefix wrong and the value is silently missing.// C#: public record Money(decimal Amount, string Currency);
public record Money(BigDecimal amount, String currency) {
public Money { // a compact constructor: no parameter list
if (amount.signum() < 0) throw new IllegalArgumentException("negative amount");
amount = amount.setScale(2, RoundingMode.HALF_EVEN); // assigns the parameter
} // Java then copies each parameter into its field
public Money(String amount) { // an extra constructor calls the main one
this(new BigDecimal(amount), "EUR");
}
}// C#: public record OrderLine(string ProductId, int Quantity, Money UnitPrice);
public record OrderLine(String productId, int quantity, Money unitPrice) {
// Java has no with expression, so you write the "wither" yourself.
public OrderLine withQuantity(int q) { return new OrderLine(productId, q, unitPrice); }
}
public record Customer(long id, String name, String email) { } // the braces are required
var line = new OrderLine("mug", 2, new Money("9.50"));
int quantity = line.quantity(); // 2: an accessor method with no get prefix
boolean sameLine = line.equals(new OrderLine("mug", 2, new Money("9.5"))); // true: by value
System.out.println(line);
// prints OrderLine[productId=mug, quantity=2, unitPrice=Money[amount=9.50, currency=EUR]]
// A record is always a reference type: Java has no record struct.var more = line.withQuantity(3); // C#: line with { Quantity = 3 }// A record is only shallowly immutable, as in C#: copy any list it is given.
public record Order(long id, Customer customer, List<OrderLine> lines, OrderStatus status) {
public Order {
lines = List.copyOf(lines); // a copy that nobody can change
}
}
var ada = new Customer(1, "Ada Doe", "ada@example.com");
var basket = new ArrayList<>(List.of(line)); // a list the caller can still change
var order = new Order(1042, ada, basket, OrderStatus.PAID);
basket.add(more); // the caller's list grows to 2 lines
int kept = order.lines().size(); // 1: the record kept its own copy
order.lines().add(more); // throws UnsupportedOperationExceptionPayment payment = new Card("4242");
if (payment instanceof Card card) { // C#: if (payment is Card card)
System.out.println(card.last4()); // prints 4242
}
// C#'s is not: !(payment instanceof Card card)String describe(Payment p) {
return switch (p) { // C#: p switch { ... }
case null -> "not paid yet"; // without this arm, null throws
case Card c when c.last4().equals("0000") -> "a test card"; // a guard, as in C#
case Card(String last4) -> "card ending " + last4; // a record pattern
case BankTransfer t -> "transfer from " + t.iban();
case Voucher _ -> "a voucher"; // _ is an unnamed variable
};
}
// Java has no property, list or relational patterns: test those in a when guard.// Payment is sealed, so the compiler knows every kind and that switch needs no default.
// Add a fourth kind and it stops compiling until you handle it. C# cannot check that.
String described = describe(payment); // "card ending 4242"var day = DayOfWeek.FRIDAY; // an enum from java.time
String packing = switch (day) { // C#: day switch { ... }
case SATURDAY, SUNDAY -> "packed on Monday"; // a comma, where C# writes or
case FRIDAY -> "packed today, delivered next week";
default -> "packed today"; // C#: _ =>
};
// A block arm returns its value with yield, which is unrelated to C#'s yield return.// The old form, case X: with break, falls through when a break is missing.var lines = new ArrayList<OrderLine>(); // var, as in C#: local variables onlyvar mixed = new ArrayList<>(); // a trap: this is an ArrayList<Object>
List<OrderLine> declared = new ArrayList<>(); // the diamond <> takes OrderLine from the left// C#: $"Order {id} comes to {total:F2} EUR"
// Java has no string interpolation. Join with +, or use formatted.
long id = 1042;
double total = 59.5;
String joined = "Order " + id + " comes to " + total + " EUR";
String formatted = "Order %d comes to %.2f EUR".formatted(id, total);
// "Order 1042 comes to 59.50 EUR", with an English locale// A text block is C#'s raw string literal, except that it still processes escapes like \n.
String json = """
{"orderId": %d, "status": "PAID"}
""".formatted(id); // the indentation is strippedString typed = "spring".toUpperCase(); // built while the program runs
boolean sameObject = typed == "SPRING"; // false: == compares references, not text
boolean sameText = typed.equals("SPRING"); // true: C#'s == on strings
boolean nullSafe = Objects.equals(typed, null); // false, and no exception if either is null// C#: enum OrderStatus { New, Paid, Shipped, Cancelled }
// A Java enum constant is an object, not a number, so it can carry fields and methods.
public enum OrderStatus {
NEW("waiting for payment"), PAID("being packed"), SHIPPED("on its way"), CANCELLED("cancelled");
private final String label;
OrderStatus(String label) { this.label = label; } // always private
public String label() { return label; }
}OrderStatus status = OrderStatus.valueOf("PAID"); // C#: Enum.Parse; throws if unknown
String statusLabel = status.label(); // "being packed"
// No cast from int. For a map keyed by enum, use EnumMap.// No [Flags] either: use an EnumSet.int position = status.ordinal(); // 1: only its position; never store it// C#: public class InMemoryRepository<T> where T : IHasId
public interface HasId { long id(); }
public class InMemoryRepository<T extends HasId> { // the constraint goes in the brackets
private final Map<Long, T> items = new HashMap<>();
public void save(T item) { items.put(item.id(), item); }
// Java erases type arguments when it compiles, so at run time T is unknown.
// public T blank() { return new T(); } // does not compile
}
// A type argument cannot be primitive: List<Integer>, never List<int>. Each element is boxed.// C#: T Pick<T>(object value), where typeof(T) just works.
// A generic method declares <T> before its return type. T is erased, so the caller
// passes the type as a value, a Class<T>, as in mock(OrderRepository.class).
<T> T pick(Object value, Class<T> type) { return type.cast(value); }
String picked = pick("mug", String.class); // "mug"; C#: Pick<string>("mug")// C#: IEnumerable<out T>. Java writes it where the type is used:
// ? extends for out, and ? super for in.
void printAll(List<? extends Payment> payments) { payments.forEach(System.out::println); }
List<Card> cards = List.of(new Card("4242"));
printAll(cards); // prints Card[last4=4242]
// List<Payment> all = cards; // does not compile// C#: [Audited(Action = "refund")]. An annotation is Java's attribute.
@Retention(RetentionPolicy.RUNTIME) // without this, reflection cannot see it
@Target(ElementType.METHOD) // C#: AttributeUsage
public @interface Audited {
String action(); // members look like methods with no body
String actor() default "system";
}
public class Refunds {
@Audited(action = "refund") // arguments are written name = value
public void refund(long orderId) { }
}
Audited audited = Refunds.class.getMethod("refund", long.class).getAnnotation(Audited.class);
String actor = audited.actor(); // "system"// Any reference can be null, and Java has no string?, no ?. and no ??.
// A method that may find nothing returns an Optional. Use it for return values only.
Optional<Customer> found = Optional.of(ada); // Optional.empty() when there is none
String name = found.map(Customer::name).orElse("unknown"); // C#: found?.Name ?? "unknown"
String nickname = null;
String shown = Objects.requireNonNullElse(nickname, "none"); // "none"; C#: nickname ?? "none"// orElse(x) always works out x. orElseGet(() -> x) does so only when empty.// C#: Invoice? Find(long orderId), checked by the compiler.
// JSpecify's annotations say the same, but only a checker such as NullAway enforces them.
public @Nullable Invoice findInvoice(long orderId) { return null; }// C#: List<string> ids = ["mug", "cup", "pot"];
// Declare the interface and create the class. ArrayList is C#'s List<T>.
List<String> ids = new ArrayList<>(List.of("mug", "cup", "pot"));
Map<String, Integer> stock = new HashMap<>(); // C#: Dictionary<string, int>
List<String> fixed = List.of("mug", "cup"); // cannot change, and rejects null
fixed.add("pot"); // throws UnsupportedOperationExceptionfor (var i : ids) stock.put(i, 12); // C#: foreach (var i in ids)
// Removing inside that loop usually throws. Call removeIf instead.
ids.removeIf(i -> i.startsWith("c"));// A Map is not a Collection, so you loop over its entries. C#: foreach (var entry in stock)
int inStock = 0;
for (Map.Entry<String, Integer> entry : stock.entrySet()) { // C#: KeyValuePair<string, int>
inStock += entry.getValue(); // getKey() gives the product id
}
// inStock is now 36. keySet() and values() loop over the keys or the values alone.// C#: stock["jug"] throws KeyNotFoundException. Java's get returns null.
Integer missing = stock.get("jug"); // null
int unboxed = stock.get("jug"); // throws NullPointerException
int safe = stock.getOrDefault("jug", 0); // 0// subList, keySet, values and reversed() return views, not copies. C#'s list[0..1] copies.
List<String> view = ids.subList(0, 1);
view.set(0, "tray");
String first = ids.getFirst(); // "tray": the change reached idsInteger boxed = 42; // a wrapper object, which can be null. C#: int?
Integer a = 127, b = 127;
Integer c = 128, d = 128;
boolean small = a == b; // true: one shared object from -128 to 127
boolean large = c == d; // false: two objects, compared by reference
boolean equal = c.equals(d); // true: compare objects with equals// C#: override Equals and GetHashCode together. The same rule holds in Java,
// or a HashMap cannot find your key. A record writes both for you.
var shelves = new HashMap<OrderLine, String>();
shelves.put(line, "shelf 4");
String shelf = shelves.get(new OrderLine("mug", 2, new Money("9.50"))); // "shelf 4"var orders = List.of(order, new Order(1043, ada, List.of(more, line), OrderStatus.NEW));
// C#: orders.Where(...).OrderByDescending(o => o.Id).Select(o => o.Id).ToList()
List<Long> newest = orders.stream() // a collection gives a stream
.filter(o -> !o.lines().isEmpty()) // Where
.sorted(Comparator.comparing(Order::id).reversed()) // OrderByDescending
.map(Order::id) // Select
.toList(); // [1043, 1042]; this list cannot be modified
int units = orders.stream()
.flatMap(o -> o.lines().stream()) // SelectMany
.mapToInt(OrderLine::quantity).sum(); // 7: sum() needs a stream of int// C#: orders.GroupBy(o => o.Status). In Java, grouping is a collector.
Map<OrderStatus, Long> byStatus = orders.stream().collect(groupingBy(Order::status, counting()));
String csv = ids.stream().collect(joining(", ")); // "tray, pot"; C#: string.Join// toMap throws on a duplicate key, as ToDictionary does, and also on a null value.// A stream can be used only once. A LINQ query would simply run again.
Stream<Order> paid = orders.stream().filter(o -> o.status() == OrderStatus.PAID);
long paidCount = paid.count(); // 1: count() returns a long
paid.toList(); // throws IllegalStateException// Leave parallelStream() alone: it shares one thread pool with the whole JVM.// C#: await Task.WhenAll(a, b). Java has no async and no await.
// Write blocking code and run it on a virtual thread, which makes waiting cheap.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> who = executor.submit(() -> ada.name()); // C#: Task.Run
Future<Integer> size = executor.submit(() -> order.lines().size());
String report = who.get() + ": " + size.get(); // "Ada Doe: 1"
} // close() waits for every task// Never pool virtual threads: to limit concurrency, use a Semaphore.
// They do not speed up calculation: use a sized pool of ordinary threads for that.// C#: await Task.WhenAll(a, b). A scope also cancels what is still running when
// its block ends. Structured concurrency is still a preview feature in Java 25:
// it needs --enable-preview to compile and to run, and its API changes between
// releases. So this stop is only a comment.
// try (var scope = StructuredTaskScope.open()) {
// var customer = scope.fork(() -> fetchCustomer(id));
// var history = scope.fork(() -> fetchOrders(id));
// scope.join(); // waits, and throws the first failure
// return merge(customer.get(), history.get());
// }
// Until it is final, production code uses the executor above.// C#: CancellationToken and OperationCanceledException. In Java, cancelling is interrupting.
Thread worker = Thread.startVirtualThread(() -> {
try {
Thread.sleep(Duration.ofSeconds(30)); // C#: Task.Delay
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // catching clears the flag, so restore it
}
});
worker.interrupt();// C#: lock (gate) { total += x; }
public class SalesTotal {
private final Object gate = new Object(); // a private final lock object, never this
private long total;
public void add(int x) {
synchronized (gate) { total += x; }
}
}// C#: ConcurrentDictionary.GetOrAdd, which may run its factory more than once.
var prices = new ConcurrentHashMap<String, Money>();
Money cached = prices.computeIfAbsent("mug", k -> new Money("9.50")); // at most once per key// AtomicInteger, shown above, and LongAdder replace C#'s Interlocked.// The primitives match C#'s, but byte is signed and there are no unsigned integers.
byte signed = (byte) 200; // -56int max = Integer.MAX_VALUE;
int wrapped = max + 1; // -2147483648: it wraps, as in C#. Java has no checked
Math.addExact(max, 1); // throws ArithmeticException// C#: decimal sum = unit * 3 + 4.95m;
// Java has no decimal. BigDecimal is a class, so arithmetic is method calls.
double wrong = 0.1 + 0.2; // 0.30000000000000004, as in C#
BigDecimal unit = new BigDecimal("19.99"); // from a string, never a double
BigDecimal sum = unit.multiply(BigDecimal.valueOf(3)).add(new BigDecimal("4.95")); // 64.92
boolean over = sum.compareTo(new BigDecimal("50")) > 0; // true: there is no > operator
boolean scale = new BigDecimal("1.0").equals(new BigDecimal("1.00")); // false: use compareTo
BigDecimal third = BigDecimal.ONE.divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_UP);
// 0.33. Without a scale and a rounding mode, 1 divided by 3 throws ArithmeticException.// Use java.time only. Its types cannot change and are safe to share between threads.
// Date, Calendar and SimpleDateFormat are the old API: read them, do not write them.
Instant now = Instant.now(); // C#: DateTimeOffset, in UTC
LocalDate due = LocalDate.of(2026, 10, 3).plusDays(30); // 2026-11-02; C#: DateOnly
Duration wait = Duration.ofMinutes(90); // PT1H30M; C#: TimeSpan// A Period is calendar time: a day, even when a clock change makes it 23 hours long.// C# has only unchecked exceptions. A Java checked exception, such as IOException,
// must be declared or caught.
String read(Path path) throws IOException { // declared: every caller has the same duty
return Files.readString(path);
}
String readUnchecked(Path path) {
try {
return Files.readString(path);
} catch (IOException e) {
throw new UncheckedIOException(e); // wrapped; always keep the cause
}
}// RuntimeException and its subclasses are unchecked, such as IllegalStateException,
// which is C#'s InvalidOperationException.// A lambda passed to map cannot throw a checked exception. C# has no such limit.
List<Path> paths = List.of(Path.of("missing.txt"));
// paths.stream().map(Files::readString).toList(); // does not compile
List<String> texts = paths.stream().map(this::readUnchecked).toList(); // throws UncheckedIOException// C#: using (var gateway = new CardGateway()) { ... }
try (var gateway = new CardGateway()) {
System.out.println(gateway.url); // prints https://pay.example
} // close() runs here, even on an exception
// url is protected, and this code is in the same package, so it can read it.// catch (IOException | SQLException e) catches several types. Java has no when filter.int load() {
try {
throw new IllegalStateException("real problem");
} finally {
return 0; // C# refuses to compile this. Java compiles it.
}
}
int loaded = load(); // 0: the exception was silently discarded// Maven is the build tool, and pom.xml is its .csproj. The layout is fixed:
// pom.xml names the project and lists its dependencies
// src/main/java/ code, in folders that match the packages
// src/main/resources/ application.yml and other files that go into the JAR
// src/test/java/ tests, in the same packages as the code they test
// Sharing the package is how a test sees package-private members. C#: InternalsVisibleTo.// ./mvnw clean package compiles, tests, and builds the JAR in target/
// ./mvnw test C#: dotnet test
// ./mvnw is the wrapper: a script in the project that downloads the right Maven.
// C#: dotnet add package. Maven has no such command: paste a <dependency> into pom.xml.// ./mvnw dependency:tree shows which version of each library Maven chose
// When two libraries ask for different versions of a third, the nearest declaration wins,
// not the highest version. NuGet takes the higher one.// JUnit, AssertJ and Mockito play the parts of xUnit, FluentAssertions and Moq.
class OrderServiceTest { // no public needed
@Test // C#: [Fact]
void place_savesTheOrder() {
OrderRepository repository = mock(OrderRepository.class); // the mock is the object
var service = new OrderService(repository);
var line = new OrderLine("mug", 1, new Money("9.50"));
var order = new Order(7, null, List.of(line), OrderStatus.NEW);
assertThat(service.place(order)).isEqualTo(order); // the actual value first
verify(repository, times(1)).save(order); // C#: Verify(..., Times.Once)
}
}The tour was compiled with javac --release 25 on 4 October 2026, and every
result shown in a comment was confirmed by running the code.
Spring Boot in 17 minutes#
This is one small order service on Spring Boot 4, from pom.xml to its tests. It
is a single listing, and the comments teach. Read it from top to bottom. The link
above each stop opens the section that explains it.
<!-- pom.xml: the parent sets compatible versions for hundreds of libraries. -->
<!-- ASP.NET Core: the .csproj, with its Sdk line and its PackageReference lines. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version> <!-- current in September 2026; check spring.io -->
</parent>
<!-- A starter is one dependency that brings a whole capability, already set up. -->
<!-- It has no version, because the parent sets it. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId> <!-- Boot 3: spring-boot-starter-web -->
</dependency>
<!-- This service also uses the starters data-jpa, flyway, data-mongodb, validation, security, -->
<!-- security-oauth2-resource-server and actuator, and the test starters. -->
<!-- Boot 3: flyway-core in place of a Flyway starter, and oauth2-resource-server. -->// BillingApplication.java
// ASP.NET Core: Program.cs, with WebApplication.CreateBuilder(args) and app.Run().
package com.acme.billing;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BillingApplication {
public static void main(String[] args) {
// Creates the beans, starts the embedded web server, and runs until stopped.
SpringApplication.run(BillingApplication.class, args);
}
}// TRAP: Spring finds your classes by scanning that class's package and everything below it.
// A component in a sibling package is invisible, so keep the class in the root package.# Auto-configuration: each starter ships configuration classes that switch themselves on
# only when their conditions hold, such as "the JDBC classes are on the classpath".
# ASP.NET Core: no counterpart. You call builder.Services.AddDbContext<AppDb>() yourself.
# Anything you define yourself wins: declare your own DataSource bean, and Boot's steps aside.
./mvnw spring-boot:run # dotnet run; the default port is 8080
./mvnw package # dotnet publish; builds one runnable JAR
java -jar target/billing-1.0.0.jar --debug # --debug prints what Boot configured, and why# src/main/resources/application.yml
# ASP.NET Core: appsettings.json. Here indentation shows nesting, instead of braces.
billing: # your own settings can have any name
max-lines: 50
spring:
datasource:
url: jdbc:postgresql://localhost/billing
mvc:
problemdetails:
enabled: true # RFC 9457 error bodies, as AddProblemDetails(); off by default
jpa:
open-in-view: false # on by default; the stop on find() below says why it is offhibernate:
ddl-auto: validate # Hibernate only checks the tables; the migrations build them# Boot does not use virtual threads unless you ask: the default is false, on Boot 3.2
# and later and on Boot 4. With true, Tomcat runs each request on a virtual thread.
threads:
virtual:
enabled: true# A profile is Spring's word for an environment.
# ASP.NET Core: ASPNETCORE_ENVIRONMENT=Development and appsettings.Development.json
export SPRING_PROFILES_ACTIVE=dev # Boot also loads application-dev.yml, on top
# An environment variable beats both files. It is upper case, with one underscore.
# ASP.NET Core: Billing__MaxLines, with two underscores.
export BILLING_MAX_LINES=20 # sets billing.max-lines// BillingProperties.java
// ASP.NET Core: IOptions<BillingOptions>. Here the record is injected itself, with no wrapper.
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@ConfigurationProperties(prefix = "billing") // binds the billing section of the settings
public record BillingProperties(int maxLines) { } // max-lines fills maxLines
@Configuration
@EnableConfigurationProperties(BillingProperties.class) // creates the BillingProperties bean
class BillingConfig { }// Order.java
// C#: an EF Core entity class. JPA maps it with annotations, not in OnModelCreating.
import jakarta.persistence.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
enum OrderStatus { NEW, PAID, SHIPPED, CANCELLED }
// TRAP: an entity cannot be a record. JPA needs a no-argument constructor and fields it can
// change, and Hibernate generates subclasses of entities. A record is final and immutable.
@Entity
@Table(name = "orders") // ORDER is a reserved word in SQL
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // the database generates the id
private Long id;
// TRAP: the default is ORDINAL, which stores the constant's position as a number.
// Reorder the enum later, and every stored row silently means something different.
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.NEW;
private Instant placedAt = Instant.now(); // the column is placed_at
protected Order() { } // for JPA only // C#: navigation properties. In JPA a collection loads lazily by default.
// TRAP: @ManyToOne is EAGER by default, so every order would load its customer.
// Set LAZY every time.
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
// @OneToMany is LAZY by default: the lines load when they are first touched.
// mappedBy names the field on OrderLine that holds the foreign key.
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
public Order(Customer customer, String productId, int quantity) {
this.customer = customer;
lines.add(new OrderLine(this, productId, quantity)); // saved with the order, by cascade
}
public Long getId() { return id; }
public Customer getCustomer() { return customer; }
public List<OrderLine> getLines() { return lines; }
public OrderStatus getStatus() { return status; }
public void cancel() { status = OrderStatus.CANCELLED; }
}// OrderLine.java and Customer.java. C#: two more entity classes.
@Entity
class OrderLine {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Order order; // the field that mappedBy = "order" names
private String productId;
private int quantity;
protected OrderLine() { }
OrderLine(Order order, String productId, int quantity) {
this.order = order;
this.productId = productId;
this.quantity = quantity;
}
}
@Entity
class Customer {
@Id
private Long id;
private String name;
protected Customer() { }
public String getName() { return name; }
}// OrderRepository.java
// C#: a DbSet<Order>, queried with LINQ.
// Java has no expression trees, so Spring Data builds each query from the method's name.
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
// An interface with no implementation: Spring Data creates the class at startup.
// save, findById, findAll and delete are inherited.
public interface OrderRepository extends JpaRepository<Order, Long> {
// A derived query: where status = ? order by placed_at desc
List<Order> findByStatusOrderByPlacedAtDesc(OrderStatus status);
// When the name would get silly, write the query. It names the entity and its fields,
// not the tables. join fetch loads the lines in the same query.
@Query("select o from Order o join fetch o.lines where o.id = :id")
Optional<Order> findWithLines(@Param("id") Long id); // TRAP, N+1: loop over findAll() and call order.getLines() on each order, and every
// call runs a query. 100 orders cost 101 queries.
// C#: .Include(o => o.Lines). Here @EntityGraph asks for the lines in the same query.
@EntityGraph(attributePaths = "lines")
List<Order> findByStatus(OrderStatus status);
}
interface CustomerRepository extends JpaRepository<Customer, Long> { }-- src/main/resources/db/migration/V2__add_order_status.sql
-- ASP.NET Core: dotnet ef migrations add AddOrderStatus, then dotnet ef database update.
-- Flyway: you write plain SQL in numbered files. Boot runs each new file once, at startup.
ALTER TABLE orders
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'NEW';
-- Flyway stores a checksum of every file it applied. Edit one later, even to add a
-- comment, and startup fails. The fix for a mistake is always a new migration.// Invoice.java: a document, not a row. Suppose the invoices live in Amazon DocumentDB, which
// speaks the MongoDB API. Spring Data MongoDB stores each invoice whole, in a collection.
// C#: a class whose Id carries [BsonId] and [BsonRepresentation(BsonType.ObjectId)].
// The @Id is Spring Data's own, spelled out below because jakarta.persistence.Id is imported
// above. A String id is generated on saving. A long id is not.
import java.math.BigDecimal;
import java.time.LocalDate;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;// A document can be a record, which a JPA entity cannot.
@Document("invoices")
public record Invoice(
@org.springframework.data.annotation.Id String id,// TRAP: no index exists, and nothing is enforced, until the setting
// spring.data.mongodb.auto-index-creation is true.
@Indexed(unique = true) String orderId, Money total, // a nested record is stored inside the document
LocalDate issuedOn) { }
record Money(BigDecimal amount, String currency) { }// InvoiceRepository.java: a repository again, this time over a collection.
// C#: IMongoCollection<Invoice> invoices = database.GetCollection<Invoice>("invoices");
// await invoices.Find(i => i.OrderId == orderId).FirstOrDefaultAsync();
import org.springframework.data.mongodb.repository.MongoRepository;
public interface InvoiceRepository extends MongoRepository<Invoice, String> {
// A derived query: it sends the filter {"orderId": orderId}.
Optional<Invoice> findByOrderId(String orderId);
}// In a service:
// Invoice saved = invoices.save(invoice);
// TRAP: a record cannot change, so save returns a copy that carries the generated id.
// invoice.id() is still null. C#: InsertOneAsync sets Id on the object you passed.// CreateOrder.java: the request body. A type that only carries data is a record.
// C#: record CreateOrder(long CustomerId, [Range(1, 100)] int Quantity, [Required] string ProductId);
import jakarta.validation.constraints.*;
public record CreateOrder(
long customerId,
@Min(1) @Max(100) int quantity,// TRAP: @NotNull is not [Required] for a string, because it accepts "".
// @NotBlank rejects null, empty and whitespace-only strings.
@NotBlank String productId) { }// OrderDto.java: the response. Jackson, Spring's JSON library, reads and writes records.
// Boot 4 uses Jackson 3, in the package tools.jackson. Boot 3: com.fasterxml.jackson.
// C#: public record OrderDto(long Id, string Status, int LineCount);
public record OrderDto(long id, String status, int lineCount) {
static OrderDto from(Order order) {
return new OrderDto(order.getId(), order.getStatus().name(), order.getLines().size());
}
}// OrderService.java
// ASP.NET Core: builder.Services.AddScoped<OrderService>();
// Spring has no registration list. The annotation is the registration.
// An object that Spring creates and hands to other objects is called a bean.
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderRepository orders; // final is C#'s readonly
private final CustomerRepository customers;
private final ApplicationEventPublisher events;
// Constructor injection: a class with one constructor needs no annotation for it.
public OrderService(OrderRepository orders, CustomerRepository customers,
ApplicationEventPublisher events) {
this.orders = orders;
this.customers = customers;
this.events = events;
}// ASP.NET Core: you choose AddSingleton, AddScoped or AddTransient every time.
// TRAP: a bean is a singleton unless you say otherwise. There is one OrderService,
// so this field would be shared by every request:
// private Order current;
// Keep beans stateless. @RequestScope is AddScoped; @Scope("prototype") is AddTransient.// C#: BeginTransaction, SaveChanges and Commit, with Rollback in a catch block.
// Spring starts a transaction before the method runs, commits when it returns,
// and rolls back when an unchecked exception escapes.
// Put it on the service, not the repository: the use case is the boundary.
@Transactional
public OrderDto place(CreateOrder request) {
var customer = customers.findById(request.customerId()).orElseThrow();
var order = orders.save( // db.Orders.Add(order)
new Order(customer, request.productId(), request.quantity()));
events.publishEvent(new OrderPlaced(order.getId())); // see OrderPlacedListener below
return OrderDto.from(order);
}// TRAP: on a document database, such as the one that holds the invoices, @Transactional
// does nothing until you declare a MongoTransactionManager bean.
// Boot sets a transaction manager up for a relational database, and none for MongoDB.// C#: AsNoTracking() is the nearest thing to readOnly = true.
@Transactional(readOnly = true)
public Optional<OrderDto> find(long id) {
// Map to the DTO here, inside the transaction, while lazy fields can still load.
return orders.findWithLines(id).map(OrderDto::from);
// TRAP: return the Order instead, and order.getCustomer().getName() in the controller
// throws LazyInitializationException, because the transaction has closed.
// Boot's open-in-view hides that: it keeps the persistence context open for the
// whole request. It also hides N+1 queries and holds a connection longer.
}
@Transactional(readOnly = true)
public List<OrderDto> list(OrderStatus status) {
return orders.findByStatus(status).stream().map(OrderDto::from).toList();
}// EF Core: order.Cancel(), then SaveChangesAsync(). In Spring that second line does not exist.
@Transactional(rollbackFor = Exception.class)
public void cancel(long orderId) throws OrderAlreadyShipped {
var order = orders.findById(orderId).orElseThrow();
var shipped = order.getStatus() == OrderStatus.SHIPPED;
order.cancel(); // no save call: the change to a loaded entity is written on commit // TRAP: only unchecked exceptions roll back. A checked exception COMMITS on the way out.
// C#: no counterpart. C# has no checked exceptions, so you have no instinct for this.
// OrderAlreadyShipped extends Exception, so it is checked. rollbackFor, above, is the fix.
if (shipped) throw new OrderAlreadyShipped(); // without rollbackFor, the cancel stays
} // TRAP: @Transactional is applied by a proxy, a stand-in object that wraps this one.
// this::place calls the method on the object itself, so it never passes the proxy.
// No transaction starts, and nothing warns you. A private method fails the same way.
// C#: no counterpart. Nothing in ASP.NET Core wraps your class for you.
public void placeAll(List<CreateOrder> requests) {
requests.forEach(this::place);
}
}
class OrderAlreadyShipped extends Exception { }
// The fix: make the call come from another bean, so that it goes through the proxy.
@Service
class BatchService {
private final OrderService orders;
BatchService(OrderService orders) { this.orders = orders; }
public void placeAll(List<CreateOrder> requests) {
requests.forEach(orders::place); // one transaction for each order
}
}// What other classes receive is the proxy: a subclass that Spring generates at run time.
// C#: nothing does this for you. You write a decorator class and register it yourself.
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
class ShowProxy implements ApplicationRunner { // ASP.NET Core: IHostedService.StartAsync
private final OrderService orders;
ShowProxy(OrderService orders) { this.orders = orders; }
@Override
public void run(ApplicationArguments args) { // Boot calls this once, after startup
System.out.println(orders.getClass().getName());
// prints com.acme.billing.OrderService$$SpringCGLIB$$0
// TRAP: a subclass cannot override a final method, so final on a @Transactional
// method turns the transaction off, with only a warning in the startup log.
// A final class stops startup: "Cannot subclass final class".
}
}// Propagation: what happens when a transaction is already running.
// REQUIRED, the default, joins it. C#: TransactionScopeOption.RequiresNew for the one below.
import org.springframework.transaction.annotation.Propagation;
@Service
class AuditService {
// REQUIRES_NEW suspends the caller's transaction and commits on its own,
// so the audit row survives when the caller rolls back.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String what) { }
}// TRAP: REQUIRES_NEW takes a second connection from the pool. If it writes a row the caller
// has already written, it waits for a lock that the caller cannot release.// Send a message inside place(), and it goes out even if the transaction rolls back.
// So place() publishes an event, and this listener runs only after the commit.
// C#: do the work after SaveChanges and Commit succeed.
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
record OrderPlaced(long orderId) { }
@Component
class OrderPlacedListener {
// An event published while no transaction is running is dropped.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(OrderPlaced e) {
// send the confirmation here
}
}// OrderController.java
// ASP.NET Core: [ApiController] [Route("api/orders")] class OrdersController : ControllerBase
import jakarta.validation.Valid;
import java.net.URI;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController // return values are written to the response body as JSON
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orders;
public OrderController(OrderService orders) { this.orders = orders; }
// GET /api/orders/{id}. ASP.NET Core: [HttpGet("{id}")] returning ActionResult<OrderDto>
@GetMapping("/{id}")
public ResponseEntity<OrderDto> get(@PathVariable long id) { // text that is no number: 400
return orders.find(id)
.map(ResponseEntity::ok) // 200, as Ok(order)
.orElseGet(() -> ResponseEntity.notFound().build()); // 404, as NotFound()
}// GET /api/orders?status=PAID. ASP.NET Core: [FromQuery] string status = "NEW"
// TRAP: @RequestParam is required by default. A request without it gets 400, not null.
// Give it a defaultValue, as here, or required = false.
@GetMapping
public List<OrderDto> list(@RequestParam(defaultValue = "NEW") String status) {
return orders.list(OrderStatus.valueOf(status)); // returned directly: 200
}// POST /api/orders. ASP.NET Core: [HttpPost] with [FromBody]; [ApiController] validates for you.
// TRAP: forget @Valid and nothing checks CreateOrder's constraints, with no warning.
// With it, a bad body gets 400 Bad Request, and this method never runs.
@PostMapping
public ResponseEntity<OrderDto> create(@Valid @RequestBody CreateOrder request) {
var created = orders.place(request);
var location = URI.create("/api/orders/" + created.id());
return ResponseEntity.created(location).body(created); // 201, as CreatedAtAction
} // ASP.NET Core: [Authorize(Policy = "OrdersScope")], a policy that requires the scope orders.
// A token's scopes become authorities: the scope orders arrives as SCOPE_orders.
// TRAP: hasRole('ADMIN'), the counterpart of [Authorize(Roles = "Admin")], checks for the
// authority ROLE_ADMIN. hasAuthority adds no prefix. A token's "roles" claim is ignored
// until a JwtAuthenticationConverter bean maps it. This service declares none,
// so hasRole('ADMIN') here would refuse every caller with 403.
// TRAP: without @EnableMethodSecurity the annotation is silently ignored.
// It is applied by a proxy too, so a call from inside the same class skips the check.
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('SCOPE_orders')")
public ResponseEntity<Void> cancel(@PathVariable long id) throws OrderAlreadyShipped {
orders.cancel(id);
return ResponseEntity.noContent().build(); // 204, as NoContent()
}
}// ApiExceptionHandler.java: one class gives every controller's exceptions an RFC 9457 body.
// ASP.NET Core: app.UseExceptionHandler(...), or an IExceptionFilter.
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
// Extending this class keeps Spring's bodies for its own exceptions, such as a failed @Valid.
// That default body does not name the failing fields, where ASP.NET Core's does.
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(OrderAlreadyShipped.class)
ProblemDetail handleShipped(OrderAlreadyShipped e) {
var problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, "A shipped order cannot be cancelled");
problem.setTitle("Order already shipped");
return problem; // status 409, with "title" and "detail" in the JSON
}
}// TRAP: do not give a failed @Valid a second advice class of its own. The class above
// already handles it, through the handleMethodArgumentNotValid method it inherits, and which
// of the two classes answers depends on their names. Override that method instead.// TRAP: once the security starter is on the classpath, every endpoint needs a login, and a
// request with no credentials gets 401. ASP.NET Core: open until you add [Authorize].
// Boot supplies that default chain until you declare a SecurityFilterChain bean of your own.// SecurityConfig.java. Spring Security is a chain of filters that every request passes
// through before it reaches a controller. One bean configures it.
// ASP.NET Core: AddAuthentication(), AddAuthorization(), UseAuthentication(), UseAuthorization()
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // switches @PreAuthorize on
public class SecurityConfig {
@Bean // the method's return value becomes a bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http// TRAP: rules are checked in order and the first match wins. anyRequest() goes last.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll() // [AllowAnonymous]
.anyRequest().authenticated())
// TRAP: CSRF protection is on by default, so a POST without its token gets 403.
// Switch it off only for a token-based API that browsers do not call with cookies.
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // ASP.NET Core: AddJwtBearer(), with Authority for the issuer.
// Here the setting is spring.security.oauth2.resourceserver.jwt.issuer-uri.
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
}# application.yml, continued. The actuator starter adds endpoints under /actuator.
# ASP.NET Core: AddHealthChecks(), for the health endpoint only.
management:
endpoints:
web:
exposure:
include: health,info,prometheus # only health is exposed by default
# TRAP: never expose env, heapdump, beans or configprops publicly.
# Boot 4 adds /actuator/health/liveness and /readiness by default.// OrderControllerTest.java
// ASP.NET Core: WebApplicationFactory<Program>, which starts the whole application.
// @WebMvcTest is a test slice: it starts the web layer with this one controller, and no database.
// Boot 4 moved it to the module spring-boot-webmvc-test, so its import differs from Boot 3.
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc; // sends requests with no real server or network
@MockitoBean OrderService orders; // a Mockito mock replaces the bean. Boot 3: @MockBean
// TRAP: with Spring Security on the classpath, the slice protects every request.
// Without a signed-in user this test gets 401, not 200.
@Test
@WithMockUser
void get_returnsOrder() throws Exception {
when(orders.find(1L)).thenReturn(Optional.of(new OrderDto(1, "PAID", 2)));
mvc.perform(get("/api/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1));
// An unstubbed find returns Optional.empty(), so the controller would answer 404.
}
}// @SpringBootTest starts the real application context.
// ASP.NET Core: WebApplicationFactory<Program> again.
// @SpringBootTest does not provide MockMvc by itself, on Boot 3 or on Boot 4.
// Add @AutoConfigureMockMvc, or the test fails with "No qualifying bean of type MockMvc".
// Boot 4 moved the annotation to the package below, in the spring-boot-starter-webmvc-test
// starter. What Boot 4 did stop providing is TestRestTemplate: add @AutoConfigureTestRestTemplate.
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class OrderIntegrationTest {
@Autowired MockMvc mvc;
}// @DataJpaTest starts JPA and the repositories, and nothing else.
// ASP.NET Core: no counterpart. Test slices do not exist there.
// TRAP: by default it swaps in an in-memory H2 database and rolls each test back.
// H2 does not behave like PostgreSQL, and the rollback hides errors that fire on commit.
// Replace.NONE keeps the real database, which the chapter starts in a container.
import static org.assertj.core.api.Assertions.assertThat;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class OrderRepositoryTest {
@Autowired OrderRepository orders;
@Test
void findsByStatus() {
assertThat(orders.findByStatus(OrderStatus.PAID)).isEmpty();
}
}
// Most tests should need no Spring at all. Constructor injection lets a plain JUnit test
// write new OrderService(...) with fakes, and such a test runs in milliseconds.Modern C# in 10 minutes#
This is one listing of C# from C# 9 to C# 14, oldest feature first, so you can stop reading where your own knowledge ends. Each stop names the version that introduced the feature and links to the section where the book compares it with Java.
// C# 9: top-level statements. Program.cs can begin with statements, as this listing does,
// with no class and no Main method.
// Java: void main() { IO.println("Hello"); } in a compact source file, since Java 25.
Console.WriteLine("Hello"); // prints Hello// C# 9: records. One line declares a class with a constructor, properties, value equality
// and ToString.
// Java: public record OrderLine(String productId, int quantity, Money unitPrice) { }
public record Money(decimal Amount, string Currency);
public record OrderLine(string ProductId, int Quantity, Money UnitPrice);
var line = new OrderLine("mug", 2, new Money(9.50m, "EUR"));
Console.WriteLine(line == new OrderLine("mug", 2, new Money(9.50m, "EUR"))); // prints True// C# 9: with expressions. They copy a record and change the properties you name.
// Java: no with expression. Write new OrderLine(line.productId(), 3, line.unitPrice()).
var more = line with { Quantity = 3 };
Console.WriteLine(more.Quantity + line.Quantity); // prints 5// C# 9: init-only setters. An init property is set while the object is created, and never
// after that.
// Java: a final field, set once in the constructor.
public class Customer
{
public long Id { get; init; }
public string Name { get; set; } = "";
}
var ada = new Customer { Id = 7, Name = "Ada" };
// ada.Id = 8; // does not compile: error CS8852// C# 9: target-typed new. When the declaration states the type, new() does not repeat it.
// Java: List<OrderLine> lines = new ArrayList<>(); the diamond fills in only the type argument.
List<OrderLine> lines = new();
Money shipping = new(4.95m, "EUR");// C# 9: the logical patterns not, and, or. A negated type pattern still declares its
// variable, for the code after the if.
// Java: if (!(payment instanceof Card card)) { ... }, because Java has no "is not".
public abstract record Payment;
public record Card(string Last4) : Payment;
static string LastDigits(Payment payment)
{
if (payment is not Card card) throw new ArgumentException("not a card");
return card.Last4;
}
Console.WriteLine(LastDigits(new Card("4242"))); // prints 4242// C# 9: relational patterns. A pattern can compare with <, >, <= or >=. The switch
// expression around these is older: C# 8.
// Java: no relational patterns. Write a when guard: case Order o when o.lines().size() > 10.
static string Size(int lineCount) => lineCount switch
{
0 => "empty",
> 0 and <= 10 => "normal",
> 10 => "large",
_ => "invalid"
};
Console.WriteLine(Size(12)); // prints large// C# 10: record structs. A record that is a value type: copied when assigned.
// Java: public record Percentage(BigDecimal value) { } is the closest, but it is still an
// object on the heap.
public readonly record struct Percentage(decimal Value);// C# 10: file-scoped namespaces. One line puts the whole file in a namespace, with no
// braces. In a real file it comes before every type.
// Java: package com.acme.orders;
namespace Acme.Orders;// C# 10: global using directives. Written at the top of one file, it applies to every
// file in the project.
// Java: no equivalent. Each file lists its own imports.
global using Acme.Orders;// C# 10: extended property patterns. A dot reaches a nested property. Before, you wrote
// { Lines: { Count: 0 } }.
// Java: no property patterns. Write a when guard: case Order o when o.lines().isEmpty().
public record Order(long Id, Customer Customer, List<OrderLine> Lines);
static string Describe(Order order) => order switch
{
{ Lines.Count: 0 } => "empty",
{ Customer.Name: "Ada", Lines.Count: > 10 } => "large",
_ => "normal"
};
Console.WriteLine(Describe(new Order(1, ada, lines))); // prints empty// C# 10: a lambda has a natural type, so var can hold one: here Func<Order, bool>.
// Java: a lambda stands in for a functional interface, such as Function<Integer, String>.
var isEmpty = (Order o) => o.Lines.Count == 0;// C# 11: raw string literals. Three quotes open and close the text, and nothing inside is
// an escape. The closing quotes mark the left margin. With $$ in front, a value goes in
// double braces, so a single brace is plain text.
// Java: a text block, also in three quotes, but it still processes escapes such as \n.
var orderId = 1042;
var json = $$"""
{
"orderId": {{orderId}}
}
""";
Console.WriteLine(json.Split('\n')[1].Trim()); // prints "orderId": 1042// C# 11: required members. The caller must set a required property in the object initializer.
// Java: no equivalent. A final field set in the constructor does this job.
public class Invoice
{
public required long OrderId { get; init; }
public Money? Total { get; set; }
}
var invoice = new Invoice { OrderId = 1042 };
// var unset = new Invoice(); // does not compile: error CS9035// C# 11: list patterns. Square brackets match a list by its shape: [] is empty, a name
// takes one element, and .. skips any number.
// Java: no list patterns. Test the size in a when guard: case Order o when o.lines().size() == 1.
static string Shape(List<OrderLine> items) => items switch
{
[] => "no lines",
[var only] => $"one line: {only.ProductId}",
[var first, .., var last] => $"from {first.ProductId} to {last.ProductId}"
};
lines.Add(line);
Console.WriteLine(Shape(lines)); // prints one line: mug// C# 12: primary constructors on any class or struct. The parameters are in scope in the
// whole body. Unlike a record's, they do not become properties.
// Java: no primary constructors. Declare a final field and write the constructor out.
public interface IOrderRepository { void Save(Order order); }
public class OrderService(IOrderRepository repository)
{
public void Place(Order order) => repository.Save(order);
}// C# 12: collection expressions. Square brackets build a list, an array or a span, and ..
// spreads another collection into it.
// Java: List.of("mug", "cup") is a fixed list. new ArrayList<>(List.of("mug", "cup")) can grow.
List<string> products = ["mug", "cup"];
string[] catalogue = [..products, "plate"];
Console.WriteLine(catalogue.Length); // prints 3// C# 13: params collections. A params parameter need not be an array. It can be a span,
// a List<T> or an interface such as IEnumerable<T>.
// Java: varargs, as in void log(String fmt, Object... args). The method receives an array.
static decimal Sum(params ReadOnlySpan<decimal> amounts)
{
decimal sum = 0;
foreach (var amount in amounts) sum += amount;
return sum;
}
Console.WriteLine(Sum(9.50m, 4.95m)); // prints 14.45// C# 13: the Lock type, System.Threading.Lock, new in .NET 9. Declare the gate as a Lock,
// not an object. The lock statement then uses the Lock's own API in place of Monitor.
// Java: synchronized (gate) { total += x; } with a private final Object gate.
public class SalesTotal
{
private readonly Lock _gate = new();
private long _total;
public long Add(int x)
{
lock (_gate) { return _total += x; }
}
}// C# 13: partial properties and indexers. As with a partial method, one part declares the
// property and another implements it.
// Java: no equivalent. One class cannot span files.
public partial class Product
{
public partial string Sku { get; set; }
}
public partial class Product
{
private string _sku = "";
public partial string Sku { get => _sku; set => _sku = value.ToUpperInvariant(); }
}// C# 14: extension members. An extension block names the receiver once, and every member
// in it extends that type. Extension properties, static members and operators are new.
// The older syntax, a static method with a "this" parameter, still works.
// Java: no extension members. Write a static utility method, called as Strings.isBlank(name).
public static class OrderExtensions
{
extension(Order order)
{
public bool IsEmpty => order.Lines.Count == 0;
public int Units() => order.Lines.Sum(l => l.Quantity);
}
extension(Order) // no receiver name: static members of Order
{
public static Order Empty(Customer customer) => new(0, customer, []);
}
}
var order = new Order(1042, ada, [line, more]);
Console.WriteLine(order.Units()); // prints 5
Console.WriteLine(Order.Empty(ada).IsEmpty); // prints True// C# 14: the field keyword. Inside an accessor, field is the backing field the compiler
// creates, so you declare none.
// Java: no properties. A private field, getName() and setName(String v).
public partial class Product
{
public string Name { get; set => field = value.Trim(); } = "";
}// C# 14: partial constructors and partial events. As with partial properties, one part
// declares and the other implements.
public partial class Product
{
public partial Product(string sku);
}
public partial class Product
{
public partial Product(string sku) { Sku = sku; }
}
var mug = new Product("mug-01") { Name = " Mug " };
Console.WriteLine($"{mug.Sku}: {mug.Name}"); // prints MUG-01: Mug// C# 14: null-conditional assignment. ?. and ?[] can stand on the left of = or +=. When
// the receiver is null, nothing is assigned and the right side is not evaluated.
// Java: no ?. operator. Write the null check yourself.
Invoice? missing = null;
missing?.Total = shipping;
invoice?.Total = shipping;
Console.WriteLine(invoice?.Total == shipping); // prints True// C# 14: nameof accepts an unbound generic type. Before, it needed type arguments, as in
// nameof(Dictionary<string, int>).
// Java: no nameof. A name in a message is a plain string.
Console.WriteLine(nameof(Dictionary<,>)); // prints Dictionary// C# 14: modifiers on simple lambda parameters. A parameter can be marked ref, in, out or
// scoped with no type written.
// Java: no out parameters. Return an OptionalInt, or a record.
public delegate bool Parser<T>(string text, out T value);
Parser<int> parse = (text, out value) => int.TryParse(text, out value);
Console.WriteLine(parse("12", out var quantity) && quantity == 12); // prints True// C# 14: user-defined compound assignment operators. A type can declare += itself, as an
// instance method that returns void and changes the object in place. Without one, a += b
// still means a = a + b.
// Java: no operator overloading. BigDecimal has methods: price.multiply(quantity).add(shipping).
public class Basket
{
public decimal Total { get; private set; }
public void operator +=(decimal amount) => Total += amount;
}
var basket = new Basket();
basket += 9.50m;
Console.WriteLine(basket.Total); // prints 9.50// C# 14: file-based apps. With the .NET 10 SDK, dotnet run hello.cs runs one file with no
// project. Lines that start with #: at the top of that file do the work of a .csproj.
// Inside a project they are error CS9298, so they are commented out.
// Java: java Hello.java runs one source file with no separate compile step.
// #:package Humanizer@2.14.1
// #:property Nullable=enable// C# 13 and C# 14: smaller changes, mostly for library authors. None of them has a Java
// side, and the book does not need them.
// C# 13: the escape sequence \e is the ESCAPE character, U+001B. Before, you wrote \u001b.
var bold = "\e[1mPAID\e[0m";
Console.WriteLine((int)bold[0]); // prints 27
// C# 13: a ref struct can implement an interface, and "allows ref struct" lets a type
// parameter take one.
// An async method can declare a ref struct local, if it is not used across an await.
// An object initializer can index from the end: Slots = { [^1] = 9 }.
// [OverloadResolutionPriority(1)] on an overload tells the compiler to prefer it.
public ref struct LineReader : IDisposable
{
public void Dispose() { }
}
static void Close<T>(T resource) where T : IDisposable, allows ref struct => resource.Dispose();
Close(new LineReader());
// C# 14: more implicit conversions for Span<T> and ReadOnlySpan<T>. An array now converts
// to a span in more places, such as the receiver of an extension method.
decimal[] prices = [9.50m, 4.95m];
Console.WriteLine(prices.StartsWith(9.50m)); // prints TrueAppendix A: Java 9 to 25#
This appendix lists the Java release that added each feature. It also gives the status of
each feature, and the status is as important as the release. Most features are
final. Others are still a preview, an incubator or
experimental. A few are deprecated or were withdrawn.
Roughly half the Java features that people write about in blogs are still preview-only.
Preview features need the option
--enable-preview, and they can change or disappear between releases.
Releases with long-term support (LTS) are marked. Target an LTS. Most rows name the chapter that shows the feature in code. Search for the title of that chapter to go there.
The entries below for Java 21, 22, 24 and 25 are verified against the project pages of
those releases on openjdk.org. The entries for Java 9 to 20 and for Java 23 are
widely documented and stable, but they were not verified again for this edition. If you rely on
an exact release number, check openjdk.org/projects/jdk/<n>.
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 | no equivalent, because a using directive imports one namespace | Access modifiers and packages |
| Compact source files, instance main | 25 | final | top-level statements | Getting a JDK |
| Flexible constructor bodies | 25 | final | none, because base(...) is written before the body | 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 Java 23, 24 or 25. Java has no string interpolation. Any tutorial that shows
STR."..." describes a feature that no longer exists. See
var, strings and text blocks.
Concurrency#
Each feature was added through a proposal to the Java Development Kit (JDK). Such a proposal is called a JDK Enhancement Proposal (JEP). The two biggest concurrency changes were virtual threads and structured concurrency. Virtual threads are now final. Structured concurrency is still a preview. This table lists the concurrency features:
| Feature | Release | Status | Note |
|---|---|---|---|
| CompletableFuture improvements | 9 | final | The improvements are timeouts and delayed executors. See the chapter CompletableFuture and Task. |
| Virtual threads | 21 | final | Java uses virtual threads for the work that C# does with async and await. See the chapter Threads are cheap now. |
| Synchronize virtual threads without pinning | 24 | final | JEP 491 removes the synchronized trap. See the chapter Threads are cheap now. |
| Scoped values | 25 | final | A scoped value is like AsyncLocal in C#. See the chapter Structured concurrency and cancellation. |
| Structured concurrency | 25 | PREVIEW | This is the fifth preview, and the API has changed. See the chapter Structured concurrency and cancellation. |
| Stable values | 25 | PREVIEW | A stable value is like Lazy<T> in C#, and the JIT can treat it as a constant. See the chapter Locks, atomics and the memory model. |
Structured concurrency is still preview in Java 25. It is JEP 505, and this is its fifth preview. Virtual threads are final and safe to use. The scope API around virtual threads is not final, and it is not safe to rely on. Any text that you read from 2023 or 2024 uses an earlier form of the API.
Library and API#
| Feature | Release | Status | Note |
|---|---|---|---|
| Collection factories List.of, Map.of | 9 | final | These factories create an unmodifiable collection in one call. See the chapter Collections. |
| Stream takeWhile, dropWhile, iterate | 9 | final | takeWhile and dropWhile are like TakeWhile and SkipWhile in LINQ. See the chapter Streams vs LINQ. |
| Optional.stream, ifPresentOrElse | 9 | final | These methods give you more ways to use a value that may be absent. Only stream() is listed in the chapter Nullability. |
| New HTTP client (java.net.http) | 11 | final | This client is part of the JDK, and it is like HttpClient in .NET. See the chapter HTTP clients and resilience. |
| String isBlank, lines, strip, repeat | 11 | final | These methods are like IsNullOrWhiteSpace and similar string methods in C#. See the chapter var, strings and text blocks. |
| Files.readString, writeString | 11 | final | These methods are like File.ReadAllText and File.WriteAllText in C#. See the chapter Files and I/O. |
| Collectors.teeing | 12 | final | This collector combines two collectors into one result. See the chapter Streams vs LINQ. |
| Stream.toList() | 16 | final | This method replaces collect(toList()). See the chapter Streams vs LINQ. |
| Sequenced collections | 21 | final | These collections have the methods getFirst, getLast and reversed(). See the chapter Collections. |
| Foreign Function and Memory API | 22 | final | This API is like P/Invoke in .NET. See the appendix Going deeper. |
| Class-File API | 24 | final | This API is like Reflection.Emit in .NET. See the appendix Going deeper. |
| Stream gatherers | 24 | final | Gatherers are custom intermediate operations, such as Chunk. See the chapter Streams vs LINQ. |
| Ahead-of-Time Class Loading and Linking | 24 | final | Startup is faster after a training run. See the chapter Packaging and native images. |
| Permanently disable the Security Manager | 24 | final | The Security Manager was already deprecated. See the appendix The Java you'll inherit. |
| ZGC: remove non-generational mode | 24 | final | Generational is now the only ZGC mode. See the chapter 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 | This is the tenth incubation. The Vector API is like System.Numerics.Vector in .NET. |
Runtime, garbage collection (GC) and tooling#
| Feature | Release | Status | Note |
|---|---|---|---|
| jshell (REPL) | 9 | final | This is the Java REPL, which you use to try an API quickly. See the chapter Getting a JDK. |
| JPMS modules | 9 | final | A module is declared in the file module-info.java. See the chapter Modules, JPMS and the missing internal. |
| Single-file source launch | 11 | final | You run java Foo.java with no compile step. See the chapter Getting a JDK. |
| Flight Recorder open-sourced | 11 | final | JDK Flight Recorder (JFR) is free to use in production. See the chapter Actuator and observability. |
| Helpful NullPointerExceptions | 14 | final | The exception message names the expression that was null. The feature has been on by default since Java 15. |
| Strong encapsulation of JDK internals | 17 | final | Old libraries that use JDK internals now fail. See the chapter Modules, JPMS and the missing internal. |
| Deprecate finalization for removal | 18 | deprecated | Never override finalize(). See the chapter Exceptions and resources. |
| Generational ZGC | 21 | final | ZGC gained generations, which reduced 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 choose the non-generational mode |
| Compact object headers | 25 | final | The option -XX:+UseCompactObjectHeaders is off by default. It makes the header of every object smaller. |
| Ahead-of-time command-line ergonomics | 25 | final | One flag creates the AOT cache. See the chapter Packaging and native images. |
| Ahead-of-time method profiling | 25 | final | Profiles from a training run make warm-up faster. See the chapter The JVM at runtime. |
| Generational Shenandoah | 25 | final | Generational Shenandoah is a supported option but not the default. The option is -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, but it lacks almost everything in the tables 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 has a new release every six months, in March and September. Every fourth release is an LTS, so a new LTS arrives every two years. A non-LTS release receives updates for six months. Use an LTS in production. Treat a non-LTS release as a way to try preview features early.
Appendix B: C# to Java, A to Z#
This appendix lists every C# construct, keyword, type and library in alphabetical order. Each row gives what Java uses in its place. Use this page when you know the C# word and need the Java word.
Search is faster than this page. Press ⌘K, or Ctrl+K, and type the C# name. Every row of every table in this book is indexed.
Some rows are about runtime concepts, such as assembly probing and the classpath. Those rows name the chapter How Java runs your code. Read that chapter once before you rely on those rows.
Two more places answer questions that this index cannot answer. For the Java tools that have no C# name to search for, see the section Only in Java. For the whole language as one listing of commented code, see the chapter Java in 19 minutes.
A to C#
| C# | Java | Chapter |
|---|---|---|
| abstract | abstract | Interfaces, abstract classes and sealing |
| Action<T> | Consumer<T> | Methods and parameters |
| AggregateException | CompletionException / ExecutionException | CompletableFuture and Task |
| Akka.NET | Akka, or Apache Pekko | Your library, translated |
| AngleSharp | jsoup | Your library, translated |
| AppContext.GetData | System.getProperty | How Java runs your code |
| as | no equivalent, so use an instanceof pattern | Sealed types and pattern matching |
| ASP.NET Core Identity | Spring Security and your own user tables, or Keycloak | Your library, translated |
| Asp.Versioning | built into Spring Framework 7 | Your library, translated |
| assembly probing | the classpath | How Java runs your code |
| async / await | nothing, use virtual threads | Threads are cheap now |
| AsyncLocal<T> | ScopedValue | Structured concurrency and cancellation |
| AutoMapper | MapStruct | Your library, translated |
| AWS SDK for .NET | AWS SDK for Java 2.x | Your library, translated |
| Azure SDK for .NET | Azure SDK for Java | Your library, translated |
| base | super | Classes, constructors and initialisation |
| BCrypt.Net | Spring Security PasswordEncoder | Your library, translated |
| BenchmarkDotNet | JMH | Testing |
| bool | boolean | Numbers, money and time |
| [BsonElement] | @Field | Document data |
| [BsonId] | @Id, from org.springframework.data.annotation | Document data |
| [BsonIgnore] | @Transient | Document data |
| Builders<T>.Filter | a derived query method, or Criteria.where | Document data |
| Builders<T>.Update | Update, with MongoTemplate.updateFirst | Document data |
| byte (unsigned) | no equivalent, because byte is signed | Numbers, money and time |
| CancellationToken | Thread.interrupt, or a scope | Structured concurrency and cancellation |
| checked / unchecked | no equivalent, so use Math.addExact | The week-one gotchas |
| class | class | Classes, constructors and initialisation |
| ClosedXML | Apache POI | Your library, translated |
| const | static final | Fields, properties and immutability |
| ConcurrentDictionary | ConcurrentHashMap | Locks, atomics and the memory model |
| ConfigureAwait | no equivalent, and not needed | Threads are cheap now |
D to F#
| C# | Java | Chapter |
|---|---|---|
| DataAnnotations | Jakarta Bean Validation | Validation and error responses |
| DateTime | LocalDateTime / Instant | Numbers, money and time |
| DateTimeOffset | OffsetDateTime / Instant | Numbers, money and time |
| DbContext | EntityManager / repository | Data access, JPA and migrations |
| DbUp | Flyway | Your library, translated |
| decimal | BigDecimal | Numbers, money and time |
| default(T) | null, or a primitive default | Generics and type erasure |
| 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 |
| Duende IdentityServer | Spring Authorization Server, or Keycloak | Your library, translated |
| dynamic | no equivalent | Generics and type erasure |
| Entity Framework | Hibernate / Spring Data JPA | Data access, JPA and migrations |
| enum | enum, but far more powerful | Enums |
| Environment.GetEnvironmentVariable | System.getenv | How Java runs your code |
| EPPlus | Apache POI | Your library, translated |
| ErrorOr | a sealed Result type, or Vavr | Your library, translated |
| event | a listener list, or a functional interface | Interfaces, abstract classes and sealing |
| Expression<T> | no equivalent, because Java has no expression trees | Streams vs LINQ |
| extension method | a static utility, or a default method | Methods and parameters |
| [Flags] | EnumSet | Enums |
| FluentAssertions | AssertJ | Testing |
| FluentMigrator | Flyway Java migrations, or Liquibase | Your library, translated |
| FluentValidation | Bean Validation | Validation and error responses |
| Flurl | RestClient | Your library, translated |
| Func<T,R> | Function<T,R> | Methods and parameters |
G to L#
| C# | Java | Chapter |
|---|---|---|
| GetHashCode | hashCode | Equality, hashing and comparison |
| GetType() | getClass() | Classes, constructors and initialisation |
| global using | no equivalent | Access modifiers and packages |
| goto | labelled break / continue | Switch expressions and statements |
| Hot Chocolate | Spring for GraphQL | Your library, translated |
| HtmlAgilityPack | jsoup | Your library, translated |
| HttpClient | RestClient / java.net.http.HttpClient | HTTP clients and resilience |
| HybridCache | no equivalent, so combine Caffeine and Redis by hand | Your library, translated |
| IAsyncDisposable | no equivalent | Exceptions and resources |
| IAsyncEnumerable<T> | no equivalent, so use a BlockingQueue | Threads are cheap now |
| IClientSessionHandle | @Transactional and a MongoTransactionManager | Document data |
| IComparable<T> | Comparable<T> | Equality, hashing and comparison |
| IDisposable | AutoCloseable | Exceptions and resources |
| IDistributedCache | Spring Data Redis behind @Cacheable | Your library, translated |
| IEnumerable<T> | Iterable<E> | Collections |
| IEnumerator<T> | Iterator<E> | Collections |
| ILogger<T> | SLF4J Logger | Actuator and observability |
| ImageSharp | ImageIO, Thumbnailator | Your library, translated |
| IMemoryCache | Caffeine behind @Cacheable | Your library, translated |
| IMongoCollection<T> | MongoRepository<T, ID>, or MongoTemplate | Document data |
| in parameter | not needed | Methods and parameters |
| init | final field, or a record | Fields, properties and immutability |
| InsertOne | save on a MongoRepository | Document data |
| internal | package-private, or a module | Access modifiers and packages |
| IOptions<T> | @ConfigurationProperties | Dependency injection and configuration |
| is T x | instanceof T x | Sealed types and pattern matching |
| iText | iText | Your library, translated |
| LanguageExt | Vavr | Your library, translated |
| lock | synchronized / ReentrantLock | Locks, atomics and the memory model |
| LINQ | Streams | Streams vs LINQ |
| List<T> | ArrayList<E> | Collections |
M to R#
| C# | Java | Chapter |
|---|---|---|
| MailKit | Jakarta Mail through JavaMailSender | Your library, translated |
| Mapster | MapStruct | Your library, translated |
| MediatR | Spring events / Axon | Caching, scheduling and events |
| Microsoft.FeatureManagement | Togglz / OpenFeature | Your library, translated |
| Microsoft.Identity.Web | Spring Cloud Azure | Your library, translated |
| MongoClient | created by Boot from spring.mongodb.uri | Document data |
| MongoDB.Driver | Spring Data MongoDB | Document data |
| Moq | Mockito | Testing |
| namespace | package | Access modifiers and packages |
| NativeAOT | GraalVM native-image | Packaging and native images |
| NBomber | Gatling / JMeter | Your library, translated |
| Newtonsoft.Json | Jackson | Your library, translated |
| NodaTime | java.time | Numbers, money and time |
| NPOI | Apache POI, the original | Your library, translated |
| NuGet | Maven Central | Maven vs csproj |
| NuGet global packages folder | ~/.m2/repository | How Java runs your code |
| nameof | no equivalent | Annotations and attributes |
| Nullable<T> / T? | the wrapper type, and Optional for returns | Nullability |
| null-forgiving ! | no equivalent | Nullability |
| null-conditional ?. | Optional.map, or an explicit check | Nullability |
| null-coalescing ?? | Objects.requireNonNullElse | Nullability |
| Ocelot | Spring Cloud Gateway | Your library, translated |
| OneOf | sealed interfaces and records | Your library, translated |
| OpenIddict | Spring Authorization Server, or Keycloak | Your library, translated |
| operator overloading | no equivalent | Methods and parameters |
| Orleans | cluster sharding in Pekko or Akka, the nearest match | Your library, translated |
| out parameter | return a record, or Optional | Methods and parameters |
| override | @Override, which is an annotation and not a keyword | Interfaces, abstract classes and sealing |
| params | varargs, Object... | Methods and parameters |
| partial class | no equivalent | Classes, constructors and initialisation |
| Playwright | Playwright for Java | Your library, translated |
| Polly | Resilience4j, or @Retryable on Boot 4 | HTTP clients and resilience |
| Predicate<T> | Predicate<T> | Methods and parameters |
| ProblemDetails | ProblemDetail | Validation and error responses |
| property | getX / setX, or a record component | Fields, properties and immutability |
| QuestPDF | OpenPDF / Apache PDFBox | Your library, translated |
| readonly | final | Fields, properties and immutability |
| record | record | Records |
| record struct | no equivalent | Records |
| ref parameter | no equivalent | Methods and parameters |
| Refit | @HttpExchange interfaces | HTTP clients and resilience |
| Reqnroll | Cucumber-JVM | Your library, translated |
| RestSharp | RestClient | Your library, translated |
S to Z#
| C# | Java | Chapter |
|---|---|---|
| Scrutor | Spring component scanning, built in | Your library, translated |
| sealed class | final class | Interfaces, abstract classes and sealing |
| Selenium WebDriver | Selenium | Your library, translated |
| Serilog | Logback via SLF4J | Your library, translated |
| SignalR | WebSocket + STOMP | Messaging and WebSockets |
| Span<T> | ByteBuffer / MemorySegment | Methods and parameters |
| SpecFlow | Cucumber-JVM | Your library, translated |
| Stateless | stateless4j | Your library, translated |
| static class | final class, private constructor | Classes, constructors and initialisation |
| string | String | var, strings and text blocks |
| string interpolation | none, so use concatenation or formatted() | var, strings and text blocks |
| struct | no equivalent, use a record | Classes, constructors and initialisation |
| Stryker.NET | PIT | Your library, translated |
| Swashbuckle | springdoc-openapi | Controllers, routing and binding |
| switch expression | switch expression | Switch expressions and statements |
| System.Reactive | Project Reactor / RxJava | Your library, translated |
| System.Text.Json | Jackson | Your library, translated |
| Task<T> | CompletableFuture<T> | CompletableFuture and Task |
| Task.Run | executor.submit | Threads are cheap now |
| Task.WhenAll | StructuredTaskScope, or futures | Structured concurrency and cancellation |
| Testcontainers | Testcontainers, same project | Testing |
| this() constructor chaining | this(...) in the body | Classes, constructors and initialisation |
| ThreadPool | ExecutorService | Legacy concurrency you will still meet |
| ToString | toString | Classes, constructors and initialisation |
| TryParse | catch NumberFormatException | Numbers, money and time |
| typeof(T) | T.class, or Class<T> | Generics and type erasure |
| uint / ulong | no equivalent | Numbers, money and time |
| UpdateOne | MongoTemplate.updateFirst | Document data |
| using directive | import | Access modifiers and packages |
| using statement | try-with-resources | Exceptions and resources |
| var | var | var, strings and text blocks |
| Verify | ApprovalTests.Java | Your library, translated |
| virtual | the default, so use final to prevent overriding | Interfaces, abstract classes and sealing |
| volatile | volatile, stronger in Java | Locks, atomics and the memory model |
| WebApplicationFactory<T> | @SpringBootTest with MockMvc | Your library, translated |
| where T : X | <T extends X> | Generics and type erasure |
| with expression | no equivalent | Records |
| xUnit | JUnit 5 | Testing |
| yield return | no equivalent, so use Stream.iterate | Streams vs LINQ |
| ZXing.Net | ZXing, the original | Your library, translated |
The rows to remember are in the next table. These rows change how you design, and not only how you type:
| C# | Java | Why it matters |
|---|---|---|
| async / await | virtual threads | no function colouring, so write blocking code |
| IEnumerable<T> | Iterable<E> | not Iterator, because Iterator gives single-use APIs |
| decimal | BigDecimal | method-call arithmetic, and the top source of money bugs |
| 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 almost never breaks old code. So a real codebase holds layers from several eras, with the oldest style at the bottom. This appendix is a guide to those layers. For each old technology, it says what the technology is, roughly when it was written, and what replaced it.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | .NET Framework 1.x, WebForms, ASP.NET Core 1-3, .NET 5-10 | J2EE, Spring XML, Spring Boot, modern Java | each of the four eras has its own house style, so the style of a file tells you its era Why |
| twist | ArrayList and Hashtable, from before 2005 | import javax.servlet, applicationContext.xml, @Autowired on fields | each signal dates the code, as ArrayList and Hashtable date C# Why |
| twist | List<Order> orders = new List<Order>(); | List<Order> orders = new ArrayList<Order>(...); | Java 7 added the diamond, <>, in 2011. Code that repeats the type argument is older, or was written from habit. Why |
| twist | a constructor parameter | @Autowired on a private field, in older code | Field injection dates a class to about 2007 to 2016. New code injects through the constructor. Why |
| trap | a namespace that changes in every file | import jakarta.persistence.Entity | Spring Boot 2 uses javax, and Spring Boot 3 and 4 use jakarta. Nothing translates between javax and jakarta. Why |
| twist | why the names changed | Jakarta EE, once Java EE | Oracle kept the Java trademarks, so Eclipse could not change anything under the javax names Why |
| trap | expecting XML binding inside the runtime | NoClassDefFoundError: javax/xml/bind/JAXBContext | Java 11 removed JAXB from the JDK, and the code fails only when the line that uses it runs Why |
| twist | adding a NuGet package | jakarta.xml.bind-api and jaxb-runtime | add JAXB back as two dependencies, and change the imports to jakarta.xml.bind Why |
| twist | APIs you may still see in old code | applets, Nashorn, the Security Manager, Thread.stop | Java 15 removed Nashorn, Java 24 disabled the Security Manager, and Java 26 removed the Applet API and Thread.stop Why |
| twist | orders.Sort((a, b) => a.Total.CompareTo(b.Total)) | Collections.sort(orders, new Comparator<Order>() { ... }) | Before Java 8, in 2014, a callback was a whole anonymous class. Today a callback is a lambda. Why |
| twist | services registered in Program.cs | <bean> elements in applicationContext.xml | This is how Spring was configured before annotations. The XML still loads in Spring 7, often beside annotations. Why |
| twist | MSBuild targets | build.xml, for Ant | Ant is a task runner with no conventions and no dependency management, and Maven replaced it Why |
| twist | IHttpHandler, Web Forms, Silverlight | servlets, Struts and JSF, applets | Servlets still run under Spring MVC. If you inherit Struts, migrate to Spring MVC. Why |
| twist | COM+, .NET Remoting and IIS | EJB 2 and application servers | Spring beans replaced EJB 2, and an embedded web server replaced application servers Why |
| twist | ASMX and WCF | @WebService, WSDL and SOAP | these are XML services, and they are still current in banking, government and telecoms Why |
| twist | dating advice by the C# it assumes | dating advice by the Java era it assumes | an old answer may still recommend finalize(), which Java 18 deprecated for removal Why |
Four erastwist#
The history of Java has four eras, and each era has its own house style. The first era was built on the Java 2 Platform, Enterprise Edition (J2EE). J2EE was a set of standards for business software that ran inside large shared servers. The last column of the table names what .NET developers were using at the same time. That name is often the quickest way to picture an era. These are the four eras:
| 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, and 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. It is more useful to recognise the era of a file from its style.
Dating a codebase at a glancetwist#
You can already date C# code by its style. A class full of ArrayList and
Hashtable was written before generics arrived in 2005. A class that uses
async and await was written after 2012. Java code shows its age in
the same way. Two fragments of one file can be enough:
import javax.servlet.http.HttpServlet; // javax, not jakarta: before 2020
@Autowired
private OrderRepository repository; // injected into a field: about 2007 to 2016The javax.servlet import and the @Autowired field are two signals.
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, and 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 |
One class, as written around 2010twist#
This Spring service returns the shop's orders, with the biggest total first.
OrderRepository is an interface. Its method findAll() returns a
List<Order>:
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;
}
}@AutowiredThis is called field injection. Spring creates the
OrderReport, then writes the repository directly into the private field. Field injection is typical of the annotation era of Spring, about 2007 to 2016.List<Order> orders = new ArrayList<Order>(repository.findAll());<Order>is written twice. Java 7 added the empty brackets<>in 2011, and they are called the diamond. Code that repeats the type is older, or was written from habit.Collections.sort(orders, new Comparator<Order>() {This is an anonymous class. It is a whole class written inline, only to pass one method. Java had no lambdas until Java 8, in 2014.
return b.getTotal().compareTo(a.getTotal());Comparing
bwithaputs the biggest total first.getTotal()is a getter method on an ordinary class. Before records, a class exposed its data through getter methods like this one.
The same class, written todaytwist#
Today the class uses
constructor injection.
Order is now a record, so the code reads the total with total(), not
with 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;This is constructor injection. Spring passes the repository to the constructor. So the field can be
final, and anOrderReportcannot exist without a repository. A class with one constructor needs no@Autowired..sorted(Comparator.comparing(Order::total).reversed())Order::totalis a method reference to thetotal()method of the record. This method reference replaces the whole anonymous class.reversed()changes the order from smallest first to biggest first..toList();This line returns a list that cannot be changed. The
toList()method arrived in Java 16.
Both versions are correct. The older version still runs on Java 25 and Spring 7. But a class in the older style tells you that the classes around it were probably written in the same era.
The javax to jakarta renametrap#
When you move a Spring Boot 2 service to Spring Boot 3, nearly every
entity, controller and validator stops compiling. The
enterprise standards of Java were once called Java Enterprise Edition (Java EE). They are now
called Jakarta EE. In 2020, Jakarta EE 9 moved every enterprise API
package from javax to jakarta,
because of a trademark:
| 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) |
Only the import lines change. The classes, annotations and methods keep their names:
// 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),
which is the standard behind Hibernate.
NotNull comes from Jakarta
Validation. The rename never affected the java.* packages, such as
java.util.
This is a hard break, not a deprecation. Nothing in the framework translates
between javax and jakarta. An upgrade means that you change those
imports everywhere, and that you replace any library that still uses javax. Tools
such as OpenRewrite and the Eclipse Transformer can do the renaming. But plan the upgrade as a
project, not as the work of one afternoon.
Why the packages were renamed#
The cause is not a bug. The cause is a trademark. The enterprise standards of Java were
called the Java Enterprise Edition (Java EE). They were in the javax packages. In
2017 Oracle gave Java EE to the Eclipse Foundation, and the Eclipse Foundation later renamed it
Jakarta EE. Oracle kept the Java trademarks, and the agreement did not let Eclipse change
anything under the javax names. So in 2020, Jakarta EE 9 moved every enterprise API
package from javax to jakarta. The
servlet class that every web application uses shows the
result:
import javax.servlet.http.HttpServlet; // the Java EE name
import jakarta.servlet.http.HttpServlet; // the Jakarta EE name, from 2020HttpServlet is the same class under both names. Only the package in front of the
class name changed.
What Java removedtrap#
A service that exports invoices as XML moves from Java 8 to Java 11. It starts normally, then fails on its first export:
java.lang.NoClassDefFoundError: javax/xml/bind/JAXBContext#Caused by: java.lang.ClassNotFoundException.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. Java 11 then deleted them. The error names a missing class. It does not tell you that a dependency is missing.
Adding JAXB back, and the other removalstwist#
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 it chooses the Jakarta versions:
<!-- 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>This dependency holds the classes that your code calls. You write no
<version>in these dependencies, because Spring Boot supplies it. Spring Boot 4.1 chooses version 4, and version 4 is in the packagejakarta.xml.bind. Soimport javax.xml.bind.JAXBContextmust becomeimport jakarta.xml.bind.JAXBContext.<artifactId>jaxb-runtime</artifactId>This dependency is the engine behind the API. Without it, the code compiles. But the code then fails when it creates a
JAXBContext. The failure is aJAXBException, which says 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 | Java 11 removed the browser plugin. Java 17 deprecated the API, and Java 26 removed it. |
| Java Web Start | Java 11 | it is gone, so ship an installer built with jpackage instead |
| CORBA and Java EE modules | Java 11 | JAXB and JAX-WS became separate dependencies, as the JAXB example above shows |
| Nashorn JavaScript engine | Java 15 | it is gone, so use GraalJS to run JavaScript on the JVM |
| Security Manager | Java 24 | it is permanently disabled, so you can no longer enable it |
| Thread.suspend and resume | Java 23 | They were removed completely. They were never safe to call. |
| Thread.stop | Java 26 | It has thrown an exception instead of stopping the thread 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 mode that ZGC has |
Language habits from before Java 8#
LegacyAnonymous inner classes as callbacks
Java 8 arrived in 2014. Before Java 8, you had to write a whole class inline to pass a piece of behaviour. C# has had lambdas since 2007, so the same sort looks very different in C# and in old Java:
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 versions sort orders with the smallest total first. The Java version
creates an unnamed class that implements Comparator. The class has one method,
compare, which does the comparison. 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
The JDK did not always have collection factories, Optional and string helpers.
Before the JDK had them, two libraries provided them. The two libraries are Guava, from Google,
and Apache Commons. Older code is full of calls to these libraries, and most of the calls now
have a JDK method that does the same job. For each common call and its replacement, see the Quick
reference at the end of this appendix.
Both libraries are still in use. The caches, multimaps and graph types of Guava have no JDK equivalent. But new code should not use Guava or Apache Commons to do what the JDK now does.
LegacyChecked exceptions everywhere
Early Java APIs declared checked exceptions
freely. A checked exception is an exception that the compiler forces every caller to catch or
declare. The try/catch blocks that result are a large reason why Java
is known as a wordy language. Since then, libraries have done the opposite. Spring, Hibernate
and the AWS SDK all wrap checked exceptions in
unchecked exceptions before the exceptions
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 this in an XML file, usually called applicationContext.xml. Every
Spring bean was declared in that file. A data source is the
object that supplies database connections. In this example, the order service gets its
repository, and the repository gets its data source:
<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. The address contains a version number, here
3.0, and this number dates the file. Spring 7 still accepts the file.class="com.acme.orders.OrderService">This element declares one bean. Spring creates an
OrderServiceand registers it under the nameorderService. The class name is a plain string, and that is the weakness of the whole approach.<constructor-arg ref="orderRepository"/>This line passes the bean named
orderRepositoryto the constructor.refalways means another bean.<property name="auditEnabled" value="true"/>This line calls
setAuditEnabled(true)after the object is created.valuepasses a literal value, not a bean.
Large systems had thousands of lines of this XML. The XML held class names as plain strings.
So when you renamed a class, the build still passed, and the application failed only at its next
start-up. 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.
@Configuration classes arrived in Spring 3, in 2009. XML still works in Spring 7.
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 it copied the format. Before annotations, Hibernate read the mapping between a
class and its table from an XML file. Each class had its own file, with a name 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. The
id element names the key column, and it lets the database choose how to generate
the key. Each property element maps one field. To rename a field, you had to edit
two files. If you forgot the second file, the application failed at run time. The
@Entity, @Id and @Column annotations replaced these files
in 2006. The engine under the annotations is the same.
Build tools#
LegacyAnt and Ivy
Ant dates from 2000. It is a general-purpose task runner that is configured in XML. Ant is
closer to MSBuild than to Maven. Ant 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"/>This line defines a variable, which is used later as
${src}. Ant assumes nothing about the layout, so every project states its layout in full.<target name="compile" depends="init">This is a target, like an MSBuild target.
dependsruns the targetinitfirst.<javac srcdir="${src}" destdir="${build}" classpath="lib/commons-lang-2.6.jar"/>This line compiles the sources against a Java archive (JAR) that is checked into the repository. There is no package manager. The file in
lib/is the dependency.
Ivy is a later add-on that gave Ant dependency resolution. Maven dates from 2004. Its real
contribution was not its XML. Its real contribution was the conventions, such as
src/main/java, and a central repository of libraries. Ant is still used 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. An applet was user-interface code that was downloaded as a JAR and run by a browser plugin. Silverlight later worked in a similar way for .NET. Security flaws ended the use of applets. Then browsers dropped the Netscape Plugin API (NPAPI), which 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 an applet in an internal tool that nobody dares to delete.
LegacyServlets and JSP
A servlet is a class that handles an HTTP request. A
servlet does the same as an IHttpHandler in classic ASP.NET. Servlets date from the
late 1990s. Unlike applets, servlets are still in wide use, but they are hidden under
Spring. This servlet 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 calledweb.xmlmapped a URL such as/ordersto this class.protected void doGet(HttpServletRequest request, HttpServletResponse response)A servlet has one method for each HTTP verb, such as
doGetanddoPost. The request object and the response object are the whole API. In classic ASP.NET,HttpContextwas the whole API in the same way.response.getWriter().println("<h1>Orders</h1>");This line writes the HTML of the page directly in code. Nobody does this any more. Today a template or a JSON library produces the response.
Spring MVC is the model-view-controller web framework of
Spring, and it is built on this same API. Its DispatcherServlet is a servlet that
receives every request and passes it to your controllers. So servlets still run under every
Spring MVC application. The only change is that developers no longer write servlets
themselves. A WebFlux application is different. It runs on Netty, which is not a servlet
server.
JavaServer Pages (JSP) are HTML pages with Java mixed in, and each page is compiled into a servlet. JSP is truly legacy, so use Thymeleaf templates instead.
LegacyStruts 1 and 2
Struts was the leading model-view-controller framework of the early 2000s. Struts did the
same job in Java that ASP.NET MVC later did in .NET. 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. Struts 2 is best known for
CVE-2017-5638. This was the remote-code-execution flaw behind the 2017 Equifax breach.
If you inherit Struts, the migration target is Spring MVC. Treat the Struts version as a security matter, not as a preference.
LegacyJSF and Java EE MVC
JavaServer Faces (JSF) was the official web framework of Java EE. It is a component-based framework that keeps page state on the server. JSF is like ASP.NET Web Forms, and in the end it had the same problems as Web Forms. JSF still exists as Jakarta Faces. It is used mostly in large companies, for internal applications that have been in use for many years.
Java EE MVC is a later standard, and it is now called Jakarta MVC. Some older guides name it as the equivalent of ASP.NET MVC. Few teams use Jakarta MVC. In practice, the equivalent of ASP.NET MVC is Spring MVC, through Spring Boot.
EJB and the application server#
LegacyEJB 2.x
Enterprise JavaBeans (EJB) were the central component model of J2EE. An EJB was an object inside a server, and the server gave it transactions, security and remote calls. The closest matches in .NET are COM+ and .NET Remoting. In version 2, every bean needed four pieces:
- a home interface
- a remote interface
- an implementation class
- an XML deployment descriptor
The home interface and the remote interface 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 exception
RemoteException, and every caller had to handle it.
Spring exists because EJB needed this much extra code. In his 2002 book, Rod Johnson argued that plain objects could have the same transactions without EJB. The plain objects would run in a container that did not interfere with them. In 2006, EJB 3 switched to annotations and became reasonable. But by then Spring had replaced EJB in most new code.
LegacyApplication servers
WebSphere, WebLogic, JBoss and GlassFish were application servers. An application server is a long-running server that several applications share, and you manage it through a web console. It is like IIS when IIS hosts several sites. You packaged a web application as a web application archive (WAR). You packaged several web applications together as an enterprise archive (EAR). Then you deployed the archive into the server. Deployment was something that you did to a server. It was not a process that you started.
Spring Boot reversed this model. The web server is a library inside your JAR. Each service is its own process. The file that you deploy never changes after it is built. These changes made Java suitable for 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 technology. Before REST, the contract of a service was a Web Services Description Language (WSDL) document. Each message was a SOAP envelope, which is an XML document sent over HTTP. Tools generated client code from the WSDL, and this gave real type safety across languages. REST lost that type safety, and OpenAPI later restored much of it.
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);
}
}@WebServiceThis annotation publishes the class as a SOAP service. The server generates the WSDL from the class.
public Invoice findInvoice(@WebParam(name = "orderId") long orderId) {@WebMethodmakes the method an operation of the service.@WebParamnames the parameter in the XML. JAXB turns the returnedInvoiceinto XML.
More standards were built on SOAP, such as WS-Security and WS-ReliableMessaging. These
extra standards gave “enterprise integration” its reputation. SOAP is still current
in banking, government and telecoms. Spring Boot supports SOAP through
spring-boot-starter-webservices. The same change happened in .NET, from WCF to
ASP.NET Core.
Why any of this matterstwist#
When you know the age of a codebase, you know which advice to trust. A codebase with
applicationContext.xml and anonymous classes was probably written before 2014. At
that time, virtual threads, records and lambdas did
not exist. Search results and forum answers from that time are just as old. Some of them still
recommend overriding finalize(), which Java 18 deprecated for removal.
Most of the technology in this appendix still runs. So a Java codebase is not rewritten. It gets new layers on top of the old layers. This is why the skill of reading old Java stays useful.
1Why does nearly every entity, controller and validator stop compiling when a service moves
from Spring Boot 2 to Spring Boot 3?
javax packages, and Spring Boot 3 uses the
jakarta packages. Nothing translates between javax and
jakarta. So every enterprise import must change, for example from
javax.persistence to jakarta.persistence.2A service moved from Java 8 to Java 11 fails with NoClassDefFoundError:
javax/xml/bind/JAXBContext. What is the fix?
NoClassDefFoundError:
javax/xml/bind/JAXBContext. What is the fix?jakarta.xml.bind-api and
jaxb-runtime as dependencies, and change the imports to
jakarta.xml.bind.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, and is now called 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. It shows where each topic is, and it does not teach the topic in full. Each topic below needs more than a handbook section, and some topics 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 a convention in a few hundred words. The internals of the Java Virtual Machine (JVM) get signposts only, because they are behaviour that you have to measure. No summary replaces a measurement.
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, propagation and isolation |
| Deadlocks that only appear under load | REQUIRES_NEW holds one connection while it waits for a second, and the pool has no connection left | Transactions, propagation and isolation |
| Correct on MySQL, wrong on PostgreSQL | the two databases have different default isolation levels | Transactions, propagation and isolation |
| 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, and 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, used by metaspace, direct buffers and thread stacks | The JVM at runtime |
| Startup is slow and you have no idea why | too many auto-configurations or classes to load, and the conditions report shows which ran | Spring Boot orientation, and 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 that the build could not infer | Spring AOT, below, and Packaging and native images |
Spring internals#
These three topics explain what Spring Boot does at start-up, and each topic takes an afternoon to learn. The first topic is how Spring Boot's auto-configuration works from the library's side.
Writing an auto-configuration and a starter#
Say that several of the shop's services call the same payment provider. You want to write the client once, as a library. Then each service only adds a dependency and sets a URL. The library needs an auto-configuration. An auto-configuration is a class that creates the beans of the library, 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 does the opposite. The library registers itself, and the application does nothing. |
The library holds two types. PaymentGatewayClient wraps Spring's
RestClient. PaymentGatewayProperties is a
record that is 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;The class is in a package of its own, which no application will scan. The gotcha below explains why that matters.
@AutoConfiguration@AutoConfigurationmarks a configuration class that Spring Boot loads only through the registration file. Spring Boot loads this class after the application's own configuration. That order allows the class to skip its own bean when the application has defined one.@ConditionalOnClass(RestClient.class)Spring Boot skips the whole class unless
RestClientis on the classpath.RestClientis on the classpath when the service uses Spring's web client.@EnableConfigurationProperties(PaymentGatewayProperties.class)This line binds
payment.gateway.urlto the record and makes the record a bean. Spring can then pass the record to the method below.@ConditionalOnMissingBeanSpring creates the client only if the application has not defined its own
PaymentGatewayClient. The application's own bean always has priority.
The second part is the registration file. It is a plain text file with a fixed folder and a fixed name. It lists 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. It loads each class that
the file names, which here is PaymentGatewayAutoConfiguration. Lines that start
with a # are comments.
A starter is the last piece, and it is optional. A
starter is a dependency with no code of its own. It adds the library and everything that the
library needs, as spring-boot-starter-webmvc does for web applications. Name your
starter payment-gateway-spring-boot-starter, because names that begin with
spring-boot are reserved for Spring Boot's own modules.
Three things cause bugs here. First, the folder and the name of the registration file 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 that the application scans.
@SpringBootApplication skips classes named in a registration file, but a plain
@ComponentScan does not skip them. A plain @ComponentScan loads the
class as ordinary configuration, before Spring has seen the application's own beans. So
@ConditionalOnMissingBean does not find those beans, and the application has 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 ignored, and no error is reported.
Read the Spring Boot reference, section “Creating Your Own Auto-configuration”. Then read 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 before any bean exists. A bean definition is the recipe that Spring uses to
create a bean. A BeanPostProcessor sees each
bean when the bean is created, and can wrap or replace the bean. Spring itself uses a
BeanPostProcessor to put a proxy around a bean, and you can
use one in 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 a BeanPostProcessor. |
| 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. But Spring's hooks run inside the container and see every bean. |
Here a post-processor times every call to the shop's PaymentGateway.
PaymentGateway is an interface, and its charge method takes an
Invoice and returns a receipt number. The post-processor wraps each gateway in a
proxy. The Proxy class makes the proxy, and this class is part of the
Java Development Kit (JDK). Proxy does the same as
DispatchProxy in .NET:
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 method for every bean, once the bean is created and initialised. Other beans receive the object that the method returns.
if (!(bean instanceof PaymentGateway gateway)) {Every bean that is not a gateway passes through unchanged.
return Proxy.newProxyInstance(This call builds an object at run time. The object implements
PaymentGatewayand sends every call to the lambda below. The lambda receives the proxy, the method that is called and the arguments of the call.return method.invoke(gateway, args);This line calls the real gateway.
throw e.getCause();invokewraps any exception that the gateway throws in anInvocationTargetException. Unwrapping the exception lets the caller catch the original exception, such as anIllegalArgumentException.log.info("{}.{} took {} microseconds", beanName, method.getName(), micros);This line runs whether the call succeeded or failed. It logs the bean, the method and the time.
A BeanPostProcessor is itself a bean, and Spring must create it before the beans
that it processes. So every bean that the post-processor depends on is created very early too.
Such a bean is created before the other post-processors are ready, and it 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 the
bean can do nothing, and no error is reported.ObjectProvider<AuditLog> and call
getObject() only when the bean is first needed.Read the Spring Framework reference, section "Container Extension Points".
Spring AOT and native processing#
Normally Spring makes all its decisions at start-up. It decides which configuration applies, which beans to create and how to connect them. Ahead-of-time (AOT) processing makes those decisions during the build instead. AOT processing also 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 the Spring Boot reference, section "Ahead-of-Time Processing". Then read the chapter Packaging and native images in this book.
The rest of this appendix is signposts. Each signpost says what a 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 the JVM 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. Or a library works in tests but not in the deployed server. Or 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 matters. It appears in ordinary bugs, and it is what "JAR hell" means. |
| Read | Java Language Specification 12.2, and Oaks, "Java Performance". For the application-server case, read the Tomcat class loader documentation. |
For the basics, see the chapter How Java runs your code. That chapter covers 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 the basics 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 it recompiles the busiest methods with an optimising compiler, C2, guided by what it has seen. It makes bold guesses, and it throws the compiled code away when a guess proves wrong. This is called deoptimisation. |
| You need it when | Throughput changes minutes after start-up. Or a microbenchmark reports impossible numbers. Or the slowest requests spike and the garbage-collection logs do not explain it. |
| The C# instinct | .NET also recompiles hot methods using profile data. This is tiered compilation, with dynamic PGO on by default since .NET 8. But .NET does not deoptimise as HotSpot does. Because HotSpot deoptimises, the speed of a Java method can change long after start-up. |
| Read | Aleksey Shipilev's blog is the reference. -XX:+PrintCompilation and JITWatch show the compilation as it happens. Always measure with JMH, never with a loop that you wrote yourself. |
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 | You have a concurrency bug that you cannot reproduce. Or you are writing a lock-free structure. Or 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", is still the definitive treatment. For the specification, read JSR-133 and its FAQ. |
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. The ordering is plain, opaque, acquire/release or volatile. |
| You need it when | Reflection appears as hot in a profiler. Or you are writing a framework or serialiser. Or 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 | the java.lang.invoke package documentation, and 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 is the low-level library. 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. This is how the OpenTelemetry Java agent adds tracing with no code change. The zero-code instrumentation in .NET does the same through the CLR profiling API. |
| Read | the ByteBuddy tutorial, JEP 484 for the Class-File API, and 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. It has 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, which often causes bugs. The modern replacement is MemorySegment, from the Foreign Function and Memory API. |
| Read | the java.nio package documentation, and Netty's source for how it is really used |
Platform#
These are parts of the platform that most services never use, until one day a service must use one of them.
| 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 | the javax.management docs. Actuator also exposes endpoints over JMX. |
| ServiceLoader and the SPI pattern | you are building plug-in implementations found on the classpath, which is how JDBC drivers are found | java.util.ServiceLoader, and 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, and the keytool documentation |
| Foreign Function and Memory API | you need to call native code, or manage memory outside the heap precisely | JEP 454. This API is 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. The Vector API is still an incubator in Java 25, so it is not for production. |
| Locale, charset and i18n | text is garbled, or sorting differs between environments | always set the charset explicitly, and use ICU4J for serious internationalisation |
Ecosystem, beyond one service#
These are the projects that you use 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, read these:
| For | Read |
|---|---|
| Concurrency, properly | Goetz, "Java Concurrency in Practice" |
| Everyday idiom and API design | Bloch, "Effective Java" |
| Performance and the JVM | Oaks, "Java Performance", and Shipilev's blog for depth |
| Spring | the Spring Boot and Spring Framework reference documentation, which is genuinely good, and better than most books about them |
| What is coming | the JEP index at openjdk.org/jeps |
Remember one point. Java has a severe problem with outdated advice. The
language is thirty years old, and search results do not sort by the Java version that you use.
An answer that was right in 2011 appears beside an answer that is right today, and both are
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). Kotlin code calls Java code, and Java code calls Kotlin code. Spring Boot supports Kotlin fully. The syntax of Kotlin is much closer to C# than the syntax of Java is. But the platform under Kotlin is exactly the Java platform in this book.
| Kind | C# | Java | Note |
|---|---|---|---|
| twist | public record Customer(string Name, int Age) | data class Customer(val name: String, val age: Int) | This is Kotlin, not Java. A data class is like a record, and its properties are real properties. Why |
| twist | ann with { Age = 41 } | ann.copy(age = 41) | In Kotlin, copy with a named argument does the same as a with expression in C#. Java has no with expression. Why |
| twist | string? name = found?.Name | val name: String? = found?.name | C# only warns about an unchecked use of a nullable value, but Kotlin refuses to compile it Why |
| trap | expecting a different runtime | the same JVM, Spring and JPA | Kotlin changes the syntax only, so type erasure, proxies and virtual threads work as in Java Why |
| twist | class OrderController(OrderService service) | class OrderController(private val service: OrderService) | in Kotlin, the class header is the constructor, and Spring injects through that constructor Why |
| trap | a class Spring can subclass | a Kotlin class, final unless marked open | without the kotlin-spring plugin, start-up fails with "Cannot subclass final class" Why |
| trap | an entity class, written in Kotlin | a Kotlin @Entity class | in Kotlin, the kotlin-jpa plugin adds the no-argument constructor but leaves the entity final Why |
| trap | string name = directory.FindName(2) | val name: String = directory.findName(2) | Kotlin does not check a value from Java, so this compiles and throws if Java returns null Why |
| twist | static members and optional parameters | @JvmStatic and @JvmOverloads | Kotlin code needs these annotations to look natural when Java code calls it Why |
| twist | async Task<Report> and await | suspend fun and async { ... }.await() | Kotlin coroutines look like async and await, and suspend spreads to every caller too Why |
| twist | choosing a language for a team | Kotlin or Java | Kotlin needs less extra code, and its compiler checks nulls. Far more developers know Java. Why |
What Kotlin gives back that Java took awaynew#
Imagine your first day on a Kotlin team. Many C# features that you missed in Java are back. Each C# feature in this table has a direct Kotlin form. For most of these features, 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 C# and in Kotlin. It has 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) {This line declares a data class. A data class in Kotlin is like a record. The compiler writes
equals,hashCodeandtoStringfrom the properties in the header. It also writescopy.valmakes each property read-only. Each type comes after the name of its property.val label get() = "$name ($age)"This is 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 same aswithin C#. Kotlin has nonewkeyword, soCustomer("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 the one parameter of the lambda.val name: String? = found?.nameThis is the same safe call as
?.in C#. There is no Bob, sonameis null here. When you use such a value without a check, C# only warns. Kotlin refuses to compile the code.
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 change. Type erasure, the equality contract and the traps of Jakarta Persistence (JPA) are unchanged. Annotations that work through proxies are unchanged too. Virtual threads, the class loader, the tuning of garbage collection (GC) and the whole of Spring are also unchanged. Kotlin is a different front end to the same platform.
Kotlin with Spring Bootnew#
Spring supports Kotlin fully. It does not only tolerate Kotlin. Spring Boot 4 needs Kotlin
2.2 or later. Here is the shop's order controller written in Kotlin. The code assumes two
things. An OrderService has a find method that returns an
OrderDto?, which is null when there is no such order. An
OrderNotFound exception exists, and the application turns it 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 this constructor with no annotation. Spring does the same for a Java class that has only one 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=>in C#.service.find(id) ?: throw OrderNotFound(id)?:is called the Elvis operator. It uses the right-hand side when the left-hand side is null, like??in C#. In Kotlin,throwis an expression, so it can be on the right-hand side, as it can in C#.fun create(@RequestBody @Valid command: CreateOrder): OrderDto =The annotations are the same as in Java. Each annotation is written before its 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, and no annotation is needed |
| Final by default | Kotlin classes and functions are final unless marked open, which breaks CGLIB proxies |
| kotlin-spring plugin | It opens classes that have Spring annotations, and their functions. This plugin is essential. |
| kotlin-jpa plugin | It generates the no-argument constructor that JPA needs on entities, but leaves them final. Hibernate also needs them open, for lazy loading. |
| 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. Here is the first
way. A Kotlin class is final unless it is marked open, and
Code Generation Library (CGLIB) proxies work by subclassing.
So start-up fails with Cannot subclass final class.
The second way is worse. If someone writes open on the class but not on
its functions, start-up succeeds. Each final function is left out of the proxy, so
its annotation does nothing. The function also runs on the proxy object itself, and the
injected fields of the proxy object are null. The only sign at start-up is a warning that the
method “cannot get proxied via CGLIB”.
The same failure is described in the chapter How Spring actually works. Here the failure has a different cause. On any Kotlin Spring project where an annotation seems to be ignored, check the plugin first.
Calling Java from Kotlin and backtrap#
The null checks of Kotlin stop at the edge of Kotlin code. Suppose the shop still has a
Java class, CustomerDirectory. Its findName method returns the name
of a customer, or null when there is no such customer. There is no customer 2:
val directory = CustomerDirectory()
val maybe: String? = directory.findName(2)
println(maybe?.length ?: 0)
val name: String = directory.findName(2)Show the answer
findName(...) must not be
null.Kotlin's null safety does not cover platform types. Kotlin cannot know
whether a value from Java may be null. So Kotlin trusts the type that you choose, and does not
force a check. When such a value is used as a non-null type, Kotlin adds a check that throws
on that same line. A NullPointerException in Kotlin code almost always comes from
a value that crossed the boundary from Java code.
Declare a value from Java as nullable, as maybe is. Spring Framework 7 helps
here. Its signatures now have 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.
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 |
Coroutines against virtual threads#
The Kotlin way of waiting for the network is older than virtual threads. It looks much like
the C# way. Suppose a report needs a customer from one service and the orders of that customer
from another service. The code fetches both 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 that was started inside it. This is structured concurrency.val customer = async { customerClient.find(customerId) }asyncstarts the call and returns at once with aDeferred. ADeferredin Kotlin does the same as aTaskin C#. Both calls now run at the same time.Report(customer.await(), orders.await())awaitwaits for each result. If one call fails, the scope cancels the other call. C# needs aCancellationTokento do that.
| 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, because suspend spreads to every caller | no |
With coroutines, the problem that virtual threads removed is back. Once a function is
suspend, every function that calls it must be suspend too. In
exactly the same way, async spreads through C# code. In exchange, coroutines give
you structured concurrency that has been stable for years. StructuredTaskScope in
Java is still a preview. On a Kotlin project, follow the choice that the team has already
made, instead of mixing coroutines and virtual threads.
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 |
Here is an honest summary for someone who comes from C#. Kotlin will feel comfortable sooner, and Java has removed much of the difference since version 8. Kotlin is not a wrong choice, and Java is not a wrong choice. On an existing codebase, the choice is rarely yours to make.
1A Kotlin build for Spring Boot needs two compiler plugins, kotlin-spring and
kotlin-jpa. What does each one do?
kotlin-spring and
kotlin-jpa. What does each one do?kotlin-spring opens every class that has a Spring annotation, and the
functions of that class. Spring can then wrap the class in a proxy. Without the plugin, a
Kotlin class is final. kotlin-jpa generates the no-argument constructor that JPA
needs on an entity. It leaves the entity final.
Hibernate also needs the entity to be open, for
lazy loading.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 that you cannot read. Each entry here gives the message as you will see it, what the message means in plain words, and the fix. Many entries also have a small example that triggers the error, folded under the entry. Paste an error into search (⌘K, or Ctrl+K), and search shows its entry here.
You need one distinction first. Java separates an Exception from an
Error. Your code may reasonably catch an Exception. An Error means that the
Java Virtual Machine (JVM) itself is in trouble. If the name
of a class ends in Error, catching that class is almost always wrong.
Reading a Java stack trace#
Every entry below needs one skill, so start with that skill. Suppose that the shop's checkout
fails because the payment provider never answers. OrderController calls
OrderService. Then OrderService calls
PaymentGatewayClient. 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 where the exception happened.
at com.acme.payments.PaymentGatewayClient.charge(PaymentGatewayClient.java:20)The top frame is the place where the exception was thrown. It gives the file and the line. Each line below the top frame is the method that called the method in the line above it. The list ends at
main.Caused by: java.net.SocketTimeoutException: Read timed outThis is the exception that the first exception wrapped. The last
Caused by:in a trace is usually the real problem. Here, the real problem is that the payment provider did not answer in time.at java.base/sun.nio.ch.NioSocketImpl.timedRead(NioSocketImpl.java:276)These frames are inside Java's own libraries. You can usually skip these frames and go to the first frame from your own code. Here that frame is
PaymentGatewayClient.chargeat line 17.... 3 moreThe last three frames are the same as the last three frames printed above, so Java does not print them again.
| Convention | Meaning |
|---|---|
| Top frame | where the exception was thrown, not where it was caught |
| Caused by | the underlying exception, and the last Caused by in a trace 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. Every part above it is a layer that threw the
exception again. This is the reverse of the habit that most people bring from .NET. In .NET, the
InnerException is nested, and it is not printed last.
The entries below are grouped by the place where you meet them. The groups are 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 that reads APPLICATION FAILED TO START. This
explanation 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 the class is
already annotated, move the class into the package of the
application class, or into a package below that one. Then
component scanning finds the class.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.#@Component or
@Service, or declare an @Bean method that returns an object of that
type.UnsatisfiedDependencyException: Error creating bean with name 'pricing'#Caused by: line. This line 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 bean that 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 you cast. Also search earlier in the
code for a raw type or an unchecked warning.What triggers it
Object o = "x";
Integer i = (Integer) o;java.util.ConcurrentModificationException#removeIf, or use an explicit Iterator and call its
remove() method. See Modifying while iterating.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
the input first.What triggers it
Integer.parseInt("12a");java.lang.ArrayStoreException: java.lang.Integer#Object[] really holds a narrower type. A value
of the wrong type was stored in the array.List<T>. Generics are checked at compile time, but arrays are
checked 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 again.List once and
reuse that list.What triggers it
var s = List.of(1, 2).stream();
s.toList();
s.toList(); // The second call throws.java.lang.IllegalStateException: block()/blockFirst()/blockLast() are blocking, which is not supported in thread parallel-2#block(). The
name of the thread is at the end of the message, and it varies.java.lang.ArithmeticException: / by zero#double gives
Infinity and does not throw.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. The pattern letters in Java
differ from the pattern letters in .NET, so check them.What triggers it
LocalDate.parse("11/09/2026");
LocalDate.parse("11/09/2026", DateTimeFormatter.ofPattern("dd/MM/yyyy")); // This works.java.nio.file.NoSuchFileException: src/main/resources/rates.json#Cannot invoke "java.io.InputStream.readAllBytes()" because "in" is null#getResourceAsStream found nothing under that name. It returned null and did
not throw.Persistence#
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.acme.Order.items: could not initialize proxy - no Session#Could not initialize proxy [com.acme.Customer#7] - no session.JOIN FETCH or
@EntityGraph. Or map the data to a
data transfer object (DTO) inside the transaction. 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.org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'com.acme.Order.items' with key '7' (no session)#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 name of the constraint. 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. This means that someone else changed the row
after you read it. The message above is the wording of Hibernate 6.6, which Spring Boot 3.4
uses. On Spring Boot 4, Hibernate 7 prints
Row was already updated or deleted by another transaction, followed by the
entity.ObjectOptimisticLockingFailureException: Row was already updated or deleted by another transaction for entity [com.acme.Order with id '1']#@Version check as in the
entry before this one. Someone else changed the row after you read it. The message is in the
wording of Hibernate 7, which Spring Boot 4 uses.detached entity passed to persist: com.acme.Order#persist was called on an object that already has an id, so Hibernate
treats the object as a row that already exists. On Spring Boot 4, Hibernate 7 writes the same
message with a capital D.merge. Or use save on a
Spring Data repository, which chooses
between persist and merge. Do not set a generated id yourself.No identifier specified for entity: com.acme.Order#@Entity has no field marked @Id. The message
above is the wording of Hibernate 5 and 6.0. Hibernate 6.1 and later print the message in
the next entry. So the Hibernate of Spring Boot 3.4 and the
Hibernate of Spring Boot 4 both print that message.@Id field, usually with @GeneratedValue.Entity 'com.acme.Order' has no identifier (every '@Entity' class must declare or inherit at least one '@Id' or '@EmbeddedId' property)#@Entity has no field marked @Id. This is the
wording since Hibernate 6.1. On Spring Boot 4, Hibernate 7 printed this message in a
run.@Id field, usually with @GeneratedValue.The next entries belong to document data. The first entry and the last two entries are quoted from the AWS developer guide, as of October 2026. The other entries were produced on 3 October 2026 against a MongoDB container, not against Amazon DocumentDB.
{"ok":0,"errmsg":"Retryable writes are not supported","code":301,"name":"MongoError"}#retryWrites=false to the connection string, or move an instance-based
cluster to engine version 8.0.2. See Connecting to Amazon
DocumentDB.What triggers it
spring:
mongodb:
uri: mongodb://sample-user:password@sample-cluster.node.us-east-1.docdb.amazonaws.com:27017/shop?replicaSet=rs0com.mongodb.MongoTimeoutException: Timed out while waiting for a server that matches ReadPreferenceServerSelector{readPreference=primary}. Client view of cluster state is {type=UNKNOWN, servers=[{address=localhost:27019, type=UNKNOWN, state=CONNECTING, exception={com.mongodb.MongoSocketWriteException: Exception sending message}, caused by {javax.net.ssl.SSLHandshakeException: (certificate_unknown) PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target}#MongoTimeoutException, so read the message as far as the innermost
caused by.global-bundle.pem. A tlsCAFile parameter in the
connection string changed nothing in the run. See
Connecting to Amazon DocumentDB.What triggers it
spring:
mongodb:
uri: mongodb://localhost:27019/shop?tls=true # no spring.mongodb.ssl.bundlecom.mongodb.MongoSocketReadException: Prematurely reached end of stream#MongoTimeoutException, as in the entry before this
one.spring.mongodb.ssl.bundle, or
add tls=true to the connection string.org.springframework.dao.DuplicateKeyException: Write operation error on MongoDB server localhost:27018. Write error: WriteError{code=11000, message='E11000 duplicate key error collection: shop.invoices index: orderId dup key: { orderId: "o-1" }', details={}}.#Index build failed. Then it means that auto index
creation tried to build a unique index over documents that already hold duplicates.What triggers it
// spring.data.mongodb.auto-index-creation is true, and orderId is @Indexed(unique = true)
invoices.save(new Invoice(null, "o-1", total, today));
invoices.save(new Invoice(null, "o-1", total, today));org.springframework.data.mongodb.UncategorizedMongoDbException: This MongoDB deployment does not support retryable writes. Please add retryWrites=false to your connection string.#new MongoDBContainer("mongo:8.0") starts in a
test..withReplicaSet(). See
Developing and testing without a cluster.Given transaction number 0 does not match any in-progress transactions.#Feature not supported: 'causal consistency'#ClientSessionOptions.builder().causallyConsistent(false).build(). Spring's
MongoTransactionManager opens a causally consistent session. No test checked
whether Amazon DocumentDB rejects that session, so test a @Transactional method on
your cluster. See Transactions.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). On a mapper that you build yourself, you can also disable
FAIL_ON_UNKNOWN_PROPERTIES.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 missing from the request, and request parameters are
required by default.403 Forbidden on a POST, with an empty response body#Errors, not exceptions#
For a worked example of each of the first five entries, see the chapter 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. This exception is a
checked exception, unlike
NoClassDefFoundError, which is an error.java.lang.NoSuchMethodError: 'java.lang.String com.acme.Greeter.greet(java.lang.String)'#mvn dependency:tree to find the two versions, and pin one version. 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 one
version, and ship that same 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 you raise -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 that the program 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 the message. In
IntelliJ, Alt+Enter usually adds the missing import. On a Mac, the keys are
⌥↩.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 the exception. 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 the value does not fit.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 change the code so
that the variable is assigned only 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.compareTo to order objects, and use equals to compare them.
See the chapter Equality, hashing and comparison.What triggers it
boolean later = name > "m";error: reached end of file while parsing#error: find(String) in EmptyLookup cannot implement find(String) in PriceLookup attempting to assign weaker access privileges; was public#public keyword. In a
class, a method with no access keyword is
package-private. Package-private is less visible
than the interface's method.public on the implementing method. See
the full entry.Appendix G: The one-page card#
This card has 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 List<T>() | ArrayList::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)) |
| .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 |
| 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, so pass a String |
| 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 |
| IMongoCollection<T> | MongoRepository<T, ID> |
| [BsonId] | @Id, on a String |
| appsettings.json | application.yml |
| Environments | profiles |
| ILogger<T> | SLF4J Logger |
| [Authorize] | @PreAuthorize |
| Polly | Resilience4j, or @Retryable |
| Symptom | Look at |
|---|---|
| Could not find or load main class | classpath, or package and folder mismatch |
| Annotation does nothing | self-invocation, private, or final method |
| 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 |
One simple rule explains 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. To get the safe behaviour, you must write it explicitly.
Most traps on the card are predictions. Each prediction is in the chapter The week-one gotchas or in the chapter for its own subject. So each prediction is also a card in Review. Press r to be asked the question instead of being told the answer.
If a term on the card is unfamiliar, especially the classpath, start with the chapter 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 a few short sentences, and where the book explains it in full. If 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 | Short for Advanced Message Queuing Protocol. It is the wire protocol that RabbitMQ uses. | RabbitMQ |
| annotation | A marker written with @ before a class, method, field or parameter, such as @Override. It does the same as an attribute in C#. | Syntax |
| annotation processor | A plug-in that the Java compiler runs during a build. It reads annotations and generates extra source files. It does the same as a source generator in C#. | Annotation processing vs source generators |
| annotation retention | How long an annotation is kept. An annotation is kept only in the source, or in the class file, or at runtime. Reflection can read an annotation that is kept at runtime. C# attributes are always there at runtime. | Retention: the concept C# lacks |
| AOP | Short for aspect-oriented programming. It is a way of 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 | Short for ahead-of-time. It is work that is done when the application is built, not when the application runs. Two examples are compiling to native code and preparing a startup cache. | GraalVM native-image |
| AOT cache | A file that 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 | The Spring container. It is the object that creates your beans, connects them to each other and gives them to the code that asks for them. It does the same as IServiceProvider in .NET. | The bean lifecycle |
| application event | A message that one part of a Spring application publishes and other parts receive, in the same process. It is like a MediatR notification in .NET. | Application events |
| application server | A large server, such as WildFly or WebSphere, that runs many deployed applications. The applications are deployed as 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. It holds behaviour that Spring applies around many methods, such as timing every service call. | AOP: writing your own cross-cutting behaviour |
| AssertJ | The assertion library that most Java tests use. An assertion is written assertThat(actual).isEqualTo(expected). AssertJ is like FluentAssertions in .NET. | A test |
| @Async | A Spring annotation that runs a method on another thread, so the caller does not wait for the method. It works only when the call goes through a Spring proxy. | @Async |
| atomic variable | A class such as AtomicInteger. Its operations, such as incrementAndGet, are safe to call from many threads at once without a lock. An atomic variable is like Interlocked in .NET. | Atomics |
| auto-configuration | Spring Boot creating suitable beans for you, such as a database connection pool. Spring Boot creates a bean because a library is on the classpath and you have not defined that bean yourself. | Auto-configuration |
| autoboxing | Java automatically converting between a primitive, such as int, and its wrapper object, such as Integer. Unboxing a null wrapper throws a NullPointerException. | Wrapper types |
| @Bean | An annotation that marks a method in a configuration class. Spring manages the return value of the method as a bean. Use @Bean for types that you cannot annotate yourself, such as a client class from a library. | 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. | Beans and component scanning |
| bean scope | How many instances of a bean Spring makes. The default scope is called singleton, and it means one instance for the whole application. The scope called prototype means a new instance each time the bean is asked for. | Lifetimes |
| Bean Validation | The Java standard for validation annotations such as @NotBlank and @Size. It has the same purpose as DataAnnotations in .NET. | Validation and error responses |
| BeanPostProcessor | An object that Spring passes every bean to while the bean is created. This object can change, wrap or replace the bean. Spring uses one such object 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 not with +. You compare with compareTo() and not with >. | Money is BigDecimal |
| BOM | Short for bill of materials. It is a Maven file that sets the versions of many related libraries at once, so the libraries stay compatible. In a text file, BOM can also mean byte order mark. | Profiles and BOMs |
| BSON | The format in which MongoDB and Amazon DocumentDB store a document. It has types that JSON does not have, such as ObjectId, Date and Decimal128. The .NET attributes such as [BsonId] are named after it. | What the stored document holds: types, names and _class |
| Buildpacks | A tool that turns an application into a container image without a Dockerfile. The build-image task of Spring Boot uses it. | Containers |
| bytecode | The instructions that the Java compiler javac produces from your source code. The JVM runs bytecode in the same way that the .NET runtime runs IL. | From source to a running program |
| @Cacheable | A Spring annotation that stores the result of a method. A second call with the same arguments returns the stored value without running the method. | Caching |
| CAS | Short for compare-and-set. It is an operation that changes a value only if the value is still what you expected. The check and the change happen as one step that cannot be divided. Atomic classes use compare-and-set instead of a lock. | Atomics |
| CDS | Short for class data sharing. It is a feature of the JVM. The JVM stores already-parsed classes in a file and maps the file into memory at startup, so the program starts faster. | Startup without going native |
| CGLIB | Short for Code Generation Library. It is the library that Spring uses to create a proxy as a subclass of your class at runtime. Spring Boot uses it by default, even for a bean that implements an interface. | 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 a charset explicitly keeps older code correct too. | Charsets, and why to always pass one |
| checked exception | An exception that the compiler makes you handle. You must catch it, or declare it with throws. C# has no checked exceptions. | 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 the class, and reads the bytecode of the class. | Class loading, at the level you need |
| classpath | The ordered list of folders and Java archive (JAR) files that the JVM searches when it needs a class. The JVM uses the first match. | The classpath is a search list |
| classpath resource | A file that is packaged with your classes, such as a configuration file or a template. Code reads it through the classpath, not from a path on disk. | Resources live on the classpath too |
| CME | Short for ConcurrentModificationException. It is thrown when a collection is changed while a loop over that collection is still running, even on a single thread. | Modifying while iterating |
| compact constructor | A record constructor that is written without a parameter list. It is 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> in C#. The natural order of a class itself comes from Comparable, which is like IComparable<T> in C#. | Ordering |
| CompletableFuture | The Java class that is closest to Task<T> in C#. It holds a result that arrives later. It has methods such as thenApply that chain more work onto the result. | Translation |
| component scanning | Spring searching your packages at startup for classes marked @Component, @Service and similar annotations. Spring registers each class that it finds as a bean. | Beans and component scanning |
| @ConfigurationProperties | An annotation that binds a group of settings from application.yml to the fields of a class or record. In ASP.NET Core, IOptions<T> binds a section in the same way. | IOptions becomes @ConfigurationProperties |
| constructor injection | Giving a class what it needs through its constructor parameters. Spring provides the values when it creates an object of the class. Constructor injection is the recommended style, and ASP.NET Core uses the same style. | Beans and component scanning |
| controller advice | A class of exception handlers that apply to every controller. It is used to turn exceptions into error responses in one place. | Global exception handling |
| coroutine | The lightweight form of concurrency in Kotlin. A coroutine is a function marked suspend that can pause without blocking a thread. It is close to async and await in C#. | Coroutines against virtual threads |
| CORS | Short for cross-origin resource sharing. It is the browser rule that decides whether a web page may call an API on another domain. The server allows the call with response headers. | Middleware |
| CSRF | Short for cross-site request forgery. It is another site making a user's browser send a request with that user's cookies. Spring Security blocks it by default for requests that change data. | Two chain traps: rule order and CSRF |
| dependency scope | A setting on a Maven dependency that says when the dependency is available. compile means everywhere, and test means tests only. runtime and provided are for the cases in between. | Coordinates |
| detached entity | An entity that was loaded by a persistence context that is now closed. Changes to the entity are no longer tracked, and its lazy fields can no longer load. | Lazy loading and detached entities |
| DI | Short for dependency injection. It means that a class receives the objects it needs, usually through its constructor, and does not create them itself. Spring and ASP.NET Core both do dependency injection. | Dependency injection and configuration |
| DSL | Short for domain-specific language. It is a small, focused way of writing one kind of thing, often as a chain of method calls. One example is the configuration of Spring Security. | The filter chain |
| DTO | Short for data transfer object. It is a simple class, often a record, that carries data in or out of an API without exposing your entities. | Records as DTOs |
| EAR | Short for enterprise archive. It is a file that packages several WAR and JAR files for an application server. You will only see it in older systems. | EJB and the application server |
| EJB | Short for Enterprise JavaBeans. It is 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. It is like an entity in Entity Framework. | 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 that it manages. For example, it can use a fixed pool of threads, or one virtual thread for each task. You submit work and receive a Future. | Fixed thread pools |
| fat JAR | One JAR file that holds your application and every library that the application needs, so java -jar app.jar runs it. Spring Boot builds a fat JAR by default. | The fat JAR |
| final field | The keyword final means that a thing cannot change after it is 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. It has the same purpose as EF Core migrations in .NET. Liquibase is the main alternative. | Migrations |
| fully qualified name | A class name with its package in front, such as com.acme.billing.Invoice. The JVM uses this name to find the class file. | One program, start to finish |
| functional interface | An interface with exactly one method to implement, such as Runnable or Function<T, R>. You can write a lambda in its place, as you write a lambda for a delegate in C#. | Lambdas and method references |
| G1 | The default garbage collector of the JVM on most machines. It balances short pauses against throughput. | Choosing a collector |
| gatherer | A custom step that is added to a stream with gather(). It is for operations that are not built in, such as sliding windows. It is final since Java 24. | Gatherers |
| GC | Short for garbage collector. It is the part of the JVM that frees memory that no object uses any more. The JVM offers several garbage collectors, 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. The native executable starts in milliseconds, but it limits reflection. | GraalVM native-image |
| Gradle | A Java build tool, and the main alternative to Maven. Its build files are written in a small Groovy or Kotlin language, not in XML. | Gradle against Maven |
| happens-before | The rule in the Java memory model for when one thread is guaranteed to see the writes of another thread. For example, the guarantee holds after a lock is released or after 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 all health indicators into /actuator/health. This is like health checks in ASP.NET Core. | 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 implementation that Spring Boot uses by default. | The landscape |
| HQL | Short for Hibernate Query Language. It is the query language of Hibernate, and a superset of the Jakarta Persistence Query Language (JPQL). Queries are written against entities, not against tables. | Repositories |
| initialiser block | A block of code in a class body. A static initialiser block runs when the class is loaded. An instance initialiser block runs 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. A nested class in C# holds no such reference. | Inner classes capture the outer instance |
| Integer cache | Java keeps ready-made Integer objects for the values -128 to 127. So == on two small Integers can be true, while the same code on larger numbers is false. | The Integer cache |
| IoC | Short for inversion of control. It means that the framework, not your code, creates objects and calls their methods. Dependency injection is the most common form. | Beans and component scanning |
| isolation level | How much one transaction may see of the unfinished changes of another transaction. One level is READ_COMMITTED. Isolation levels are the same database idea as in .NET. | Isolation |
| J2EE | Short for Java 2 Platform, Enterprise Edition. It is the old name for enterprise Java. The name later became Java EE, and it is now Jakarta EE. | Four eras |
| Jackson | The JSON library that Spring Boot uses by default. It does the same as System.Text.Json in .NET. | Jackson against System.Text.Json |
| Jakarta EE | The set of enterprise Java standards, such as Persistence, Validation and Servlet. Its old name is Java EE. Its packages moved from javax to jakarta. | The javax to jakarta rename |
| JAR | Short for Java archive. It is a zip file of class files and resources, usually with a manifest. It does the same as a .dll assembly in .NET. | Running with -cp or -jar |
| Java module | A named group of packages, described in a module-info.java file. The file states what the module exports and what the module needs. Most applications never write a module. | 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 | Short for Java Architecture for XML Binding. Its name today is Jakarta XML Binding. It is the standard for turning XML into Java objects and back. It was removed from the JDK in Java 11 and is now a separate library. | What Java removed |
| JDBC | Short for Java Database Connectivity. It is the low-level standard for talking to a database, like ADO.NET. Jakarta Persistence and the JdbcClient of Spring are built on it. | The landscape |
| JDK | Short for Java Development Kit. It is the compiler, the JVM and the tools that you need to build and run Java. It is like the .NET SDK. | JDK or JRE: which to install |
| JEP | Short for JDK Enhancement Proposal. It is the numbered document that describes one change to Java, such as JEP 444 for virtual threads. | The release train |
| JFR | Short for JDK Flight Recorder. It is a low-overhead recorder built into the JVM. It captures what a running program is doing, for later analysis. | JFR: the recorder built into the JDK |
| JIT | Short for just-in-time compiler. It is the part of the JVM that compiles frequently run bytecode to machine code while the program runs. RyuJIT does the same in .NET. | The JIT and warmup |
| jlink | A JDK tool that builds a trimmed Java runtime, which contains only the modules that your application needs. | jlink and jpackage |
| JMH | Short for Java Microbenchmark Harness. It is the standard tool for measuring small pieces of Java code. It does the same as BenchmarkDotNet in .NET. | JMH against BenchmarkDotNet |
| JMS | Short for Jakarta Messaging. It is 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. | JMS and JmsClient |
| JMX | Short for Java Management Extensions. It is built into the JVM, and it exposes the metrics and operations of a running program to monitoring tools. | Actuator endpoints |
| JNDI | Short for Java Naming and Directory Interface. It is a lookup service that old application servers used to provide resources such as database connections. | Single values and precedence |
| JNI | Short for Java Native Interface. It is the way that Java code calls native C or C++ code, like P/Invoke in .NET. | When virtual threads are the wrong tool |
| JPA | Short for Jakarta Persistence. It is the Java standard for mapping objects to database tables. It has the same purpose as Entity Framework in .NET. Hibernate implements Jakarta Persistence. | 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 | Short for Java Platform Module System. It is the module system that was added in Java 9. It lets a library hide its internal packages. | Modules, JPMS and the missing internal |
| JPQL | Short for Jakarta Persistence Query Language. It is a language for SQL-like queries that are written against entities and their fields, not against tables and columns. | Repositories |
| JRE | Short for Java Runtime Environment. It is a JVM and its libraries without the compiler. Oracle stopped shipping a separate JRE in Java 11, but some vendors, such as Temurin, still offer one. | JDK or JRE: which to install |
| JShell | The Java read-eval-print loop (REPL). You type a line of Java and see the result immediately, without a class or a main method. | jshell: run one line of Java |
| JSP | Short for JavaServer Pages. It is 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. With them, tools can warn in the same way as nullable reference types in C#. | JSpecify: the annotation standard |
| JSR | Short for Java Specification Request. It is a numbered proposal for a Java standard. One example is JSR 305, an older attempt at null annotations. | Null-safety in the Spring API |
| JUnit | The standard Java testing framework. It does the same as xUnit in .NET. A test is a method marked @Test. | A test |
| JVM | Short for Java Virtual Machine. It is the program that loads bytecode and runs it, in the same way that the CLR runs IL in .NET. | From source to a running program |
| JWT | Short for JSON Web Token. It is a signed token that carries the identity and claims of a user. It is 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 the syntax of Kotlin closer to C# than the syntax of Java. | What Kotlin gives back that Java took away |
| lambda | A short unnamed function written with an arrow, such as x -> x * 2. C# writes =>. Java writes ->. | Lambdas and method references |
| lazy loading | Loading the related data of an entity only when you first use that data. 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. When you run a phase, Maven first runs every phase before it. | The lifecycle |
| local repository | The folder ~/.m2/repository, where Maven keeps every library that it downloads. It is like the NuGet global packages folder. | Where your dependencies actually live |
| Logback | The logging library that 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 Lombok. | A word on Lombok |
| LTS | Short for long-term support. An LTS release is 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, at META-INF/MANIFEST.MF. It can name the class that holds main, and it can list other JARs to load. | Inside a JAR: the manifest |
| MapStruct | A library that generates object-to-object mapping code when you compile. It has the same purpose as AutoMapper in .NET. | MapStruct against AutoMapper |
| Maven | The most common Java build tool. It reads a pom.xml file and downloads the libraries listed there. Then it compiles, tests and packages the project. It is like MSBuild with NuGet in .NET. | pom.xml against csproj |
| Maven coordinates | The three values that identify a library in Maven. They are groupId for the organisation, artifactId for the library, and version. They are like the package id and version of a NuGet package. | 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. It downloads and runs the right Maven version, so everyone builds with the same version. | The wrapper |
| MDC | Short for mapped diagnostic context. It is a set of key and value pairs, such as a request id, that are attached to the current thread. Every log line then includes them. A mapped diagnostic context is like ILogger scopes in .NET. | Logging |
| metaspace | The memory outside the heap where the JVM keeps class definitions. Loading very many classes can use all of it. | The memory model |
| method reference | A short way to pass an existing method where a lambda is expected. It is written Type::method, such as String::length. | Lambdas and method references |
| Micrometer | The metrics library that Spring Boot uses. It does the same as System.Diagnostics.Metrics in .NET. It sends counters and timers to Prometheus and other systems. | Metrics |
| Mockito | The standard Java library for mock objects. It does the same as Moq in .NET. | 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 that the JVM loads when the module system is switched on. It is like the classpath, but for modules. | Classpath vs module path |
| MongoDB collection | A named set of documents in MongoDB or Amazon DocumentDB. It has the same purpose as a table in a relational database. @Document("orders") names the collection that a class is stored in. | A document, not a row |
| MongoDB document | One stored object with its nested objects and lists inside it, such as an order with its lines. It has the same purpose as a row in a relational database. Every document has an _id. | A document, not a row |
| MVC | Short for model-view-controller. It is the pattern behind Spring MVC, which is the web framework of Spring for controllers. Spring MVC does the same as ASP.NET Core MVC. | Controllers, routing and binding |
| N+1 problem | Loading a list with one query, and then running one more query for each item to fetch the related data of that item. It is the same trap as in Entity Framework. | Counting the queries in a loop |
| NPAPI | Short for Netscape Plugin API. It is the old browser plug-in interface that Java applets needed. Browsers removed it, so the Java plugin could no longer run applets in a browser. | Web frameworks, in order of extinction |
| NPE | Short for NullPointerException. It is thrown when code uses a null reference. It does the same as NullReferenceException in C#. | The gap, stated plainly |
| ObjectId | The kind of id that MongoDB generates for a document. Spring Data MongoDB stores a generated String id as an ObjectId. | A document, not a row |
| OIDC | Short for OpenID Connect. It is the standard login protocol built on OAuth 2.0. It is used with identity providers such as Entra ID or Keycloak. | Concept mapping |
| OOM | Short for OutOfMemoryError. It is 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. It is used as a return type to say that a result may be missing. It is not a replacement for every null. | Optional is for return values |
| ORM | Short for object-relational mapper. It is a library that maps classes to database tables, such as Hibernate or Entity Framework. | The landscape |
| @Override | An annotation that says that a method replaces a method from a parent class or interface. The compiler reports an error if nothing is actually overridden. | Always write @Override |
| package | A named group of classes, such as com.acme.billing. It is like a namespace in C#, but its folders are expected to match the name, and it is also an access boundary. | Packages are not namespaces |
| package-private | The default access in Java when you write no modifier. A package-private class or member is visible to every class in the same package, and nowhere else. | The mapping |
| PECS | Short for producer extends, consumer super. It is the rule for wildcards. Use ? extends T for a collection you read from, and ? super T for a collection 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. | Sealed types: Java's discriminated unions |
| persistence context | The set of entities that one EntityManager is tracking. It is like the change tracker of a DbContext. It usually lasts as long as one transaction. | Lazy loading and detached entities |
| pinning | A virtual thread staying attached to its carrier thread while the virtual thread blocks, so the carrier thread cannot run other work. Java 24 removed the main cause, which was synchronized blocks. | Pinning: the one performance trap |
| platform thread | An ordinary Java thread, which is backed by exactly one operating system thread. Every Java thread was a platform thread 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 | Short for plain old Java object. It is an ordinary class with fields, getters and setters, and no framework base class. | The property gap |
| POM | Short for Project Object Model. It is the pom.xml file that describes a Maven project. It does the same as a .csproj file in .NET. | 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. One example is IntStream. A primitive stream avoids boxing each number, and it adds methods such as sum(). | Primitive streams |
| ProblemDetail | The Spring class for an RFC 9457 error response body. It does the same as ProblemDetails in ASP.NET Core. | ProblemDetail |
| proxy | An object that Spring puts in front of your bean. The proxy adds behaviour, such as a transaction, before it calls your method. Calls from inside the bean skip the proxy. | What a proxy actually is |
| R2DBC | Short for Reactive Relational Database Connectivity. It is a non-blocking database API for reactive applications. It is like Java Database Connectivity (JDBC), but for reactive code. | 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 in which code describes a pipeline of asynchronous events. The code uses the Mono and Flux types of Reactor. Spring WebFlux is built on this style. | The model |
| record | A short class that holds data. Its equals, hashCode, toString and accessors are generated. A Java record is like a record in C#. 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. It also has 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 | The Java schedule of a new version every six months, in March and September. An LTS release comes every two years. | The release train |
| REPL | Short for read-eval-print loop. It is a prompt where you type code and see the result immediately. The REPL of Java is called JShell. | jshell: run one line of Java |
| replica set | A group of database instances that hold the same data. One instance is the primary, and it takes every write. The other instances are replicas, and they can serve reads. An Amazon DocumentDB connection string names the replica set rs0. | Two parameters that change behaviour |
| retryable write | A write that the driver may send a second time when the first attempt fails on a network error. Drivers switch retryable writes on by default. Amazon DocumentDB supports retryable writes from engine version 8.0.2. | Connecting to Amazon DocumentDB |
| RMI | Short for Remote Method Invocation. It is the old Java way of calling methods on objects in another process, like .NET Remoting. It depends on Java serialization. | Java's built-in serialization, and why to avoid it |
| rollback rule | The rule for which exceptions undo a Spring transaction. By default, an unchecked exception undoes the transaction, and a checked exception does not. | The rollback rule |
| SAM | Short for single abstract method. It is the name for an interface with exactly one method to implement. You can write a lambda in place of such an interface. Such an interface is also called a functional interface. | Lambdas and method references |
| @Scheduled | A Spring annotation that runs a method on a timer or a cron schedule. It is like a hosted background service in .NET. | Scheduling |
| ScopedValue | A value that is available to a block of code and to every method that the block calls. It is also available to 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. | Self-invocation: a call from inside the class |
| sequenced collection | A collection with a defined order, and with methods such as getFirst() and reversed(). Sequenced collections were added in Java 21. | Sequenced collections |
| servlet | The Jakarta standard for handling an HTTP request in Java. Spring MVC runs on top of it. A servlet filter does the same as middleware in ASP.NET Core. | Middleware |
| SLF4J | Short for Simple Logging Facade for Java. It is the logging interface that Java code writes to, like ILogger in .NET. A library such as Logback does the writing. | SLF4J and Logback against ILogger and Serilog |
| SpEL | Short for Spring Expression Language. It is a language for small expressions inside annotations and configuration, written #{...}. | SpEL |
| SPI | Short for service provider interface. It is an interface that a library defines for others to implement. The implementations are found at runtime from files in META-INF. | Auto-configuration |
| Spring Data repository | An interface that you declare, such as OrderRepository extends JpaRepository<Order, Long>. Spring generates the data-access code for it. | Repositories |
| Spring profile | A named set of configuration, such as dev or prod, that Spring switches on at startup. It is like an environment in ASP.NET Core. | Profiles are environments |
| starter | A Spring Boot dependency that adds a group of libraries for one job. One example is spring-boot-starter-web for a web API. Boot 4 names this starter spring-boot-starter-webmvc. | Starters |
| static import | An import that brings the static members of a class into scope, so you can write max(a, b) instead of Math.max(a, b). It is like using static in C#. | Imports |
| STOMP | Short for Simple Text Oriented Messaging Protocol. It is a simple messaging protocol that Spring runs over WebSockets for publish and subscribe. | WebSockets and the SignalR gap |
| stream collector | The object that turns the elements of a stream into a final result, such as a list or a map, when you call collect(). Collectors.toList() is one collector. | Collectors |
| stream pipeline | A chain of operations on a sequence, such as filter, map and collect. The chain runs once, when the final operation is called. A stream pipeline does the same as LINQ to Objects in .NET. | The shape |
| structured concurrency | Running related tasks in a scope that ends only when all the tasks finish. If one task fails, the scope cancels the other tasks. Structured concurrency is a preview feature in Java 25. | The problem it solves |
| switch expression | A switch that returns a value, written with case X -> result. It is like a switch expression in C#. A Java switch expression 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. It is like the lock statement in C#. | 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 | Short for Technology Compatibility Kit. It is the test suite that a JDK must pass to be called Java compatible. This 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. It is like a raw string literal in C#. | Text blocks |
| ThreadLocal | A variable with a separate value for each thread. Scoped values are the modern way to pass context. | ThreadLocal on virtual threads |
| transaction propagation | What a transactional method does when a transaction is already running. The method can join the running transaction, which is the default. It can also start a new transaction, or refuse to run. | Propagation |
| @Transactional | A Spring annotation that runs a method inside a database transaction. The transaction is committed when the method returns. It is rolled back when the method throws an unchecked exception. | The default |
| trust store | A file of certificates that a Java program trusts when it opens an encrypted connection. The JDK tool keytool builds one. | A trust store made with keytool: one of AWS's two Java routes |
| try-with-resources | A try statement that opens resources in brackets after try and closes them automatically at the end. It is like the using statement in C#. | 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 the type arguments. | What erasure takes away |
| type pattern | A type check and a cast in one step, such as if (o instanceof String s). It is like is string s in C#. | Type patterns |
| unchecked exception | An exception that the compiler does not make you catch. The unchecked exceptions are 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 that you do not need. It is like the discard in C#. It is final since Java 22. | Nested patterns and unnamed variables |
| var | A keyword that lets the compiler infer the type of a local variable from its value, exactly as in C#. | var |
| varargs | A last parameter written Type... name, which accepts any number of arguments. It is like params in C#. | Varargs |
| virtual thread | A lightweight thread that 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 immediately, and stops reordering around it. It does not make ++ safe. | volatile means more in Java |
| WAR | Short for web application archive. It is 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>. It means some unknown type. It gives Java the flexibility that 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 is not related to yield return in C#. | Multi-statement arms |
| ZGC | A JVM garbage collector designed for very short pauses, even on large heaps. | Choosing a collector |
Appendix I: Your library, translated#
This chapter lists the libraries you use in .NET. For each library, it names the Java library that does the same job. Every row is indexed. So press ⌘K, or Ctrl+K, and type the .NET name to find its row.
The rows added in October 2026 were checked against each project's own documentation in that month. Not every older row was checked again then.
Some rows name several Java libraries. If Spring Boot uses one of them by default, that library comes first, and the note says when to choose another library. Some .NET libraries are copies of a Java original that runs on the Java Virtual Machine (JVM). Examples are NPOI, Akka.NET and ZXing.Net. The row of such a library says so.
Web and API#
| .NET | Java | Note |
|---|---|---|
| ASP.NET Core | Spring Boot | Spring Boot is the usual choice. Spring led Java web frameworks with 65% in the JetBrains survey of 2025. |
| Minimal APIs | Spring Boot, Javalin, Helidon | stay with Spring Boot in a Spring team, but Javalin is closest in style, with routes written as lambdas |
| Kestrel | embedded Tomcat, Jetty, Netty | Tomcat is the default in Boot. A starter replaces Tomcat with Jetty, and WebFlux applications run on Netty. |
| IIS hosting | a JAR with an embedded server | the JAR contains its own web server, so there is no separate server to deploy it into |
| Swashbuckle / NSwag | springdoc-openapi | It reads your controllers at run time and serves the OpenAPI document. Use version 3 with Boot 4. |
| SignalR | Spring WebSocket + STOMP | no direct equivalent, but WebSocket plus the STOMP protocol is closest |
| gRPC for .NET | grpc-java, Spring gRPC | grpc-java is the gRPC project's own library, and Spring gRPC configures grpc-java servers and clients in Spring |
| YARP | Spring Cloud Gateway | a library for building an API gateway on Spring, and the library has a WebFlux version and a Web MVC version |
| Ocelot | Spring Cloud Gateway | the same library replaces both YARP and Ocelot, and it does routing, rate limiting and path rewriting |
| Hot Chocolate | Spring for GraphQL | Spring's GraphQL support, built on the GraphQL Java library by both teams together |
| Asp.Versioning | built into Spring Framework 7 | Boot 4 selects an API version by header, path or query parameter, with no extra library |
| Blazor | Vaadin, Thymeleaf, JTE | no equivalent model, but Vaadin is closest for server-driven UI |
| Razor / Razor Pages | Thymeleaf, JTE, Freemarker | Boot auto-configures Thymeleaf, FreeMarker and Mustache, and JTE provides its own Boot starter |
Here is the smallest web endpoint in each framework. It is 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}")@GetMappingmapsGET /hello/{name}to this method, likeMapGetin ASP.NET Core.@PathVariable String name@PathVariabletakesnamefrom the path. A Minimal API does the same by matching the name of the parameter.
Security and identity#
This table shows which part of Spring Security, or which separate server, replaces each .NET security library. For the code, see the chapter Security.
| .NET | Java | Note |
|---|---|---|
| ASP.NET Core authentication and authorization | Spring Security | the standard for securing Spring applications, with login, access rules and attack protection in one framework |
| ASP.NET Core Identity | Spring Security with your own user tables, Keycloak | No full equivalent exists. Spring Security checks passwords, and you write registration and reset, or run Keycloak. |
| Duende IdentityServer / OpenIddict | Spring Authorization Server, Keycloak | Spring Authorization Server is a framework for your own token server, and part of Spring Security since 7.0. Keycloak is a ready-made server. |
| Microsoft.AspNetCore.Authentication.JwtBearer | Spring Security OAuth2 resource server | It checks the bearer token on every request. Set the issuer address, and Boot configures the rest. |
| System.IdentityModel.Tokens.Jwt | Nimbus JOSE + JWT | the library that Spring Security itself uses to parse tokens and check their signatures |
| Microsoft.Identity.Web | Spring Cloud Azure | its Microsoft Entra ID support signs users in with their work or school accounts |
| BCrypt.Net | Spring Security PasswordEncoder | the default encoder hashes with bcrypt, and can move to a newer algorithm while old hashes still work |
Data access#
| .NET | Java | Note |
|---|---|---|
| Entity Framework Core | Hibernate / Spring Data JPA | Boot's JPA starter includes Hibernate, and Spring Data JPA adds repository interfaces that use Hibernate |
| Dapper | JdbcClient, JdbcTemplate, JDBI | You write the SQL, and the library maps rows to objects. Boot configures JdbcClient, so start with JdbcClient. |
| LINQ to SQL, IQueryable | jOOQ | builds type-safe SQL in Java code, using classes it generates from your database schema |
| ADO.NET | JDBC | the low-level database API that every Java driver implements |
| EF Migrations | Flyway, Liquibase | Flyway runs versioned SQL files in order. Choose Liquibase for changelogs in YAML or XML with rollbacks. |
| DbUp | Flyway | the same idea as DbUp, with SQL scripts that each run once, in order, and are recorded as done |
| FluentMigrator | Flyway Java migrations, Liquibase | Flyway also accepts migrations written as Java classes, and Liquibase describes changes in YAML or XML |
| DbContext | EntityManager, or a Spring Data repository | the unit of work that tracks the entities you loaded |
| IDbConnection | DataSource, Connection | a DataSource gives you pooled connections, and HikariCP is the default pool in Boot |
| Npgsql / SqlClient | the PostgreSQL / MSSQL JDBC driver | a driver JAR on the classpath, chosen by its JDBC URL |
| EF Core InMemory provider | H2 | an embedded database that Boot configures with no connection URL, once its dependency is added |
| StackExchange.Redis | Lettuce, Jedis | Lettuce is the default in Boot's Redis starter, and Jedis is the other client that Boot can configure |
| MongoDB.Driver | mongodb-driver-sync, Spring Data MongoDB | The official blocking driver is mongodb-driver-sync. For the repositories that Spring Data MongoDB adds, see the chapter Document data. |
| Elasticsearch.Net | elasticsearch-java (co.elastic.clients) | the official Java API Client, with typed requests and responses for every Elasticsearch API |
For MongoDB, and for Amazon DocumentDB through the MongoDB API, see the chapter Document data: Amazon DocumentDB and the MongoDB API. That chapter shows the .NET driver and Spring Data MongoDB side by side.
Here each library loads the orders of one customer with SQL that you write yourself.
customerId holds the id of the customer:
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)This line gives a value to
:idin the SQL, like the anonymous-object parameter in Dapper..query(Order.class)This line maps each row to an
Order. It matches the columns to the components of the record by name..list();This line runs the query and returns the rows as a
List<Order>.
Mapping and validation#
This table covers two jobs. The first job is copying one object into another, which AutoMapper does. The second job is checking input, which FluentValidation does.
| .NET | Java | Note |
|---|---|---|
| AutoMapper, Mapster | MapStruct, ModelMapper | MapStruct generates the mapping code when you compile, but ModelMapper matches properties by convention instead |
| FluentValidation rules | Jakarta Validation with Hibernate Validator | Rules are annotations on the fields, not a separate validator class. Boot's validation starter includes both libraries. |
| DataAnnotations attributes | Jakarta Validation annotations | @NotNull and @Size are the same idea as [Required] and [StringLength], and they are checked where a parameter has @Valid |
For MapStruct in code, see the section MapStruct against AutoMapper. For validation, see the chapter Validation and error responses.
Serialisation#
| .NET | Java | Note |
|---|---|---|
| System.Text.Json | Jackson | the default in Spring Boot, which configures it for you |
| Newtonsoft.Json | Jackson, Gson | Jackson is the default JSON library in Boot, and Boot also configures Gson when Gson is on the classpath |
| JsonSerializer.Serialize | objectMapper.writeValueAsString | turns an object into a JSON String, the same job as Serialize |
| [JsonPropertyName] | @JsonProperty | sets the JSON name of one field or record component |
| [JsonIgnore] | @JsonIgnore | leaves one field or record component out of the JSON |
| JsonSerializerOptions | ObjectMapper configuration | the options are set once on the mapper, and Spring Boot builds the mapper from properties |
| protobuf-net | protobuf-java | protobuf-java uses code generated from .proto files, but protobuf-net uses attributes |
| MessagePack | msgpack-java | msgpack-java is the MessagePack project's Java library, and its Jackson module maps objects through Jackson |
| YamlDotNet | SnakeYAML, Jackson YAML | Spring Boot reads application.yaml with SnakeYAML, and Jackson YAML maps YAML to your classes |
| CsvHelper | OpenCSV, Apache Commons CSV, Jackson CSV | OpenCSV binds rows to annotated classes, and Commons CSV and Jackson CSV also read and write CSV files |
Spring Boot 4 moves to Jackson 3. The root
package of Jackson 3 is tools.jackson, not
com.fasterxml.jackson. So every Jackson import changes, except the imports of
annotations. The annotations stay in
com.fasterxml.jackson.annotation.
Boot renamed several of its own types at the same time. @JsonComponent became
@JacksonComponent. @JsonMixin became @JacksonMixin. See
Migrating Boot 3 to 4.
DI, logging, config#
| .NET | Java | Note |
|---|---|---|
| Microsoft.Extensions.DependencyInjection | Spring, or Jakarta CDI | the container is part of the framework, not a separate package |
| Autofac | Spring | inside a Spring application you use Spring's container, so there is no second container library to choose |
| Scrutor | Spring component scanning | scanning is built in, so Spring finds classes marked @Component, @Service or @Repository by itself |
| ILogger<T> | SLF4J Logger | the interface that your code calls, and Logback implements this interface |
| Serilog | Logback, Log4j2 | Spring Boot logs through Logback unless you switch to another library |
| NLog | Logback, Log4j2 | Logback is the default in Boot, and Boot also includes a default configuration for Log4j2 if you switch |
| structured logging | Logstash encoder, or Boot structured logging | structured logging means JSON log lines, and Spring Boot 3.4 and later writes them with no extra library |
| IConfiguration | Spring Environment | one object answers every property lookup, whichever file or variable the value came from |
| appsettings.json | application.yml / application.properties | Boot loads either file by itself at startup, and the YAML form nests keys as appsettings.json does |
| 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, in an ignored local file or in Vault |
| Azure App Configuration | Spring Cloud Azure, Spring Cloud Config | Spring Cloud Azure reads App Configuration itself, but Spring Cloud Config serves settings from Git instead |
| Microsoft.FeatureManagement | Togglz, OpenFeature | Togglz has a Spring Boot starter, and OpenFeature is a vendor-neutral API that also has a .NET SDK |
Caching#
A Spring application rarely calls a cache directly. It marks a method with
@Cacheable and chooses a store. So this table maps each .NET cache to a store. For
the code, see the chapter
Caching, scheduling and events.
| .NET | Java | Note |
|---|---|---|
| IMemoryCache | Caffeine | an in-process cache with size and time limits, which Boot can put behind @Cacheable |
| IDistributedCache with Redis | Spring Data Redis | its RedisCacheManager keeps the same @Cacheable entries in Redis, shared by every instance |
| HybridCache | no direct equivalent | Boot's cache providers include no two-level cache, so you combine Caffeine and Redis in your own code |
Resilience, messaging, scheduling#
| .NET | Java | Note |
|---|---|---|
| Polly | Resilience4j | the choice for Boot 3, and it is a separate library |
| Polly | Spring Framework @Retryable | the choice for Boot 4, because retry moved into the framework |
| HttpClientFactory | RestClient, HTTP interface clients | RestClient is the modern client, and Boot configures its builder for you |
| Refit | @HttpExchange interfaces | declare an interface, and Spring generates the HTTP client |
| RestSharp, Flurl | RestClient | Spring's RestClient is already a fluent HTTP client, so the same style needs no extra library |
| MediatR | Spring ApplicationEvents, Axon | no single library does everything that MediatR does |
| MassTransit / NServiceBus | Spring Cloud Stream, Spring Integration, Camel, Axon | No single library matches. Spring Cloud Stream hides the broker, and Spring Integration and Camel route messages. |
| RabbitMQ.Client | Spring AMQP | use RabbitTemplate to send and @RabbitListener to receive |
| Confluent.Kafka | Spring Kafka | use KafkaTemplate to send and @KafkaListener to receive |
| Hangfire | Quartz, JobRunr, Spring @Scheduled | @Scheduled is good for simple timers. Quartz and JobRunr can keep jobs in a database, and JobRunr has a dashboard. |
| Quartz.NET | Quartz | Quartz is the Java scheduler of the same name. Boot has a starter for it and can keep its jobs in your database. |
| Azure Functions | Spring Cloud Function | you write the logic as a plain function, and adapters run it on Azure Functions, AWS Lambda or Google Cloud |
| System.Reactive | Project Reactor, RxJava | Spring WebFlux is built on Reactor, and RxJava is the Reactive Extensions library for the JVM |
| Akka.NET | Akka, Apache Pekko | Akka.NET is a port of Akka, and Pekko is a fork of Akka 2.6 that stayed under the Apache licence |
| Orleans | Apache Pekko, Akka | the nearest match is cluster sharding, which reaches an actor by a logical id on any node |
Documents and files#
This table covers the files and messages that a business application handles. They are spreadsheets, PDF documents, HTML pages, images, barcodes and email.
| .NET | Java | Note |
|---|---|---|
| ClosedXML, EPPlus | Apache POI | reads and writes Excel files, both .xlsx and .xls, and also Word and PowerPoint files |
| NPOI | Apache POI | Apache POI is the original, and NPOI describes itself as the .NET version of Apache POI |
| QuestPDF | OpenPDF, Apache PDFBox | OpenPDF creates PDFs and can render them from HTML, and PDFBox also extracts text and fills forms |
| iText for .NET | iText | the same product for Java, with the same choice between the AGPL and a commercial licence |
| HtmlAgilityPack, AngleSharp | jsoup | parses real-world HTML into a tree that you query with CSS selectors or XPath |
| ImageSharp | ImageIO, Thumbnailator, TwelveMonkeys | ImageIO is in the JDK. Thumbnailator makes thumbnails, and TwelveMonkeys adds formats such as WebP. |
| MailKit | Jakarta Mail through JavaMailSender | Boot's mail starter builds a JavaMailSender once spring.mail.host is set, and JavaMailSender uses Jakarta Mail |
| ZXing.Net | ZXing | ZXing is the original Java barcode library, and ZXing.Net is a port of it. ZXing is now in maintenance mode. |
Cloud SDKs#
AWS and Azure each publish a Java SDK and a .NET SDK. For each Java SDK, a Spring Cloud project wraps the SDK in Spring's own style.
| .NET | Java | Note |
|---|---|---|
| AWS SDK for .NET | AWS SDK for Java 2.x, Spring Cloud AWS | The SDK is from Amazon itself. Spring Cloud AWS is a community project that adds Spring-style S3 and SQS support. |
| Azure SDK for .NET | Azure SDK for Java, Spring Cloud Azure | the client libraries are in the com.azure group, and Spring Cloud Azure adds Spring support for Service Bus and Key Vault |
Testing and quality#
| .NET | Java | Note |
|---|---|---|
| xUnit / NUnit | JUnit 5 (JUnit 6 on Boot 4) | Boot's test starter includes JUnit, so a project does not choose a test framework |
| Moq / NSubstitute | Mockito | the mocking library in Boot's test starter, so it is already on the test classpath |
| FluentAssertions | AssertJ | a fluent assertion library, also included in Boot's test starter |
| WebApplicationFactory<T> | @SpringBootTest with MockMvc | It starts the whole application for a test. Add @AutoConfigureMockMvc to get MockMvc, on Boot 3 and Boot 4. |
| Testcontainers | Testcontainers | the same project in another language, which runs a real database in Docker for the length of a test |
| WireMock.Net | WireMock | WireMock is the Java original, and WireMock.Net copies its features in a separate .NET codebase |
| AutoFixture | Instancio | fills a whole object with random values, so a test sets only the fields that matter to it |
| Bogus | Datafaker | generates fake data for tests in Java and Kotlin, from a large set of data providers |
| BenchmarkDotNet | JMH | JMH is the benchmark harness from the OpenJDK project. For one benchmark, see the chapter Testing. |
| coverlet + ReportGenerator | JaCoCo | One library does both jobs. Its Maven plugin attaches an agent to the tests and writes the report. |
| Stryker.NET | PIT | Stryker supports JavaScript, C# and Scala only, but PIT does mutation testing for Java, from Maven or Gradle |
| Reqnroll / SpecFlow | Cucumber-JVM | Reqnroll is Cucumber-style, so scenarios read the same. Cucumber-JVM runs them on the JUnit Platform. |
| NBomber | Gatling, JMeter | A Gatling load test is Java code, as an NBomber test is C# code. JMeter is a separate application. |
| Verify | ApprovalTests.Java | compares a result with an approved snapshot file, and works with JUnit and TestNG |
| Playwright for .NET | Playwright for Java | the same Microsoft project, published as the Maven artifact com.microsoft.playwright:playwright |
| Selenium WebDriver | Selenium | the same project in both languages, and the Java binding is the Maven artifact selenium-java |
| SonarAnalyzer | SonarQube | the same Sonar product, with its Java rules |
| Roslyn analyzers | Error Prone | finds bug patterns at compile time, inside javac |
| StyleCop | Checkstyle | checks naming, imports and formatting against a rule file, and Checkstyle includes the Google and Sun styles |
| dotnet format | Spotless | a Maven or Gradle plugin that checks and fixes formatting, for example with google-java-format |
| ArchUnitNET | ArchUnit | ArchUnit is the original, and ArchUnitNET calls itself the C# fork of this library |
Observability and diagnostics#
| .NET | Java | Note |
|---|---|---|
| dotnet-counters | JFR, Micrometer | JFR records JVM events, and Micrometer exposes application metrics |
| dotnet-trace / PerfView | JFR + JDK Mission Control | JFR records inside the JVM, and 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 change to your code |
| App Insights / Prometheus | Micrometer + Prometheus | Micrometer is the metrics interface, and a backend such as Prometheus connects to it |
| 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 with low overhead |
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.jfrThis command starts a one-minute flight recording and writes it to
app.jfr. The application does not restart.jcmd 4242 Thread.printThis command prints what every thread is doing, like a thread dump from
dotnet-dump.jmap -histo 4242 | headThis command counts the objects of each class on the heap. The class that uses the most memory comes first.
Learn JFR (Java Flight Recorder)
early. JFR is a production-grade profiler that can stay switched on. It is built into
the Java Development Kit (JDK). The nearest .NET tools are
dotnet-trace and dotnet-counters, but they are separate tools that you
install. Run jcmd <pid> JFR.start on a running process to get allocation,
lock contention, I/O and CPU data with no restart. See
The JVM at runtime: memory, GC and containers.
Utility libraries#
| .NET | Java | Note |
|---|---|---|
| LINQ | Streams | Streams are built into the JDK. See the chapter Streams vs LINQ. |
| Humanizer | no direct equivalent | there is no popular equivalent, so format such text yourself |
| AutoMapper | MapStruct | MapStruct works at compile time, so mapping problems appear in the build |
| FluentValidation | Jakarta Bean Validation | rules are annotations such as @NotNull and @Size, or custom validators |
| System.Collections.Immutable | List.of, Guava immutables | List.of and Map.of are built in, and Guava adds richer immutable types |
| Nito.AsyncEx | virtual threads | the problems that Nito.AsyncEx solves largely disappear with virtual threads |
| CommandLineParser | picocli | annotation-driven command-line parsing, with generated help |
| Scriban / Handlebars | Thymeleaf, Mustache | Thymeleaf and Mustache are text template libraries, and Mustache is the closest to Handlebars |
| NodaTime | java.time | java.time is built into the JDK, and NodaTime and java.time both descend from Joda-Time |
| Guard clauses libraries | Objects.requireNonNull, Guava Preconditions | argument checks that throw with a clear message |
| OneOf | sealed interfaces and records | Sealed interfaces and records are built into Java 21. A sealed interface lists its cases, and a switch must handle every case. |
| ErrorOr | a sealed Result type, or Vavr | the JDK has Optional but no Result type, so write a small sealed interface or use Either from Vavr |
| LanguageExt | Vavr | a functional library for Java with Option, Either, Try and persistent collections |
| Stateless | stateless4j | The library stateless4j is a Java port of Stateless. Spring Statemachine was archived in July 2026, so avoid it in new code. |
Only in Java#
These tools are in daily use in Java teams. Most of these tools exist because Java or the JDK lacks something that C# or the .NET SDK has. The last column says what .NET does instead.
| Java tool | What it is | The problem it solves | What .NET does instead |
|---|---|---|---|
| Lombok | Annotations that add getters, constructors and builders while the code compiles. | A Java class otherwise needs every accessor and constructor written by hand. | Properties, records and primary constructors are part of C#. |
| Annotation processors (MapStruct, Immutables) | Code generators that the Java compiler runs during the build. | Mapping code and value classes are repetitive to write by hand. | Roslyn source generators do the same job. |
| jOOQ | A library that generates Java classes from your database and builds SQL with them. | Java has no LINQ, so SQL written as text is checked only when it runs. | LINQ with Entity Framework Core checks a query when you compile. |
| Spring Data derived queries | A repository method whose name is the query, such as findByEmailAndStatus. | A simple query needs no hand-written JPQL or SQL. | A LINQ Where call on a DbSet says the same thing. |
| Guava | Google's core libraries, with immutable collections, multimaps, hashing and more. | The JDK's collections have no multimap or multiset. | System.Collections.Immutable and ToLookup cover the common cases. |
| Apache Commons | Small helper libraries such as Commons Lang, Commons IO and Commons Text. | The JDK leaves out many small helpers for strings, files and collections. | Most such helpers are in the base class library. |
| Project Reactor | A library of non-blocking pipelines, built from the types Mono and Flux. | A server can handle many requests with a small number of threads. | async and await do this in C#, and virtual threads now do it in Java too. |
| Java agents | A JAR named with -javaagent, which the JVM loads before main and which can rewrite classes as they load. | Tracing and profiling are added without a change to the application's code. | The CLR profiler and startup hooks, both set through environment variables. |
| JDK Flight Recorder | An event recorder that is part of the JDK and is started with jcmd or a flag. | You see what the application, the JVM and the operating system are doing. | dotnet-trace and dotnet-counters, which are installed as separate tools. |
| SDKMAN | A command-line tool that installs several JDKs side by side and switches between them. | Java has many JDK vendors and versions, and a project needs one of them. | .NET SDKs install side by side, and global.json selects one. |
| Maven and Gradle wrappers | The scripts mvnw and gradlew, committed with the project. | They download the declared version of the build tool, so nobody installs it by hand. | The dotnet command is part of the SDK, so there is nothing to download. |
| JBang | A tool that runs one .java file and reads its dependencies from //DEPS comments. | A script with dependencies otherwise needs a whole Maven or Gradle project. | .NET 10 runs one file with dotnet run app.cs and #:package lines. |
| OpenRewrite | Recipes that rewrite source code automatically. | Framework migrations and other mechanical changes are applied across a whole codebase. | Roslyn analyzers with code fixes, which an IDE applies across a solution. |
| Error Prone and NullAway | Plugins for the Java compiler that report likely bugs and null errors. | Java has no nullable reference types, so javac does not check for null by itself. | Nullable reference types and Roslyn analyzers come with the .NET SDK. |
| Spring Modulith | A toolkit that verifies the module structure inside one Spring Boot application. | A public class is visible to every package, so module boundaries are not enforced. | Separate projects and the internal keyword enforce the same boundary. |
| Kotlin, Scala and Groovy | Other languages that run on the JVM and use the same Java libraries. | A team gets a different syntax without leaving Java's libraries, and Gradle scripts use Groovy or Kotlin. | F# and Visual Basic share the CLR with C# in the same way. |
For Lombok in code, see the section Lombok against source generators. For MapStruct in code, see the section MapStruct against AutoMapper. Other chapters teach four more of these tools. The four tools are derived queries, Reactor, Flight Recorder and the wrappers.
Jackson against System.Text.Json#
Jackson is the most-used library in Java after the JDK itself. Spring Boot configures Jackson for you, so most of the time you only see its annotations. When you do build a mapper yourself, the options translate like this:
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. C# fills in an options object instead.
incl -> incl.withValueInclusion(Include.NON_NULL)This setting leaves null values out of the JSON, like
WhenWritingNullin C#.mapper.readValue(json, Order.class)This call passes the class, because erasure removes the type argument. See Generics and type erasure.
C# refresherNewtonsoft.Json: JsonConvertC# 3
string json = JsonConvert.SerializeObject(order);
var back = JsonConvert.DeserializeObject<Order>(json);On Boot 3, with Jackson 2, the same code differs in three ways:
- It imports from
com.fasterxml.jackson.databind. - It needs
.addModule(new JavaTimeModule())forjava.timetypes. - It usually leaves null values out 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 idThis annotation writes the component as
order_id, likeJsonPropertyNamein C#.@JsonIgnore String secretThis annotation leaves the component out of the JSON entirely.
@JsonSerialize(using = MoneySerializer.class)This annotation names a custom serializer, like a
JsonConverterin C#.MoneySerializeris your own class.
| System.Text.Json | Jackson | Note |
|---|---|---|
| JsonSerializer.Serialize | mapper.writeValueAsString | returns the JSON as a String, as Serialize does |
| 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 | writes and reads the member under the JSON name you give |
| [JsonIgnore] | @JsonIgnore | leaves the member out of the JSON entirely |
| [JsonConverter] | @JsonSerialize / @JsonDeserialize | names your own serializer or deserializer class for one member |
| JsonSerializerOptions | JsonMapper.builder() | Jackson 3 sets options on a builder, then builds the mapper once |
| CamelCase policy | PropertyNamingStrategies.LOWER_CAMEL_CASE | rarely needed, because Java field names are already camelCase |
| DateTime support | built in (Jackson 3), JavaTimeModule (Jackson 2) | Jackson 3 handles java.time itself, but Jackson 2 needs the module |
| Unknown members ignored | FAIL_ON_UNKNOWN_PROPERTIES | Jackson 2 fails on unknown members by default, but Jackson 3 ignores them |
Two Jackson defaults changed between Boot 3 and Boot 4. Boot 3 uses
Jackson 2. Jackson 2 throws on an unknown JSON property unless you disable
FAIL_ON_UNKNOWN_PROPERTIES. Jackson 2 also writes java.time types as
unreadable objects unless JavaTimeModule is registered.
Boot 4 uses Jackson 3. Jackson 3 ignores unknown properties and handles
java.time itself, as System.Text.Json does. On Boot 3 and on Boot 4,
Spring Boot configures the mapper that it builds to ignore unknown properties. So this
difference affects you only when you build a mapper yourself.
MapStruct against AutoMapper#
The difference is when the mapping is decided. AutoMapper matches properties by reflection while the program runs. MapStruct writes a plain Java class when you compile. So mapping problems appear in the build, and not 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")This line fills
customerNamefrom the name of the order's customer, likeMapFromin AutoMapper. A field with the same name on both sides needs no line at all.OrderDto toDto(Order order);This is 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. To read the generated class, look in target/generated-sources. The class is
ordinary field-by-field assignment. It uses no reflection and does no work at startup.
Java's built-in serialization, and why to avoid it#
Java has its own binary serialization mechanism. It is older than the popularity of JSON,
and it is built into the language through the marker interface Serializable. You
will see this mechanism, and you should not choose it. Here an order is written to a file, and
p is the path of the file:
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 that objects of the class may be serialized.private transient String secret;transientleaves a field out of the bytes.out.writeObject(order);This line writes the whole object, and every object that it refers to, in Java's own binary format.
Deserializing untrusted data is remote code execution. The format says which classes to create, and the reader creates them before you can inspect anything. A chain of ordinary library classes can turn a byte array into arbitrary code. Such a chain is called a gadget chain. This is the most exploited kind of Java vulnerability. It is the reason that so many Java security advisories look alike.
If you must read serialized data that you do not control, use a deserialization filter. Java 9 added deserialization filters, 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/*;!*'This filter allows classes under
com.acmeand classes in thejava.basemodule. It rejects every other class, before any object is built.
| Problem | Detail |
|---|---|
| Security | untrusted input can execute code, as the warning above explains |
| 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 the format, and no other platform understands it |
| Bypasses constructors | objects are reconstructed without running your invariants |
Use Jackson. You can inspect JSON and version it, and every language can read it. Reading JSON also does not create arbitrary classes. If you really need a compact binary format, use Protobuf, Avro or CBOR. All three formats have schemas, and all three formats are free of this danger.
Serializable is still required in a few places. Two examples are 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
harmless.
SLF4J and Logback against ILogger and Serilog#
The Simple Logging Facade for Java (SLF4J) is the interface, and
Logback is the implementation. Your code always calls
SLF4J, exactly as your C# code calls ILogger<T> and not Serilog. Here the
order service logs a placed order. It also logs a warning that is 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)This call gets a logger that is named after the class, like
ILogger<OrderService>in C#. The field isstatic, so one logger serves every instance.log.info("Placed {} for {}", id, total);The placeholders are
{}, and the arguments fill them in order. The placeholders have no names.MDC.put("correlationId", cid);This line adds a value to the mapped diagnostic context (MDC). Every log line on this thread then includes the value, until the value is removed. This is like
BeginScopein C#.
SLF4J placeholders are positional {}, not named. There is no
{OrderId}, so structured logging needs the MDC or a structured encoder. Here SLF4J
is clearly weaker than Serilog. This is the reason that the MDC matters more in Java than
logging scopes matter in .NET.
Log levels and the line format are set in application.yml. This file does 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: DEBUGThis line logs everything from
DEBUGup for the billing code. All other code 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 while the code compiles, and annotations tell Lombok what to write. In C#, the language itself makes most of that code unnecessary. Here is the C#, 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 @SetterThese annotations generate a getter and a setter for every field.
@RequiredArgsConstructorThis annotation generates a constructor that takes every
finalfield. Spring passes dependencies through such a constructor.
For immutable data, a record is now better than Lombok and needs no dependency.
Lombok is still useful for mutable Jakarta Persistence
(JPA) entities, which cannot be
records. Lombok is also still useful for
@Slf4j, which declares the logger field for you. @Builder is the other
Lombok annotation that is still common.
Build and deploy#
| .NET | Java | Note |
|---|---|---|
| MSBuild | Maven, Gradle | Maven is the most widely used, at 67% in the JetBrains survey of 2025, and Gradle is the alternative |
| 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 that you deliver together with the app |
| NativeAOT | GraalVM native-image | It compiles ahead of time to a native binary. See the chapter Packaging and native images. |
| ReadyToRun | AOT cache, CDS | the AOT cache and CDS are startup caches, and 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 |
Appendix J: About this book#
This appendix says what the book targets, and how and when its facts were checked. It also says what the book does not cover, and where its structure came from.
What it targets#
| Side | Version | Note |
|---|---|---|
| Java | 25 LTS | where Java 21 is different in a way that matters, the text says so |
| .NET | 10, C# 14 | all C# code uses the modern style, such as records, patterns and primary constructors |
| Spring | Boot 3 and Boot 4 | the two versions are shown side by side wherever they differ |
Many of the Java features that people write about online are still preview
features. A preview feature is finished, but it is not final. You enable a preview feature with
--enable-preview, and the feature can still change between releases. So this book
marks every preview feature, because you can easily lose an afternoon if you copy a preview
API into production code.
On accuracy#
Java is thirty years old. Search results are not sorted by the Java version that you use. An answer that was correct in 2011 appears next to an answer that is correct today. Nothing shows you which answer is the old one. That is the biggest danger when you learn Java from the internet.
So every claim in this book that depends on a version was checked against the original sources:
- The OpenJDK project pages were used for the features of the Java Development Kit (JDK) releases 21, 22, 24 and 25.
- The JDK Enhancement Proposals (JEPs) were used for the exact shape of each API. The JEPs are the documents that describe each change to Java.
- The Spring release notes and migration guides were used for the differences between Spring Boot 3 and Spring Boot 4.
If a claim could not be verified, the book marks it as uncertain or does not include it.
When it was written#
This is the October 2026 edition. The facts that depend on a version were checked against those sources, 9 September to 4 October 2026. Java, .NET and Spring all release on a fixed schedule. So anything that this book calls current, latest or new was true on those dates. Each sentence that says so gives the date.
After those dates, treat version numbers, support dates and preview status as claims that you must check. Check them against the current documentation. When this book and the reference documentation disagree, the documentation is right.
The sidebar shows the date when a copy was built. That date is not the edition. A copy that is rebuilt later from the same text is still the October 2026 edition.
What it does not cover#
This is a transition handbook for server-side Java. It is for someone who builds web services and APIs. Java runs in many other places, and this book covers none of those places.
| 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, so almost none of the Spring Boot part 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, and 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, and Spring Boot is assumed |
| Java as a first language | any introductory text | this book assumes you are fluent in C# already |
Appendix C is The Java you'll inherit.
Within server-side Java, the book is still selective. It does not teach you the internals of the Java Virtual Machine (JVM). The JVM is the program that runs your bytecode. The book does not cover the memory model in depth. For reactive programming, the book covers only the decision of whether to use it.
Kotlin is a language that also runs on the JVM. Kotlin has an appendix and not a chapter, because the platform facts in this book apply unchanged to a Kotlin codebase.
For the expert topics, see Appendix D. For each topic, that appendix says what the topic is and which symptom means that you now need it. It also says where to read about the topic in full. The appendix does not teach the topics. It only points to them, and this is on purpose. For Kotlin, see Appendix E. For an explanation of every Java word that the book uses, see Appendix H.
A note on sources#
The structure of this book was influenced by an older PDF that compared the .NET and Java
ecosystems. The PDF was shared freely. It was written for C# 5 and Java 8, and it is now far
out of date. No text was taken from the PDF. This book explicitly corrects several
recommendations of the PDF. The most important one is the advice to override
finalize(). That method has been deprecated for removal since Java 18.
End of the book