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.

Compiled bykodebot

October 2026 edition

Java 25, a long-term support release .NET 10 · C# 14 Spring Boot 3 & 4

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 virtual was 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:

MarkMeansWhat to do
sameit works as it does in C#, so nothing changesread the row and move on
twistthe same idea as in C#, written differentlyread the row, and open the section only if you want the detail
trapyour C# habit gives the wrong resultRead the section. Most traps ask you to predict the result first.
newC# has nothing like it, so you have no C# habit to useread 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:

ModeShowsTakes
Essentialsevery difference, and the explanation of the traps and the new ideas in the main chaptersabout 3 hours 45 minutes
Everydayalso the detail behind each twistabout 7 hours 25 minutes
Completeeverything, including legacy code and JVM internalsabout 10 hours
Referenceonly each chapter's table of differences, with the tours and the appendicesa 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#

Day one · Chapter 01· 7 min read

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#.

KindC#JavaNote
samevar order = new Order(customer, lines)var order = new Order(customer, lines)classes, constructors, exceptions, generics and var all work the same way in Java
trapsealed classfinal classNothing may extend the class. Java has a sealed keyword too, and it means something else. Why
twistreadonly fieldfinal fieldassigned once, in the declaration or in the constructor Why
twistOrderService(IOrderRepository repository)a written-out constructorJava classes have no primary constructors Why
twistArgumentExceptionIllegalArgumentExceptionthe standard exception for a bad argument value Why
twistforeach (var line in lines)for (var line : lines)C# writes foreach and in, but Java writes for and a colon Why
newasync Task<Order> PlaceAsync()Order place(), run on a virtual threadblocking is cheap on a virtual thread, so there is no async Why
twistTotal { get; set; }getTotal() and setTotal()Java has no properties, but a record gives you accessors instead Why
trapno modifier means privateno modifier means package-privatevisible to the whole package, which surprises C# developers Why
newFile.ReadAllText(path), with nothing declaredthrows IOExceptiona checked exception must be caught, or listed after throws Why
twisttypeof(T)no equivalenttype arguments are erased when Java compiles, so a list does not know what it holds Why
trapVisual Studio's Build menu, with MSBuild hidden behind itMaven or Gradle runs the buildthe build is run by a separate tool, rather than by the IDE Why
trapprotectedprotectedThe 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 vendora specification, and a JDK from any of several vendorsthe web framework, the ORM and the test runner are third-party choices Why
twistIOrderRepositoryOrderRepositoryJava code drops the I prefix on interface names Why
twistnamespace Acme.Billingpackage com.acme.billingthe package should match the folder, and the file is named after its public class Why

One class, side by sidetwist#

C#
// 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;
    }
}
Java 25
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;
    }
}
Reading the code
public final class OrderService

final on a class means the same as sealed on a class in C#. No other class may extend the class. Java has a sealed keyword too, but it means something else.

private final OrderRepository repository;

A final field is assigned once, in the constructor, like a C# readonly field.

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 IllegalArgumentException

IllegalArgumentException does the same as ArgumentException in C#.

for (var line : lines)

This loop does the same as foreach in C#. Java writes the keyword for, and it writes a colon in place of in.

var order = new Order(customer, lines);

var works as it does in C#.

repository.save(order);

There is no await, and the method returns Order, not Task<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.

C#
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);
}
Java 25
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); }
}
Reading the code
public Money getTotal() { return total; }

Java has no properties. A private field holds the value, and the method getTotal reads 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 IOException

Files.readString can fail with IOException. IOException is a checked exception, so you must catch it or list it after throws.

public void save() { repository.save(this); }

The method has no async and no Task, 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 checked

This table lists all six things, each with its chapter:

Your instinctThe Java realityChapter
Exceptions are all uncheckedChecked exceptions must be declared or caughtExceptions and resources
Generics are reifiedType arguments are erased at runtimeGenerics and type erasure
Properties are a language featureGetters and setters are just methodsFields, properties and immutability
Default access is privateDefault access is package-privateAccess modifiers and packages
I need async for scaleBlock on a virtual thread insteadThreads are cheap now
The build is part of the IDEMaven or Gradle runs the build, outside the IDEMaven 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#

Gotcha

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.

C#
public class Order
{
    protected void Recalculate() { }
}
Java 25
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:

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:

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

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#JavaExample
PascalCase methodscamelCase methodsGetName() to getName()
IFoo for interfacesno prefixIRepo to Repo
_camelCase fieldscamelCase, no prefix_name to name
Namespace need not match folderPackage should match folderthe build tools and the JVM expect it
One public type per file, looselyOne public type per file, strictlyfilename must match
PropertiesgetX() / setX()or a record component

Here is one interface and one class in each language, written in the style of each community:

C#: Acme/Billing/OrderService.cs
// 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;
}
Java: com/acme/billing/OrderService.java
// 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.

Check yourself
1
An 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?
The file is named after its public class, so its name is 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.
2
TranslateWrite 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 {
    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.

3
An expert asksA Java developer tells you that Java is a specification, not a product. What does that change for a service you build?
Several vendors ship a JDK built to the same specification, so you choose one JDK. The JDK holds no web framework, no object-relational mapper and no test runner. Spring Boot, Hibernate and JUnit are third-party libraries, and you list them as dependencies in the build file.

The week-one gotchas#

Day one · Chapter 02· 10 min read

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
KindC#JavaNote
trapa == b on stringsa.equals(b)== compares references in Java, even for String Why
trapa == b on two int or int? valuesa.equals(b) on two Integer valuesan Integer from -128 to 127 is a shared object, so == seems to work on small values Why
trapdecimalBigDecimalJava has no decimal type, and a double sum is inexact. Money is a BigDecimal, with methods in place of operators. Why
trapcounts["missing"]counts.getOrDefault("missing", 0)get returns null, which throws when unboxed to int Why
trapexpecting a copysubList, keySet and Arrays.asList return viewsa view shares the original's data, so a change to one shows in the other Why
trapa field with no modifiera private fieldJava's default is package-private, not private Why
trapprotectedprotected, which also opens the packageJava cannot say subclass-only, so the whole package gets access too Why
trapvirtual, to opt infinal, to opt outevery Java method can be overridden unless it is marked final Why
trapa nested classa static nested classwithout static, a nested class holds a hidden reference to its outer object Why
trapswitch on a null stringcase null -> ...a switch on a null String or enum throws unless it has case null Why
trapDateTimejava.time, never Date or SimpleDateFormatSimpleDateFormat is not thread-safe, and a Date can be changed after it is created Why
trapreturn in finally does not compilereturn in finally compilesit discards the exception that was leaving the try block, and reports no error Why
twistchecked(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
trapa lambda that throwsa lambda that wraps the exceptiona lambda passed to map cannot throw a checked exception such as IOException Why
sameobject[] 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.

Predict== on two strings#
What does this print?
String a = "mug";
String b = readProductId();     // also "mug", read from an incoming order
System.out.println(a == b);
Show the answer
false

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.

PredictTwo Integers with the same value#
What does this print?
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b);
System.out.println(c == d);
Show the answer
true false

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#

PredictAdding 0.1 and 0.2#
What does this print?
System.out.println(0.1 + 0.2);
Show the answer
0.30000000000000004

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.

C#
decimal total = 0.1m + 0.2m;   // 0.3 exactly
bool over = total > limit;
Java 25
BigDecimal total = new BigDecimal("0.1").add(new BigDecimal("0.2"));   // 0.3
boolean over = total.compareTo(limit) > 0;   // no > operator for BigDecimal

Build 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.

PredictReading a missing key from a map#
What happens on the second line?
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.

PredictA list made from an array#
Which line fails?
var numbers = Arrays.asList(1, 2, 3);
numbers.set(0, 9);
numbers.add(4);
Show the answer
numbers.add(4) throws UnsupportedOperationException.

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:

  • subList
  • keySet, values and entrySet
  • reversed()

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#

Gotcha

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.

Predictprotected, from a class that is not a subclass#
Does this compile?
// 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
Yes.

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.

PredictAn overridable method called from a constructor#
What does this print?
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
null

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#

Gotcha

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();           // normal

Write 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.

PredictA switch on a null string#
What happens here?
String s = null;
switch (s) {
    case "a" -> run();
    default  -> skip();
}
Show the answer
It throws NullPointerException.

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#

Gotcha

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#

PredictA return inside finally#
What does this print?
int load() {
    try {
        throw new IllegalStateException("real problem");
    } finally {
        return 0;
    }
}

System.out.println(load());
Show the answer
0

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#.

PredictAdding one to the largest int#
This prints -2147483648, as the same two lines do in C#. How do you make Java throw instead?
int big = Integer.MAX_VALUE;
System.out.println(big + 1);
Show the answer
Math.addExact(big, 1), which throws ArithmeticException: integer overflow

Java 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 OverflowException

Checked 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.

PredictA lambda that reads a file#
Does this compile?
// 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
No: unreported exception IOException; must be caught or declared to be thrown

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();

See Exceptions and resources.

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 runtime
Reading the code
Object[] objects = new String[2];

An array of String can be used as an array of Object. That compiles in both languages.

objects[0] = 42;

The array is really a String array, so it cannot hold an Integer. The error is caught only when the line runs, with ArrayStoreException.

Generics are invariant to avoid exactly this problem. That is why a List<String> is not a List<Object>.

Check yourself
1
Either 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.
2
Why should a nested class be static unless you have a reason?
Without 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.
3
An expert asksA service keeps one 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#

Day one · Chapter 03· 9 min read· Quick reference

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.

KindC#JavaNote
twistthe dotnet-install scriptcurl -s "https://get.sdkman.io" | bashinstalls SDKMAN, the tool that installs a JDK and switches between several Why
twistseveral .NET SDKs, side by sidesdk install java 25.0.4-temSDKMAN installs and switches JDKs. The identifier is exact, so copy it from sdk list java. Why
twistdotnet new webapicurl https://start.spring.io/starter.tgz -d dependencies=webThe Spring Initializr generates the project. Its web page is start.spring.io, and IntelliJ has it built in. Why
twistdotnet run./mvnw spring-boot:runthe Maven wrapper is a script in the project that downloads Maven, so you never install it Why
samea Kestrel app runs on its ownthe JAR contains its own web serverthere is nothing to deploy into, on either side
twistC# Interactivejshella 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 classAn annotation is like an attribute in C#. Spring Boot listens on port 8080. Why
twist.NET 6, .NET 8, .NET 10Java 17, Java 21, Java 25These 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 awayJava 8, still in production in 2026Java 8 has no var, no records, no switch expressions and no text blocks Why
twist.NET 8, or .NET 10Java 21, or Java 25 for a new projectas of September 2026 most production Java runs on 21, and every library supports it Why
trapDOTNET_ROOTJAVA_HOMEThe mvn and gradle scripts use this JDK folder when the variable is set. An IDE compiles against the JDK in its project settings. Why
twistdotnet add package Xa <dependency> pasted into pom.xmlthere is no mvn add command, and the Spring Boot parent chooses the version Why
twistone .NET SDK, from MicrosoftTemurin, Corretto, Zulu and other builds of the JDKevery build comes from the same OpenJDK source, so take Temurin unless you have a reason Why
twisttop-level statements, then dotnet runvoid main() in Hello.java, then java Hello.javaThe 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:run
Reading the code
curl -s "https://get.sdkman.io" | bash

This line installs SDKMAN. SDKMAN is a tool that installs Java versions and switches between them.

sdk install java 25.0.4-tem

This line installs Temurin 25, which is a free build of the JDK.

curl https://start.spring.io/starter.tgz

This 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:run

This 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> /exit

Each 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;
    }
}
Reading the code
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 using directive in C#.

@RestController

This 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 /hello to this method, like [HttpGet("/hello")].

@RequestParam(defaultValue = "world") String name

Spring reads name from the query string, like [FromQuery] with a default value. When name is missing, Spring uses "world".

Call the endpoint from another terminal:

curl "http://localhost:8080/hello?name=java"
# hello java

The 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.

ReleaseDateStatus.NET analogy
Java 82014LTS, still everywhere.NET Framework 4.8
Java 112018LTS, legacy.NET Core 3.1
Java 172021LTS, common.NET 6
Java 212023LTS, the usual default in September 2026.NET 8
Java 252025LTS, 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#

PredictA record and var, compiled for Java 8#
What does the compiler say?
// 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 would
Show 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.

Note

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 running
Reading the code
sdk list java

This 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-tem

This 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-tem

This command sets the JDK that every new terminal starts with.

java -version

This command prints the version of the java command on your path, so you can check which JDK is really in use.

Gotcha

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 names

sdk env init writes the file once. Commit the file. Then anyone who runs sdk env in the folder gets the same JDK.

Gotcha

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.

Gotcha

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:

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

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:

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

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

// java Hello.java

The 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.

Check yourself
1
You 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.
2
An expert asksWhich Java version should a new production service target, and why not the newest?
It should target a long-term support (LTS) release, which is Java 21 or Java 25. The releases in between get six months of updates, and no updates after that. So those releases are for trying features, not for shipping.
3
What are the JDK, the JRE and the JVM?
The Java Virtual Machine (JVM) is the program that runs compiled Java code, as the CLR runs IL. A Java Runtime Environment (JRE) is a JVM with its libraries and no compiler. The Java Development Kit (JDK) adds the compiler and the tools. So a developer installs the JDK, like the .NET SDK.

Quick reference#

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

Here are three more items, which you look up by their .NET name:

.NETJavaNote
dotnet --list-sdkssdk list javamarks each JDK that SDKMAN has installed on this machine
dotnet --versionjava -versionprints to stderr, but java --version, with two dashes, prints to stdout
global.json.sdkmanrc, or Maven toolchainspins which JDK this project builds with

IntelliJ for Visual Studio users#

Day one · Chapter 04· 6 min read· a look-up chapter: Essentials shows its table, and each section opens· Quick reference

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.

KindC#JavaNote
twistVisual StudioIntelliJ IDEA, one download since version 2025.3free for core Java and Kotlin, but an Ultimate subscription adds the full Spring and database tooling Why
twistVS CodeVS Code with the Red Hat Java and Spring Boot extension packsa lighter option than IntelliJ, and you will also meet Eclipse in older organisations Why
twista solution that holds projectsa project that holds modulesThe names moved. An IntelliJ project is what Visual Studio calls a solution, and an IntelliJ module is what Visual Studio calls a project. Why
twistBilling.slna parent pom.xml that lists its moduleseach module is a folder with its own pom.xml, and builds one JAR Why
trapVisual Studio's Build menu, with MSBuild hidden behind itthe build tool owns the project, and the IDE reads its fileswhen the IDE and mvn clean package disagree, trust the command line Why
twistCtrl+.Alt+Enterthe key to learn first, because it fixes errors, adds imports and offers language upgrades Why
twistVisual Studio's keysSettings, Keymap, Visual StudioIntelliJ has a Visual Studio keymap, but most people adapt to its own keys Why
twistauto-properties, written by the compilergetters and setters, generated with Cmd+NJava has no properties, so the IDE writes the methods. A record removes most of these methods. Why
twistthe Immediate windowEvaluate Expression, Alt+F8debugging works in much the same way as in Visual Studio, but under different names Why
trapEdit and Continue while debuggingHotSwapreplaces method bodies only, so you must restart if you add a field or change a signature Why
twistRoslyn analyzers and StyleCopError Prone, Checkstyle and SpotBugsseparate 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 StudioIntelliJ IDEANote
Solution (.sln)Projectthe top-level thing you open, and it holds every module in the build
Project (.csproj)Moduleeach module has its own pom.xml and produces one JAR
Assembly outputJARbuilt by Maven or Gradle, and the IDE asks the build tool to build it
Solution ExplorerProject tool windowshows the file tree, and it opens with Cmd+1 on macOS, or Alt+1 on Windows and Linux
Package ManagerMaven or Gradle tool windowshows the tree, but you add dependencies by editing pom.xml
Build menuBuild, or the Maven panelthe 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).

Gotcha

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.

Note

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());
Reading the code
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:

C#: the compiler generates it
public class Customer
{
    public string Name { get; set; }
    public string Email { get; set; }
}
Java: the IDE generates it
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:

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

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() > 10

The condition is ordinary Java. == compares the customer's numeric id, and && joins the two tests. The debugger stops only when both tests are true.

Gotcha

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:

.NETJavaCatches
Roslyn analyzersError Pronereal bug patterns at compile time
StyleCopCheckstyleformatting and naming
FxCop / analyzersSpotBugsbytecode-level bug patterns
EditorConfigEditorConfigsupported by IntelliJ directly
dotnet formatSpotlessapplies formatting in the build

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.

Check yourself
1
What is the Java equivalent of the solution file, and who owns it?
It is a parent 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#

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

Classes, constructors and initialisation#

Language core · Chapter 05· 8 min read

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.

KindC#JavaNote
twistclass A : Base, IFooclass A extends Base implements Fooextends names the one superclass, and implements lists the interfaces Why
trapsealed class Invoicefinal class InvoiceJava has a sealed keyword too, and it means something else Why
sameabstract class Discountabstract class DiscountThe keyword and its meaning are the same. No code can create an object of the class directly.
twiststatic class MoneyMatha final class with a private constructorJava has no static classes, so a class that only holds static methods is written this way Why
twiststructrecordJava has no structs. A record compares by value, but it is still an object. Why
twist: this(...)this(...) as the first statementcalls another constructor of the same class Why
twist: base(...)super(...) as the first statementsuper(...) calls the superclass constructor. Since Java 25, checks may come before the call. Why
twiststatic Foo() { }static { }a static initialiser block, which runs once, the first time the class is used Why
twistfield initialiserfield initialiserC# runs a field initialiser before the base constructor. Java runs it after super() returns. Why
twistno equivalentinstance initialiser { }a bare block in the class body, which runs after super() and before each constructor body Why
twistoverride@Override@Override is an annotation that the compiler checks. It is optional, but Java developers always write it. Why
twistToString()toString()called by string concatenation and by loggers to print the object Why
twistEquals(object)equals(Object)equals compares values, but == on two objects compares references Why
twistGetHashCode()hashCode()must return the same value for two objects that are equal according to equals Why
twistGetType()getClass()returns a Class object, which is like a System.Type object in C# Why
twistMemberwiseClone()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
twistMonitor.Wait / Monitor.Pulsewait() / 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.

C#
public class OrderService : ServiceBase, IDisposable
{
    public void Dispose() { }
}

public sealed class Invoice { }
public abstract class Discount { }
public static class MoneyMath { }
Java 25
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.
Reading the code
extends ServiceBase

extends names the superclass. C# writes a colon there. A class has only one superclass, as in C#.

implements AutoCloseable

implements lists the interfaces. AutoCloseable does the same as IDisposable in C#. It has one method, close(). The Java form of using is called try-with-resources. It calls close() at the end of the block.

@Override public void close() { }

@Override does the same as the override keyword in C#. Java also writes @Override on a method that implements an interface method.

public final class Invoice

final means that no class may extend Invoice, like sealed in C#.

The other declaration rules are in this table:

C#JavaNote
: Baseextends Basea class has only one superclass, as in C#
: IFooimplements Fooa class may implement many interfaces, as in C#
sealed classfinal classno class can extend a final class, and the Java keyword sealed means something else
abstract classabstract classThe keyword and its meaning are the same. The class cannot be instantiated.
static classfinal class + private constructorJava has no static classes, so this form is the usual replacement
partial classno equivalentone class cannot be split across files, so use composition or generated code
internal classpackage-private (no keyword)visible only to the same package, not to the whole JAR
nested classstatic nested classa 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#

PredictA class marked sealed, as in C##
Does this compile?
public sealed class Invoice { }
Show the answer
No: sealed class must have subclasses

In 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#

static class

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#

struct

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:

C#
public readonly record struct Percentage(decimal Value);
Java 25
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#

C#
public class ExpressOrder : Order
{
    public ExpressOrder(Customer customer)
        : this(customer, []) { }

    public ExpressOrder(Customer customer, List<OrderLine> lines)
        : base(customer, lines) { }
}
Java 25
public class ExpressOrder extends Order {

    public ExpressOrder(Customer customer) {
        this(customer, List.of());
    }

    public ExpressOrder(Customer customer, List<OrderLine> lines) {
        super(customer, lines);
    }
}
Reading the code
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#.

Gotcha

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() { }
}
Reading the code
private static final Map<String, Handler> HANDLERS;

This line declares a class-level constant. The constant is static because it belongs to the class. It is final because 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 after super(). 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.

Note

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:

C#
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"
Java 25
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"
Reading the code
@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. @Override does the same as the override keyword in C#, but Java does not require it.

o instanceof Money m

This expression tests the type and names the result in one step, like o is Money m in C#. It is called a type pattern.

amount.equals(m.amount)

equals compares values. In Java, == on two objects compares references, even for BigDecimal. equals on a BigDecimal also 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.Combine in C#.

money.getClass().getSimpleName()

getClass() returns the class of the object. The result is like a System.Type in 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.

Check yourself
1
TranslateWrite this in Java:
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().

2
An 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?
Before 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#

Language core · Chapter 06· 6 min read

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.

KindC#JavaNote
sameprivate Money total;private Money total;the same, but in Java you must write private to get it
trapdecimal fee; with no keywordBigDecimal fee; with no keywordprivate in C#, but package-private in Java, so the whole package can change it Why
trapprotected, for subclasses onlyprotected, which also opens the packageJava cannot say subclasses only. With protected, the whole package gets access too. Why
twistnamespace Acme.Orders;package com.acme.orders;a package name is lower-case, is a reversed domain, and matches the folders Why
trapAcme.Orders.Internal, inside Acme.Orderscom.acme.orders.internal, a package of its ownpackages do not nest, so it sees no package-private member of com.acme.orders Why
twistusing System.Text;import java.util.List;Java imports types, not namespaces, and has no aliases Why
twistusing static System.Math;import static java.lang.Math.*;names one member, or all of a type's static members with .* Why
twistinternal, for the whole assemblyno keyword, for one packageJava has no internal, and package-private is the nearest level Why
twist[InternalsVisibleTo]tests in the same packagetests under src/test/java declare the package they test Why
twistAcme.Billing.Apicom.acme.billing.apithe 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.

Gotcha

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 modifierVisible toClosest C#
publiceveryonepublic
protectedpackage + subclasses anywhereno exact equal
(none)the same package onlyno equal, package-private
privatethe declaring classprivate
Gotcha

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.

C#
// Anywhere/AtAll/OrderService.cs
namespace Acme.Orders;

public class OrderService { }
Java 25
// 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#

PredictA package-private field, read from a sub-package#
Does this compile?
// 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
No: id is not public in Order; cannot be accessed from outside package

Packages 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#JavaNote
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 importswhen two types clash, write one of them with its full package name instead
global usingno equivalenteach file lists its own imports, but the IDE adds them for you
implicit usingsjava.lang is always importedjava.lang is imported everywhere. It contains types such as String, Object and Integer.

Here are the common forms side by side:

C#
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);
Java 25
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 full
Reading the code
import java.util.*;

This line imports every type in java.util. It does not import the types in the packages below java.util, such as java.util.regex, because packages do not nest.

import static java.lang.Math.max;

This is a static import, like using static in C#. It imports one static method, so max(1, 2) needs no class name. import static java.lang.Math.*; imports every static member of Math.

java.sql.Date d = null;

Java has no import aliases. Sometimes two types share a name, such as java.util.Date and java.sql.Date. Then you write one of them with its full package name every time.

Note

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:

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

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# modifierVisible toClosest Java
publiceveryonepublic
protectedsubclasses onlyno exact equal (Java's is wider)
internalthe assemblypackage-private, roughly
protected internalassembly or subclassesprotected, roughly
private protectedsubclasses in the assemblyno equal
privatethe declaring typeprivate
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 folders

The 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.

Check yourself
1
TranslateWrite this file in Java:
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.

2
An expert asksWhy are Java tests in the same package as the code they test?
Java has no 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#

Language core · Chapter 07· 8 min read

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.

KindC#JavaNote
twistinterface IPriceLookupinterface PriceLookupJava interface names have no I prefix Why
twistdefault interface methoddefault boolean has(...) { ... }an interface method with a body, and every implementing class inherits it Why
twistIEnumerable<out T>List<? extends T>Java has no in or out, so you write a wildcard where the type is used Why
twistconst int MaxLines = 50; in an interfaceint MAX_LINES = 50;no keyword is needed, because every interface field is public, static and final Why
samea method that implements an interfacepublic Money find(String productId)the method needs public in both languages, and C# reports CS0737 without it Why
twistvoid ILogger.Log(string m)no equivalentone public method serves every interface that declares that signature Why
twistevent Action<Order> PlacedList<Consumer<Order>> plus onPlaced(...)Java has no events, so keep a list of listeners instead Why
twistabstract record Payment plus derived recordssealed interface Payment permits Card, BankTransfer, Vouchera closed set of types, so the compiler knows every kind Why
twista switch ending in a discard arma switch with no defaultover a sealed type, Java checks that every kind is handled, but C# only warns (CS8509) Why
newno equivalentfinal, sealed or non-sealedeach permitted type says whether its own branch stays closed Why
twistno equivalentpermits left outwhen every subtype is in the same file, the compiler finds them without a permits list Why
sameabstract class with abstract methodsabstract class with abstract methodsthe same keywords and the same meaning as in C# Why
trapvirtualno keyword, because every method is virtualwrite final to stop a method being overridden Why
twistoverride@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.

C#
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) { }
}
Java 25
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) { }
}
Reading the code
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)

default gives an interface method a body, like a default interface method in C#. Every class that implements PriceLookup gets 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:

FeatureC#Java
Default method bodyC# 8Java 8
Static methodC# 8Java 8
Private methodC# 8Java 9
Constant fieldyes, since C# 8yes, implicitly public static final
Fieldsstatic fields only, since C# 8no (constants only)
Explicit implementationyesno
Generic variance on the interfaceyes, in/outno, 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++;               // compiles
Gotcha

Every 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#

An implementing method with no access modifier

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#
Means
A method declared in an interface is public, although it has no keyword. In a class, no keyword means package-private. An implementing method may not be less visible than the method that it implements.
Fix
Write 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");       // compiles
Explicit interface implementation

In 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#

event

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:

C#
public class OrderService
{
    public event Action<Order>? Placed;

    public void Place(Order order)
    {
        Placed?.Invoke(order);
    }
}

service.Placed += order => Console.WriteLine(order.Id);
Java 25
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()));
Reading the code
private final List<Consumer<Order>> placedListeners

A list of listeners replaces the event. Consumer<Order> is a built-in functional interface. This is an interface with one method. Here the method is accept, which takes an Order and returns nothing. Consumer<Order> does the same as Action<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:

IntentC#Java
Cannot be extended at allsealed classfinal class
Only named types may extendno equivalentsealed 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 { }
Reading the code
permits Card, BankTransfer, Voucher

The complete list comes after permits. The compiler rejects any other class that implements Payment. Each permitted type must itself choose one of three keywords. final ends the branch. sealed names a closed set of its own. non-sealed opens 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#

PredictA subclass declares a method its superclass already has#
What does this print?
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
10% winter sale

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.

Gotcha

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 Cabstract class C
abstract void M();abstract void m();
override void M()@Override void m()
virtual by opt-invirtual by default, use final to opt out
new (member hiding)no equivalent for methods

Here is a discount with one method of each kind:

C#
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;
}
Java 25
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"));
    }
}
Reading the code
public String describe() { return "discount"; }

This method has no keyword, and yet any subclass may override it. In C#, you must write virtual to allow that.

public final String code()

final on a method forbids overriding. The method is then like a C# method without virtual.

@Override public BigDecimal amount

The @Override annotation 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:

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

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 branch

UserEvent 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.

Note

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.

Check yourself
1
TranslateWrite 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 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.

2
An expert asksA sealed interface permits a class, and the compiler demands that the class be final, sealed or non-sealed. Why?
The set of types under a sealed interface stays closed only if every branch says how it continues. 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#

Language core · Chapter 08· 9 min read· Quick reference

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.

KindC#JavaNote
twistint limit = 100an overload for each shorter callJava has no optional parameters, so a shorter overload calls the full one Why
twistCountOrders("paid", limit: 50)a builder: .timeout(...).build()Java has no named arguments, so a builder sets one value per call Why
twista parameter name, seen by reflectionarg0, unless compiled with -parametersFrameworks match values by parameter name. Spring Boot's parent POM and its Gradle plugin set the flag. Why
twistint.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
twistref parameters, or a tuplerecord Pair(int a, int b) { }a method returns several results together in one record Why
twistparams object[] argsObject... argsvarargs are the same idea as params, and they must also be the last parameter Why
twistname.IsBlank()Strings.isBlank(name)Java has no extension methods, so a static helper takes the value Why
twistcalls chained left to rightcalls nested inside-outa static helper wraps its argument, but streams are the exception Why
twistprice * quantity + shippingprice.multiply(quantity).add(shipping)Java has no operator overloading, so BigDecimal uses methods Why
twistdelegate decimal PriceRule(Order o);interface PriceRule { BigDecimal apply(Order o); }a functional interface has one method, and a lambda can implement that method Why
twistx => x + 1x -> x + 1the lambda is the same, but Java writes the arrow as -> instead of => Why
twist(x, y) => x + y(x, y) -> x + yseveral 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
twistFoo.Bar (method group)Foo::barpasses the method itself as a lambda Why
twist() => new Foo()Foo::newpasses the constructor as a factory function Why
twistFunc<int, string>Function<Integer, String>primitives must be boxed in a generic Why
twistAction<T>Consumer<T>a function that takes a T and returns nothing Why
twistshow(42)show.apply(42)Call the one method of the interface. The method is apply on a Function and accept on a Consumer. Why
samePredicate<T>Predicate<T>The name and the meaning are the same. A Predicate takes a T and returns a boolean. Why
trapa lambda that changes a locala lambda that only reads localsa 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.

C#
public int CountOrders(
    string filter,
    bool caseSensitive = false,
    int limit = 100)
{
    ...
}

var n = CountOrders("paid", limit: 50);
Java 25
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 arguments
Reading the code
return 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 false only because the limit comes after it.

Builders stand in for named argumentstwist#

Optional and named parameters

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();
Reading the code
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. HttpRequest belongs 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 HttpRequest from all the values that were set.

Parameter names and the -parameters flag#

Gotcha

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.java

That 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:

C#
if (int.TryParse(s, out var value))
{
    Use(value);
}
Java 25
// Return a value that can be empty, instead of an out parameter.
OptionalInt parsed = parseQuantity(s);
if (parsed.isPresent()) {
    use(parsed.getAsInt());
}
Reading the code
OptionalInt parsed = parseQuantity(s);

parseQuantity is a small helper that you would write yourself. It returns an empty OptionalInt when the text is not a number. Java's own method Integer.parseInt throws a NumberFormatException in that case. OptionalInt is the int version of Optional. An OptionalInt is 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. Use and use represent the code that you would write next.

Several results come back in a recordtwist#

C#
void Swap(ref int a, ref int b) { ... }
void Print(in BigStruct s) { ... }  // read-only, by reference
Java 25
// Return a record when there are several results.
record Pair(int a, int b) { }
Pair swapped = swap(a, b);
Reading the code
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 ref parameters or a tuple for this.

Pair swapped = swap(a, b);

swap returns a new Pair with 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 4

Varargs#

Varargs are the same idea as params in C#. The same rule applies, so a varargs parameter must be the last parameter:

C#
void Log(string fmt, params object[] args) { }
Log("a {0}", 1);
Java 25
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.

Gotcha

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.

C#: an extension method
public static class StringExtensions
{
    public static bool IsBlank(this string? s) =>
        string.IsNullOrWhiteSpace(s);
}

if (name.IsBlank()) { ... }  // reads as if String had it
Java: a static utility method
public 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 first
Reading the code
public final class Strings

This 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()

String has had its own isBlank() method since Java 11. But a call to isBlank() on null throws, so the helper checks for null first.

Extension methods

These are the replacements for an extension method:

  • A static utility class. You write StringUtils.isBlank(s) in place of s.IsBlank(). This is why Apache Commons and Guava exist.
  • A default method. 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 above

Java 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:

C#
decimal total = price * quantity + shipping;
if (total > limit) { ... }
Java 25
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.

C#
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>();
Java 25
interface PriceRule { BigDecimal apply(Order o); } // a functional interface

Function<Integer, String> show = n -> n.toString();
Consumer<String> print = System.out::println;  // method reference
Predicate<String> empty = String::isEmpty;
Supplier<List<Integer>> make = ArrayList::new;  // constructor reference
Reading the code
interface PriceRule { BigDecimal apply(Order o); }

The delegate type becomes an interface with one method. A lambda such as o -> BigDecimal.TEN matches this interface.

Function<Integer, String> show = n -> n.toString();

Function<Integer, String> does the same as Func<int, string> in C#. A Java generic type cannot hold a plain int, so the code uses the wrapper type Integer.

Consumer<String> print = System.out::println;

This is called a method reference. The form Type::method passes an existing method as a lambda, like a method group in C#. Consumer<String> does the same as Action<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 as Func<T> in C#.

Calling a lambdatwist#

names is a list of strings.

C#
Func<int, string> show = n => n.ToString();
Action<string> print = Console.WriteLine;

print(show(42));          // prints 42
names.ForEach(print);
Java 25
Function<Integer, String> show = n -> n.toString();
Consumer<String> print = System.out::println;

print.accept(show.apply(42));   // prints 42
names.forEach(print);
Reading the code
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 is apply. On a Consumer, that method is accept.

names.forEach(print);

forEach calls print once for each element.

A lambda and the variables around ittrap#

PredictA lambda that counts in a local variable#
These lines are inside a method. Do they compile?
var lines = List.of("mug", "pen", "cup");
int count = 0;
lines.forEach(line -> count++);
System.out.println(count);
Show the answer
No: local variables referenced from a lambda expression must be final or effectively final

Captured variables must be effectively final. A Java lambda may use a local variable only if that variable is never reassigned after it is set. C# captures the variable itself and lets you change it. 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 3

An 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.

Check yourself
1
TranslateWrite this in Java:
Func<string, int> length = s => s.Length;
Action<int> print = Console.WriteLine;

print(length("mug"));     // prints 3
Function<String, Integer> length = s -> s.length();
Consumer<Integer> print = System.out::println;

print.accept(length.apply("mug"));     // prints 3

A 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.

2
An expert asksDoes Java pass objects by reference?
No. Java passes every argument by value. An object reference is passed by value too. 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 Java has no ref and no out.

Quick reference#

This table lists every C# parameter pattern and its replacement in Java:

C# patternJava replacement
out parameterreturn Optional, or a record
TryParseOptional-returning method, or catch NumberFormatException
ref parameterreturn the new value and reassign
Multiple return valuesa record, or a small value class
ref struct / SpanByteBuffer, MemorySegment, or an array plus offsets
in parameternothing needed, 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 copy

Fields, properties and immutability#

Language core · Chapter 09· 6 min read

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.

KindC#JavaNote
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 + constructorThe field is set once in the constructor. A record does this for every field. Why
twist=> expressiona plain methodthe method computes the value on each call, and there is no backing field Why
twistrequiredno equivalentmake the field final and require its value in the constructor Why
twistfield keywordno equivalentdeclare the backing field yourself, and the getter and the setter use that field Why
trapa property, whatever its namegetName(), isActive(), setName(v)a method without the prefix is no property, so Jackson silently skips it Why
twistrecord Customer(long Id, string Name)record Customer(long id, String name) { }one line replaces the fields, constructor and accessors Why
twistcustomer.Namecustomer.name()record accessors have no get prefix, but current Jackson and Spring read them Why
twistreadonlyfinala final field is assignable once, but final fixes the reference, not the object it points to Why
twistconst, or static readonlystatic finalone form for both, with a name in capitals such as MAX_RETRIES Why
samea const is copied into the code that uses ita static final constant is copied toocallers keep the old value of a changed constant until they recompile Why
twistno equivalent@Getter @Setter @Builder, from LombokLombok writes the accessors while the class compiles, and it is common in older code Why

The property gaptwist#

C#
public class Customer
{
    public long Id { get; init; }
    public string Name { get; set; }
    public string Label => $"{Name} ({Id})";
}
Java 25
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 + ")";
    }
}
Reading the code
private final long id;

final means 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 get plus the property name, and the property name starts with a capital letter.

public void setName(String v)

This is a setter. Its name is set plus the property name.

public String getLabel()

A computed value is an ordinary method. The get prefix 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 error

The names are the contracttrap#

PredictA getter without the get prefix#
Jackson turns a Customer into JSON. What does this print?
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
{"active":true}

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:

C# record
public record Customer(long Id, string Name, string Email)
{
    public string Label => $"{Name} ({Id})";
}

var renamed = customer with { Name = "Ada" };
Java 16+ record
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());
Reading the code
public record Customer(long id, String name, String email)

This one line declares the fields, a constructor and the accessors. It also declares equals, hashCode and toString.

public String label()

A record can have methods too.

new Customer(customer.id(), "Ada", customer.email())

Java records have no with expression. So you create a changed copy yourself, with a call to the constructor.

Gotcha

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#JavaMeaning
readonly fieldfinal fieldassignable once, in constructor or initialiser
conststatic finala compile-time constant, which is copied into the code that uses it
static readonlystatic finalassigned in a static initialiser
readonly structno equivalentas of Java 25 there are no user-defined value types, so use a record
init-onlyfinal + constructorassign once in the constructor, and a record does this automatically
Gotcha

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 reference

names.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:

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

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.

Gotcha

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.

Check yourself
1
TranslateWrite this in Java:
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.

2
An 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?
Yes. 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#

Modern Java · Chapter 10· 6 min read

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

KindC#JavaNote
twistrecord OrderLine(string ProductId, ...)record OrderLine(String productId, ...) { }the braces are required, even when the body is empty Why
twistline.Quantityline.quantity()an accessor method, with brackets and no get prefix Why
samevalue equalityvalue equalitytwo records with equal components are equal, with no code to write
twistrecord structno equivalenta Java record is always a reference type, never a struct Why
newno equivalentpublic Money { ... }a compact constructor, which checks values before they are stored Why
twistline with { Quantity = 3 }line.withQuantity(3)Java has no with expression, so you write a wither method yourself Why
twista 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
twista record as a DTO or a value objectthe same, but never a JPA entityHibernate needs a no-argument constructor and fields it can change Why
twistpositional pattern on a recordcase 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.

C#
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 };
Java 25
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 below
Reading the code
public 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, hashCode and toString. 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 get prefix. In C#, Quantity is 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 of ToString uses braces.

This table compares the other features of C# records and Java records:

FeatureC# recordJava record
Immutable by defaultinit-only, but can add settersalways, no exceptions
Positional syntaxyesyes
Nominal (body) propertiesyesno, only components
with expressionyesno
Value equalityyesyes
Inheritancerecords can inherit recordsno inheritance at all
Can implement interfacesyesyes
struct variantrecord structno
Custom constructoryesyes, 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 change

Checking 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");
    }
}
Reading the code
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 = amount here. setScale rounds 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#

with expressions

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.
Reading the code
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#

PredictA record that holds a list#
What does this print?
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
1

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.

UseSuitableWhy
DTO / API request or responseyesJackson reads and writes records with no extra setup
Value object (Money, Range)yesa value that never changes is safe to share and to use as a map key
Query result / projectionyesSpring Data supports record projections
Pattern-matching payloadyesa switch can take a record apart into its components
JPA entitynoHibernate needs a no-argument constructor and fields it can change
Mutable domain objectnoevery field is final, so an object that changes state cannot be a record
Needs inheritancenoa 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;
    };
}
Reading the code
case Card(String last4)

This is a record pattern. If p is a Card, the pattern puts the component of the card into the variable last4. Components are matched by position.

when last4.equals("0000")

This is a guard, like when in 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.

Check yourself
1
TranslateWrite this in Java, where 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.

2
An expert asksWhy can a JPA entity not be a record?
JPA creates an entity through a no-argument constructor and then sets its fields. A record has no such constructor, because its constructor takes every component. And the fields of a record are final, so they cannot be set later. Use a record for the data that you send out, and use a class for the entity.

Sealed types and pattern matching#

Modern Java · Chapter 11· 9 min read

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.

KindC#JavaNote
twistis T xinstanceof T xthe same type test with a binding, spelled instanceof in Java Why
twistis not T x!(o instanceof T x)Java has no is not, so put the test in brackets and negate it Why
twisto is int io instanceof Integer itest against the wrapper type, because a boxed int is an Integer Why
twistswitch expression armscase T x ->each arm is written case pattern -> result, not pattern => result Why
samewhen clausewhen clausethe same keyword for an extra condition, added in Java 21 Why
twist_ discarddefaultJava uses default, or _ for unnamed variables Why
twista _ arm, even when every kind has an armno default over a sealed typeJava checks that every permitted type has an arm. Without the _ arm, C# only warns (CS8509). Why
trapnull =>case nulldefault does not catch null, so without case null the switch throws NullPointerException Why
twistproperty pattern { X: 1 }no equivalenttest the property in a when clause instead Why
twistlist pattern [1, .., 2]no equivalentJava has no form for this, so check the size and the elements in ordinary code Why
twistrelational pattern > 5only inside whenwrite case Integer i when i > 5 instead of a bare > 5 Why
twistpositional patternrecord patternJava 21 added the record pattern. It works on records and takes them apart by component. Why
twist_ inside a positional pattern_ inside a record patternan unnamed pattern skips a component you do not need, and it was added in Java 22 Why
samea nested positional pattern over a null componentMoney(_, var currency) never matches nullAs in C#, the whole pattern then fails. But var matches null in both languages.
trapOrder(_, Customer c) skips a null customerOrder(_, Customer c) matches it, and c is nulla type pattern that covers the component's whole type also matches null in Java Why
twistno equivalentquantity instanceof int wholeThe 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#

C#
void Refund(Payment payment, decimal amount)
{
    if (payment is Card card)
    {
        RefundToCard(card.Last4, amount);
    }
}
Java 25
void refund(Payment payment, BigDecimal amount) {
    if (payment instanceof Card card) {
        refundToCard(card.last4(), amount);
    }
}
Reading the code
payment instanceof Card card

If payment is a Card, the test creates a new variable, card. The variable has the type Card and is already cast. This is called a type pattern. It is the same idea as is Card card in 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#:

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
}
Java 25
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
}
Reading the code
!(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.

C#
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")
};
Java 25
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();
    };
}
Reading the code
case null

This 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 if payment is a Card and the condition is true, as in C#. Strings are compared with equals, 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 Card arm 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:

PredictA fourth kind of payment#
Does this compile?
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
No: the switch expression does not cover all possible input values

A 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#

Gotcha

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#

C#
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"
};
Java 25
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";
    };
}
Reading the code
case Order o when o.lines().isEmpty()

Java has no property, list or relational patterns. The type pattern Order o matches 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" };  // relational

Record patterns#

A type pattern gives you the whole object, and you then call its accessors. A record pattern Java 21 takes the record apart as it matches. It puts each component into its own variable. Here is the receipt description again, written with record patterns:

C#
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")
};
Java 25
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;
    };
}
Reading the code
case Card(var last4)

This pattern matches a Card and puts its one component into a new variable, last4. With var, the compiler infers the type, which is String here. You may write String last4 instead.

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, because Payment is 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);
}
Reading the code
OrderLine(var productId, _, Money(_, var currency))

This pattern matches an OrderLine and puts its productId into a variable. It skips the quantity. Then it takes the unit price apart. There it skips the amount and puts the currency into 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)).

Note

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:

PredictAn order with no customer#
What does this print?
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
placed by null

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 .5
Reading the code
quantity instanceof int whole

This test is true, because 3.0 fits in an int exactly. So whole becomes 3.

2.5 instanceof int

This test is false, because converting 2.5 to an int would 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.

Gotcha

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.

Check yourself
1
TranslateA C# switch over an 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.
2
An 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?
Arms are tried from top to bottom. The plain 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#

Modern Java · Chapter 12· 6 min read

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.

KindC#JavaNote
twistvalue switch { ... }switch (value) { ... }the value goes in brackets after the keyword Why
twistpattern => resultcase pattern -> resultthe word case, and an arrow instead of => Why
twistor between patternscomma between labelslabels that share an arm are separated by a comma, as in case SATURDAY, SUNDAY -> Why
twist_defaultthe fallback arm, used when no other arm matches Why
samethrow in an armthrow in an armin both languages, an arm can throw instead of giving a value
twista missing case is a warning (CS8509), and an exception at run timea missing case is a compile errorover an enum or a sealed type, Java checks every case and needs no default Why
twistCard => 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
twistyield returnno equivalentyield in Java only gives the value of a block to its switch, and it produces no sequence Why
trapa case with no break is a compile errora case with no break falls throughin the old colon form the next case runs too, and Java compiles it with no warning Why
twistgoto done;break scan;leaves the labelled loop and every loop inside it Why
twistswitch on a longno equivalenta 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:

C#
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"
};
Java 25
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";
};
Reading the code
switch (day) {

The value that is tested goes in brackets after switch. C# puts the value before the keyword and writes day 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# writes or between two labels. Enum constants are usually written without the type name. So you write SATURDAY, not DayOfWeek.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:

C#
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);
Java 25
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;
    };
}
Reading the code
case Card c -> {

The arm's result is a block in braces instead of a single expression.

yield percentFee.max(new BigDecimal("0.30"));

yield ends the block and gives the arm its value. Here the value is the larger of the two fees. A return here would try to leave the whole method, and the compiler rejects it.

Gotcha

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);   // 0246

Exhaustivenesstwist#

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.

PredictA colon switch with one break#
What does this print?
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
send a payment reminder reserve stock

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:

C#
foreach (var order in orders)
    foreach (var line in order.Lines)
        if (line.Quantity == 0)
            goto done;
done:
Console.WriteLine("scan finished");
Java 25
scan:
for (var order : orders)
    for (var line : order.lines())
        if (line.quantity() == 0)
            break scan;          // leaves both loops
System.out.println("scan finished");
Reading the code
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 foreach in C#. Java writes the keyword for, and a colon between the variable and the collection. C# writes in there.

break scan;

This statement leaves the loop labelled scan, and it leaves the inner loop too. A plain break would 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:

TypeJavaNote
int, short, char, byteyesthe original switch types, allowed since Java 1.0
Stringyesallowed since Java 7, and labels are compared with equals, not with ==
enumyesallowed since enums arrived in Java 5, and you write the constant name without its type
sealed interface / classyessince Java 21, with a type pattern for each permitted subtype
any Objectyessince Java 21, with type patterns and usually a default arm
long, float, double, booleannouse 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.

Gotcha

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.

Check yourself
1
TranslateWrite this in Java, where 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.

2
An 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?
A switch expression must produce a value, and 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#

Modern Java · Chapter 13· 10 min read

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.

KindC#JavaNote
samevar 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 + bJava also accepts var on the parameters of a lambda, but C# allows var on local variables only Why
trapvar 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 " + bJava 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
twiststring.FormatString.formatJava fills the %s placeholders in order, so they have no numbers like {0} and {1} Why
sameStringBuilderStringBuilderJava has the same class with the same methods, and you use it in the same way Why
twiststring.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
twiststring.IsNullOrEmpty(s)s == null || s.isEmpty()you write the null check yourself, because isEmpty() alone throws on null Why
twiststring.IsNullOrWhiteSpace(s)s == null || s.isBlank()isBlank() arrived in Java 11 and ignores whitespace, like the C# method Why
sames.Contains(t)s.contains(t)the name and the meaning are the same, and both methods throw if t is null Why
traps == ts.equals(t)== compares references, so it fails on strings built at run time and reports no error Why
twist""" raw string literal""" text blockthe same three quotes, but Java needs a line break after the opening quotes Why
twist\d in a raw string\\d in a text blocka text block still processes escapes, so a regular expression keeps doubled backslashes Why
twistregex.Match(text)pattern.matcher(text).find()Java splits the work between a Pattern, which is compiled once, and a Matcher for each input Why
trapregex.IsMatch(text)matcher.find(), not matcher.matches()matches() succeeds only if the whole text matches, so it is not IsMatch Why
twistStringBuilder out of habita + b + cSince Java 9 the compiler builds the string once. Keep StringBuilder for loops. Why

vartwist#

C#
var lines = new List<OrderLine>();
var count = 0;
foreach (var line in lines) { count += line.Quantity; }
Java 25
var lines = new ArrayList<OrderLine>();
var count = 0;
for (var line : lines) { count += line.quantity(); }
Reading the code
var lines = new ArrayList<OrderLine>();

The compiler reads the value on the right and gives lines the type ArrayList<OrderLine>. After that the type cannot change, exactly as in C#. ArrayList does the same as List<T> in C#.

for (var line : lines)

var works in loops too. For a for-each loop, C# writes foreach and in. Java writes for and 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.

ContextC# varJava var
Local variableyesyes
for / foreach variableyesyes
Fieldnono
Method parameternono
Return typenono
Lambda parameterimplicityes, explicit var allowed
Without an initialisernono
With nullnono

var and the diamondtrap#

Predictvar with an empty diamond#
Does this compile?
import java.util.ArrayList;

var names = new ArrayList<>();
names.add("mug");
String first = names.get(0);
Show the answer
No: incompatible types: Object cannot be converted to String

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.

C#
var greeting = $"Hello {name}, your order {orderId} comes to {total:F2} EUR";
Java 25
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);
Reading the code
"Hello " + name + ", your order "

Joining with + turns orderId and total into text for you.

.formatted(name, orderId, total)

formatted fills in a template, like string.Format in C#. %s takes any value as text. %d takes a whole number. %.2f takes a decimal number and shows it with two decimal places. The values fill the placeholders in order.

String interpolation

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.

C#
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}";
Java 25
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);
Reading the code
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 calling isBlank() on null throws, so the null check comes first. To do the same as IsNullOrEmpty, call isEmpty() 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);
Reading the code
String.format("%,.2f", total)

String.format is the older form of the same template method. Every Java version has it. The comma in %,.2f adds thousands separators.

sb.append(line.productId()).append(',');

append returns the builder, so you can chain the calls. A StringBuilder in C# works the same way.

""".formatted(name, orderId);

A text block is an ordinary String, so formatted works on it too.

Comparing stringstrap#

Gotcha

== 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 null

typed.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 text

Text blockstwist#

C# raw string literal
var json = """
    {
      "orderId": 1042,
      "status": "PAID"
    }
    """;
Java 15+ text block
String json = """
    {
      "orderId": 1042,
      "status": "PAID"
    }
    """;
Reading the code
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:

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

A Java text block still processes escape sequences 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:

PredictA product code inside a note#
What does this print?
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
false

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:

C#
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");
Java 25
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");
Reading the code
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 Matcher applies the pattern to one piece of text. It remembers its position in that text, so you make a new Matcher for each input.

m.find()

find() searches for the pattern anywhere in the text, like Match followed by Success in C#.

m.group("area")

This call reads a named group, like Groups["area"].Value in C#.

results().toList()

This line gets every match, like Matches in 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:

C#
var sku = new Regex(@"sku-\d+",
    RegexOptions.IgnoreCase | RegexOptions.Compiled);

bool mentionsProduct = sku.IsMatch(note);
string[] pieces = sku.Split(note);  // the text between the codes
Java 25
Pattern 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 piece
Reading the code
Pattern.CASE_INSENSITIVE

Options are constants on Pattern. You pass them as the second argument. Java has no option like RegexOptions.Compiled, so leave it out.

sku.matcher(note).find()

This call does the same as IsMatch in C#. Use find(), because the similar-looking matches() 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, Split in .NET also returns an empty last piece. split in Java does not return that empty piece.

Here is the full mapping from Regex in .NET:

.NETJavaNote
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.IgnoreCasePattern.CASE_INSENSITIVEpass it as the second argument to Pattern.compile
RegexOptions.Compiledno equivalentJava has no such option, so leave it out when you translate
@"verbatim"no equivalentevery backslash must be doubled
Gotcha

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.

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#

Note

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#.

Check yourself
1
TranslateWrite this in Java. 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.

2
An 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?
The JVM reuses one object for identical string literals, so both sides are the same object. A string built while the program runs is a different object, even when it has the same text. A string read from a request is one example. For such a string, == is false. Compare strings with equals.

Enums#

Modern Java · Chapter 14· 6 min read

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.

KindC#JavaNote
twistenum OrderStatus { New = 1, Paid = 2 }enum OrderStatus { NEW, PAID }each constant is an object, not a named number Why
twist(Status) 2no equivalentJava has no cast from int, so index into values() only if you must Why
trap(int) ee.ordinal()ordinal() is the position in the declaration. Never store it, because a new constant shifts it. Why
new[Description] attributea field on the enumgive the enum a constructor argument, such as a label Why
newa switch, or a class for each optiona method body on each constanteach constant can supply its own version of a method Why
twistEnum.Parse<T>(s)Status.valueOf(s)matches the exact name, and throws IllegalArgumentException if unknown Why
twistEnum.TryParseno equivalentvalueOf throws IllegalArgumentException for an unknown name and NullPointerException for null, so catch both Why
twistEnum.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
twiste.ToString()e.name()name() is always the constant as declared, but toString() can be overridden Why
twistDictionary keyed by enumEnumMapa map backed by an array indexed by ordinal, so lookups are fast Why
twist[Flags]EnumSetJava has no bitwise enums, but an EnumSet is a bit vector inside Why
twistOrderStatus.New =>case NEW ->a case label is the bare constant, without the type name Why
twista singleton classenum ExchangeRates { INSTANCE; }a one-constant enum has exactly one object, but in a Spring application, use a bean Why

The shape of the differencetwist#

C#
public enum OrderStatus
{
    New = 1,
    Paid = 2,
    Shipped = 3,
    Cancelled = 4
}

var status = (OrderStatus) 2;          // Paid
int number = (int) OrderStatus.Paid;   // 2
Java 25
public enum OrderStatus {
    NEW, PAID, SHIPPED, CANCELLED
}

OrderStatus status = OrderStatus.valueOf("PAID");
int position = OrderStatus.PAID.ordinal();  // 1, but see the next section
Reading the code
NEW, PAID, SHIPPED, CANCELLED

These 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.Parse in 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#

PredictAn order status stored as a number#
What does this print?
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
ON_HOLD

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"
Reading the code
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 50
Reading the code
public 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 price for standard delivery.

total.compareTo(new BigDecimal("50")) >= 0

BigDecimal has no >= operator. compareTo returns a negative number, zero or a positive number. So >= 0 means 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:

C#
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>();
Java 25
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);
Reading the code
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 that valueOf throws for an unknown name and for null. The | catches either type.

OrderStatus.values()

This call returns every constant in declaration order, like Enum.GetValues in 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.class is the type itself as a value, like typeof(OrderStatus) in C#.

Flags become an EnumSettwist#

[Flags] enums

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:

C#
[Flags]
enum Handling { None = 0, GiftWrap = 1, Express = 2, Fragile = 4 }

var handling = Handling.GiftWrap | Handling.Fragile;
if (handling.HasFlag(Handling.Fragile)) { }
Java 25
import java.util.EnumSet;

enum Handling { GIFT_WRAP, EXPRESS, FRAGILE }

var handling = EnumSet.of(Handling.GIFT_WRAP, Handling.FRAGILE);
if (handling.contains(Handling.FRAGILE)) { }
Reading the code
EnumSet.of(Handling.GIFT_WRAP, Handling.FRAGILE)

This is a set that holds two constants. C# combines two flags with |. The empty set is EnumSet.noneOf(Handling.class). It does the same as None in C#.

handling.contains(Handling.FRAGILE)

This call does the same as HasFlag in 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.

Note

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"));
Reading the code
INSTANCE;

This is the only constant, so there is exactly one ExchangeRates object in the program.

new ConcurrentHashMap<>()

This is a map that several threads can use at the same time, like ConcurrentDictionary in 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.

Check yourself
1
TranslateWrite this C# line in Java: 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.

2
An expert asksWhy can a Java enum constant hold a code and a label, when a C# enum member cannot?
A C# enum member is a name for a number. A Java enum is a class, and each constant is one object of that class. So the enum can have final fields, a private constructor and methods.

Generics and type erasure#

Modern Java · Chapter 15· 9 min read

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.

KindC#JavaNote
twistwhere T : Base<T extends Base>classes and interfaces both use extends Why
twistwhere T : IFoo, IBar<T extends Foo & Bar>Join bounds with &. If one bound is a class, it must come first. Why
twistwhere T : classno equivalentevery type argument is a reference type, so Java needs no such constraint Why
twistwhere T : structno equivalentJava has no user-defined value types to constrain to Why
twistwhere T : unmanagedno equivalentJava has no unmanaged or pointer types Why
trapList<string> and List<int> are two typesone class at run timeJava erases type arguments when it compiles, so a running program cannot see them Why
twistwhere T : new()no equivalentJava has no new T(), so pass a Supplier<T> such as Customer::new instead Why
twistx is List<string>x instanceof List<?>a list does not know its element type at run time, so only List<?> can be tested Why
twistdefault(T)nulla type parameter is always a reference type, so null is its only default Why
twisttypeof(T)a Class<T> parameterthe caller passes Customer.class, because T is erased Why
twistIEnumerable<out T>List<? extends T>variance is written at each use, with a wildcard Why
twistIComparer<in T>Comparator<? super T>for the consumer side, C# declares in and Java writes ? super Why
twistList<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
twistDictionary<int,V>Map<Integer,V>every int key is boxed into an Integer object Why
twistIEnumerable<int>IntStreama stream of plain ints, so nothing is boxed Why
twistNullable<int> / int?Integeran Integer can be null, so it does the same as int? in C# Why
twistJsonSerializer.Deserialize<List<Customer>>(json)new TypeReference<List<Customer>>() { }an anonymous subclass keeps the full generic type, so Jackson can read it back Why
twistthe non-generic ArrayList of System.CollectionsList raw = new ArrayList();A raw type disables type checking. You will see it in old code, but never write it. Why

Declarationtwist#

C#
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);
}
Java 25
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); }
}
Reading the code
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# writes where T : IHasId after the class. Java uses extends for interfaces too.

Map<Long, T>

The key type is Long, not long, 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 call id() on any T.

return items.get(id);

get returns null when the id is missing, like GetValueOrDefault in 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.

PredictA list of strings and a list of numbers#
What does this print?
List<String> names = new ArrayList<>();
List<Integer> quantities = new ArrayList<>();
System.out.println(names.getClass() == quantities.getClass());
Show the answer
true

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:

Gotcha

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
}
Reading the code
T t = new T();

C# allows this with a new() constraint. Java has no T at run time, so it has nothing to create.

x instanceof List<String>

At run time a List<String> is only a List, so Java cannot make this test. x instanceof List<?> compiles, because it only asks whether x is a list.

static int count;

A static field belongs to the class, and after erasure there is only one class. In C#, Box<int> and Box<string> each have their own count.

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 check

Java 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:

C#
// typeof(T) is available here: T exists at run time
T Read<T>(string json) =>
    JsonSerializer.Deserialize<T>(json)!;

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

var customer = read(json, Customer.class);
Reading the code
Class<T> type

The caller passes the type as a value. Class<T> is like Type in C#, but Class<T> has a type argument. So the compiler knows that the method returns a T.

read(json, Customer.class)

Customer.class is the type as a value, like typeof(Customer) in C#.

mapper.readValue(json, type)

Spring Boot 3 uses Jackson 2. With Jackson 2, readValue also declares a checked exception, so this method must add throws JsonProcessingException. Spring Boot 4 uses Jackson 3. With Jackson 3, readValue does not declare a checked exception.

Note

This is why these methods all take a .class value:

  • getBean(OrderService.class) in Spring
  • readValue(json, Customer.class) in Jackson
  • mock(OrderRepository.class) in Mockito

Variance is at the use sitetwist#

C#
// 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>
Java 25
// 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>
Reading the code
List<? extends Payment> payments

? extends Payment is called a wildcard. With the wildcard, the parameter accepts a list of Payment, or a list of any type that implements Payment. Reading from the list gives you a Payment. Adding to the list is not allowed, because the list might really be a List<Card>. A Voucher does not belong in a List<Card>.

printAll(cards);

Without the wildcard, printAll(List<Payment>) would reject a List<Card>. A Java List is not converted automatically. The same is true for a List<T> in C#.

Note

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 cards
Reading the code
List<? super Card> target

This parameter accepts a list of Card, or a list of any type above Card. Two such types are Payment and Object. You can add a Card. But reading gives you only an Object, because the list might hold other payments too.

Here are the four forms that a list type can have:

WildcardMeansYou canMnemonic
List<String>exactly String, so a List<Object> is not acceptedread and write Stringinvariant
List<? extends Number>a list of Number or of any subtype, such as Integerread as Number, cannot addproducer
List<? super Integer>a list of Integer or of any supertype, such as Numberadd Integer, read as Objectconsumer
List<?>a list of something, so you only know that each element is an Objectread as Object, cannot addany

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 boxed
Reading the code
quantities.add(42);

42 is an int, so Java wraps it in an Integer before storing it. C# boxes a value in the same way when it stores the value as an object.

IntStream.of(raw).sum()

IntStream is a stream built for int, 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#.

Gotcha

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>>() { });
Reading the code
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 RestClient of Spring. Customer.class alone would lose the List around Customer.

Gotcha

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 runs
Reading the code
List 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 throws ClassCastException.

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.

Check yourself
1
TranslateWrite 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") });
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.

2
An expert asksWhy does new T() not compile in Java, when C# allows it with a new() constraint?
Java checks type arguments when it compiles and then removes them. This is called type erasure. While the program runs there is no T, so there is nothing to create. Pass a Class<T>, or a Supplier<T> such as Customer::new.
3
A colleague new to Java declares List<int> quantities. Why does it not compile, and what do you write?
A type argument cannot be a primitive type. The compiler reports 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#

Modern Java · Chapter 16· 8 min read

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.

KindC#JavaNote
twist[Attr]@Attran @ prefix instead of square brackets Why
same[Attr] on its own line@Attr on its own linestacked 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]@Deprecatedpair it with a @deprecated javadoc tag that names the replacement Why
twistnameof(x)no equivalentwrite the name as a string, and use IntelliJ's rename or a test to keep it correct Why
newno equivalent@Retentionit says how long the annotation survives, and only RUNTIME is visible to reflection Why
trapan 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
twistclass AuditedAttribute : Attributepublic @interface Auditedan annotation type is declared with @interface, and its members look like methods Why
twistAttributeUsage@Targetit says which declarations the annotation may be attached to Why
twistAllowMultiple = true@Repeatableadded in Java 8, and it needs a second annotation that is the container Why
twisteveryday attributes, such as [Obsolete]@Override, @Deprecated, @SuppressWarningsa few annotations appear in almost every Java codebase Why
twist[Conditional]no equivalentJava has no way to remove calls at compile time Why
trapa misspelt override is a compile error (CS0115)a misspelt method without @Override compilesIt is a new method that overrides nothing, and no error says so. Always write @Override. Why
twista C# source generatoran annotation processora javac plugin that reads annotations and writes new source files as you compile Why
twistGetCustomAttribute<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:

C#
[Route("/orders")]
public class OrderController : ControllerBase
{
    [HttpGet("{id}")]
    public Order Get(long id, [FromQuery] bool withLines) => orders.Find(id);
}
Java 25
@RestController
@RequestMapping("/orders")
public class OrderController {

    @GetMapping("/{id}")
    public Order get(@PathVariable long id, @RequestParam boolean withLines) {
        return orders.find(id);
    }
}
Reading the code
@RestController

This 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 withLines

Annotations go on parameters too. This annotation reads withLines from the query string, like [FromQuery] in C#.

Named argumentstwist#

C#
[Obsolete("Use FindById instead", DiagnosticId = "SHOP001")]
public Order Find(long id) => FindById(id);
Java 25
/** @deprecated Use {@link #findById(long)} instead. */
@Deprecated(since = "2.0", forRemoval = true)
public Order find(long id) { return findById(id); }
Reading the code
@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 called value. 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 @deprecated tag 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 it

Java 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.

PredictAn annotation of your own, read while the program runs#
What does this print?
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
null

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:

RetentionSurvives toUsed for
SOURCEcompiler only@Override, @SuppressWarnings, Lombok
CLASSthe .class file, not reflectionbytecode tools, and it is the default
RUNTIMEreflectionSpring, 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:

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

@Target is like AttributeUsage in C#. Here it says that the annotation may only go on methods.

public @interface Audited {

@interface declares 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";

default gives a member a value. The annotation uses this value when you do not set the member.

Note

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:

C#
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class TagAttribute(string name) : Attribute { }

[Tag("billing"), Tag("slow")]
void Run() { }
Java 25
@Repeatable(Tags.class)
public @interface Tag { String value(); }

public @interface Tags { Tag[] value(); }  // the container

@Tag("billing") @Tag("slow")
void run() { }
Reading the code
@Repeatable(Tags.class)

This line allows @Tag more 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 value is an array of Tag. 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.

AnnotationMeaning
@Overrideasserts that the method overrides a supertype method, so the compiler catches typos
@Deprecateddoes the same as [Obsolete], and you pair it with a @deprecated tag in the Javadoc
@SuppressWarnings("unchecked")silences a specific compiler warning
@FunctionalInterfaceasserts that the interface has exactly one abstract method
@SafeVarargsasserts that a generic varargs method does not leak the array
@Nullable / @NonNullthey say whether a value may be null, as the chapter on Optional and JSpecify explains
@Entity, @Columnthese JPA annotations map a class to a table, and a field to a column
@Test, @ParameterizedTestthese JUnit annotations mark a test method, or a test that runs once per set of inputs
@Service, @Component, @Beanthese Spring annotations mark a class that is a bean, or a method that makes a bean
@JsonProperty, @JsonIgnorethese 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
}
Reading the code
@FunctionalInterface

This 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 Object to List<String> cannot be checked while the program runs, so the compiler warns. This annotation silences that one warning, for this one method.

@Override

This annotation asks the compiler to check that toString really overrides a method from a supertype. Here the supertype is Object.

Always write @Overridetrap#

Gotcha

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
}
Reading the code
@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 Order to the field of OrderDto that has the same name.

Each .NET way of producing code has a Java counterpart:

.NETJavaDoes
Source generatorsAnnotation processors (APT)generate code at compile time
Roslyn analyzersError Prone, annotation processorsreport errors at compile time
IL weaving (Fody)bytecode manipulation (ByteBuddy)rewrite after compilation
Reflection at startupreflection, or APTframeworks increasingly prefer APT

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:

ToolGeneratesReplaces in .NET
Lombokgetters, setters, builders, equalsboilerplate, or a source generator
MapStructtype-to-type mappersAutoMapper, but at compile time
Micronaut / QuarkusDI wiring, no runtime reflectioncompile-time DI
JPA metamodeltyped criteria query classesEF Core's typed queries
Immutablesimmutable value classesrecords
Note

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:

C#
var attr = typeof(OrderService)
    .GetMethod("Cancel")!
    .GetCustomAttribute<AuditedAttribute>();
if (attr is not null) Log(attr.Action);
Java 25
Method cancel = OrderService.class.getMethod("cancel", long.class);
Audited a = cancel.getAnnotation(Audited.class);
if (a != null) log(a.action());
Reading the code
OrderService.class.getMethod("cancel", long.class)

This call finds the public method cancel that takes a long. Java needs the parameter types, because several methods can share a name.

cancel.getAnnotation(Audited.class)

This call is like GetCustomAttribute in C#. It returns null when the method has no @Audited. It also returns null when the retention of the annotation is not RUNTIME.

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.

Check yourself
1
TranslateWrite this in Java:
[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.

2
An expert asksWhat does @Override actually do, given it is only an annotation?
It makes the compiler check that the method really does override something. Without @Override, a method with a slightly wrong signature becomes a new method, and the compiler reports no error.

Nullability: Optional and JSpecify#

Nullability · Chapter 17· 6 min read

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.

KindC#JavaNote
twiststring? nameString namethe Java language has no way to say that a value may be null Why
twiststring name (non-null)String namethe same declaration, but nothing enforces non-null in Java Why
twistname!.Lengthname.length()Java has no ! operator, so the call simply throws if name is null Why
twistname?.Lengthname == null ? null : name.length()Java has no ?. operator, so chain with Optional.map instead Why
twista ?? ba != null ? a : bObjects.requireNonNullElse(a, b) also works, but it throws when a and b are both null Why
twista ??= bif (a == null) a = b;Java has no ??= operator, so write the if statement in full Why
newcustomer?.Name ?? "unknown"find(id).map(Customer::name).orElse("unknown")return Optional from a method that may find nothing Why
trapa nullable field or parametera plain type, never OptionalOptional is for return values only, so do not use it for fields, parameters or collections Why
trapfound ?? LoadGuest()found.orElseGet(() -> loadGuest())orElse(loadGuest()) calls loadGuest even when a value is present Why
new<Nullable>enable</Nullable>@NullMarked on the packageeverything in the package is non-null unless it is marked @Nullable Why
newNullable reference types warningsJSpecify + NullAway or IntelliJyou opt in with annotations, but only a checker tool turns them into errors Why
twistArgumentNullException.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:

C#
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 null
Java 25
String 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";
Reading the code
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. If name is null, this line throws NullPointerException.

name == null ? null : name.length()

Java has no ?. operator, so you write the null check yourself. The result is an Integer, not an int, because an int cannot 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 throws NullPointerException when 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 check

Optional 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

C#
Customer? Find(long id);

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

String name = find(7)
        .map(Customer::name)
        .orElse("unknown");
Reading the code
.map(Customer::name)

If a customer was found, map applies 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::name is a method reference. It is a short way of writing the lambda c -> c.name().

.orElse("unknown")

This call opens the box. It returns the name if there is one, and otherwise "unknown". orElse is like ?? in C#, but Java always calculates the argument of orElse. See the section Where Optional goes wrong.

Here are the other methods of Optional, each with the nearest C# form:

Optional methodDoesC# analogy
Optional.of(x)wraps, throws if x is nulln/a
Optional.ofNullable(x)wraps, empty if nulln/a
Optional.empty()absentnull
.map(f)transform if present?.
.flatMap(f)transform returning Optional?. returning nullable
.filter(p)keep if it matchesn/a
.orElse(v)value or default??
.orElseGet(sup)value or lazily computed default?? with a factory
.orElseThrow()value or NoSuchElementException?? throw
.ifPresent(c)run if presentif (x is not null)
.isPresent() / .isEmpty()testis not null / is null
.stream()0 or 1 element streamn/a, and Java 9 added this method

Where Optional goes wrongtrap#

Gotcha

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 Optional cannot be serialised, and it adds an extra object to every instance.
  • Method parameters. Callers have to wrap their argument, and they can still pass null anyway. 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.

PredictorElse with a method call as its default#
What does this print?
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
reading the database Ada

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 found

JSpecify: 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:

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

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:

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

This call throws NullPointerException with the message customer if customer is null. Otherwise it returns customer, so the check and the assignment fit in one statement. The second argument names the parameter. In C#, ThrowIfNull adds the name of the parameter for you.

Note

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.

Check yourself
1
TranslateWrite 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";
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.

2
An expert asksWhy is Optional the wrong type for a field, a method parameter or a list?
The designers of 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#

Collections and streams · Chapter 18· 8 min read· Quick reference

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.

KindC#JavaNote
twistIEnumerable<T>Iterable<E>an Iterable can be iterated more than once Why
twistIEnumerator<T>Iterator<E>an Iterator is a one-pass cursor like IEnumerator, but it can also remove() Why
twistList<T>ArrayList<E>ArrayList is the everyday list, and it is backed by an array, like List<T> Why
twistDictionary<K,V>HashMap<K,V>no guaranteed iteration order, so use LinkedHashMap to keep insertion order Why
twistIDictionary<K,V>Map<K,V>A Map is not a Collection. Iterate entrySet(), keySet() or values(). Why
twistQueue<T>ArrayDeque<E>Use offer() and poll(). ArrayDeque is the fast general-purpose queue. Why
twistStack<T>ArrayDeque<E>use push() and pop(), and avoid the legacy java.util.Stack class Why
twistSortedDictionary<K,V>TreeMap<K,V>it is kept sorted by key, like SortedDictionary, so firstKey() is the smallest key Why
twistConcurrentDictionary<K,V>ConcurrentHashMap<K,V>it is a thread-safe map, and its computeIfAbsent does the same as GetOrAdd Why
twistKeyValuePair<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
twistList<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
twistImmutableList<T>List.of(...)it makes an unmodifiable list that rejects nulls, and it was added in Java 9 Why
trapnew List<string> { "mug" }, then AddList.of("mug"), then addList.of and Map.of return collections that throw on any change Why
twisttags.RemoveAll(...)tags.removeIf(String::isBlank)removing inside a for loop over the list fails in both languages Why
twistList<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
twistlist[0]list.getFirst()available from Java 21, and before that you write list.get(0) Why
twistlist[^1]list.getLast()available from Java 21, and before that you write list.get(list.size() - 1) Why
traplist.Reverse()list.reversed()it returns a reversed view, but Collections.reverse reverses the list itself Why
traplist[1..3]list.subList(1, 3)it returns a view of the same list, but the C# range makes a copy Why
twistthe non-generic System.Collections types, such as HashtableVector, Hashtable, Stack, Enumerationthese 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 values

A 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<E> IEnumerable<T> Collection<E> ICollection<T> List<E> IList<T> Set<E> ISet<T> Queue<E>, Deque<E> Queue<T>, Stack<T> not a Collection Map<K,V> IDictionary<K,V> ArrayList<E> List<T> LinkedList<E> LinkedList<T> HashSet<E> HashSet<T> LinkedHashSet<E> no .NET equivalent TreeSet<E> SortedSet<T> ArrayDeque<E> Queue<T> and Stack<T> HashMap<K,V> Dictionary<K,V> LinkedHashMap<K,V> OrderedDictionary<K,V> TreeMap<K,V> SortedDictionary<K,V>
The collection interfaces and their main implementations, with the .NET type beneath each. Iterable is IEnumerable, ArrayList is List, HashMap is Dictionary, and Map is not a Collection.

Iterable or Iteratortwist#

Gotcha

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:

C#
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);
Java 25
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:

C#
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);
Java 25
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);
Reading the code
Deque<Long> queue = new ArrayDeque<>();

Queue and Deque are interfaces. The class that you create is ArrayDeque. It is a double-ended queue. In Java it replaces both Queue<T> and Stack<T> from .NET.

queue.offer(1042L); var next = queue.poll();

offer adds to the back, like Enqueue in C#. poll takes from the front, like Dequeue in C#. On an empty queue, Dequeue throws, but poll returns null.

prices.firstEntry()

A TreeMap keeps its keys sorted, like SortedDictionary in C#. So its first entry has the smallest key, which is cup. Map.Entry does the same as KeyValuePair in C#.

cache.computeIfAbsent("mug", k -> 12);

computeIfAbsent does the same as GetOrAdd in C#. It runs the lambda only when the key is missing, and it stores the result. A plain get returns null for 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 position

Creating collectionstrap#

PredictAdding to a list made with List.of#
What happens on the second line?
var ids = List.of("mug", "cup");
ids.add("pot");
System.out.println(ids.size());
Show the answer
It throws UnsupportedOperationException.

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:

C#
// 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");
Java 25
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");  // fixed
Reading the code
new ArrayList<>(List.of("mug", "cup"))

List.of makes a fixed list. new ArrayList<>(...) copies the fixed list into a list that you can add to. In Java, this is the closest match to new 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:

Gotcha

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();
Reading the code
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# throws InvalidOperationException for the same mistake.

tags.removeIf(String::isBlank);

This one call removes every element for which the test is true. String::isBlank is a method reference. It is short for tag -> 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:

PredictClearing part of a list#
What does this print?
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
[mug, tray]

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)).

Gotcha

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#

Note

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<>();    // avoid

When 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 added
Reading the code
ids.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 LinkedHashMap remembers the order in which keys were added. So its first entry is the entry that was added first.

Note

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:

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

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 locking
Reading the code
Vector<String> old = new Vector<>();

Every call locks the vector, even when only one thread ever uses it.

Stack<Integer> oldStack = new Stack<>();

Stack extends Vector, so it locks too. A loop over a Stack visits the bottom of the stack first.

Check yourself
1
TranslateWrite this in Java:
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.

2
An expert asksWhy does Java code declare a List<String> and not an ArrayList<String>?
The convention is to declare variables, fields, parameters and return types with the interface. The class is named only where the object is created. You can then change to another implementation by changing one line.

Quick reference#

.NETJavaNote
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/aLinkedHashSet<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.unmodifiableLista 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#

Collections and streams · Chapter 19· 7 min read

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.

KindC#JavaNote
trapa == b on stringsa.equals(b)in Java, == compares objects, so strings, dates and every other object need equals Why
trapa == b, either may be nullObjects.equals(a, b)two nulls count as equal, and nothing throws Why
twistReferenceEquals(a, b)a == bthis is the only meaning that == has for objects in Java Why
trapa == b on two int or int? valuesa.equals(b) on two Integer valuesan Integer from -128 to 127 is a shared object, so == seems to work on small values Why
twistGetHashCode()hashCode()Override it together with equals, as in C#. By default javac does not warn when you forget. Why
samechanging a key while it is in a HashSetthe set can no longer find itThe bucket came from the old hash code, as in C#. So keep the fields that hashCode uses final.
twistHashCode.Combine(x, y)Objects.hash(x, y)hash the same fields that equals compares Why
twistIEquatable<T>equals(Object)there is no typed version, so check the type with instanceof Why
trapAmount == o.Amountamount.compareTo(m.amount) == 0BigDecimal.equals also compares the number of decimal places Why
twistIComparable<T>Comparable<T>it gives a type its natural order, through the method compareTo Why
twistOrderBy(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 null

Objects.equals counts two nulls as equal. Objects is in the package java.util.

CompareC#Java
Value equalitya == b, a.Equals(b)a.equals(b)
Value equality, null-safea == bObjects.equals(a, b)
Reference identityReferenceEquals(a, b)a == b
Primitivesa == ba == b
Orderinga.CompareTo(b)a.compareTo(b)
Custom orderingIComparer<T>Comparator<T>
Gotcha

The boxed-integer cache. 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#

PredictA map key that overrides equals only#
What does this print?
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
null

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:

RuleMeaning
Reflexivean object always equals itself, so a.equals(a) is true
Symmetricif a.equals(b) is true, then b.equals(a) is true
Transitiveif a equals b and b equals c, then a equals c
Consistentrepeated calls give the same result
Nulla.equals(null) is false, and it never throws
hashCode agreementif 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:

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

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

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

    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);
    }
}
Reading the code
if (!(o instanceof Money m)) return false;

equals takes an Object. So this line first checks that o is a Money, and gives it the name m. The line also returns false when o is null.

amount.compareTo(m.amount) == 0

This line compares the amounts by value. On a BigDecimal, equals also compares the number of decimal places. This is a trap.

Objects.hash(amount.stripTrailingZeros(), currency)

Objects.hash does the same as HashCode.Combine in 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
Note

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#

Gotcha

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 match

That 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:

C#
orders.Sort((a, b) => a.Id.CompareTo(b.Id));

var sorted = orders
    .OrderBy(o => o.Status)
    .ThenByDescending(o => o.PlacedAt)
    .ToList();
Java 25
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();
Reading the code
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. comparingLong avoids boxing each long. comparingInt does the same for an int.

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 ThenByDescending in C#.

The rest of the LINQ ordering methods map to Java like this:

LINQJava Comparator
OrderBy(f)Comparator.comparing(f)
OrderByDescending(f)Comparator.comparing(f).reversed()
ThenBy(f).thenComparing(f)
ThenByDescending(f).thenComparing(f, Comparator.reverseOrder())
key is intComparator.comparingInt(f), avoids boxing
nullsComparator.nullsFirst(cmp) / nullsLast(cmp)

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:

C#
public record Version(int Major, int Minor) : IComparable<Version>
{
    public int CompareTo(Version? o) =>
        (Major, Minor).CompareTo((o!.Major, o.Minor));
}
Java 25
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);
    }
}
Reading the code
implements Comparable<Version>

This line gives Version a natural order. Collections.sort, TreeSet and TreeMap use 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.

Gotcha

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.

Check yourself
1
TranslateWrite 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);
@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.

2
An 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?
The set chose the customer's bucket from the hash code that the customer had when it was added. The new email gives a new hash code. So the lookup searches in the wrong bucket, although the customer is still in the set. Keep the fields that equals and hashCode use final, or use a record.

Streams vs LINQ#

Collections and streams · Chapter 20· 9 min read· Quick reference

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.

KindC#JavaNote
twistcustomers.Where(...).Select(...).ToList()customers.stream().filter(...).map(...).toList()a list is not a stream, so you ask it for one first with stream() Why
trapnames.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
twistorders.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
trapenumerating one query twicecollect once, then reuse the lista stream can be used only once, and a second use throws IllegalStateException Why
twistorders.GroupBy(o => o.Status)orders.stream().collect(groupingBy(Order::status))collect finishes a stream, and the Collectors class holds grouping, joining and counting Why
twistorders.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
traporders.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
traporders.AsParallel()orders.parallelStream()every parallel stream shares one pool of threads, so never use it for I/O Why
twistproductIds.Chunk(100)productIds.stream().gather(Gatherers.windowFixed(100))a gatherer is an intermediate operation of your own, from Java 24 Why
twistlines.Sum(l => l.Quantity)lines.stream().mapToInt(OrderLine::quantity).sum()IntStream, LongStream and DoubleStream hold primitives without boxing them Why
trapEnumerable.Range(0, 10)IntStream.range(0, 10)the second argument is where to stop, not how many to produce Why
twistdb.Customers.Where(c => c.Age > 18)a JPQL string, the Criteria API or jOOQJava has no expression trees, so no library can turn a lambda into SQL Why
twistyield return nStream.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>:

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

A list is not a stream, so you first ask the list for a stream. C# lets you call Where on the list itself.

.filter(c -> c.name().endsWith("Doe"))

Where becomes filter. C# writes the lambda with =>. Java writes it with ->.

.map(Customer::name)

Select becomes map. Customer::name is called a method reference. It is short for c -> c.name().

.sorted()

This is OrderBy with the natural order. For strings, the natural order in Java is not the order that C# uses. See Sorting strings. Pass a Comparator to 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#

Gotcha

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:

C#
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);
Java 25
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();
Reading the code
.flatMap(o -> o.lines().stream())

SelectMany becomes flatMap. The lambda of flatMap must return a stream. So the lambda turns each order's list of lines into a stream.

.skip(10).limit(5)

Skip becomes skip. Take becomes limit.

.anyMatch(o -> o.lines().isEmpty());

Any with a test becomes anyMatch.

findFirst().orElse(null)

findFirst returns an Optional. Adding orElse(null) gives the behaviour of FirstOrDefault.

.mapToInt(o -> o.lines().size()).sum();

sum is a method of IntStream, which is a stream of plain int values. So you map to int first.

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 threads

Reading a stream twicetrap#

PredictOne stream, read twice#
What happens on the last two lines?
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
The second toList() throws IllegalStateException: stream has already been operated upon or closed

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:

C#
var byStatus = orders
    .GroupBy(o => o.Status)
    .ToDictionary(g => g.Key, g => g.Count());

var csv = string.Join(", ", names);
Java 25
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)
Reading the code
import static java.util.stream.Collectors.*;

This line imports the static methods of Collectors, so groupingBy and counting need 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.Join in C#.

These are the collectors you will use most:

CollectorProduces
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#

Gotcha

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 null

Remove 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#

Gotcha

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();
Reading the code
urls.parallelStream().filter(this::isReachable)

Each isReachable call 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#

Gotcha

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, 8

For 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();
Reading the code
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.

Note

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();
Reading the code
.mapToInt(OrderLine::quantity)

This step turns a stream of order lines into a stream of plain int values.

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 int in an Integer, so that the numbers can go into a List<Integer>.

NeedMethod
Stream to IntStreammapToInt / mapToLong / mapToDouble
IntStream to Streamboxed(), or mapToObj(f)
A rangeIntStream.range(a, b) or rangeClosed(a, b)
StatisticssummaryStatistics() 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] > 18
Expression trees / IQueryable

C# can inspect a lambda as data and translate it. That is how EF Core turns Where(c => c.Age > 18) into SQL. Java lambdas compile 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:

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

jOOQ is the 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 ... 512
yield return

Java 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 ... 1009
Reading the code
Stream.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, like Take(10) on an endless yield 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 for loop.

Check yourself
1
TranslateWrite 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));
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.

2
An expert asksWhy can no Java library turn a lambda into SQL, as EF Core does?
C# can give a lambda to a library as an expression tree, which is data that the library can read. A Java lambda compiles directly to bytecode and cannot be inspected. So queries are written as JPQL strings, with the Criteria API, or with jOOQ.
3
An expert asksWhy should a parallel stream never call another service or a database?
Every parallel stream runs on one pool of threads that the whole program shares. This pool is called the common 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#

LINQStreamNote
Wherefilter
Selectmap
SelectManyflatMap
OrderBysorted(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)
TakeWhiletakeWhileadded in Java 9, and it takes items from the start while the test holds
SkipWhiledropWhileadded in Java 9 with the name dropWhile, and it skips items while the test holds
Distinctdistinct()compares with equals and hashCode, so implement both
Reverseno direct equala stream cannot reverse, so collect to a list and then call reversed()
ConcatStream.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 equalcollect 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 / Maxmin(cmp) / max(cmp)return an Optional, empty for an empty stream, instead of throwing
Aggregatereducereduce(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[]
ToDictionarycollect(toMap(k, v))throws on a duplicate key, and on a null value
GroupBycollect(groupingBy(f))gives a Map of lists, but a second collector can count each group instead
Zipno equivalentuse 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
DefaultIfEmptyno equivalentJava 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#

Collections and streams · Chapter 21· 7 min read· a look-up chapter: Essentials shows its table, and each section opens· Quick reference

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.

KindC#JavaNote
twistFile.ReadAllTextFiles.readString(p)added in Java 11, and it reads UTF-8 unless you pass a charset Why
twistFile.ReadAllLinesFiles.readAllLines(p)reads the whole file into memory, so use Files.lines for big files Why
twistFile.WriteAllTextFiles.writeString(p, s)added in Java 11, and it writes UTF-8 and replaces the file Why
twistFile.AppendAllTextFiles.writeString(p, s, APPEND)pass StandardOpenOption.APPEND to add to the end Why
sameFile.ExistsFiles.exists(p)the same check, on a Path in place of a string
trapFile.DeleteFiles.delete(p) / deleteIfExists(p)delete throws NoSuchFileException if the file is missing Why
twistFile.Copy / MoveFiles.copy / Files.movepass REPLACE_EXISTING to overwrite Why
twistDirectory.CreateDirectoryFiles.createDirectories(p)creates any missing parent folders too, like CreateDirectory Why
twistPath.Combine("a","b")Path.of("a","b")or dir.resolve("b") to add one part to an existing Path Why
twistPath.GetFileNamep.getFileName()returns a Path, so call toString() for the name as text Why
twistPath.GetExtensionno equivalentJava has no method for this, so take the text after the last dot yourself Why
twistPath.GetTempFileNameFiles.createTempFile(prefix, suffix)creates an empty temporary file and returns its Path Why
trapFile.ReadLines (lazy)Files.lines(p)returns a Stream, so it must be closed Why
trapDirectory.GetFilesFiles.list(dir).filter(Files::isRegularFile)Files.list returns the sub-folders of the folder too, in a Stream that must be closed Why
trapDirectory.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
trapturning bytes into textnew String(bytes, StandardCharsets.UTF_8)always pass a charset, because before Java 18 the default was the charset of the machine Why
twistFileStreamFiles.newInputStream / newOutputStreamthese are byte streams, so wrap them in a Buffered stream for speed Why
twistStreamReaderFiles.newBufferedReader(p)a buffered text reader, UTF-8 unless you say otherwise Why
trapasm.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:

C#
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);
Java 25
Path p = Path.of("data", "orders.csv");

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

boolean there = Files.exists(p);
Files.deleteIfExists(p);
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
Files.createDirectories(dir);
Reading the code
Path p = Path.of("data", "orders.csv");

A Path joins its parts with the right separator for the operating system, like Path.Combine in .NET. A Path is an object, not a string.

Files.readString(p)

This call reads the whole file as UTF-8 text. It exists from Java 11. readAllLines gives a list of lines instead.

StandardOpenOption.APPEND

writeString replaces the file unless you pass this option. With this option, writeString adds to the end of the file, like AppendAllText.

Files.deleteIfExists(p);

Files.delete throws NoSuchFileException if the file is missing. In C#, File.Delete does nothing if the file is missing, and reports no error. deleteIfExists behaves like the C# method.

StandardCopyOption.REPLACE_EXISTING

Without this option, copy throws if the target already exists.

Names, lazy reading and folder listingstwist#

C#
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);
Java 25
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)) { }
Reading the code
p.getFileName().toString()

getFileName returns a Path. toString turns that Path into text, here orders.csv.

dot < 0 ? "" : name.substring(dot)

Java has no method like GetExtension. So the code takes the text from the last dot, which is .csv here. 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 using in C#. Whatever is declared in the brackets is closed when the block ends. Files.lines reads lazily, like File.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.GetFiles returns only files. Add .filter(Files::isRegularFile) to get the same result as Directory.GetFiles.

Files.walk(Path.of("data"))

This call gives every file and folder below data, and data itself. EnumerateFiles returns 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#

Gotcha

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();
}
Reading the code
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, safe
Reading the code
new 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.

Note

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.

KindJava typeFor
Bytes inInputStreambinary reading
Bytes outOutputStreambinary writing
Characters inReadertext reading
Characters outWritertext writing
BufferingBufferedInputStream / BufferedReaderthese wrap the types above, and they always help
BridgingInputStreamReader(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
}
Reading the code
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 a BufferedReader, which reads ahead in large blocks. In .NET, a StreamReader does 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 CopyTo in C#. It was added in Java 9.

Gotcha

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());
}
Reading the code
.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, walk does 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 .csv or .tsv.

.NETJava
Directory.EnumerateFiles(dir, "*.csv")Files.newDirectoryStream(dir, "*.csv")
Directory.EnumerateFiles(dir, "*", AllDirectories)Files.walk(dir).filter(Files::isRegularFile)
Directory.EnumerateDirectoriesFiles.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:

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

This call finds the resource on the classpath and opens it as a stream of bytes. It returns null if there is no such resource.

in.readAllBytes()

This call reads every byte. Then new String(..., UTF_8) turns the bytes into text.

Note

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.

Check yourself
1
Files.lines(p).filter(...).count(). What is the bug?
The stream holds an open file handle, and nothing closes the stream. The stream must be opened in a try-with-resources block. The bug appears as “too many open files” under load.

Quick reference#

System.IOjava.nio.fileNote
MemoryStreamByteArrayInputStream / ByteArrayOutputStreamin-memory byte streams, one class for each direction
FileSystemWatcherWatchServiceWatchService is much lower level than FileSystemWatcher in .NET

Threads are cheap now#

Concurrency · Chapter 22· 13 min read

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.

KindC#JavaNote
newasync Task<T> BuildAsync()T build()Java has no async or await, so write a blocking method and run it on a virtual thread Why
newawait SomeIoAsync()someIo()just call the method, because blocking is fine on a virtual thread Why
newBuildAsync(7).Result, blocking on an async methodbuild(7)Blocking is the normal style. Java has no Task<T> in signatures, no ConfigureAwait and no context to deadlock on. Why
newno equivalenta virtual threada Thread whose stack is on the heap, so it starts at a few hundred bytes, not at about 1 MB Why
twistTask.Run(() => f())exec.submit(() -> f())with a virtual-thread executor, which starts a new thread for every task Why
twistTask.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
twistTask.Delay(d)Thread.sleep(d)blocks only the virtual thread, so sleeping is cheap Why
twistCancellationTokenThread.interrupt()cooperative in both languages, so the target must check, or be blocked in a call that checks Why
twistSemaphoreSlimSemaphorestill needed to limit concurrency Why
twistIAsyncEnumerable<T>no direct equalJava has no async streams. The usual form is a producer thread that feeds a BlockingQueue. Why
twistTask.Run, for one taskThread.startVirtualThread(() -> f())This call starts one virtual thread. To give the thread a name first, build it with Thread.ofVirtual(). Why
newno equivalentspring.threads.virtual.enabled: trueIt is false by default. Set it to true, on Boot 3.2 or later, and each web request runs on a virtual thread. Why
trapParallel.ForExecutors.newFixedThreadPool(cores)virtual threads do not speed up CPU-bound work, so keep a pool of platform threads for it Why
trapa pool that reuses its threadsa new virtual thread for every tasknever pool virtual threads, because they are too cheap to create for a pool to help Why
twistlock around a blocking callsynchronized around a blocking callOn Java 21 to 23, this pinned the carrier of the virtual thread. Since Java 24, only native code pins. Why
trapThreadLocal<T>ScopedValue, not ThreadLocalone value per thread is a memory problem with 500,000 virtual threads Why
twistAsyncLocal<T>ScopedValueScopedValue is final in Java 25. It is immutable, and it is bound to one block of code rather than ambient. Why
twistyour async habitsthe migration checklistsix .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:

C#
// 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;
Java 21+
// no colour. Callers are unaffected.
Report build(int id) {
    Customer c = api.getCustomer(id);   // blocks
    Orders   o = api.getOrders(id);     // blocks
    return merge(c, o);
}

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

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 context
async / await

Java 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.

C#
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(); }
}
Java 25
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(); }
}
Reading the code
void run() throws Exception {

Three calls in this method throw the checked exception InterruptedException. The calls are get, sleep and acquire. get also throws ExecutionException. So the method declares throws or catches these exceptions.

try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {

This is a try-with-resources block. It does the same as a using block 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. submit returns a Future, which is like a Task in C#.

a.get(); b.get();

Each get waits for the result of one task, like await. The two get calls together do the same as Task.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 acquire at the same time, like SemaphoreSlim in 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();
Reading the code
} // close() waits for every task to finish

The 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, like Wait() on a task in C#.

.name("import-", 0)

This call names the threads import-0, import-1 and so on. The names help when you read logs or thread dumps.

Note

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:

PredictThe thread behind a Spring Boot request#
Nothing in the service's configuration mentions threads. A client sends GET /thread. What comes back?
@RestController
class ThreadController {

    @GetMapping("/thread")
    String thread() {
        return "virtual=" + Thread.currentThread().isVirtual();
    }
}
Show the answer
virtual=false

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: true

With 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#

Gotcha

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#.

Gotcha

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();
}
Reading the code
synchronized (monitor) {

This line holds monitor as a lock, like lock (monitor) in C#. On Java 21 to 23, waiting on I/O inside the block pinned the carrier. For more about synchronized, see Mutual exclusion.

lock.lock();

This line takes a ReentrantLock explicitly. The unlock call in the finally block releases the lock. Waiting while the thread holds a ReentrantLock never 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 task
Gotcha

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

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 running

build is the same blocking method as before. submit runs it on a virtual thread, and report.get() waits for the result.

Check yourself
1
An expert asksWhy does Java have no async and await?
Java made threads cheap instead. When a virtual thread blocks, the JVM takes the thread off its carrier and keeps the state of the thread on the heap. await does the same in .NET. No compiler rewrites the method, so there is no function colouring.
2
TranslateWrite 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);
}
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.

3
An expert asksWhat is pinning, and is it still a problem?
A pinned virtual thread cannot leave its carrier while it is blocked. On Java 21 to 23, blocking inside a synchronized block pinned the virtual thread. Java 24 fixed that, so now only native code pins.

Structured concurrency and cancellation#

Concurrency · Chapter 23· 7 min read

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.

KindC#JavaNote
twistTask.WhenAll(a, b)fork twice, then joinStructured concurrency is a preview feature in Java 25. With it, join waits for every subtask, and fails as soon as one fails. Why
twistCancellationTokenSourcethe scope itselfleaving the try block cancels every subtask still running, so no token is passed down Why
twisttry/finally to clean upthe try-with-resources blockno subtask can outlive the block that opened the scope Why
trapTask.WhenAll, a finished APIStructuredTaskScope, behind --enable-previewJava 25 has the fifth preview of StructuredTaskScope. The flag is needed to compile and to run, and the API still changes. Why
twistTask.WhenAny(a, b)a scope configured to complete on the first successJoiner.anySuccessfulResultOrThrow() returns the first result and cancels the rest Why
twistCancelAfter(timeout)a timeout configured on the scopeC# sets the timeout on the token source, but Java sets the timeout on the scope Why
twista token passed down by handa scope around the subtasksA token cancels but does not contain. A scope ties each subtask to the block that started it. Why
twistTask.WhenAll(a, b), on final APIsexec.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
twistct.IsCancellationRequestedThread.currentThread().isInterrupted()Cancellation is cooperative in both languages. In Java, interrupt() sets a flag, which the thread must check. Why
twistct.ThrowIfCancellationRequested()Thread.interrupted() checks, mostly implicitblocking JDK calls see the flag and throw InterruptedException for you Why
trapOperationCanceledExceptionInterruptedExceptionthe call that throws it clears the interrupt flag, so restore the flag or let the exception propagate Why
twistAsyncLocal<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#

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

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

        return merge(a.get(), b.get());
    }
}
Reading the code
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. fork returns a handle to the future result of the subtask.

scope.join();

This line waits for the subtasks. If one subtask failed, join throws, and the other subtask is cancelled.

merge(a.get(), b.get())

This line reads the result of each subtask. The results are ready after join has 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.

Gotcha

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
}
Reading the code
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, join returns 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:

JoinerBehaviour.NET analogue
anySuccessfulResultOrThrow()returns the first successful result and cancels the restTask.WhenAny
allSuccessfulOrThrow()a stream of all subtasks, all succeededTask.WhenAll
awaitAll()waits for all, success or failureWhenAll then inspect
awaitAllSuccessfulOrThrow()waits for all, and throws on any failureWhenAll with fail-fast
allUntil(Predicate)cancels when the predicate is satisfiedno direct equivalent
Note

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#

Note

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
    }
}
Reading the code
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
}
Reading the code
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())

get waits for each result, one after the other. If a task failed, get throws ExecutionException, which wraps the task's own exception.

Gotcha

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.

PredictThe interrupt flag inside the catch block#
What does this print?
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
false

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;
        }
    }
}
Reading the code
while (!Thread.currentThread().isInterrupted())

This line checks the interrupt flag between chunks, like a check of ct.IsCancellationRequested in C#.

Thread.sleep(100);

When the thread is interrupted, a blocking call such as sleep throws InterruptedException. It does not wait until its time has passed.

Thread.currentThread().interrupt();

This line sets the flag again for the callers, because sleep cleared 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:

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

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

ScopedValue.where(USER, "ada").run(() -> {
    // USER.get() is "ada" here, and in forked subtasks
    doWork();
});                      // rebound to nothing after the block
Reading the code
ScopedValue.newInstance()

This call creates the key. The key is usually in a static final field, like the static AsyncLocal in C#.

ScopedValue.where(USER, "ada").run(() -> {

This line binds USER to "ada" while the lambda runs. Code that is called from inside the lambda reads the value with USER.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 timeImmutable, bound for a block
Lifetime is ambientLifetime is the block
Flows to async continuationsInherited by forked subtasks
Can leak if never clearedCannot leak, because it unbinds on block exit
Note

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.

Check yourself
1
Structured concurrency looks perfect for your fan-out. Can you ship it?
No, you cannot ship it on Java 25. Structured concurrency is still a preview. Java 25 has the fifth preview, and the API has changed between previews. The code needs --enable-preview to compile and again to run. Its class files will not load on any other JDK version.
2
An expert asksWhy does Java tie subtasks to a scope instead of passing a cancellation token?
A 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.
3
TranslateWrite this in Java, for code that runs on virtual threads:
static readonly AsyncLocal<string> User = new();

User.Value = "ada";
DoWork();     // reads User.Value
static 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#

Concurrency · Chapter 24· 6 min read· a look-up chapter: Essentials shows its table, and each section opens

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.

KindC#JavaNote
twistTask<T>CompletableFuture<T>a value that arrives later, with methods to chain more work onto it Why
twistTask (void)CompletableFuture<Void>a future that carries no result, such as the one thenAccept returns Why
twistTask.FromResult(v)CompletableFuture.completedFuture(v)a future that is already complete when you create it Why
twistTask.Run(f)CompletableFuture.supplyAsync(f)runs f on another thread, and completes the future with its result Why
twistTask.Run(action)CompletableFuture.runAsync(action)the form for work that returns no value Why
twistTaskCompletionSource<T>new CompletableFuture<>()create it empty, then call complete(value) when the result arrives Why
twistawait task.join(), or just block on a virtual threadJava 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
twistcombine two results.thenCombine(other, fn)waits for both futures, then merges their two results with fn Why
twistTask.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
twistTask.WhenAny(a, b)CompletableFuture.anyOf(a, b)the result is typed Object, so you must cast it back Why
twisttry/catch around await.exceptionally(fn) or .handle(fn)exceptionally returns a replacement value when the future fails Why
trapblocking inside Task.RunsupplyAsync(f, exec)with no executor argument, the work shares the common pool, and blocking there starves it Why
twistTask.DelayCompletableFuture.delayedExecutor(...)it returns an executor that runs a task after a delay, and it was added in Java 9 Why
twistIProgress<T>no equivalentpass a Consumer<Integer> and call it as the work progresses Why
twistTask, for all concurrent workCompletableFuture, for a value that completes laterto run two blocking calls at once, use virtual threads and an executor instead Why
twista Task you only wait forFuture, from ExecutorService.submitthe 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.

C#
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);
Java 25
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();
Reading the code
CompletableFuture.supplyAsync(() -> compute())

This call runs the lambda on another thread, like Task.Run in C#. It completes the future with the result of the lambda.

new CompletableFuture<String>()

This is an empty future, like a TaskCompletionSource in C#. Nothing completes the future until some code calls complete.

client.onReply(reply::complete);

When the reply arrives, the client calls complete. That call gives the future its value.

reply.join()

join waits for the value, like .Result in C#. On a virtual thread, waiting uses almost no resources.

Object first = CompletableFuture.anyOf(a, b).join();

anyOf returns an Object, 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:

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

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

Report report = f.join();
Reading the code
.thenApply(this::decorate)

thenApply runs decorate on the result when the result arrives. It is like ContinueWith with a plain function.

.thenCompose(this::enrichAsync);

Use thenCompose when the next step itself returns a future. It flattens the future of a future into one future, like Unwrap after ContinueWith.

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:

MethodRunsReturns
thenApply(fn)on the completing threadCompletableFuture<R>
thenApplyAsync(fn)on the common pool, or a supplied executorCompletableFuture<R>
thenCompose(fn)fn returns a future, and thenCompose flattens itCompletableFuture<R>
thenCombine(other, fn)when both completeCompletableFuture<R>
thenAccept(consumer)side effectCompletableFuture<Void>
exceptionally(fn)only on failureCompletableFuture<T>
handle(fn)on success or failureCompletableFuture<R>
whenComplete(action)on success or failure, but does not change the valueCompletableFuture<T>
Gotcha

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();
Reading the code
.map(id -> supplyAsync(() -> load(id)))

This line starts one future for each id.

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

allOf takes an array, so the list is converted to an array first. Then join waits for every future.

.map(CompletableFuture::join)

This line collects each result. Here join returns at once, because every future has already completed.

Which thread does the work#

Gotcha

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);
Reading the code
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 *Async step 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 seconds

delayedExecutor 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);
Reading the code
} catch (CompletionException e) {

join wraps the task's exception in a CompletionException.

Throwable cause = e.getCause();

getCause returns the exception that the task actually threw.

future.exceptionally(ex -> {

exceptionally handles a failure inside the chain. The lambda receives the exception and returns a replacement value. Here the replacement value is an empty report, so publish still runs.

Note

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:

SituationUse
Two blocking calls concurrentlyvirtual threads + an executor, not CompletableFuture
Adapting a callback API to a valueCompletableFuture, completed manually
Caffeine or another async cacheCompletableFuture, because the API requires it
Spring WebFlux / reactive codeMono/Flux, which are a different model again
Fire-and-forget with a completion hookCompletableFuture.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;
}
Reading the code
client.send(request, done::complete, done::completeExceptionally);

The client calls complete with the reply, or completeExceptionally with an error. Either call finishes the future.

return done;

The caller gets a future, like the Task of a TaskCompletionSource in 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.

Check yourself
1
CompletableFuture.supplyAsync(() -> httpCall()) waits on another service. Which threads run it, and what is the risk?
With no executor argument, the work runs on 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#

Concurrency · Chapter 25· 8 min read

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.

KindC#JavaNote
twistlock (_gate) { ... }synchronized (gate) { ... }the same mutual exclusion, and any object can be the lock Why
twistlock (this)a synchronized methodthe method modifier locks on this, so prefer a private final lock object Why
twistMonitor.TryEnter(o, ts)lock.tryLock(t, unit)ReentrantLock adds timeouts, interruption and fairness. Unlock it in a finally block. Why
twistInterlocked.Increment(ref x)atomicInt.incrementAndGet()Java has no ref parameters, so the operations are methods of an object that holds the number Why
trapInterlocked.CompareExchange(ref x, v, c)atomicRef.compareAndSet(c, v)the expected value comes first, but CompareExchange puts the new value first Why
twistInterlocked for high contentionLongAdder, far better under contentionfor a counter that many threads write constantly and that is read now and then Why
twistvolatilevolatileIt is the same for a stop flag or double-checked locking. Java also keeps all volatile reads and writes in one order. Why
twistVolatile.Read / Writevolatile field, or VarHandlemark the field volatile, so that another thread sees the latest value Why
twistLazy<T>a static holder classIt is lazy and thread-safe with no locking. Stable values are like Lazy<T>, and they are a preview feature in Java 25. Why
twistConcurrentDictionary<K,V>ConcurrentHashMap<K,V>the thread-safe HashMap, and its reads never lock Why
twistGetOrAdd(k, factory)computeIfAbsent(k, fn)it is atomic, so the function runs at most once per key, unlike GetOrAdd Why
trapa GetOrAdd factory that calls GetOrAdda computeIfAbsent function that calls the map againJava holds a lock while the function runs, so it can throw IllegalStateException with the message Recursive update Why
twistAddOrUpdatecompute(k, fn) / merge(k, v, fn)compute and merge update a value atomically Why
twistBlockingCollection with capArrayBlockingQueue<E>a bounded queue with a fixed capacity set when it is created Why
twistChannel<T>BlockingQueue, or SubmissionPublisherJava has no async channel, so a BlockingQueue on virtual threads is the usual answer Why

Mutual exclusiontwist#

C#
private readonly object _gate = new();
private long _total;

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

public void add(int x) {
    synchronized (gate) {
        total += x;
    }
}
Reading the code
private final Object gate = new Object();

This is a private object that is used only as the lock, like _gate in 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 this

Every call to add now locks the object itself.

Gotcha

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:

NeedC#Java
Basic mutual exclusionlocksynchronized, or ReentrantLock
Try with timeoutMonitor.TryEnter(o, ts)lock.tryLock(t, unit)
Interruptible acquireThread.Interrupt() ends the wait for any locklock.lockInterruptibly()
Read/write splitReaderWriterLockSlimReentrantReadWriteLock
Condition variableMonitor.Wait / PulseCondition.await / signal
Fair queueingnonew ReentrantLock(true)
Non-reentrantSemaphoreSlim(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();
}
Reading the code
lock.tryLock(200, TimeUnit.MILLISECONDS)

tryLock waits at most 200 milliseconds for the lock, like Monitor.TryEnter with a timeout in C#. It returns false if the lock was still busy after that time.

lock.unlock();

A ReentrantLock is not released automatically. So always put the call to unlock in a finally block.

throw new TimeoutException();

You decide what the code does when the lock did not become free in time.

Note

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:

C#
private int _count;
Interlocked.Increment(ref _count);
Interlocked.Add(ref _count, 10);
Interlocked.Exchange(ref _count, 0);            // set, returning the old value
Java 25
private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();
count.addAndGet(10);
count.getAndSet(0);                             // set, returning the old value
Reading the code
new AtomicInteger()

Java has no ref parameters. 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.Increment in C#.

PredictcompareAndSet with the arguments in C# order#
What does this print?
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
false 4

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:

.NETJava
Interlocked.Add(ref x, n)atomicInt.addAndGet(n)
Interlocked.Exchange(ref x, v)atomicRef.getAndSet(v)
n/aatomicInt.updateAndGet(fn)
n/aatomicRef.accumulateAndGet(v, fn)
Note

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 check
Gotcha

Both languages have the keyword volatile, but its guarantees differ:

C# volatileJava volatile
Reorderingacquire on read, release on writesequentially consistent, so every thread agrees on one order
Visibilitythe field, plus writes made before a volatile writethe field, plus writes made before a volatile write
Atomicity of 64-bitnot allowed, because volatile cannot be applied to long or doubleguaranteed for volatile long and double
Use for a flagyesyes
Use for double-checked lockingsufficientsufficient

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 check

getVolatile 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;
}
Reading the code
private volatile Config config;

volatile guarantees that a thread that sees the new Config also 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 null from loading the configuration twice.

Note

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 read

Concurrent collectionstwist#

Here pair is a currency pair, and job is a job to process:

C#
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 empty
Java 25
var 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 empty
Reading the code
rates.computeIfAbsent(pair, p -> fetch(p))

This call returns the cached rate. If the map has no rate for the key, the call runs fetch and stores the result. It runs fetch at most once per key. In C#, GetOrAdd may call Fetch twice when two threads race.

hits.merge("home", 1, Integer::sum);

merge stores 1 if the key is new. Otherwise it combines the old value with 1 by calling Integer::sum. This is like AddOrUpdate in C#.

new ArrayBlockingQueue<>(100)

This is a queue that holds at most 100 jobs, like a bounded BlockingCollection in 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 continue

Java 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:

.NETJavaNote
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 equalJava has no unordered bag, but a ConcurrentLinkedQueue is usually good enough
ImmutableList + swapCopyOnWriteArrayList<E>every write copies the array, so use it only when writes are rare
CountdownEventCountDownLatchcounts down to zero once, and cannot be reset
BarrierCyclicBarrierreleases all waiters, then resets for the next round
ManualResetEventSlimCountDownLatch, or a Conditiona one-shot gate is a CountDownLatch(1), but a gate that can be reset needs a Condition
SemaphoreSlimSemaphoreIt 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#:

PredictA function that calls computeIfAbsent on its own map#
What does this print?
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
It throws 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.

Check yourself
1
TranslateWrite this in Java:
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.

2
An expert asksWhy is 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#

Concurrency · Chapter 26· 7 min read· a look-up chapter: Essentials shows its table, and each section opens· Quick reference

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.

KindC#JavaNote
twistTask.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
twistThreadPool.QueueUserWorkItempool.submit(task), on a pool you createboth queue work to a pool, but in Java before 21 you made the pool, sized it and shut it down Why
twistnew Thread(DoWork)new Thread(this::doWork)This is the Java 1.0 API. Write Thread.ofVirtual().start(...) or use an executor instead. Why
twista thread pool you never sizeExecutors.newFixedThreadPool(50)the size is a guess, so for work that waits, use a virtual-thread executor instead Why
trapa pool with nothing to shut downpool.shutdown() in a finally blockif code from before Java 19 forgets this call, the pool's threads keep the program running Why
twistMonitor.Wait / Pulsequeue.wait() / queue.notify()You must hold the lock and wait in a loop. A BlockingQueue does this correctly. Why
twista lock around every callCollections.synchronizedMap(...)a check followed by an update is still a race, so use ConcurrentHashMap Why
trapa timer that runs many tasksjava.util.Timerone task that throws kills its only thread, so use ScheduledExecutorService Why
trapThreadLocal<T>ThreadLocalcall remove() in a pool, and prefer ScopedValue on virtual threads Why
twistno pool to managetry (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:

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

var t = Task.Run(() => Handle(id));
await t;
What older Java code does
// pre-21 Java: you sized the pool yourself
ExecutorService pool = Executors.newFixedThreadPool(50);
Future<?> f = pool.submit(() -> handle(id));
f.get();
pool.shutdown();
Reading the code
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.Run in .NET. It returns a Future, which is like a Task in .NET. Then f.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();
Reading the code
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 as IsBackground = true in .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:

C#
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 later
Java 25
Thread 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 notice
Reading the code
new Thread(this::doWork, "worker-1")

The second argument names the thread, as Name does in C#.

interrupt() and let it notice

You 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.NETJavaNote
Background threadIsBackground = truesetDaemon(true)the JVM exits without waiting for it to finish
Foreground threaddefaultdefaultthe JVM will not exit until this thread finishes
NameThread.NamesetName()the name appears in thread dumps and logs, so name your threads
PriorityThread.PrioritysetPriority()only a hint that the OS may ignore, so do not rely on it
Wait for completionJoin()join()blocks until the thread ends, exactly like Join()
Kill itAbort(), which only throwsstop(), which only throwscannot 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);
}
Reading the code
pool.invokeAll(tasks)

This call runs every task and waits for all of them. It returns one Future for each task.

pool.shutdown();

This line stops the pool from accepting new work. Then awaitTermination waits 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:

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

ExecutorService 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();
}
Reading the code
synchronized (queue) {

wait and notify work 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() or notifyAll(). 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);
}
Reading the code
Collections.synchronizedMap(new HashMap<>())

Every single call, such as containsKey or put, takes the map's lock.

if (!stock.containsKey(productId)) {

The check and the put are 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);
Reading the code
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 poll immediately, 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
}
Reading the code
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);
}
Reading the code
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.

Check yourself
1
A command-line tool written before Java 19 finishes its work, but the process never exits. What is the likely cause, and the fix?
The likely cause is a thread pool that nobody shut down. Its threads keep the Java Virtual Machine (JVM) running. Older code must call 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 seeReplace with
new Thread(...)Thread.ofVirtual(), or an executor
newFixedThreadPool for I/OnewVirtualThreadPerTaskExecutor
wait / notifyBlockingQueue, or Condition
Collections.synchronizedMapConcurrentHashMap
java.util.TimerScheduledExecutorService
ThreadLocalScopedValue
Thread.stop / suspendnothing, because suspend is gone and stop only throws
CompletableFuture merely to parallelise blocking callsvirtual threads

Reactive: WebFlux and Reactor#

Concurrency · Chapter 27· 9 min read· a look-up chapter: Essentials shows its table, and each section opens

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.

KindC#JavaNote
twistTask<T>Mono<T>zero or one value that arrives later, like a Task Why
twistIAsyncEnumerable<T>Flux<T>a stream of any number of values that arrive over time Why
twistIObservable<T>Flux<T>one type covers both the pull and the push styles Why
twistTask.FromResult(v)Mono.just(v)a Mono that already holds its value Why
twistTask.CompletedTaskMono.empty()a Mono that completes with no value Why
twist.Select.maptransform each value as it arrives Why
twist.Where.filterkeep only the values that match Why
twist.SelectMany.flatMapeach value becomes a publisher, and the results merge Why
twisttwo 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
trapasync code, for throughputSpring MVC on virtual threadsdo not adopt WebFlux for throughput, because blocking code scales to the same numbers on Java 21 Why
twistIAsyncEnumerable + ChannelFlux + backpressureReactor lets a slow consumer tell the producer to send less Why
twistresults that arrive over timeFlux, as server-sent eventsreactive code still fits streams, backpressure and very large fan-out Why
trapawait.block(), almost always wrongblock() waits for the value, but on one of Reactor's non-blocking threads it throws IllegalStateException instead Why
trapa blocking call in async codea blocking call on an event-loop threadit stalls unrelated requests, so one blocking library undoes the whole model Why
trapan ORM behind async codeR2DBC, with no ORM at allthere is no reactive JPA, so WebFlux means giving up Spring Data JPA Why
trapcalling a method that returns a Taskbuilding a Mono or Fluxa 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:

C#: Task, IAsyncEnumerable, Rx.NET
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);
Java: Reactor
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);
Reading the code
Mono<Order> one = Mono.just(order);

A Mono holds zero or one value that arrives later, like a Task<T> in .NET. The method just wraps a value that you already have, like Task.FromResult.

Flux<Order> many = repo.findAll();

A Flux is a stream of any number of values that arrive over time. It is like IAsyncEnumerable<T> or IObservable<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);

map transforms each value, like Select in C#. interval counts in long values, so this line turns each value into an int.

Combining results: Mono.zip#

Combining two results shows the difference in style. C# awaits each call. Reactor combines two Mono objects into one:

C#: async/await
public async Task<Report> BuildAsync(int id)
{
    var c = await _api.GetCustomerAsync(id);
    var o = await _api.GetOrdersAsync(id);
    return Merge(c, o);
}
Java: Reactor
public Mono<Report> build(int id) {
    return Mono.zip(
            api.getCustomer(id),
            api.getOrders(id))
        .map(t -> merge(t.getT1(), t.getT2()));
}
Reading the code
Mono.zip(

This call waits for both Mono objects and pairs their values. The calls getCustomer and getOrders run at the same time.

.map(t -> merge(t.getT1(), t.getT2()));

zip pairs the two values into one object. getT1() and getT2() read the two values from that object.

Note

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:

QuantityBefore Java 21Java 21 and later
One requestone platform threadone virtual thread
One thread's stackabout 1 MBa few hundred bytes to start
10,000 concurrent requestsabout 10 GB of stacks, which is impossiblea few MB, which is not a problem
So the advice wasnever block, and restructure everything into callbacksblock, because it is fine

So on Java 21 and later, the same code can simply block:

What you'd write in C#
var c = await _api.GetCustomerAsync(id);
var o = await _api.GetOrdersAsync(id);
return Merge(c, o);
Java 21+: and it scales
var c = api.getCustomer(id);   // blocks
var o = api.getOrders(id);     // blocks
return merge(c, o);
Reading the code
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.

Gotcha

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:

CaseWhy virtual threads do not solve it
Server-sent events, long-lived streamsthe value is many results over time, not one
Backpressure across a boundarya slow consumer must actually slow the producer
Very large fan-out (thousands of concurrent calls per request)a combinator graph expresses this better than thousands of scopes
Streaming a large result without buffering itFlux processes element by element
You are already on WebFluxmixing blocking code into it is worse than committing
R2DBC, RSocketthe 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
}
Reading the code
produces = MediaType.TEXT_EVENT_STREAM_VALUE

This 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
Note

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#

Gotcha

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
}
Reading the code
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());
Reading the code
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#
Means
Code running on one of Reactor's non-blocking threads called 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.
Fix
Return the 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 MVCSpring WebFlux
ServerTomcat (servlet)Netty (event loop)
Threadingone thread per requesta few event-loop threads
Return typesOrder, ResponseEntityMono, Flux
Data accessJDBC, JPAR2DBC, reactive Mongo
Blocking librariesfineforbidden in the request path
Stack tracesordinary and readableassembled from operators, and hard to read
Debuggingstep throughHooks.onOperatorDebug, and patience
Scales to 10k requestsyes, on virtual threadsyes

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); }
Reading the code
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 Mono at once. Spring subscribes to the Mono and writes the order when the order arrives.

Gotcha

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()));
}
Reading the code
repo.findByCustomer(customerId)

This call returns a Flux of 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:

OperatorDoes
maptransform each element
flatMaptransform each into a publisher and merge, but order is not preserved
concatMapas flatMap, but preserves order
zipcombine several publishers element-wise
switchIfEmptyfall back when nothing was emitted
onErrorResumesubstitute a publisher on failure
retryWhenretry with a policy
timeoutfail if nothing arrives in time
subscribeOn / publishOnchoose which scheduler runs what
blockwait for the value, never in reactive code
Gotcha

Nothing happens until you subscribe. A Mono or Flux is a recipe, not a running computation. 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);     // correct
Reading the code
repo.save(order); // does nothing, never subscribed

This line builds a Mono that would save the order, and then discards the Mono. 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.

Check yourself
1
Is WebFlux the right choice for a new database-backed service?
It is almost certainly not the right choice. Virtual threads removed the scalability argument. And with WebFlux you give up Spring Data JPA, readable stack traces and every blocking library.

Numbers, money and time#

Numbers and errors · Chapter 28· 8 min read

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.

KindC#JavaNote
sameint, long, short, float, double, charint, long, short, float, double, charthe same names and the same sizes in both languages Why
twistbyteno equivalentJava has no unsigned byte, so (byte) 200 is -56, and b & 0xFF gives 200 again Why
twistsbytebytea Java byte is signed, from -128 to 127, like sbyte in C# Why
twistushortcharchar is the only unsigned type in Java, and it is 16 bits wide Why
twistuintno equivalentJava has no uint, so use a long or the Integer.*Unsigned helpers Why
twistulongno equivalentJava has no ulong, but methods such as Long.toUnsignedString treat a long as unsigned Why
twistnint / nuintno equivalentJava has no native-sized integer types, so use int or long Why
twistboolbooleanthe same true or false, spelled boolean in Java Why
twistList<int>List<Integer>Java uses a wrapper type such as Integer wherever an object is needed Why
trapdecimalno equivalentJava has no decimal type, so money is a BigDecimal object, with a method for each operator Why
trap0.1mnew BigDecimal("0.1")always build it from a string, because new BigDecimal(0.1) keeps the error of the double Why
trapa == b on two decimalsa.compareTo(b) == 0equals also compares scale, so 1.0 and 1.00 are not equal Why
traptotal / 3total.divide(three, 2, RoundingMode.HALF_UP)divide with no scale throws when the result never ends, such as 1/3 Why
twistDateOnly, DateTimeOffset, TimeSpanLocalDate, Instant, Durationjava.time gives each kind of time its own immutable type Why
trapTimeSpanDuration, or Period for calendar daysone day as a Period and 24 hours as a Duration differ across a clock change Why
twistno equivalentDate, Calendar, SimpleDateFormatThese are the old date classes. Read them, never write them, and convert to java.time at the boundary. Why
twistx.ToString("N2")String.format("%,.2f", x)uses the server's default locale unless you pass one, such as Locale.ROOT Why
twistint.TryParse(s, out v)catch NumberFormatExceptionJava has no TryParse, so catch the exception that parseInt throws Why

Primitive typestwist#

C#
sbyte sb = -100;               // signed: -128 to 127
uint u = 3_000_000_000;
ulong max = ulong.MaxValue;
decimal price = 19.99m;
bool ok = true;
Java 25
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;
Reading the code
byte sb = -100;

A Java byte holds −128 to 127, like sbyte in 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 a long. The L marks a long literal.

Long.toUnsignedString(-1L)

This method reads the 64 bits of a long as an unsigned number. Java handles values the size of a ulong in this way.

These types are the same in both languages:

C#JavaSizeNote
shortshort16-bit
intint32-bit
longlong64-bit
floatfloat32-bit
doubledouble64-bit
charchar16-bita 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:

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

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 unbox
Reading the code
Integer 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 missing is null, so there is no number to unbox. This line throws NullPointerException.

A byte is signedtwist#

Each language reads the same eight bits as a number:

C#
byte b = 200;                  // unsigned: 0 to 255
Console.WriteLine(b);          // prints 200
Java 25
byte b = (byte) 200;           // signed: -128 to 127
System.out.println(b);         // prints -56
System.out.println(b & 0xFF);  // prints 200
Reading the code
byte b = (byte) 200;

The number 200 does not fit in a signed Java byte, so the cast wraps the value to −56.

b & 0xFF

Masking with 0xFF reads the same eight bits as an unsigned number, so the result is 200 again.

Gotcha

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:

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

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

if (total.compareTo(limit) > 0) { }
NumberFormat.getCurrencyInstance().format(total);
Reading the code
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 method multiply accepts only another BigDecimal. So BigDecimal.valueOf(3) makes a BigDecimal from the number 3.

.add(shipping)

This call returns a new value, because a BigDecimal never changes.

total.compareTo(limit) > 0

compareTo returns a negative number, zero or a positive number. So > 0 means 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#. NumberFormat is in java.text.

Each operator on a C# decimal has a method on a BigDecimal:

OperationBigDecimal
a + ba.add(b)
a - ba.subtract(b)
a * ba.multiply(b)
a / ba.divide(b, scale, RoundingMode.HALF_UP)
a > ba.compareTo(b) > 0
a == b (value)a.compareTo(b) == 0
roundinga.setScale(2, RoundingMode.HALF_UP)
from a literalnew BigDecimal("19.99"), always the String constructor
PredictA BigDecimal built from a double#
What does this print?
System.out.println(new BigDecimal(0.1));
System.out.println(new BigDecimal("0.1"));
Show the answer
0.1000000000000000055511151231257827021181583404541015625 0.1

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.

Gotcha

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.

Note

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);
Conceptjava.time.NET
Date, no time, no zoneLocalDateDateOnly
Time, no date, no zoneLocalTimeTimeOnly
Date and time, no zoneLocalDateTimeDateTime (Unspecified)
Instant on the timelineInstantDateTimeOffset (UTC)
Date, time and zoneZonedDateTimeDateTimeOffset + TimeZoneInfo
Date, time and offsetOffsetDateTimeDateTimeOffset
Amount of timeDurationTimeSpan
Calendar amountPeriodno direct equal
Time zoneZoneIdTimeZoneInfo
FormattingDateTimeFormatterformat strings

These are the common operations:

C#
var now = DateTimeOffset.UtcNow;
var due = now.AddDays(30);
var d   = DateOnly.FromDateTime(DateTime.Today);
var span = due - now;
Java 25
var now = Instant.now();
var due = now.plus(30, ChronoUnit.DAYS);
var d   = LocalDate.now();
var span = Duration.between(now, due);
Reading the code
var now = Instant.now();

This is an exact point on the timeline, in UTC.

now.plus(30, ChronoUnit.DAYS)

An Instant has 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 DateTimeOffset values and get a TimeSpan.

Note

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:

PredictOne day later, across a clock change#
What does this print?
// 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
2026-03-29T12:00+01:00[Europe/London] 2026-03-29T13:00+01:00[Europe/London]

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:

ClassProblem
java.util.Datemutable, and it is really an instant, despite the name
java.sql.Dateextends Date but forbids the time part
Calendarmonths are ZERO-based. January is 0
SimpleDateFormatNOT thread-safe, so a shared instance corrupts output silently
TimeZonesuperseded 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();
Reading the code
oldDate.toInstant()

A Date is 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:

C#
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);
Java 25
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; }
Reading the code
String.format(Locale.ROOT, "%,.2f", 1234.5)

Locale.ROOT fixes 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 that parseInt throws for bad input.

Gotcha

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
Check yourself
1
TranslateWrite this in Java:
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.

2
An expert asksWhich type does Java use for money, and why do you compare two amounts with compareTo and not with equals?
Java uses 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#

Numbers and errors · Chapter 29· 8 min read

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#.

KindC#JavaNote
newno equivalentthrows IOExceptiona checked exception must be declared or caught, or the code does not compile Why
newno equivalentthrow new UncheckedIOException(e)application code usually wraps a checked exception, and passes it as the cause Why
newno equivalentextends Exception, or extends RuntimeExceptiona type under RuntimeException or Error is unchecked, and every other exception type is checked Why
trapa lambda that throws IOExceptiona lambda that catches it and rethrowsthe lambda types declare no checked exceptions, so wrap the exception inside the lambda Why
twistthrow new InvalidOperationException("...")throw new IllegalStateException("...")most .NET exceptions have a close Java match under another name Why
twistthrow new ArgumentException("...")throw new IllegalArgumentException("...")it covers every bad argument, and Java has no ArgumentOutOfRangeException Why
twistArgumentNullException.ThrowIfNull(order)Objects.requireNonNull(order, "order")throws NullPointerException, not an argument exception, by Java convention Why
twistcatch (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
twistthrow;throw e;Java rethrows the same object, so the trace is preserved Why
twistwhen (filter)no equivalentJava has no exception filters, so test inside the catch, and rethrow the rest Why
samefinallyfinallyidentical in both languages, and it runs whether or not the try block threw Why
twistusingtry-with-resourcesdoes the same as the block form of using, and it closes what its brackets declare Why
twistusing declarationno equivalent, always a blockJava has only the block form, so the block is the resource's scope Why
twistIDisposableAutoCloseableimplement it, and try-with-resources calls close for you Why
twistDispose()close()the one method of AutoCloseable, called when the block ends Why
twistIAsyncDisposableno equivalentJava has no asynchronous form of close or of try-with-resources Why
twistDispose throws, and the first exception is loste.getSuppressed()Java throws the body's exception and keeps the close() failure in getSuppressed() Why
twistSafeHandleCleaner, 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:

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

This clause declares that read may throw IOException. Every caller must now catch the exception, or declare it too.

// may throw; nothing to declare

C# 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
    }
}
Reading the code
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.

Note

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.

Gotcha

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:

RuleMeaning
Extends RuntimeException or Errorunchecked, so it behaves like every C# exception
Extends Exception, not RuntimeExceptionchecked, 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#

Gotcha

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:

C#
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 missing
Java 25
Objects.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 exception
Reading the code
Objects.requireNonNull(order, "order");

This method is like ThrowIfNull in C#, but it throws NullPointerException, not an argument exception. That is the Java convention.

throw new IllegalArgumentException("qty must be positive");

Java has no ArgumentOutOfRangeException. IllegalArgumentException covers every bad argument.

var v = map.get(key);

A missing key does not cause an exception in Java. The method get returns null instead.

This table lists every common .NET exception and its Java equivalent:

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

try, catch, finally#

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:

C#
try
{
    Do();
}
catch (Exception ex) when (ex is IOException or SqlException)
{
    Log(ex);
    throw;              // rethrow, preserving the stack
}
finally
{
    Cleanup();
}
Java 25
try {
    doIt();
} catch (IOException | SQLException e) {   // multi-catch
    log(e);
    throw e;            // rethrow the same instance
} finally {
    cleanup();
}
Reading the code
catch (IOException | SQLException e)

This is one catch for two types, joined with |. C# needs a when filter for this.

throw e;

This line rethrows the same exception object, so the stack trace is kept. In C#, a throw with 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
}
Reading the code
if (!"23505".equals(e.getSQLState())) throw e;

23505 is the standard SQL code for a unique-key violation. Any other exception is rethrown unchanged.

Gotcha

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:

C#
using var conn = new SqlConnection(cs);
using var cmd = new SqlCommand(sql, conn);
conn.Open();
Java 25
try (var conn = dataSource.getConnection();
     var stmt = conn.prepareStatement(sql)) {
    stmt.executeUpdate();
}   // closed in reverse order, even on exception
Reading the code
try (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 scope

AutoCloseable 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 exception
Reading the code
public final class Conn implements AutoCloseable {

AutoCloseable has one method, close, like Dispose in C#.

try (var c = new Conn()) {

close() runs at the end of the block, even if use throws.

Note

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.

PredictThe body throws, and then close throws#
What does this print?
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
body failed close failed

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
}
Reading the code
CLEANER.register(this, state)

This call asks the Cleaner to run state when this Buffer is 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 the Buffer reachable forever.

@Override public void close() { cleanable.clean(); }

This is the normal path. close runs the cleanup now, and the cleanup will not run again later.

Note

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.

Check yourself
1
TranslateWrite 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) {
    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.

2
An 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?
Declare a checked exception in a library API, when the failure is an expected condition that the caller can really recover from. The compiler then makes that caller handle the failure. Propagate the exception through one or two layers at most. Most callers cannot recover, so for them a declared exception adds nothing useful.

How Java runs your code#

Build and tooling · Chapter 30· 17 min read· Quick reference

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.

KindC#JavaNote
twistdotnet build writes MyApp.dlljavac writes .class files, and jar zips them into a JARJava shows the steps that dotnet build hides. The steps are compile, package and run. Why
twistthe .NET SDKa JDKa JRE is a runtime with no compiler, so install a JDK for development Why
twistan app built for a newer runtimeUnsupportedClassVersionErrorA runtime refuses bytecode newer than itself. The class file version is the release plus 44, so 69 is Java 25. Why
twistnamespace Acme, in any folderpackage com.acme, in the folder com/acmeclass files must be in folders that match the package name Why
trapdotnet MyApp.dll, a file pathjava -cp out com.acme.Appjava takes the package plus the class name, never a file path Why
twistthe 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
trapone copy of each dependencytwo JARs with the same classthe JVM silently uses the first JAR, and a missing method fails later with NoSuchMethodError Why
twista dependency missing at run timeClassNotFoundException, NoClassDefFoundErrorno classpath entry has the class, so check the dependency and its Maven scope Why
twistdotnet publishjar --create --main-class com.acme.Appthe JAR's manifest names the class to start, so java -jar app.jar can run it Why
trapno equivalentjava -cp lib -jar app.jarjava -jar ignores -cp and CLASSPATH, and reads only the JAR and its manifest Why
twistthe entry point in the assemblyMain-Class in META-INF/MANIFEST.MFClass-Path in the same file adds JARs, and each entry is resolved from the JAR's own folder Why
twistno single equivalentthree class loaderseach loader asks the loader above it first, so the JDK's own classes always come first Why
trapa file read from diskgetResourceAsStream("/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
twistEnvironment.GetEnvironmentVariableSystem.getenv, or -Drates.url=...-D sets a system property. Put -D before -jar, or your program receives it as text. Why
twistthe NuGet cache~/.m2/repositoryMaven downloads each JAR once, and your project holds only a description of the JARs Why
twistsettings 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.

JAVA Source App.java javac compiles Class files App.class jar zips JAR app.jar java starts JVM running program .NET Source Program.cs csc compiles Assembly MyApp.dll holds the IL directly dotnet starts CLR running program
On the way from source to a running program, javac compiles .java files into .class files of bytecode. Then jar zips the class files into a JAR, and java starts a JVM. The .NET compiler writes IL straight into a .dll, in one step.

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.jar

javac 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
.NETJavaMeaning
.cs file.java fileA .java file holds source code. A public class must be in a file named after the class.
Roslyn (csc)javacthe compiler, which writes bytecode and not machine code
ILbytecodethe portable instruction set, which the runtime compiles to machine code while the program runs
IL inside the .dllclass 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 manifestJAR manifesta text file inside a JAR that can name its main class and other JARs
CLRJVMthe runtime, which loads classes, checks them, and compiles hot code to machine code
.NET SDKJDKthe compiler, the tools and a runtime together, so install a JDK to write Java
.NET runtimeJREa runtime with no compiler, for running programs only, so install a JDK to develop
namespace plus type namefully qualified namethe package plus the class name, as in com.acme.App, which also gives the path of the class file
deps.json and assembly probingclasspaththe ordered list of folders and JARs the JVM searches for classes
no single equivalentclass loaderthe part of the JVM that finds a class on the classpath and loads it
dotnet MyApp.dlljava -jar myapp.jarruns 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 compiler

javac is the compiler, so only a JDK includes it.

Note

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#
Means
The class was compiled for a newer Java than the Java that runs it. The class file version is the Java release plus 44, so 69 means Java 25 and 65 means Java 21.
Fix
Run the program on a Java that is at least as new as the target. Or compile for the older Java with 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.App

One 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.

C#: the assembly knows where to start
// 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 run
Java: src/com/acme/App.java
package 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.App
Reading the code
package com.acme;

This line puts App in the package com.acme, so the full name of the class is com.acme.App.

public static void main(String[] args)

This is the entry point. The java command starts the program in the main method of the class that you name.

java -cp out com.acme.App

This command runs the program. It gives the classpath after -cp, and then the full name of the class that has main.

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/*.java

javac 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.class

The 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 java

The only classpath entry is out. The java command looked under that folder for com/acme/App.class.

The name you pass to javatrap#

PredictStarting a class by its file path#
What happens?
ls out/com/acme
# App.class  Greeter.class

java out/com/acme/App.class
Show 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.class

The 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#
Means
No classpath entry holds 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.
Fix
Pass the fully qualified name. Point -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 package

The 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.App

target/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.

Note

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#

Gotcha

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)'#
Means
The class was found, but the method was not found. The class on the classpath is a different version from the version that your code was compiled against.
Fix
Find which JAR supplies the class. Change that JAR to the version that you compiled against. 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.App

A class that is on no classpath entrytwist#

Two errors say that no classpath entry has a class:

java.lang.ClassNotFoundException: org.postgresql.Driver#
Means
Code asked for a class by name, as a string, and no classpath entry has the class. The name usually comes from configuration, for example the name of a database driver or of a plugin.
Fix
Add the dependency that contains the class. Give the dependency a dependency scope that is kept at run time. In Maven, that is the default scope compile, or the scope runtime.
What triggers it
Class.forName("org.postgresql.Driver");   // the driver's JAR is not on the classpath
java.lang.NoClassDefFoundError: com/acme/Greeter#
Means
A class that was present when your code compiled is on no classpath entry now. The slashes show that the name comes from inside a compiled class file.
Fix
Put the class, or its JAR, back on the classpath. In Maven, 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.App

Running 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 java
Reading the code
jar --create --file app.jar

This part creates a JAR called app.jar. The jar command is the archiving tool of the JDK.

--main-class com.acme.App

This option writes Main-Class: com.acme.App into the manifest of the JAR, so that java -jar knows where to start.

-C out .

This part adds everything in the folder out, and it keeps the package folders.

A classpath with -jartrap#

PredictA classpath in front of -jar#
What happens?
# 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.jar
Show the answer
Exception in thread "main" java.lang.NoClassDefFoundError: com/acme/Greeter

java -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:

Questionjava -cp out com.acme.Appjava -jar app.jar
Where do classes come from?the entries listed after -cp, in orderthe JAR itself, plus any JARs its manifest names
Which class starts?the name typed after the classpaththe Main-Class line in the JAR's manifest
Are -cp and CLASSPATH used?yesno, both are silently ignored
Typical userunning from a build folder, or with a classpath you choosea packaged application, including a Spring Boot JAR
Note

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.jar

Main-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:

AttributeDoesNote
Main-Classnames the class whose main method starts the programa fully qualified name, written without the .class suffix
Class-Pathadds more JARs to the classpaththe 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#
Means
The JAR's manifest has no Main-Class line, so java -jar does not know which class to start.
Fix
Build the JAR with its main class set. You can do that with 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.jar
Gotcha

Class-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.

Bootstrap loader the JDK core: java.lang, java.util Platform loader the rest of the JDK's modules 1. asks its parent first 2. not found above? looks itself Application loader your classpath: target/classes, lib/*.jar your code first uses a class
Class loaders delegate upwards. The application loader asks the platform loader, and the platform loader asks the bootstrap loader. A loader looks for a class itself only when the loaders above it cannot find the class. So 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#
Means
The class was found, but its static initialiser threw an exception. The static initialiser is the code that sets the static fields of the class. ExceptionInInitializerError does the same as TypeInitializationException in .NET. The Caused by line shows the real exception.
Fix
Fix the cause. Every later use of the class fails with 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%");
}
Note

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);
}
Reading the code
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_8 is StandardCharsets.UTF_8, imported with a static import.

Gotcha

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#
Means
The code read a resource as a file on disk. That path exists in your project folder, but not next to a packaged JAR.
Fix
Read the resource through the classpath, with getClass().getResourceAsStream("/rates.json").
Cannot invoke "java.io.InputStream.readAllBytes()" because "in" is null#
Means
getResourceAsStream found nothing with that name. It returned null and did not throw. The null then causes this exception on the next line.
Fix
Check the name. A name with a leading slash, such as "/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.

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

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

// system property, set with -Drates.url=...
String url2 = System.getProperty("rates.url");

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:

SettingPassed asRead withSpring key
Environment variableRATES_URL=...System.getenvrates.url, by relaxed binding
System propertyjava -Drates.url=... -jar app.jarSystem.getPropertyrates.url
Command-line argumentjava -jar app.jar --rates.url=...Spring onlyrates.url
Gotcha

When the same key is set more than one way, Spring Boot prefers the command-line argument, then the system property, then the environment variable. So a -D flag in a startup script overrides the environment variable that your container platform sets, and nothing reports it. For the full order, see the chapter Dependency injection and configuration.

Gotcha

-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
.NETMavenNote
~/.nuget/packages~/.m2/repositorya shared cache on your machine, holding one copy of each version
nuget.config sources~/.m2/settings.xmlsays where packages are downloaded from, so it holds repositories, mirrors and credentials
obj/project.assets.jsonthe resolved dependency treecomputed 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 use

mvn 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#
Means
A JAR in the local repository is incomplete or corrupt, usually because a download was interrupted. Two other messages mean the same thing. One message is invalid LOC header. The other message is Invalid or corrupt jarfile, which comes from java -jar.
Fix
Delete the folder of that artifact under ~/.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#

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

So the 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'#
Means
The JVM does not know an -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.
Fix
Correct the spelling. java -XX:+PrintFlagsFinal -version lists every option the JVM accepts.
What triggers it
java -XX:MaxRAMPercentag=70 -jar app.jar
Check yourself
1
What does UnsupportedClassVersionError: class file version 69.0 mean?
The class was compiled for a newer Java release than the release that runs it. The class file version is the release plus 44. So 69 is Java 25, 65 is Java 21, and 61 is Java 17. Check maven.compiler.release against the Java that runs the service.
2
An 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?
The JVM takes the first copy of a class that it finds on the classpath, and then it stops searching. So the other JAR is never read. Code that was compiled against the other version then fails with NoSuchMethodError at run time, far from its cause.

Quick reference#

This table shows the commands that this chapter used:

TaskCommandNote
Compilejavac -d out src/com/acme/*.javawrites one class file per class, in folders that match each package
Run from classesjava -cp out com.acme.Appgive the classpath first, then the fully qualified name of the class with main
Packagejar --create --file app.jar --main-class com.acme.App -C out .zips the classes and records the main class in the manifest
Run a JARjava -jar app.jartakes everything from the manifest, and ignores -cp and CLASSPATH
Pass a settingjava -Drates.url=... -jar app.jarthis sets a system property, and the -D must come before -jar
Read a settingSystem.getProperty("rates.url")environment variables are read with System.getenv instead
Read a resourcegetClass().getResourceAsStream("/rates.json")goes through the classpath, so it also works inside a JAR
See the classpathmvn dependency:build-classpathprints every JAR Maven puts on the classpath, in order

Maven vs csproj#

Build and tooling · Chapter 31· 9 min read· Quick reference

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.

KindC#JavaNote
twistany folder layoutsrc/main/java and src/test/javaMaven expects every project to use the same folders Why
twistInternalsVisibleToa test in the same packagea test under src/test/java, in the package of the code it tests, sees package-private members Why
twistPackageIdgroupId + artifactIdan org prefix plus a name, so vendors never collide Why
twistVersionversionSNAPSHOT means "in development" Why
twistTargetFrameworkmaven.compiler.releasenames the Java release to compile for, as TargetFramework names the .NET release Why
twistPackageReferencedependencya dependency element, identified by group, artifact and version Why
twistProjectReferencedependency on a sibling modulewritten exactly like any other dependency, by coordinates Why
newno equivalent./mvnw packagerunning a phase runs every phase before it, so this command compiles, tests and then packages Why
twistdotnet add package Serilogno command, so edit pom.xmlthere is no mvn add command, so you add a dependency element to pom.xml yourself Why
twistdotnet restoreno separate stepMaven downloads what it needs during the build, into ~/.m2/repository Why
twistnuget.configsettings.xmlthis file is in ~/.m2, and it lists repositories, mirrors and credentials Why
twistglobal.jsonmvnw, the Maven wrappercommit it, because it fixes the Maven version and downloads that version on first use Why
twistDirectory.Build.propsa parent POMchildren inherit from the parent POM, but the parent POM is not textually included Why
twista Boot 3 tutorial on Boot 4spring-boot-starter-webmvcthe web starter was named spring-boot-starter-web until Boot 4.0 Why
twistDirectory.Packages.propsdependencyManagementsets versions centrally, so child modules can leave them out Why
twistno single equivalenta Maven profile, switched on with -Pa named block of configuration that varies the build, never the product Why
trapNuGet takes the higher versionMaven takes the nearest declarationadding 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.

Gotcha

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:

csproj
<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>
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>

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

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

  <dependencies>
    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-api</artifactId>
      <version>2.0.16</version>
    </dependency>
  </dependencies>
</project>
Reading the code
<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, groupId and artifactId do the same as PackageId in a csproj file.

<version>1.0.0-SNAPSHOT</version>

SNAPSHOT marks 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 TargetFramework in a csproj file.

<artifactId>slf4j-api</artifactId>

This is a dependency. It is named by its group, artifact and version, like a PackageReference in 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>
Reading the code
<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 ordinary PackageReference. The import scope is only for importing a bill of materials (BOM). For an example, see Profiles and BOMs.

ScopeAvailable at.NET analogy
compileeverywhere, and it is the defaultnormal PackageReference
providedcompile and test, not packageda framework reference
runtimerun and test, not compilea runtime-only dependency
testtest onlya test-project-only reference
importonly in dependencyManagementfor BOMs

The lifecyclenew#

Running a phase runs every phase before it. MSBuild has no equivalent for this idea. The idea explains the Maven command line.

validate compile test package verify install deploy mvn package runs every phase up to and including package
The main phases of the Maven lifecycle are validate, compile, test, package, verify, install and deploy, in that order. Maven has more phases between them. Running a phase runs every phase before it, so mvn package also compiles and tests.

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
Reading the code
./mvnw package

This command runs validate, compile and test. Then it builds the JAR in the target folder.

./mvnw install

This 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.

Note

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:tree

Maven 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
Reading the code
./mvnw -o package

-o makes Maven work offline. Maven then uses only what is already in ~/.m2.

./mvnw -pl billing-api -am package

-pl selects one module of a multi-module build. -am also 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.

Gotcha

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>
Reading the code
<id>integration</id>

This is the profile's name. You use the name with -P on the command line.

<property><name>env.CI</name></property>

This element switches the profile on automatically when the CI environment 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 verify phase.

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
Reading the code
./mvnw -Pintegration verify

-P switches a profile on by name.

./mvnw help:active-profiles

This command lists the profiles that are really on. Check this list first when a profile seems to be ignored.

Gotcha

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>
Reading the code
<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.

Note

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:

PredictTwo versions of one library in the dependency tree#
Which version of jackson-databind does Maven use?
billing
├── reports-client
│   └── jackson-databind 2.15.0
└── pdf-export
    └── templating
        └── jackson-databind 2.17.0
Show the answer
2.15.0

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>.

Gotcha

“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
Reading the code
./mvnw dependency:tree

This command prints every dependency as a tree. The tree also shows the dependencies that each dependency brings in.

-Dincludes=com.fasterxml.jackson.core:jackson-databind

This option shows only the paths that lead to jackson-databind. Use it to find which dependency brought in an unexpected version.

Check yourself
1
What does ./mvnw package do before it builds the JAR?
It validates, compiles and runs the tests, because running a lifecycle phase runs every phase before it.
2
An expert asksA Spring Boot pom lists spring-boot-starter-test with no <version>. Where does the version come from?
The version comes from the parent POM of Spring Boot. This parent POM sets compatible versions for several hundred libraries. A project that cannot inherit that parent POM imports the same versions as a bill of materials (BOM). The import uses scope=import and type=pom.

Quick reference#

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

Gradle and multi-module builds#

Build and tooling · Chapter 32· 6 min read· a look-up chapter: Essentials shows its table, and each section opens· Quick reference

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.

KindC#JavaNote
twista .csproj filebuild.gradle.ktsa Gradle build file is a Kotlin or Groovy program, not an XML document Why
newno equivalentMaven or GradleIf you are choosing, pick Maven. Choose Gradle when build times are a real problem. Why
twistPackageReferenceimplementation("org.slf4j:slf4j-api:2.0.16")a dependency is one string that holds the group, artifact and version, separated by colons Why
twistPrivateAssets="compile" on a PackageReferenceimplementation against apian implementation dependency stays off the compile classpath of projects that use yours Why
twistSolution (.sln)aggregator pom with <modules>, or settings.gradle.ktsone file at the root lists the modules that are built together Why
twistProjectReferencea normal dependency on the sibling, or project(":billing-domain")Maven names a sibling module by its coordinates, and Gradle by its path Why
twistDirectory.Packages.props<dependencyManagement>, or a version catalog (libs.versions.toml)versions are declared once at the root, and every module inherits them Why
twistBuild the solutionmvn package at the root, or ./gradlew build at the rootthe root build compiles, tests and packages every module Why
trapBuild one projectmvn -pl billing-api -am package, or ./gradlew :billing-api:buildwithout -am, Maven expects the sibling modules to be installed already Why
twistnuget.orgMaven Central (repo1.maven.org)Maven Central is the public repository, and a company repository fetches from it for you Why
twist~/.nuget/packages~/.m2/repositorythe local repository, where downloaded artifacts are kept on your machine Why
twistnuget.config~/.m2/settings.xmlset it up once, to send every download through the company repository Why
twistAzure Artifacts / GitHub PackagesNexus, Artifactory, GitHub Packagesin a company, every download usually goes through one of these Why
trappackages.lock.jsonMaven has none, so set exact versions, but Gradle has gradle.lockfileMaven 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:

AspectMavenGradle
FormatXML, declarativeKotlin or Groovy DSL, imperative
Learning curveshallow, because conventions do the worksteeper, with more freedom to make mistakes
Speed on big buildsslowermuch faster, because it is incremental and has a build cache
Customisationwrite or find a pluginwrite code inline
Predictabilityvery highdepends on the author
Ecosystem defaultenterprise, Spring tutorialsAndroid, 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}") }
}
Reading the code
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.

Note

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#

Maven: pom.xml
<properties>
  <maven.compiler.release>25</maven.compiler.release>
</properties>

<dependencies>
  <dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>2.0.16</version>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.0.3</version>
    <scope>test</scope>
  </dependency>
</dependencies>
Gradle: build.gradle.kts
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")
}
Reading the code
plugins {

Plugins give a build its abilities. The java plugin 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.release in 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 test scope in Maven.

A Maven scope is called a configuration in Gradle. This table maps each scope to its configuration:

Maven scopeGradle configurationMeaning
compileimplementationused internally, but NOT exposed to consumers
compile (exported)apiexposed to consumers transitively
providedcompileOnlyneeded to compile, but supplied at run time by the server
runtimeruntimeOnlyneeded only at run time, such as a JDBC driver
testtestImplementationon the test classpath only, so it is never shipped
Gotcha

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 deployable

Only billing-api is deployed. The aggregator's own pom.xml lists the modules, and declares their versions once:

<!-- billing/pom.xml; the aggregator -->
<packaging>pom</packaging>

<modules>
  <module>billing-domain</module>
  <module>billing-persistence</module>
  <module>billing-api</module>
</modules>

<!-- versions declared once, inherited by every module -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.acme</groupId>
      <artifactId>billing-domain</artifactId>
      <version>${project.version}</version>
    </dependency>
  </dependencies>
</dependencyManagement>
Reading the code
<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
Reading the code
./gradlew build

This command compiles, tests and builds every module, like mvn package at the root.

./gradlew :billing-api:bootRun

The colon path names one module, which is billing-api. bootRun starts the Spring Boot application of that module.

./gradlew dependencies --configuration runtimeClasspath

This 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.

Note

-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>
Reading the code
<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 -->
Gotcha

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.

Check yourself
1
You are choosing a build tool for a new service. Which one, and when would you choose the other?
Choose 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.

Quick reference#

TaskMavenGradle
Compilemvn compile./gradlew classes
Testmvn test./gradlew test
Packagemvn package./gradlew build
Cleanmvn clean./gradlew clean
Run a Boot appmvn spring-boot:run./gradlew bootRun
Dependency treemvn dependency:tree./gradlew dependencies
One modulemvn -pl mod -am package./gradlew :mod:build
Skip tests-DskipTests-x test
List tasksn/a./gradlew tasks

Modules, JPMS and the missing internal#

Build and tooling · Chapter 33· 6 min read· a look-up chapter: Essentials shows its table, and each section opens

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.

KindC#JavaNote
twistan assemblymodule com.acme.billing { ... }a module declares what it exports and what it requires, and the JVM enforces both Why
twistan assembly referencerequires java.sqlthe module needs java.sql to compile and to run Why
twistpublic types in the assemblyexports com.acme.billing.apionly the public types in an exported package are usable outside the module Why
twistinternala package the module does not exportits public types cannot be used from outside, but this works per package, not per type Why
twistInternalsVisibleTo("X")exports p to Xa qualified export makes one package visible to one named module Why
twistInternalsVisibleTo, for a test projecttests in the same packagea test in the same package gets package-private access Why
newno equivalentjava -p mods -m com.acme.billing/com.acme.Appthe module path enforces module boundaries, but the classpath enforces nothing Why
trapreflection into private fieldsopens com.acme to a named moduleunder JPMS, Spring, Hibernate and Jackson can reflect only into an opened package Why
newno equivalent--add-opens java.base/java.lang=ALL-UNNAMEDThe JDK's internals are closed by default since Java 16. Since Java 17, only a flag like this reopens a package. Why
newno equivalentno module-info.java at alla 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
}
Reading the code
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.PaymentProvider

This 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:

DirectiveMeans.NET analogy
requires Xthis module needs module X to compile and to runassembly reference
requires transitive Xneeds X, and any module that requires this one gets X tooa public dependency
exports pthe public types in package p are usable outside the modulepublic types in the assembly
exports p to Mpackage p is visible only to MInternalsVisibleTo, inverted
opens plets frameworks reflect into p, private fields includedneeded by Spring, Hibernate, Jackson
uses / providesdeclares a service it looks up, or an implementation it suppliesDI at the platform level

This is the closest thing to internaltwist#

C#: per type, inside an assembly
// visible only within this assembly
internal class StripeGateway { }

// and grant one friend assembly access
[assembly: InternalsVisibleTo("Acme.Billing.Tests")]
Java: per package, inside a module
// module-info.java: the package is simply not exported,
// so its public types are unreachable outside the module
module com.acme.billing {
    exports com.acme.billing.api;
    // com.acme.billing.internal stays hidden
}
Reading the code
internal class StripeGateway { }

C# hides one type from other assemblies.

// com.acme.billing.internal stays hidden

Java hides a whole package, by not exporting it.

This table shows each C# visibility and its Java form:

GoalC#Java
Public to my code, hidden outsideinternala non-exported package in a module
Public to everyonepublicexported package + public type
Visible to my testsInternalsVisibleTotests in the same package
Visible to one other componentInternalsVisibleTo("X")exports p to X
Gotcha

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 visible
Reading the code
import 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#

ClasspathModule path
Flat namespaceyesno
Access enforcednoyes
Split packages allowedyesno
Needs module-infonoyes (or it becomes an automatic module)
Most Spring Boot appsthis onerarely
Note

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.App
Reading the code
java -cp "app.jar:lib/*" com.acme.App

This 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

-p gives the module path. -m names the module and its main class. On the module path, the module boundaries are enforced.

Why frameworks need opens#

Gotcha

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#
Means
A framework tried to read a private field by reflection. The module that owns the class has not opened the package of the class to the framework.
Fix
Add 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 InaccessibleObjectException

Flags you will meet#

FlagDoes
--add-opens M/p=ALL-UNNAMEDgrant deep reflection into a JDK package
--add-exports M/p=ALL-UNNAMEDgrant compile/runtime access to a non-exported package
--add-modules Madd a module not required transitively
--illegal-access=permitremoved in Java 17, 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
Reading the code
--add-opens java.base/java.lang=ALL-UNNAMED

This flag opens the java.lang package of the java.base module to all code on the classpath. The JDK calls the code on the classpath the unnamed module.

Gotcha

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:

SituationUse JPMS?
Spring Boot service in a containerno, because the fat JAR is the boundary
Library published to Maven Centralyes, publish at least an Automatic-Module-Name
Desktop app shipped with jlinkyes, jlink requires modules
Large internal platform with many teamsmaybe, enforced boundaries help
Anything on Java 8not available
Note

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>
Reading the code
<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.

Check yourself
1
A class is public, yet code in another module cannot import it. Why?
The module of the class does not export the package of the class. A package that is not exported is the closest thing Java has to internal. An export works per package. You cannot export a package and hide one class in it.

Testing#

Build and tooling · Chapter 34· 10 min read· Quick reference

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.

KindC#JavaNote
twistxUnit / NUnit / MSTestJUnit Jupiter (JUnit 5 or 6)the test framework nearly every Java project uses Why
twist[Fact]@Testmarks a test method, and the class needs no attribute Why
twistpublic class MoneyTestsclass MoneyTestTest classes and methods are package-private by convention. Only JUnit 4 required public. Why
sameAssert.Equal(expected, actual)assertEquals(expected, actual)The expected value comes first, as in xUnit. The AssertJ form assertThat(actual) removes the doubt.
twistFluentAssertionsAssertJfluent assertions that start with assertThat(actual), then isEqualTo or a similar method Why
twistConstructor / IDisposable@BeforeEach / @AfterEachmethods that run before and after every test Why
twistIClassFixture@BeforeAll / @AfterAllruns once per class, and the method must be static Why
twist[Trait]@Taglabels a test so a build can include or exclude it Why
twist[Theory] + [InlineData]@ParameterizedTest + @CsvSourceThe test runs once for each row of inline data. Use @ValueSource for a single argument. Why
twistMoqMockitoMockito is the standard. There is no .Object, because the mock is the object. Why
twistNSubstituteMockitoMockito covers this style too, and Java has no separate favourite for it Why
twistMoq Callback, and MockBehavior.StrictArgumentCaptor, and a default answer that throwscapture an argument, or fail on any call you did not stub Why
twistTestcontainersTestcontainersThis is the same project in another language. It runs a real database in Docker for the length of a test. Why
twistWireMock.NetWireMockWireMock is the Java original. WireMock.Net copies its features in a separate .NET codebase. Why
twistBenchmarkDotNetJMHA stopwatch loop measures the JIT, not your code. JMH handles warm-up and dead-code elimination. Why
twistArchUnitNETArchUnitarchitecture rules, such as layer boundaries, written as tests Why
twistcoverletJaCoCocode coverage, collected by an agent while the tests run Why
twistReportGeneratorjacoco:reportwrites an HTML report into target/site/jacoco Why
twistthreshold gate in CIjacoco:checkfails the build itself when coverage drops below the limit Why
twistxunit or xunit.v3, the package you referenceJUnit 5 on Spring Boot 3, JUnit 6 on Spring Boot 4Boot chooses the JUnit version. The programming model stays the same, but JUnit 6 needs Java 17 or later. Why
twistMethod_Condition_ResultmethodUnderTest_condition_expectedResult, or @DisplayNameJava 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:

xUnit
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() { }
}
JUnit 5 and 6
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() { }
}
Reading the code
@BeforeAll static void startDb() { }

This method runs once, before any test in the class, like an IClassFixture in xUnit. The method must be static, 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")

@Test marks a test method, like [Fact] in xUnit. @Tag labels 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:

xUnit
public class MoneyTests
{
    [Fact]
    public void Add_SumsAmounts()
    {
        var a = new Money(10m, "GBP");
        var b = new Money(5m, "GBP");

        var result = a.Add(b);

        result.Amount.Should().Be(15m);
    }
}
JUnit 5 and 6
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");
    }
}
Reading the code
class MoneyTest {

The class has no attribute and no public. JUnit finds the test methods by their @Test annotation.

void add_sumsAmounts() {

The test method returns void and takes no arguments.

.isEqualByComparingTo("15");

This is an AssertJ assertion, like Should().Be(...) in FluentAssertions. It compares BigDecimal values with compareTo, so 15 and 15.00 count as equal.

Note

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.

Note

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#

C#
[Theory]
[InlineData(1, 1, 2)]
[InlineData(2, 3, 5)]
public void Adds(int a, int b, int expected)
    => Assert.Equal(expected, Add(a, b));
Java 25
@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "2, 3, 5"
})
void adds(int a, int b, int expected) {
    assertThat(add(a, b)).isEqualTo(expected);
}
Reading the code
@ParameterizedTest

This 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:

SourceProvides
@ValueSource(ints = {1,2,3})one primitive argument
@CsvSource({"a,1", "b,2"})several arguments inline
@CsvFileSource(resources = "/data.csv")from a file
@MethodSource("provider")from a static method returning a Stream
@EnumSource(Status.class)every enum constant
@NullAndEmptySourcenull and empty string

Mockingtwist#

OrderService saves each order that it processes through an OrderRepo. The test replaces the OrderRepo with a mock:

Moq
var repo = new Mock<IOrderRepo>();
repo.Setup(r => r.Find(7))
    .Returns(new Order(7));

var svc = new OrderService(repo.Object);
svc.Process(7);

repo.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);
Mockito
OrderRepo repo = mock(OrderRepo.class);
when(repo.find(7))
    .thenReturn(new Order(7));

var svc = new OrderService(repo);
svc.process(7);

verify(repo, times(1)).save(any(Order.class));
Reading the code
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), like Setup(...).Returns(...) in Moq.

verify(repo, times(1)).save(any(Order.class));

This line checks that save was called exactly once, with any order. Moq does this with Verify and Times.Once.

Here is the rest of Moq:

MoqMockito
new Mock<T>()mock(T.class)
mock.Objectthe 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)
CallbackthenAnswer(invocation -> ...), or an ArgumentCaptor
MockBehavior.Strictno exact equal, so use a default answer that throws, or STRICT_STUBS
Gotcha

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 argument

Here 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); });
Reading the code
ArgumentCaptor.forClass(Order.class)

The captor keeps the argument of the verified call. In Moq, a Callback usually 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.Strict in Moq.

Gotcha

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
    }
}
Reading the code
@Testcontainers

This 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 package org.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 Postgres

The container reports where it is listening, so the code under test connects to a real database.

Note

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:

WireMock.Net
var server = WireMockServer.Start();
server
  .Given(Request.Create()
      .WithPath("/rates/GBPUSD").UsingGet())
  .RespondWith(Response.Create()
      .WithStatusCode(200)
      .WithBodyAsJson(new { rate = 1.27 }));
WireMock
@WireMockTest
class RatesClientTest {

  @Test
  void fetchesRate(WireMockRuntimeInfo wm) {
    stubFor(get("/rates/GBPUSD")
        .willReturn(okJson("{\"rate\":1.27}")));

    var client = new RatesClient(wm.getHttpBaseUrl());
    assertThat(client.fetch("GBPUSD").rate())
        .isEqualTo(1.27);
  }
}
Reading the code
@WireMockTest

This 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 GET for /rates/GBPUSD.

new RatesClient(wm.getHttpBaseUrl())

This line gives the real client the address of the fake server.

Note

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:

BenchmarkDotNet
[MemoryDiagnoser]
public class ParseBench
{
    [Params(10, 1000)]
    public int N;

    [Benchmark]
    public int Sum() => Enumerable.Range(0, N).Sum();
}

BenchmarkRunner.Run<ParseBench>();
JMH
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 10)
@Fork(2)
public class ParseBench {

    @Param({"10", "1000"})
    public int n;

    @Benchmark
    public int sum() {
        return IntStream.range(0, n).sum();
    }
}
Reading the code
@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.

Gotcha

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);
}
Reading the code
@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 @Autowired on 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>
Reading the code
<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 check goal 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#

Gotcha

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>
Reading the code
<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.Test in place of org.junit.jupiter.api.Test
  • public test classes
  • @Before in place of @BeforeEach
  • @After in place of @AfterEach
  • @RunWith in 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);
}
Reading the code
@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.

Check yourself
1
A 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?
Yes. Boot 4 brings JUnit 6 in place of JUnit 5, and both annotations work as before. Check the Java version first, because JUnit 6 needs Java 17 or later. If the build sets the versions of JUnit itself, the build needs one edit, because JUnit 6 has one version number for all its parts.
2
TranslateWrite 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);
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.

3
An expert asksWhy can a hand-rolled timing loop give a Java benchmark that is orders of magnitude too fast?
The JIT compiles hot code after thousands of iterations. It also deletes work whose result you discard. Use JMH, which handles warmup, forking and dead-code elimination.

Quick reference#

.NETJavaNote
AutoFixtureInstancio, EasyRandomfill objects with generated test data
BogusJava Faker, Datafakerfake but realistic names, addresses and so on
FsCheckjqwikproperty-based testing, which checks many generated inputs against one rule
Stryker.NETPITmutation testing, and PIT is the usual Java tool

Spring Boot orientation#

ASP.NET Core to Spring Boot · Chapter 35· 8 min read· Quick reference

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.

KindC#JavaNote
twistthe ASP.NET Core versionBoot 3.x or Boot 4.x, set by the parent in pom.xmlExisting systems and most tutorials use Boot 3. Boot 4 is for new work. Why
twistProgram.csa class marked @SpringBootApplication, with a main methodone class starts Boot, and it holds no list of registered services Why
trapa service registered from any namespacea class in the application class's package, or below ita class in a sibling package is never scanned, so its bean does not exist Why
twista PackageReference for each libraryone starter, such as spring-boot-starter-data-jpaa starter brings a set of libraries and switches on their configuration Why
twistno equivalentspring-boot-starter-web in Boot 3, spring-boot-starter-webmvc in Boot 4Boot 4 renamed its modules, and this is the rename you meet first Why
newbuilder.Services.AddDbContext<AppDb>(), written by youauto-configurationBoot creates the beans a library needs, unless you define your own Why
twistno equivalent--debugprints the conditions evaluation report, which lists what Boot configured and why Why
twistthe folders of a .NET projectsrc/main/java and src/main/resourcesthe application class is in the root package, above every other class Why
twista framework-dependent dotnet publish./mvnw package, then java -jarone runnable JAR holds your code, every dependency and an embedded Tomcat, and needs only a JVM Why
twistMinimal APIs, NativeAOTJavalin, Quarkus, MicronautBoot is the default. Quarkus and Micronaut start faster, and Javalin is tiny. Why

Which versiontwist#

Two version lines of Boot are in use:

LineSpring FrameworkBaselinesUse when
Boot 3.xFramework 6Java 17, Jakarta EE 10existing systems and most tutorials
Boot 4.xFramework 7Java 17 (25 recommended), Jakarta EE 11, Kotlin 2.2, GraalVM 25, JUnit 6greenfield
Note

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>
Reading the code
<artifactId>spring-boot-starter-parent</artifactId>

This is the parent Project Object Model (POM) of Boot. Your pom.xml inherits 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#

ASP.NET Core 8+
// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddDbContext<AppDb>();

var app = builder.Build();
app.MapControllers();
app.Run();
Spring Boot
// BillingApplication.java
@SpringBootApplication
public class BillingApplication {

    public static void main(String[] args) {
        SpringApplication.run(BillingApplication.class, args);
    }
}
// services are discovered by annotation, not registered here
Reading the code
@SpringBootApplication

This 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 here

There is no builder.Services list. Spring finds your classes by their annotations, such as @Service.

Which packages are scannedtrap#

PredictA service in a sibling package#
Does the application start?
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
No. Spring never creates an 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:

CapabilityBoot 3 starterBoot 4 starter
Web MVCspring-boot-starter-webspring-boot-starter-webmvc
Reactive webspring-boot-starter-webfluxspring-boot-starter-webflux
JPAspring-boot-starter-data-jpaspring-boot-starter-data-jpa
Flyway migrationsflyway-core, with no starterspring-boot-starter-flyway
Securityspring-boot-starter-securityspring-boot-starter-security
OAuth2 clientspring-boot-starter-oauth2-clientspring-boot-starter-security-oauth2-client
Validationspring-boot-starter-validationspring-boot-starter-validation
Testingspring-boot-starter-testspring-boot-starter-test
Actuatorspring-boot-starter-actuatorspring-boot-starter-actuator
SOAP servicesspring-boot-starter-web-servicesspring-boot-starter-webservices
OpenTelemetryn/aspring-boot-starter-opentelemetry

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>
Reading the code
<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.

Gotcha

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) { ... }
}
Reading the code
@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 DataSource yourself.

@Bean

The return value of the method becomes a bean. Spring can inject this bean into other classes.

Note

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
Reading the code
-Dspring-boot.run.arguments=--debug

This argument passes --debug to 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 -jar takes the same --debug flag.

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 SQL

BillingApplication 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#

Note

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
Reading the code
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

This command runs the service from source, with the dev profile active.

java -jar target/billing-1.0.0.jar --server.port=9090

This 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#

FrameworkPitchCompared to
Spring Bootthe default, with a vast ecosystemASP.NET Core
Quarkuscompile-time DI, fast startup, native-firstASP.NET Core + NativeAOT
Micronautcompile-time DI, no runtime reflectionsimilar to Quarkus
Javalintiny, explicit, no magicMinimal APIs
HelidonOracle, MicroProfile and NimaMinimal APIs

Quarkus and Micronaut do their dependency injection 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"; }
}
Reading the code
@Path("/hello")

@Path sets the path that this class answers, like @RequestMapping in Spring.

@GET

@GET marks a method that handles GET requests, like @GetMapping in Spring.

Check yourself
1
TranslateWrite 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();
// 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.

2
What does @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.
3
An 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?
The library lists its auto-configuration classes in a file under 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.NETSpring Boot
Rundotnet run./mvnw spring-boot:run
Run with a profileASPNETCORE_ENVIRONMENT=Development--spring.profiles.active=dev
Build a deployabledotnet publish./mvnw package
Run the artifactdotnet MyApp.dlljava -jar target/billing-1.0.0.jar
Watch and reloaddotnet watchspring-boot-devtools
Default port50008080
Change the port--urls--server.port=9090

Dependency injection and configuration#

ASP.NET Core to Spring Boot · Chapter 36· 12 min read

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.

KindC#JavaNote
twistbuilder.Services.AddScoped<OrderService>()@Service on the classthe annotation is the registration, so there is no registration list Why
newno equivalent for services, so each is registered by handcomponent scanningat startup Spring finds every annotated class under the application's package and makes a bean of it Why
sameconstructor injectionconstructor injectionA class with one constructor needs no annotation. Older code puts @Autowired on a field instead.
trapAddScoped, chosen every timesingleton, unless you say otherwiseone bean is shared by every request, so a field in it is shared too Why
twistAddSingleton, AddScoped, AddTransientsingleton, @RequestScope, @Scope("prototype")the same three lifetimes, under different names Why
twistAddKeyedScoped<IPaymentGateway, CardGateway>("card")@Service("card"), then @Qualifier("card")when two beans fit one type, a bean name picks between them Why
twistIEnumerable<IPaymentGateway>List<PaymentGateway> or Map<String, PaymentGateway>a List or Map parameter receives every bean of that type Why
twista factory lambda in AddSingletona @Bean method in a @Configuration classfor a class you cannot annotate, such as a library type Why
twistappsettings.jsonapplication.ymlthe same settings in YAML, where indentation shows nesting Why
trapa MongoDB connection string in appsettings.jsonspring.mongodb.uriOn Boot 3 it is spring.data.mongodb.uri. Boot 4 ignores that old name and tries localhost:27017. Why
twistASPNETCORE_ENVIRONMENTSPRING_PROFILES_ACTIVEan environment, such as dev or prod, is called a profile in Spring Why
twistappsettings.Development.jsonapplication-dev.ymlBoot also loads it when the dev profile is active, and its settings override application.yml Why
twistIHostEnvironment.IsDevelopment()@Profile("dev")the bean exists only while that profile is active Why
twist--environment Development--spring.profiles.active=deva command-line argument that switches a profile on Why
twistone environment name at a timeseveral active profiles: dev,localSpring switches on each name in the comma-separated list, but .NET reads the same text as one name Why
twistIOptions<RatesOptions>a @ConfigurationProperties record, injected directlybinds one section of the settings to a type, with no wrapper Why
twistRates__TimeoutSecondsRATES_TIMEOUT_SECONDSan environment variable sets rates.timeout-seconds, with one underscore Why
twistdotnet user-secretsapplication-local.yml, gitignoredSpring 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:

C#
// 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)
{
}
Java 25
// 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;
    }
}
Reading the code
@Repository

This annotation marks OrderRepository as a class that Spring should create. @Repository is for classes that talk to the database.

@Service

This annotation does the same for OrderService. This one line replaces the builder.Services line in C#.

private final OrderRepository repository;

final means that the field is set once, in the constructor, and never changes. A final field is like a readonly field in C#.

public OrderService(OrderRepository repository) {

Spring sees that the constructor needs an OrderRepository. It finds the OrderRepository that 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:

AnnotationMeansNote
@Componentany class Spring should create and injectan object that Spring creates and injects is called a "bean"
@Servicea component that holds business logicworks the same as @Component, but the name shows what the class is for
@Repositorya component that does data accessalso converts database errors into Spring's own exception types
@Controller / @RestControllera component that handles web requests
@Configurationa class that declares @Bean methods
@Beana method whose return value becomes a beanuse 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;
}
Reading the code
@Autowired

@Autowired tells 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#

PredictA bean that keeps the current order in a field#
Two customers pay at the same moment. What can go wrong?
@Service
public class PaymentService {

    private Order current;

    public void pay(Order order) {
        current = order;
        charge(current);
    }

    private void charge(Order order) { }
}
Show the answer
Both requests can charge the second customer's order.

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 CoreSpringNote
AddSingletonsingletonthe default in Spring, which gives one shared instance for the whole application
AddScopedrequestone instance per HTTP request, declared with @RequestScope
AddTransientprototypea new instance each time the bean is injected
n/asessionone instance per user's HTTP session
n/aapplicationone per deployed web application

The three lifetimes, side by sidetwist#

C#
// 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>();
Java 25
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 { }
Reading the code
class SystemTime { }

There is no scope annotation, so this bean is a singleton. Spring creates one SystemTime and injects that same object everywhere.

@RequestScope

Spring creates one Cart for each HTTP request, like AddScoped in ASP.NET Core. A singleton can still take a Cart in 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 IdGenerator every time one is injected, like AddTransient in 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:

C#
var url = builder.Configuration["Rates:Url"]!;

builder.Services.AddSingleton(sp =>
    new HttpClient { BaseAddress = new Uri(url) });
Java 25
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);
    }
}
Reading the code
@Configuration

@Configuration marks a class that holds @Bean methods. Spring calls these methods at startup.

@Bean

The object that the method returns becomes a bean. The bean gets the name of the method, which is ratesRestClient. Any class can now take a RestClient in its constructor. Bean names must be unique. Two beans with the same name stop Boot from starting.

@Value("${rates.url}") String url

Spring injects the parameters of a @Bean method, 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:

C#
// CardGateway and BankGateway both
// implement IPaymentGateway
builder.Services
    .AddKeyedScoped<IPaymentGateway, CardGateway>("card")
    .AddKeyedScoped<IPaymentGateway, BankGateway>("bank");

public class CheckoutService(
    [FromKeyedServices("card")] IPaymentGateway gateway)
{
}
Java 25
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;
    }
}
Reading the code
@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 be cardGateway.

@Qualifier("card") PaymentGateway gateway

@Qualifier asks for the bean named card, 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:

NeedSpring
Pick one by name@Qualifier("name")
Prefer one by default@Primary on that bean
Inject all of themList<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"
    }
}
Reading the code
PaymentRouter(Map<String, PaymentGateway> gateways) {

A Map parameter with String keys receives every bean of that type, keyed by bean name. Here the names are card and bank. A List<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:

appsettings.json
{
  "ConnectionStrings": {
    "Default": "Server=localhost;Database=billing"
  },
  "Rates": {
    "Url": "https://rates.example.com",
    "TimeoutSeconds": 5
  },
  "Logging": {
    "LogLevel": { "Default": "Information" }
  }
}
application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost/billing

rates:
  url: https://rates.example.com
  timeout-seconds: 5

logging:
  level:
    root: INFO
    com.acme.billing: DEBUG
Reading the code
url: jdbc:postgresql://localhost/billing

This is the setting spring.datasource.url. Each level of indentation adds one part to the name. Settings under spring: belong to Spring Boot itself. This setting is the database address, in the form used by Java Database Connectivity (JDBC).

timeout-seconds: 5

Your own settings can have any name. The convention in Boot is lower case with hyphens.

com.acme.billing: DEBUG

This 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/billing
spring:
  mongodb:
    uri: mongodb://localhost:27017/billing
Reading the code
uri: mongodb://localhost:27017/billing

This 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 is spring.mongodb.uri.

Gotcha

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:

C#
// run with ASPNETCORE_ENVIRONMENT=Development;
// .NET then also loads appsettings.Development.json
if (builder.Environment.IsDevelopment())
    builder.Services
        .AddSingleton<IEmailSender, FakeEmailSender>();
Java 25
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) { }
}
Reading the code
SPRING_PROFILES_ACTIVE=dev

This is an environment variable that switches on the dev profile, like ASPNETCORE_ENVIRONMENT in ASP.NET Core. Boot then also reads application-dev.yml. Its settings override the settings in application.yml.

@Profile("dev")

Spring creates this bean only when the dev profile 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) { }
}
Reading the code
@Profile("!prod")

! means not. The bean is created unless the prod profile 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:

C#
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)
{
}
Java 25
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) { }
}
Reading the code
@ConfigurationProperties(prefix = "rates")

This annotation binds the rates section of application.yml to 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 timeoutSeconds gets the value of timeout-seconds.

@EnableConfigurationProperties(RatesProperties.class)

This annotation tells Spring to create a RatesProperties bean. On a larger service, put @ConfigurationPropertiesScan on 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) {
    }
}
Reading the code
@Value("${rates.url}") String url

${rates.url} is a placeholder. Spring replaces the placeholder with the value of the rates.url setting. 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.jar
Reading the code
export RATES_URL=https://rates.staging.example.com

This line sets the environment variable RATES_URL. Relaxed binding reads RATES_URL as rates.url.

java -Drates.url=https://rates.example.com -jar billing.jar

This 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:

PrioritySource
1command-line arguments
2SPRING_APPLICATION_JSON
3Java system properties, set with -D
4OS environment variables
5application-{profile}.yml
6application.yml
7@PropertySource
8defaults in code
Note

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.

Gotcha

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:

.NETSpring
User Secretsapplication-local.yml, gitignored
Azure Key VaultSpring Cloud Azure, or HashiCorp Vault through Spring Cloud Vault
AWS Secrets ManagerSpring Cloud AWS
Environment variablesenvironment variables
.NET refresherdotnet user-secrets
dotnet user-secrets init
dotnet user-secrets set "Db:Password" "dev-only-secret"   # stored per user, outside the repo
Gotcha

Spring 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=local
Reading the code
password: dev-only-secret

This is the development password. The file is listed in .gitignore, so git never commits the file.

-Dspring-boot.run.profiles=local

This argument runs the service with the local profile. So Boot also loads application-local.yml, and its settings override the settings in application.yml.

Check yourself
1
TranslateWrite 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) { }
@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.

2
The 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?
Put @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.
3
An expert asksOlder Spring code puts @Autowired on a private field. Why does new code take the dependency through the constructor instead?
A constructor parameter can be stored in a 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#

ASP.NET Core to Spring Boot · Chapter 37· 12 min read· Quick reference

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.

KindC#JavaNote
newservices.Decorate<IOrderService, TransactionalDecorator>()an annotation such as @Transactional, and Spring makes the proxythe wrapper is generated while the program runs, and no code shows it Why
trapthis.Place(order) skips a decorator too, but you wrote that decoratorplace(order) from inside the class skips the proxya call that never leaves your object starts no transaction, and no code shows why Why
newno equivalenta CGLIB subclass, OrderService$$SpringCGLIB$$0Boot generates a subclass as the proxy, even for a bean with an interface Why
trapa method is not virtual unless you say soa final method loses its annotationIt runs on the proxy itself, with no transaction and with null injected fields. A final class stops startup. Why
twistorderService.GetType().FullNameorderService.getClass().getName()prints the generated subclass's name, because you hold the proxy Why
twistIHostedServicea bean that implements ApplicationRunnerBoot calls its run method one time, after the application context has started Why
twistwork in a constructorApplicationRunner, or a @PostConstruct methodwhile a constructor runs, other beans may not exist yet Why
twistthe service providerthe application context, with its BeanPostProcessorsa post-processor can replace a new bean, which is how proxies are made Why
twistmiddleware or an action filteran @Aspect class with @Aroundwraps one behaviour around many bean methods, not only controllers Why
twistno equivalent#{...} in an annotation valueSpEL evaluates an expression, but ${...} only reads a setting Why
twista dependency cycle throwsa cycle stops Boot at startupcycles are rejected by default since Boot 2.6, so extract a third bean Why
twistthe order of registration@Order(1) on the beansets 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:

C#: you wire it yourself
// 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 code
Spring: the container wraps it
import 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 exists
Reading the code
services.Decorate<IOrderService, TransactionalDecorator>();

Scrutor wraps OrderService in your own TransactionalDecorator class, so the wrapping appears in Program.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#

PredictA method that calls a @Transactional method of its own class#
A controller calls placeAll. Does each call to place run in a transaction?
@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
No. No transaction starts, and nothing warns you.

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.

caller OrderService$$SpringCGLIB starts the transaction checks @PreAuthorize the proxy: what is actually injected OrderService, your object the real object: the proxy delegates to it this.place(order) OrderService.place() a plain call inside your object: it never passes through the proxy
In a call through a Spring proxy, the caller holds the generated OrderService$$SpringCGLIB subclass. The subclass starts the transaction and delegates to your object. A this.place(order) call inside your object never passes through the proxy, so place's annotations do nothing.

There are four ways to fix this:

FixHowCost
Move the method to another beaninject it and call itbest, 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 itneeds exposeProxy=true, and is obscure
Use AspectJ weaving instead of proxiescompile-time or load-time weavingpowerful, much heavier
Note

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.

PredictA final method marked @Transactional#
A controller calls place on the injected OrderService. Does it run in a transaction?
@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
No. It runs on the proxy object itself: no transaction starts, and the proxy's injected fields are null, so 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:

KindUsed whenBuilt byLimitation
JDK dynamic proxythe bean implements an interface, and proxyTargetClass is falsejava.lang.reflect.Proxyonly interface methods are proxied
CGLIB proxythere is no interface, or proxyTargetClass is truea generated subclasscannot proxy final classes or final methods
Note

Spring Boot sets proxyTargetClass=true by default. So you almost always get a CGLIB subclass, 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$$0
Reading the code
System.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$$0

This is the name of a subclass of OrderService that 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
    }
}
Reading the code
class Warmup implements ApplicationRunner {

Boot finds every bean that implements ApplicationRunner. After startup, Boot calls the run method of each of these beans. The run method is like StartAsync in ASP.NET Core.

Gotcha

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:

scan, and read @Bean methods instantiate constructor injection happens here populate @Value, @Autowired fields and setters postProcessBeforeInitialization every BeanPostProcessor sees the bean @PostConstruct, afterPropertiesSet your initialisation code runs postProcessAfterInitialization proxies are created here the bean handed to everyone else may be a proxy, not your object @PreDestroy, destroy() at container shutdown
In the Spring bean lifecycle, Spring instantiates a bean, populates it, runs the initialisation callbacks and then post-processes it. Proxies are created in postProcessAfterInitialization, so the bean everyone else receives may be a wrapper, not your object.

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 CoreSpringWhen
constructorconstructordependencies arrive
n/a@PostConstructafter all dependencies are set
n/aInitializingBean.afterPropertiesSet()same point, interface form
n/aBeanPostProcessoraround every bean, and this is how the framework extends itself
IDisposable / IAsyncDisposable@PreDestroycontainer shutdown
IHostedService.StartAsyncApplicationRunner, @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);
        }
    }
}
Reading the code
@Aspect

This annotation marks the class as an aspect. @Component makes 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 finally block 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:

AdviceRuns.NET analogy
@Beforebefore the methodfilter OnActionExecuting
@AfterReturningafter a successful returnOnActionExecuted
@AfterThrowingonly on exceptionexception filter
@Afteralways, like finallyfinally block
@Aroundwraps 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 expressionMatches
execution(* com.acme..*Service.*(..))any method on any *Service under that package
@annotation(com.acme.Audited)any method annotated @Audited
@within(org.springframework.stereotype.Service)any method in a class annotated @Service
within(com.acme.billing..*)any method in that package tree
args(String, ..)first argument is a String
Gotcha

Aspects are applied by the same 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;
    }
}
Reading the code
#{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) { ... }
Reading the code
@PreAuthorize("#order.owner == authentication.name")

#order is the order parameter, and authentication is 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.

Gotcha

${...} 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) { }
}
Reading the code
OrderService(BillingService billing) { }

To create an OrderService, Spring needs a BillingService. But to create a BillingService, Spring needs an OrderService.

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]
└─────┘
Reading the code
┌─────┐

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:

FixNote
Extract the shared logic into a third beanthe usual fix, because a cycle often hides a missing third responsibility
@Lazy on one of the injection pointsSpring injects a proxy and creates the real bean on first use, but this hides a design problem
spring.main.allow-circular-references=truethis setting enables the old behaviour again, so avoid it
Setter injectionThis no longer works. Since Boot 2.6 a cycle through setters or fields fails too, unless circular references are allowed.
Note

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:

NeedAnnotation
Order a list of injected beans@Order(n) on each, or implement Ordered
Force one bean to be built first@DependsOn("otherBean")
Register only if a property is set@ConditionalOnProperty
Register only if a class is present@ConditionalOnClass
Register only if nobody else defined one@ConditionalOnMissingBean
Register only in some profiles@Profile("!prod")

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
}
Reading the code
@Component @Order(1) class AuditStep implements CheckoutStep { }

A bean with a lower number comes first in any injected List of this type.

Checkout(List<CheckoutStep> steps) { }

The constructor receives every CheckoutStep bean, sorted by @Order.

Gotcha

@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.

Check yourself
1
TranslateWrite 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>();
@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.

2
An 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?
This class is the proxy that every other class has injected. Boot sets 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#

SymptomCauseFix
@Transactional does nothingthe call comes from the same classcall it from another bean
One annotated method is ignoredthe method is final, so it runs on the proxy itself, where injected fields are nullremove final
Cannot subclass final classa final class cannot be proxiedremove final
The beans form a cycle at startuptwo beans need each otherextract a third bean

Controllers, routing and binding#

ASP.NET Core to Spring Boot · Chapter 38· 11 min read· Quick reference

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.

KindC#JavaNote
twist[ApiController] on a ControllerBase class@RestControllerevery 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, @DeleteMappingthere 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
twistroute constraint :inta 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]@PathVariablebinds a part of the URL path, converted to the parameter's type Why
twist[FromQuery]@RequestParambinds one value from the query string, as in ?q=tea Why
trap[FromQuery] int page is 0 when missing@RequestParam int pagerequired by default, whatever the type, so a request without it gets 400 Why
twist[FromHeader]@RequestHeaderbinds a request header, which is also required by default Why
twist[FromBody]@RequestBodybinds the request body, which Jackson reads from JSON Why
twistno equivalentthe compiler's -parameters flagSpring reads a parameter's name from the class file, and Boot's parent POM sets the flag for you Why
twista request or response classa record, such as CreateOrderJackson reads and writes records directly, so a DTO should be one Why
twistOk(x), NotFound() and the other result helpersthe value itself, or a ResponseEntityYou return the value for a plain 200. A ResponseEntity sets any other status, and build() finishes a response with no body. Why
twistmiddleware and action filtersa servlet Filter and a HandlerInterceptora filter sees every request, but an interceptor wraps controller methods only Why
twistAddCors and UseCorsaddCorsMappings in a WebMvcConfigurerthe cross-origin rules are in one configuration class Why
twistversioned 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
twistAddSwaggerGen()the springdoc-openapi-starter-webmvc-ui dependencythe UI appears with no configuration, generated from your controllers Why

A controllertwist#

ASP.NET Core
[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);
    }
}
Spring MVC
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);
    }
}
Reading the code
@RestController

This annotation makes the class a bean. The return values of its methods are written into the response body as JSON, by Jackson. @RestController does the same as [ApiController] and ControllerBase together.

@RequestMapping("/api/orders")

This is the path prefix for every method, like [Route] in ASP.NET Core.

@PathVariable long id) {

This parameter takes id from the {id} part of the path. Spring converts the text to a long. If Spring cannot convert the text, the request fails with 400 Bad Request.

.map(ResponseEntity::ok)

find returns an Optional. For more about Optional, see the Optional chapter. If the Optional holds an order, the order becomes a 200 OK response. ResponseEntity::ok is a method reference. It is short for order -> 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 Location header for the new order, like CreatedAtAction in 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) { ... }
}
Reading the code
@RequestMapping(path = "/api/orders", produces = "application/json")

path is the prefix for every path in the class. produces says that every method returns JSON, like [Produces] in ASP.NET Core. In Spring, produces is 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/abc gets 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, @PostMapping and @PutMapping are three of them. The other two are @PatchMapping and @DeleteMapping.

Model bindingtrap#

PredictA query parameter the client leaves out#
A client sends GET /api/orders/search with no query string. What comes back?
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/search")
    public List<OrderDto> search(@RequestParam int page) {
        return List.of();
    }
}
Show the answer
400 Bad Request. 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:

C#
[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 = "")
{
    ...
}
Java 25
@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) {
    ...
}
Reading the code
@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 without q gets 400 Bad Request. A non-nullable string in an [ApiController] behaves the same way.

@RequestParam(defaultValue = "0") int page,

This parameter is optional. A missing page becomes 0.

@RequestParam(required = false) String status,

This parameter is optional. A missing status becomes null.

@RequestHeader(value = "X-Tenant",

This value comes from the X-Tenant header, like [FromHeader]. Headers are required by default too, so this header is marked required = 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.#
Means
Spring needs the parameter's name, here 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.
Fix
The parent Project Object Model (POM) of Spring Boot sets the compiler's -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) { }
Reading the code
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}.

Gotcha

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#

C#
// 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");
Java 25
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));
Reading the code
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 a Resource, and the Content-Disposition header names the file.

Note

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 CoreSpringRuns
app.Use(...) middlewareServlet Filterbefore the dispatcher, and sees every request
IActionFilterHandlerInterceptoraround controller methods
IAsyncActionFilterHandlerInterceptor
IExceptionFilter@ControllerAdvice + @ExceptionHandler
IAuthorizationFilterSpring Security filter chain
Endpoint routingDispatcherServlet

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();
        }
    }
}
Reading the code
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
    }
}
Reading the code
public boolean preHandle(

This method runs before the controller method, like OnActionExecuting.

return true;

Returning true lets the request continue. Returning false stops 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):

C#
builder.Services.AddCors(o => o.AddDefaultPolicy(p => p
    .WithOrigins("https://shop.example.com")
    .WithMethods("GET", "POST")));

app.UseCors();
Java 25
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());
    }
}
Reading the code
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.

Gotcha

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() { ... }
}
Reading the code
@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-Version
Reading the code
header: X-API-Version

This setting tells Spring to read the version from the X-API-Version header. The version can also come from a query parameter, a path segment or a media type parameter. The other use settings 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.

Note

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
.NETSpring
Swashbuckle / NSwagspringdoc-openapi
[ProducesResponseType]@ApiResponse
[SwaggerOperation]@Operation
XML doc commentsjavadoc, plus annotations
/swagger/swagger-ui.html

Add the springdoc-openapi-starter-webmvc-ui dependency, and the UI appears with no configuration. 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) { ... }
Reading the code
@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.

Check yourself
1
TranslateWrite 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();
}
@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.

2
An 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+}")?
With /{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 CoreSpringNote
[FromForm]@RequestParam / @ModelAttributethe same annotations bind the fields of submitted form data
[FromServices]just take a constructor dependencythe container supplies it, through the controller's constructor
Request.Cookies["theme"]@CookieValueIt binds the value of one cookie to a method parameter. ASP.NET Core has no binding attribute for a cookie.
IFormFile@RequestPartbinds one part of a multipart request, such as an uploaded file, to a method parameter
ASP.NET CoreSpringNote
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 valuea 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 bodyfor a download, the body is the bytes as a Resource, and a Content-Disposition header names the file
Problem()a ProblemDetail bodyIt is the standard JSON error body. See the chapter Validation and error responses.

Validation and error responses#

ASP.NET Core to Spring Boot · Chapter 39· 10 min read

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.

KindC#JavaNote
trap[Required]@NotNullrejects null only, so an empty string still passes Why
twist[Required] on a string@NotBlankrejects null, empty and whitespace-only strings Why
twist[MinLength(1)] on a collection@NotEmptyrejects 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]@Emailtreats 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 equivalentJakarta Validation has none, so write a class-level constraint Why
newno equivalent@Positive, @Negative, @PositiveOrZeroconstraints on the sign of a number, which .NET lacks Why
newno equivalent@Past, @Future, @PastOrPresentconstraints on dates in the past or future, which work on java.time types Why
trapa nested object is validated without being asked@Valid on the nested fieldwithout it, the constraints inside the nested object are never checked Why
twist[CreditCard]@CreditCardNumberfrom Hibernate Validator, not the Jakarta standard set Why
trap[ApiController] automatic 400@Valid on the parameterwithout @Valid nothing is checked, and nothing warns you Why
twistvalidation of a single query parameter@RequestParam @Min(0) int pagechecked with no @Valid, and fails with a different exception Why
twistAddProblemDetails()spring.mvc.problemdetails.enabled: trueBoot's RFC 9457 error bodies are switched off by default Why
twistan errors member that lists each fieldno field list in the default bodySpring's default body says only "Invalid request content." Why
twistthe errors member, filled in for youoverride handleMethodArgumentNotValidmost teams add each failing field to the body themselves Why
twistIExceptionFilter@RestControllerAdvice with @ExceptionHandlerone class turns an exception into a response for every controller Why
trapa second handler class for validation errorshandleMethodArgumentNotValid, overridden in the one advice classthe one advice class already handles this exception, and which of two classes answers depends on their names Why
twista FluentValidation validator classan annotation plus a ConstraintValidator classa custom constraint is then used like any built-in one Why

Constraint annotationstwist#

C#
// System.ComponentModel.DataAnnotations
public record CreateOrder(
    [Required, StringLength(50)] string CustomerId,
    [Range(1, 100)] int Quantity,
    [EmailAddress] string Email);
Java 25
import jakarta.validation.constraints.*;

public record CreateOrder(
        @NotBlank @Size(max = 50) String customerId,
        @Min(1) @Max(100) int quantity,
        @Email String email) { }
Reading the code
@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 email

The value must look like an email address. Like most constraints, @Email treats a missing value as valid. To require a value, add @NotBlank as 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#

PredictA required name, marked @NotNull#
The client sends a name that is an empty string. Is the request rejected?
public record CreateCustomer(@NotNull String name) { }
Show the answer
No. An empty string passes. Only a null name is rejected.

@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#

PredictA controller parameter with no @Valid#
A client posts an order with a blank customer and a quantity of 0. What happens?
public record CreateOrder(
        @NotBlank String customerId,
        @Min(1) @Max(100) int quantity) { }

@PostMapping
public ResponseEntity<OrderDto> create(
        @RequestBody CreateOrder request) {
    ...
}
Show the answer
The order is accepted, and goes straight into your method.

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) {
    ...
}
Reading the code
@RequestParam @Min(0) int page

A request for page -1 is rejected with 400 Bad Request. This failure arrives as a HandlerMethodValidationException. A failed @Valid raises 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) { }
Reading the code
@Valid Money unitPrice) { }

This annotation tells the validator to check the constraints inside the Money as well.

Gotcha

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: true
Reading the code
enabled: true

Boot 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 @Valid is 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"
}
Reading the code
"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; }
}
Reading the code
super("No order with id " + orderId);

This is the exception's message. It becomes the detail of the response.

One class turns this exception into a 404 with a problem details body. The class does this for every controller:

C#
app.UseExceptionHandler(...);

// or a filter
public class ApiExceptionFilter : IExceptionFilter
{
    public void OnException(ExceptionContext ctx) { ... }
}
Java 25
@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;
    }
}
Reading the code
@RestControllerAdvice

This 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
}
Reading the code
"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:

NeedSpring
Handle one exception type@ExceptionHandler(X.class)
Handle several@ExceptionHandler({A.class, B.class})
Apply to all controllers@RestControllerAdvice
Apply to some controllers@RestControllerAdvice(basePackages = "...")
Override framework error shapesextend ResponseEntityExceptionHandler
Add fields to the responseproblemDetail.setProperty(k, v)

Returning field-level errors#

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);
}
Reading the code
protected ResponseEntity<Object> handleMethodArgumentNotValid(

Spring calls this method when @Valid fails. 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"
  }
}
Reading the code
"customerId": "must not be blank",

The default messages come from Hibernate Validator. To replace a default message, set the message attribute of the constraint, as in @NotBlank(message = "customer is required").

Gotcha

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) { ... }
}
Reading the code
@ExceptionHandler(MethodArgumentNotValidException.class)

This method handles a failed @Valid on a request body, in a separate class.

Gotcha

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) { }
Reading the code
@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 {

@interface declares 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 groups and payload. 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 value ABC is 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.

Check yourself
1
TranslateWrite 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 };
        }
    }
}
@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.

2
An expert asksWhy must an error handler cover two different exceptions for failed validation?
A failed @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#

ASP.NET Core to Spring Boot · Chapter 40· 16 min read· Quick reference

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.

KindC#JavaNote
twistADO.NETJDBCthe low-level API that sends SQL and reads rows Why
twistEF CoreSpring Data JPA, on HibernateHibernate implements the JPA standard, and Spring Data writes the repositories Why
twistOnModelCreatingannotations, or orm.xmlthe mapping is written on the entity class itself Why
twistDbContextEntityManagertracks loaded entities and writes their changes on commit Why
trapSaveChangesno call, because Hibernate writes on commit, which it calls a flushin a @Transactional method a change to a loaded entity is written with no call to save Why
twistInclude()@EntityGraph, or JOIN FETCHloads an association in the same query as its owner Why
twistAsNoTracking()a read-only transaction, or a projectionHibernate then skips watching loaded objects for changes Why
twistFromSqlRaw@Query(nativeQuery = true)plain SQL for your database, with named parameters Why
twist[Table], [Column]@Entity, @Table, @Columnannotations map the class to a table and its fields to columns Why
twistan entity classa class, never a recordJPA creates the object empty, then fills in its fields, and a record has no empty constructor Why
trapan enum property@Enumerated(EnumType.STRING)the default stores the position, so a reordered enum changes old rows Why
twistEquals and GetHashCodeinstanceof, the id, a constant hashCodeCompare by id only. The id is null before saving, and Hibernate gives you proxies. Why
newDbSet<T>a Spring Data repositoryan interface whose method names, such as findByCustomerId, are the queries Why
trapa loop that reads order.Linesjoin fetch, @EntityGraph or a projectioncollections are lazy by default, so the loop runs one query per order, which is called the N+1 problem Why
trapa disposed DbContextLazyInitializationExceptiona lazy field cannot be loaded once the transaction has ended Why
traporder.Customer@ManyToOne(fetch = FetchType.LAZY)@ManyToOne and @OneToOne are EAGER unless you say LAZY Why
twistBeginTransaction and Commit@Transactional on the service methodcommits on return, and rolls back on an unchecked exception Why
twistMigrationsFlyway or Liquibasethese are separate tools, and the ORM does not own the schema Why
twista Down() methoda new SQL file, never an editFlyway only goes forward, and a changed file stops the startup Why
trapDatabase.EnsureCreated(), called in your codespring.jpa.hibernate.ddl-auto=validateIt is a setting, not a call. The value validate only checks the mapping, and update would alter your schema. Why
twistDapperJdbcClient, JdbcTemplate, JDBIruns your SQL and maps each row to a record Why
twistLINQ to SQLjOOQgenerates typed Java from your real schema, so renames break the build Why
Note

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#

EF Core
// 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();
Spring Data JPA
// 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
Reading the code
@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 FromSqlRaw in EF Core. :min is 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, SaveChanges writes pending changes in the same way.

An entitytwist#

C#
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; } = [];
}
Java 25
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
}
Reading the code
@Entity

This 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 @Column still maps to a column. Boot's default naming turns customerId into customer_id. In the same way, createdAt becomes created_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. mappedBy names the OrderLine field that holds the foreign key. Because of cascade and orphanRemoval, 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. protected stops your code from calling the constructor.

Two entity rules: no records, and enum columns by nametrap#

Gotcha

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.

Gotcha

@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:

ProblemWhy
The id is null before persistan 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 proxiesa lazy association is an object of a generated subclass, so a comparison with getClass() fails
Lombok @Data on an entityit 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();
    }
}
Reading the code
if (!(o instanceof Order other)) return false;

A proxy is a subclass of Order, so instanceof accepts a proxy. A comparison with getClass() 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.

Gotcha

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);
}
Reading the code
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. findBy starts the query. CustomerId names the field. The parameter gives the value.

findByCustomerIdAndStatusOrderByCreatedAtDesc(

Longer names combine conditions with And and Or, and sort with OrderBy. 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 Order and its field lines, not the tables. join fetch loads 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:

KeywordSQL
findBy / getBy / readByselect
And, Orand / or
Between, LessThan, GreaterThancomparisons
Like, StartingWith, Containinglike
In, NotInin
IsNull, IsNotNullis null
OrderBy...Asc/Descorder by
Top, Firstlimit
Distinctdistinct
existsBy, countBy, deleteByexists / count / delete
Note

JpaRepository already gives you save 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#

PredictA loop over orders and their lines#
The table holds 100 orders. How many queries does this run, and how do you make it one?
// 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
101. A 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);
Reading the code
@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 find and By is 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:

AssociationJPA defaultAdvice
@OneToManyLAZYkeep it lazy, and fetch explicitly
@ManyToManyLAZYkeep it lazy
@ManyToOneEAGERset it to LAZY explicitly
@OneToOneEAGERset it to LAZY explicitly
PredictReading a customer's name after the service has returned#
What happens on the last line?
// 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
It throws 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.

Gotcha

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:

EF Core
// db is your DbContext
var order = await db.Orders.FindAsync(orderId);
order!.Cancel();
await db.SaveChangesAsync();
Spring Data JPA
// in OrderService; orders is the OrderRepository
@Transactional
public void cancel(long orderId) {
    var order = orders.findById(orderId).orElseThrow();
    order.cancel();
    // no call to save
}
Reading the code
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 save

Nothing is missing here. The EntityManager of JPA does the tracking. It does the same as the DbContext in EF Core. The repository uses an EntityManager internally, so you rarely see one.

Gotcha

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:

C#
using var tx = db.Database.BeginTransaction();
db.Orders.Add(order);
await db.SaveChangesAsync();
tx.Commit();
Java 25
@Transactional
public Order place(CreateOrder request) {
    var order = new Order(request);
    orders.save(order);
    // committed when the method returns
    return order;
}
Reading the code
@Transactional

Spring 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.

Gotcha

@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 @Transactional on other() 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:

EF Core migrations
dotnet ef migrations add AddOrderStatus
dotnet ef database update

// generated C# with Up/Down methods
Flyway
-- src/main/resources/db/migration/V3__add_order_status.sql
ALTER TABLE orders
    ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'NEW';

-- applied automatically at startup
Reading the code
-- src/main/resources/db/migration/V3__add_order_status.sql

The name of the file identifies the migration. V3 is the version, and the words after the two underscores describe the migration. Boot looks in db/migration by default.

ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'NEW';

This is plain SQL. Existing orders get the value NEW.

-- applied automatically at startup

Boot runs new migrations before it serves requests. Boot 3 needs flyway-core for that. Boot 4 needs spring-boot-starter-flyway. spring-boot-starter-data-jpa does not include it.

AspectEF MigrationsFlyway
Formatgenerated C#plain SQL you write
NamingtimestampedV{version}__{description}.sql
Applieddotnet ef database updateautomatically on app startup
RollbackDown() methodforward-only, so write a new migration
Baseline an existing DBpossible, fiddlyflyway.baselineOnMigrate
Checksum enforcementnoyes, editing an applied migration stops the startup
Gotcha

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#
Means
A migration file was changed after it ran. Flyway stored a checksum of each file that it applied, and the file on disk no longer matches the checksum.
Fix
Undo the edit, and put the change in a new migration, 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 later

An 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.#
Means
Flyway found tables that it did not create. Flyway does not guess which migrations these tables correspond to.
Fix
Set 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 V2

ddl-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: validate
Reading the code
ddl-auto: validate

With 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.

Gotcha

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:

SituationBetter tool
Complex reporting queriesjOOQ, or JdbcTemplate with SQL
You want typed SQL like LINQjOOQ
Simple CRUD, no object graphSpring Data JDBC, which has a much simpler model
Bulk operationsnative SQL, because JPA is poor at bulk operations
Read-heavy projectionsinterface 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();
Reading the code
jdbc.sql("select id, customer_id, total from orders where total > :min")

jdbc is a JdbcClient. 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 column customer_id fills the component customerId.

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);
}
Reading the code
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.

Note

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.

Check yourself
1
TranslateWrite 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();
@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.

2
An 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?
The setting keeps the persistence context open for the whole web request, so a lazy field can still load in the controller. This hides N+1 queries, and it holds a database connection longer than necessary. When the setting is off, the service must fetch what the response needs inside its transaction.

Quick reference#

SettingUse it to
spring.jpa.hibernate.ddl-auto=validatecheck the mapping against the schema Flyway built
spring.jpa.open-in-view=falseclose the persistence context when the service returns
spring.flyway.baseline-on-migrate=trueadopt a database that already has tables
spring.jpa.properties.hibernate.generate_statistics=truecount queries while developing

Transactions, propagation and isolation#

ASP.NET Core to Spring Boot · Chapter 41· 10 min read· Quick reference

@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.

KindC#JavaNote
twistBeginTransactionAsync, CommitAsync, RollbackAsync@Transactional on the service methodcommits on return, and rolls back on an unchecked exception Why
trapsession.StartTransaction() on a MongoDB sessiona MongoTransactionManager beanon a document database @Transactional does nothing until you declare a transaction manager Why
trapcatch { await tx.RollbackAsync(); throw; }rollbackFor = Exception.classby default a checked exception commits, and only an unchecked exception rolls back Why
trapa call to a method of the same classa call through another bean@Transactional starts no transaction on a self-call, a private method or a final method Why
twistTransactionScopeOption@Transactional(propagation = ...)It says what the method does when a transaction is already running. Spring has seven modes, and .NET has three. Why
twistTransactionScopeOption.RequiresNewPropagation.REQUIRES_NEWa separate transaction, which commits even when the caller rolls back Why
trapa second transaction on the caller's rowsREQUIRES_NEW for independent writes onlyit waits for a lock that the caller holds, and it waits forever if no lock timeout is set Why
twistCreateSavepointPropagation.NESTEDa savepoint inside the same transaction, not a second transaction Why
twistIsolationLevel.ReadCommittedIsolation.READ_COMMITTEDthe level names are the same, and Isolation.DEFAULT uses the default level of the database Why
twistraising the isolation level@Version on the entityoptimistic locking turns a lost update into an exception Why
twistsending a message after Commit succeeds@TransactionalEventListenerruns after the commit, so a rolled-back change sends nothing Why

The defaulttwist#

C#
using var tx = await db.Database.BeginTransactionAsync();
try
{
    db.Orders.Add(order);
    await db.SaveChangesAsync();
    await tx.CommitAsync();
}
catch
{
    await tx.RollbackAsync();
    throw;
}
Java 25
@Transactional
public Order place(CreateOrder request) {
    var order = new Order(request);
    orders.save(order);
    return order;
}
// commit on normal return,
// rollback on an unchecked exception
Reading the code
@Transactional

Spring 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
Note

readOnly = true is more than a hint on PostgreSQL. Spring marks the connection read-only, so a write inside such a method fails with “cannot execute UPDATE in a read-only transaction”. With Hibernate, the setting has a second effect. Hibernate does not check loaded entities for changes.

The rollback ruletrap#

PredictA transfer that throws a checked exception#
The destination account is closed. What happens to the debit?
// 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
It is committed.

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#

Gotcha

@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) { ... }
}
Reading the code
private void reserveStock(CreateOrder request) { ... }

Only OrderService can call this method. So every call skips the proxy, and no transaction starts.

A document database needs a transaction managertrap#

Gotcha

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:

ModeIf a transaction existsIf none existsUse for
REQUIREDjoin itstart onethe default, and almost always right
REQUIRES_NEWsuspend it, start a separate onestart oneaudit rows that must survive a rollback
NESTEDa savepoint inside itstart onepartial rollback, JDBC only
SUPPORTSjoin itrun with noneread-only helpers
NOT_SUPPORTEDsuspend it, run with nonerun with nonelong non-transactional work
MANDATORYjoin itthrowassert a caller opened one
NEVERthrowrun with noneassert nobody opened one

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));
    }
}
Reading the code
@Transactional // REQUIRED

REQUIRED is 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();

PaymentFailed is 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 record in a new transaction. The new transaction commits when record returns. 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
    }
}
Reading the code
orders.addNote(orderId, note);

This line needs the lock that ship already holds on the same row.

Gotcha

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#

Gotcha

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)#
Means
Every connection in the pool is in use. Here the suspended transaction of the caller uses the connection. The inner REQUIRES_NEW transaction waited for a connection until the timeout of the pool.
Fix
Set the size of the pool for the number of connections that one request can hold at the same time. Or remove the 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
}
Note

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:

LevelPreventsStill allows.NET name
READ_UNCOMMITTEDnothingdirty readsReadUncommitted
READ_COMMITTEDdirty readsnon-repeatable reads, phantomsReadCommitted
REPEATABLE_READnon-repeatable readsphantom readsRepeatableRead
SERIALIZABLEeverythingnothing, but a transaction may abortSerializable
DEFAULTwhatever the database default isdepends on the databaseUnspecified

Each level is defined by the anomalies that it prevents:

AnomalyMeans
Dirty readyou see another transaction's uncommitted write
Non-repeatable readthe same row changes between two reads in your transaction
Phantom readthe same query returns new rows between two reads
Gotcha

Isolation.DEFAULT 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.

Note

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
}
Reading the code
@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 a StaleObjectStateException. Spring converts this exception to an ObjectOptimisticLockingFailureException. That class is a subclass of OptimisticLockingFailureException.

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);
    }
}
Reading the code
events.publishEvent(new OrderPlaced(order.getId()));

This line publishes an application event. events is an ApplicationEventPublisher, 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.

Gotcha

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:

PhaseRuns
BEFORE_COMMITbefore the commit, so it can still fail the transaction
AFTER_COMMITafter a successful commit, and this is the default
AFTER_ROLLBACKonly if it rolled back
AFTER_COMPLETIONeither way
Note

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.

Check yourself
1
TranslateThis 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;
}
@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.

2
Two users save the same order, and one change is lost with no error. What do you add to the entity, and what happens then?
Add a field marked @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.
3
An 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 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:

SymptomLikely cause
Annotation seems ignoredself-invocation, private method, or final method
Data committed despite an exceptionit was a checked exception, so set rollbackFor
A request hangs, or ends in a lock timeoutREQUIRES_NEW writing a row the caller has already written
Connection pool exhaustedREQUIRES_NEW doubling connection use, or open-in-view holding one for the whole request
Works on MySQL, wrong on PostgreSQLisolation inherited from the database default
Lost update with no errorthere is no @Version, so add optimistic locking
Message sent for a rolled-back changepublish in AFTER_COMMIT, or use an outbox
SettingEffect
rollbackFor = Exception.classroll back for checked exceptions too
noRollbackFor = NotFound.classcommit even though this was thrown
readOnly = truewrites fail on PostgreSQL, and Hibernate does not check for changes
timeout = 5seconds before a running statement is cancelled and the transaction rolled back
Spring.NET
@TransactionalBeginTransaction, SaveChanges and Commit around the method
Propagation.REQUIRES_NEWTransactionScopeOption.RequiresNew
Propagation.NESTEDa savepoint, as with CreateSavepoint in EF Core
Isolation.READ_COMMITTEDIsolationLevel.ReadCommitted
@TransactionalEventListenerwork done after SaveChanges and Commit succeed

Document data: Amazon DocumentDB and the MongoDB API#

ASP.NET Core to Spring Boot · Chapter 42· 17 min read· Quick reference

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.

KindMongoDB .NET driverSpring Data MongoDBNote
twistGetCollection<Order>("orders") and [BsonId] on a property@Document("orders") and @Idthe class is mapped by Spring Data's own annotations, not by the JPA annotations Why
twist[BsonRepresentation(BsonType.ObjectId)] string Id@Id String ida String id is generated, but a long id like the id of the relational chapters is not generated Why
trapInsertOne sets order.IdOrder saved = orders.save(order)a document can be a record, and then only the returned copy has the id Why
twistIMongoCollection<Order>MongoRepository<Order, String>Spring Data implements this interface. String is the type of the id. Why
twistBuilders<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
twistReplaceOneAsync(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
twistUpdateOne with Builders<Order>.Update.SetMongoTemplate.updateFirst with new Update().setchanges one field in place, but save replaces the whole document Why
twista nested object or lista nested record or listembed what is read with its owner, but reference what exists without its owner Why
trapnew MongoClient(connectionString)spring.mongodb.uri, with retryWrites=falsebefore engine version 8.0.2, or on an elastic cluster, retryable writes must be off (October 2026) Why
trapglobal-bundle.p7b in X509Store(StoreName.Root)spring.mongodb.ssl.bundlethe Java driver takes the certificate from a bundle, not from tlsCAFile Why
trapreadPreference=secondaryPreferredthe same parametera replica is eventually consistent, so a read can miss the write before it Why
trapa feature MongoDB supportsthe same call, which AWS lists as unsupportedAmazon DocumentDB lacks features that a MongoDB container accepts Why
trapsession.StartTransaction()@Transactional and a MongoTransactionManager beanwithout the bean no transaction starts, and nothing rolls back Why
trapa decimal, stored as a Decimal128a BigDecimal, stored as a string on Boot 3 and as a Decimal128 on Boot 4after an upgrade one collection holds both forms, unless you set the representation to string Why
trapan 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
twista test against a MongoDB container@DataMongoTest with a MongoDBContainera 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"), @Transientthese attributes and annotations rename a stored field, or leave a member out of the document Why
trapMongoDBRef@DBRefAWS lists DBRefs as unsupported on every engine version (October 2026), so the warning applies to both drivers Why
twista property that holds the customer's id@DocumentReference Customer customerstores only the id, so each order that is read needs one more query, and nothing cascades Why
twista .p7b file in the Windows root storea trust store made with keytoolAWS's Java instructions build a trust store from the PEM form of the same bundle Why
twistengine 5.0, or an elastic clusterthe same cluster, the same limitsengine 5.0 and elastic clusters both lack features that engine 8.0 has on an instance-based cluster, whatever the driver Why
twistIClientSessionHandle@Transactional on Amazon DocumentDBas of October 2026 AWS limits a transaction to one minute, and a transaction cannot create a collection Why
Note

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#

MongoDB .NET driver
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;
}
Spring Data MongoDB
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;
    }
}
Reading the code
@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.

@Id

This @Id is from org.springframework.data.annotation, not from Jakarta Persistence (JPA). It maps the field to the _id of the document.

private String id;

Spring Data generates an id only for a String, an ObjectId or a BigInteger. It stores the generated id as an ObjectId, which is the kind of id that MongoDB itself uses. So the id is a String here, not a long as 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.

Gotcha

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 6ac1845b71b478951620fb72

Continue 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#

MongoDB .NET driver
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();
Spring Data MongoDB
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#

MongoDB .NET driver
var update = Builders<Order>.Update
    .Set(o => o.Status, OrderStatus.Paid);

await orders.UpdateOneAsync(o => o.Id == id, update);
Spring Data MongoDB
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);
Reading the code
template.updateFirst(

template is the MongoTemplate that Boot creates. The class is in the package org.springframework.data.mongodb.core. This call changes one field in place. A call to save would 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 id to _id.

new Update().set("status", OrderStatus.PAID),

In the run, this line sent {"$set": {"status": "PAID"}}. The methods inc, push and pull work 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"
}
Reading the code
"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.pem
spring:
  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.pem
Reading the code
retryWrites=false

The 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: docdb

This 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 ignored tlsCAFile.

certificate: file:/etc/billing/global-bundle.pem

This 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.

Gotcha

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 replica

So findById can miss the order. If a read must see the write, read from the primary instance. To do that, leave the parameter out.

Gotcha

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.

Gotcha

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 unsupportedA Spring Data service reaches it throughInstead
DBRefs@DBRefuse @DocumentReference, or a field that holds the id
$wherean @Query string that uses $whereuse the query operators, such as $gt, $in and $elemMatch
Hashed Index, Wildcard index@HashIndexed, @WildcardIndexeduse a single-field or a compound index
2d Index@GeoSpatialIndexed, with its default typeask for a 2dsphere index, which AWS lists as supported
Capped Collections, cursor.tailable()a capped collection, a tailable cursorAWS 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#

PredictA transactional method that saves, then throws#
What does this print?
// 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
1

@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:

MongoDB .NET driver
// the session goes to every call
using IClientSessionHandle session = await client.StartSessionAsync();
session.StartTransaction();

await orders.InsertOneAsync(session, order);

await session.CommitTransactionAsync();
Spring Data MongoDB
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.

Gotcha

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#

Gotcha

@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 2

Nothing 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: true

With 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#

Gotcha

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 decimal128

big-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);
    }
}
Reading the code
@DataMongoTest

This 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.

Gotcha

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:

MemberSpring Data MongoDB storedThe .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
amounta Decimal128, from a BigDecimal, but a string on Boot 3a Decimal128, from a decimal
a field's namethe name of the Java field, for example customerthe 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:

MongoDB .NET driver
[BsonIgnoreExtraElements]
public class Product
{
    [BsonId]
    public string Id { get; set; } = "";

    [BsonElement("display_name")]
    public string Name { get; set; } = "";

    [BsonIgnore]
    public string Label { get; set; } = "";
}
Spring Data MongoDB
@Document("products")
@TypeAlias("product")
public class Product {

    @Id
    private String id;

    @Field("display_name")
    private String name;

    @Transient
    private String label;
}
Reading the code
[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 a FormatException in the run.

@TypeAlias("product")

This annotation stores product in _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.

@Transient

This annotation leaves the field out of the document, as [BsonIgnore] does. It is from org.springframework.data.annotation.

Gotcha

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.

Note

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
done
Reading the code
split -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 \

keytool is 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.jar

The 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 DocumentDBWhat it means for your code
$regexuses an index only with hint(), and best for a prefix without options
allowDiskUsenot supported for find, only as part of an aggregation
cursor.noCursorTimeout()not supported, so leave Query.noCursorTimeout() off
The admin and local databasesnot supported
Write concernthe values you send are ignored, and it always writes with majority
Document sizeat 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:

FeatureEngine 5.0Engine 8.0Elastic clusters
ACID transactionsYesYesNo
Retryable writesNoYes (8.0.2+)No
Change streamsYesYesNo
CollationNoYesNo
ViewsNoYesNo
Text IndexV1V2No
Partial indexYesYesNo
$facetNoYes (8.0.2+)No
$set, $unset and $replaceWith stagesNoYesNo
GridFSYesYesNo

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=false

The 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.

Note

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:

LimitWhat AWS states
Engine versiontransactions need engine version 4.0 or later
Elastic clustersACID transactions are not supported
New collectionsa transaction cannot create a collection, or query one that does not exist
Durationa transaction has a one minute execution limit
Sizethe transaction log must be smaller than 32 MB
Cursorscursors within a transaction are not supported
Causal consistencynot 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.

Check yourself
1
TranslateWrite 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 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.

2
An 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?
Spring Data creates no index unless auto index creation is on, and it is off by default. So the annotation alone enforces nothing. When the collection already held duplicates, switching 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.
3
An expert asksYour tests pass against a MongoDB container. Why can the same code still fail on Amazon DocumentDB?
Amazon DocumentDB emulates the MongoDB API on its own engine. AWS lists some features as unsupported, such as DBRefs and $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#

SettingBoot 4Boot 3
Connection stringspring.mongodb.urispring.data.mongodb.uri
Database namespring.mongodb.databasespring.data.mongodb.database
SSL bundlespring.mongodb.ssl.bundlespring.data.mongodb.ssl.bundle
Auto index creationspring.data.mongodb.auto-index-creationspring.data.mongodb.auto-index-creation
BigDecimal representationspring.data.mongodb.representation.big-decimalno such setting
@DataMongoTest, the packageorg.springframework.boot.data.mongodb.test.autoconfigureorg.springframework.boot.test.autoconfigure.data.mongo
MongoDBContainer, the packageorg.testcontainers.mongodborg.testcontainers.containers

Security#

ASP.NET Core to Spring Boot · Chapter 43· 10 min read

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.

KindC#JavaNote
twistClaimsPrincipalAuthentication / Principalthe signed-in user, and its authorities do the same as claims in .NET Why
twistUser.Identity.Nameauthentication.getName()the principal's name, such as the JWT subject Why
trapendpoints are open until you add [Authorize]every endpoint needs a login once the security starter is presentBoot supplies a default chain until you declare your own SecurityFilterChain Why
twistAddAuthenticationSecurityFilterChain beanone bean declares how requests are authenticated Why
twistAddAuthorizationauthorizeHttpRequests(...)the URL rules, matched in order inside the same bean Why
twist[AllowAnonymous]permitAll() in the chainusually a path rule in the chain, not an annotation Why
twistCookie authenticationformLogin() + sessionform login backed by an HTTP session Why
twistJwtBeareroauth2ResourceServer().jwt()validates bearer JWTs, and you set the issuer in application.yml Why
trapURL rules in any orderspecific rules first, anyRequest() lastrules are checked in order, and the first one that matches decides Why
trapAntiforgery tokenCSRF protection, on by defaultA 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
twistPolicy-based authorisationSpEL in @PreAuthorize, or an AuthorizationManagerwrite the rule as an expression, or as a small bean Why
trapno equivalent@EnableMethodSecuritywithout it, every @PreAuthorize is silently ignored Why
trapUser.IsInRole("Admin")hasRole('ADMIN'), which needs ROLE_ADMINa JWT's roles claim gives no authorities until a converter maps it Why
trapa direct call to an [Authorize] action skips its check tooa call through another beana call from inside the same class skips the @PreAuthorize check Why
twistUser.FindFirst("sub")@AuthenticationPrincipal Jwt jwta controller parameter receives the caller's validated token Why
twistIdentityUserUserDetailsthe user record Spring Security loads to check a password Why
twistUserManagerUserDetailsServiceloads users by name, but you build account management yourself Why
twistData protectionno direct equivalentthere is no built-in key ring, so use a KMS or Spring Vault Why
twiststring? in a signature@Nullable from JSpecifySpring 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.

C#
// 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")));
Java 25
// 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) { ... }
Reading the code
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 with ROLE_.

@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 the orders scope becomes the authority SCOPE_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>
Reading the code
<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.

Gotcha

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.

C#
builder.Services.AddAuthentication()
    .AddJwtBearer();
builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers().RequireAuthorization();
Java 25
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain chain(HttpSecurity http)
            throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/api/admin/**")
                    .hasRole("ADMIN")
                .anyRequest().authenticated())
            .oauth2ResourceServer(o ->
                o.jwt(Customizer.withDefaults()))
            // stateless API only
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(
                SessionCreationPolicy.STATELESS))
            .build();
    }
}
Reading the code
.authorizeHttpRequests(auth -> auth

This call holds the URL rules. It is like AddAuthorization and RequireAuthorization together.

.requestMatchers("/actuator/health").permitAll()

Anyone may call the health check. This rule is like [AllowAnonymous].

.hasRole("ADMIN")

Paths under /api/admin need the authority ROLE_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 from application.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/shop
Reading the code
issuer-uri: https://login.acme.example/realms/shop

This is the address of the identity provider. It is like Authority in 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());
Reading the code
.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.

Gotcha

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.

Gotcha

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:

C#
[Authorize(Roles = "Admin")]
public async Task Delete(int id) { ... }

[Authorize(Policy = "OwnsOrder")]
public async Task<Order> Get(int id) { ... }
Java 25
@PreAuthorize("hasRole('ADMIN')")
public void delete(long id) { ... }

@PreAuthorize("@orderGuard.owns(#id, authentication)")
public Order get(long id) { ... }
Reading the code
@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. @orderGuard is the bean named orderGuard. #id is the id parameter of the method. authentication is 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);
    }
}
Reading the code
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:

ExpressionMeans
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
#idrefers to the method parameter named id
authenticationthe 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 { }
Reading the code
@EnableMethodSecurity

This annotation makes Spring wrap each bean that has @PreAuthorize in a proxy. The proxy checks the rule. Without @EnableMethodSecurity, the annotations are ignored, and nothing warns you.

PredicthasRole and a JWT with a roles claim#
May this caller call delete?
// 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
No. Spring refuses the call, with 403 Forbidden.

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.

Gotcha

@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:

C#
public IActionResult Get()
{
    var id = User.FindFirst("sub")?.Value;
    ...
}
Java 25
@GetMapping
public OrderDto get(@AuthenticationPrincipal Jwt jwt) {
    String id = jwt.getSubject();
    ...
}

// or anywhere, without passing it along
var auth = SecurityContextHolder.getContext()
                                .getAuthentication();
Reading the code
@AuthenticationPrincipal Jwt jwt

Spring passes the caller's validated token to this parameter. getSubject() reads the sub claim 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.

Gotcha

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();
}
Reading the code
PasswordEncoderFactories.createDelegatingPasswordEncoder();

This encoder hashes new passwords with bcrypt. It can still check hashes that were made with older algorithms.

Note

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#

Note

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 line
Reading the code
String url = env.getProperty("rates.url");

getProperty returns null when the setting is missing. Spring 7 declares that with the @Nullable annotation of JSpecify.

int length = url.length();

Using url without a null check could throw, so a checker such as NullAway reports this line when you build.

Check yourself
1
TranslateWrite 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");
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.

2
A service method carries @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.
3
An 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?
In the attack, another site makes the user's browser send a request with the user's cookies. Browsers do not call the shop's API with cookies, because the API uses tokens. So the protection can be switched off. Anything that uses cookies must keep the protection.

HTTP clients and resilience#

ASP.NET Core to Spring Boot · Chapter 44· 7 min read· a look-up chapter: Essentials shows its table, and each section opens· Quick reference

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.

KindC#JavaNote
twisthttpClientFactory.CreateClient("rates")a RestClient bean, built from RestClient.BuilderRestClient is the default client on Boot 3.2 and later, and it blocks Why
twistGetFromJsonAsync<Rate>(url)get().uri(url).retrieve().body(Rate.class)a blocking call, which is exactly right on a virtual thread Why
twistRefit interfacean @HttpExchange interfaceone method per endpoint, and Spring writes the class that makes the calls Why
twistAddRefitClient<IRatesApi>()Boot 3: HttpServiceProxyFactory. Boot 4: @ImportHttpServicesBoot 4 registers the client bean, but on Boot 3 you build it in a @Bean method Why
twistConfigureHttpClient(c => c.BaseAddress = ...)spring.http.serviceclient.<group>.base-urlon Boot 4, the address of an @ImportHttpServices group is a setting Why
twistPolly's AddRetryBoot 3: @Retry, from Resilience4j. Boot 4: @RetryableSpring Framework 7 has retry in its core, so Boot 4 needs no extra library Why
twista Polly bulkheadBoot 3: @Bulkhead. Boot 4: @ConcurrencyLimit(10)It limits how many calls run at once. Virtual threads have no pool that limits them. Why
twistPolly's AddCircuitBreaker@CircuitBreaker, from Resilience4jcore has no circuit breaker and no rate limiter, on Boot 3 or on Boot 4 Why
twistMaxRetryAttempts = 3@Retryable(maxRetries = 3)Both count only the retries, so 3 means four calls. On Boot 3, maxAttempts counted the first call too. Why
trapa pipeline you call yourself@Retryable, which works through a proxya call from inside the same class skips the proxy, so nothing is retried Why
trapa client with no timeout setsetConnectTimeout(...) and setReadTimeout(...)the defaults are effectively infinite, so a hung service holds the connection Why
twistEnsureSuccessStatusCode()retrieve()a 4xx or 5xx reply becomes an exception, and onStatus replaces it with your own Why
twistHttpClientrestTemplate.getForObject(...), in existing codethe original client is in maintenance mode, so write RestClient in new code Why

The client optionstwist#

Spring offers several clients for an HTTP call:

ClientStyleUse when
RestClientfluent, blockingthe default on Boot 3.2+
WebClientfluent, reactiveyou are in WebFlux, or need streaming
@HttpExchange interfacedeclarativeyou want Refit-style typed clients
RestTemplatefluent, blockinglegacy code, as described below
java.net.http.HttpClientJDK built-inno Spring dependency wanted

The last row is the client built into the Java Development Kit (JDK). Here is the everyday call, in both frameworks:

C#
var client = httpClientFactory.CreateClient("rates");
var rate = await client
    .GetFromJsonAsync<Rate>($"/rates/{pair}");
Java 25
Rate rate = restClient.get()
        .uri("/rates/{pair}", pair)
        .retrieve()
        .body(Rate.class);
Reading the code
.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 the RestClient was built.

.retrieve()

This call sends the request. A 4xx or 5xx response becomes an exception, as with EnsureSuccessStatusCode in .NET.

.body(Rate.class);

This call reads the JSON body into a Rate. Here Rate is a record, declared as record Rate(String pair, double value).

Note

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#

Refit
public interface IRatesApi
{
    [Get("/rates/{pair}")]
    Task<Rate> GetRate(string pair);

    [Post("/quotes")]
    Task<Quote> CreateQuote([Body] QuoteRequest req);
}

builder.Services
    .AddRefitClient<IRatesApi>()
    .ConfigureHttpClient(c =>
        c.BaseAddress = new Uri(url));
Spring HTTP interfaces
public interface RatesApi {

    @GetExchange("/rates/{pair}")
    Rate getRate(@PathVariable String pair);

    @PostExchange("/quotes")
    Quote createQuote(@RequestBody QuoteRequest request);
}
Reading the code
@GetExchange("/rates/{pair}")

This method sends a GET to that path, like [Get] in Refit. The annotations @PathVariable and @RequestBody say where each argument goes. They are the same annotations as in a controller.

Quote createQuote(@RequestBody QuoteRequest request);

This method sends the QuoteRequest as the JSON body, and reads the reply into a Quote.

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 bean
Reading the code
return factory.createClient(RatesApi.class);

On Boot 3, HttpServiceProxyFactory creates a proxy. The proxy is an object that implements RatesApi. It turns each method call into an HTTP request through the RestClient.

@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 in application.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.com
Reading the code
base-url: https://rates.example.com

This is the address that every RatesApi call starts from, like BaseAddress in .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);
    }
}
Reading the code
@Retry(name = "rates", fallbackMethod = "cached")

This is Boot 3, with Resilience4j. The settings for the rates policy are in application.yml. The method cached is 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:

PollyBoot 3 (Resilience4j)Boot 4 (Spring Framework core)
Retry@Retry@Retryable
Bulkhead@Bulkhead@ConcurrencyLimit
Circuit breaker@CircuitBreakernot in core, so keep Resilience4j
Rate limiter@RateLimiternot in core, so keep Resilience4j
Timeout@TimeLimiter@Retryable(timeout = ...) limits the whole retry, so set a request timeout for each call
FallbackfallbackMethodnot in core, so catch the exception after the last retry fails
ProgrammaticRetryRegistryRetryTemplate 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);
}
Reading the code
@Retryable

The core defaults apply. They are 3 retries, 1 second apart, on any exception.

Gotcha

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.

Note

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.

Gotcha

@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#

Gotcha

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();
}
Reading the code
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 a SocketTimeoutException.

On Boot 4, two settings apply the same limits to every client that Boot builds:

spring:
  http:
    clients:
      connect-timeout: 2s
      read-timeout: 5s
Reading the code
read-timeout: 5s

This 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);
Reading the code
.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 @Retryable example above retries on this exception.

Without any handler, these are the defaults:

SituationRestClient default
4xxthrows HttpClientErrorException
5xxthrows HttpServerErrorException
Want the status, not an exceptionuse .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);
Reading the code
restTemplate.getForObject(

RestTemplate has one method for each verb and kind of result. This method sends a GET and reads the body into a Rate. The URI template works as it does in RestClient.

Migration is mostly mechanical. You can build a RestClient from the configuration of an existing RestTemplate with RestClient.create(restTemplate).

Check yourself
1
What takes the place of Polly on Boot 3, and what on Boot 4?
Boot 3 uses the Resilience4j library. 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#

SettingUse it to
spring.http.clients.connect-timeoutlimit the wait for a connection, for every client
spring.http.clients.read-timeoutlimit the wait for a reply, for every client
spring.http.serviceclient.<group>.base-urlset the address for an @ImportHttpServices group

Messaging and WebSockets#

ASP.NET Core to Spring Boot · Chapter 45· 7 min read· a look-up chapter: Essentials shows its table, and each section opens

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.

KindC#JavaNote
twistProduceAsync (Confluent.Kafka)kafkaTemplate.send(topic, key, value)records with the same key go to one partition, so they stay in order Why
twistConsume loopa @KafkaListener methodSpring calls the method for each record, so there is no loop to write Why
trapan exception in the consume loopa listener method that throwsredelivered nine more times, then logged and skipped, unless you add a dead-letter topic Why
trapa publish inside a database transactionkafkaTemplate.send inside @Transactionala 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
twistIConsumer<T> (MassTransit)a @RabbitListener methodSpring calls the method for each message on the queue Why
twistMassTransit sagas and schedulingno equivalentSpring AMQP does not include these, so you build them yourself. The nearest Java libraries are Spring Integration, Camel and Axon. Why
newno equivalentjmsClient.destination("orders").send(event)Jakarta Messaging is the standard messaging API in Java, and JmsClient needs Boot 4 Why
twistMapHub<PricesHub>("/prices")a STOMP broker over WebSocket, with @MessageMappingno Java framework bundles what SignalR does, so Spring gives you the pieces Why
twistClients.Group(...).SendAsyncmessagingTemplate.convertAndSend("/topic/...", payload)pushes to every client subscribed to that destination Why
trapa SignalR backplaneenableStompBrokerRelay, to RabbitMQ or ActiveMQenableSimpleBroker is in memory, so it works on one instance only Why
twistTypedResults.ServerSentEventsSseEmitter, or a Flux on WebFluxThe 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:

Confluent.Kafka / MassTransit
// producer
await _producer.ProduceAsync("orders",
    new Message<string, string> {
        Key = order.Id, Value = json });

// consumer
_consumer.Subscribe("orders");
while (!ct.IsCancellationRequested)
{
    var cr = _consumer.Consume(ct);
    Handle(cr.Message.Value);
}
Spring Kafka
// producer
kafkaTemplate.send("orders", order.id(), order);

// consumer
@KafkaListener(topics = "orders",
               groupId = "billing")
public void handle(OrderPlaced order) {
    process(order);
}
Reading the code
kafkaTemplate.send("orders", order.id(), order);

This line sends the event to the orders topic. 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: all
Reading the code
auto-offset-reset: earliest

A 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: all

A send is complete only when every in-sync replica has the record.

Gotcha

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:

ConcernSpring Kafka
Orderingguaranteed per partition only, so key by aggregate id to keep an entity's events ordered
Concurrencyconcurrency = "3" on @KafkaListener, capped by partition count
Manual acksAckMode.MANUAL plus an Acknowledgment parameter
Retry with backoffDefaultErrorHandler with an ExponentialBackOff
Dead letterDeadLetterPublishingRecoverer, publishes to topic-dlt
Batch consumptionbatch = "true" and a List parameter
TransactionsKafkaTransactionManager, but it does not span Kafka and your database

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
}
Reading the code
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 -dlt added. 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.

Gotcha

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:

C#
_bus.Publish(new OrderPlaced(order.Id));

public class Consumer : IConsumer<OrderPlaced>
{
    public Task Consume(
        ConsumeContext<OrderPlaced> ctx) { ... }
}
Java 25
rabbitTemplate.convertAndSend(
    "orders.exchange", "order.placed", event);

@RabbitListener(queues = "orders.queue")
public void handle(OrderPlaced event) { ... }
Reading the code
rabbitTemplate.convertAndSend(

This call converts the event to a message. Then it publishes the message to the exchange orders.exchange with the routing key order.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 IConsumer in MassTransit.

You declare the rest of the configuration yourself:

ConceptSpring AMQP
Declare topology@Bean Queue / TopicExchange / Binding
SerialisationJacksonJsonMessageConverter, registered as a bean, but Jackson2JsonMessageConverter on Boot 3
Retryspring.rabbitmq.listener.simple.retry.*
Dead letterx-dead-letter-exchange argument on the queue
Manual ackAcknowledgeMode.MANUAL plus a Channel parameter
Note

MassTransit and NServiceBus do 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);
Reading the code
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. The Optional is 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 group
SignalR

No 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 featureSpring equivalent
Hub@Controller with @MessageMapping, over STOMP
Strongly typed hub clientnone, so you send to a destination by name
Transport fallbacknone built in, but SockJS is an optional fallback for WebSocket
GroupsSTOMP destinations, or a broker topic
Backplane for scale-outan external broker, RabbitMQ or ActiveMQ, as a STOMP relay
Automatic reconnectclient-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);
Reading the code
registry.addEndpoint("/ws").setAllowedOriginPatterns("*");

This line sets the address on which browsers open the WebSocket, like MapHub in 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. Here prices is a service that returns the latest price.

messagingTemplate.convertAndSend("/topic/prices", price);

The type of messagingTemplate is SimpMessagingTemplate, which is part of Spring. Spring injects it like any other bean. This call pushes the price to every subscriber of the destination, like Clients.Group(...).SendAsync in SignalR.

Gotcha

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:

C#
// ASP.NET Core 10; Prices(ct) is an IAsyncEnumerable<Price>
app.MapGet("/prices", (CancellationToken ct) =>
    TypedResults.ServerSentEvents(Prices(ct),
        eventType: "price"));
Java 25
@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;
}
Reading the code
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. 0L means 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. send can throw IOException, so the call is inside a try block.

emitter.complete();

This line ends the stream. A real price feed would continue to send events.

Note

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.

Check yourself
1
A @KafkaListener method throws every time it receives one record. What happens to that record by default?
Spring redelivers the record up to nine more times, with no pause. Then Spring logs the record at 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#

ASP.NET Core to Spring Boot · Chapter 46· 9 min read· a look-up chapter: Essentials shows its table, and each section opens· Quick reference

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).

KindC#JavaNote
twistAddHealthChecks()/actuator/healthone starter adds health, metrics and diagnostic endpoints under /actuator Why
trapan endpoint is reachable once you map itmanagement.endpoints.web.exposure.includeonly health is exposed over HTTP by default, and you must never expose env or heapdump publicly Why
twisthealth checks for Kubernetes probes/actuator/health/liveness and /readinessBoot 4 adds these two endpoints by default, but Boot 3 adds them only when it detects Kubernetes Why
twistediting appsettings.json, which reloadsPOST /actuator/loggers/com.acme.billingan HTTP call changes the level on one running instance, but ASP.NET Core rereads the file instead Why
twistIHealthChecka HealthIndicator beanAny bean that implements HealthIndicator joins /actuator/health. The package of HealthIndicator moved in Boot 4. Why
twistmeter.CreateCounter<long>("orders.placed")Counter.builder("orders.placed").register(registry)Micrometer is the metrics facade. Prometheus scrapes this counter as orders_placed_total. Why
trapa filter that times each call@Timed on a service methoddoes nothing until you enable observation annotations and add AspectJ Why
twistILogger<T>SLF4J Loggeryour code calls SLF4J, and Logback does the actual writing Why
twist{Named} placeholders{} positional placeholdersSLF4J placeholders are positional, and argument names are not kept Why
twistBeginScopeMDCper-thread key/value pairs that every log line can include Why
twistappsettings logging levelslogging.level.* in application.ymlsets the level for one package and everything below it Why
twistSerilogLogbackLogback is what Spring Boot uses unless you switch Why
twistNLogLog4j2Boot also includes a default configuration for Log4j2 if you switch Why
twista JSON formatterlogging.structured.format.console: ecsBoot 3.4 and later write JSON logs with one setting and no extra library Why
twistOpenTelemetry for .NETspring-boot-starter-opentelemetry, or the Java agentThe starter needs Boot 4. The agent instruments the application with no code changes. Why
twistdotnet-trace and dotnet-counters, installed as separate toolsjcmd <pid> JFR.starta 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:

EndpointGives you.NET analogy
/actuator/healthliveness and readinessAddHealthChecks
/actuator/metricsevery metric, queryabledotnet-counters
/actuator/prometheusPrometheus scrape formatprometheus-net
/actuator/infobuild and git info
/actuator/envresolved configuration
/actuator/loggersread AND CHANGE log levels at runtimeappsettings.json, reread when it changes
/actuator/threaddumpevery thread's stackdotnet-dump
/actuator/heapdumpa heap dump filedotnet-gcdump
/actuator/mappingsevery route
/actuator/beansthe whole container
/actuator/configpropsbound @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 Kubernetes
Reading the code
include: health,info,prometheus,loggers

This line lists the endpoints that are reachable over HTTP. Without this line, only health is reachable.

show-details: when-authorized

Anonymous callers see only UP or DOWN. Signed-in callers see each check. The default value is never.

enabled: true

This line adds /actuator/health/liveness and /actuator/health/readiness for 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.

Gotcha

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:

  • env
  • heapdump
  • beans
  • configprops

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"}'
Reading the code
localhost:8080/actuator/loggers/com.acme.billing

This 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 null to restore the earlier level.

Note

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:

C#
builder.Services.AddHealthChecks()
    .AddCheck<RatesHealthCheck>("rates");

public class RatesHealthCheck : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken ct)
        => Task.FromResult(HealthCheckResult.Healthy());
}
Java 25
@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();
        }
    }
}
Reading the code
public class RatesHealthIndicator implements HealthIndicator {

Any bean that implements HealthIndicator joins /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.

Note

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:

C#
var counter = meter.CreateCounter<long>("orders.placed");
counter.Add(1,
    new KeyValuePair<string, object?>("status", "ok"));
Java 25
private final Counter placed;

OrderService(MeterRegistry registry) {
    this.placed = Counter.builder("orders.placed")
            .tag("status", "ok")
            .register(registry);
}

placed.increment();
Reading the code
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 KeyValuePair in the C# code. Each distinct value is a separate series.

The meter types of Micrometer match the instruments of .NET:

InstrumentMicrometerUse for
CounterCountermonotonic counts
GaugeGaugea current value
HistogramDistributionSummarya distribution of values
Timer / durationTimerlatency
n/aLongTaskTimerin-flight long operations
Gotcha

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.0
Reading the code
orders_placed_total{status="ok"} 1.0

Micrometer 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) { ... }
Reading the code
@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.

Gotcha

@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#

C#
private readonly ILogger<OrderService> _log;

_log.LogInformation("Placed order {OrderId} for {Total}",
    id, total);
Java 25
private static final Logger log =
        LoggerFactory.getLogger(OrderService.class);

log.info("Placed order {} for {}", id, total);
Reading the code
LoggerFactory.getLogger(OrderService.class);

Each class has one logger, kept in a static final field. 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 is Placed 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.

Gotcha

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: ecs
Reading the code
console: ecs

This 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 are gelf and logstash.

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>
Reading the code
<springProfile name="!prod">

This block applies only when the prod profile 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 conceptLogback equivalent
Sinkappender
EnricherMDC, or a custom converter
JSON formatterLogstashEncoder, or Boot's own structured logging
Minimum level override per namespacelogging.level.com.acme in application.yml
Rolling fileRollingFileAppender with a TimeBasedRollingPolicy
Environment-specific configspringProfile blocks, as above
Note

Spring Boot 3.4 added built-in structured logging, 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>
Reading the code
<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.

Note

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.jfr
Reading the code
jcmd <pid> JFR.start name=diag settings=profile duration=60s filename=/tmp/rec.jfr

jcmd sends a command to a running Java process, which you name by its process id. This command starts a 60-second recording with the more detailed profile settings.

jcmd <pid> JFR.dump name=diag filename=/tmp/now.jfr

This 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.NETJava
Continuous production profilinglimitedJFR
Deep CPU profileVisual Studio Profiler, PerfViewasync-profiler, JFR
Heap analysisdotnet-gcdumpjmap + Eclipse MAT
Thread dumpdotnet-dumpjcmd Thread.print
GC logsGC ETW events-Xlog:gc*
Check yourself
1
Which Actuator endpoints can be reached over HTTP by default, and which should never be public?
Only 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#

SettingUse it to
management.endpoints.web.exposure.includechoose the endpoints reachable over HTTP
management.endpoint.health.show-detailsshow each check, set to never, when-authorized or always
management.server.portserve Actuator on a separate port
management.observations.annotations.enabledmake @Timed and @Observed work on any bean
logging.structured.format.consolewrite JSON logs in the format ecs, gelf or logstash

Caching, scheduling and events#

ASP.NET Core to Spring Boot · Chapter 47· 7 min read· a look-up chapter: Essentials shows its table, and each section opens· Quick reference

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.

KindC#JavaNote
twist_cache.TryGetValue then _cache.Set@Cacheable on the methodthe proxy does the lookup, the miss handling and the storing Why
trapAddMemoryCache()@EnableCachingWithout @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
trapTimeSpan.FromMinutes(5) on each entryspring.cache.type: caffeine, with a specthe default store never evicts and never expires, so configure Caffeine or Redis Why
twistBackgroundService with a Task.Delay loop@Scheduled(fixedDelay = ...)runs once at startup, then again that long after each run finishes Why
trapa five-field cron line, as in Unixcron = "0 0 3 * * *"Spring cron has six fields with seconds first, so with five fields the application refuses to start Why
trapseveral timers running at oncespring.task.scheduling.pool.sizethe scheduler has one thread by default, so one slow job delays every other job Why
trapa job that must run once@SchedulerLock, from ShedLockevery instance runs every schedule, and there is no built-in leader election Why
twistHangfireQuartzthe choice for jobs with retries, persistence and a dashboard Why
twistINotification and INotificationHandleran event record and an @EventListener methodSpring 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
twista handler that must wait for the commit@TransactionalEventListenerruns after the transaction commits, so it is the safe default for side effects Why
twistTask.Run(...)an @Async methodThe call returns at once. The method needs @EnableAsync, and it may return only void or a Future. Why
trapa method that calls another in its own classself-invocationThe 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:

C#
public async Task<Rate> GetRateAsync(string pair)
{
    if (_cache.TryGetValue(pair, out Rate hit))
        return hit;

    var rate = await _client.FetchAsync(pair);
    _cache.Set(pair, rate, TimeSpan.FromMinutes(5));
    return rate;
}
Java 25
@Cacheable("rates")
public Rate getRate(String pair) {
    return client.fetch(pair);
}
// lookup, miss handling and population
// are all done by the proxy
Reading the code
@Cacheable("rates")

Before the method runs, Spring looks in the cache named rates. The key is the argument pair. 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:

AnnotationDoes.NET equivalent
@Cacheablereturn the cached value, or call the method and cache itGetOrCreate
@CachePutalways call the method, then update the cacheSet
@CacheEvictremove an entryRemove
@CacheEvict(allEntries = true)clear the cacheMemoryCache.Clear(), which IMemoryCache does not have
@Cachingcombine several of the aboveseveral calls
@EnableCachingswitch the whole mechanism onAddMemoryCache
Gotcha

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) { ... }
}
Reading the code
@EnableCaching

This 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, #pair means the parameter pair.

@CacheEvict(value = "rates", key = "#rate.pair()")

This annotation removes the entry for the pair of this rate. Rate is a record, so you read the pair with pair().

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.

Gotcha

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:

ProviderAddNotes
Simple (ConcurrentHashMap)nothing, because it is the defaultno eviction and no size limit, so use it in development only
Caffeinecom.github.ben-manes.caffeinethe usual choice for a cache inside the process
Redisspring-boot-starter-data-redisone cache shared by every instance of the service
Hazelcast, Infinispantheir startersa 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=5m
Reading the code
spec: maximumSize=10000,expireAfterWrite=5m

Each 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.

Gotcha

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:

C#
public class ReconcileService : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            await ReconcileAsync();
            await Task.Delay(TimeSpan.FromMinutes(30), ct);
        }
    }
}
Java 25
@Component
public class ReconcileJob {

    @Scheduled(fixedDelay = 30, timeUnit = TimeUnit.MINUTES)
    public void reconcile() {
        ...
    }
}
Reading the code
@Scheduled(fixedDelay = 30, timeUnit = TimeUnit.MINUTES)

Spring runs reconcile once at startup. Then Spring runs it again 30 minutes after each run finishes, like the loop with Task.Delay. The annotation needs @EnableScheduling on a configuration class.

Other attributes choose other timetables:

AttributeMeans
fixedDelaywait this long after the previous run finishes
fixedRatestart this often, regardless of how long a run takes
initialDelayhow 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
Gotcha

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.

Gotcha

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#

Gotcha

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() { ... }
Reading the code
@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:

MediatR
public record OrderPlaced(long Id) : INotification;

await _mediator.Publish(new OrderPlaced(order.Id));

public class SendEmail : INotificationHandler<OrderPlaced>
{
    public Task Handle(OrderPlaced n, CancellationToken ct)
        => _email.SendAsync(n.Id);
}
Spring events
public record OrderPlaced(long id) { }

events.publishEvent(new OrderPlaced(order.getId()));

@Component
class SendEmail {

    @EventListener
    public void on(OrderPlaced e) {
        email.send(e.id());
    }
}
Reading the code
events.publishEvent(new OrderPlaced(order.getId()));

The type of events is ApplicationEventPublisher, 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.

@EventListener

Spring calls this method for every published OrderPlaced. Spring chooses the method by its parameter type. You do not implement an interface.

Gotcha

@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:

AnnotationRuns
@EventListenersynchronously, in the caller's thread and transaction
@EventListener(condition = "#e.total > 100")only when the SpEL condition holds
@Async @EventListeneron another thread, and outside the transaction
@TransactionalEventListenerafter 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:

C#
_ = Task.Run(() => _reports.Rebuild());
Java 25
@Async
public void rebuild() { ... }        // returns immediately

@Async
public CompletableFuture<Report> build() {
    return CompletableFuture.completedFuture(...);
}
Reading the code
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 as Task<T> in C#. The caller can wait for the CompletableFuture, or chain more work onto it.

Gotcha

@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: true
Reading the code
enabled: true

Boot then runs @Async methods and scheduled jobs on virtual threads. By default, Boot uses a pool of eight threads for @Async and one thread for scheduling.

Check yourself
1
A method carries @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#

SettingUse it to
spring.cache.typechoose the cache provider, such as caffeine or redis
spring.task.scheduling.pool.sizerun more than one scheduled job at once, because the default is 1
spring.threads.virtual.enabledrun @Async methods and scheduled jobs on virtual threads

Testing Spring Boot#

ASP.NET Core to Spring Boot · Chapter 48· 9 min read· Quick reference

@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.

KindC#JavaNote
twistWebApplicationFactory<Program>@SpringBootTeststarts the whole application context for an integration test Why
twistIClassFixture<...>the cached application context, shared automaticallySpring caches a context across test classes with the same configuration Why
twistHttpClient from app.CreateClient()MockMvc, or TestRestTemplate and RestTestClient on a random portMockMvc needs no server, but TestRestTemplate makes a real HTTP call Why
newno equivalent@WebMvcTest, @DataJpaTest and other slicesA test slice starts only one layer. On Boot 4 each slice has its own test module. Why
twistno equivalent@DataMongoTestIt is the slice for document data. Unlike @DataJpaTest, it rolls nothing back. Why
newno equivalent@WebMvcTest(OrderController.class)starts the web layer with this one controller, and provides a MockMvc Why
trapno equivalent@WithMockUser on a @WebMvcTest testwith Spring Security present, the slice applies Boot's default security Why
twista Moq mock in place of a service@MockitoBean, @MockitoSpyBeanThey add a Mockito mock or spy as a bean. Boot 4 removed @MockBean and @SpyBean. Why
twistno equivalent@AutoConfigureMockMvc@SpringBootTest provides no MockMvc without it, on Boot 3 or Boot 4. Boot 4 moved its package. Why
twistno equivalent@AutoConfigureTestRestTemplateBoot 3 gave a @SpringBootTest a TestRestTemplate by itself, but Boot 4 needs this annotation Why
twista database in a container@Testcontainers, @ServiceConnectionBoot reads the container's address and points the datasource at it Why
trapno equivalent@DataJpaTest with a @ServiceConnection containerthe slice rolls back each test, and it uses H2 unless the database comes from such a container Why
twistno equivalent@TestPropertySource, @ActiveProfiles, @TestConfigurationthey adjust the properties, profiles and beans of a test's context Why
twista unit test that starts no hosta plain JUnit test, with no Springconstructor 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:

AnnotationStartsUse for
@SpringBootTestthe whole contextend-to-end integration
@WebMvcTest(X.class)web layer only, with no databasecontroller tests
@DataJpaTestJPA and an in-memory or container DBrepository tests
@JdbcTestJDBC onlyJdbcTemplate tests
@JsonTestJackson onlyserialisation tests
@RestClientTestHTTP client + mock serveroutbound client tests
(no annotation)nothing, because the test is plain JUnitunit 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:

  • @WebMvcTest comes from the module spring-boot-webmvc-test. Its package is org.springframework.boot.webmvc.test.autoconfigure.
  • @DataJpaTest comes from the module spring-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:

ASP.NET Core
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);
    }
}
Spring Boot 4
@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));
    }
}
Reading the code
@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 OrderService bean, so the controller receives the mock. An unstubbed method of the mock returns an empty value. Here the empty value is Optional.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 id in the JSON body.

PredictA @WebMvcTest test with Spring Security on the classpath#
What status does this test get?
// 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
401

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#

Gotcha

@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;
}
Reading the code
@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. @SpyBean did the same before Boot 4.

MockMvc and TestRestTemplate under @SpringBootTesttwist#

Gotcha

@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;
}
Reading the code
@AutoConfigureMockMvc

This annotation adds a MockMvc bean 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);
    }
}
Reading the code
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)

This annotation starts the real application, with a real web server on a free port.

@AutoConfigureTestRestTemplate

Boot 4 needs this annotation to provide a TestRestTemplate. The TestRestTemplate now comes from the spring-boot-resttestclient module. Boot 3 provided one automatically.

@Container

With @Testcontainers on the class, the JUnit extension starts this container before the tests and stops it after the tests.

@ServiceConnection

Boot reads the container's address, user name and password, and points the datasource at the container. The annotation comes from the spring-boot-testcontainers module.

static PostgreSQLContainer db = new PostgreSQLContainer("postgres:16");

This is the Testcontainers 2 class, from the org.testcontainers.postgresql package. The older class PostgreSQLContainer<?> in org.testcontainers.containers is deprecated.

What @ServiceConnection replaceddeep dive
Note

@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:

ClientUse for
MockMvcfast, with no real server and no network
TestRestTemplatea real HTTP call against a random port
WebTestClientfluent, works for MVC and WebFlux
RestTestClienta 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);
    }
}
Reading the code
@DataJpaTest

This 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 @ServiceConnection

Here the database comes from a @ServiceConnection container. 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.

Gotcha

@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#

Note

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:

NeedAnnotation
Override properties@TestPropertySource(properties = "x=y")
Activate a profile@ActiveProfiles("test")
Add or replace beans@TestConfiguration + @Import
Replace one bean with a mock@MockitoBean
Reset context after a test@DirtiesContext, 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);
    }
}
Reading the code
@TestConfiguration

This 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 Clock that always says midnight on 1 January 2026. Code that takes a Clock instead of calling Instant.now() can be tested this way.

Gotcha

@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#

Note

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");
    }
}
Reading the code
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 BigDecimal values by amount, so 90 equals 90.00.

Check yourself
1
TranslateWrite 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);
}
@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.

2
A Boot 3 test class uses @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.
3
An expert asksA @DataJpaTest passes, and the same repository fails against the real database. Which two defaults of the slice can hide that?
The slice replaces your datasource with an in-memory H2 database, unless the database comes from a @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 3Boot 4
@MockBean@MockitoBean
@SpyBean@MockitoSpyBean
@AutoConfigureMockMvc from org.springframework.boot.test.autoconfigure.web.servletfrom org.springframework.boot.webmvc.test.autoconfigure, in spring-boot-webmvc-test
TestRestTemplate under @SpringBootTest, automaticallyadd @AutoConfigureTestRestTemplate, or use RestTestClient
@WebMvcTest from spring-boot-test-autoconfigurefrom spring-boot-webmvc-test

Migrating Spring Boot 3 to 4#

ASP.NET Core to Spring Boot · Chapter 49· 8 min read· a look-up chapter: Essentials shows its table, and each section opens

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.

KindWhat changesBoot 3 becomes Boot 4Note
twistParent version3.5.x becomes 4.xBoot 4 needs Java 17 or later, Spring Framework 7 and Jakarta EE 11 Why
twistTest frameworkJUnit 5 becomes JUnit 6Boot 4 also needs Kotlin 2.2 and GraalVM 25 or later, if you use them Why
twistWeb starterspring-boot-starter-web becomes spring-boot-starter-webmvcmodules now follow one spring-boot-<technology> naming pattern Why
trapFlyway and Liquibaseflyway-core becomes spring-boot-starter-flywayWithout the starter, Boot 4 starts with no error and runs no migration. Liquibase moved in the same way. Why
twistJackson importscom.fasterxml.jackson becomes tools.jacksonevery Jackson import moves, except com.fasterxml.jackson.annotation, which stays as it is Why
twistBoot's Jackson annotations@JsonComponent becomes @JacksonComponent, @JsonMixin becomes @JacksonMixinBoot's own Jackson types were renamed to match Jackson 3 Why
twistMock beans in tests@MockBean becomes @MockitoBean, @SpyBean becomes @MockitoSpyBeanremoved in Boot 4, not deprecated, so switch on Boot 3.4 or 3.5, before you upgrade Why
twistMockMvc in a @SpringBootTest@AutoConfigureMockMvc, from org.springframework.boot.webmvc.test.autoconfigureIt is needed on Boot 3 too. On Boot 4, add the starter spring-boot-starter-webmvc-test, which brings its module. Why
twistTest slices@WebMvcTest comes from the module spring-boot-webmvc-testyou add the starter spring-boot-starter-webmvc-test to the build, and it brings that module Why
twistNullable annotationorg.springframework.lang.Nullable becomes org.jspecify.annotations.Nullablethe old annotation is deprecated, so it still compiles, with a warning Why
twistTestRestTemplate in a @SpringBootTestadd @AutoConfigureTestRestTemplateBoot 3 provided it with a running server, but Boot 4 needs the annotation and spring-boot-resttestclient Why
trapUndertowremoved, so switch to Tomcat, Jetty or Nettya dependency swap rather than a compile error, so it is easy to miss Why
twistRemoved in Spring Framework 7ListenableFuture and javax.inject supportuse CompletableFuture and the jakarta.* equivalents instead Why
trapConfiguration settingsspring.jackson.parser.* becomes spring.jackson.json.read.*Boot 4 ignores the old name and reports nothing, so a missed rename fails silently Why
trapMongoDB connection settingsspring.data.mongodb.uri becomes spring.mongodb.uriBoot 4 ignores the old name and tries localhost:27017. The setting auto-index-creation keeps its old name. Why
trapA BigDecimal stored in MongoDBa string becomes a Decimal128to keep strings on Boot 4, set spring.data.mongodb.representation.big-decimal to string Why
newNew in Boot 4@Retryable, @ImportHttpServices, JmsClient, RestTestClienteach new feature is shown in the chapter that covers it Why
twistUpgrade orderBoot 3.5 first, then the test annotations, then Boot 4keep each step small, and never upgrade Boot and Java in the same change Why

Baseline requirementstwist#

First, check the versions that Boot 4 needs:

RequirementBoot 3Boot 4
Java17+17+, 25 recommended
Spring Framework6.x7.x
Jakarta EE10 (Servlet 6.0)11 (Servlet 6.1)
Kotlin1.9+2.2+
GraalVM22+25+
JUnit56
Gradle8.x9 supported, 8.14+ still works

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>
Reading the code
<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 3Boot 4
spring-boot-starter-webspring-boot-starter-webmvc
spring-boot-starter-web-servicesspring-boot-starter-webservices
spring-boot-starter-oauth2-clientspring-boot-starter-security-oauth2-client
spring-boot-starter-aopspring-boot-starter-aspectj
flyway-core, with no starterspring-boot-starter-flyway
liquibase-core, with no starterspring-boot-starter-liquibase
spring-boot-data-mongodb (health indicators)spring-boot-mongodb

For the billing service, only the web starter changes:

Boot 3 pom
<parent>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>3.5.x</version>
</parent>

<dependency>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
Boot 4 pom
<parent>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
</parent>

<dependency>
  <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
Reading the code
<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#

Gotcha

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 3Boot 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
JsonObjectSerializerObjectValueSerializer
JsonValueDeserializerObjectValueDeserializer
Jackson2ObjectMapperBuilderCustomizerJsonMapperBuilderCustomizer
Jackson2ObjectMapperBuilderdeprecated 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 { ... }
Reading the code
import tools.jackson.databind.ObjectMapper;

This is the same class, under the new root package of Jackson 3.

@JacksonComponent

This annotation registers the serializers in MoneyJson with Boot's JSON mapper, as @JsonComponent did. The annotation is in the package org.springframework.boot.jackson.

Gotcha

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 3Boot 4Note
@MockBean@MockitoBeanremoved in Boot 4, not deprecated, so switch before you upgrade
@SpyBean@MockitoSpyBeanremoved in Boot 4, not deprecated, so switch before you upgrade
MockitoTestExecutionListenerMockito's MockitoExtension
@AutoConfigureMockMvc from org.springframework.boot.test.autoconfigure.web.servletfrom org.springframework.boot.webmvc.test.autoconfigureA @SpringBootTest needs it on both versions. Only its package and module changed.
@AutoConfigureMockMvc(htmlUnit-related)@AutoConfigureMockMvc(htmlUnit = @HtmlUnit(...))
@PropertyMappingmoved to org.springframework.boot.test.contextthe same annotation, moved to a new package
@WebMvcTest from spring-boot-test-autoconfigurefrom the module spring-boot-webmvc-testadd the starter spring-boot-starter-webmvc-test to the build, because it brings that module
@SpringBootTest gives TestRestTemplateadd @AutoConfigureTestRestTemplateit also needs the spring-boot-resttestclient and spring-boot-restclient modules
Note

@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.

Gotcha

@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;
Reading the code
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.

Note

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:

RemovedReplacement
javax.annotation / javax.inject supportthe jakarta.* equivalents
spring-jclApache Commons Logging 1.3, which Spring now depends on directly
ListenableFutureCompletableFuture
Undertow supportTomcat, Jetty, or Netty
suffixPatternMatch and similar path optionsexplicit mappings
Jackson2ObjectMapperBuildernot removed yet, but deprecated for removal, so use Jackson's native builders
Certificate validity threshold in SSL infon/a
Gotcha

Undertow support is gone. If your service runs on Undertow you must switch to Tomcat, Jetty or Netty as part of the upgrade. This is easy to miss, because it is a 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>
Reading the code
<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:

FeatureDetail
@Retryable and @ConcurrencyLimit in coreorg.springframework.resilience.annotation, switched on by @EnableResilientMethods
API versioningspring.mvc.apiversion.*, spring.webflux.apiversion.*
HTTP service client auto-config@ImportHttpServices for @HttpExchange interfaces
BeanRegistrarprogrammatic bean registration, AOT-friendly
JmsClienta unified JMS send/receive API alongside JmsTemplate
spring-boot-starter-opentelemetryfirst-class OTel starter
spring-boot-starter-kotlin-serializationKotlin serialization support
RestTestClienta 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 3Boot 4
spring.jackson.parser.*spring.jackson.json.read.*
spring.jackson.generator.*spring.jackson.json.write.*
spring.data.mongodb.* (driver-level)spring.mongodb.*
spring.session.redisspring.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=billing
Reading the code
spring.jackson.json.read.allow-single-quotes=true

This is the same Jackson feature. It is a parser setting on Boot 3, and a json.read setting on Boot 4. In a run on Boot 4.1, the old spring.jackson.parser line was ignored, with no error. So single quotes were rejected again.

spring.session.data.redis.namespace=billing

These are the Redis session settings, which are now under spring.session.data.redis.

Note

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.

Gotcha

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:

StepDo
1Get to Boot 3.5 and Java 17+ first, and fix all deprecation warnings
2Replace @MockBean and @SpyBean with @MockitoBean and @MockitoSpyBean
3Replace Undertow if you use it
4Change the parent version to Boot 4. Then fix the starter names, and add the Flyway or Liquibase starter
5Run the build, and fix each Jackson import error
6Fix the import of @AutoConfigureMockMvc, and add @AutoConfigureTestRestTemplate where a test injects TestRestTemplate
7Check the property renames above against your application.yml
8Consider replacing Resilience4j with core @Retryable where it fits
Gotcha

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.

Check yourself
1
A service moves from Boot 3 to Boot 4, and so from Jackson 2 to Jackson 3. Which Jackson imports change, and which stay?
Every Jackson import moves from 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.
2
A Boot 3 service sets spring.data.mongodb.uri. What becomes of that setting on Boot 4?
Boot 4 reads 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#

Ship it · Chapter 50· 8 min read· a look-up chapter: Essentials shows its table, and each section opens· Quick reference

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.

KindC#JavaNote
twistdotnet publish, framework-dependent./mvnw package, giving a fat JARone JAR holds your code, every dependency and an embedded web server Why
twistexpecting the libraries merged into one fileeach dependency's JAR, kept whole inside the fat JARmerging JARs lets one META-INF/services file silently replace another, so Boot does not merge Why
twistdotnet billing.dlljava -jar target/billing-1.0.0.jarruns on any machine that has a JVM installed, but the fat JAR does not contain a JVM Why
twista bare dll, a publish folder, an installer, NativeAOTplain JAR, fat JAR, jlink, jpackage, native-imagethere are five ways to ship, but a Spring Boot service almost always starts with the fat JAR Why
twistdotnet publish /t:PublishContainer./mvnw spring-boot:build-imageBuildpacks build a layered container image, with no Dockerfile to maintain Why
twista Dockerfile that copies the publish outputjava -Djarmode=tools -jar application.jar extract --layersone image layer per JAR layer, so a code change re-sends only your own classes Why
twistdotnet publish --self-containedjlink, a trimmed runtime around your appneeds modular libraries, so it suits desktop and command-line tools more than Spring Boot Why
twistan MSI or dmg installerjpackage --type dmgwraps the JAR and a runtime in a native installer of the type msi, dmg or deb Why
twistPublishAotGraalVM native-imagestarts in milliseconds on a fraction of the resources, GraalVM states, but builds in minutes with lower peak throughput Why
trapa feature toggle that adds a service from configuration@ConditionalOnPropertyThis is not supported in a native image. Beans are fixed at build time, so the toggle does nothing and reports no error. Why
trapreflection with no setup@RegisterReflectionForBinding(OrderDto.class)a native image needs reflection registered, or it fails at run time, not at build time Why
twista faster start without going nativejava -XX:AOTCache=app.aot -jar app.jarthe AOT cache of Java 24 and 25 reuses the classes a training run loaded, so later starts are faster Why
newsmaller objects on the heap-XX:+UseCompactObjectHeadersThis option is supported since Java 25 and is off by default. It shrinks every object by four bytes. Why
twista site deployed into IISa WAR deployed into an application serverThis 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:

ApproachProducesNeeds a JVM installed.NET analogy
Plain JARjust your classesyes, plus the classpatha bare dll
Fat / uber JAReverything in one JARyesframework-dependent publish
jlinka trimmed JVM + your appnoself-contained publish
jpackagea platform installernoMSI / dmg installer
GraalVM native-imageone native binarynoNativeAOT

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
Reading the code
./mvnw package

This 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.jar

This command starts the service on any machine that has a JVM. It is like dotnet billing.dll in .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 in

When 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
Reading the code
./mvnw spring-boot:build-image

This 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.

Note

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"]
Reading the code
RUN ./mvnw -B dependency:go-offline

This line downloads the dependencies in a separate step. Docker reuses the result of that step until pom.xml changes.

java -Djarmode=tools -jar application.jar extract --layers --destination extracted

This line runs a tool that is part of Spring Boot. The tool splits the JAR into four folders under extracted. The first two folders are dependencies and spring-boot-loader. The last two folders are snapshot-dependencies and application.

COPY --from=build /app/extracted/dependencies/ ./

Each COPY line 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.jar holds only your code. It refers to the extracted libraries, which are next to it. This layout also starts faster than the fat JAR.

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:

ToolProducesUse for
jlinka custom runtime image with only the modules you needshrinking a container
jpackagea native installer of the type msi, dmg or debdesktop 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 dmg
Reading the code
jlink --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 dmg

jpackage wraps the JAR and that runtime in a macOS disk image. The type msi does the same for Windows, and the type deb does the same for Linux.

Gotcha

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:

NativeAOT
<PublishAot>true</PublishAot>

dotnet publish -r linux-x64 -c Release
GraalVM native-image
<!-- Maven, with the Spring Boot parent -->
./mvnw -Pnative native:compile
./target/billing

<!-- Gradle -->
./gradlew nativeCompile
Reading the code
./mvnw -Pnative native:compile

This 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/billing

This is an ordinary executable. It needs no JVM to run.

Each choice is better in some ways and worse in other ways:

AspectJVM fat JARNative image
Startupslower"starts in milliseconds", in GraalVM's words
Memory at restlarger"a fraction of the resources", in GraalVM's words
Peak throughputhigher, because the JIT is faster over timelower
Build timesecondsminutes
Reflectionneeds no registrationmust be registered
Debugging in productionJFR, full toolingmore limited
Gotcha

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 whenUse the JVM when
Serverless, scale-to-zeroLong-running services
CLI toolsPeak throughput matters
Very high instance countsYou need JFR and full diagnostics
Memory is the binding constraintBuild time matters

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 { }
Reading the code
@RegisterReflectionForBinding(OrderDto.class)

This line registers the record OrderDto for the reflection that JSON binding uses. So an OrderDto can 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 faster
Reading the code
java -XX:AOTCacheOutput=app.aot -jar app.jar

This command runs the application once as a training run. It writes the cache when the application exits.

java -XX:AOTCache=app.aot -jar app.jar

With 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.

Note

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.

Check yourself
1
What is the Java equivalent of a self-contained dotnet publish?
The equivalent is 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.
2
Why does a feature toggle built on @ConditionalOnProperty stop working in a GraalVM native image?
A native image must know everything reachable at build time. So Spring fixes its bean definitions at build time. @ConditionalOnProperty is not supported, and the toggle does nothing and reports no error.

Quick reference#

TaskCommand
Build it./mvnw package
Run itjava -jar target/app.jar
Run with a profilejava -jar app.jar --spring.profiles.active=prod
Override a propertyjava -jar app.jar --server.port=9090
Set JVM optionsjava -Xmx512m -jar app.jar
Inspect the layersjava -Djarmode=tools -jar app.jar list-layers

The JVM at runtime: memory, GC and containers#

Ship it · Chapter 51· 8 min read· a look-up chapter: Essentials shows its table, and each section opens

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.

KindC#JavaNote
trapGCHeapHardLimit-Xmx / -XX:MaxRAMPercentageThese flags cap the heap only. Metaspace, thread stacks and direct buffers need 25 to 30 percent more. Why
twistGen0/1/2Young / OldG1 is region-based, not strictly generational Why
twistLOHhumongous regions in G1large objects get their own regions, and collect poorly Why
twist"System.GC.Server": true in runtimeconfig.jsonjava -XX:+UseG1GC -jar app.jarthe .NET GC settings are command-line flags on the JVM Why
twistDOTNET_GCHeapCount-XX:ParallelGCThreadshow many threads the collector uses in parallel Why
twistGC.Collect()System.gc()HotSpot runs a full collection, but the flag -XX:+DisableExplicitGC turns this off Why
trap"System.GC.HeapHardLimitPercent": 70-XX:MaxRAMPercentage=70the default is 25, so a 1 GB container gets a 256 MB heap unless you set a share Why
trapa 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
trapmemory for loaded classes-XX:MaxMetaspaceSize=256mmetaspace has no limit by default, so a class-loading leak can consume the container Why
twistServer GCG1 (default)the default, except on very small machines, where Serial is chosen Why
twistWorkstation GCSerialGCa single-threaded collector, for small heaps and tools Why
twistGCSettings.LatencyModechoice of collectorpick ZGC or Shenandoah when pauses matter more than throughput Why
newno equivalent-XX:+UseZGCpauses stay under 1 ms whatever the heap size, but Server GC pauses grow with the heap Why
trapan old start script-XX:+ZGenerationalThe flag no longer exists, so the JVM refuses to start. ZGC is already generational with -XX:+UseZGC. Why
twistdotnet-counters ps, dotnet-gcdumpjcmd -l, jcmd <pid> GC.heap_dumpjcmd inspects the flags, heap, threads and Flight Recorder of a running JVM Why
trapa pool of 500 threads500 platform threads, at about 1 MB of stack eachthe stacks are reserved outside the heap, but a virtual thread keeps its stack on the heap Why
twistBenchmarkDotNetJMHcode 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.jar

The 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:

RegionLimited byHolds
Heap-Xmx, or -XX:MaxRAMPercentageyour objects, in a young generation for new objects and an old generation for survivors
Metaspace-XX:MaxMetaspaceSizeclass metadata, with no limit by default
Code cache-XX:ReservedCodeCacheSizemachine code compiled while the program runs
Thread stacks-Xssabout 1 MB for each platform thread
GC overheadthe collector itselfthe bookkeeping the collector needs
Direct and native memory-XX:MaxDirectMemorySizeByteBuffers, Netty buffers and JNI allocations
Gotcha

-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 GCs

In .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:

runtimeconfig.json / env
{
  "configProperties": {
    "System.GC.Server": true,
    "System.GC.HeapHardLimitPercent": 70
  }
}
JVM flags
java -XX:+UseG1GC \
     -XX:MaxRAMPercentage=70 \
     -XX:MaxMetaspaceSize=256m \
     -XX:+ExitOnOutOfMemoryError \
     -jar app.jar
Reading the code
-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 HeapHardLimitPercent in .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.jar
Reading the code
docker run -m 1g eclipse-temurin:25 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize

This 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 MaxRAMPercentage is 25.

java -XX:MaxRAMPercentage=70 -jar app.jar

With 70%, the same container allows a heap of about 718 MB. The rest is left for metaspace, thread stacks and buffers.

Gotcha

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:

FlagDoes
-XX:MaxRAMPercentage=70heap as a share of the container limit
-XX:InitialRAMPercentage=70avoid repeated heap resizing
-XX:MaxMetaspaceSize=256mlimit class metadata
-XX:+ExitOnOutOfMemoryErrorexit and let the orchestrator restart the process, not continue in a broken state
-XX:+HeapDumpOnOutOfMemoryErrorwrite a dump for analysis
-XX:HeapDumpPath=/dumpswhere to write it
-Xss512ksmaller platform thread stacks

Choosing a collectornew#

The JVM offers several collectors. Each collector makes a different choice between short pauses and high throughput:

CollectorPause timesThroughputUse when
SerialGChighfine for tiny heapssmall containers, CLI tools
ParallelGChigh, but efficienthighestbatch jobs, where latency does not matter
G1 (default)~10-200 msvery goodalmost everything
ZGCunder 1 msslightly lowerlarge heaps, latency-critical
Shenandoahunder 1 msslightly loweras 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 CPU
Reading the code
java -XX:+UseG1GC -jar app.jar

The 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.jar

ZGC 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.jar

Serial uses one GC thread and has the least overhead. It is meant for tools and tiny containers.

Gotcha

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.

Note

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 logging
Reading the code
jcmd <pid> VM.flags

This 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_histogram

This 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.hprof

This 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-gcdump in .NET.

java '-Xlog:gc*:file=gc.log:time,uptime' -jar app.jar

This 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:

SymptomLook at
Container OOMKilled, heap looks finemetaspace, thread stacks, direct buffers
Long pausesGC logs, and consider ZGC
High CPU, low throughputJFR CPU profile, or async-profiler
Memory grows steadilyheap dump, then Eclipse MAT dominator tree
Threads climbingthread dump, and look for a leaked executor
Slow startupAOT cache, CDS, or the auto-configuration report
Gotcha

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#

Note

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 | head
Reading the code
java -XX:+PrintCompilation -jar app.jar | head

A 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.

Check yourself
1
Your container is OOMKilled but the heap graph looks flat. Where is the memory?
The memory is outside the heap, in metaspace, thread stacks or direct buffers. -Xmx limits the heap only, so allow 25 to 30 percent more for everything else.
2
Which garbage collector does the JVM use by default, and when do you choose ZGC instead?
The default is G1, except on a very small machine, where the JVM chooses Serial. Choose ZGC when pauses matter more than throughput. The flag for ZGC is -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 in 19 minutes#

Quick tours · Chapter 52· 19 min read

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.

One class, side by side
// 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;
    }
}
Installing and switching
// 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
One program, start to finish
// 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
A classpath with -jar
// java -jar app.jar                          reads the main class from the JAR, ignores -cp
The classpath is a search list
// 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 JARs, one class
// Two versions of one library do not conflict: the first one silently wins,
// and you get NoSuchMethodError at run time.
Declaration
// 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; }
}
No static classes
// 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); }
}
Constructors and chaining
// 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
}
Inner classes capture the outer instance
// 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
}
The mapping
// 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
}
A package inside a package
// Java has no internal. Packages do not nest: com.acme.orders.internal cannot see
// the package-private members of com.acme.orders.
Interfaces
// 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();
    }
}
No explicit interface implementation
// Java has no explicit interface implementation.
No events
// It has no events either: a list of listeners replaces a C# event.
Sealed types: Java's discriminated unions
// 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 { }
Methods are virtual by default
// 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
Signatures
// 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, no out
// No ref and no out: everything is passed by value. Return a record or an Optional.
No extension methods
// No extension methods and no operator overloading, both unlike C#:
// call a static helper or an ordinary method.
Lambdas and method references
// 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>
Calling a lambda
boolean mug = isMug.test("mug");                      // true: call the interface's method by name
A lambda and the variables around it
// 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
The property gap
// 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; }
}
The names are the contract
// Jackson finds a property by these exact names.
// Get the prefix wrong and the value is silently missing.
Checking values: the compact constructor
// 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");
    }
}
The basics
// 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.
The missing with expression
var more = line.withQuantity(3);                 // C#: line with { Quantity = 3 }
A record that holds a list
// 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 UnsupportedOperationException
Type patterns
Payment 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)
Pattern matching in switch
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.
No default over a sealed type
// 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"
The arrow form
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 colon form
// The old form, case X: with break, falls through when a break is missing.
var
var lines = new ArrayList<OrderLine>();          // var, as in C#: local variables only
var and the diamond
var mixed = new ArrayList<>();                   // a trap: this is an ArrayList<Object>
List<OrderLine> declared = new ArrayList<>();    // the diamond <> takes OrderLine from the left
No string interpolation
// 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
Text blocks
// 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 stripped
Comparing strings
String 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
Enums with state and behaviour
// 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; }
}
Everyday enum operations
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.
Flags become an EnumSet
// No [Flags] either: use an EnumSet.
Storing an enum as a number
int position = status.ordinal();                     // 1: only its position; never store it
What erasure takes away
// 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.
Passing the type yourself
// 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")
Variance is at the use site
// 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
Retention: the concept C# lacks
// 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"
Optional is for return values
// 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"
Where Optional goes wrong
// orElse(x) always works out x. orElseGet(() -> x) does so only when empty.
JSpecify: the annotation standard
// 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; }
Creating collections
// 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 UnsupportedOperationException
Modifying while iterating
for (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"));
The interface hierarchy
// 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.
A missing key in a map
// 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 and reversed
// 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 ids
The Integer cache
Integer 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
The equals and hashCode contract
// 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"
The shape
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
Collectors
// 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 and null values
// toMap throws on a duplicate key, as ToDictionary does, and also on a null value.
Reading a stream twice
// 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
Parallel streams
// Leave parallelStream() alone: it shares one thread pool with the whole JVM.
Creating and running them
// 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
When virtual threads are the wrong tool
// 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.
The problem it solves
// 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.
Cancellation is interruption
// 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();
Mutual exclusion
// 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; }
    }
}
Concurrent collections
// 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
Atomics
// AtomicInteger, shown above, and LongAdder replace C#'s Interlocked.
A byte is signed
// The primitives match C#'s, but byte is signed and there are no unsigned integers.
byte signed = (byte) 200;                        // -56
Integer overflow
int max = Integer.MAX_VALUE;
int wrapped = max + 1;                           // -2147483648: it wraps, as in C#. Java has no checked
Math.addExact(max, 1);                           // throws ArithmeticException
Money is BigDecimal
// 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.
java.time
// 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 or a Duration
// A Period is calendar time: a day, even when a clock change makes it 23 hours long.
Checked exceptions
// 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
    }
}
The hierarchy
// RuntimeException and its subclasses are unchecked, such as IllegalStateException,
// which is C#'s InvalidOperationException.
Checked exceptions and lambdas
// 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
try-with-resources
// 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.
try, catch, finally
// catch (IOException | SQLException e) catches several types. Java has no when filter.
return inside finally
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
The standard layout
// 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.
The lifecycle
//   ./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.
Two versions of one library
//   ./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.
A test
// 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#

Quick tours · Chapter 53· 17 min read

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.

Starters
<!-- 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. -->
The entry point
// 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);
    }
}
Which packages are scanned
// 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
# 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
Configuration files
# 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 off
ddl-auto: who changes the schema
hibernate:
  ddl-auto: validate      # Hibernate only checks the tables; the migrations build them
Is your Spring Boot service on virtual threads?
# 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
Profiles are environments
# 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
IOptions becomes @ConfigurationProperties
// 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 { }
Two entity rules: no records, and enum columns by name
// 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
Lazy loading and detached entities
    // 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; }
}
An entity
// 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; }
}
Repositories
// 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);
Counting the queries in a loop
    // 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> { }
Migrations
-- 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.
A document, not a row
// 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 record can be a document
// A document can be a record, which a JPA entity cannot.
@Document("invoices")
public record Invoice(
        @org.springframework.data.annotation.Id String id,
Indexes
// TRAP: no index exists, and nothing is enforced, until the setting
// spring.data.mongodb.auto-index-creation is true.
@Indexed(unique = true) String orderId,
Embed or reference
        Money total,                       // a nested record is stored inside the document
        LocalDate issuedOn) { }

record Money(BigDecimal amount, String currency) { }
Repositories and derived queries
// 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);
}
A record can be a document
// 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.
Constraint annotations
// 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,
@NotNull on a string
// TRAP: @NotNull is not [Required] for a string, because it accepts "".
// @NotBlank rejects null, empty and whitespace-only strings.
@NotBlank String productId) { }
Records as DTOs
// 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());
    }
}
Registration by annotation
// 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;
    }
Lifetimes
// 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.
The default
// 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);
}
A document database needs a transaction manager
// 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.
Lazy loading and detached entities
// 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();
}
A change is saved with no call to save
// 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
The rollback rule
    // 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
}
Self-invocation: a call from inside the class
    // 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
    }
}
Two kinds of proxy
// 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
// 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) { }
}
REQUIRES_NEW on the caller's own rows
// 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.
Doing something after the commit
// 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
    }
}
A controller
// 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()
    }
Model binding
// 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
}
Triggering validation
// 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
}
Three ways a method rule fails
    // 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()
    }
}
Global exception handling
// 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
    }
}
One advice class, not two
// 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.
The starter locks every endpoint
// 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.
The filter chain
// 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
Two chain traps: rule order and CSRF
// 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))
Where the tokens come from
            // 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();
    }
}
Actuator endpoints
# 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.
Testing a controller
// 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.
    }
}
MockMvc and TestRestTemplate under @SpringBootTest
// @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;
}
Testing repositories
// @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#

Quick tours · Chapter 54· 10 min read

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.

Running source directly
// 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
The basics
// 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
The missing with expression
// 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
The property gap
// 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
var and the diamond
// 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");
Type patterns
// 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
Patterns Java does not have
// 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
No structs
// 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);
Packages are not namespaces
// 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;
Imports
// 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;
Patterns Java does not have
// 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
Lambdas and method references
// 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;
Text blocks
// 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
The property gap
// 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
Patterns Java does not have
// 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
One class, side by side
// 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);
}
Creating collections
// 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
Varargs
// 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
Mutual exclusion
// 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; }
    }
}
Declaration
// 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(); }
}
No extension methods
// 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
The property gap
// 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(); } = "";
}
Declaration
// 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
The gap, stated plainly
// 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
Named arguments
// 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
No ref, no out
// 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
No operator overloading
// 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
Running source directly
// 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
Who it is for
// 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 True

Appendix A: Java 9 to 25#

Appendices · Chapter 55· 4 min read

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.

Note

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#

FeatureReleaseStatusC# analogueChapter
var for locals10finalvarvar, strings and text blocks
Text blocks15finalraw string literalsvar, strings and text blocks
Records16finalrecordsRecords
instanceof pattern16finalis T xSealed types and pattern matching
Sealed classes and interfaces17finalno equivalentSealed types and pattern matching
Switch expressions14finalswitch expressionsSwitch expressions and statements
Pattern matching for switch21finalswitch on patternsSealed types and pattern matching
Record patterns21finalpositional patternsSealed types and pattern matching
Unnamed variables and patterns22finaldiscard _Sealed types and pattern matching
Module import declarations25finalno equivalent, because a using directive imports one namespaceAccess modifiers and packages
Compact source files, instance main25finaltop-level statementsGetting a JDK
Flexible constructor bodies25finalnone, because base(...) is written before the bodyClasses, constructors and initialisation
Primitive types in patterns25PREVIEWrelational patternsSealed types and pattern matching
String templates21, 22WITHDRAWNinterpolation. Java has nonevar, strings and text blocks
Gotcha

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:

FeatureReleaseStatusNote
CompletableFuture improvements9finalThe improvements are timeouts and delayed executors. See the chapter CompletableFuture and Task.
Virtual threads21finalJava uses virtual threads for the work that C# does with async and await. See the chapter Threads are cheap now.
Synchronize virtual threads without pinning24finalJEP 491 removes the synchronized trap. See the chapter Threads are cheap now.
Scoped values25finalA scoped value is like AsyncLocal in C#. See the chapter Structured concurrency and cancellation.
Structured concurrency25PREVIEWThis is the fifth preview, and the API has changed. See the chapter Structured concurrency and cancellation.
Stable values25PREVIEWA 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.
Gotcha

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#

FeatureReleaseStatusNote
Collection factories List.of, Map.of9finalThese factories create an unmodifiable collection in one call. See the chapter Collections.
Stream takeWhile, dropWhile, iterate9finaltakeWhile and dropWhile are like TakeWhile and SkipWhile in LINQ. See the chapter Streams vs LINQ.
Optional.stream, ifPresentOrElse9finalThese 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)11finalThis client is part of the JDK, and it is like HttpClient in .NET. See the chapter HTTP clients and resilience.
String isBlank, lines, strip, repeat11finalThese methods are like IsNullOrWhiteSpace and similar string methods in C#. See the chapter var, strings and text blocks.
Files.readString, writeString11finalThese methods are like File.ReadAllText and File.WriteAllText in C#. See the chapter Files and I/O.
Collectors.teeing12finalThis collector combines two collectors into one result. See the chapter Streams vs LINQ.
Stream.toList()16finalThis method replaces collect(toList()). See the chapter Streams vs LINQ.
Sequenced collections21finalThese collections have the methods getFirst, getLast and reversed(). See the chapter Collections.
Foreign Function and Memory API22finalThis API is like P/Invoke in .NET. See the appendix Going deeper.
Class-File API24finalThis API is like Reflection.Emit in .NET. See the appendix Going deeper.
Stream gatherers24finalGatherers are custom intermediate operations, such as Chunk. See the chapter Streams vs LINQ.
Ahead-of-Time Class Loading and Linking24finalStartup is faster after a training run. See the chapter Packaging and native images.
Permanently disable the Security Manager24finalThe Security Manager was already deprecated. See the appendix The Java you'll inherit.
ZGC: remove non-generational mode24finalGenerational is now the only ZGC mode. See the chapter The JVM at runtime.
Quantum-resistant ML-KEM and ML-DSA24finalpost-quantum key exchange and digital signatures
Key Derivation Function API25finala standard API for deriving keys from shared secrets
PEM encodings25PREVIEWreading and writing keys and certificates as PEM text
Vector API25INCUBATORThis is the tenth incubation. The Vector API is like System.Numerics.Vector in .NET.

Runtime, garbage collection (GC) and tooling#

FeatureReleaseStatusNote
jshell (REPL)9finalThis is the Java REPL, which you use to try an API quickly. See the chapter Getting a JDK.
JPMS modules9finalA module is declared in the file module-info.java. See the chapter Modules, JPMS and the missing internal.
Single-file source launch11finalYou run java Foo.java with no compile step. See the chapter Getting a JDK.
Flight Recorder open-sourced11finalJDK Flight Recorder (JFR) is free to use in production. See the chapter Actuator and observability.
Helpful NullPointerExceptions14finalThe exception message names the expression that was null. The feature has been on by default since Java 15.
Strong encapsulation of JDK internals17finalOld libraries that use JDK internals now fail. See the chapter Modules, JPMS and the missing internal.
Deprecate finalization for removal18deprecatedNever override finalize(). See the chapter Exceptions and resources.
Generational ZGC21finalZGC gained generations, which reduced its memory overhead
Multi-file source launch22finaljava Main.java can now use the other source files beside it
Generational ZGC by default23finalZGC is generational unless you choose the non-generational mode
Compact object headers25finalThe option -XX:+UseCompactObjectHeaders is off by default. It makes the header of every object smaller.
Ahead-of-time command-line ergonomics25finalOne flag creates the AOT cache. See the chapter Packaging and native images.
Ahead-of-time method profiling25finalProfiles from a training run make warm-up faster. See the chapter The JVM at runtime.
Generational Shenandoah25finalGenerational Shenandoah is a supported option but not the default. The option is -XX:ShenandoahGCMode=generational.
JFR CPU-time profiling25EXPERIMENTALCPU-time sampling in Flight Recorder, experimental in Java 25
JFR cooperative sampling25finalsafer, lower-overhead stack sampling inside Flight Recorder
JFR method timing and tracing25finaltime or trace chosen methods without changing their code
Remove the 32-bit x86 port25finalthe JDK builds for 64-bit x86 only from Java 25

Release cadence#

ReleaseDateLTSNotes
Java 82014LTSstill widespread, but it lacks almost everything in the tables above
Java 92017modules, jshell, collection factories
Java 112018LTSHTTP client, var refinements, single-file launch
Java 172021LTSsealed types, strong encapsulation
Java 212023LTSvirtual threads, pattern matching, sequenced collections
Java 252025LTSscoped values, compact source files, AOT, compact headers
Note

Java 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#

Appendices · Chapter 56· 4 min read

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#JavaChapter
abstractabstractInterfaces, abstract classes and sealing
Action<T>Consumer<T>Methods and parameters
AggregateExceptionCompletionException / ExecutionExceptionCompletableFuture and Task
Akka.NETAkka, or Apache PekkoYour library, translated
AngleSharpjsoupYour library, translated
AppContext.GetDataSystem.getPropertyHow Java runs your code
asno equivalent, so use an instanceof patternSealed types and pattern matching
ASP.NET Core IdentitySpring Security and your own user tables, or KeycloakYour library, translated
Asp.Versioningbuilt into Spring Framework 7Your library, translated
assembly probingthe classpathHow Java runs your code
async / awaitnothing, use virtual threadsThreads are cheap now
AsyncLocal<T>ScopedValueStructured concurrency and cancellation
AutoMapperMapStructYour library, translated
AWS SDK for .NETAWS SDK for Java 2.xYour library, translated
Azure SDK for .NETAzure SDK for JavaYour library, translated
basesuperClasses, constructors and initialisation
BCrypt.NetSpring Security PasswordEncoderYour library, translated
BenchmarkDotNetJMHTesting
boolbooleanNumbers, money and time
[BsonElement]@FieldDocument data
[BsonId]@Id, from org.springframework.data.annotationDocument data
[BsonIgnore]@TransientDocument data
Builders<T>.Filtera derived query method, or Criteria.whereDocument data
Builders<T>.UpdateUpdate, with MongoTemplate.updateFirstDocument data
byte (unsigned)no equivalent, because byte is signedNumbers, money and time
CancellationTokenThread.interrupt, or a scopeStructured concurrency and cancellation
checked / uncheckedno equivalent, so use Math.addExactThe week-one gotchas
classclassClasses, constructors and initialisation
ClosedXMLApache POIYour library, translated
conststatic finalFields, properties and immutability
ConcurrentDictionaryConcurrentHashMapLocks, atomics and the memory model
ConfigureAwaitno equivalent, and not neededThreads are cheap now

D to F#

C#JavaChapter
DataAnnotationsJakarta Bean ValidationValidation and error responses
DateTimeLocalDateTime / InstantNumbers, money and time
DateTimeOffsetOffsetDateTime / InstantNumbers, money and time
DbContextEntityManager / repositoryData access, JPA and migrations
DbUpFlywayYour library, translated
decimalBigDecimalNumbers, money and time
default(T)null, or a primitive defaultGenerics and type erasure
delegatea functional interfaceMethods and parameters
deps.jsonthe classpath, or the manifest Class-PathHow Java runs your code
Dictionary<K,V>HashMap<K,V>Collections
dotnet CLImvn / gradleMaven vs csproj
Duende IdentityServerSpring Authorization Server, or KeycloakYour library, translated
dynamicno equivalentGenerics and type erasure
Entity FrameworkHibernate / Spring Data JPAData access, JPA and migrations
enumenum, but far more powerfulEnums
Environment.GetEnvironmentVariableSystem.getenvHow Java runs your code
EPPlusApache POIYour library, translated
ErrorOra sealed Result type, or VavrYour library, translated
eventa listener list, or a functional interfaceInterfaces, abstract classes and sealing
Expression<T>no equivalent, because Java has no expression treesStreams vs LINQ
extension methoda static utility, or a default methodMethods and parameters
[Flags]EnumSetEnums
FluentAssertionsAssertJTesting
FluentMigratorFlyway Java migrations, or LiquibaseYour library, translated
FluentValidationBean ValidationValidation and error responses
FlurlRestClientYour library, translated
Func<T,R>Function<T,R>Methods and parameters

G to L#

C#JavaChapter
GetHashCodehashCodeEquality, hashing and comparison
GetType()getClass()Classes, constructors and initialisation
global usingno equivalentAccess modifiers and packages
gotolabelled break / continueSwitch expressions and statements
Hot ChocolateSpring for GraphQLYour library, translated
HtmlAgilityPackjsoupYour library, translated
HttpClientRestClient / java.net.http.HttpClientHTTP clients and resilience
HybridCacheno equivalent, so combine Caffeine and Redis by handYour library, translated
IAsyncDisposableno equivalentExceptions and resources
IAsyncEnumerable<T>no equivalent, so use a BlockingQueueThreads are cheap now
IClientSessionHandle@Transactional and a MongoTransactionManagerDocument data
IComparable<T>Comparable<T>Equality, hashing and comparison
IDisposableAutoCloseableExceptions and resources
IDistributedCacheSpring Data Redis behind @CacheableYour library, translated
IEnumerable<T>Iterable<E>Collections
IEnumerator<T>Iterator<E>Collections
ILogger<T>SLF4J LoggerActuator and observability
ImageSharpImageIO, ThumbnailatorYour library, translated
IMemoryCacheCaffeine behind @CacheableYour library, translated
IMongoCollection<T>MongoRepository<T, ID>, or MongoTemplateDocument data
in parameternot neededMethods and parameters
initfinal field, or a recordFields, properties and immutability
InsertOnesave on a MongoRepositoryDocument data
internalpackage-private, or a moduleAccess modifiers and packages
IOptions<T>@ConfigurationPropertiesDependency injection and configuration
is T xinstanceof T xSealed types and pattern matching
iTextiTextYour library, translated
LanguageExtVavrYour library, translated
locksynchronized / ReentrantLockLocks, atomics and the memory model
LINQStreamsStreams vs LINQ
List<T>ArrayList<E>Collections

M to R#

C#JavaChapter
MailKitJakarta Mail through JavaMailSenderYour library, translated
MapsterMapStructYour library, translated
MediatRSpring events / AxonCaching, scheduling and events
Microsoft.FeatureManagementTogglz / OpenFeatureYour library, translated
Microsoft.Identity.WebSpring Cloud AzureYour library, translated
MongoClientcreated by Boot from spring.mongodb.uriDocument data
MongoDB.DriverSpring Data MongoDBDocument data
MoqMockitoTesting
namespacepackageAccess modifiers and packages
NativeAOTGraalVM native-imagePackaging and native images
NBomberGatling / JMeterYour library, translated
Newtonsoft.JsonJacksonYour library, translated
NodaTimejava.timeNumbers, money and time
NPOIApache POI, the originalYour library, translated
NuGetMaven CentralMaven vs csproj
NuGet global packages folder~/.m2/repositoryHow Java runs your code
nameofno equivalentAnnotations and attributes
Nullable<T> / T?the wrapper type, and Optional for returnsNullability
null-forgiving !no equivalentNullability
null-conditional ?.Optional.map, or an explicit checkNullability
null-coalescing ??Objects.requireNonNullElseNullability
OcelotSpring Cloud GatewayYour library, translated
OneOfsealed interfaces and recordsYour library, translated
OpenIddictSpring Authorization Server, or KeycloakYour library, translated
operator overloadingno equivalentMethods and parameters
Orleanscluster sharding in Pekko or Akka, the nearest matchYour library, translated
out parameterreturn a record, or OptionalMethods and parameters
override@Override, which is an annotation and not a keywordInterfaces, abstract classes and sealing
paramsvarargs, Object...Methods and parameters
partial classno equivalentClasses, constructors and initialisation
PlaywrightPlaywright for JavaYour library, translated
PollyResilience4j, or @Retryable on Boot 4HTTP clients and resilience
Predicate<T>Predicate<T>Methods and parameters
ProblemDetailsProblemDetailValidation and error responses
propertygetX / setX, or a record componentFields, properties and immutability
QuestPDFOpenPDF / Apache PDFBoxYour library, translated
readonlyfinalFields, properties and immutability
recordrecordRecords
record structno equivalentRecords
ref parameterno equivalentMethods and parameters
Refit@HttpExchange interfacesHTTP clients and resilience
ReqnrollCucumber-JVMYour library, translated
RestSharpRestClientYour library, translated

S to Z#

C#JavaChapter
ScrutorSpring component scanning, built inYour library, translated
sealed classfinal classInterfaces, abstract classes and sealing
Selenium WebDriverSeleniumYour library, translated
SerilogLogback via SLF4JYour library, translated
SignalRWebSocket + STOMPMessaging and WebSockets
Span<T>ByteBuffer / MemorySegmentMethods and parameters
SpecFlowCucumber-JVMYour library, translated
Statelessstateless4jYour library, translated
static classfinal class, private constructorClasses, constructors and initialisation
stringStringvar, strings and text blocks
string interpolationnone, so use concatenation or formatted()var, strings and text blocks
structno equivalent, use a recordClasses, constructors and initialisation
Stryker.NETPITYour library, translated
Swashbucklespringdoc-openapiControllers, routing and binding
switch expressionswitch expressionSwitch expressions and statements
System.ReactiveProject Reactor / RxJavaYour library, translated
System.Text.JsonJacksonYour library, translated
Task<T>CompletableFuture<T>CompletableFuture and Task
Task.Runexecutor.submitThreads are cheap now
Task.WhenAllStructuredTaskScope, or futuresStructured concurrency and cancellation
TestcontainersTestcontainers, same projectTesting
this() constructor chainingthis(...) in the bodyClasses, constructors and initialisation
ThreadPoolExecutorServiceLegacy concurrency you will still meet
ToStringtoStringClasses, constructors and initialisation
TryParsecatch NumberFormatExceptionNumbers, money and time
typeof(T)T.class, or Class<T>Generics and type erasure
uint / ulongno equivalentNumbers, money and time
UpdateOneMongoTemplate.updateFirstDocument data
using directiveimportAccess modifiers and packages
using statementtry-with-resourcesExceptions and resources
varvarvar, strings and text blocks
VerifyApprovalTests.JavaYour library, translated
virtualthe default, so use final to prevent overridingInterfaces, abstract classes and sealing
volatilevolatile, stronger in JavaLocks, atomics and the memory model
WebApplicationFactory<T>@SpringBootTest with MockMvcYour library, translated
where T : X<T extends X>Generics and type erasure
with expressionno equivalentRecords
xUnitJUnit 5Testing
yield returnno equivalent, so use Stream.iterateStreams vs LINQ
ZXing.NetZXing, the originalYour library, translated
Note

The rows to remember are in the next table. These rows change how you design, and not only how you type:

C#JavaWhy it matters
async / awaitvirtual threadsno function colouring, so write blocking code
IEnumerable<T>Iterable<E>not Iterator, because Iterator gives single-use APIs
decimalBigDecimalmethod-call arithmetic, and the top source of money bugs
sealedfinalJava's sealed is a different, better feature
protectedwider than C#'spackage access comes with it
no modifierpackage-privateinverted from C#
Expression<T>nothingno LINQ-to-SQL is possible

Appendix C: The Java you'll inherit#

Appendices · Chapter 57· 17 min read· Quick reference

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.

KindC#JavaNote
twist.NET Framework 1.x, WebForms, ASP.NET Core 1-3, .NET 5-10J2EE, Spring XML, Spring Boot, modern Javaeach of the four eras has its own house style, so the style of a file tells you its era Why
twistArrayList and Hashtable, from before 2005import javax.servlet, applicationContext.xml, @Autowired on fieldseach signal dates the code, as ArrayList and Hashtable date C# Why
twistList<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
twista constructor parameter@Autowired on a private field, in older codeField injection dates a class to about 2007 to 2016. New code injects through the constructor. Why
trapa namespace that changes in every fileimport jakarta.persistence.EntitySpring Boot 2 uses javax, and Spring Boot 3 and 4 use jakarta. Nothing translates between javax and jakarta. Why
twistwhy the names changedJakarta EE, once Java EEOracle kept the Java trademarks, so Eclipse could not change anything under the javax names Why
trapexpecting XML binding inside the runtimeNoClassDefFoundError: javax/xml/bind/JAXBContextJava 11 removed JAXB from the JDK, and the code fails only when the line that uses it runs Why
twistadding a NuGet packagejakarta.xml.bind-api and jaxb-runtimeadd JAXB back as two dependencies, and change the imports to jakarta.xml.bind Why
twistAPIs you may still see in old codeapplets, Nashorn, the Security Manager, Thread.stopJava 15 removed Nashorn, Java 24 disabled the Security Manager, and Java 26 removed the Applet API and Thread.stop Why
twistorders.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
twistservices registered in Program.cs<bean> elements in applicationContext.xmlThis is how Spring was configured before annotations. The XML still loads in Spring 7, often beside annotations. Why
twistMSBuild targetsbuild.xml, for AntAnt is a task runner with no conventions and no dependency management, and Maven replaced it Why
twistIHttpHandler, Web Forms, Silverlightservlets, Struts and JSF, appletsServlets still run under Spring MVC. If you inherit Struts, migrate to Spring MVC. Why
twistCOM+, .NET Remoting and IISEJB 2 and application serversSpring beans replaced EJB 2, and an embedded web server replaced application servers Why
twistASMX and WCF@WebService, WSDL and SOAPthese are XML services, and they are still current in banking, government and telecoms Why
twistdating advice by the C# it assumesdating advice by the Java era it assumesan 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:

EraYearsHouse style.NET contemporary
Applets and J2EE1995-2005XML configuration files, heavyweight application servers.NET Framework 1.x
Spring and annotations2005-2014Spring XML, then annotations, and Maven.NET 2.0-4.5, WebForms
Boot and microservices2014-2020Spring Boot, embedded servers, DockerASP.NET Core 1-3
Modern Java2020 onwardrecords, 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 2016

The javax.servlet import and the @Autowired field are two signals. Each signal in this table points to an era:

If you seeIt was written aroundEra
import javax.servletbefore 2020pre-Jakarta
Struts action classes2001-2008J2EE
EJB with Home interfaces1999-2006J2EE
build.xml (Ant)before 2008J2EE
applicationContext.xml2004-2013Spring XML
Hibernate .hbm.xml mappings2003-2010Spring XML
@Autowired on fields2007-2016annotation era
new StringBuilder() everywhereany era, and it is still right inside loops-
Anonymous inner classes as callbacksbefore 2014pre-lambda
Guava for collections2010-2018pre-Java-9
@SpringBootApplication2014 onwardBoot
records and switch expressions2021 onwardmodern

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;
    }
}
Reading the code
@Autowired

This 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 b with a puts 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();
    }
}
Reading the code
private final OrderRepository repository;

This is constructor injection. Spring passes the repository to the constructor. So the field can be final, and an OrderReport cannot exist without a repository. A class with one constructor needs no @Autowired.

.sorted(Comparator.comparing(Order::total).reversed())

Order::total is a method reference to the total() 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:

BeforeAfterAffects
javax.servlet.*jakarta.servlet.*every web application
javax.persistence.*jakarta.persistence.*every JPA entity
javax.validation.*jakarta.validation.*every @NotNull
javax.annotation.*jakarta.annotation.*@PostConstruct, @Resource
javax.transaction.*jakarta.transaction.*@Transactional (the JTA one)

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.

Gotcha

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 2020

HttpServlet 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#
Means
The code uses the Java Architecture for XML Binding (JAXB). JAXB is the library that turns objects into XML and back. Java 8 included JAXB in the Java Development Kit (JDK), and Java 11 removed it. The code was compiled long ago, so nothing reports a problem until the line that uses JAXB runs. The stack trace then reads Caused by: java.lang.ClassNotFoundException.
Fix
Add JAXB back as a dependency, and change its imports from javax.xml.bind to jakarta.xml.bind.
Gotcha

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>
Reading the code
<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 package jakarta.xml.bind. So import javax.xml.bind.JAXBContext must become import 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 a JAXBException, 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:

RemovedWhenNote
Applet APIJava 26Java 11 removed the browser plugin. Java 17 deprecated the API, and Java 26 removed it.
Java Web StartJava 11it is gone, so ship an installer built with jpackage instead
CORBA and Java EE modulesJava 11JAXB and JAX-WS became separate dependencies, as the JAXB example above shows
Nashorn JavaScript engineJava 15it is gone, so use GraalJS to run JavaScript on the JVM
Security ManagerJava 24it is permanently disabled, so you can no longer enable it
Thread.suspend and resumeJava 23They were removed completely. They were never safe to call.
Thread.stopJava 26It has thrown an exception instead of stopping the thread since Java 20. Java 26 removes it.
32-bit x86 portJava 25the JDK now builds for 64-bit x86 only
Non-generational ZGCJava 24generational 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:

C# 3 and later
orders.Sort((a, b) => a.Total.CompareTo(b.Total));
Java before 8
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>
Reading the code
<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 OrderService and registers it under the name orderService. 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 orderRepository to the constructor. ref always means another bean.

<property name="auditEnabled" value="true"/>

This line calls setAuditEnabled(true) after the object is created. value passes 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>
Reading the code
<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. depends runs the target init first.

<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>");
    }
}
Reading the code
public class OrderServlet extends HttpServlet {

Every servlet extends HttpServlet. A separate file called web.xml mapped a URL such as /orders to this class.

protected void doGet(HttpServletRequest request, HttpServletResponse response)

A servlet has one method for each HTTP verb, such as doGet and doPost. The request object and the response object are the whole API. In classic ASP.NET, HttpContext was 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;
}
Reading the code
OrderService create() throws RemoteException, CreateException;

A client never used new. It looked up the home interface on the server, then called create to get an OrderService.

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);
    }
}
Reading the code
@WebService

This annotation publishes the class as a SOAP service. The server generates the WSDL from the class.

public Invoice findInvoice(@WebParam(name = "orderId") long orderId) {

@WebMethod makes the method an operation of the service. @WebParam names the parameter in the XML. JAXB turns the returned Invoice into 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.

Check yourself
1
Why does nearly every entity, controller and validator stop compiling when a service moves from Spring Boot 2 to Spring Boot 3?
Spring Boot 2 uses the 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.
2
A service moved from Java 8 to Java 11 fails with NoClassDefFoundError: javax/xml/bind/JAXBContext. What is the fix?
Java 11 removed JAXB from the JDK. Add jakarta.xml.bind-api and jaxb-runtime as dependencies, and change the imports to jakarta.xml.bind.

Quick reference#

Old JavaUse todayThe .NET counterpart of the old one
Appletsa web front endSilverlight, also a browser plugin
Hand-written servletsSpring MVC controllersIHttpHandler in classic ASP.NET
JSP pagesThymeleaf templatesclassic ASP or .aspx pages
StrutsSpring MVCASP.NET MVC
JSFSpring MVCASP.NET Web Forms
EJB 2 session beansSpring beansCOM+ and .NET Remoting
Application serversan embedded web server inside your JARIIS hosting several applications
Spring XML files@Configuration classes and annotationsXML configuration for Unity or Castle Windsor
Hibernate XML mapping files@Entity annotationsNHibernate .hbm.xml files
Ant build filesMaven or GradleNAnt, a port of Ant, or MSBuild
JAX-WS SOAP servicesREST with OpenAPIASMX and WCF
Anonymous Comparator classeslambdas and method referencesanonymous methods in C# 2
Old third-party callModern JDK equivalentSince
Lists.newArrayList()new ArrayList<>()Java 7, when the diamond <> arrived
ImmutableList.of(a, b)List.of(a, b)Java 9
Optional (Guava)java.util.OptionalJava 8
StringUtils.isBlank(s)s.isBlank(), after a null checkJava 11
Lists.partition(xs, n)Gatherers.windowFixed(n)Java 24
Joiner.on(",").join(xs)String.join(",", xs)Java 8
Short formStands forWhat it is
J2EEJava 2 Platform, Enterprise Editionthe enterprise standards until 2006, then Java EE, now Jakarta EE
EJBEnterprise JavaBeansserver-managed objects with transactions and remote calls
JSPJavaServer PagesHTML with Java inside, compiled into a servlet
JSFJavaServer Facesa component framework that keeps page state on the server
WARweb application archiveone web application, packaged for a server
EARenterprise archiveseveral WARs and JARs, deployed together
JAXBJava Architecture for XML Bindingmaps objects to XML and back, and is now called Jakarta XML Binding
JAX-WSJava API for XML Web ServicesSOAP services from annotated classes
WSDLWeb Services Description Languagethe XML contract of a SOAP service

Appendix D: Going deeper#

Appendices · Chapter 58· 10 min read

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 seeingWhat explains itWhere
An annotation silently does nothingthe method was called from inside its own class, which bypasses Spring's proxyHow Spring actually works
A bean is null inside @PostConstructthe order in which Spring creates and initialises beansHow Spring actually works
Data committed despite an exceptionby default, a checked exception does not roll the transaction backTransactions, propagation and isolation
Deadlocks that only appear under loadREQUIRES_NEW holds one connection while it waits for a second, and the pool has no connection leftTransactions, propagation and isolation
Correct on MySQL, wrong on PostgreSQLthe two databases have different default isolation levelsTransactions, propagation and isolation
p99 is spiky, the mean is finethe slowest 1% of requests hit garbage-collection pauses, or the JIT recompiling codeThe JVM at runtime, and the JIT entry below
Works in tests, fails in the app serverthe server's classloaders see a different set of classesClassloaders, below
ClassCastException naming the same class twicetwo classloaders each loaded their own copy of the classClassloaders, below
Slow build, unexplained generated sourcesan annotation processor writing code at compile timeAnnotation processing
A concurrency bug you cannot reproducea missing happens-before rule in the Java Memory ModelThe Java Memory Model, below
Memory grows but the heap looks flatmemory outside the heap, used by metaspace, direct buffers and thread stacksThe JVM at runtime
Startup is slow and you have no idea whytoo many auto-configurations or classes to load, and the conditions report shows which ranSpring Boot orientation, and Spring AOT, below
You need to add behaviour to a class you do not ownrewriting the class's bytecodeBytecode manipulation, below
Your own starter is not auto-configuringa wrong registration file, or a condition that is not metWriting an auto-configuration, below
Native image compiles but fails at runtimereflection metadata that the build could not inferSpring 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.

RowDetail
What it isA 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 whenYou are packaging shared setup for several services, and want each service to get it just by adding one dependency.
The C# instinctAn 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()));
    }
}
Reading the code
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

@AutoConfiguration marks 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 RestClient is on the classpath. RestClient is on the classpath when the service uses Spring's web client.

@EnableConfigurationProperties(PaymentGatewayProperties.class)

This line binds payment.gateway.url to the record and makes the record a bean. Spring can then pass the record to the method below.

@ConditionalOnMissingBean

Spring 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.PaymentGatewayAutoConfiguration

At 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.

Gotcha

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.

Note

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.

RowDetail
What it isTwo 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 whenYou 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# instinctThere 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);
                    }
                });
    }
}
Reading the code
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 PaymentGateway and 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();

invoke wraps any exception that the gateway throws in an InvocationTargetException. Unwrapping the exception lets the caller catch the original exception, such as an IllegalArgumentException.

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.

Gotcha

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)#
Means
A post-processor depends on this bean, so Spring created the bean before every post-processor was ready. The bean gets no proxies. So annotations such as @Transactional on the bean can do nothing, and no error is reported.
Fix
Remove the dependency, or take an ObjectProvider<AuditLog> and call getObject() only when the bean is first needed.
Note

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.

RowDetail
What it isA 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 whenYou 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# instinctNativeAOT plus source generators, which do the same job for the same reason. Compiling ahead of time needs everything decided before the program runs.
Note

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#

RowDetail
What it isJava 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 whenA 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.
ReadJava Language Specification 12.2, and Oaks, "Java Performance". For the application-server case, read the Tomcat class loader documentation.
Note

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#

RowDetail
What it isHotSpot, 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 whenThroughput 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.
ReadAleksey 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#

RowDetail
What it isThe 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 whenYou 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# instinctThe .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.
ReadGoetz, "Java Concurrency in Practice", is still the definitive treatment. For the specification, read JSR-133 and its FAQ.

MethodHandle, VarHandle and reflection performance#

RowDetail
What it isFaster, 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 whenReflection 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# instinctExpression trees compiled to delegates, or Unsafe.As. A MethodHandle is closer to a compiled delegate than to reflection.
Readthe java.lang.invoke package documentation, and JEP 193 for VarHandle

Bytecode manipulation#

RowDetail
What it isGenerating 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 whenYou 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# instinctIL 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.
Readthe ByteBuddy tutorial, JEP 484 for the Class-File API, and java.lang.instrument for agents

NIO channels, selectors and memory-mapped files#

RowDetail
What it isThe 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 whenYou 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# instinctSpan, 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.
Readthe 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.

TopicYou need it whenRead
JMX and MBeansyou must read or change a runtime value on a live JVM, or an operations tool demands itthe javax.management docs. Actuator also exposes endpoints over JMX.
ServiceLoader and the SPI patternyou are building plug-in implementations found on the classpath, which is how JDBC drivers are foundjava.util.ServiceLoader, and the provides directive in Modules, JPMS and internal
JCA, JCE, keystores and TLSyou must configure mutual TLS, load a keystore, or pick a cipher suiteJava Security Standard Algorithm Names, and the keytool documentation
Foreign Function and Memory APIyou need to call native code, or manage memory outside the heap preciselyJEP 454. This API is the modern alternative to JNI and, for off-heap memory, to ByteBuffer.
Vector APIyou have measurable number-crunching that suits SIMD instructionsJEP 508. The Vector API is still an incubator in Java 25, so it is not for production.
Locale, charset and i18ntext is garbled, or sorting differs between environmentsalways 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.

TopicWhat it isYou need it when
Spring Cloudconfig server, service discovery, gateway, distributed tracingyou run many services and need shared configuration and routing
Spring Batchchunk-oriented batch processing with restartabilityyou have long-running jobs that must resume after failure, not restart
Spring Integration and Camelenterprise integration patterns as a DSLyou are routing and transforming between many systems
Kafka patternsconsumer groups, exactly-once, the transactional outboxyou are building event-driven services and need delivery guarantees
Testcontainers at scaleshared containers, reuse, parallel suitesyour integration tests have become the slowest part of the build
Mutation testing (PIT)mutates your code to check the tests noticecoverage is high and you do not trust it
ArchUnitarchitecture rules enforced as unit testslayering keeps eroding and review is not catching it

The short list#

If you read only a few things after this handbook, read these:

ForRead
Concurrency, properlyGoetz, "Java Concurrency in Practice"
Everyday idiom and API designBloch, "Effective Java"
Performance and the JVMOaks, "Java Performance", and Shipilev's blog for depth
Springthe Spring Boot and Spring Framework reference documentation, which is genuinely good, and better than most books about them
What is comingthe JEP index at openjdk.org/jeps
Note

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#

Appendices · Chapter 59· 9 min read· Quick reference

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.

KindC#JavaNote
twistpublic 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
twistann 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
twiststring? name = found?.Nameval name: String? = found?.nameC# only warns about an unchecked use of a nullable value, but Kotlin refuses to compile it Why
trapexpecting a different runtimethe same JVM, Spring and JPAKotlin changes the syntax only, so type erasure, proxies and virtual threads work as in Java Why
twistclass OrderController(OrderService service)class OrderController(private val service: OrderService)in Kotlin, the class header is the constructor, and Spring injects through that constructor Why
trapa class Spring can subclassa Kotlin class, final unless marked openwithout the kotlin-spring plugin, start-up fails with "Cannot subclass final class" Why
trapan entity class, written in Kotlina Kotlin @Entity classin Kotlin, the kotlin-jpa plugin adds the no-argument constructor but leaves the entity final Why
trapstring 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
twiststatic members and optional parameters@JvmStatic and @JvmOverloadsKotlin code needs these annotations to look natural when Java code calls it Why
twistasync Task<Report> and awaitsuspend fun and async { ... }.await()Kotlin coroutines look like async and await, and suspend spreads to every caller too Why
twistchoosing a language for a teamKotlin or JavaKotlin 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# featureJavaKotlin
PropertiesgetX() / setX()val / var, real properties
Nullable reference typesOptional plus annotationsString? in the type system
Extension methodsstatic utility classesextension functions
String interpolationnone"$name has ${x.size}"
Operator overloadingnoneoperator fun plus
Recordsrecorddata class
with expressionnonecopy()
Named and optional argumentsnonefull support
Top-level functionsnone, everything in a classsupported
switch expressionswitch expressionwhen expression
async/awaitvirtual threadscoroutines, plus virtual threads

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:

C#
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;
Kotlin
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?.name
Reading the code
data 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, hashCode and toString from the properties in the header. It also writes copy. val makes 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 =>. $name puts a value into the string, as {Name} does in C#.

val older = ann.copy(age = 41)

copy with a named argument does the same as with in C#. Kotlin has no new keyword, so Customer("Ann", 40) creates the object.

val found: Customer? = customers.firstOrNull { it.name == "Bob" }

Customer? is a type that may be null. The braces are a lambda, and it is the one parameter of the lambda.

val name: String? = found?.name

This is the same safe call as ?. in C#. There is no Bob, so name is null here. When you use such a value without a check, C# only warns. Kotlin refuses to compile the code.

Gotcha

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)
}
Reading the code
class OrderController(private val service: OrderService) {

The constructor is written in the class header. private val also 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 =

fun declares 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, throw is 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:

ConcernWhat to know
Constructor injectionconstructor parameters in the class header, and no annotation is needed
Final by defaultKotlin classes and functions are final unless marked open, which breaks CGLIB proxies
kotlin-spring pluginIt opens classes that have Spring annotations, and their functions. This plugin is essential.
kotlin-jpa pluginIt 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 boundarySpring 7's JSpecify annotations tell Kotlin which values may be null
Coroutines in controllerssuspend functions work in both Spring MVC and WebFlux controllers
Gotcha

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:

PredictA Java method that returns null, called from Kotlin#
Both declarations compile. What does each one do when it runs?
val directory = CustomerDirectory()

val maybe: String? = directory.findName(2)
println(maybe?.length ?: 0)

val name: String = directory.findName(2)
Show the answer
The first prints 0. The second throws NullPointerException: 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:

DirectionWhat happens
Kotlin calls JavaJava types arrive as "platform types". Kotlin does not know whether they can be null, and does not check.
Java calls KotlinA data class is an ordinary class to Java. Each val becomes a getX() method, and each var adds setX().
Kotlin null safety at the boundarya Java method returning null into a non-null Kotlin val throws at the assignment
Default arguments from Javanot visible unless the function is annotated @JvmOverloads
Top-level functions from Javaappear as static methods on a class named after the file, such as FileNameKt
Companion object membersneed @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:

C#
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);
}
Kotlin
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())
}
Reading the code
suspend fun buildReport(customerId: Long): Report = coroutineScope {

suspend marks a function that can pause without blocking its thread, as async does in C#. coroutineScope waits for everything that was started inside it. This is structured concurrency.

val customer = async { customerClient.find(customerId) }

async starts the call and returns at once with a Deferred. A Deferred in Kotlin does the same as a Task in C#. Both calls now run at the same time.

Report(customer.await(), orders.await())

await waits for each result. If one call fails, the scope cancels the other call. C# needs a CancellationToken to do that.

C#KotlinJava 21+
async Task<T>suspend funa plain blocking method
awaitjust call the suspend functionjust call the method
Task.WhenAllawaitAll / coroutineScopeStructuredTaskScope, still preview
CancellationTokenthe coroutine Job hierarchythread interrupt, or a scope
Function colouringyes, because suspend spreads to every callerno
Note

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 itReason to stay on Java
Genuinely less ceremony, particularly for data and null handlingThe Java talent pool is far larger
Null safety enforced by the compilerRecords, patterns and virtual threads closed much of the gap
Excellent Spring and Gradle supportOne language means one set of build and tooling problems
Your team already knows itKotlin adds a compiler plugin dependency to Spring

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.

Check yourself
1
A Kotlin build for Spring Boot needs two compiler plugins, 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#

KotlinWhat it meansC#Java
val x = 1a read-only variable or propertyreadonly, or { get; }final
var x = 1a variable or property you can changea field, or { get; set; }a field that is not final
String?a type that may be null, checked by the compilerstring?, with warnings only@Nullable, checked by tools
a?.bnull if a is nulla?.bOptional.map
a ?: bb when a is nulla ?? bObjects.requireNonNullElse(a, b)
a!!a, or a NullPointerException if it is nulla!, which does not checkObjects.requireNonNull(a)
"$name, ${a.b}"a string template$"{name}, {a.b}"concatenation, or formatted()
data classa class with equals, hashCode, toString and copy generatedrecordrecord
c.copy(age = 41)a copy with one property changedc with { Age = 41 }no equivalent
fun f(x: Int = 0)a default argumentan optional parameterone overload per case
when (x) { ... }a switch expressionswitch expressionswitch expression
object Registrya single instance, created on first usea static classan enum with one constant, or static members
companion objectmembers that belong to the class, not to an instancestatic membersstatic members
suspend funa function that can pause without blockingasync Taska plain method, run on a virtual thread
@JvmStatic, @JvmOverloadsmake Kotlin code look natural when called from Java--

Appendix F: What that error means#

Appendices · Chapter 60· 18 min read

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 more
Reading the code
Exception in thread "main" java.lang.IllegalStateException: payment provider did not answer

The 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 out

This 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.charge at line 17.

... 3 more

The last three frames are the same as the last three frames printed above, so Java does not print them again.

ConventionMeaning
Top framewhere the exception was thrown, not where it was caught
Caused bythe underlying exception, and the last Caused by in a trace is usually the real cause
... N moreframes shared with the enclosing trace, left out to save space
Suppressedan exception from close() that did not replace the primary one
java.base/the module the class came from, printed since Java 9
Note

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#

Gotcha

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.#
Means
Another program is already listening on the port that the embedded web server wants. Often the other program is an earlier run of the same application that is still running.
Fix
Stop the other program, or choose another port with 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#
Means
Something asked Spring for an object of this type. No bean provides one, so Spring never created the object.
Fix
Annotate the class with @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.#
Means
This is the readable summary that Spring Boot prints for the problem in the entry before this one. No bean provides an object of the type that a constructor needs. The summary names the class, and the constructor parameter that needed the missing bean.
Fix
Annotate the class of the missing bean with @Component or @Service, or declare an @Bean method that returns an object of that type.
UnsatisfiedDependencyException: Error creating bean with name 'pricing'#
Means
Spring could not build a bean. The constructor of that bean needs other beans, and Spring could not build one of those beans. This exception only wraps the real cause.
Fix
Read the last Caused by: line. This line names the dependency that failed.
The dependencies of some of the beans in the application context form a cycle#
Means
Two beans each need the other bean in their constructor. So Spring cannot build either bean first. The underlying exception is BeanCurrentlyInCreationException.
Fix
Move the logic that the two beans share into a third bean. See Circular dependencies.
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.#
Means
A Jakarta Persistence (JPA) or Java Database Connectivity (JDBC) starter is on the classpath, so Spring Boot expects a database. But no database is configured.
Fix
Set 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#
Means
Two beans implement the type, and nothing tells Spring which bean to inject.
Fix
Mark one bean as @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#
Means
A null reference was used. Since Java 15 the message names the expression that was null. If the code was compiled without debugging information, a local variable appears as <local1> instead of its name.
Fix
Find the place where that value should have been set. To stop nulls from spreading, see the chapter Nullability: Optional and JSpecify.
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#
Means
A cast named the wrong type at run time. With generics, type erasure can move the failure far from the line that caused it.
Fix
Test the real type with 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#
Means
A collection was changed while a loop was iterating over it. By chance, removing the second-to-last element does not throw, so a test with two items can miss the bug.
Fix
Use 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#
Means
Code tried to add an element to a collection, or to remove an element from it, and the collection does not allow that. An example is a collection made by List.of or Arrays.asList.
Fix
Copy the collection into a mutable collection first, with new ArrayList<>(List.of(...)).
What triggers it
List.of(1).add(2);
java.lang.NumberFormatException: For input string: "12a"#
Means
Text could not be parsed as a number. Java has no TryParse, so the parse throws.
Fix
Catch 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#
Means
An array that is declared as Object[] really holds a narrower type. A value of the wrong type was stored in the array.
Fix
Use a 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#
Means
A stream was used twice. A stream can be consumed only once. In C#, you can enumerate an IEnumerable again.
Fix
Create a new stream for each pass, or collect the stream to a 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#
Means
Code on one of Reactor's own non-blocking threads called block(). The name of the thread is at the end of the message, and it varies.
Fix
Return the Mono, or chain the next step with map or flatMap. See the full entry.
java.lang.ArithmeticException: / by zero#
Means
The code divided an integer by zero. Division on double gives Infinity and does not throw.
Fix
Check the divisor first, or divide as double if Infinity is acceptable.
java.time.format.DateTimeParseException: Text '11/09/2026' could not be parsed at index 0#
Means
The text does not match the pattern. LocalDate.parse expects ISO format, such as 2026-09-11.
Fix
Pass a 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#
Means
The code read a resource as a file on disk. That path exists in the project folder, but it does not exist beside a packaged Java archive (JAR).
Fix
Read the resource through the classpath. See the full entry.
Cannot invoke "java.io.InputStream.readAllBytes()" because "in" is null#
Means
getResourceAsStream found nothing under that name. It returned null and did not throw.
Fix
Check the name and its leading slash. See the full entry.

Persistence#

org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.acme.Order.items: could not initialize proxy - no Session#
Means
Hibernate loads some related data lazily, which means only when the data is first read. Here the data was first read after its transaction had closed, usually in a controller. The message above is the wording of Hibernate 6.6, which Spring Boot 3.4 uses. For the wording of Hibernate 7, see the next entry. For a single entity, both versions print Could not initialize proxy [com.acme.Customer#7] - no session.
Fix
Fetch what you need inside the transaction, with 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)#
Means
This is the same late read of a lazy collection as in the entry before this one. A lazy collection was first read after its transaction had closed. The message is in the wording of Hibernate 7, which Spring Boot 4 uses. The key in the message is the id of the order that owns the collection.
Fix
Fetch what you need inside the transaction, or map the data to a DTO inside the transaction. See Lazy loading.
jakarta.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread - cannot reliably process 'persist' call#
Means
A write used the shared EntityManager while no transaction was running.
Fix
Put @Transactional on the service method that makes the change.
org.springframework.dao.DataIntegrityViolationException: could not execute statement#
Means
The database rejected a write because of a constraint. The constraint is a unique key, a foreign key, or a NOT NULL column.
Fix
Read the 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)#
Means
The @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.
Fix
Reload and retry, or tell the user their copy is out of date.
ObjectOptimisticLockingFailureException: Row was already updated or deleted by another transaction for entity [com.acme.Order with id '1']#
Means
This is the same failed @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.
Fix
Reload and retry, or tell the user their copy is out of date.
detached entity passed to persist: com.acme.Order#
Means
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.
Fix
Use 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#
Means
A class marked @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.
Fix
Add an @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)#
Means
A class marked @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.
Fix
Add an @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"}#
Means
Amazon DocumentDB rejected a write that the driver marked as retryable. Drivers mark writes as retryable by default. Engine versions before 8.0.2 and elastic clusters do not support retryable writes. The message is the server's reply as AWS prints it. The Java exception around the reply was not run.
Fix
Add 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=rs0
com.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}#
Means
The server's certificate was signed by a certificate authority that this Java program does not trust. The driver reports every failed connection as a MongoTimeoutException, so read the message as far as the innermost caused by.
Fix
Give the program the certificates of the authority, through an SSL bundle or a trust store. For Amazon DocumentDB, the certificates are in the file 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.bundle
com.mongodb.MongoSocketReadException: Prematurely reached end of stream#
Means
The program connected without Transport Layer Security (TLS) to a server that accepts only TLS, and the server closed the connection. The message is inside a MongoTimeoutException, as in the entry before this one.
Fix
Enable TLS. To do this, name an SSL bundle in 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={}}.#
Means
A unique index rejected a second document with the same value. The same exception can appear at startup, with the words Index build failed. Then it means that auto index creation tried to build a unique index over documents that already hold duplicates.
Fix
Handle the exception where a duplicate is possible. For the startup failure, remove the duplicates before you enable index creation. See Indexes.
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.#
Means
A transaction started on a MongoDB server that is not a replica set. In the run, that server was the container that a plain new MongoDBContainer("mongo:8.0") starts in a test.
Fix
Start the test's container with .withReplicaSet(). See Developing and testing without a cluster.
Given transaction number 0 does not match any in-progress transactions.#
Means
This is error 251 from Amazon DocumentDB. AWS states that this error follows one of two events. The first event is a transaction that ran longer than the one-minute limit and was aborted. The second event is a restart of the instance.
Fix
Keep the transaction short, and make its writes safe to repeat. See the documented limits.
Feature not supported: 'causal consistency'#
Means
This is error 303 from Amazon DocumentDB, which does not support causal consistency. AWS shows a read that fails with this error in a session started with the default options (as of October 2026). The message is quoted from AWS, and it was not run.
Fix
AWS's own examples turn causal consistency off when they start the session. They use 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"#
Means
The request body could not be converted to the parameter's type. The cause is malformed JSON, or a value of the wrong type.
Fix
Compare the JSON with the components of the record. The message names the value and the type.
MethodArgumentNotValidException: Validation failed for argument [0]#
Means
Bean Validation rejected a @Valid request body, and Spring answers with a 400.
Fix
This is usually the 400 that you want. Shape the body of the response in a @RestControllerAdvice. See field-level errors.
InvalidDefinitionException: No serializer found for class com.acme.Empty and no properties discovered to create BeanSerializer#
Means
Jackson found nothing that it could write for a type. The type has no getters and no public fields, or the object is a lazy Hibernate proxy. For a class with nothing to write, only Jackson 2 on Spring Boot 3 throws this exception. On Spring Boot 4, Jackson 3 writes {} instead.
Fix
Return a record or a DTO in place of the entity, or give the class getters.
UnrecognizedPropertyException: Unrecognized field "extra" (class com.acme.Order), not marked as ignorable#
Means
The JSON has a property that the target type does not have, and the mapper is set to fail on an unknown property. Jackson 2 fails on an unknown property by default. Jackson 3 and the mapper that Spring Boot builds do not fail on one by default.
Fix
Add the field, or annotate the type with @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#
Means
The request's Content-Type does not match what the endpoint consumes.
Fix
Send Content-Type: application/json, or widen the mapping's consumes.
MissingServletRequestParameterException: Required request parameter 'q' for method parameter type String is not present#
Means
A @RequestParam was missing from the request, and request parameters are required by default.
Fix
Send the parameter, or declare it with required = false or a defaultValue. See Model binding.
403 Forbidden on a POST, with an empty response body#
Means
Cross-site request forgery (CSRF) protection rejected a request that changes state, because the request carried no CSRF token.
Fix
For a stateless API that authenticates with a token, disable CSRF in the security chain. Otherwise, send the CSRF token. See Two chain traps: rule order and CSRF.

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)#
Means
The code was built for a newer Java than the Java that runs it. Subtract 44 from the class file version to get the release. So version 69 is Java 25.
Fix
Make the build's target match the runtime image. See the full entry.
java.lang.NoClassDefFoundError: com/acme/Greeter#
Means
A class that was present at compile time is missing at run time. The cause is a packaging problem, or a dependency scope that excludes the class.
Fix
Put the class, or its Java archive (JAR), back on the classpath. See the full entry.
java.lang.ClassNotFoundException: org.postgresql.Driver#
Means
A lookup by name found nothing. The lookup is usually Class.forName, or a class named in configuration. This exception is a checked exception, unlike NoClassDefFoundError, which is an error.
Fix
Add the dependency with a run-time scope. See the full entry.
java.lang.NoSuchMethodError: 'java.lang.String com.acme.Greeter.greet(java.lang.String)'#
Means
The class was found, but it is a different version from the version that your code was compiled against.
Fix
Run mvn dependency:tree to find the two versions, and pin one version. See the full entry.
java.lang.ExceptionInInitializerError#
Means
A static initialiser threw an exception. Every later use of the class fails with NoClassDefFoundError: Could not initialize class.
Fix
Read the Caused by: line for the real exception. See the full entry.
java.lang.IncompatibleClassChangeError: Expected static method 'void Util.go()'#
Means
A class changed shape after its callers were compiled. For example, a static method became an instance method, or a class became an interface.
Fix
The cause is the same as the cause of 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 App
java.lang.StackOverflowError#
Means
Recursion went too deep. Often the cause is two entities that call each other's toString, equals or hashCode.
Fix
Find the frames that repeat at the top of the trace, and break the cycle.
java.lang.OutOfMemoryError: Java heap space#
Means
The heap is full, and the garbage collector could not free enough to continue.
Fix
Capture a heap dump with -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#
Means
Class metadata filled its space. The usual cause is a class loader leak from repeated redeploys or from generated classes.
Fix
Set -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#
Means
The operating system refused to create another platform thread. The cause is too many threads, or a process limit.
Fix
Run work that waits on input and output on virtual threads, or put a limit on the pool.

Compiler messages#

error: cannot find symbol#
Means
The compiler does not know a name. The cause is a missing import, a typo, or a class that is not on the classpath.
Fix
Read the 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#
Means
A checked exception is not caught and is not declared.
Fix
Catch the exception, add 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#
Means
Java will not narrow a number silently.
Fix
Cast explicitly after you check that the value fits. Or use 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#
Means
A local variable could be read on some path before any value was assigned.
Fix
Assign the variable on every path, or give it an initial value.
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#
Means
A lambda captures a local variable that is reassigned somewhere.
Fix
Use an 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#
Means
Instance state was used from a static method, most often main.
Fix
Create an instance and use it, or make the member 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#
Means
A public class must be in a file that has exactly the name of the class.
Fix
Rename the file or the class so that the file name and the class name match.
error: bad operand types for binary operator '>'#
Means
An operator was used on types that do not support it, usually > or < on strings or other objects.
Fix
Use 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#
Means
A brace or parenthesis was opened and never closed.
Fix
Let the IDE highlight the unmatched brace. The IDE finds the brace faster than javac does.
error: find(String) in EmptyLookup cannot implement find(String) in PriceLookup attempting to assign weaker access privileges; was public#
Means
A method that implements an interface method has no public keyword. In a class, a method with no access keyword is package-private. Package-private is less visible than the interface's method.
Fix
Write public on the implementing method. See the full entry.

Appendix G: The one-page card#

Appendices · Chapter 61· 1 min read

This card has everything you need in week one, on one side of paper. Print it and put it next to the keyboard.

Syntax you will use
C#Java
var x = ...var x = ...
sealed classfinal class
: Baseextends Base
: IFooimplements Foo
override@Override
readonlyfinal
conststatic final
namespacepackage
using X;import X;
stringString
boolboolean
x => x + 1x -> x + 1
Foo.BarFoo::bar
() => new List<T>()ArrayList::new
nameof(x)no equivalent
$"{a} b"a + " b"
Collections
C#Java
IEnumerable<T>Iterable<E>
IEnumerator<T>Iterator<E>
List<T>ArrayList<E>
Dictionary<K,V>HashMap<K,V>
HashSet<T>HashSet<E>
Queue / StackArrayDeque<E>
ImmutableListList.of(...)
ConcurrentDictionaryConcurrentHashMap
LINQ to Stream
LINQStream
Wherefilter
Selectmap
SelectManyflatMap
OrderBysorted
First()findFirst().orElseThrow()
Any(p) / All(p)anyMatch / allMatch
ToList()toList()
GroupBycollect(groupingBy(f))
Sum()mapToInt(f).sum()
Chunk(n)gather(windowFixed(n))
Commands
.NETMaven
dotnet build./mvnw compile
dotnet test./mvnw test
dotnet run./mvnw spring-boot:run
dotnet publish./mvnw package
dotnet add packageedit pom.xml by hand
list --include-transitivedependency:tree
Traps that take a day to find
Looks rightActually
a == b on Stringcompares references. Use equals
Integer 128 == 128false. Cache stops at 127
no access modifierpackage-private, not private
protectedalso grants package access
methodsvirtual unless final
map.get(missing)null, then NPE on unboxing
switch (nullRef)throws. Add case null
stream reusedIllegalStateException
List.of(..).add(..)UnsupportedOperationException
double for moneyuse BigDecimal
new BigDecimal(0.1)imprecise, so pass a String
1.0.equals(1.00)false. Use compareTo
nested classinner unless static
checked exception in lambdadoes not compile
return in finallyswallows the exception
Spring, day one
ASP.NET CoreSpring
AddScoped@Service, singleton by default
[ApiController]@RestController
[HttpGet("x")]@GetMapping("/x")
[FromBody]@RequestBody
[FromQuery]@RequestParam
[FromRoute]@PathVariable
IOptions<T>@ConfigurationProperties
IMongoCollection<T>MongoRepository<T, ID>
[BsonId]@Id, on a String
appsettings.jsonapplication.yml
Environmentsprofiles
ILogger<T>SLF4J Logger
[Authorize]@PreAuthorize
PollyResilience4j, or @Retryable
When something breaks
SymptomLook at
Could not find or load main classclasspath, or package and folder mismatch
Annotation does nothingself-invocation, private, or final method
Bean not foundpackage outside the scan root
LazyInitializationExceptionfetch inside the transaction
class file version 69built on 25, run on something older
Container OOMKilledmetaspace, stacks, direct buffers
Note

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#

Appendices · Chapter 62· 25 min read

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.

TermWhat it meansWhere it is explained
ActuatorThe Spring Boot module that adds health, metrics and information endpoints to a service, such as /actuator/health.Actuator endpoints
AMQPShort for Advanced Message Queuing Protocol. It is the wire protocol that RabbitMQ uses.RabbitMQ
annotationA marker written with @ before a class, method, field or parameter, such as @Override. It does the same as an attribute in C#.Syntax
annotation processorA 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 retentionHow 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
AOPShort 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
AOTShort 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 cacheA 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 contextThe 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 eventA 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 serverA 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
aspectA 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
AssertJThe assertion library that most Java tests use. An assertion is written assertThat(actual).isEqualTo(expected). AssertJ is like FluentAssertions in .NET.A test
@AsyncA 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 variableA 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-configurationSpring 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
autoboxingJava automatically converting between a primitive, such as int, and its wrapper object, such as Integer. Unboxing a null wrapper throws a NullPointerException.Wrapper types
@BeanAn 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
beanAn 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 scopeHow 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 ValidationThe Java standard for validation annotations such as @NotBlank and @Size. It has the same purpose as DataAnnotations in .NET.Validation and error responses
BeanPostProcessorAn 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
BigDecimalThe 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
BOMShort 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
BSONThe 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
BuildpacksA tool that turns an application into a container image without a Dockerfile. The build-image task of Spring Boot uses it.Containers
bytecodeThe 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
@CacheableA 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
CASShort 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
CDSShort 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
CGLIBShort 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
charsetThe 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 exceptionAn exception that the compiler makes you handle. You must catch it, or declare it with throws. C# has no checked exceptions.Checked exceptions
class fileA 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 loaderThe 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
classpathThe 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 resourceA 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
CMEShort 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 constructorA 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
ComparatorAn 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
CompletableFutureThe 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 scanningSpring 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
@ConfigurationPropertiesAn 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 injectionGiving 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 adviceA class of exception handlers that apply to every controller. It is used to turn exceptions into error responses in one place.Global exception handling
coroutineThe 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
CORSShort 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
CSRFShort 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 scopeA 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 entityAn 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
DIShort 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
DSLShort 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
DTOShort 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
EARShort 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
EJBShort 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
entityA class marked @Entity that Jakarta Persistence maps to a database table. It is like an entity in Entity Framework.An entity
enumA 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
ExecutorServiceAn 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 JAROne 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 fieldThe 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
FlywayA 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 nameA 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 interfaceAn 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
G1The default garbage collector of the JVM on most machines. It balances short pauses against throughput.Choosing a collector
gathererA 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
GCShort 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
GraalVMA 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
GradleA 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-beforeThe 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 indicatorA 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
heapThe 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
HibernateThe most widely used implementation of Jakarta Persistence, and the implementation that Spring Boot uses by default.The landscape
HQLShort 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 blockA 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 classA 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 cacheJava 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
IoCShort 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 levelHow 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
J2EEShort 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
JacksonThe JSON library that Spring Boot uses by default. It does the same as System.Text.Json in .NET.Jackson against System.Text.Json
Jakarta EEThe 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
JARShort 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 moduleA 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.timeThe 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
JAXBShort 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
JDBCShort 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
JDKShort 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
JEPShort 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
JFRShort 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
JITShort 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
JMHShort 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
JMSShort 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
JMXShort 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
JNDIShort 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
JNIShort 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
JPAShort 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
jpackageA JDK tool that wraps an application and a Java runtime into a native installer, such as an .msi or a .dmg.jlink and jpackage
JPMSShort 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
JPQLShort 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
JREShort 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
JShellThe 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
JSPShort 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
JSpecifyA 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
JSRShort 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
JUnitThe standard Java testing framework. It does the same as xUnit in .NET. A test is a method marked @Test.A test
JVMShort 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
JWTShort 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
KotlinA 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
lambdaA short unnamed function written with an arrow, such as x -> x * 2. C# writes =>. Java writes ->.Lambdas and method references
lazy loadingLoading 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 phaseOne 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 repositoryThe folder ~/.m2/repository, where Maven keeps every library that it downloads. It is like the NuGet global packages folder.Where your dependencies actually live
LogbackThe 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
LombokA 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
LTSShort 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
manifestA 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
MapStructA library that generates object-to-object mapping code when you compile. It has the same purpose as AutoMapper in .NET.MapStruct against AutoMapper
MavenThe 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 coordinatesThe 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 profileA named set of settings in pom.xml that you switch on for some builds only, with mvn -P name.Profiles and BOMs
Maven wrapperThe mvnw script in a project. It downloads and runs the right Maven version, so everyone builds with the same version.The wrapper
MDCShort 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
metaspaceThe memory outside the heap where the JVM keeps class definitions. Loading very many classes can use all of it.The memory model
method referenceA 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
MicrometerThe 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
MockitoThe standard Java library for mock objects. It does the same as Moq in .NET.Mocking
MockMvcA Spring test helper that sends fake HTTP requests to your controllers without starting a server.Testing a controller
module pathThe 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 collectionA 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 documentOne 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
MVCShort 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 problemLoading 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
NPAPIShort 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
NPEShort for NullPointerException. It is thrown when code uses a null reference. It does the same as NullReferenceException in C#.The gap, stated plainly
ObjectIdThe 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
OIDCShort 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
OOMShort 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
OpenTelemetryThe open standard for traces, metrics and logs across services. Spring Boot and .NET both support it.Tracing
OptionalA 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
ORMShort for object-relational mapper. It is a library that maps classes to database tables, such as Hibernate or Entity Framework.The landscape
@OverrideAn 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
packageA 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-privateThe 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
PECSShort 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
permitsThe clause in a sealed class or interface that lists the only types allowed to extend it.Sealed types: Java's discriminated unions
persistence contextThe 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
pinningA 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 threadAn 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
pointcutAn expression that picks which methods an aspect wraps, such as execution(public * *(..)) for every public method.AOP: writing your own cross-cutting behaviour
POJOShort for plain old Java object. It is an ordinary class with fields, getters and setters, and no framework base class.The property gap
POMShort 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 featureA 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 streamA 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
ProblemDetailThe Spring class for an RFC 9457 error response body. It does the same as ProblemDetails in ASP.NET Core.ProblemDetail
proxyAn 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
R2DBCShort 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 typeA 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
reactiveA 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
recordA 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 patternA pattern that takes a record apart into its components while matching it, such as case Point(int x, int y).Record patterns
ReentrantLockA lock object that means the same as synchronized. It also has extras, such as tryLock with a timeout.ReentrantLock
relaxed bindingSpring 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 trainThe Java schedule of a new version every six months, in March and September. An LTS release comes every two years.The release train
REPLShort 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 setA 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 writeA 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
RMIShort 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 ruleThe 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
SAMShort 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
@ScheduledA Spring annotation that runs a method on a timer or a cron schedule. It is like a hosted background service in .NET.Scheduling
ScopedValueA 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
SDKMANA command-line tool that installs Java versions and switches between them.Installing and switching
sealed typeA 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-invocationA 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 collectionA collection with a defined order, and with methods such as getFirst() and reversed(). Sequenced collections were added in Java 21.Sequenced collections
servletThe 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
SLF4JShort 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
SpELShort for Spring Expression Language. It is a language for small expressions inside annotations and configuration, written #{...}.SpEL
SPIShort 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 repositoryAn interface that you declare, such as OrderRepository extends JpaRepository<Order, Long>. Spring generates the data-access code for it.Repositories
Spring profileA 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
starterA 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 importAn 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
STOMPShort 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 collectorThe 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 pipelineA 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 concurrencyRunning 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 expressionA 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
synchronizedA 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 propertyA 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
TCKShort 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
TemurinA free, widely used JDK distribution from the Eclipse Adoptium project.Which distribution
test sliceA Spring Boot test that starts only one layer of the application, such as the controllers or the repositories, so it runs faster.Test slices
TestcontainersA library that starts real databases and message brokers in Docker containers for a test run. It exists for .NET too.Testcontainers
text blockA multi-line string written between three double quotes. It is like a raw string literal in C#.Text blocks
ThreadLocalA variable with a separate value for each thread. Scoped values are the modern way to pass context.ThreadLocal on virtual threads
transaction propagationWhat 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
@TransactionalA 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 storeA 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-resourcesA 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 erasureJava 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 patternA 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 exceptionAn 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 variableThe 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
varA keyword that lets the compiler infer the type of a local variable from its value, exactly as in C#.var
varargsA last parameter written Type... name, which accepts any number of arguments. It is like params in C#.Varargs
virtual threadA 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
volatileA 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
WARShort 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
warmupThe 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 guardA 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
wildcardA ? 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
witherA 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 typeThe object version of a primitive, such as Integer for int. Collections and generics need wrappers, because they cannot hold primitives.Wrapper types
yieldThe keyword that returns a value from a block inside a switch expression. It is not related to yield return in C#.Multi-statement arms
ZGCA JVM garbage collector designed for very short pauses, even on large heaps.Choosing a collector

Appendix I: Your library, translated#

Appendices · Chapter 63· 17 min read

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#

.NETJavaNote
ASP.NET CoreSpring BootSpring Boot is the usual choice. Spring led Java web frameworks with 65% in the JetBrains survey of 2025.
Minimal APIsSpring Boot, Javalin, Helidonstay with Spring Boot in a Spring team, but Javalin is closest in style, with routes written as lambdas
Kestrelembedded Tomcat, Jetty, NettyTomcat is the default in Boot. A starter replaces Tomcat with Jetty, and WebFlux applications run on Netty.
IIS hostinga JAR with an embedded serverthe JAR contains its own web server, so there is no separate server to deploy it into
Swashbuckle / NSwagspringdoc-openapiIt reads your controllers at run time and serves the OpenAPI document. Use version 3 with Boot 4.
SignalRSpring WebSocket + STOMPno direct equivalent, but WebSocket plus the STOMP protocol is closest
gRPC for .NETgrpc-java, Spring gRPCgrpc-java is the gRPC project's own library, and Spring gRPC configures grpc-java servers and clients in Spring
YARPSpring Cloud Gatewaya library for building an API gateway on Spring, and the library has a WebFlux version and a Web MVC version
OcelotSpring Cloud Gatewaythe same library replaces both YARP and Ocelot, and it does routing, rate limiting and path rewriting
Hot ChocolateSpring for GraphQLSpring's GraphQL support, built on the GraphQL Java library by both teams together
Asp.Versioningbuilt into Spring Framework 7Boot 4 selects an API version by header, path or query parameter, with no extra library
BlazorVaadin, Thymeleaf, JTEno equivalent model, but Vaadin is closest for server-driven UI
Razor / Razor PagesThymeleaf, JTE, FreemarkerBoot 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:

ASP.NET Core Minimal API
var app = WebApplication.Create(args);

app.MapGet("/hello/{name}",
    (string name) => $"hello {name}");

app.Run();
Spring Boot
@RestController
class HelloController {

    @GetMapping("/hello/{name}")
    String hello(@PathVariable String name) {
        return "hello " + name;
    }
}
Reading the code
@GetMapping("/hello/{name}")

@GetMapping maps GET /hello/{name} to this method, like MapGet in ASP.NET Core.

@PathVariable String name

@PathVariable takes name from 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.

.NETJavaNote
ASP.NET Core authentication and authorizationSpring Securitythe standard for securing Spring applications, with login, access rules and attack protection in one framework
ASP.NET Core IdentitySpring Security with your own user tables, KeycloakNo full equivalent exists. Spring Security checks passwords, and you write registration and reset, or run Keycloak.
Duende IdentityServer / OpenIddictSpring Authorization Server, KeycloakSpring 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.JwtBearerSpring Security OAuth2 resource serverIt checks the bearer token on every request. Set the issuer address, and Boot configures the rest.
System.IdentityModel.Tokens.JwtNimbus JOSE + JWTthe library that Spring Security itself uses to parse tokens and check their signatures
Microsoft.Identity.WebSpring Cloud Azureits Microsoft Entra ID support signs users in with their work or school accounts
BCrypt.NetSpring Security PasswordEncoderthe default encoder hashes with bcrypt, and can move to a newer algorithm while old hashes still work

Data access#

.NETJavaNote
Entity Framework CoreHibernate / Spring Data JPABoot's JPA starter includes Hibernate, and Spring Data JPA adds repository interfaces that use Hibernate
DapperJdbcClient, JdbcTemplate, JDBIYou write the SQL, and the library maps rows to objects. Boot configures JdbcClient, so start with JdbcClient.
LINQ to SQL, IQueryablejOOQbuilds type-safe SQL in Java code, using classes it generates from your database schema
ADO.NETJDBCthe low-level database API that every Java driver implements
EF MigrationsFlyway, LiquibaseFlyway runs versioned SQL files in order. Choose Liquibase for changelogs in YAML or XML with rollbacks.
DbUpFlywaythe same idea as DbUp, with SQL scripts that each run once, in order, and are recorded as done
FluentMigratorFlyway Java migrations, LiquibaseFlyway also accepts migrations written as Java classes, and Liquibase describes changes in YAML or XML
DbContextEntityManager, or a Spring Data repositorythe unit of work that tracks the entities you loaded
IDbConnectionDataSource, Connectiona DataSource gives you pooled connections, and HikariCP is the default pool in Boot
Npgsql / SqlClientthe PostgreSQL / MSSQL JDBC drivera driver JAR on the classpath, chosen by its JDBC URL
EF Core InMemory providerH2an embedded database that Boot configures with no connection URL, once its dependency is added
StackExchange.RedisLettuce, JedisLettuce is the default in Boot's Redis starter, and Jedis is the other client that Boot can configure
MongoDB.Drivermongodb-driver-sync, Spring Data MongoDBThe official blocking driver is mongodb-driver-sync. For the repositories that Spring Data MongoDB adds, see the chapter Document data.
Elasticsearch.Netelasticsearch-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:

Dapper
var orders = conn.Query<Order>(
    "select id, total from orders where customer_id = @Id",
    new { Id = customerId });
Spring JdbcClient
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();
Reading the code
.param("id", customerId)

This line gives a value to :id in 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.

.NETJavaNote
AutoMapper, MapsterMapStruct, ModelMapperMapStruct generates the mapping code when you compile, but ModelMapper matches properties by convention instead
FluentValidation rulesJakarta Validation with Hibernate ValidatorRules are annotations on the fields, not a separate validator class. Boot's validation starter includes both libraries.
DataAnnotations attributesJakarta 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#

.NETJavaNote
System.Text.JsonJacksonthe default in Spring Boot, which configures it for you
Newtonsoft.JsonJackson, GsonJackson is the default JSON library in Boot, and Boot also configures Gson when Gson is on the classpath
JsonSerializer.SerializeobjectMapper.writeValueAsStringturns an object into a JSON String, the same job as Serialize
[JsonPropertyName]@JsonPropertysets the JSON name of one field or record component
[JsonIgnore]@JsonIgnoreleaves one field or record component out of the JSON
JsonSerializerOptionsObjectMapper configurationthe options are set once on the mapper, and Spring Boot builds the mapper from properties
protobuf-netprotobuf-javaprotobuf-java uses code generated from .proto files, but protobuf-net uses attributes
MessagePackmsgpack-javamsgpack-java is the MessagePack project's Java library, and its Jackson module maps objects through Jackson
YamlDotNetSnakeYAML, Jackson YAMLSpring Boot reads application.yaml with SnakeYAML, and Jackson YAML maps YAML to your classes
CsvHelperOpenCSV, Apache Commons CSV, Jackson CSVOpenCSV binds rows to annotated classes, and Commons CSV and Jackson CSV also read and write CSV files
Gotcha

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#

.NETJavaNote
Microsoft.Extensions.DependencyInjectionSpring, or Jakarta CDIthe container is part of the framework, not a separate package
AutofacSpringinside a Spring application you use Spring's container, so there is no second container library to choose
ScrutorSpring component scanningscanning is built in, so Spring finds classes marked @Component, @Service or @Repository by itself
ILogger<T>SLF4J Loggerthe interface that your code calls, and Logback implements this interface
SerilogLogback, Log4j2Spring Boot logs through Logback unless you switch to another library
NLogLogback, Log4j2Logback is the default in Boot, and Boot also includes a default configuration for Log4j2 if you switch
structured loggingLogstash encoder, or Boot structured loggingstructured logging means JSON log lines, and Spring Boot 3.4 and later writes them with no extra library
IConfigurationSpring Environmentone object answers every property lookup, whichever file or variable the value came from
appsettings.jsonapplication.yml / application.propertiesBoot loads either file by itself at startup, and the YAML form nests keys as appsettings.json does
IOptions<T>@ConfigurationPropertiesbinds a configuration section to a typed class or record
User Secretsa local profile, or Vaultkeep secrets out of the repository, in an ignored local file or in Vault
Azure App ConfigurationSpring Cloud Azure, Spring Cloud ConfigSpring Cloud Azure reads App Configuration itself, but Spring Cloud Config serves settings from Git instead
Microsoft.FeatureManagementTogglz, OpenFeatureTogglz 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.

.NETJavaNote
IMemoryCacheCaffeinean in-process cache with size and time limits, which Boot can put behind @Cacheable
IDistributedCache with RedisSpring Data Redisits RedisCacheManager keeps the same @Cacheable entries in Redis, shared by every instance
HybridCacheno direct equivalentBoot's cache providers include no two-level cache, so you combine Caffeine and Redis in your own code

Resilience, messaging, scheduling#

.NETJavaNote
PollyResilience4jthe choice for Boot 3, and it is a separate library
PollySpring Framework @Retryablethe choice for Boot 4, because retry moved into the framework
HttpClientFactoryRestClient, HTTP interface clientsRestClient is the modern client, and Boot configures its builder for you
Refit@HttpExchange interfacesdeclare an interface, and Spring generates the HTTP client
RestSharp, FlurlRestClientSpring's RestClient is already a fluent HTTP client, so the same style needs no extra library
MediatRSpring ApplicationEvents, Axonno single library does everything that MediatR does
MassTransit / NServiceBusSpring Cloud Stream, Spring Integration, Camel, AxonNo single library matches. Spring Cloud Stream hides the broker, and Spring Integration and Camel route messages.
RabbitMQ.ClientSpring AMQPuse RabbitTemplate to send and @RabbitListener to receive
Confluent.KafkaSpring Kafkause KafkaTemplate to send and @KafkaListener to receive
HangfireQuartz, JobRunr, Spring @Scheduled@Scheduled is good for simple timers. Quartz and JobRunr can keep jobs in a database, and JobRunr has a dashboard.
Quartz.NETQuartzQuartz is the Java scheduler of the same name. Boot has a starter for it and can keep its jobs in your database.
Azure FunctionsSpring Cloud Functionyou write the logic as a plain function, and adapters run it on Azure Functions, AWS Lambda or Google Cloud
System.ReactiveProject Reactor, RxJavaSpring WebFlux is built on Reactor, and RxJava is the Reactive Extensions library for the JVM
Akka.NETAkka, Apache PekkoAkka.NET is a port of Akka, and Pekko is a fork of Akka 2.6 that stayed under the Apache licence
OrleansApache Pekko, Akkathe 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.

.NETJavaNote
ClosedXML, EPPlusApache POIreads and writes Excel files, both .xlsx and .xls, and also Word and PowerPoint files
NPOIApache POIApache POI is the original, and NPOI describes itself as the .NET version of Apache POI
QuestPDFOpenPDF, Apache PDFBoxOpenPDF creates PDFs and can render them from HTML, and PDFBox also extracts text and fills forms
iText for .NETiTextthe same product for Java, with the same choice between the AGPL and a commercial licence
HtmlAgilityPack, AngleSharpjsoupparses real-world HTML into a tree that you query with CSS selectors or XPath
ImageSharpImageIO, Thumbnailator, TwelveMonkeysImageIO is in the JDK. Thumbnailator makes thumbnails, and TwelveMonkeys adds formats such as WebP.
MailKitJakarta Mail through JavaMailSenderBoot's mail starter builds a JavaMailSender once spring.mail.host is set, and JavaMailSender uses Jakarta Mail
ZXing.NetZXingZXing 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.

.NETJavaNote
AWS SDK for .NETAWS SDK for Java 2.x, Spring Cloud AWSThe SDK is from Amazon itself. Spring Cloud AWS is a community project that adds Spring-style S3 and SQS support.
Azure SDK for .NETAzure SDK for Java, Spring Cloud Azurethe client libraries are in the com.azure group, and Spring Cloud Azure adds Spring support for Service Bus and Key Vault

Testing and quality#

.NETJavaNote
xUnit / NUnitJUnit 5 (JUnit 6 on Boot 4)Boot's test starter includes JUnit, so a project does not choose a test framework
Moq / NSubstituteMockitothe mocking library in Boot's test starter, so it is already on the test classpath
FluentAssertionsAssertJa fluent assertion library, also included in Boot's test starter
WebApplicationFactory<T>@SpringBootTest with MockMvcIt starts the whole application for a test. Add @AutoConfigureMockMvc to get MockMvc, on Boot 3 and Boot 4.
TestcontainersTestcontainersthe same project in another language, which runs a real database in Docker for the length of a test
WireMock.NetWireMockWireMock is the Java original, and WireMock.Net copies its features in a separate .NET codebase
AutoFixtureInstanciofills a whole object with random values, so a test sets only the fields that matter to it
BogusDatafakergenerates fake data for tests in Java and Kotlin, from a large set of data providers
BenchmarkDotNetJMHJMH is the benchmark harness from the OpenJDK project. For one benchmark, see the chapter Testing.
coverlet + ReportGeneratorJaCoCoOne library does both jobs. Its Maven plugin attaches an agent to the tests and writes the report.
Stryker.NETPITStryker supports JavaScript, C# and Scala only, but PIT does mutation testing for Java, from Maven or Gradle
Reqnroll / SpecFlowCucumber-JVMReqnroll is Cucumber-style, so scenarios read the same. Cucumber-JVM runs them on the JUnit Platform.
NBomberGatling, JMeterA Gatling load test is Java code, as an NBomber test is C# code. JMeter is a separate application.
VerifyApprovalTests.Javacompares a result with an approved snapshot file, and works with JUnit and TestNG
Playwright for .NETPlaywright for Javathe same Microsoft project, published as the Maven artifact com.microsoft.playwright:playwright
Selenium WebDriverSeleniumthe same project in both languages, and the Java binding is the Maven artifact selenium-java
SonarAnalyzerSonarQubethe same Sonar product, with its Java rules
Roslyn analyzersError Pronefinds bug patterns at compile time, inside javac
StyleCopCheckstylechecks naming, imports and formatting against a rule file, and Checkstyle includes the Google and Sun styles
dotnet formatSpotlessa Maven or Gradle plugin that checks and fixes formatting, for example with google-java-format
ArchUnitNETArchUnitArchUnit is the original, and ArchUnitNET calls itself the C# fork of this library

Observability and diagnostics#

.NETJavaNote
dotnet-countersJFR, MicrometerJFR records JVM events, and Micrometer exposes application metrics
dotnet-trace / PerfViewJFR + JDK Mission ControlJFR records inside the JVM, and Mission Control opens the recording
dotnet-dumpjmap, jcmdheap and thread dumps taken from a running JVM
OpenTelemetry .NETOpenTelemetry Java agentan agent added at startup instruments common libraries with no change to your code
App Insights / PrometheusMicrometer + PrometheusMicrometer is the metrics interface, and a backend such as Prometheus connects to it
HealthChecksSpring Boot Actuatorhealth, metrics and info endpoints, switched on by one starter
Visual Studio Profilerasync-profiler, JMCasync-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 heap
Reading the code
jcmd 4242 JFR.start duration=60s filename=app.jfr

This command starts a one-minute flight recording and writes it to app.jfr. The application does not restart.

jcmd 4242 Thread.print

This command prints what every thread is doing, like a thread dump from dotnet-dump.

jmap -histo 4242 | head

This command counts the objects of each class on the heap. The class that uses the most memory comes first.

Note

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#

.NETJavaNote
LINQStreamsStreams are built into the JDK. See the chapter Streams vs LINQ.
Humanizerno direct equivalentthere is no popular equivalent, so format such text yourself
AutoMapperMapStructMapStruct works at compile time, so mapping problems appear in the build
FluentValidationJakarta Bean Validationrules are annotations such as @NotNull and @Size, or custom validators
System.Collections.ImmutableList.of, Guava immutablesList.of and Map.of are built in, and Guava adds richer immutable types
Nito.AsyncExvirtual threadsthe problems that Nito.AsyncEx solves largely disappear with virtual threads
CommandLineParserpicocliannotation-driven command-line parsing, with generated help
Scriban / HandlebarsThymeleaf, MustacheThymeleaf and Mustache are text template libraries, and Mustache is the closest to Handlebars
NodaTimejava.timejava.time is built into the JDK, and NodaTime and java.time both descend from Joda-Time
Guard clauses librariesObjects.requireNonNull, Guava Preconditionsargument checks that throw with a clear message
OneOfsealed interfaces and recordsSealed interfaces and records are built into Java 21. A sealed interface lists its cases, and a switch must handle every case.
ErrorOra sealed Result type, or Vavrthe JDK has Optional but no Result type, so write a small sealed interface or use Either from Vavr
LanguageExtVavra functional library for Java with Option, Either, Try and persistent collections
Statelessstateless4jThe 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 toolWhat it isThe problem it solvesWhat .NET does instead
LombokAnnotations 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.
jOOQA 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 queriesA 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.
GuavaGoogle'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 CommonsSmall 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 ReactorA 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 agentsA 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 RecorderAn 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.
SDKMANA 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 wrappersThe 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.
JBangA 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.
OpenRewriteRecipes 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 NullAwayPlugins 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 ModulithA 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 GroovyOther 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:

System.Text.Json
var opts = new JsonSerializerOptions {
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    DefaultIgnoreCondition =
        JsonIgnoreCondition.WhenWritingNull
};

string json = JsonSerializer.Serialize(order, opts);
var back = JsonSerializer.Deserialize<Order>(json, opts);
Jackson 3 (Boot 4)
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);
Reading the code
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 WhenWritingNull in 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);
Note

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()) for java.time types.
  • 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:

Attributes
public record Order(
    [property: JsonPropertyName("order_id")] long Id,
    [property: JsonIgnore] string Secret,
    [property: JsonConverter(typeof(MoneyConverter))]
    decimal Total);
Annotations
public record Order(
    @JsonProperty("order_id") long id,
    @JsonIgnore String secret,
    @JsonSerialize(using = MoneySerializer.class)
    BigDecimal total) { }
Reading the code
@JsonProperty("order_id") long id

This annotation writes the component as order_id, like JsonPropertyName in C#.

@JsonIgnore String secret

This annotation leaves the component out of the JSON entirely.

@JsonSerialize(using = MoneySerializer.class)

This annotation names a custom serializer, like a JsonConverter in C#. MoneySerializer is your own class.

System.Text.JsonJacksonNote
JsonSerializer.Serializemapper.writeValueAsStringreturns 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 typenew TypeReference<List<T>>() { }the trailing braces make a subclass that keeps List<T> at run time
[JsonPropertyName]@JsonPropertywrites and reads the member under the JSON name you give
[JsonIgnore]@JsonIgnoreleaves the member out of the JSON entirely
[JsonConverter]@JsonSerialize / @JsonDeserializenames your own serializer or deserializer class for one member
JsonSerializerOptionsJsonMapper.builder()Jackson 3 sets options on a builder, then builds the mapper once
CamelCase policyPropertyNamingStrategies.LOWER_CAMEL_CASErarely needed, because Java field names are already camelCase
DateTime supportbuilt in (Jackson 3), JavaTimeModule (Jackson 2)Jackson 3 handles java.time itself, but Jackson 2 needs the module
Unknown members ignoredFAIL_ON_UNKNOWN_PROPERTIESJackson 2 fails on unknown members by default, but Jackson 3 ignores them
Gotcha

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:

AutoMapper
public class OrderProfile : Profile
{
    public OrderProfile()
    {
        CreateMap<Order, OrderDto>()
            .ForMember(d => d.CustomerName,
                o => o.MapFrom(s => s.Customer.Name));
    }
}

var dto = _mapper.Map<OrderDto>(order);
MapStruct
@Mapper(componentModel = "spring")
public interface OrderMapper {

    @Mapping(target = "customerName",
             source = "customer.name")
    OrderDto toDto(Order order);
}

// injected like any bean
var dto = orderMapper.toDto(order);
Reading the code
source = "customer.name")

This line fills customerName from the name of the order's customer, like MapFrom in 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.

Note

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
}
Reading the code
class Order implements Serializable {

Serializable has no methods. It only marks that objects of the class may be serialized.

private transient String secret;

transient leaves 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.

Gotcha

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
Reading the code
-Djdk.serialFilter='com.acme.**;java.base/*;!*'

This filter allows classes under com.acme and classes in the java.base module. It rejects every other class, before any object is built.

ProblemDetail
Securityuntrusted input can execute code, as the warning above explains
VersioningserialVersionUID must be managed by hand, or old data stops loading
Couplingthe wire format is your private field layout, so refactoring breaks it
Portabilityonly Java can read the format, and no other platform understands it
Bypasses constructorsobjects are reconstructed without running your invariants
Note

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:

ILogger + Serilog
private readonly ILogger<OrderService> _log;

_log.LogInformation(
    "Placed {OrderId} for {Total}", id, total);

using (_log.BeginScope(new Dictionary<string, object>
       { ["CorrelationId"] = cid }))
{
    _log.LogWarning("slow");
}
SLF4J + Logback
private static final Logger log =
    LoggerFactory.getLogger(OrderService.class);

log.info("Placed {} for {}", id, total);

MDC.put("correlationId", cid);
try {
    log.warn("slow");
} finally {
    MDC.remove("correlationId");
}
Reading the code
LoggerFactory.getLogger(OrderService.class)

This call gets a logger that is named after the class, like ILogger<OrderService> in C#. The field is static, 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 BeginScope in C#.

Gotcha

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"
Reading the code
com.acme.billing: DEBUG

This line logs everything from DEBUG up for the billing code. All other code stays at INFO.

[%X{correlationId}]

%X reads 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:

C#
// records and primary constructors cover most of it
public record Customer(string Name, int Age);

public class Order(IOrderRepo repo)
{
    private readonly IOrderRepo _repo = repo;
}
Java 25
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
@EqualsAndHashCode
public class Customer {
    private String name;
    private int age;
}

@RequiredArgsConstructor        // one ctor for all final fields
public class OrderService {
    private final OrderRepo repo;
}
Reading the code
@Getter @Setter

These annotations generate a getter and a setter for every field.

@RequiredArgsConstructor

This annotation generates a constructor that takes every final field. Spring passes dependencies through such a constructor.

Note

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#

.NETJavaNote
MSBuildMaven, GradleMaven is the most widely used, at 67% in the JetBrains survey of 2025, and Gradle is the alternative
NuGetMaven Centralthe public package repository, addressed by group and artifact
dotnet publishmvn packagewith the Boot plugin, builds one JAR that holds every dependency
Self-contained deploymentfat JAR, or jlinkjlink builds a trimmed runtime that you deliver together with the app
NativeAOTGraalVM native-imageIt compiles ahead of time to a native binary. See the chapter Packaging and native images.
ReadyToRunAOT cache, CDSthe AOT cache and CDS are startup caches, and Java 24 and 25 improved them substantially
dotnet toolJBang, or a shaded JARJBang runs a Java program straight from a file or a URL
global.json.sdkmanrc, Maven toolchainspins the JDK that a project builds with

Appendix J: About this book#

Appendices · Chapter 64· 3 min read

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#

SideVersionNote
Java25 LTSwhere Java 21 is different in a way that matters, the text says so
.NET10, C# 14all C# code uses the modern style, such as records, patterns and primary constructors
SpringBoot 3 and Boot 4the 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 coveredWhere Java is used for itWhy it is out of scope
Mobile developmentAndroid, and Kotlin Multiplatforma different SDK, build system, lifecycle and UI model, so almost none of the Spring Boot part applies
Desktop applicationsJavaFX, Swing, SWTa separate UI stack with no ASP.NET Core parallel to translate from
GameslibGDX, jMonkeyEngineniche on the JVM, and the .NET comparison would be Unity
Embedded and IoTJava ME, embedded JVMsa subset of the platform with different constraints
Big data and analyticsSpark, Flink, Hadoop, Kafka Streamslarge ecosystems in their own right, and the language is the smallest part
Applets and browser pluginsthe plugin went in Java 11, the Applet API in Java 26see Appendix C, which explains what you may still find
Jakarta EE application serversWildFly, WebSphere, Open LibertyAppendix C covers enough to recognise them, and Spring Boot is assumed
Java as a first languageany introductory textthis 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