Java for C# Developers#

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

Compiled bykodebot

September 2026 edition

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

Facts verified 9 to 11 September 2026. This copy was built 13 September 2026; the edition, not the build date, says how current the content is.

Preface#

This book exists because the syntax is the easy part.

If you know C#, you can read Java code on your first afternoon. Both languages have curly braces, classes, interfaces, generics and lambdas. Both compile your code to an intermediate form, bytecode in Java and IL in .NET, and both free unused memory with a garbage collector. C# borrowed much of its shape from Java, so the resemblance is deliberate.

What costs you weeks is everything around the language:

  • which Java Development Kit (JDK) to install;
  • why the build tool works so differently from MSBuild;
  • why an annotation, Java's version of a C# attribute, silently did nothing;
  • why there is no await;
  • why a method you did not mark virtual was overridden anyway.

Those are the things this book is about.

Who it is for#

You write C# today, on .NET 8 or later, and you are moving to Java, most likely to work on a Spring Boot service. You are comfortable with classes, LINQ, async and await, and ASP.NET Core controllers. You do not need to know any Java. Every Java word is explained the first time a chapter uses it, and the glossary at the back explains each one in plain English.

What you need is to know where your C# habits still work in Java, and where they lead you to the wrong answer. So each idea starts with a small example, often something going wrong, then gives the rule, then shows the Java code with every unfamiliar piece explained. Almost every page has a side-by-side comparison, with C# on the left and Java on the right.

Each chapter ends with What to remember, a few sentences that sum up what it taught. After that comes a Quick reference with its tables and short forms, for when you come back later.

How it is organised#

The book has two reading modes and one view for refreshing later. Switch between them with the buttons in the sidebar, or press m.

ModeShowsTakesLeaves you
Everydaywhat you will use in your first months, fully explainedabout 5 hours 5 minuteswriting Java productively
Completeeverything, including rarely needed material such as legacy APIs and JVM internalsabout 7 hours 5 minutesable to work on anything in a Java codebase
Referenceonly each chapter's summary, "What to remember" and "Quick reference"a quick passrefreshing what you have already learned

In Everyday mode, rarely needed material folds into one-line headings. Nothing is hidden from you: open any single one that looks relevant. Complete opens all of it, including the asides marked Legacy, which explain code you will inherit but should not write. Reference is for coming back later: it hides the explanations and keeps each chapter's short forms. Earlier copies of this book called the two modes Fast and Full.

There is another way to read it: press ⌘K and type a C# name, or paste an error message. Every table row, C# refresher, error entry, callout, diagram and glossary word is indexed. So IEnumerable, IOptions, ConfigureAwait or the first line of a stack trace takes you straight to its answer.

Where a comparison leans on a C# feature you may not have used recently, a one-line C# refresher sits beside it. It stays closed until you open it, so it costs nothing if you remember the feature.

What it targets#

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

Many of the Java features people write about online are still preview features: finished, but not final. You switch one on with --enable-preview, and it can still change between releases. So every preview feature in this book is marked as one, because copying a preview API into production code is an easy way to lose an afternoon.

On accuracy#

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

So every claim here that depends on a version was checked against the original sources:

  • the OpenJDK project pages, for the features in JDK 21, 22, 24 and 25;
  • the JDK Enhancement Proposals (JEPs), the documents that describe each change to Java, for the exact shape of each API;
  • the Spring release notes and migration guides, for the differences between Spring Boot 3 and Spring Boot 4.

Where something could not be verified, it was either marked as uncertain or left out.

When it was written#

This is the September 2026 edition. Its version-sensitive facts were checked against those sources between 9 to 11 September 2026. Java, .NET and Spring all release on a fixed schedule. So anything this book calls current, latest or new was true on those dates, and the sentences that say so carry the date.

After that, treat version numbers, support dates and preview status as claims to check against the current documentation. When this book and the reference documentation disagree, the documentation is right. The date a copy was built is shown in the sidebar, and it is not the edition: a copy rebuilt later from the same text is still the September 2026 edition.

What it does not cover#

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

Not coveredWhere Java is used for itWhy it is out of scope
Mobile developmentAndroid, and Kotlin Multiplatforma different SDK, build system, lifecycle and UI model; almost none of Part 9 applies
Desktop applicationsJavaFX, Swing, SWTa separate UI stack with no ASP.NET Core parallel to translate from
GameslibGDX, jMonkeyEngineniche on the JVM, and the .NET comparison would be Unity
Embedded and IoTJava ME, embedded JVMsa subset of the platform with different constraints
Big data and analyticsSpark, Flink, Hadoop, Kafka Streamslarge ecosystems in their own right; the language is the smallest part
Applets and browser 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; Spring Boot is assumed
Java as a first languageany introductory textthis book assumes you are fluent in C# already

Within server-side Java it is still selective. It will not teach you the internals of the Java Virtual Machine (JVM), the program that runs your bytecode. Nor does it cover the memory model in depth, or reactive programming beyond the decision of whether to use it. Kotlin, a language that also runs on the JVM, gets an appendix rather than a chapter, because the platform facts in this book apply unchanged to a Kotlin codebase.

Appendix D maps the expert territory: what each topic is, the symptom that means you now need it, and where to read properly. It points the way rather than teaching, deliberately. Appendix E covers Kotlin, and Appendix H explains every Java word the book uses.

A note on sources#

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

Why Java feels familiar#

Part P0 · Day one · Chapter 01· 7 min read· Quick reference

C# and Java look alike: curly braces, classes, interfaces, generics, lambdas and garbage collection all work much the same way. This chapter shows how close they are with one small class written in both languages.

It then names the six places where your C# habits will mislead you, and says which chapter explains each one.

What transfers unchanged#

Your first job on a Java team is often to read code, not to write it. Here is the service that places orders in an online shop, first in C# and then in Java. The C# version is the kind of class you write every week.

C# 14
// The parameter list after the class name is a primary constructor.
public sealed class OrderService(IOrderRepository repository)
{
    public async Task<Order> PlaceAsync(Customer customer, List<OrderLine> lines)
    {
        if (lines.Count == 0)
            throw new ArgumentException("An order needs at least one line.");
        var order = new Order(customer, lines);
        await repository.SaveAsync(order);
        return order;
    }
}
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.");
        var order = new Order(customer, lines);
        repository.save(order);
        return order;
    }
}
Reading the code
public final class OrderService

final on a class means what sealed means in C#: no other class may extend it.

private final OrderRepository repository;

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

public OrderService(OrderRepository repository)

Java classes have no primary constructors, so the constructor is written out and stores its parameter in the field. Spring, the framework most Java services use, passes the repository in, exactly as the ASP.NET Core container would.

throw new IllegalArgumentException

IllegalArgumentException is Java's ArgumentException. Throwing works the same way.

var order = new Order(customer, lines);

var works as it does in C#: the compiler works out the type, Order, from the right-hand side.

repository.save(order);

There is no await, and the method returns Order, not Task<Order>. The call simply waits until the save has finished. Threads are cheap now explains why that is the right way to write Java today.

Classes, constructors, exceptions, generics such as List<OrderLine>, and var all carry over one to one. That resemblance is real, and it is also the danger: it makes it easy to write Java as if it were C#. The next section shows where that goes wrong.

The six things that will actually cost you time#

Here is a small invoice class that shows four of the six at once. It loads a template from disk and saves itself through a repository field, left out to keep it short. Read the C# side first, then compare it line by line with the Java side.

C# 14
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. The value lives in a private field, and a method called getTotal reads it. Fields, properties and immutability shows the shorter forms.

String loadTemplate(Path path)

No access modifier means private in C#, but package-private in Java: every class in the same package can call it.

throws IOException

Files.readString can fail with an IOException, which is a checked exception. Java makes you either catch it or list it after throws, as here. C# lets you ignore it.

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

No async and no Task. The call blocks until the save has finished, which costs almost nothing on a virtual thread, the lightweight kind of thread Java uses for this.

The other two traps do not fit in a class. Java removes generic type arguments when it compiles, a step called type erasure, so at run time a list does not know what it holds. And the build is run by a separate tool, Maven or Gradle, rather than by the IDE.

C# refresherReified generics: the type argument exists at run time

In .NET a generic type keeps its type argument at run time, so code can ask for typeof(T) or test whether an object is a List<string>.

static string NameOf<T>() => typeof(T).Name;

NameOf<int>();                             // "Int32": T is known at run time

object xs = new List<int>();
Console.WriteLine(xs is List<string>);     // False: the type argument is checked

Here are all six, each with the chapter that explains it:

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

One more false friend deserves a warning of its own, because it looks identical in both languages.

Gotcha

protected does not mean what you think. In C# it means “this class and its subclasses”. In Java it means “this class, its subclasses, and every other class in the same package”. Nothing warns you. Here is the same method in both languages:

C#
public class Invoice
{
    protected Money Discount() => Money.Zero;
}
Java 25
package com.acme.billing;

public class Invoice {
    protected Money discount() { return Money.ZERO; }
}

In C#, only Invoice and its subclasses can call Discount(). In Java, any class in com.acme.billing can call discount() as well, even one that has nothing to do with invoices.

A mental model for the platform#

.NET is one product. Microsoft ships the runtime, the base class library, the web framework, the object-relational mapper (ORM), the dependency injection (DI) container and the test runner, all versioned together.

Java is a specification plus a market. Oracle, Eclipse, Amazon, Azul and Red Hat all ship a Java Development Kit (JDK) built to the same specification. The web framework, the DI container and the ORM come from Spring or Quarkus, not from the JDK.

Each .NET piece has a Java counterpart, though rarely from the same vendor:

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

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

So a Java web service begins by choosing its framework, as dependencies in the build file. Where ASP.NET Core arrives with the .NET SDK, these are downloaded like any other library:

<!-- pom.xml: none of these ship with the JDK -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>       <!-- the ASP.NET Core role -->
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>  <!-- the EF Core role -->
</dependency>

Each <dependency> names a library by its group and its artifact, the two parts of a Maven name. spring-boot-starter-web is a starter: one dependency that brings in Spring's web framework and a web server. spring-boot-starter-data-jpa brings in Hibernate, the most widely used ORM. No versions appear, because the Spring Boot parent file chooses them for you.

Conventions that differ#

Java code also looks different on the page, because the two communities settled on different conventions:

C#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, laid out the way each community writes them:

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 package com.acme.billing; and sits in the folder com/acme/billing/, and the file name matches the public class inside it. The interface loses its I, method and field names start with a lower-case letter, and the field has no underscore.

LegacyThe Impl suffix

Older Java codebases pair every interface with a single implementation named OrderServiceImpl. You will see it often, especially in Spring code written before about 2015. It is no longer recommended. If there is exactly one implementation, you usually do not need the interface at all. If you do need it, name the implementation for what makes it different, such as JdbcOrderRepository or InMemoryOrderRepository.

How to read this book#

The book has two reading modes and one view for refreshing later. The buttons are in the sidebar, and m moves from one to the next.

ModeShowsTakesLeaves you
Everydaywhat you will use in your first months, fully explainedabout 5 hours 5 minutesable to write Java productively
Completeeverything, including rarely needed material such as legacy APIs and JVM internalsabout 7 hours 5 minutesable to work on anything in a Java codebase
Referenceonly each chapter's summary, "What to remember" and "Quick reference"a quick passrefreshing what you have already learned

Everyday keeps everything you will meet in a typical Spring Boot service in your first months. That means each chapter's summary, its examples and their explanations, the mapping tables, and the traps you will actually run into. Rarely needed material is folded into a one-line heading you can click if a particular topic matters to you right now.

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

Reference is for later. It hides the explanations and shows only each chapter's summary, what to remember and its quick reference, so you can refresh one topic, or the whole book, in one pass.

There is one more way to use it. Press ⌘K and type a C# name, or paste an error message. Mapping-table rows, C# refreshers, error entries, callouts, diagrams and glossary words are all indexed, so IEnumerable, IOptions and ConfigureAwait resolve to their Java answers without reading anything.

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

What to remember

The order service reads almost the same in both languages. Classes, constructors, exceptions and var carry over, sealed becomes final, and the async disappears.

The invoice class showed four of the six traps at once. It used a getter instead of a property, package-private instead of private, a checked exception that must be declared, and a blocking call instead of await.

The other two are generics that lose their type arguments when compiled, and a build tool, Maven or Gradle, that runs outside the IDE.

Java's protected opens a member to the whole package, not only to subclasses.

Quick reference#

C#JavaNote
sealed classfinal classnothing may extend the class; the keyword differs, the meaning does not
readonly fieldfinal fieldassigned once, in the declaration or in the constructor
OrderService(IOrderRepository repository)a written-out constructorJava classes have no primary constructors
ArgumentExceptionIllegalArgumentExceptionthe standard exception for a bad argument value
async Task<Order> PlaceAsync()Order place(), run on a virtual threadblocking is cheap on a virtual thread, so there is no async
Total { get; set; }getTotal() and setTotal()Java has no properties; a record gives you accessors instead
no modifier: privateno modifier: package-privatevisible to the whole package, which surprises C# developers
protectedprotectedalso visible to every other class in the same package
IOrderRepositoryOrderRepositoryJava code drops the I prefix on interface names
namespace Acme.Billingpackage com.acme.billingthe package should match the folder the file sits in

Getting a JDK, and the release train#

Part P0 · Day one · Chapter 02· 7 min read· Quick reference

To write Java you install a Java Development Kit (JDK): the compiler, the runtime and the tools, like the .NET SDK. There is no single Java to download. You choose a version and a vendor, and this chapter shows which to choose, how to keep several side by side, and the commands you will use every day.

Five minutes to a running service#

It is your first morning, and you want a service running before you read anything else. These three steps install a JDK, generate a Spring Boot project and start it:

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

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

# 3. run it
./mvnw spring-boot:run
Reading the code
curl -s "https://get.sdkman.io" | bash

Installs SDKMAN, a tool that installs Java versions and switches between them, much like the dotnet-install script.

sdk install java 25.0.4-tem

Installs Temurin 25, a free build of the JDK. The identifier includes the exact patch release, and sdk list java shows the current ones.

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

Asks the Spring Initializr, a project generator, for a new Maven project with the web starter, and unpacks it into a folder called demo.

./mvnw spring-boot:run

Builds and starts the service with the Maven wrapper, a script inside the project that downloads the right Maven version. You never install Maven yourself.

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

package com.example.demo;

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

@RestController
public class Hello {

    @GetMapping("/hello")
    public String hello(@RequestParam(defaultValue = "world") String name) {
        return "hello " + name;
    }
}
Reading the code
package com.example.demo;

Names the package, Java's version of a namespace. It matches the folder the file sits in.

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

Imports every class in that package, like a C# using directive.

@RestController

An annotation, Java's version of a C# attribute. This one tells Spring that the class handles HTTP requests, like [ApiController] on a controller.

@GetMapping("/hello")

Sends GET /hello to this method, like [HttpGet("/hello")].

@RequestParam(defaultValue = "world") String name

Reads name from the query string, and uses "world" when it is missing, like [FromQuery] with a default value.

Call it from another terminal:

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

The response is the text hello java. Spring Boot listens on port 8080 unless you configure another.

Note

That is the equivalent of dotnet new webapi plus dotnet run. The Java archive (JAR) that the build produces contains its own web server, so there is nothing to deploy into, just as a Kestrel app runs on its own.

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

That service ran on Java 25. The next section explains what the version numbers mean, and which one to choose.

The release train#

You will see Java 8, 11, 17, 21 and 25 in job adverts and in existing code, but rarely 22 or 23. The reason is the release schedule.

Java ships a new version every six months, in March and September. Since Java 17, every fourth release is a long-term support (LTS) release, so one arrives every two years. Earlier LTS releases were further apart, which is why the table below is not evenly spaced. The releases in between get six months of updates and then nothing. They exist so that new features can mature in the open.

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), such as JEP 444, which added virtual threads in Java 21. The book gives these numbers where they help you look something up.

Note

Target Java 21 unless you control the whole deployment. As of September 2026 it is where most production Java runs. It has virtual threads and pattern matching, and every library supports it. Java 25 is the right choice for a new project. A release that is not LTS is for experiments.

The JDK that runs the build and the Java version you target are separate settings, as they are in .NET. You can build on JDK 25 and still produce bytecode, the compiled form of your code, that runs on Java 21:

<!-- pom.xml: compile for Java 21, whichever JDK runs the build -->
<properties>
    <maven.compiler.release>21</maven.compiler.release>
</properties>

<!-- under the Spring Boot parent, set this instead; the parent copies it across -->
<properties>
    <java.version>21</java.version>
</properties>

maven.compiler.release tells the compiler which Java version the output must run on, much as <TargetFramework> does in a .csproj file. Under the Spring Boot parent you set java.version instead, and the parent copies it into the compiler setting.

Gotcha

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

Which distribution#

Several vendors build and ship the JDK, each from the same OpenJDK source code:

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

Each of them passes the Technology Compatibility Kit (TCK), the test suite that proves a JDK behaves like Java. The differences are support, licensing and a few extras. So do not spend a week choosing; take Temurin.

Installing and switching#

You will soon need more than one JDK: Java 21 for one service and Java 25 for another, just as you keep several .NET SDKs. SDKMAN installs them side by side and switches between them. These are the commands you will use:

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

sdk list java                        # every version and vendor, with its identifier
sdk install java 25.0.4-tem          # Temurin 25
sdk install java 21.0.12+1.1-tem     # Temurin 21 alongside it
sdk use java 21.0.12+1.1-tem         # this shell only
sdk default java 25.0.4-tem          # new shells

java -version                        # what am I actually running
Reading the code
sdk list java

Lists every JDK SDKMAN can install, with the identifier to use, and marks the ones you already have.

sdk use java 21.0.12+1.1-tem

Switches only the current terminal to that JDK, which is handy for trying one project on an older version.

sdk default java 25.0.4-tem

Sets the JDK that every new terminal starts with.

java -version

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, and it changes with every quarterly update. Copy it from sdk list java; the ones above were current in September 2026.

In .NET, a global.json file pins the SDK a folder uses.

.NET refresherglobal.json: pinning the .NET SDK for a folder

A global.json file makes every dotnet command in its folder, and below it, use the SDK version it names.

{
  "sdk": { "version": "10.0.100", "rollForward": "latestFeature" }
}

SDKMAN's version of it is a .sdkmanrc file in the project folder:

sdk env init      # writes .sdkmanrc naming the current JDK, such as java=25.0.4-tem
sdk env           # switches this shell to the JDK that .sdkmanrc names

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

Gotcha

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

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

With a JDK installed and chosen for each project, the last thing you need on day one is a way to add a library.

jshell: the REPL#

Java has had a read-eval-print loop (REPL), a prompt where you type code and see the result at once, since Java 9 Java 9. It is called JShell, it is the equivalent of C# Interactive, and it is the fastest way to check how a library behaves:

$ jshell
|  Welcome to JShell -- Version 25

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

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

jshell> /exit

Each line is run as soon as you press Enter. xs ==> [1, 2, 3, 4] is jshell showing the value it stored, and filter(x -> x % 2 == 0) keeps the even numbers, with -> as Java's lambda arrow, the => of C#. /exit leaves.

Running source directly#

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

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 void main(): no class around it, no String[] args and no static. IO.println writes a line, like Console.WriteLine. The feature is final in Java 25, as JEP 512, so it is safe in scripts and teaching. Most real code still lives in an ordinary class with a public static void main(String[] args).

LegacyThe full main method

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

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

System.out.println is the Console.WriteLine you will meet in real code. IO.println is new in Java 25.

Adding a library#

Your service needs input validation. In .NET you would run dotnet add package. The Java build tools have no such command.

Gotcha

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

Adding the validation library looks like this:

<!-- pom.xml: the whole of "adding a package" -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <!-- no version: the Spring Boot parent chooses it -->
</dependency>

The groupId and artifactId together name the library, like a NuGet package id. There is no version, because the Spring Boot parent file chooses a compatible one. The next ./mvnw command downloads it.

What to remember

One script installed a JDK with SDKMAN, generated a Spring Boot project and started it with the Maven wrapper. One annotated class then answered HTTP requests on port 8080.

Java ships every six months. The long-term support releases, 21 and 25, are the ones to run in production.

You can build with a newer JDK and target an older release with maven.compiler.release, or with java.version under the Spring Boot parent.

SDKMAN keeps several JDKs side by side, and a .sdkmanrc file pins one per project, like global.json. There is no mvn add: you add a library by editing pom.xml.

Quick reference#

.NETJavaNote
dotnet --list-sdkssdk list javamarks each JDK that SDKMAN has installed on this machine
global.json.sdkmanrc, or Maven toolchainspins which JDK this project builds with
dotnet --versionjava -versionprints to stderr; java --version, with two dashes, prints to stdout
DOTNET_ROOTJAVA_HOMEthe JDK folder that mvn and gradle use when set; IDEs ignore it
Task.NETJava
Compiledotnet buildjavac Foo.java, or mvn compile
Rundotnet runjava Foo, or mvn spring-boot:run
Run one filedotnet scriptjava Foo.java
REPLdotnet-script / C# Interactivejshell
Testdotnet testmvn test
Packagedotnet publishmvn package
Add dependencydotnet add package Xedit pom.xml by hand
Cleandotnet cleanmvn clean

How Java runs your code#

Part P0 · Day one · Chapter 03· 14 min read· Quick reference

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

The classpath is the ordered list of folders and Java archive (JAR) files, zip files of compiled classes, that the Java Virtual Machine (JVM) searches for each class it needs. The first match wins. This chapter follows one small program from source code to a running process, so that each startup error has a cause you have already seen.

From source to a running program#

Java takes the same route as .NET in more visible steps. The compiler turns source into bytecode, Java's IL, and writes one class file per class. Class files are zipped into a JAR, and the java command starts a JVM to run them.

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
From source to a running program: javac compiles .java files into .class files of bytecode, jar zips them into a JAR, and java starts a JVM. The .NET compiler writes IL straight into a .dll, in one step.
.NET refresherWhat dotnet build writes: the .dll, deps.json and runtimeconfig.json

The C# compiler (Roslyn, run as csc) writes IL straight into an assembly, and the runtime reads deps.json at startup to find every dependency. You never assemble a search list yourself.

dotnet build
ls bin/Debug/net10.0/
# MyApp.dll                   the IL, compiled by Roslyn
# MyApp.deps.json             every dependency, and where to find it
# MyApp.runtimeconfig.json    which .NET runtime to start
.NETJavaMeaning
.cs file.java filesource code; a public class must sit in a file named after it
Roslyn (csc)javacthe compiler; it writes bytecode, not machine code
ILbytecodethe portable instruction set; the runtime compiles it to machine code as it runs
IL inside the .dllclass file (.class)the bytecode of one class; javac writes one per class, nested classes too
assembly (.dll)JAR (.jar)a zip of class files plus a manifest; the unit you ship and depend on
assembly manifestJAR manifesta text file inside a JAR that can name its main class and other JARs
CLRJVMthe runtime: loads classes, checks them, and compiles hot code to machine code
.NET SDKJDKthe compiler, the tools and a runtime together; install this to write Java
.NET runtimeJREa runtime with no compiler, for running programs only; see the note below
namespace plus type namefully qualified namepackage plus class, as in com.acme.App, which is also its file's path
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
Note

For development, install a Java Development Kit (JDK). It does everything a Java Runtime Environment (JRE) does, and adds the compiler. Oracle stopped offering a separate JRE with Java 11, and recommends jlink to build a small runtime instead. Some other vendors still ship one for deployment.

Every class file stores the Java release it was compiled for, and a runtime refuses bytecode newer than itself:

java.lang.UnsupportedClassVersionError: com/acme/App has been compiled by a more recent version of the Java Runtime (class file version 69.0), this version of the Java Runtime only recognizes class file versions up to 65.0#
Means
The class was compiled for a newer Java than the one running it. The class file version is the Java release plus 44, so 69 means Java 25 and 65 means Java 21.
Fix
Run on a Java at least as new as the target, or compile for the older one 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 finish#

Maven and Gradle, Java's build tools, hide every step below, as dotnet build hides the compiler. Doing it by hand once makes every classpath error readable. Take two classes in the package com.acme. A package is Java's namespace, with one extra rule: the class files must sit in folders that match the package name.

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;

Puts App in the package com.acme, so its full name is com.acme.App.

public static void main(String[] args)

The entry point. A normal Java class has no top-level statements, so the program starts in a static method called main, like C#'s static void Main.

System.out.println

Writes a line to the console, like Console.WriteLine.

java -cp out com.acme.App

The command that runs it: the classpath after -cp, then the full name of the class that has main.

App uses a second class, Greeter, in a file of its own:

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

public class Greeter {
    public String greet(String name) { return "hello " + name; }
}

Greeter is in the same package as App, so App can use it without an import. Compile both. The -d option says where the class files go:

javac -d out src/com/acme/*.java

javac writes one class file per class, and turns the package name into folders: com.acme becomes com/acme.

out/
└── com/
    └── acme/
        ├── App.class
        └── Greeter.class

That mapping is the whole trick. A class's fully qualified name, its package plus its name, is also the path of its class file: com.acme.App is the file App.class in the folder com/acme. Run it with -cp, the classpath, followed by the class whose main method starts the program:

java -cp out com.acme.App
# hello java

Here is what java did:

  1. Started a JVM whose classpath has one entry, the folder out.
  2. Turned the name com.acme.App into the path com/acme/App.class.
  3. Found out/com/acme/App.class, loaded it, and called its main method.
  4. When main first used Greeter, looked up com/acme/Greeter.class the same way.

To ship it, zip the classes into a JAR and write the main class into the JAR's manifest, a small text file inside the archive:

jar --create --file app.jar --main-class com.acme.App -C out .
java -jar app.jar
# hello java
Reading the code
jar --create --file app.jar

Creates a JAR called app.jar, with jar, the JDK's archiving tool.

--main-class com.acme.App

Writes Main-Class: com.acme.App into the JAR's manifest, so that java -jar knows where to start.

-C out .

Adds everything in the folder out, keeping the package folders.

java -jar app.jar

Runs the JAR, taking the main class from its manifest.

Gotcha

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

The classpath is a search list#

After an upgrade, your service has two copies of the billing library on its classpath: version 2.1, and an old version 1.9 that nobody removed. Which one does Java use?

The first time your code uses a class, the JVM turns its fully qualified name into a path: com.acme.billing.Invoice becomes com/acme/billing/Invoice.class. It then checks each classpath entry, a folder or a JAR, in the order listed. The first entry that has the file wins, and the search stops.

Think of a row of shelves that you search in order for a book. You take the first copy you find, even if a newer edition sits on a later shelf. The comparison stops at the shelves themselves: a JAR is a zip file, not a folder, and the JVM looks inside it.

When the list is wrong, you get one of four errors:

Error: Could not find or load main class com.acme.App#
Means
No classpath entry holds com/acme/App.class. The line after it says why: ClassNotFoundException means the file is on no entry, and NoClassDefFoundError ... (wrong name: com/acme/App) means the classpath points inside the package folder.
Fix
Pass the fully qualified name, and point -cp at the folder or JAR that holds the package's top folder: out, not out/com/acme.
What triggers it
java -cp target/classes com.acme.App   # the classes are in out, not target/classes
java -cp out App                       # the package is missing from the name
java -cp out/com/acme App              # the entry points inside the package
java.lang.ClassNotFoundException: org.postgresql.Driver#
Means
Code asked for a class by name, as a string, and no classpath entry has it. The name usually comes from configuration: a database driver or a plugin.
Fix
Add the dependency that contains the class, in a dependency scope kept at run time: Maven's default compile, or 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 there when your code compiled is on no classpath entry now. The slashes show 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 compile but are left out at run time.
What triggers it
rm out/com/acme/Greeter.class
java -cp out com.acme.App
java.lang.NoSuchMethodError: 'java.lang.String com.acme.Greeter.greet(java.lang.String)'#
Means
The class was found but the method was not: the class on the classpath is a different version from the one your code was compiled against.
Fix
Find which JAR supplies the class and make it the version 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
Gotcha

Two JARs holding the same class do not conflict. One silently wins. In the diagram, billing-core-1.9.jar is never read. If code compiled against 1.9 calls a method that 2.1 removed, the result is a NoSuchMethodError at run time, far from its cause. This is “JAR hell”, and why Maven's dependency resolution, covered in Maven vs csproj, matters so much.

Running with -cp or -jar#

There are two ways to start a Java program, and the JVM reads different things in each:

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

In practice, the two look like this:

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

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

target/classes:lib/* is a classpath with two entries: the compiled classes, then every JAR in the lib folder. The second command names neither, because the JAR's manifest supplies both.

Gotcha

java -jar ignores -cp, and the CLASSPATH variable too; the JDK documentation says so. java -cp lib -jar app.jar fails with NoClassDefFoundError even when the class is in lib. List the dependency in the JAR's manifest, bundle it inside the JAR as Spring Boot does, or drop -jar: java -cp "app.jar:lib" com.acme.App.

Note

Entries are separated by : on macOS and Linux and ; on Windows. An entry ending in /* means every JAR in that folder; quote it so the shell leaves it alone. With no -cp and no CLASSPATH, the classpath is the current directory.

Note

A Spring Boot JAR is built for -jar. Its manifest names Spring Boot's own launcher as the Main-Class and names your class as Start-Class. The launcher then loads your dependencies from inside the JAR, from BOOT-INF/lib, which is why a Boot JAR runs with no classpath at all.

LegacyThe CLASSPATH environment variable

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

Inside a JAR: the manifest#

A JAR is a zip file; rename one to .zip and you can open it. Next to the class files it carries META-INF/MANIFEST.MF, the manifest: a short text file that java -jar reads to decide what to run.

Manifest-Version: 1.0
Main-Class: com.acme.App
Class-Path: lib/jackson-databind.jar lib/slf4j-api.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 classpathspace-separated, and resolved from the JAR's own folder, not from where you run
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: jar --main-class, the Spring Boot plugin, or the Maven JAR plugin's configuration. Or run it with java -cp app.jar com.acme.App.
What triggers it
jar --create --file app.jar -C out .     # no --main-class
java -jar app.jar
Gotcha

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

Class loading, at the level you need#

Classes are not all loaded at startup. Each one is loaded the first time your code uses it, by a class loader: the part of the JVM that finds a class and reads its bytecode. There are three loaders, stacked so that the JDK's own classes always come first.

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, which asks the bootstrap loader. A loader looks for a class itself only when the loaders above it cannot find it, so the JDK's own classes always win.

That is why putting your own java.lang.String on the classpath achieves nothing: the bootstrap loader finds the real one first. It also explains the two “not found” errors above. ClassNotFoundException is thrown when code asks a loader for a class by name and no loader finds it. NoClassDefFoundError is thrown when a class your compiled code refers to cannot be loaded at run time. A third error means the class was found but could not be set up:

java.lang.ExceptionInInitializerError#
Means
The class was found, but its static initialiser, the code that sets its static fields, threw an exception. It is Java's TypeInitializationException. 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.
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, often one per application or plugin, and that is where the rarer failures live. The depth is signposted in Going deeper. You do not need it to run a Spring Boot service.

Resources live on the classpath too#

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

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

// works everywhere: read it from the classpath
try (var in = getClass().getResourceAsStream("/rates.json")) {
    String cfg = new String(in.readAllBytes(), UTF_8);
}
Reading the code
Files.readString(Path.of("src/main/resources/rates.json"))

Reads a file from disk. That path exists in your project folder, so it works in the IDE, but there is no such folder next to a packaged JAR.

getClass().getResourceAsStream("/rates.json")

Asks the classpath for the resource instead, which works inside a JAR too. The leading slash means “from the root of the classpath”.

try (var in =

try-with-resources, Java's using statement: the stream opened in the brackets is closed when the block ends.

new String(in.readAllBytes(), UTF_8)

Reads every byte and turns them into text. UTF_8 is StandardCharsets.UTF_8, brought in by a static import.

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 it through the classpath with getResourceAsStream, as above.
Cannot invoke "java.io.InputStream.readAllBytes()" because "in" is null#
Means
getResourceAsStream found nothing at that name and returned null, rather than throwing. The null surfaces on the next line.
Fix
Check the name. A leading slash, "/rates.json", is looked up from the root of the classpath; without one, it is looked up beside the calling class.
What triggers it

A typo in the name is enough. If the class was compiled without debug information, the message says "<local1>" instead of the variable's name.

try (var in = getClass().getResourceAsStream("/rates.jsn")) {
    String cfg = new String(in.readAllBytes(), UTF_8);
}

The Files and I/O chapter has the rest.

System properties and environment variables#

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

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, which you pass to the JVM with -D when it starts. Spring Boot reads both, and command-line arguments too:

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

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

Gotcha

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

Where your dependencies actually live#

.NET refresherNuGet's cache: ~/.nuget/packages, nuget.config and project.assets.json

Restore downloads each package version once into a shared cache, reads its feeds from nuget.config, and writes the resolved dependency graph into the obj folder.

dotnet restore
ls ~/.nuget/packages/newtonsoft.json/
# 13.0.3/                     one folder per version, shared by every project
cat nuget.config              # the package feeds, and their credentials
cat obj/project.assets.json   # the resolved graph, written by restore
.NETMavenNote
~/.nuget/packages~/.m2/repositorya shared cache on your machine, holding one copy of each version
nuget.config sources~/.m2/settings.xmlwhere packages are downloaded from: repositories, mirrors and credentials
obj/project.assets.jsonthe resolved dependency treecomputed on every build, not saved; mvn dependency:tree prints it

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

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

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

mvn dependency:build-classpath prints every JAR Maven will put on the classpath, in order: the very list this chapter has been about.

error: error reading ~/.m2/repository/org/slf4j/slf4j-api/2.0.16/slf4j-api-2.0.16.jar; zip END header not found#
Means
A JAR in the local repository is cut short or corrupt, usually by an interrupted download. invalid LOC header and, from java -jar, Invalid or corrupt jarfile mean the same thing.
Fix
Delete that artifact's folder under ~/.m2/repository and build again. mvn clean does not help, because it only empties target/. It is the Java version of clearing a stuck NuGet cache.

JVM flags at a glance#

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

So the familiar pieces sort themselves out: -D is configuration, -X and -XX: tune the JVM itself, and everything after the JAR name belongs to your program. Tuning is covered in The JVM at runtime.

Unrecognized VM option 'MaxRAMPercentag=70'#
Means
The JVM does not know an -XX: option, usually because of a typo, and it refuses to start rather than ignore it. It may suggest the closest option 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
What to remember

javac compiled App and Greeter into class files, in folders that match their package, and java -cp out com.acme.App ran them. The name you pass is the package plus the class, never a file path.

The classpath is a search list. The JVM checks each entry in order and takes the first copy of a class it finds, so an old JAR earlier in the list silently wins.

java -jar reads the main class and any extra JARs from the JAR's manifest, and ignores both -cp and CLASSPATH.

Resources are read through the classpath, not from disk, and Maven keeps every JAR it downloads in ~/.m2/repository.

Quick reference#

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.Appthe 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.jara system property; 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

IntelliJ for Visual Studio users#

Part P0 · Day one · Chapter 04· 6 min read· Quick reference

Most Java developers use IntelliJ IDEA, from JetBrains, the company behind ReSharper and Rider. This chapter maps what you know from Visual Studio onto it. It covers how IntelliJ organises a project, the key to learn first, how to debug, and the tools that check your code.

IntelliJ comes in two editions. The free Community Edition covers core Java, Maven, Gradle, Git and JUnit. Ultimate adds Spring, Jakarta Persistence (JPA) and database tooling, and it is what most Java teams pay for. VS Code with the Red Hat Java and Spring Boot extension packs is a lighter option, and you will meet Eclipse in older organisations.

The project model is different#

You open the team's repository and IntelliJ shows one project with three modules. Visual Studio would have shown a solution with three projects. The names moved, and so did the owner: in Java, the build tool decides how the code is organised, and the IDE reads its files.

Visual StudioIntelliJ IDEANote
Solution (.sln)Projectthe top-level thing you open; it holds every module in the build
Project (.csproj)Moduleeach has its own pom.xml and produces one JAR
Assembly outputJARbuilt by Maven or Gradle; the IDE asks them to build it
Solution ExplorerProject tool windowthe file tree; Cmd+1 on macOS, Alt+1 on Windows and Linux
Package ManagerMaven or Gradle tool windowshows the tree; you add dependencies by editing pom.xml
Build menuBuild, or the Maven panelthe IDE delegates to the build tool

The Java answer to a solution file is a parent pom.xml that lists its modules. IntelliJ opens it and shows each module the way Solution Explorer shows a project:

<!-- billing/pom.xml: the parent, doing the job of Billing.sln -->
<packaging>pom</packaging>
<modules>
    <module>billing-core</module>   <!-- a folder with its own pom.xml -->
    <module>billing-api</module>    <!-- each module builds one JAR -->
</modules>

<packaging>pom</packaging> says this file builds nothing itself; it only lists its modules. Each <module> names a folder with its own pom.xml, which builds one Java archive (JAR).

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 it. That is usually automatic, and the refresh button in the Maven panel does it by hand. A build that works in the IDE but fails on mvn clean package almost always means the IDE's view has drifted. Trust the command line.

With the project open, the next thing to get used to is the keyboard.

The key to learn first#

IntelliJ's shortcuts differ enough to be frustrating for a week. There is a Visual Studio keymap under Settings → Keymap, but most people adapt, and the muscle memory transfers faster than you expect. The full mapping is in the quick reference at the end of this chapter. One key matters more than all the others.

Note

Alt+Enter is the one to learn first. It is Visual Studio's Ctrl+., but broader. It fixes errors, adds imports, adds a missing dependency and generates a missing method. It also offers language upgrades, such as turning an anonymous class into a lambda, or a chain of if statements into a switch expression.

Here is the first of those upgrades. A list of names is sorted by length, first with an anonymous class, then with the lambda that Alt+Enter offers instead:

List<String> names = new ArrayList<>(List.of("Grace", "Ada", "Linus"));

// Before: put the cursor on new Comparator, press Alt+Enter, and choose Replace with lambda.
names.sort(new Comparator<String>() {
    public int compare(String a, String b) { return a.length() - b.length(); }
});

// After the fix:
names.sort((a, b) -> a.length() - b.length());
Reading the code
new Comparator<String>() {

An anonymous class: a class with no name, defined and created in one expression. It implements Comparator, the interface Java uses to compare two values, like IComparer<T> in C#.

public int compare(String a, String b)

The one method a Comparator must have. It returns a negative number when a comes first and a positive number when b does.

(a, b) -> a.length() - b.length()

The same comparison as a lambda. -> is Java's lambda arrow, where C# writes =>; Lambdas and method references covers them properly.

Generating the boilerplate Java expects#

Java has no properties, so getters and setters are real methods that someone has to write. Nobody writes them by hand: Cmd+N generates constructors, getters, setters, equals, hashCode, toString and delegating methods. Here is a customer class in both languages:

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 over a private field. IntelliJ writes them for you, but they are still code you read and maintain. Most of that ceremony disappears when the type can be a record Java 16; see Records.

Debugging#

Debugging works much as it does in Visual Studio, under different names:

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

A conditional breakpoint's condition is a Java expression, evaluated where the breakpoint sits, so it can use any variable in scope:

// Right-click the breakpoint, then type the condition:
order.customer().id() == 1042 && order.lines().size() > 10

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

Gotcha

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

Static analysis#

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

.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 new IllegalArgumentException(...) line creates an exception and then drops it, because the throw is missing. The compiler accepts it; Error Prone stops the build.

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

IntelliJ's project holds modules, as a solution holds projects, and a parent pom.xml lists them. The build tool, not the IDE, is the source of truth.

Alt+Enter is the key to learn first: it turned an anonymous Comparator class into a one-line lambda.

IntelliJ generates getters, setters, equals and hashCode with Cmd+N, and a record removes most of that code.

A conditional breakpoint takes any Java expression, and Error Prone catches bugs such as an exception that is created but never thrown.

Quick reference#

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

Classes, constructors and initialisation#

Part P1 · Language core · Chapter 05· 6 min read· Quick reference

C# and Java classes are nearly identical. This chapter shows the few differences: extends and implements instead of a colon, final instead of sealed, constructor chaining inside the constructor body, and no static classes.

It also shows initialiser blocks, a Java feature C# does not have, which you will meet in older code.

Declaration#

The team's order service extends a base class the team wrote, ServiceBase, and must be closed when the application stops. In C# one colon introduces both the base class and the interface. Here is that declaration in both languages, with three smaller ones below it:

C#
public class OrderService : ServiceBase, IDisposable
{
}

public sealed class Invoice { }
public abstract class Discount { }
public static class MoneyMath { }
Java 25
public class OrderService extends ServiceBase
        implements AutoCloseable {
}

public final class Invoice { }
public abstract class Discount { }
// Java has no static classes; see below.
Reading the code
extends ServiceBase

extends names the one superclass, where C# uses a colon. A class still has only one superclass, as in C#.

implements AutoCloseable

implements lists the interfaces. AutoCloseable is Java's IDisposable: its close() method is what try-with-resources, Java's using, calls at the end.

public final class Invoice

final means no class may extend Invoice, like C#'s sealed.

public abstract class Discount

The same keyword and meaning as in C#: nobody can create a plain Discount, only a subclass of it.

The rest of the declaration rules map like this:

C#JavaNote
: Baseextends Baseone superclass only, same as C#
: IFooimplements Fooa class may implement many interfaces, as in C#
sealed classfinal classcannot be subclassed; Java's sealed means something else
abstract classabstract classsame keyword and meaning: it cannot be instantiated
static classfinal class + private constructorJava has no static classes; this is the idiom
partial classno equivalentone class cannot span files; use composition or generated code
internal classpackage-private (no keyword)visible to the same package only, not the whole JAR
nested classstatic nested classa non-static nested class also holds a hidden reference to its outer object
C# refresherPartial classes: one class split across files

A C# partial class is declared in several files and compiled as one, usually so generated code and hand-written code can live apart.

// Customer.g.cs, generated
public partial class Customer { public string Name { get; set; } = ""; }

// Customer.cs, written by hand
public partial class Customer { public bool IsVip => Name.StartsWith("VIP"); }
static class

Java has no static classes. The usual way to write a class that only holds static helper methods is a final class with a private constructor. That is what java.util.Objects and java.util.Collections do:

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

    public static BigDecimal round(BigDecimal v) {
        return v.setScale(2, RoundingMode.HALF_EVEN);
    }
}

The private constructor stops other code creating a MoneyMath, and the AssertionError stops the class itself from doing so by mistake. An interface with only static methods is sometimes used instead, but it says less clearly what you mean: interfaces are there to be implemented.

struct

Java has no structs either: every type you declare is a class, and a variable holds a reference to an object. When you want a small value that compares by its contents, the closest thing is a record. Here is a discount percentage in both languages:

C#
public readonly record struct Percentage(decimal Value);
Java 25
public record Percentage(BigDecimal value) { }

Both compare by value, and neither can be changed once created. The Java record is still an object, though: it lives on the heap and is passed by reference, where the C# struct is copied. Records covers it in full.

With a class declared, the next difference is in how its constructors call each other.

Constructors and chaining#

An express order is an order that ships faster. It has two constructors: one that takes only the customer, and one that also takes the order lines. The short one should call the full one, and the full one should call Order's constructor. Both languages can do this, but in Java the call is the first statement of the body, not part of the signature:

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

Calls the other constructor of the same class, like C#'s : this(...). List.of() makes an empty list, like the [] on the C# side.

super(customer, lines);

Calls the constructor of the superclass, Order, like C#'s : base(...).

Gotcha

For most of Java's history, this(...) or super(...) had to be the very first statement, so you could not check an argument before passing it on. Java 25 finalised flexible constructor bodies Java 25. They allow statements before the call, as long as those statements do not use the object being built:

public ExpressOrder(Customer customer, List<OrderLine> lines) {
    if (lines.isEmpty()) throw new IllegalArgumentException("no lines");  // legal since Java 25
    super(customer, lines);
}

On Java 21 and earlier, the if line before super(customer, lines) does not compile. The workaround is a private static helper called inside the argument, as in super(customer, requireLines(lines)), which you will see in a lot of existing code.

Initialiser blocks#

Java has two constructs with no C# equivalent. Both run at predictable points, and both appear in real code, particularly older code and static registries. Here a registry maps a file format to the object that handles it: Handler is an interface the team wrote, and JsonHandler is one implementation.

public class Registry {

    private static final Map<String, Handler> HANDLERS;

    static {
        HANDLERS = new HashMap<>();
        HANDLERS.put("json", new JsonHandler());
    }

    private final long created;

    {
        created = System.nanoTime();
    }

    public Registry() { }
}
Reading the code
private static final Map<String, Handler> HANDLERS;

A class-level constant: static because it belongs to the class, and final because it is assigned once, in the block below.

static {

A static initialiser. It runs once, the first time the class is used, like a C# static constructor. A class can have several, and they run from top to bottom.

created = System.nanoTime();

Inside an instance initialiser, the bare { } block. It runs before every constructor body, just after super(). It is rare: a constructor is usually clearer.

Note

Class initialisation is lazy and thread-safe, as it is in C#. The Java Virtual Machine (JVM) guarantees that a class's static initialiser runs exactly once, before any static member is read. That is why a static field set in a static initialiser is a safe way to create one shared object, even when many threads start at once.

Every class extends Object#

As in C#, every class inherits from a root class, Object, and overrides a few of its methods. The method names start with a lower-case letter, and the rules about them are kept by convention rather than checked by the compiler. Here is Money, an amount in a currency, overriding the three you will write most:

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 that marks a method as replacing one it inherits, and the compiler checks that it really does. It plays the part of C#'s override keyword, though Java does not require it.

o instanceof Money m

Tests the type and names the result in one step, like C#'s o is Money m. This is a type pattern.

amount.equals(m.amount)

Compares values. In Java, == on two objects compares references, even for BigDecimal. equals on a BigDecimal also compares the number of decimal places; Equality, hashing and comparison explains why that matters for money.

Objects.hash(amount, currency)

Combines the fields into one hash code, like HashCode.Combine.

money.getClass().getSimpleName()

getClass() returns the object's class, Java's System.Type, and getSimpleName() gives the name without the package.

In practice you rarely write these three by hand: a record generates all of them from its fields. See Records.

Legacyclone() and Cloneable

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

What to remember

OrderService extends ServiceBase and implements AutoCloseable, where C# writes one colon for both, and final is Java's sealed.

ExpressOrder chained its constructors with this(...) and super(...) as the first statement of the body. Since Java 25, a check may come before super.

Java has no static classes: MoneyMath is a final class with a private constructor. Nor does it have structs: a record is the nearest thing.

Money overrode toString, equals and hashCode, each marked with @Override, and a record would generate all three. A static initialiser block runs once, when the class is first used.

Quick reference#

C#JavaNote
: this(...)this(...) as the first statementcalls another constructor of the same class
: base(...)super(...) as the first statementcalls the superclass constructor; checks may come first since Java 25
override@Overridean annotation the compiler checks; optional, but always written
C#JavaNote
ToString()toString()called by string concatenation and by loggers to print the object
Equals(object)equals(Object)value comparison; == compares references instead
GetHashCode()hashCode()must return equal values for objects that equals calls equal
GetType()getClass()returns a Class object, Java's counterpart of System.Type
MemberwiseClone()clone()a shallow copy that skips constructors; use a copy constructor instead
Finalize()finalize()deprecated for removal since Java 18; use try-with-resources
n/await() / notify()every object has a built-in lock; a 1990s condition-variable API
C#JavaRuns when
static Foo() { }static { }first use of the class
field initialiserfield initialiserbefore constructor body
n/ainstance initialiser { }before constructor body, after super()

Access modifiers and packages#

Part P1 · Language core · Chapter 06· 5 min read· Quick reference

Java's access keywords look like C#'s, but two of them behave differently. Java's protected also opens a member to every class in the same package. And writing no keyword at all does not make a member private: it makes it package-private.

Java also has no internal. This chapter shows what each keyword means, how packages differ from namespaces, and how imports work.

The mapping#

A teammate's invoice class has a total field with no access keyword. In C# that field would be private. In Java, any class in the same package can read it and change it:

public class Invoice {
    Money total;           // No keyword: package-private. Any class in this package can change it.
    private Money paid;    // This is what was meant.
}

Money total; has no keyword, so it is package-private: visible to every class in the same package, and nowhere else. C# has no such level. To hide the field, write private, as on the paid line.

Here are Java's four levels, from widest to narrowest, with the nearest C# keyword:

Java modifierVisible toClosest C#
publiceveryonepublic
protectedpackage + subclasses anywhereno exact equal
(none)the same package onlyno equal, package-private
privatethe declaring classprivate

And the other way round, starting from the C# keywords 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 protected

internal opens a member to the whole assembly; the two combined modifiers widen or narrow protected by the assembly boundary.

public class Account
{
    internal decimal Limit;           // any code in this assembly
    protected internal decimal Fee;   // subclasses anywhere, or any code in this assembly
    private protected decimal Rate;   // subclasses, but only those in this assembly
}
Gotcha

protected is not a narrowing of public in Java the way you expect. It also grants package access, so any class placed in the same package, including one written later by someone else, can read a protected member without extending the class. If you want “subclasses only”, Java cannot express it. Keep the member private and offer a protected method whose behaviour you control, or make the class sealed, so that it lists exactly which classes may extend it.

Gotcha

Forgetting a modifier is not the same as writing private. A field with no modifier is visible to the whole package. In C# a field with no modifier is private, so your habit is exactly the wrong way round.

Package-private access only makes sense once you know what a package is, and that is where Java differs most from C#.

Packages are not namespaces#

A C# namespace is a logical grouping: it need not match the folder layout, and one file can declare several. A Java package is tied to folders. Every build tool and IDE expects the package to match the folder the file sits in. The compiled class files must sit in matching folders, because that is how the Java runtime finds them, as How Java runs your code shows.

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 C# file can sit in any folder and declare any namespace. The Java file declares package com.acme.orders; and sits in the folders com/acme/orders/, under src/main/java, which is where Maven looks for source code.

Gotcha

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

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

Imports#

Imports work almost like using directives, with three differences: Java imports types rather than namespaces, it has no aliases, and nothing is imported for the whole project.

C#JavaNote
using System;import java.util.List;Java imports types, not namespaces
using System.*;import java.util.*;pulls in every type in that package
using RX = System.Text.RegularExpressions;no alias importswrite one of two clashing types fully qualified instead
using static Math;import static java.lang.Math.max;imports one member, not the whole type
global usingno equivalenteach file lists its own imports; the IDE adds them for you
implicit usingsjava.lang is always importedjava.lang is imported everywhere: String, Object, 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.*;

Imports every type in java.util, but not the types in the packages below it, such as java.util.regex, because packages do not nest.

import static java.lang.Math.max;

A static import: it brings in one static method, so max(1, 2) needs no class name, like C#'s using static.

java.sql.Date d = null;

Java has no import aliases, so when two types share a name, such as java.util.Date and java.sql.Date, one of them is written with its full package name every time.

Note

Java 25 added module import declarations Java 25, such as import module java.base;, which import every exported package of a module at once. They are handy for scripts, but usually too broad for production code.

There is no internal#

This is a real gap, and it shapes how Java libraries are designed. C# gives you an assembly-wide level, plus InternalsVisibleTo for tests. Java's options are weaker. The strongest, the Java Platform Module System (JPMS), hides whole packages rather than single classes:

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

The last row is the one to remember: Java tests normally live in the same package as the code they test, under src/test/java rather than src/main/java. That gives them package-private access, with no need for anything like InternalsVisibleTo. See the modules chapter for the module system.

C# refresherInternalsVisibleTo: letting a test project see internal members
// in the production project
[assembly: InternalsVisibleTo("Billing.Tests")]

internal static class InvoiceNumbers { ... }   // Billing.Tests may now call it
// src/main/java/com/acme/billing/InvoiceNumbers.java
package com.acme.billing;
class InvoiceNumbers {                       // no modifier: package-private
    static String next() { ... }
}

// src/test/java/com/acme/billing/InvoiceNumbersTest.java
package com.acme.billing;                    // same package, so the test can see it
class InvoiceNumbersTest {
    @Test void startsWithInv() { assertTrue(InvoiceNumbers.next().startsWith("INV")); }
}

InvoiceNumbers has no access keyword, so only code in com.acme.billing can use it. The test declares the same package, even though it lives under src/test/java, so it can call InvoiceNumbers.next() directly.

Package naming#

Package names are a reversed domain name, all lower-case, with no underscores: com.acme.billing.api. The convention is universal and the tools assume it. Unlike in C#, the company prefix is not decoration: it exists so that Java archive (JAR) files from different vendors never collide on the classpath.

// src/main/java/com/acme/billing/api/InvoiceController.java
package com.acme.billing.api;    // reverse domain, lowercase, and it matches the folders

The folders under src/main/java spell out the package: com/acme/billing/api, one folder for each part of the name.

What to remember

The invoice's total field had no keyword, so it was package-private: visible to every class in its package. In C# it would have been private.

Java's protected also opens a member to its whole package, so “subclasses only” cannot be expressed.

A package should match its folders, and packages do not nest: com.acme.orders.internal cannot see the package-private members of com.acme.orders.

Imports bring in types or single static members, with no aliases. There is no internal, so tests share the package of the code they test.

Quick reference#

C#JavaNote
private Money total;private Money total;the same, but in Java you must write private to get it
decimal fee; with no keywordBigDecimal fee; with no keywordprivate in C#, but package-private in Java, so the whole package can change it
[InternalsVisibleTo]tests in the same packagetests under src/test/java declare the package they test
using static System.Math;import static java.lang.Math.max;a Java static import brings in one member at a time
namespace Acme.Orders;package com.acme.orders;lower-case, a reversed domain, and matching the folders

Interfaces, abstract classes and sealing#

Part P1 · Language core · Chapter 07· 7 min read· Quick reference

Java interfaces work much like C#'s: they can have default method bodies, static methods and private helpers, and their names have no I prefix. Java methods can also be overridden unless you forbid it, the opposite of C#.

The big difference is sealed. In Java it means “only these named types may extend me”, which lets a switch cover every case, and C# has nothing like it.

Interfaces#

The order service looks up prices through an interface, so that tests can swap in a fake. In C# you would call it IPriceLookup; in Java it is PriceLookup, with no I. Both versions below also use EmptyLookup, a class that finds nothing, which is left out:

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

No ? on the return type: Java has no nullable reference types, so any reference may be null. Nullability: Optional and JSpecify covers what to do about it.

default boolean has(String productId)

default gives an interface method a body, like a C# default interface method. Every class that implements PriceLookup gets it, and may override it.

static PriceLookup empty()

A static method on the interface, called as PriceLookup.empty().

private void log(String m)

A private helper that only the interface's own methods can call.

Here is when each capability arrived, and what C# has that Java lacks:

FeatureC#Java
Default method bodyC# 8Java 8
Static methodC# 8Java 8
Private methodC# 8Java 9
Constant fieldnoyes, implicitly public static final
Fieldsnono (constants only)
Explicit implementationyesno
Generic variance on the interfaceyes, in/outno, use-site only
C# refresherGeneric variance on the interface: in and out

A C# interface can promise it only produces T (out) or only consumes it (in). The compiler then lets an IEnumerable<string> stand in for an IEnumerable<object>.

interface IProducer<out T> { T Next(); }
interface IConsumer<in T>  { void Take(T item); }

IEnumerable<object> items = new List<string>();   // allowed by IEnumerable<out T>

Action<object> print = o => Console.WriteLine(o);
Action<string> printText = print;                 // allowed by Action<in T>

Java has no variance on the declaration. It says the same thing where the type is used, with a wildcard: a parameter of type List<? extends Number> accepts a List<Integer>. See Variance is at the use site.

Gotcha

Every field in an interface is implicitly public static final. There is no way to declare instance state. Older code sometimes uses a “constant interface” that classes implement only to inherit its constants. That is a known mistake: put constants in a final class or an enum instead.

C# refresherExplicit interface implementation

A member implemented for one interface only: callable through that interface, and not through the class.

interface ILogger { void Log(string m); }

class FileLog : ILogger
{
    void ILogger.Log(string m) { }       // no access modifier
}

new FileLog().Log("x");                  // does not compile
((ILogger)new FileLog()).Log("x");       // compiles
Explicit interface implementation

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

interface Printer { void close(); }
interface Channel { void close(); }

class Device implements Printer, Channel {
    public void close() { }              // one method serves both, and it must be public
}

One close() method serves both interfaces. If two interfaces, say A and B, both give the same method a default body, the class must override it and choose one explicitly:

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

A.super.name() calls interface A's default version of name(), much as base.Name() calls a base class's version in C#.

event

C# has events: a class declares one, and other code subscribes with +=. Java has no event keyword. The usual replacement is a list of listeners, each an object with one method to call. In both versions below, service is an OrderService:

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 takes the place of the event. Consumer<Order> is a built-in functional interface, an interface with one method, accept, which takes an Order and returns nothing. It is Java's Action<Order>.

placedListeners.forEach(l -> l.accept(order));

Calls every listener in turn, as Placed?.Invoke(order) does.

service.onPlaced(order -> System.out.println(order.id()));

Subscribes with a lambda, where C# writes +=. Spring applications often use application events for this instead; see Application events.

So far, interfaces have behaved much as they do in C#. The next feature has no C# counterpart at all.

Sealed types: Java's discriminated unions#

An order can be paid by card, by bank transfer or with a voucher, and in no other way. The code that charges a payment must handle all three. In C# you would write a base type and hope that nobody adds a fourth kind without updating every switch. Java can make the compiler enforce it.

Be careful with the word first. sealed means a different thing in each language:

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

A sealed interface lists its complete set of subtypes. Here are the three payment kinds:

public sealed interface Payment
        permits Card, BankTransfer, Voucher {
}

public record Card(String last4) implements Payment { }
public record BankTransfer(String iban) implements Payment { }
public record Voucher(String code) implements Payment { }
Reading the code
sealed interface Payment

sealed means only the types listed after permits may implement Payment.

permits Card, BankTransfer, Voucher

The complete list. The compiler rejects any other class that tries to implement Payment; permits has a few more rules, listed at the end of this chapter.

public record Card(String last4) implements Payment { }

Each kind is a record, a short class that only holds data. A card keeps the last four digits of its number.

Because the set is closed, a switch over a payment can be complete without a default arm. In the C# version, assume the same three kinds exist as records that derive from an abstract Payment record:

C# 14: no exhaustiveness
// The compiler cannot know the set is closed,
// so it warns unless you add a discard arm.
decimal Fee(Payment p) => p switch
{
    Card c         => 0.30m,
    BankTransfer b => 0m,
    Voucher v      => 0m,
    _ => throw new ArgumentException()
};
Java 21+: exhaustive
// No default needed: add a fourth Payment and this
// stops compiling until you handle it.
BigDecimal fee(Payment p) {
    return switch (p) {
        case Card c         -> new BigDecimal("0.30");
        case BankTransfer b -> BigDecimal.ZERO;
        case Voucher v      -> BigDecimal.ZERO;
    };
}
Reading the code
return switch (p) {

A switch expression, like C#'s p switch { ... }, written with the value in brackets after switch.

case Card c -> new BigDecimal("0.30");

A type pattern: if p is a Card, call it c and use this arm. The arrow -> separates the pattern from the result, where C# writes =>.

stops compiling until you handle it.

There is no default arm, because the compiler knows Payment has exactly three kinds. Add a fourth to permits, and this method stops compiling until you handle it.

Note

This is one of the few places where Java is ahead of C#. Sealed interfaces, from Java 17, and pattern matching in switch, from Java 21, give you a closed set of types that the compiler checks. C# developers usually need a library, such as OneOf, or a visitor pattern to get the same safety. See Sealed types and pattern matching.

Sealed types limit who may extend a type. The last difference runs the other way: an ordinary Java class is more open than you expect.

Abstract classes, and methods that are virtual by default#

Abstract classes are essentially identical to C#'s. The choice between an abstract class and an interface with default methods follows the same reasoning: state and constructors force an abstract class, and otherwise you prefer the interface.

C#Java
abstract class 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: one that subclasses must override, one they may override, and one they may not:

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"; }

No keyword, and yet any subclass may override it: every Java method is virtual unless it is marked final. C# makes you write virtual.

public final String code()

final on a method forbids overriding, like a C# method without virtual.

@Override public BigDecimal amount

The @Override annotation asks the compiler to check that this method really overrides one from the superclass.

Gotcha

Java methods are virtual by default. C# requires virtual to allow overriding; Java requires final to forbid it. So a method you did not intend as an extension point is one, unless you say otherwise. @Override enables nothing, but always write it: it catches a mistyped signature that would otherwise quietly create a new method instead of overriding.

The rules for permits#

A sealed type's permitted subtypes follow four rules:

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, and a switch case for SystemEvent also covers those subclasses.

What to remember

PriceLookup showed that Java interfaces have default bodies, static methods and private helpers, and drop the I prefix. There is no explicit implementation: one public method serves every interface that declares it.

Java has no events: a list of listeners, each a Consumer<Order>, does the job.

Payment is a sealed interface whose permits names its only three kinds. So a switch over it needs no default, and a fourth kind breaks every switch until it is handled.

Java methods are virtual unless marked final, and @Override asks the compiler to check each override.

Quick reference#

C#JavaNote
interface IPriceLookupinterface PriceLookupJava interface names have no I prefix
default interface methoddefault boolean has(...) { ... }an interface method with a body; implementing classes inherit it
event Action<Order> PlacedList<Consumer<Order>> plus onPlaced(...)Java has no events, so keep a list of listeners instead
abstract record Payment plus derived recordssealed interface Payment permits Card, BankTransfer, Vouchera closed set: the compiler knows every kind
virtualno keyword: every method is virtualwrite final to stop a method being overridden
override@Overridean annotation the compiler checks; optional, but always write it

Methods and parameters#

Part P1 · Language core · Chapter 08· 7 min read· Quick reference

Java methods look like C# methods, but with fewer parameter features. There are no optional parameters, no named arguments, no ref or out, no extension methods and no operator overloading.

This chapter shows what Java code does instead, and how lambdas and method references take the place of C# delegates.

Signatures#

The order service needs a method that counts orders matching a filter, with an optional case-sensitive flag and an optional limit. In C# you write one method with default values, and call it with a named argument. Java has neither, so the Java version is an overload ladder: each shorter version calls the next with the default filled in.

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 fills in the missing values and calls the full version, which does the real work.

int n = countOrders("paid", false, 50);

With no named arguments, you pass every value up to the one you want, in order. Here false has to be written just to reach the limit.

Optional and named parameters

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

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

Starts a builder: an object you set up one value at a time before it creates the real thing. HttpRequest belongs to the HTTP client built into the Java Development Kit (JDK).

.timeout(Duration.ofSeconds(10))

Each method sets one named value, which is how a builder stands in for named arguments. Values you do not set keep their defaults.

.build();

Creates the finished HttpRequest from everything set so far.

Gotcha

Parameter names are not in the compiled output by default. Reflection sees arg0, arg1, unless the class was compiled with the -parameters flag. That matters when a framework matches values to parameters by name, as Spring does for some constructor injection and request binding. The Spring Boot plugins for Maven and Gradle switch the flag on for you; a build set up by hand may not.

Default parameter values are one gap. The next is bigger: how arguments are passed at all.

No ref, no out#

A customer types a quantity into a form, and you need to parse it. In C# you write int.TryParse(s, out var value). Java has no out parameters, because Java passes every argument by value. An object reference is passed by value too: the method can change the object it points at, but it cannot make the caller's variable point somewhere else.

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

void Swap(ref int a, ref int b) { ... }
void Print(in BigStruct s) { ... }  // read-only, by reference
Java 25
// Return a value that can be empty, instead of an out parameter.
OptionalInt parsed = parseQuantity(s);
if (parsed.isPresent()) {
    use(parsed.getAsInt());
}

// Return a record when there are several results.
record Pair(int a, int b) { }
Pair swapped = swap(a, b);
Reading the code
OptionalInt parsed = parseQuantity(s);

parseQuantity is a small helper you would write. It returns an empty OptionalInt when the text is not a number, where Java's own Integer.parseInt throws a NumberFormatException. OptionalInt is the int version of Optional: a box that holds a value or nothing.

parsed.getAsInt()

Takes the number out, once isPresent() has confirmed there is one. Use and use stand for whatever you do next.

record Pair(int a, int b) { }

A record, a short class that only holds data, carries several results back, where C# would use ref parameters or a tuple.

Pair swapped = swap(a, b);

swap returns a new Pair with the two values the other way round. The caller's own variables are not changed.

C# refresherTuples: multiple return values

A C# method can return several values at once as a tuple, and the caller can take them apart into separate variables.

(int Min, int Max) Range(int[] xs) => (xs.Min(), xs.Max());

var (lo, hi) = Range([3, 1, 4]);     // lo is 1, hi is 4

High-performance C# also passes Span<T>, a view over memory that avoids copying. Java's nearest tools are ByteBuffer and MemorySegment, which you will rarely need in a web service.

C# refresherSpan and stackalloc: a view over memory, with no copy
Span<byte> buffer = stackalloc byte[256];        // on the stack: no heap allocation
ReadOnlySpan<char> head = text.AsSpan(0, 3);     // a view of text, not a copy

Varargs#

Varargs are the same idea as params, with the same rule that it 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 is Java's params object[] args: the caller passes any number of values, and the method receives an array. The %s in the format string is Java's {0}.

Gotcha

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

No extension methods#

You want an IsBlank() check on every string, where name is a string that may be null. In C# an extension method adds it to string. Java cannot add a method to a type it does not own, so the helper lives in a class of its own:

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

A utility class: final, with a private constructor, holding only static methods, as Classes, constructors and initialisation showed.

s == null || s.isBlank()

String has had its own isBlank() since Java 11, but calling it on null throws, so the helper checks for null first.

Strings.isBlank(name)

The call names the helper's class first. C# reads as if string had the method.

Extension methods

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

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

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

No operator overloading#

C# refresherOperator overloading: defining + for your own type
public readonly record struct Money(decimal Amount)
{
    public static Money operator +(Money a, Money b) => new(a.Amount + b.Amount);
}

var total = price + shipping;     // calls the operator above

Java overloads exactly one operator, + for strings, and does not let you add more. That matters most in money code. With price, quantity, shipping and limit as decimal in C# and BigDecimal in Java:

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

multiply and add replace * and +, and compareTo(limit) > 0 replaces >, because BigDecimal cannot define operators. See Numbers, money and time: this is the most common source of quiet mistakes when a C# developer writes their first Java money code.

Methods cannot be added to other types, but they can be passed around as values, and that is where Java and C# look most alike again.

Lambdas and method references#

The shop applies pricing rules: each rule takes an order and returns a price. In C# a rule is a delegate. Java has no delegates. Instead, a rule is an interface with exactly one method to implement, called a functional interface, or a single abstract method (SAM) type. Any lambda with the right shape can stand in for it. Here are the common delegate types and their Java equivalents:

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

names.ForEach(print);
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

names.forEach(print);
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 can be used wherever a PriceRule is expected.

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

Function<Integer, String> is Java's Func<int, string>. A generic type cannot hold a plain int, so the wrapper type Integer is used. The arrow is -> where C# writes =>.

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

A method reference: Type::method passes an existing method as a lambda, like a C# method group. Consumer<String> is Java's Action<string>.

Supplier<List<Integer>> make = ArrayList::new;

A constructor reference: ArrayList::new is a function that creates a new list. Supplier<T> is Java's Func<T>.

names.forEach(print);

names is a list of strings. forEach calls print once for each element.

Gotcha

Captured variables must be effectively final. A Java lambda may use a local variable only if that variable is never reassigned after it is set. C# captures the variable itself and lets you change it. Here list is any list:

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

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

count++ changes a local variable, which a lambda may not do. AtomicInteger is an object whose value can change, so the lambda only reads the variable that points to it. Awkward, but it removes a whole class of surprises, and it is what makes lambdas safe to hand to another thread.

What to remember

countOrders used an overload ladder in place of optional parameters. With more than two or three options, a builder is the usual answer.

Java passes everything by value, so there is no ref or out: return an OptionalInt or a record instead.

There are no extension methods. Strings.isBlank(name) is a static helper in a utility class.

A delegate becomes a functional interface such as PriceRule, filled by a lambda or a method reference such as System.out::println. A lambda can only use local variables that never change.

Quick reference#

C# patternJava replacement
out parameterreturn Optional, or a record
TryParseOptional-returning method, or catch NumberFormatException
ref parameterreturn the new value and reassign
Multiple return valuesa record, or a small value class
ref struct / SpanByteBuffer, MemorySegment, or an array plus offsets
in parameternothing needed; everything is already by value
C#JavaNote
x => x + 1x -> x + 1the same lambda; Java writes the arrow as -> instead of =>
(x, y) => x + y(x, y) -> x + yidentical apart from the arrow, which is -> in Java
() => Foo()() -> foo()a lambda with no parameters; Java names methods in camelCase
Foo.Bar (method group)Foo::barpasses the method itself as a lambda
new Foo() as factoryFoo::newpasses the constructor as a factory function
Func<int, string>Function<Integer, String>primitives must be boxed in a generic
Action<T>Consumer<T>a function that takes a T and returns nothing
Predicate<T>Predicate<T>same name and meaning: takes a T and returns a boolean

Fields, properties and immutability#

Part P1 · Language core · Chapter 09· 5 min read· Quick reference

Java has no properties. A property is a naming convention: a private field, a getX() method that reads it and a setX() method that changes it. Frameworks find properties by that convention, so the exact names matter.

For data that never changes, a record writes all of this for you. This chapter shows both, and how final and static final map to readonly and const.

The property gap#

The order service keeps customers. In C#, a customer's id is fixed when it is created, its name can change, and a label is worked out from the two. Each is a property. Here is the same class in Java, where each property becomes a field plus methods:

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;

The field that holds the id. final means it is set once, in the constructor, which does the job of C#'s { get; init; }.

public String getName()

A getter: get plus the property name, starting with a capital letter.

public void setName(String v)

A setter: set plus the name. A private field with a getter and a setter is what Java calls a property.

public String getLabel()

A computed value is an ordinary method. The get prefix lets frameworks treat it as a read-only property.

A class like this, with private fields, getters and setters and no framework base class, is often called a plain old Java object (POJO). When it follows the naming rules exactly, it is also called a JavaBean. C# also has required and init, and since C# 14 the field keyword. Java has none of them: a final field set in the constructor does their job.

C# refresherrequired, init-only and the field keyword

required forces the caller to set a property when creating the object, init allows setting it only then, and C# 14's field names the hidden backing field inside an accessor.

public class Customer
{
    public required string Id { get; init; }   // must be set at creation, then fixed

    public string Name
    {
        get;
        set => field = value.Trim();           // field: the compiler's backing field
    }
}

var c = new Customer { Id = "C1", Name = " Ada " };   // leave out Id: compile error
Note

The naming rule is exact, and tools depend on it: getName() for a name property, isActive() for a boolean, and setName(v). Get the prefix wrong and Jackson, the JSON library, will not write the field, and Hibernate, the database mapper, will not store it. There is no error, just a missing value.

Writing all these methods for every class is tedious, and for one common kind of class you no longer have to.

Records make most of this disappear#

Most customer objects in the order service never change once they are created: they are data. A record suits immutable data, such as a data transfer object (DTO), a value object or a query result. It replaces the whole ceremony, including equals, hashCode and toString. Below, customer is an existing Customer:

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)

One line declares the fields, a constructor, the accessors, and equals, hashCode and toString.

public String label()

A record can have methods too. Note the name: label(), not getLabel().

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

Java records have no with expression, so a changed copy is built by hand. The accessors are id() and email(), with no get prefix.

Gotcha

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

final is not readonly, quite#

Java has one keyword, final, where C# has readonly and const:

C#JavaMeaning
readonly fieldfinal fieldassignable once, in constructor or initialiser
conststatic finalcompile-time constant, inlined
static readonlystatic finalassigned in a static initialiser
readonly structno equivalentas of Java 25 there are no user-defined value types; use a record
init-onlyfinal + constructorassign once in the constructor; records do it automatically
Gotcha

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

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

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

names.add changes the list the field points to, which final allows. names = new ArrayList<>() points the field at a new list, which final forbids. For a list that truly cannot change, use List.of(...) Java 9, which returns a list that throws on any change.

static final constants#

Constants look like this in each language:

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

static final covers both const and static readonly. MAX_RETRIES is written in capitals with underscores, the one place Java does not use camelCase. The rule is universal.

Gotcha

A static final primitive or String set to a compile-time constant is copied into the code that uses it, exactly like C#'s const. If you publish a library, change such a constant, and a user of the library does not recompile, they keep the old value. Use a static method, or a value computed at run time, for anything that might change.

A word on Lombok#

LegacyProject Lombok

Lombok is a tool that reads annotations such as @Getter while your code compiles, and writes getters, setters, constructors, equals, hashCode and builders for you. It is an annotation processor. You will meet it in a great many Java codebases, and it removed a lot of pain before records existed.

@Getter @Setter @Builder
public class Customer {
    private String name;
    private String email;
}

@Getter and @Setter generate the accessors for every field, and @Builder generates a builder, all while the class compiles.

Lombok is not deprecated and remains widely used, but it has a cost. It relies on the compiler's internal workings, which has caused breakage on several Java Development Kit (JDK) upgrades. For immutable data, a record is now the better answer and needs no dependency. For mutable entities, the classes Hibernate stores in the database, many teams still use Lombok.

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

Customer showed that a Java property is a private field plus getName and setName methods, found by an exact naming rule.

A final field set in the constructor does the job of init and required, and a computed value is an ordinary method.

For data that never changes, a record writes the fields, constructor, accessors, equals, hashCode and toString, with accessors named name() rather than getName().

final fixes the reference, not the object, and static final is Java's const and static readonly.

Quick reference#

C#JavaNote
{ get; set; }getX() / setX()frameworks find them by this exact naming rule
{ get; }final field + getX()read-only after construction; assign it in the constructor
{ get; init; }final field + constructorset once in the constructor; a record does this for every field
=> expressiona plain methodcomputed each time it is called; there is no backing field
requiredno equivalentmake the field final and demand it in the constructor
field keywordno equivalentdeclare the backing field explicitly; the getter and setter use it

Records#

Part P2 · Modern Java · Chapter 10· 4 min read· Quick reference

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

Java records differ in three ways: they can never be changed, they have no with expression, and their accessors have no get prefix.

The basics#

Every line on an order carries a product id, a quantity and a unit price, and it never changes once it is on the order. That is exactly what a record is for. Here is OrderLine in both languages, where Money is itself a small record of an amount and a currency:

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) { }

The header lists the record's components. From it, Java generates a private final field for each, a constructor that takes them in order, an accessor for each, and equals, hashCode and toString. The braces are required, even when the body is empty.

line.quantity()

The accessor is a method called quantity(), with brackets and no get prefix. C# gives you a property, Quantity.

System.out.println(line);

Prints OrderLine[productId=mug, quantity=2, unitPrice=Money[amount=9.50, currency=EUR]]: square brackets, where C#'s ToString uses braces.

Most of what you know about C# records carries over. These are the differences:

FeatureC# recordJava record
Immutable by defaultinit-only, but can add settersalways, no exceptions
Positional syntaxyesyes
Nominal (body) propertiesyesno: components only
with expressionyesno
Value equalityyesyes
Inheritancerecords can inherit recordsno inheritance at all
Can implement interfacesyesyes
struct variantrecord structno
Custom constructoryesyes, plus compact form
C# refresherC# records: positional, nominal, inheritance, record struct and with

A C# record can be declared by its parameters or by a body of properties, can inherit another record, can be a value type, and compares by value.

public record Point(int X, int Y);                              // positional
public record Customer { public string Name { get; init; } = ""; }  // nominal: a body
public record Shape(string Name);
public record Circle(string Name, double R) : Shape(Name);      // inherits a record
public readonly record struct Money(decimal Amount);            // a value-type record

var p = new Point(1, 2);
Console.WriteLine(p == new Point(1, 2));                        // True: value equality
var q = p with { Y = 5 };                                       // a copy with one change

A record takes whatever values it is given. The next step is to check them.

Checking values: the compact constructor#

An amount of money must never be negative, and it should always have two decimal places, so that 9.5 and 9.50 compare as equal. Java records have a special constructor for this, the compact constructor, which C# does not have. It checks or adjusts the values without restating the parameter list or the assignments:

public record Money(BigDecimal amount, String currency) {

    // A compact constructor: no parameter list, and no assignments to fields.
    public Money {
        if (amount.signum() < 0) throw new IllegalArgumentException("negative amount");
        amount = amount.setScale(2, RoundingMode.HALF_EVEN);
    }

    // Any extra constructor must call the main one first.
    public Money(String amount) {
        this(new BigDecimal(amount), "EUR");
    }
}
Reading the code
public Money {

The compact constructor. It has no parameter list, because its parameters are the record's components. It runs before the fields are set.

amount = amount.setScale(2, RoundingMode.HALF_EVEN);

Assigns to the parameter, not the field. When the body ends, Java copies each parameter into its field, so writing this.amount = amount here is not allowed. setScale rounds to two decimal places, using banker's rounding.

this(new BigDecimal(amount), "EUR");

An extra constructor must call the main one first, with this(...), so every record passes through the checks.

With values checked on the way in, the next question is how to change one. The answer is that you do not: you make a new record.

The missing with expression#

with expressions

As of Java 25, Java has no with expression and nothing that replaces it. Here are the options, using line from the first example:

// 1. Just construct it: fine for small records.
var more = new OrderLine(line.productId(), 3, line.unitPrice());

// 2. A hand-written "wither": common in domain code.
public record OrderLine(String productId, int quantity, Money unitPrice) {
    public OrderLine withQuantity(int q) { return new OrderLine(productId, q, unitPrice); }
}

// 3. A builder, for records with many components.
Reading the code
new OrderLine(line.productId(), 3, line.unitPrice())

Repeats every component and changes one. It is fine for three components, and tedious for eight.

public OrderLine withQuantity(int q)

Writes that once, inside the record, so callers can write line.withQuantity(3). Methods like this are called withers.

Derived record creation, a with-like feature, has been discussed for a future release but is not in Java 25. For a record with more than about four components, a builder, as shown in Methods and parameters, is the practical answer.

Records are not the right tool for every class. The next section says where they fit.

Where records fit#

Records suit data that is created once and then only read. Here is where they fit, and where they do not:

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-arg constructor and mutability
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; use an interface
Gotcha

Records are shallowly immutable. A record holding a List<OrderLine> hands the same changeable list to every caller. Copying it is your job:

public record Order(long id, List<OrderLine> lines) {
    public Order {
        lines = List.copyOf(lines);   // now genuinely immutable
    }
}

List.copyOf makes an unmodifiable copy. A caller who keeps the original list can no longer change the order's lines, and the order's own list throws if anyone tries. C# has the same problem with a record that holds a List<T>, but Java makes the fix one line in the compact constructor.

Records and pattern matching#

Records are what make record patterns work. Because a record's components are part of its type, a switch can take a record apart by position. Using the sealed Payment type from Interfaces, abstract classes and sealing:

String describe(Payment p) {
    return switch (p) {
        case Card(String last4) when last4.equals("0000") -> "a test card";
        case Card(String last4)                           -> "card ending " + last4;
        case BankTransfer(String iban)                    -> "transfer from " + iban;
        case Voucher(String code)                         -> "voucher " + code;
    };
}
Reading the code
case Card(String last4)

A record pattern: if p is a Card, take its component out into last4. Components are matched by position.

when last4.equals("0000")

A guard, like C#'s when: the arm matches only if the condition is also true.

C# can pattern-match on positional records too. But without sealed types it cannot know the set of cases is complete, so it warns unless you add a discard arm.

What to remember

OrderLine showed a whole record in one line: its components became final fields, a constructor, accessors such as quantity(), and equals, hashCode and toString.

A compact constructor checks or adjusts the values: Money rejected negative amounts and rounded to two places before its fields were set.

There is no with expression. Build a new record, or write a wither such as withQuantity.

Records are only shallowly immutable, so copy lists with List.copyOf. Record patterns take records apart inside a switch.

Quick reference#

C#JavaNote
record OrderLine(string ProductId, ...)record OrderLine(String productId, ...) { }the braces are required, even when the body is empty
line.Quantityline.quantity()an accessor method, with brackets and no get prefix
line with { Quantity = 3 }line.withQuantity(3)a wither you write yourself; Java has no with expression
no equivalentpublic Money { ... }a compact constructor, which checks values before they are stored
record structno equivalenta Java record is always a reference type, never a struct

Sealed types and pattern matching#

Part P2 · Modern Java · Chapter 11· 7 min read· Quick reference

Pattern matching tests what kind of object you have and gives you a typed variable for it in one step. Java's patterns work like C#'s. instanceof is Java's is Java 16, and a switch can match types, null and extra conditions Java 21.

Over a sealed type Java 17, the compiler checks that a switch handles every case, which C# 14 cannot do. Record patterns take a record apart as they match it. Java lacks C#'s property, list and relational patterns.

Type patterns#

A customer asks for a refund. Card payments are refunded to the same card, so the refund code must first ask: is this payment a card? If it is, the code needs the card's last four digits. In C# you write is, a type and a variable name. Java writes instanceof in the same place:

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

Tests whether payment is a Card. If it is, the test is true and card is a new variable of type Card, already cast. This is a type pattern, the same idea as C#'s is Card card.

card.last4()

Because card has the type Card, you can call its accessor straight away. last4() has brackets because Card is a record.

The variable exists only where the test is known to have passed. Usually that is inside the if. It can also be the code after the if, when the if leaves the method on failure. Here, only card payments may be refunded at all:

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. You put the whole test in brackets and negate it with !.

refundToCard(card.last4(), amount);

The if throws for anything that is not a card. So on this line the compiler knows that payment is a Card, and card is in scope. C# follows the same rule.

LegacyCast after instanceof

Before Java 16, a type test and a cast were two separate steps. Every Java codebase older than about 2021 is full of this:

if (payment instanceof Card) {
    Card card = (Card) payment;
    refundToCard(card.last4(), amount);
}

The first line only tests the type. The second line declares the variable and casts, naming Card twice more. It still compiles, but there is no reason to write it now.

One test suits one kind of payment. When every kind needs its own handling, a switch reads better than a chain of if statements.

Pattern matching in switch#

The receipt describes how the customer paid, and each kind of payment is described differently. A payment can also be missing, when the order is not paid yet, and test cards, whose last four digits are 0000, get their own wording. In Interfaces, abstract classes and sealing, the fee method switched over a payment's type. This switch also handles null and adds a condition to one arm:

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

Handles a missing payment. Without this arm, a null payment makes the switch throw NullPointerException, as the warning below explains.

case Card c when c.last4().equals("0000")

A type pattern with a when guard: the arm matches only if payment is a Card and the condition is true, as in C#. Strings are compared with equals, because Java's == only checks whether two variables point to the same object.

case Card c -> "card ending " + c.last4();

Arms are tried from top to bottom, so the guarded Card arm must come first. Put it after this one and the compiler reports that it can never match. C# reports the same error.

The two versions differ at the end. C# needs the _ arm, because nothing stops another assembly from adding a fourth kind of payment. Java needs no default, because Payment is sealed and lists exactly three kinds. If someone adds a fourth, every switch over Payment stops compiling until it handles the new kind.

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. A C# switch expression lets a null arm or the _ arm handle null, and throws only if no arm matches. In Java, decide for each switch whether null can arrive. If it can, write case null, or case null, default -> to send it to the fallback arm.

Java's patterns cover types, records and extra conditions. C# has three more kinds of pattern that Java does not.

Patterns Java does not have#

C# can match on an object's properties, on the shape of a list, and on a comparison such as > 10. Java has none of these patterns, so you test the same things in a when guard. Here the shop labels an order by its number of lines:

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

The type pattern Order o matches every order, so the when guard does all the testing. It reads the list with o.lines() and asks it directly.

case Order o -> "normal";

With no guard, this arm matches every order the others let through. That makes the switch complete, so it needs no default.

When every arm is a guard like this, plain if statements work just as well, and many Java developers would write them.

C# refresherC# positional, property, list and relational patterns

C# patterns can take a record apart by position, test properties by name, match the shape of a list, and compare with < or > directly.

var where = point switch
{
    (0, 0)   => "origin",               // positional pattern
    { X: 0 } => "on the y axis",        // property pattern
    _        => "elsewhere"
};

bool framed = numbers is [1, .., 9];    // list pattern: starts with 1, ends with 9

var feel = celsius switch { > 30 => "hot", < 0 => "freezing", _ => "mild" };  // relational

Type patterns and when guards are enough for most code. The rest of this chapter covers record patterns, nested patterns, and one pattern that is still a preview.

Record patterns#

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

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)

Matches a Card and puts its one component into a new variable, last4. var lets the compiler work out the type, here String, and you may write String last4 instead.

case Voucher(var code) -> "voucher " + code;

Components are matched by position, in the order the record declares them, as in C#'s positional pattern. The switch still needs no default, 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 priced in another currency. An order line holds a Money, which is itself a record, so one pattern can reach inside both:

if (line instanceof OrderLine(var productId, _, Money(_, var currency))
        && !currency.equals("EUR")) {
    System.out.println(productId + " is priced in " + currency);
}
Reading the code
OrderLine(var productId, _, Money(_, var currency))

Matches an OrderLine and takes out its productId. It skips the quantity, then takes the unit price apart, skipping the amount and taking out the currency.

_

An unnamed pattern Java 22: it matches any value and gives it no name, like C#'s discard. Use it for a component you do not need.

C# nests positional patterns in the same way: line is OrderLine(var productId, _, Money(_, var currency)).

Gotcha

A nested record pattern never matches null. An order line whose unitPrice is null does not match Money(_, var currency), so the whole pattern fails and the check silently skips that line. A var component is different: it accepts null, so productId can be null. If a null matters, test for it separately.

What is still preview#

One more kind of pattern exists, but only as a preview feature. A preview feature is finished but not yet final, and it is switched off unless you ask for it. Primitive types in patterns, instanceof and switch Java 25 preview lets a pattern test a number's value. For example, instanceof int on a double is true only if the value converts to an int with nothing lost.

Imagine quantities imported from a spreadsheet, where every number arrives as a double:

// Preview in Java 25: compile and run with --enable-preview.
double quantity = 3.0;                 // for example, read from a spreadsheet
if (quantity instanceof int whole) {   // matches: 3.0 converts to an int with no loss
    System.out.println(whole + " items");
}
boolean exact = 2.5 instanceof int;    // false: the conversion would lose the .5
Reading the code
quantity instanceof int whole

True, because 3.0 fits in an int exactly, so whole becomes 3.

2.5 instanceof int

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.

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, and they can change or disappear in the next release. Class files compiled with them refuse to load on any other Java Development Kit (JDK) version.

What to remember

The refund showed a type pattern: payment instanceof Card card tests the type and gives you a typed variable in one step, like C#'s is.

The receipt's describe method switched over a payment with case null, a when guard and no default. Because Payment is sealed, the compiler checks that every kind is handled.

Java has no property, list or relational patterns, so the order-size check used when guards instead.

Record patterns take a record apart as they match, can be nested, and skip components with _.

Quick reference#

C#JavaNote
is T xinstanceof T xthe same type test with a binding, spelled instanceof in Java
is not T x!(o instanceof T x)Java has no is not; put the test in brackets and negate it
switch expression armscase T x ->each arm is written case pattern -> result, not pattern => result
when clausewhen clausethe same keyword for an extra condition, added in Java 21
_ discarddefaultJava uses default, or _ for unnamed variables
case nullcase nullJava 21; before that, a switch on null always threw NullPointerException
positional patternrecord patternadded in Java 21; works on records, taking them apart by component
property pattern { X: 1 }no equivalenttest the property in a when clause instead
list pattern [1, .., 2]no equivalentno Java form; check the size and elements in ordinary code
relational pattern > 5only inside whenwrite case Integer i when i > 5 instead of a bare > 5

Switch expressions and statements#

Part P2 · Modern Java · Chapter 12· 5 min read· Quick reference

Java has two kinds of switch. The newer arrow form Java 14 works like C#'s switch expression, and it is the one to write. The old colon form works like C#'s switch statement, except that one case can run on into the next.

The arrow form never runs on into the next case, can return a value, and must handle every possible value when it does.

The arrow form#

The warehouse packs orders on weekdays, and the order confirmation tells the customer when their order will be packed. That depends on the day they placed it. Here day is a DayOfWeek, an enum that both .NET and Java provide:

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 being tested goes in brackets after switch. C# puts it before the keyword: day switch.

case SATURDAY, SUNDAY -> "packed on Monday";

Each arm starts with case, and an arrow -> separates the labels from the result, where C# writes =>. Two labels share this arm, separated by a comma where C# writes or. Enum constants are usually written without the type name: SATURDAY, not DayOfWeek.SATURDAY.

default -> "packed today";

The fallback arm, like C#'s _.

The whole switch produces a value, so the statement ends with a semicolon after the closing brace, as in C#. Some arms need more than one step to work out their value, and that needs a block.

Multi-statement arms#

In Interfaces, abstract classes and sealing, a card payment cost a flat fee of 0.30. Suppose the fee changes to 1.4 percent of the order total, but never less than 0.30. The card arm now needs two steps. C# switch expression arms cannot hold statements, so C# moves the steps into a method. Java puts them in a block that ends with yield:

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 larger of the two fees. A return here would try to leave the whole method, and the compiler rejects it.

Gotcha

Java's yield only hands a value out of a switch block. It has nothing to do with C#'s yield return, which produces a sequence one value at a time. Java has no yield return at all: see Streams vs LINQ for what replaces it.

C# refresheryield return: C# iterators

A C# method with yield return produces a sequence lazily: each value is handed back when the caller asks for it, and the method pauses until the next request.

IEnumerable<int> Evens(int max)
{
    for (var i = 0; i <= max; i += 2)
        yield return i;       // hand back one value, then pause here
}

foreach (var n in Evens(6)) Console.Write(n);   // 0246

A switch that returns a value must handle every possible value. When the value is an enum or a sealed type, the compiler can check that for you.

Exhaustiveness#

Every order status needs a label on the order page, and a new status must never slip through without one. OrderStatus has four constants, and this switch handles each of them:

enum OrderStatus { NEW, PAID, SHIPPED, CANCELLED }

// No default: all four constants are covered.
// Add a fifth constant and this method stops compiling.
String label(OrderStatus status) {
    return switch (status) {
        case NEW       -> "waiting for payment";
        case PAID      -> "being packed";
        case SHIPPED   -> "on its way";
        case CANCELLED -> "cancelled";
    };
}
Reading the code
enum OrderStatus { NEW, PAID, SHIPPED, CANCELLED }

An enum with four constants. It works like a C# enum; Enums covers the differences.

case CANCELLED -> "cancelled";

The last of the four constants. With all four covered, the switch needs no default.

A sealed type follows the same rule: every permitted type needs an arm, as the payment switches in Sealed types and pattern matching showed.

Note

Leaving out default on purpose is a useful habit. If someone adds a constant and forgets this switch, the build fails, instead of the program misbehaving at run time. C# cannot do this. Its switch expression over an enum always wants a discard arm to avoid a warning, so adding an enum member never breaks the build.

The arrow form is the one to write. Older code uses the colon form, which you still need to be able to read.

The old statement form#

LegacyColon switch with fall-through

The colon form is still legal and still common, and it causes a classic bug: a missing break. Learn to recognise it, but do not write it:

switch (status) {
    case NEW:
        sendPaymentReminder();
        // no break: falls through into PAID. Intended? Nobody can tell.
    case PAID:
        reserveStock();
        break;
    default:
        throw new IllegalStateException("unexpected status " + status);
}
Reading the code
case NEW:

A colon label. The statements after it run until a break.

falls through into PAID

There is no break after sendPaymentReminder(), so a new order also runs reserveStock(). C# reports an error for this, but Java compiles it without a word.

break;

Leaves the switch. Forget it, and execution carries on into the next label.

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

Java has no goto. C# code mostly uses goto to leave nested loops, and Java does that with a labelled break. Here, orders is a List<Order>, and the scan stops at the first order line with a quantity of zero:

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:

A label: a name followed by a colon, placed just before the outer loop.

for (var order : orders)

Java's foreach. It is spelled for, with a colon where C# writes in.

break scan;

Leaves the loop labelled scan, and the inner loop with it. A plain break would leave only the inner loop. continue scan; also works, and moves on to the next order.

What you can switch on#

The arrow form works on more types than the old switch did, but not on every type. Here is what a switch accepts:

TypeJavaNote
int, short, char, byteyesthe original switch types, allowed since Java 1.0
Stringyesallowed since Java 7; labels are compared with equals, not ==
enumyesallowed since enums arrived in Java 5; write the bare constant name
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, where extension holds text such as "png":

String kind = switch (extension) {
    case "jpg", "png" -> "image";       // strings are compared with equals()
    case "pdf"        -> "document";
    default           -> "other";
};

Each label is compared with equals, so "png" matches any string with those three letters. Unlike ==, it does not matter whether the two strings are the same object.

Gotcha

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

What to remember

The packing message showed the arrow form: the value goes in brackets after switch, each arm is case label -> result, and labels that share an arm are separated by commas.

The card fee showed a block arm, which hands back its value with yield, a keyword unrelated to C#'s yield return.

The order-status labels covered every OrderStatus constant with no default, so adding a constant breaks the build instead of the program.

The old colon form falls through when a break is missing, so read it but do not write it. A labelled break leaves nested loops where C# would use goto.

Quick reference#

C#JavaNote
value switch { ... }switch (value) { ... }the value goes in brackets after the keyword
pattern => resultcase pattern -> resultthe word case, and an arrow instead of =>
or between patternscomma between labelscase SATURDAY, SUNDAY -> "packed on Monday";
_defaultthe fallback arm, used when no other arm matches
throw in an armthrow in an armthe same in both languages: an arm can throw instead of giving a value
must be exhaustivemust be exhaustiveover an enum or a sealed type, Java checks it and needs no default
Card => CardFee(total)case Card c -> { ...; yield fee; }C# arms cannot hold statements; a Java block arm ends with yield
goto done;break scan;leaves the labelled loop and every loop inside it

var, strings and text blocks#

Part P2 · Modern Java · Chapter 13· 8 min read· Quick reference

var Java 10 works as it does in C#: the compiler works out a local variable's type from its value. Text blocks Java 15 are Java's version of C#'s raw string literals.

The real loss is string interpolation. Java has none, so you join strings with + or fill in a template with formatted. And == on strings compares objects rather than text, so you compare strings with equals.

var#

The order service often creates a list of order lines and then loops over them. Writing the type on both sides of = is repetitive, and var removes one of them, in both languages:

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>. The type is fixed from then on, exactly as in C#. ArrayList is Java's List<T>.

for (var line : lines)

var works in loops too. Java's for-each loop is spelled for, with a colon where C# writes in.

Java allows var in the same places as C#, with one small difference for lambda parameters:

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

var with an empty diamond gives you a list of Object. The diamond, <>, asks the compiler to work out the type argument from the variable's declared type. With var there is no declared type to work from, so the compiler picks Object:

var mixed = new ArrayList<>();            // ArrayList<Object>: almost never what you meant
var lines = new ArrayList<OrderLine>();   // what you meant

The first line compiles, and then lets you add anything at all to mixed. When you use var, write the type argument. Or declare the type and keep the diamond: List<OrderLine> lines = new ArrayList<>();.

With variables settled, the next step is building text from them, and that is where Java differs most from C#.

No string interpolation#

The order confirmation greets the customer and states the total. In C# you build that sentence with string interpolation. Java has none, so you join the pieces with +, or fill in a template. Here name is a String, orderId a long and total a BigDecimal:

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 "

Plain joining with +. It turns orderId and total into text for you, and the compiler makes it efficient.

.formatted(name, orderId, total)

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

String interpolation

There is no $"..." in Java, and none is planned for Java 25. String templates were previewed in Java 21 and 22 and then withdrawn. They are not in Java 23, 24 or 25, and the design is being reconsidered. Code from tutorials that used them no longer compiles.

Here are the replacements, and when to use each one:

// Joining with +: fine for a few pieces, and the compiler makes it efficient.
String opening = "Hello " + name + ", your order " + orderId;

// formatted or String.format: when you need a fixed number of decimal places.
String totalLine = "Total: %.2f EUR".formatted(total);
String amount = String.format("%,.2f", total);   // 1,234.50 with an English locale

// StringBuilder: in a loop, or when building something large.
var sb = new StringBuilder();
for (var line : lines) sb.append(line.productId()).append(',');

// A text block plus formatted: for text that spans several lines.
String email = """
    Hello %s,

    Your order %d has shipped.
    """.formatted(name, orderId);
Reading the code
String.format("%,.2f", total)

The same template method in its older form, which every Java version has. The comma in %,.2f adds thousands separators.

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

append returns the builder, so calls chain, just as they do on C#'s StringBuilder.

""".formatted(name, orderId);

A text block is an ordinary String, so formatted works on it too. Text blocks are explained later in this chapter.

Most everyday string methods keep their C# names, starting with a lower-case letter. Here are the ones you will reach for first. productId, note and total are single values, and productIds is a list of strings:

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's placeholders are filled in order, where C# numbers them {0} and {1}.

note == null || note.isBlank()

Java has no IsNullOrWhiteSpace. isBlank() checks for empty or whitespace-only text, but calling it on null throws, so the null check comes first.

One more everyday operation behaves differently: checking whether two strings are equal.

Comparing strings#

A customer types a voucher code, and the code upper-cases it before checking it against "SPRING". In C# you compare the two with ==. In Java, == compiles too, but it asks a different question:

C#
string typed = "spring".ToUpper();  // what the customer typed, upper-cased
bool equal = typed == "SPRING";  // True: string's == compares the text
Java 25
import java.util.Objects;

String typed = "spring".toUpperCase();  // what the customer typed, upper-cased
boolean same = typed == "SPRING";  // false: two different objects
boolean equal = typed.equals("SPRING");  // true: the same text
boolean safe = Objects.equals(typed, "SPRING"); // true, and no exception if typed is null
Reading the code
typed == "SPRING"

In Java, == on objects asks whether both sides are the same object. typed was built while the program ran, so it is a different object from the literal "SPRING", even though the text matches.

typed.equals("SPRING")

Compares the text. This is what C#'s == does for strings.

Objects.equals(typed, "SPRING")

Also compares the text, and returns false instead of throwing when typed is null.

C# refresherString == in C#: an operator overload that compares contents
string a = "abc";
string b = new string("abc".ToCharArray());   // a different object, same characters
Console.WriteLine(a == b);                    // True: string overloads == to compare text
Gotcha

== on strings compares references, not contents. It often appears to work, because the Java Virtual Machine (JVM) reuses one object for identical string literals, so "abc" == "abc" is true. A string built while the program runs compares false against the same literal. This is the most common beginner bug in Java, and there is no operator overload to rescue you as there is in C#.

Short strings are now covered. Longer text, such as a JSON document, needs a different kind of literal.

Text blocks#

The shop sends orders to a partner as JSON, and a test needs a sample document. Written as an ordinary string, every quote would need a backslash. Both languages have a multi-line literal for this:

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 followed by a line break open the text block. Java requires the line break, so the text starts on the next line.

""";

The closing quotes. The indentation that every line shares, here four spaces, is removed, so the text starts at the brace. The closing quotes count as a line too: move them further left, and the indentation is kept.

The syntax is the same three quotes, and both languages remove the shared indentation. The differences are in the details:

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

A Java text block still processes escape sequences such as \n. That matters for regular expressions and Windows paths: \d in a text block is an invalid escape and will not compile, so you still write \\d. C#'s raw strings do no escape processing at all, which makes them better for regular expressions. In Java, use a text block for SQL, JSON and HTML, and accept the doubled backslashes in a regular expression.

Regular expressions are where the missing raw string hurts most, as the next section shows.

Regular expressions#

Java's Pattern and Matcher replace .NET's Regex. The pattern syntax is almost the same, but the work is split between two objects, and every backslash is doubled. Here the shop splits a phone number such as 555-0199 into its first three digits and its last four. phone holds one number, and notes holds text that may contain several:

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})")

Compiles the pattern once. Every backslash is written twice, because in a Java string \\ stands for one backslash. Named groups use the same (?<name>...) syntax as .NET.

re.matcher(phone)

A Matcher applies the pattern to one piece of text and remembers how far it got, so you make a new one for each input.

m.find()

Searches for the pattern anywhere in the text, like C#'s Match followed by Success.

m.group("area")

Reads a named group, like Groups["area"].Value.

results().toList()

Every match, like C#'s Matches. results() returns a stream, Java's version of a LINQ sequence, and toList() collects it into a list. Streams vs LINQ covers streams.

Options and the other everyday methods look like this. A customer's note may mention product codes such as SKU-1042, typed in any mix of upper and lower case:

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);  // always compiled

boolean mentionsProduct = sku.matcher(note).find();  // not matches()
String[] pieces = sku.split(note);  // the text between the codes
Reading the code
Pattern.CASE_INSENSITIVE

Options are constants on Pattern, passed as the second argument. There is no option for compiling, because every Pattern is compiled.

sku.matcher(note).find()

Java's IsMatch. Use find(): the similar-looking matches() succeeds only if the whole text matches.

Here is the full mapping from .NET's Regex:

.NETJavaNote
new Regex(p)Pattern.compile(p)compile once, store it in a static final field
re.Match(s)p.matcher(s).find()matcher is stateful and NOT thread-safe
re.IsMatch(s)p.matcher(s).find()matches() requires the WHOLE string to match
re.Matches(s)p.matcher(s).results()added in Java 9; returns a Stream of MatchResult, one per match
m.Groups["name"]m.group("name")named groups use the same (?<name>...) syntax in both
m.Groups[1]m.group(1)group(0) is the whole match in both
re.Replace(s, r)m.replaceAll(r)the replacement refers to a group as $1 in both languages
re.Split(s)p.split(s)or String.split, which compiles each call
RegexOptions.IgnoreCasePattern.CASE_INSENSITIVEpass it as the second argument to Pattern.compile
RegexOptions.Compilednot neededevery Pattern is compiled once, so there is no option to set
@"verbatim"no equivalentevery backslash must be doubled
Gotcha

Two traps, both caused by the missing verbatim string.

Every backslash is doubled. \d in a .NET verbatim string is "\\d" in Java. A text block does not help, because it still processes escapes. This is the one place where C# raw strings are clearly better, and there is no way round it.

matches() is not IsMatch. Matcher.matches() requires the entire text to match, while find() searches for the first occurrence. Reaching for the familiar-looking name is a common bug, and it fails silently.

Note

A Pattern never changes and is safe to share between threads, so compile it once into a private static final field. A Matcher is neither, so create one each time. String.matches, String.split and String.replaceAll compile the pattern again on every call. That is fine now and then, and expensive in a loop.

Concatenation performance#

Note

Do not reach for StringBuilder out of habit. Since Java 9, the compiler turns a + b + c into a single call that works out the final length and builds the string once. StringBuilder is still the right tool inside a loop, where the compiler cannot join the pieces for you. For a fixed number of pieces, plain + is both clearer and faster.

Here are the two cases side by side, using the order's lines:

// A fixed number of pieces: plain + builds the string once.
String summary = "Order " + orderId + " has " + count + " lines";

// A loop: use a StringBuilder, because += would build a whole new string each time round.
var sb = new StringBuilder();
for (var line : lines) sb.append(line.productId()).append('\n');

In the loop, text += line.productId() would copy everything built so far on every pass, so the work grows much faster than the number of lines. sb.append adds to the same builder in place, as it does in C#.

What to remember

var lines = new ArrayList<OrderLine>() took its type from the value, exactly as in C#, but var with an empty diamond gives a list of Object.

Java has no string interpolation, so the order confirmation was built with +, or with formatted and placeholders such as %s.

The voucher code showed that strings are compared with equals, because == asks whether two strings are the same object.

Text blocks are Java's raw string literals, but they still process escapes, so a regular expression still needs doubled backslashes.

Quick reference#

C#JavaNote
$"{a} and {b}"a + " and " + bor "%s and %s".formatted(a, b) when there are many values
$"{x:F2}""%.2f".formatted(x)Java uses printf-style specifiers such as %.2f, %d and %s
string.FormatString.formatpositional %s placeholders instead of {0} and {1}
StringBuilderStringBuilderthe same class and methods, used the same way
string.Join(",", xs)String.join(",", xs)same arguments: a separator, then the items
string.IsNullOrEmpty(s)s == null || s.isEmpty()the null check is yours; isEmpty() alone throws on null
string.IsNullOrWhiteSpace(s)s == null || s.isBlank()isBlank() arrived in Java 11 and ignores whitespace, like the C# method
s.Contains(t)s.contains(t)same name and meaning; both throw if t is null
s == ts.equals(t)== compares references and silently fails on runtime strings

Enums#

Part P2 · Modern Java · Chapter 14· 5 min read· Quick reference

A C# enum is a named number. A Java enum is a class with a fixed set of objects, one for each constant, so a constant can carry fields and methods of its own.

You lose three things: choosing the numbers, [Flags] enums, and casting a number to an enum. Java gives you EnumSet and EnumMap in their place.

The shape of the difference#

An order moves through four statuses: new, paid, shipped and cancelled. In C#, each status is a name for a number, and you can choose the numbers. In Java, each status is an object:

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 warning below
Reading the code
NEW, PAID, SHIPPED, CANCELLED

The constants, written in capitals by convention. There are no numbers to choose: each constant is a single object of type OrderStatus, created when the enum is first used.

OrderStatus.valueOf("PAID")

Looks a constant up by its name, like C#'s Enum.Parse. Java has no cast from a number to an enum.

OrderStatus.PAID.ordinal()

The constant's position in the list, counting from 0. It is not a value you chose, so do not rely on it.

Gotcha

Never persist ordinal(). It is the position in the declaration, so adding a constant quietly renumbers every constant after it. Suppose someone adds ON_HOLD between NEW and PAID: every order stored as 1 turns from paid into on hold. Store name() instead, or better, a code field you control, as the next section shows.

Because each constant is an object, it can carry data. That is where Java enums leave C# behind.

Enums with state and behaviour#

The order page shows a label for each status, and the database stores a one-letter code. In C# you would keep the labels in a separate switch or dictionary, or attach them with a [Description] attribute. A Java enum holds them itself:

public enum OrderStatus {
    NEW("N", "waiting for payment"),
    PAID("P", "being packed"),
    SHIPPED("S", "on its way"),
    CANCELLED("C", "cancelled");

    private final String code;
    private final String label;

    OrderStatus(String code, String label) {   // an enum constructor is always private
        this.code = code;
        this.label = label;
    }

    public String code()  { return code; }
    public String label() { return label; }
}

String shown = OrderStatus.SHIPPED.label();   // "on its way"
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;

An ordinary field. It is a final field, set once in the constructor, so a constant's code never changes.

OrderStatus(String code, String label)

The constructor. It is always private, even without the keyword, so no code outside the enum can create a fifth status.

OrderStatus.SHIPPED.label()

Calls a method on a constant, as on any object.

Storing code() in the database fixes the ordinal() problem: a new constant gets a new code, and the existing codes stay as they are.

Constants with their own behaviour#

A constant can go one step further and supply its own version of a method. The shop offers two delivery options. Standard delivery costs 4.95, or nothing for orders of 50 or more, and express delivery always costs 9.95:

public enum Delivery {
    STANDARD {
        public BigDecimal price(BigDecimal total) {
            return total.compareTo(new BigDecimal("50")) >= 0 ? BigDecimal.ZERO : new BigDecimal("4.95");
        }
    },
    EXPRESS {
        public BigDecimal price(BigDecimal total) {
            return new BigDecimal("9.95");
        }
    };

    public abstract BigDecimal price(BigDecimal total);
}

BigDecimal cost = Delivery.STANDARD.price(new BigDecimal("62.00"));   // 0: free from 50
Reading the code
public abstract BigDecimal price(BigDecimal total);

Declares a method with no body, so every constant must supply one.

STANDARD {

A constant with a body of its own, which supplies 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 the total is at least 50.

C# enums cannot hold methods, so this usually becomes a switch, or a small class for each option. A Java enum keeps the whole closed set of options, and each option's rule, in one place.

Everyday work with enums is parsing them from text, listing them, and using them as keys. That looks different in each language.

Everyday enum operations#

An order status can arrive as text in a request, such as "PAID". Here text holds that string, and counts will count orders by status:

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

Finds the constant by its exact name. There is no option to ignore case, and an unknown name throws IllegalArgumentException.

catch (IllegalArgumentException e)

Java has no TryParse, so you catch the exception that valueOf throws.

OrderStatus.values()

Every constant in declaration order, like Enum.GetValues. It returns a new array on every call, so keep it in a field if you call it often.

new EnumMap<OrderStatus, Integer>(OrderStatus.class)

A map whose keys are enum constants. It keeps its values in an array indexed by position, so it is faster than an ordinary map. OrderStatus.class is the type itself as a value, like C#'s typeof(OrderStatus), and EnumMap needs it.

[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, which stores one bit per constant inside, so it is just as fast, and 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)

A set holding two constants, where C# combines two flags with |. EnumSet.noneOf(Handling.class) is the empty set, C#'s None.

handling.contains(Handling.FRAGILE)

Java's HasFlag.

Enums and switch#

Switch expressions showed a switch over OrderStatus that needs no default, because it covers every constant. Here it is again, to show how the labels are written:

String label = switch (status) {
    case NEW       -> "waiting for payment";
    case PAID      -> "being packed";
    case SHIPPED   -> "on its way";
    case CANCELLED -> "cancelled";
};

The four labels, NEW, PAID, SHIPPED and CANCELLED, cover every constant, so the switch needs no default.

Note

Inside a case label you write the bare constant, NEW, not OrderStatus.NEW. Before Java 21 the qualified form was a compile error. Since Java 21 both compile, and the bare name is still the convention you will see.

The enum singleton#

An enum with one constant has exactly one object. The Java Virtual Machine (JVM) guarantees that, even when objects are saved and loaded again or created through reflection. That makes a one-constant enum the most robust way to write a singleton in Java. Here the shop keeps one shared table of exchange rates:

public enum ExchangeRates {
    INSTANCE;

    private final Map<String, BigDecimal> rates = new ConcurrentHashMap<>();

    public void put(String currency, BigDecimal rate) { rates.put(currency, rate); }
    public BigDecimal get(String currency)             { return rates.get(currency); }
}

ExchangeRates.INSTANCE.put("USD", new BigDecimal("1.08"));
Reading the code
INSTANCE;

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

new ConcurrentHashMap<>()

A map that several threads can use at once, like C#'s ConcurrentDictionary.

In an application that uses Spring you would make this a bean instead: an object that Spring creates once and hands to every class that needs it. The enum idiom is still worth recognising in library code.

What to remember

OrderStatus showed that a Java enum constant is an object, not a number: you look one up with valueOf, and ordinal() is only its position, so never store it.

Because constants are objects, OrderStatus carried a code and a label in final fields set by a private constructor, and each Delivery constant supplied its own price.

Java has no TryParse, no cast from a number and no [Flags], so you catch the exception from valueOf and use EnumSet and EnumMap.

Quick reference#

C#JavaNote
Enum.Parse<T>(s)Status.valueOf(s)throws IllegalArgumentException if unknown
Enum.TryParseno equivalentcatch IllegalArgumentException from valueOf, or keep a Map of names
Enum.GetValues<T>()Status.values()returns a new copy of the array on every call, so cache it if hot
(int) ee.ordinal()the declaration position; never store it, it shifts
(Status) 2no equivalentno cast from int; index into values() if you really must
e.ToString()e.name()name() is always the constant as declared; toString() can be overridden
[Flags]EnumSetno bitwise enums; EnumSet is a bit vector underneath
[Description] attributea field on the enumgive the enum a constructor argument, such as a label
Dictionary keyed by enumEnumMapa map backed by an array indexed by ordinal, so lookups are fast

Generics and type erasure#

Part P2 · Modern Java · Chapter 15· 7 min read· Quick reference

Java generics look like C#'s, with one decisive difference: Java erases type arguments when it compiles. While the program runs, List<String> and List<Integer> are the same class. C#'s runtime, by contrast, knows every type argument.

So Java has no typeof(T), no new T(), no type test against List<String>, and no List<int>. Variance is written where a type is used, not where it is declared.

Declaration#

Everything the shop stores has an id, and a simple in-memory repository can store any type that has one. In both languages the class takes a type parameter, T, with a constraint that says T must have an id:

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#. The constraint goes inside the angle brackets with extends, where 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. The last sections of this chapter explain why.

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.

So far the two languages match. The difference appears when the program runs.

What erasure takes away#

Java checks type arguments when it compiles, and then removes them. This is called type erasure. While the program runs, an InMemoryRepository<Customer> is a plain InMemoryRepository, and inside it T is treated as HasId, its constraint. So anything that needs to know T at run time does not compile. Here x is any object:

Gotcha

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

class Box<T> {
    void tryThings(Object x) {
        T t = new T();                           // no: T is unknown at run time
        T[] a = new T[10];                       // no: nor can an array of T be created
        boolean b = x instanceof List<String>;   // no: a list does not know its element type
        Class<T> c = T.class;                    // no: T has no class literal
    }
    static int count;                            // one count shared by every Box, whatever T is
}
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 just a List, so this test cannot be made. 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 get their own count.

The workaround is to pass the type in yourself. That is why Java APIs are full of Class<T> parameters where C# would use typeof(T). Here mapper is the ObjectMapper of Jackson, the JSON library most Java services use, and json holds one customer:

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 Java's version of C#'s Type, but it carries its 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).

mapper.readValue(json, type)

Jackson needs the class to know what to create. With Jackson 2, which Spring Boot 3 uses, readValue also declares a checked exception, so this method must add throws JsonProcessingException. Jackson 3, which Spring Boot 4 uses, does not.

Note

This is why getBean(OrderService.class) in Spring, readValue(json, Customer.class) in Jackson and mock(OrderRepository.class) in Mockito all take a .class value. Once you see erasure as the reason, the style of the whole ecosystem makes sense.

C# refresherdefault(T) and dynamic

default gives a type parameter's zero value; dynamic defers member lookup to run time.

T FirstOrZero<T>(List<T> xs) => xs.Count > 0 ? xs[0] : default;   // null, 0 or false

dynamic x = "hello";
int n = x.Length;              // resolved at run time, with no compile-time check

Java has neither. A type parameter always stands for a reference type, so the only default it can have is null. For late binding, Java uses reflection, or a Map<String, Object>.

Erasure also explains the last big difference: how Java expresses variance.

Variance is at the use site#

A receipt printer should accept any list of payments, including a list that holds only cards. C# declares once, on the interface, that an IEnumerable of cards may be used as an IEnumerable of payments. Java says it at the point of use, with a wildcard:

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

A list of Payment, or of any type that implements it. Reading gives you a Payment. Adding is not allowed, because the list might really be a List<Card>, and a Voucher does not belong in it.

printAll(cards);

Without the wildcard, printAll(List<Payment>) would reject a List<Card>. A Java List, like a C# List<T>, does not convert on its own, and the wildcard is how you allow it.

The other direction, C#'s in, is ? super. This method adds test cards to any list that can hold cards:

void addTestCards(List<? super Card> target) {
    target.add(new Card("0000"));
}

List<Payment> payments = new ArrayList<>();
addTestCards(payments);   // fine: a List<Payment> can hold cards
Reading the code
List<? super Card> target

A list of Card, or of any type above it, such as Payment or 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 a list type can take:

WildcardMeansYou canMnemonic
List<String>exactly String; 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; you only know each element is an Objectread as Object, cannot addany
Note

Java developers remember this as PECS: Producer Extends, Consumer Super. If a parameter produces values for you to read, use ? extends. If it consumes values you supply, use ? super. It is exactly C#'s out and in, moved to where the type is used.

The rest of this chapter covers what erasure costs for numbers, and the tricks libraries use to get type arguments back.

No primitive type arguments#

A type argument must be a class, so List<int> is not legal Java. You write List<Integer>, and each int is wrapped in an Integer object, which is called boxing:

List<Integer> quantities = new ArrayList<>();
quantities.add(42);                        // boxed: stored as an Integer object

int[] raw = new int[1_000_000];            // one block of ints, no objects
int total = IntStream.of(raw).sum();       // a stream of plain ints: nothing is boxed
Reading the code
quantities.add(42);

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

IntStream.of(raw).sum()

IntStream is a stream built for int, so the numbers stay plain. Streams vs LINQ covers streams.

Gotcha

Boxing has a real cost in memory and speed. A List<Integer> of a million entries holds a million separate objects on the heap, plus an array that points to them. C#'s List<int> is one block of ints.

The workarounds are int[] for storage, and the streams built for numbers, IntStream, LongStream and DoubleStream, for processing. Project Valhalla aims to fix this properly, but it is not part of Java 25.

Getting type information back#

Erasure removes the type argument from an object, but a compiled class still keeps the type arguments written in its field types, its method signatures and its superclass. Libraries such as Jackson and Spring read them back to recover a full generic type. Here json holds a JSON array of customers:

// The { } creates a subclass whose superclass keeps List<Customer>.
List<Customer> customers = mapper.readValue(json, new TypeReference<List<Customer>>() { });

// Spring's version of the same trick, when calling another service.
List<Customer> body = restClient.get().uri("/customers").retrieve()
        .body(new ParameterizedTypeReference<List<Customer>>() { });
Reading the code
new TypeReference<List<Customer>>() { }

Creates an object of an anonymous subclass: a class with no name, declared on the spot by the braces. Its superclass is TypeReference<List<Customer>>, which the compiled class keeps, so Jackson can read it back.

new ParameterizedTypeReference<List<Customer>>() { }

The same idea in Spring's RestClient. Customer.class alone would lose the List around it.

Gotcha

This trick, a subclass whose only job is to keep a type argument, looks strange until you know why it exists. When you see new TypeReference<...>() { } with the trailing braces, that is what is happening. Leave out the braces and it will not compile, because TypeReference is abstract.

Raw types#

LegacyRaw types

Generics arrived in Java 5, in 2004. So that older code kept compiling, you can still write List with no type argument. That is a raw type: everything in it is an Object, type checking is off, and the compiler only warns you.

List raw = new ArrayList();          // legal, but the compiler warns
raw.add("mug");
raw.add(1);
String s = (String) raw.get(1);      // compiles, then throws ClassCastException when it runs
Reading the code
List raw = new ArrayList();

No type argument, so the list accepts anything: a string, and then a number.

(String) raw.get(1)

The cast compiles, because the compiler no longer knows what the list holds. When it runs, the element is an Integer, and the cast throws ClassCastException.

Treat every unchecked warning as an error. If you must silence one, put @SuppressWarnings("unchecked") on the smallest declaration you can, with a comment that says why it is safe.

What to remember

InMemoryRepository<T extends HasId> showed that a Java constraint goes inside the angle brackets with extends, where C# writes where T : IHasId.

Java erases type arguments when it compiles, so new T(), typeof(T) and a type test against List<String> are impossible. APIs take a Class<T>, such as Customer.class, instead.

printAll accepted a List<Card> only because its parameter said List<? extends Payment>: Java writes variance where a type is used.

Type arguments cannot be primitive, so a List<Integer> boxes every number.

Quick reference#

C#JavaNote
where T : Base<T extends Base>classes and interfaces both use extends
where T : IFoo, IBar<T extends Foo & Bar>join bounds with &; a class bound, if any, must come first
where T : classno equivalenteverything is a reference type anyway
where T : structno equivalentJava has no user-defined value types to constrain to
where T : new()no equivalentno new T(); pass a Supplier<T> such as Customer::new instead
where T : unmanagedno equivalentJava has no unmanaged or pointer types
typeof(T)a Class<T> parameterthe caller passes Customer.class, because T is erased
default(T)nulla type parameter is always a reference type, so null is its only default
IEnumerable<out T>List<? extends T>variance is written at each use, with a wildcard
IComparer<in T>Comparator<? super T>the consumer side: ? super where C# declares in
List<int>List<Integer> or int[]each element becomes a heap object
Dictionary<int,V>Map<Integer,V>every int key is boxed into an Integer object
IEnumerable<int>IntStreama stream of plain ints, so nothing is boxed
Nullable<int> / int?Integeran Integer can be null, which plays the part of int?

Annotations and attributes#

Part P2 · Modern Java · Chapter 16· 8 min read· Quick reference

Annotations are Java's attributes: @Deprecated where C# writes [Obsolete]. The syntax differs, but you use them the same way, to attach information that the compiler, a framework or a tool reads.

Two differences matter. Every annotation declares how long it survives, and one you write yourself is invisible to frameworks unless it says otherwise. And Java has a standard way to run code generators while it compiles, so tools such as Lombok and MapStruct write code for you where .NET long used reflection.

Syntax#

The order API has a controller: a class that answers web requests. Its annotations tell the web framework which web address each method handles. Here is the same controller in ASP.NET Core and in Spring, where orders is the controller's OrderRepository:

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

An annotation: an @ followed by a name, where C# puts the name in square brackets. This one tells Spring that the class answers web requests.

@RequestMapping("/orders")

An annotation with an argument, written in brackets like a method call, as in C#.

@RequestParam boolean withLines

Annotations go on parameters too. This one reads withLines from the query string, like [FromQuery].

Arguments can be named. Here a method is marked as out of date, with the version that deprecated it and a warning that it will be removed:

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, as shown later.

/** @deprecated Use {@link #findById(long)} instead. */

A Javadoc comment, Java's version of an XML doc comment. Its @deprecated tag, in lower case, tells readers what to use instead. The annotation, with a capital letter, tells the compiler.

C# refreshernameof: a member's name as a checked string
throw new ArgumentNullException(nameof(order));   // "order", and a rename updates it

Java has no nameof. Names inside annotations and messages are plain strings, so a rename can leave them out of date. IntelliJ's rename updates most of them, and a test is the only real guarantee.

The syntax is the easy part. The next difference is one C# has no word for.

Retention: the concept C# lacks#

The shop wants a log entry every time an order changes. You write your own annotation, @Audited, put it on the methods that change orders, and have code look for it while the program runs. Frameworks such as Spring, Jackson, JUnit and Jakarta Persistence (JPA) find annotations the same way. Whether that code can see an annotation depends on its retention, which says how long it survives:

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

C# attributes are always kept in the compiled assembly and can always be read by reflection. A Java annotation can disappear before the program runs.

Gotcha

If you write your own annotation and forget @Retention(RetentionPolicy.RUNTIME), it silently disappears before your framework can see it. The default is CLASS, not RUNTIME, which is the opposite of what you want almost every time. Here method is a Method object, Java's version of C#'s MethodInfo:

@Retention(RetentionPolicy.RUNTIME)          // the line people forget
public @interface Audited { }

Audited a = method.getAnnotation(Audited.class);   // null without that line
Reading the code
@Retention(RetentionPolicy.RUNTIME)

Keeps the annotation in the compiled class, where reflection can find it while the program runs.

method.getAnnotation(Audited.class)

Asks the method for its @Audited annotation. Without the retention line, the answer is always null.

That is all you need to use annotations. The next sections show how to declare one in full, and how the compiler can act on them.

Declaring one#

Here is @Audited in full. It holds the action being audited and who performs it. In C# an attribute is a class that derives from Attribute. In Java it is declared with @interface:

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)

Like AttributeUsage: 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. Using the annotation sets it: @Audited(action = "cancel").

String actor() default "system";

default gives a member a value to use when it is left out.

Note

A member named value is special: it can be set without its name. @SuppressWarnings("unchecked") is shorthand for @SuppressWarnings(value = "unchecked"), and the @Tag("billing") below works the same way. That is why so many annotations call their main member value.

To use the same annotation twice on one declaration, Java needs a second “container” annotation to hold the repeats, where C# needs one flag. Here a test method gets two tags:

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)

Allows @Tag more than once on a declaration, and names the container that holds the repeats.

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

The container: an annotation whose value is an array of Tag. You never write it on a method yourself. The compiler wraps repeated tags in it.

Ones you will see constantly#

A handful of annotations appear in almost every Java codebase. The first six come with Java itself. The rest come from libraries that later chapters cover, including Spring, whose objects are called beans:

AnnotationMeaning
@Overrideasserts this overrides a supertype method; catches typos
@Deprecatedas [Obsolete]; pair with @deprecated in javadoc
@SuppressWarnings("unchecked")silences a specific compiler warning
@FunctionalInterfaceasserts exactly one abstract method
@SafeVarargsasserts a generic varargs method does not leak the array
@Nullable / @NonNullnullability; see Optional and JSpecify
@Entity, @ColumnJPA: maps a class to a table, and a field to a column
@Test, @ParameterizedTestJUnit: a test method, or a test run once per set of inputs
@Service, @Component, @BeanSpring: a class that is a bean, or a method that makes one
@JsonProperty, @JsonIgnoreJackson: rename a JSON property, or leave a field out of JSON

Here are three of them at work. Pricing works out the price of an order, and Cart keeps a small cache:

@FunctionalInterface
interface Pricing { BigDecimal price(Order order); }   // a second abstract method will not compile

class Cart {
    private final Map<String, Object> cache = new HashMap<>();

    @SuppressWarnings("unchecked")                     // silence one known warning
    List<String> names() { return (List<String>) cache.get("names"); }

    @Override
    public String toString() { return "Cart"; }        // checked: it must override something
}
Reading the code
@FunctionalInterface

Promises that the interface has exactly one abstract method, so that it can be written as a lambda. Add a second one and the compiler reports an error.

@SuppressWarnings("unchecked")

Because of erasure, the cast from 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

Asks the compiler to check that toString really overrides a method from a supertype, here Object.

Gotcha

Always write @Override. It is optional, but without it a small mistake in the signature turns an intended override into a new method, with no error. The mistake might be a capital letter, a boxed parameter or an extra argument. C#'s override keyword is required, so this bug cannot happen there.

Annotations also drive code generation while Java compiles, which is where Java and .NET differ most in practice.

Annotation processing vs source generators#

An annotation processor is a plugin for javac that runs while your code compiles. It reads annotations and can write new source files, which are compiled in the same run. It is a mature pipeline that predates C# source generators by well over a decade, and a lot of the ecosystem depends on it.

C# refresherSource generators: C# code written at compile time

A source generator reads your code during compilation and adds more C# to it. The best-known one writes System.Text.Json's serialisation code, so no reflection runs.

[JsonSerializable(typeof(Order))]
internal partial class AppJson : JsonSerializerContext { }   // the generator fills it in

string json = JsonSerializer.Serialize(order, AppJson.Default.Order);

MapStruct is a processor that writes mapping code. You declare an interface, and MapStruct writes the class that implements it. Here OrderDto is a record holding the fields the API returns:

@Mapper(componentModel = "spring")
public interface OrderMapper {
    OrderDto toDto(Order order);     // MapStruct writes the implementation at compile time
}
Reading the code
@Mapper(componentModel = "spring")

Marks the interface for MapStruct. componentModel = "spring" makes the generated class a Spring bean, so other classes can have it injected.

OrderDto toDto(Order order);

A method with no body. MapStruct writes the body, copying each field of Order to the field of OrderDto with 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 you are most likely to meet. Some of them write what a record now gives you for free:

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

The MapStruct comparison is worth a closer look. AutoMapper works out its mappings by reflection while the program runs, and a missing property fails only then. MapStruct writes a plain Java class when you compile, so mapping problems surface in the build. A field it cannot convert is an error. A field with no match is a warning, or an error if you ask for one. The generated mapper is as fast as code written by hand.

Reading annotations at runtime#

Reading an annotation while the program runs is the same idea in both languages: find the method through reflection, then ask it for the annotation. Here the audit code checks cancel on OrderService:

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)

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)

Like GetCustomAttribute. It returns null when the method has no @Audited, or when the annotation's retention is not RUNTIME.

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

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

The order controller showed that an annotation is an @ and a name, where C# uses square brackets, and that named arguments are written name = value.

Every annotation has a retention, and your own @Audited stayed invisible to reflection until it said @Retention(RetentionPolicy.RUNTIME).

An annotation type is declared with @interface, its members look like methods with no body, and a member called value can be set without its name.

Always write @Override, and let annotation processors such as MapStruct write code for you while Java compiles.

Quick reference#

C#JavaNote
[Attr]@Attran @ prefix instead of square brackets
[Attr(1, Name = "x")]@Attr(value = 1, name = "x")named arguments work the same; a member called value may go unnamed
[Attr] on its own line@Attr on its own linestacked one per line above the declaration, as in C#
AttributeUsage@Targetwhich declarations it may be attached to
n/a@Retentionhow long it survives; only RUNTIME is visible to reflection
[Obsolete]@Deprecatedpair it with a @deprecated javadoc tag that names the replacement
[Conditional]no equivalentno way to strip calls at compile time
Multiple same attribute@Repeatableadded in Java 8; it needs a second, container annotation
GetCustomAttribute<T>()getAnnotation(T.class)returns null when the annotation is missing or not kept at run time
nameof(x)no equivalentwrite the name as a string, and let IntelliJ's rename or a test keep it right

Nullability: Optional and JSpecify#

Part P3 · Nullability · Chapter 17· 6 min read· Quick reference

Java has no string?. The language cannot say that a value may be null, so the compiler never warns you before you use one. It also lacks C#'s !, ?. and ?? operators.

There are two partial answers. Optional<T> Java 8 is for a method's return value that may be missing. JSpecify annotations, checked by a separate tool, cover everything else. Spring Framework 7 and Spring Boot 4 use JSpecify throughout, which is making it the standard.

The gap, stated plainly#

Looking up a customer's nickname can come back empty, because some customers never set one. In C#, the type string? says so, and the compiler makes you check before you use the value. In Java, the type is just String. Here id is the customer's id:

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 the value may be null. You find out from the method's documentation, or when the program fails.

int a = name.length();

Java has no ! operator, because there is no warning to silence. 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")

Java's ??: the first argument if it is not null, otherwise the second. It arrived in Java 9.

C# refresherNullable reference types: string? and the compiler's warnings

With nullable reference types on, a plain string promises non-null, string? may be null, and the compiler warns when you use a maybe-null value without checking it first.

// MyApp.csproj: <Nullable>enable</Nullable>
string? name = Find();
Console.WriteLine(name.Length);       // warning CS8602: possibly null
if (name is not null)
    Console.WriteLine(name.Length);   // fine: the compiler saw the check

For a method that may have nothing to return, Java offers a better tool than null.

Optional is for return values#

Finding a customer by id may find nobody. Instead of returning null, the Java repository method can return an Optional<Customer>: a box that holds one customer or is empty. The caller has to open the box, so it cannot forget the empty case:

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
Optional<Customer> find(long id);

The return type says that the customer may be missing, which a plain Customer return type cannot say.

.map(Customer::name)

If a customer was found, map applies a function to it and keeps the result, here the name. If not, the result stays empty. Customer::name is a method reference, a short way of writing the lambda c -> c.name().

.orElse("unknown")

Opens the box: the name if there is one, otherwise "unknown". It plays the part of C#'s ??.

Here are Optional's other methods, with the nearest C#:

Optional 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; added in Java 9
Gotcha

Optional is not a general-purpose nullable wrapper. Its designers meant it for return values, where finding nothing is a normal outcome. Do not use it for:

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

Gotcha

orElse works out its argument first, even when a value is present. If the default is expensive or has side effects, use orElseGet. Here loadGuestCustomer() reads from the database:

find(id).orElse(loadGuestCustomer());            // always calls loadGuestCustomer()
find(id).orElseGet(() -> loadGuestCustomer());   // calls it only when nobody was found
Reading the code
find(id).orElse(loadGuestCustomer());

Java works out the argument before it calls orElse, so the database is read even when a customer was found.

find(id).orElseGet(() -> loadGuestCustomer());

Passes a lambda instead: code that orElseGet runs only when the Optional is empty.

Optional covers return values. For parameters and fields, Java needs annotations, and a tool that checks them.

JSpecify: the annotation standard#

Java has had a dozen competing @Nullable annotations, from javax.annotation, JetBrains, Spring, the Checker Framework and Android. JSpecify is the industry's effort to agree on one set, and Spring Framework 7 moved its whole API to it. First, add the library to your Maven pom.xml:

<dependency>
  <groupId>org.jspecify</groupId>
  <artifactId>jspecify</artifactId>
  <version>1.0.0</version>          <!-- stable since 2024 -->
</dependency>

This is Maven's version of a PackageReference: the groupId, the artifactId and the version together name the library.

Next, declare that everything in a package is non-null unless it is marked otherwise. That goes in a special file, package-info.java, in the package's folder:

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

import org.jspecify.annotations.NullMarked;
Reading the code
@NullMarked

Makes every type in the package non-null unless it is marked @Nullable. It works like <Nullable>enable</Nullable> in a C# project file.

package com.acme.billing;

The file holds only the package declaration, so the annotation applies to the package as a whole.

Inside the package, you mark only the exceptions. Here is the Invoices class, which keeps invoices by id:

// src/main/java/com/acme/billing/Invoices.java, in the same package
import org.jspecify.annotations.Nullable;

public class Invoices {
    private final Map<String, Invoice> byId = new HashMap<>();

    public Invoice load(String id) {             // id and the result are never null
        return Objects.requireNonNull(byId.get(id), "no invoice " + id);
    }

    public @Nullable Invoice find(String id) {   // the result may be null
        return byId.get(id);
    }
}
Reading the code
public Invoice load(String id)

No annotation, so under @NullMarked both the parameter and the result are non-null. A checker reports any caller that passes null.

public @Nullable Invoice find(String id)

@Nullable marks the exception: this method may return null, so a checker makes callers test the result before they use it.

Note

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

These are the tools that check the annotations:

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

Without one of those tools wired into your build, JSpecify annotations are documentation. They will not fail a build and will not warn at compile time. If your team adopts them, add a checker to the build at the same time, or the annotations will stop telling the truth within months.

Defensive checks#

Public constructors and methods often check their arguments straight away, so that a null fails loudly at the door instead of deep inside. Here an order refuses to be created without a customer:

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

Throws NullPointerException with the message customer if customer is null, and otherwise returns it, so the check and the assignment fit in one statement. The second argument names the parameter, which C#'s ThrowIfNull fills in for you.

Note

Java 14 added helpful NullPointerException messages behind a flag, and they have been on by default since Java 15. The Java Virtual Machine (JVM) tells you which expression was null, for example Cannot invoke "Customer.name()" because "order.customer" is null. That removes most of the pain of finding the null in a chain of calls. Developers who left Java before 2020 often do not know about it.

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

The nickname lookup showed that a Java String can always be null. Java has no !, ?. or ??, so you write the null checks yourself, or call Objects.requireNonNullElse.

Optional<Customer> told callers that find may come back empty, and map and orElse opened it safely.

Use Optional only for return values, and prefer orElseGet when the default is expensive.

For parameters and fields, JSpecify's @NullMarked and @Nullable describe what may be null, but only a checker such as NullAway turns them into build errors.

Quick reference#

C#JavaNote
string? nameString nameno way to say it in the language
string name (non-null)String namethe same declaration, but nothing enforces non-null in Java
name!.Lengthname.length()no ! operator; the call simply throws if name is null
name?.Lengthname == null ? null : name.length()no ?. operator; chain with Optional.map instead
a ?? ba != null ? a : bor Objects.requireNonNullElse(a, b)
a ??= bif (a == null) a = b;no ??= operator; write the if statement out
Nullable reference types warningsJSpecify + NullAway or IntelliJopt in with annotations; only a checker tool turns them into errors
ArgumentNullException.ThrowIfNull(x)Objects.requireNonNull(x)throws NullPointerException, and returns x so it can be assigned
customer?.Name ?? "unknown"find(id).map(Customer::name).orElse("unknown")return Optional from a method that may find nothing

Collections#

Part P4 · Collections and streams · Chapter 18· 6 min read· Quick reference

Java has the same data structures as .NET under different names: List<T> is ArrayList, and Dictionary is HashMap.

Two habits differ. IEnumerable<T> maps to Iterable<E>, not Iterator. And Java code declares the interface and creates the class: you declare a List and create an ArrayList, almost always.

The interface hierarchy#

Every Java collection type fits into one family tree, and almost every one has a .NET counterpart. The picture shows the interfaces at the top and the classes you create at the bottom:

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

IEnumerable<T> is Iterable<E>, not Iterator<E>. The match is exact. An Iterable can be looped over again and again, and it is what for (var x : xs) accepts. An Iterator is a cursor that makes one pass, like IEnumerator<T>. Mix them up, and you write methods whose results can only be read once.

Here are an order's product ids, read through each of the two types:

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());
Reading the code
Iterable<String> ids

Anything you can loop over with for, like IEnumerable<T>. You can loop over it as many times as you like.

ids.iterator()

Hands out an Iterator: a cursor that goes through the elements once, like IEnumerator<T>.

while (it.hasNext()) System.out.println(it.next());

hasNext() asks whether another element is left, and next() moves on and returns it. C# splits the same job into MoveNext() and Current.

With the family tree in mind, here is how the everyday types are used.

Everyday collections#

The everyday calls translate method by method. Here are a queue of order numbers waiting to be packed, an undo stack, a price list sorted by product, and a cache of stock levels:

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

Java has no queue class to create. ArrayDeque, a double-ended queue, does the job of both Queue<T> and Stack<T>.

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

offer adds to the back and poll takes from the front, like Enqueue and Dequeue. On an empty queue, poll returns null where Dequeue throws.

stack.push("add mug"); var undo = stack.pop();

push and pop work at the front, so the same class also serves as a stack.

prices.firstEntry()

A TreeMap keeps its keys sorted, like SortedDictionary, so its first entry has the smallest key: cup. Map.Entry is Java's KeyValuePair.

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

Java's GetOrAdd. It runs the lambda only when the key is missing, and stores the result.

C# refresherOrderedDictionary (.NET 9): the insertion-ordered dictionary

A generic dictionary, new in .NET 9, that keeps keys in the order they were added and can also be read by position.

var od = new OrderedDictionary<string, int> { ["b"] = 2, ["a"] = 1 };
foreach (var (key, value) in od) Console.Write(key);   // "ba": insertion order
int second = od.GetAt(1).Value;                         // 1: read by position

The quick reference at the end of this chapter lists every .NET collection type with its Java counterpart. Next comes creating collections, where Java has a trap.

Creating collections#

C# has collection expressions and initialisers. Java has factory methods such as List.of, and they return collections that cannot be changed. Here are an order's product ids, its quantities and its tags:

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, and new ArrayList<>(...) copies it into one you can add to. This is Java's closest match to new List<string> { ... }.

var b = List.of("mug", "cup");

A fixed list: adding, removing or replacing an element throws UnsupportedOperationException.

Map.of("mug", 2)

Keys and values in turn, here a fixed map with one entry.

Gotcha

List.of(...) and Map.of(...) Java 9 return immutable collections. They throw UnsupportedOperationException on any change, and they reject null elements. They are not a replacement for new ArrayList<>(). If you need to add to one later, copy it: new ArrayList<>(List.of(...)).

Map.of also takes at most 10 key-value pairs. For more, use Map.ofEntries(Map.entry(k, v), ...).

The last everyday trap is changing a collection while a loop is reading it.

Modifying while iterating#

Cleaning up an order's tags means removing the blank ones. Removing from a list while a for loop walks over it fails in both languages. C# offers RemoveAll on a List<T>. Java has the same idea, removeIf, and its Iterator can also remove:

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)

Changes the list while the loop's hidden iterator is walking it. The loop usually fails on its next step with ConcurrentModificationException, Java's version of C#'s InvalidOperationException for the same mistake.

tags.removeIf(String::isBlank);

Removes every element for which the test is true, in one call. String::isBlank is a method reference, short for tag -> tag.isBlank().

it.remove();

Removes the element that next() just returned. That is safe, because the iterator itself makes the change.

The remaining sections cover a naming habit, the Java 21 methods for first and last elements, and the old collections you will meet in legacy code.

Declare the interface, instantiate the class#

Note

Java convention is stricter than C#'s here, and it is worth adopting straight away. Declare variables, fields, parameters and return types with the interface, and name the class only where you create the object:

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

ArrayList<String> overSpecified = new ArrayList<>();    // avoid

Declaring List lets you switch to another implementation later by changing one line. The diamond, <>, takes the type argument from the declared type, so nothing is repeated.

Sequenced collections#

Java 21 added sequenced collections Java 21: methods for the first and last elements of any collection with a defined order. Before them, each type had its own way, or none. Here are an order's product ids and a stock list that remembers the order of insertion:

var ids = new ArrayList<>(List.of("mug", "cup", "pot"));

ids.getFirst();        // "mug", was ids.get(0)
ids.getLast();         // "pot", was ids.get(ids.size() - 1)
ids.addFirst("tray");
ids.reversed();        // a reversed view, not a copy

var stock = new LinkedHashMap<String, Integer>();
stock.put("mug", 12);
stock.put("cup", 3);
stock.firstEntry();    // mug=12, the first one added
Reading the code
ids.getFirst();

The first element, from any ordered collection, like C#'s ids[0].

ids.reversed();

A reversed view: it shows the same list back to front, and a change to either one shows in the other. C#'s List<T>.Reverse() turns the list itself around.

stock.firstEntry();

A LinkedHashMap remembers the order in which keys were added, so its first entry is the one added first.

Gotcha

subList returns a view, not a copy. Changing the sublist changes the list behind it. Adding to or removing from the list behind it makes the sublist throw ConcurrentModificationException the next time you use it. C#'s range operator copies. If you want a copy, make one: new ArrayList<>(ids.subList(1, 3)).

Legacy collections#

LegacyVector, Hashtable, Stack, Enumeration

These collections date from Java 1.0. They were later fitted to the modern interfaces, but every method takes a lock. So you pay for locking you almost never need, and they are still not safe for operations that take several calls. You will meet them in old code:

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, and a loop over it visits the bottom of the stack first.

What to remember

The product ids showed that Iterable is IEnumerable, which you can loop over many times, and Iterator is the one-pass cursor, like IEnumerator.

ArrayDeque served as both the packing queue and the undo stack, TreeMap kept the prices sorted, and computeIfAbsent played the part of GetOrAdd.

List.of and Map.of make collections that cannot change, so copy them into new ArrayList<>(...) when you need to add.

Declare the interface and create the class, and remove while looping with removeIf or the iterator's remove.

Quick reference#

.NETJavaNote
IEnumerable<T>Iterable<E>can be iterated more than once
IEnumerator<T>Iterator<E>a one-pass cursor, like IEnumerator, that can also remove()
ICollection<T>Collection<E>adds size, add, remove and contains on top of iteration
IList<T>List<E>an ordered collection you can index by position with get(i)
List<T>ArrayList<E>the everyday list, backed by an array, like List<T>
LinkedList<T>LinkedList<E>almost always slower than ArrayList; avoid
T[]E[]fixed size, and covariant in both languages, which is unsafe
ISet<T>Set<E>the set interface; declare this and pick an implementation
HashSet<T>HashSet<E>no guaranteed iteration order; use LinkedHashSet to keep insertion order
SortedSet<T>TreeSet<E>kept sorted by natural order, or by a Comparator you pass
n/aLinkedHashSet<E>a HashSet that remembers insertion order; .NET has no equivalent
IDictionary<K,V>Map<K,V>a Map is not a Collection; iterate entrySet(), keySet() or values()
Dictionary<K,V>HashMap<K,V>no guaranteed iteration order; use LinkedHashMap to keep insertion order
SortedDictionary<K,V>TreeMap<K,V>kept sorted by key, like SortedDictionary; firstKey() is the smallest
OrderedDictionary<K,V>LinkedHashMap<K,V>insertion-ordered; .NET 9+; LinkedHashMap is also LRU-capable
Queue<T>ArrayDeque<E>use offer() and poll(); ArrayDeque is the fast general-purpose queue
Stack<T>ArrayDeque<E>use push() and pop(); avoid the legacy java.util.Stack class
ConcurrentDictionary<K,V>ConcurrentHashMap<K,V>a thread-safe map; computeIfAbsent plays the part of GetOrAdd
BlockingCollection<T>BlockingQueue<E>a producer-consumer queue; put() and take() wait when needed
ImmutableList<T>List.of(...)an unmodifiable list, added in Java 9; it rejects nulls
ReadOnlyCollection<T>Collections.unmodifiableLista read-only window; changes to the source show through
KeyValuePair<K,V>Map.Entry<K,V>one key and its value, as returned by entrySet()
Comparer<T>Comparator<E>compares two objects; build one with Comparator.comparing(...)
IComparable<T>Comparable<E>the natural order of a type, through its compareTo() method
list[0]list.getFirst()Java 21 and later; before that, write list.get(0)
list[^1]list.getLast()Java 21 and later; before that, list.get(list.size() - 1)
list.Reverse()list.reversed()a reversed view; Collections.reverse turns the list itself around
list[1..3]list.subList(1, 3)a view of the same list, not a copy as in C#

Equality, hashing and comparison#

Part P4 · Collections and streams · Chapter 19· 6 min read· Quick reference

In Java, == on two objects asks whether they are the same object. It never compares values, and no operator overloading can change that. So == on String, Integer, BigDecimal or LocalDate is a bug waiting for the right input.

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

== versus equals#

An incoming order names its product by id, and the shop checks that id against the catalogue. Here received was read from the request, so it is a different string object from the literal "mug", even when the text matches:

C#
// == is overloaded for string, and
// record/struct equality is value-based
var wanted = "mug";
var received = ReadProductId();
if (wanted == received) { }   // compares the text
bool same = ReferenceEquals(wanted, received);
Java 25
String wanted = "mug";
String received = readProductId();
if (wanted == received) { }        // a bug
if (wanted.equals(received)) { }   // compares the text
if (Objects.equals(wanted, received)) { }
Reading the code
if (wanted == received) { }

In Java, == on two objects asks whether they are the same object. received was built while the program ran, so this is false even when both say mug. Comparing strings showed the same trap with a voucher code.

wanted.equals(received)

Compares the text, which is what C#'s == does for strings. It throws if wanted is null.

Objects.equals(wanted, received)

Also compares the text, and is safe when either side is null: two nulls count as equal.

In short, Java turns C#'s habits around: == means identity, and equals means value.

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>

Order quantities are often stored as Integer, the object form of int, for example in a List<Integer>. Comparing two of them with == works in tests with small numbers and fails in production with larger ones:

Gotcha

The boxed-integer cache. The Java Virtual Machine (JVM) keeps one shared Integer object for each value from −128 to 127. So == appears to work on small numbers, and silently stops working above 127. This is Java's most notorious trap:

Integer a = 127, b = 127;
boolean small = a == b;       // true: the same cached object

Integer c = 128, d = 128;
boolean large = c == d;       // false: two different objects
boolean same = c.equals(d);   // true: always compare with equals
Reading the code
Integer a = 127, b = 127;

Assigning 127 to an Integer wraps the number in an object. For values from −128 to 127, Java hands out the same cached object every time.

boolean large = c == d;

128 is outside the cache, so each assignment creates a new object, and == compares two different objects.

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

Comparing with equals is half of the story. Hash-based collections also need hashCode, and the two must agree.

The equals and hashCode contract#

HashMap and HashSet, Java's Dictionary and HashSet, find an object in two steps. First hashCode picks a bucket, then equals checks the objects in it. So the two methods must agree, and nothing enforces it. If you override one, override the other. Here are the rules they must follow:

RuleMeaning
Reflexivean object always equals itself: a.equals(a) is true
Symmetrica.equals(b) implies b.equals(a)
Transitiveif a equals b and b equals c, then a equals c
Consistentrepeated calls give the same result
Nulla.equals(null) is false, never throws
hashCode agreementa.equals(b) implies a.hashCode() == b.hashCode()
Gotcha

The last rule is the one that breaks systems. Suppose the shop keys its stock levels by a small ProductKey class that overrides equals but not hashCode:

class ProductKey {
    final String id;
    ProductKey(String id) { this.id = id; }
    @Override public boolean equals(Object o) { return o instanceof ProductKey k && id.equals(k.id); }
    // no hashCode(): equal keys still hash differently
}

var stock = new HashMap<ProductKey, Integer>();
stock.put(new ProductKey("mug"), 12);
stock.get(new ProductKey("mug"));    // null: the lookup searched the wrong bucket
Reading the code
@Override public boolean equals(Object o)

Two keys with the same id are equal. o instanceof ProductKey k checks the type and names it k in one step.

// no hashCode(): equal keys still hash differently

Without an override, hashCode comes from Object and is different for every object, so the two equal keys land in different buckets.

stock.get(new ProductKey("mug"))

Looks in the bucket for the new key's hash, finds nothing there, and returns null, even though an equal key is in the map.

Two objects that are equal but hash differently can both be stored in a HashSet, and a lookup with an equal key returns null. The failure is silent, and it depends on the data.

The rest of this chapter shows how to write both methods correctly, and how to sort.

Writing them#

Money is a classic value type: two amounts of money are equal when the amount and the currency match. Here is equality written by hand in both languages:

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 (this == o) return true;

A quick exit: an object always equals itself.

if (!(o instanceof Money m)) return false;

equals takes an Object, so first check that o is a Money, and call it m. This also returns false for null.

amount.compareTo(m.amount) == 0

Compares the amounts by value. equals on a BigDecimal would also compare the number of decimal places, as the warning below explains.

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

Java's HashCode.Combine. stripTrailingZeros() makes 1.0 and 1.00 hash the same, because equals treats them as equal, and equal objects must have equal hashes.

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 31 * result + field.hashCode() form is faster, but measure first.

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

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

Two Money records are equal when both components are equal by equals, and their hashes then match automatically.

Gotcha

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

Ordering#

Sorting works the same way in both languages: you supply the comparison. C# usually passes a lambda or uses LINQ's OrderBy, and Java builds a Comparator. Here orders is a List<Order>, and each order has an id, a status and the time it was placed:

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)

Builds a comparison from a key: compare orders by their id. comparingLong avoids boxing each long, as comparingInt does for an int.

orders.stream()

A stream, Java's version of a LINQ query. Streams vs LINQ, the next chapter, covers streams.

Comparator.comparing(Order::status)

Compares by status, in the order the enum declares its constants.

.thenComparing(Order::placedAt, reverseOrder())

Breaks ties by the time the order was placed, newest first, like ThenByDescending.

The rest of LINQ's ordering maps like this:

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's own natural order is Comparable, as it is IComparable in C#. Versions of the shop's API are ordered by major number, then minor number:

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>

Gives Version a natural order, which Collections.sort, TreeSet and TreeMap use without being handed a comparator.

private static final Comparator<Version> ORDER =

The comparison is built once and kept in a constant, rather than rebuilt on every call.

Gotcha

Comparable.compareTo should be consistent with equals: when a.compareTo(b) == 0, a.equals(b) should be true. When it is not, TreeSet and TreeMap behave differently from HashSet and HashMap, because the sorted collections use compareTo and the hashed ones use equals. BigDecimal is the standard example: 1.0 and 1.00 are equal by compareTo but not by equals, so a TreeSet keeps one of them and a HashSet keeps both.

What to remember

The product id showed that == on objects compares identity. Strings, Integers, BigDecimals and dates are compared with equals, or with Objects.equals when either may be null.

Integer quantities above 127 fail with ==, because only small values share a cached object.

Override equals and hashCode together, using the same fields, or let a record write both: ProductKey without hashCode lost its entry.

Money compared amounts with compareTo, because BigDecimal.equals also compares the number of decimal places.

Comparator.comparing and thenComparing replace OrderBy and ThenBy, and Comparable gives a type its natural order.

Quick reference#

C#JavaNote
a == b on stringsa.equals(b)Java's == compares objects, so strings need equals
a == b, either may be nullObjects.equals(a, b)two nulls count as equal, and nothing throws
ReferenceEquals(a, b)a == bthe only thing == means for objects in Java
GetHashCode()hashCode()override it whenever you override equals
HashCode.Combine(x, y)Objects.hash(x, y)hash the same fields that equals compares
IEquatable<T>equals(Object)there is no typed version; check the type with instanceof
IComparable<T>Comparable<T>the natural order, through compareTo
OrderBy(o => o.Status)Comparator.comparing(Order::status)ThenBy becomes thenComparing, and OrderByDescending adds reversed()

Streams vs LINQ#

Part P4 · Collections and streams · Chapter 20· 7 min read· Quick reference

Streams are Java's LINQ to Objects: the same idea, with different names. One difference matters every day: a stream can be used only once. A LINQ query runs again each time you enumerate it, but a used-up stream throws IllegalStateException.

Java has no LINQ to SQL, because it has no expression trees. A Java lambda compiles to ordinary code, which no library can read and turn into SQL. So Java database code uses query strings, a criteria API, or a library such as jOOQ.

The shape#

The shop wants the names of all customers whose name ends in "Doe", in alphabetical order. In C# that is a LINQ query over customers, a List<Customer>. In Java it is a stream:

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 ask it for one first. C# lets you call Where on the list itself.

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

Where becomes filter. The lambda uses an arrow, ->, where C# writes =>.

.map(Customer::name)

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

.sorted()

OrderBy with the natural order. Pass a Comparator to sort by anything else.

.toList();

Runs the pipeline and collects the result. Nothing runs until this last step, just as a LINQ query does nothing until you enumerate it.

That is the whole pattern: start a stream, chain operations, and finish with one step that produces a result. Most LINQ operators simply have a different name.

Operator translation#

Here are several operators at once. Each order has a list of lines, and each line has a product id. The query finds product ids from orders with more than two lines, and then asks three single questions of the orders:

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. Its lambda must return a stream, so each order's list of lines is turned into one.

.skip(10).limit(5)

Skip and Take become skip and limit.

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

Any with a test becomes anyMatch.

findFirst().orElse(null)

findFirst returns an Optional, so orElse(null) gives FirstOrDefault's behaviour.

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

sum lives on IntStream, a stream of plain ints, so you map to int first.

The quick reference at the end of this chapter lists every LINQ operator with its stream counterpart. If some of the rarer LINQ operators are hazy, here they are:

C# refresherLINQ operators: TakeWhile, SkipWhile, Concat, All, Single, Aggregate, Zip, Chunk and more

The rest of the table's LINQ column, one line each, with what it returns.

IEnumerable<int> xs = [1, 2, 3, 12], ys = [4, 5];
xs.TakeWhile(x => x < 10);           // 1, 2, 3: stops at the first failure
xs.SkipWhile(x => x < 10);           // 12: drops items while the test holds
xs.Concat(ys);                       // xs followed by ys
xs.Reverse();                        // last to first
xs.All(x => x > 0);                  // true if every item matches
xs.Single(x => x == 12);             // the only match; throws on none or several
xs.Average(); xs.Min(); xs.Max();    // aggregates
xs.Aggregate(1, (acc, x) => acc * x);    // a fold with a starting value: 72
xs.Zip(ys);                          // (1, 4), (2, 5): pairs, up to the shorter
xs.Chunk(2);                         // [1, 2], [3, 12]
xs.DefaultIfEmpty(0);                // xs, or a single 0 if xs is empty
xs.AsParallel().Select(Score);       // PLINQ: the query runs on several threads

The names are the easy part. The next difference changes how you write code.

Single use#

Gotcha

A stream is a pipeline over a source, and it can be used exactly once. This is the difference that bites when you translate LINQ. Here the same filtered customers are read twice:

var does = customers.stream().filter(c -> c.name().endsWith("Doe"));

var a = does.toList();
var b = does.toList();   // IllegalStateException: stream has already been operated upon
Reading the code
var a = does.toList();

The first finishing step runs the pipeline and uses the stream up.

var b = does.toList();

The second one throws IllegalStateException, with the message "stream has already been operated upon or closed".

In C#, the same IEnumerable would simply run the query again. In Java, either create the stream again, or collect it once into a List and reuse the list.

Finishing a stream with toList is the simplest case. Everything else goes through collect.

Collectors#

collect is the general way to finish a stream, and the Collectors class is where LINQ's conveniences live. Here the shop counts its orders by status, and joins customer names into one line of text:

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

Imports the static methods of Collectors, so groupingBy and counting need no class name in front.

groupingBy(Order::status, counting())

Groups the orders by status, and counts each group instead of listing it. The result is a Map<OrderStatus, Long>.

joining(", ")

Joins strings with a separator, like string.Join.

These are the collectors you will use most:

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

Collectors.toMap throws IllegalStateException on a duplicate key, as LINQ's ToDictionary throws on one. But toMap also throws NullPointerException when a value is null, which ToDictionary accepts. Whenever duplicates are possible, use the three-argument form, whose third argument says how to merge two values.

Parallel streams are the last everyday topic. The deeper sections after them cover newer and rarer tools.

Parallel streams#

Gotcha

parallelStream() looks like AsParallel(), and it is more dangerous. It runs on a pool of threads shared by the whole program, the common ForkJoinPool. So one slow parallel stream can hold up every other one in the Java Virtual Machine (JVM), including ones inside libraries. It also pays off only for large amounts of pure calculation that split evenly.

Rules of thumb: do not use it for I/O. Use virtual threads for that instead, as Threads are cheap now shows. Do not use it on a LinkedList, or on a source backed by an Iterator. Measure before and after, and for anything with a deadline, run the work on your own pool.

// I/O on the shared pool: one slow call holds up every parallel stream in the JVM
long up = urls.parallelStream().filter(this::isReachable).count();   // do not do this

// large, CPU-bound and easy to split: the case where it can pay off
double total = IntStream.range(0, 50_000_000).parallel().mapToDouble(Math::sqrt).sum();
Reading the code
urls.parallelStream().filter(this::isReachable)

Each isReachable call waits on the network while it holds one of the shared pool's few threads.

IntStream.range(0, 50_000_000).parallel()

Fifty million square roots: pure calculation, split evenly across the processor's cores.

Gatherers#

Streams long had no way to add your own intermediate operation. Stream gatherers Java 24 fixed that, and came with built-in ones that cover gaps LINQ fills. Here productIds is a list of strings, prices a list of BigDecimal and quantities a list of Integer:

// Fixed-size batches, like LINQ's Chunk(n).
var batches = productIds.stream()
        .gather(Gatherers.windowFixed(100))
        .toList();                       // List<List<String>>

// A sliding window of three neighbours.
var windows = prices.stream().gather(Gatherers.windowSliding(3)).toList();

// A running total, like a fold that keeps every step.
var running = quantities.stream().gather(Gatherers.scan(() -> 0, Integer::sum)).toList();
Reading the code
Gatherers.windowFixed(100)

Groups the elements into lists of 100, with a shorter last list, like Chunk(100).

Gatherers.windowSliding(3)

Lists of three neighbours: the first three elements, then the second to the fourth, and so on.

Gatherers.scan(() -> 0, Integer::sum)

A running total. It starts from 0, adds each element in turn, and passes on every total along the way.

Note

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

Primitive streams#

Because generics cannot hold primitives, Java has IntStream, LongStream and DoubleStream, which avoid boxing every element. Here each order line has a quantity:

int total = lines.stream()
        .mapToInt(OrderLine::quantity)   // Stream<OrderLine> to IntStream
        .sum();

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

// boxing back when you need a collection
List<Integer> xs = IntStream.range(0, 10).boxed().toList();
Reading the code
.mapToInt(OrderLine::quantity)

Turns a stream of order lines into a stream of plain ints.

IntStream.range(0, 10)

The numbers 0 to 9. Unlike Enumerable.Range, the second argument is where to stop, not how many to produce; rangeClosed(0, 10) would include 10.

.boxed()

Wraps each 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(): count, sum, min, max, average

No expression trees#

C# refresherExpression trees and IQueryable: a lambda as data

Assigned to an Expression<...>, a C# lambda becomes a data structure a library can read. That is how EF Core turns a Where into SQL.

Expression<Func<Customer, bool>> adult = c => c.Age > 18;   // data, not code
Console.WriteLine(adult.Body);                              // (c.Age > 18)

IQueryable<Customer> q = db.Customers.Where(c => c.Age > 18);
string sql = q.ToQueryString();                             // ... WHERE [c].[Age] > 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 straight to bytecode and cannot be inspected, so no Java object-relational mapper (ORM) can do this. Instead, Jakarta Persistence (JPA), Java's standard ORM, has you write queries as strings in the Jakarta Persistence Query Language (JPQL), or build them with an API:

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

jOOQ is the closest in spirit to LINQ to SQL. It generates a typed domain-specific language (DSL) from your actual database schema, so a renamed column breaks the build. See Data access.

C# refresheryield return: a lazy sequence in C#
IEnumerable<int> Powers()
{
    for (var n = 1; ; n *= 2) yield return n;   // endless, computed on demand
}

var first10 = Powers().Take(10).ToList();       // 1, 2, 4 ... 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 by hand. Here are the powers of two, and a run of order numbers:

List<Integer> powers = Stream.iterate(1, n -> n * 2).limit(10).toList();   // 1, 2, 4 ... 512
List<Integer> ids = Stream.iterate(1000, n -> n < 1010, n -> n + 1).toList();   // 1000 ... 1009
Reading the code
Stream.iterate(1, n -> n * 2).limit(10)

An endless stream that starts at 1 and doubles each time. limit(10) takes the first ten, like Take(10) on an endless yield return.

Stream.iterate(1000, n -> n < 1010, n -> n + 1)

The three-argument form, from Java 9, stops by itself when the test fails, like a for loop.

What to remember

The customer names showed the shape: stream(), then filter, map and sorted in place of Where, Select and OrderBy, ending with toList().

A stream can be used only once, so collect it into a List when you need the results twice.

Collectors such as groupingBy, counting and joining do the work of GroupBy, Count and string.Join.

Leave parallelStream alone unless the work is large, pure calculation, and measured.

Java has no expression trees, so database queries are written in JPQL, with a criteria API or with jOOQ, not as lambdas.

Quick reference#

LINQStreamNote
Wherefilter
Selectmap
SelectManyflatMap
OrderBysorted(comparator)
Take(n)limit(n)
Skip(n)skip(n)
TakeWhiletakeWhileadded in Java 9: items from the start while the test holds
SkipWhiledropWhileadded in Java 9, named dropWhile: skips items while the test holds
Distinctdistinct()compares with equals and hashCode, so implement both
Reverseno direct equalcollect to a list, then call reversed(); a stream cannot reverse
ConcatStream.concat(a, b)a static method taking exactly two streams; nest it for more
Any()findAny().isPresent()for a collection, simply !list.isEmpty()
Any(p)anyMatch(p)
All(p)allMatch(p)
Count()count()returns a long, not an int, so a cast may be needed
First()findFirst().orElseThrow()throws NoSuchElementException when the stream is empty
FirstOrDefault()findFirst().orElse(null)Java has no default(T); orElse supplies the fallback
Single()no direct equalcollect to a list and check its size is exactly one
Sum()mapToInt(f).sum()sum() lives on IntStream, so map to int first
Average()mapToInt(f).average()returns an OptionalDouble, empty when there are no items
Min / 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; 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; add a second collector to count instead
Zipno equivalentuse IntStream.range over indices
Chunk(n)Stream.gather(Gatherers.windowFixed(n))Java 24 gatherers; on Java 21, use Guava's Lists.partition
DefaultIfEmptyno equivalentno operator; test for empty and supply the default yourself
AsParallel()parallelStream()shares one JVM-wide pool; riskier than it looks

Files and I/O#

Part P4 · Collections and streams · Chapter 21· 6 min read· Quick reference

Java's Files class is System.IO.File, and Path is the path type. Almost everything you need is a static method on Files.

Two habits matter. Anything that returns a Stream must be closed, so it goes in a try-with-resources block. And always pass a character set when you turn bytes into text, because a machine's default has garbled text in every language that has one.

The everyday operations#

The shop exports each day's orders to data/orders.csv, and an import job reads them back. Here are the everyday file operations in both languages. src, dst and dir are more paths, and more is extra text to add:

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. It is an object, not a string.

Files.readString(p)

Reads the whole file as UTF-8 text. It arrived in Java 11, and readAllLines gives you a list of lines instead.

StandardOpenOption.APPEND

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

Files.deleteIfExists(p);

Files.delete throws NoSuchFileException if the file is missing, where File.Delete quietly does nothing. deleteIfExists behaves like the C# method.

StandardCopyOption.REPLACE_EXISTING

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

Names, lazy reading and folder listings look like this. Notice that every lazy Java call sits in a try block:

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();
String ext = name.substring(name.lastIndexOf('.'));

try (var lines = Files.lines(p)) { }         // lazy
try (var files = Files.list(Path.of("data"))) { }
try (var all = Files.walk(Path.of("data"))) { }

Path tmp = Files.createTempFile("orders", ".csv");
try (var reader = Files.newBufferedReader(p)) { }
Reading the code
p.getFileName().toString()

getFileName returns a Path, and toString turns it into text: orders.csv.

name.substring(name.lastIndexOf('.'))

Java has no GetExtension, so you take the text from the last dot: .csv.

try (var lines = Files.lines(p)) { }

A try-with-resources block, Java's using: whatever is declared in the brackets is closed when the block ends. Files.lines reads lazily, like File.ReadLines, but holds the file open until it is closed.

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

The entries of one folder, like Directory.GetFiles, as a stream.

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

Every file and folder below data, like EnumerateFiles with AllDirectories.

Files.createTempFile("orders", ".csv")

Creates an empty temporary file with that prefix and suffix, and returns its path.

Gotcha

Files.lines, Files.walk and Files.list return a Stream that holds an open file handle. Unlike File.ReadLines in .NET, the stream does not let go of the file when it reaches the end, and on Windows the file stays locked. So they must go in a try-with-resources. Here the import job counts the lines that are not blank:

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

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

count() finishes the stream, but nothing closes it, so the file can stay open for as long as the program runs.

try (var lines = Files.lines(p)) {

The stream is closed when the block ends, even if an exception is thrown inside it.

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

That is all most code needs. The deeper sections cover character sets, the layers of Java's older stream classes, walking folder trees, and files packaged inside your application.

Charsets, and why to always pass one#

Java's older I/O classes use the machine's default character set, or charset, when you do not pass one. So the same code can produce different text on a developer's Mac and in a Linux container. This is the classic cause of garbled accented letters in Java systems. Here bytes holds text received from another system, and file is a File:

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

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

Turns the bytes into text with the machine's default charset, whatever that happens to be.

new String(bytes, StandardCharsets.UTF_8);

Names the charset, so the result is the same on every machine.

Note

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

Streams, readers and buffering#

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

KindJava typeFor
Bytes inInputStreambinary reading
Bytes outOutputStreambinary writing
Characters inReadertext reading
Characters outWritertext writing
BufferingBufferedInputStream / BufferedReaderwrap the above; always worth it
BridgingInputStreamReader(in, charset)bytes to characters

Reading the first line of a file through these layers, and copying one file to another, look like this:

import static java.nio.charset.StandardCharsets.UTF_8;

try (var in = Files.newInputStream(p);
     var reader = new BufferedReader(new InputStreamReader(in, UTF_8))) {
    String first = reader.readLine();
}

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

Three layers: the file's bytes, an InputStreamReader that turns bytes into characters with UTF-8, and a BufferedReader that reads ahead in large blocks. A StreamReader does all three jobs in .NET.

try (var in = Files.newInputStream(p);

A try-with-resources can open several things, separated by semicolons. They are closed in reverse order when the block ends.

in.transferTo(out);

Copies everything from one stream to the other, like C#'s CopyTo. It arrived in Java 9.

Gotcha

Reading one byte or character at a time without a buffer is dramatically slower in Java than the same .NET code. In .NET, FileStream buffers by default, and Java's streams do not. Wrap them in a Buffered* class unless you have a reason not to.

Walking a directory tree#

The import job looks for export files in a folder tree. Files.walk visits every entry below a folder, and newDirectoryStream matches names in one folder against a pattern. Here dir is the folder that holds the exports:

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

// at most two levels deep, following symbolic links
try (var paths = Files.walk(dir, 2, FileVisitOption.FOLLOW_LINKS)) {
    paths.forEach(System.out::println);
}

// names that match a pattern, in one folder
try (var found = Files.newDirectoryStream(dir, "*.{csv,tsv}")) {
    for (Path p : found) System.out.println(p.getFileName());
}
Reading the code
.filter(Files::isRegularFile)

Keeps the files and drops the folders.

Files.walk(dir, 2, FileVisitOption.FOLLOW_LINKS)

Goes at most two levels down, and follows symbolic links, which walk otherwise does not.

Files.newDirectoryStream(dir, "*.{csv,tsv}")

Lists one folder, keeping the names that match the pattern: here, files ending in .csv or .tsv.

.NETJava
Directory.EnumerateFiles(dir, "*.csv")Files.newDirectoryStream(dir, "*.csv")
Directory.EnumerateFiles(dir, "*", AllDirectories)Files.walk(dir)
Directory.EnumerateDirectoriesFiles.list(dir).filter(Files::isDirectory)
new DirectoryInfo(d).GetFiles()Files.list(d)

Reading a file from inside the JAR#

Anything under src/main/resources is packaged into the Java archive (JAR) and read through the classpath. When the program runs, it is not a file on disk. Opening it with Files.readString(Path.of("config.json")) works in the IDE and fails after packaging, which is a confusing and very common first deployment failure. Read it as a resource instead, as you would read an embedded resource in .NET:

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

Finds the resource on the classpath and opens it as a stream of bytes, or returns null if there is none.

in.readAllBytes()

Reads every byte, and new String(..., UTF_8) turns them into text.

Note

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

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

The order export showed that Files holds the everyday operations as static methods that take a Path, and that readString and writeString use UTF-8.

Files.lines, walk and list hold the file open, so they always go in a try-with-resources.

Pass a charset whenever you turn bytes into text, and wrap raw streams in a buffered one.

Files packaged in the JAR are resources, read with getResourceAsStream, not files on disk.

Quick reference#

System.IOjava.nio.fileNote
File.ReadAllTextFiles.readString(p)added in Java 11; reads UTF-8 unless you pass a charset
File.ReadAllLinesFiles.readAllLines(p)reads the whole file into memory; use Files.lines for big files
File.ReadLines (lazy)Files.lines(p)returns a Stream, so it must be closed
File.WriteAllTextFiles.writeString(p, s)added in Java 11; writes UTF-8 and replaces the file
File.AppendAllTextFiles.writeString(p, s, APPEND)pass StandardOpenOption.APPEND to add to the end
File.ExistsFiles.exists(p)
File.DeleteFiles.delete(p) / deleteIfExists(p)delete throws NoSuchFileException if the file is missing
File.Copy / MoveFiles.copy / Files.movepass REPLACE_EXISTING to overwrite
Directory.CreateDirectoryFiles.createDirectories(p)creates any missing parent folders too, like CreateDirectory
Directory.GetFilesFiles.list(dir)one folder only, as a Stream that must be closed
Directory.EnumerateFiles(recursive)Files.walk(dir)the whole tree, as a Stream that must be closed
Path.Combine("a","b")Path.of("a","b")or dir.resolve("b") to add one part to an existing Path
Path.GetFileNamep.getFileName()returns a Path; call toString() for the name as text
Path.GetExtensionno equivalentno method; take the text after the last dot yourself
FileStreamFiles.newInputStream / newOutputStreambyte streams; wrap them in a Buffered stream for speed
StreamReaderFiles.newBufferedReader(p)a buffered text reader, UTF-8 unless you say otherwise
MemoryStreamByteArrayInputStream / ByteArrayOutputStreamin-memory byte streams, one class for each direction
Path.GetTempFileNameFiles.createTempFile(prefix, suffix)creates an empty temporary file and returns its Path
FileSystemWatcherWatchServicemuch lower level than the .NET one

Threads are cheap now#

Part P5 · Concurrency · Chapter 22· 9 min read· Quick reference

Stop looking for await. There isn't one, and you don't need one. C# made waiting on I/O cheap by making methods asynchronous. Java made it cheap by making threads almost free. Since Java 21 you write ordinary blocking code, run it on a virtual thread, and the runtime does the work that await does in C#.

So Java has no function colouring: no async spreading to every caller, no Task<T> in your signatures, no deadlocks from blocking on async code, and no ConfigureAwait.

The colour problem, and how Java sidestepped it#

Building an order report needs the customer and their orders, each fetched from another service. In C#, the moment a method awaits, its signature changes to async Task, and so does every caller's, all the way up. That is called function colouring. Java never introduced the colour, so the same method works everywhere:

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

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

// run it on a virtual thread and it scales anyway
Thread.startVirtualThread(() -> build(7));
Reading the code
async Task<Report> BuildAsync(int id)

The C# method is async, so it returns a Task, and every caller must await it or block on it.

Customer c = api.getCustomer(id); // blocks

The Java call simply waits for the answer. The thread is blocked until the customer arrives.

Thread.startVirtualThread(() -> build(7));

Runs build on a new virtual thread. While it waits, the thread costs almost nothing.

The Java version blocks twice. On an ordinary thread, that would tie up a whole operating-system thread while it waits. On a virtual thread it costs almost nothing. When getCustomer waits for the network, the Java Virtual Machine (JVM) takes the virtual thread off the real thread it was running on, called its carrier. It keeps the virtual thread's state on the heap. The carrier goes off to run something else. That is what await does in .NET, except that no compiler rewrote your method, and its signature never changed.

C# refresherConfigureAwait(false): not resuming on the caller's context

By default await resumes on the context it started on, such as a UI thread. Library code adds ConfigureAwait(false) to resume on any pool thread, which avoids a class of deadlocks.

var json = await http.GetStringAsync(url).ConfigureAwait(false);
// continues on a thread-pool thread, not the caller's synchronisation context
async / await

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

So the question becomes how to start virtual threads.

Creating and running them#

There are three ways, in rough order of how often you will want them. Here ids is a list of order ids, and handle(id) processes one order:

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

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

// 3. Build one without starting it; when you need a name or an
//    uncaught-exception handler.
Thread t2 = Thread.ofVirtual()
                  .name("import-", 0)
                  .unstarted(() -> handle(7));
t2.start();
Reading the code
Executors.newVirtualThreadPerTaskExecutor()

An executor that starts a new virtual thread for every task you submit. It is not a pool: nothing is reused.

exec.submit(() -> handle(id));

Submits one task, like Task.Run. It returns a Future, Java's Task.

} // close() waits for every task to finish

The try-with-resources block closes the executor at the end, and closing it waits for every submitted task.

Thread.startVirtualThread(() -> handle(7));

Starts one virtual thread straight away. join() waits for it to finish, like Wait() on a task.

.name("import-", 0)

Names the threads import-0, import-1 and so on, which helps when you read logs or thread dumps.

Here is everyday C# task code with its Java version. In C#, ct is a CancellationToken. Java cancels by interrupting the thread instead, as the next chapter explains:

C#
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
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
a.get(); b.get();

get waits for a task and returns its result, like awaiting a Task. Together the two lines do the job of Task.WhenAll.

Thread.sleep(Duration.ofSeconds(1));

Blocks only the virtual thread, so sleeping is cheap. Calling interrupt() on the thread ends the sleep early with an InterruptedException.

var gate = new Semaphore(20);

Lets at most 20 threads past acquire at once, like SemaphoreSlim. release lets the next one in.

C# refresherIAsyncEnumerable and await foreach: an async stream

An async iterator hands back each value when it is ready, and the caller consumes them with await foreach.

async IAsyncEnumerable<int> TicksAsync()
{
    for (var i = 0; i < 3; i++)
    {
        await Task.Delay(1000);
        yield return i;
    }
}

await foreach (var t in TicksAsync()) Console.WriteLine(t);
Note

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

What a virtual thread actually isdeep dive

A virtual thread is a Thread whose stack lives on the Java heap, rather than in a fixed stack reserved by the operating system. Two numbers explain the whole design:

  • A platform thread, the ordinary kind, reserves around 1 MB of stack up front. Tens of thousands of them is already painful.
  • A virtual thread starts at a few hundred bytes and grows as needed. Millions is routine.

Virtual threads run on a small pool of carrier threads, sized by default to the number of processors. Most blocking operations in the Java Development Kit (JDK) know about virtual threads, such as network calls, Thread.sleep and most locks. When a virtual thread blocks in one of them, the runtime copies its stack to the heap and frees the carrier. When the operation completes, the virtual thread carries on, on whichever carrier is free.

You can size the carrier pool with -Djdk.virtualThreadScheduler.parallelism=N, but the default is nearly always right. The .NET thread pool adds threads slowly when all of them are blocked. The carrier pool rarely needs to grow, because a blocked virtual thread gives its carrier back.

Virtual threads make waiting cheap. They are not the answer to everything, and three mistakes are common.

When virtual threads are the wrong tool#

Gotcha

Virtual threads do nothing for CPU-bound work. They make waiting cheap, not calculating. A million virtual threads multiplying matrices run exactly as fast as the carrier threads allow, plus a little overhead.

For work that keeps the processor busy, keep a pool of ordinary threads sized close to the number of processor cores, the same instinct you already have from Parallel.For.

Gotcha

Never pool virtual threads. Pools exist to spread the cost of creating a thread. Virtual threads are so cheap to create that pooling them only adds contention, and brings back the queueing the design removes. newVirtualThreadPerTaskExecutor() creates a new one for every task on purpose.

To limit concurrency, for example to protect a database that allows 20 connections, use a Semaphore, not a pool. Limit the resource, not the number of threads.

Gotcha

ThreadLocal still works, but scales badly. One ThreadLocal value per thread was fine with 200 threads. With 500,000 it is a memory problem. Use ScopedValue Java 25 instead. It is also the closer match to AsyncLocal<T>: it cannot be changed, and it lasts only for a block of code, rather than until the thread ends.

C# refresherAsyncLocal: a value that flows with the async call
static readonly AsyncLocal<string?> User = new();

User.Value = "ada";
await Task.Run(() => Console.WriteLine(User.Value));   // "ada": it flows into the task
Pinning: the one performance trapdeep dive

A virtual thread that cannot be taken off its carrier while it is blocked is pinned: it holds the carrier hostage. On Java 21 to 23 there were two causes. One was blocking inside a synchronized block, Java's version of C#'s lock statement. The other was blocking inside native code called through the Java Native Interface (JNI).

JDK Enhancement Proposal (JEP) 491 fixed the synchronized case in Java 24 Java 24. Virtual threads now come off their carrier correctly while blocked inside a synchronized block. So the old advice to rewrite every one of them as a ReentrantLock no longer applies, and on Java 24 and later only native code pins.

If you are on Java 21, the old workaround still matters. Here monitor is any object used as a lock, lock is a ReentrantLock, and db.query waits for the database:

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

// comes off the carrier correctly on every version
lock.lock();
try {
    var row = db.query(sql);
} finally {
    lock.unlock();
}
Reading the code
synchronized (monitor) {

Holds monitor as a lock, like C#'s lock (monitor). On Java 21 to 23, waiting on I/O inside it pinned the carrier. Mutual exclusion covers synchronized in full.

lock.lock();

Takes a ReentrantLock explicitly, and unlock in the finally block releases it. Waiting while holding it never pinned.

To find pinning on Java 21 to 23, run with -Djdk.tracePinnedThreads=full, which prints a stack trace whenever a virtual thread blocks while pinned. Current Java versions no longer have that option. Use the JDK Flight Recorder event jdk.VirtualThreadPinned instead.

Blocking becomes the normal style again#

Once blocking is cheap, patterns that felt expensive in .NET become ordinary. Retrying a blocking call is the clearest example. It is also one of the places where Spring Boot 4 changed: retry support moved from an add-on library into Spring Framework itself. Here a client fetches an exchange rate from another service, where restClient is a Spring RestClient pointed at that service:

// Boot 3.5: needs the Spring Retry dependency,
// or Resilience4j, as an explicit add-on.
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.retry.annotation.Retryable;

@EnableRetry
@Configuration
class RetryConfig { }

@Service
class RatesClient {

    @Retryable(maxAttempts = 3)
    public Rate fetch(String pair) {
        return restClient.get()          // blocks, fine on a
                .uri("/rates/{p}", pair) // virtual thread
                .retrieve()
                .body(Rate.class);
    }
}
// Boot 4.0: @Retryable is part of Spring Framework 7 itself.
// No extra dependency.
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.resilience.annotation.Retryable;

@EnableResilientMethods
@Configuration
class RetryConfig { }

@Service
class RatesClient {

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

In both versions, @Retryable wraps fetch so that a failed call is tried again, and fetch itself stays a plain blocking method. Watch the number, though. In Boot 3, maxAttempts = 3 means three calls in total. In Boot 4, maxRetries = 3 means three retries after the first call, so up to four calls. @EnableRetry and @EnableResilientMethods switch the feature on.

Take Boot 4's annotation from org.springframework.resilience.annotation, as the imports show. Spring Framework 7 also has an interface called Retryable, in org.springframework.core.retry, and an IDE may offer that one first.

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

spring:
  threads:
    virtual:
      enabled: true

With spring.threads.virtual.enabled set to true, the embedded web server handles each request on a new virtual thread instead of a thread from a fixed pool.

What you'll still meet in existing code#

LegacyExecutors.newFixedThreadPool(n)

Before Java 21, this was the default for I/O work: a pool whose size was guessed, then tuned after each incident. You will see it everywhere, and it still works. For I/O work on Java 21 and later, replace it with Executors.newVirtualThreadPerTaskExecutor() and delete the size. Keep a fixed pool only for work that keeps the processor busy.

LegacyCompletableFuture chains as the async style

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

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

The two supplyAsync calls start the fetches, thenCombine merges the results when both arrive, and join waits for the merged value. CompletableFuture is still the right tool when you need a value that completes later, as the next chapters show. It is no longer the right tool just for running two blocking calls at once.

LegacyThread.stop() and Thread.suspend()

Both have been unsafe since Java 1.2. suspend() was removed in Java 23. stop() still compiles against Java 25, but it throws instead of stopping anything, and Java 26 removes it. There has never been a safe way to kill a thread from outside, in Java or in .NET. Cancellation is cooperative: interrupt the thread, and let it notice.

Migration checklist#

If your .NET instinct 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.

The next chapter covers what this one left out: running several blocking calls at once and joining them, with proper cancellation. It is Java's answer to Task.WhenAll plus a CancellationToken.

What to remember

The order report showed that Java has no function colouring: build stays an ordinary blocking method, and running it on a virtual thread makes the waiting cheap.

newVirtualThreadPerTaskExecutor starts a virtual thread for each task, and closing it in a try-with-resources block waits for them all.

Virtual threads help only with waiting: keep a sized pool for calculation, limit resources with a Semaphore, and prefer ScopedValue to ThreadLocal.

Since Java 24, blocking inside synchronized no longer pins a virtual thread.

Quick reference#

.NETJava 21+Notes
async Task<T> BuildAsync()T build()write it blocking, and run it on a virtual thread
Task.Run(() => f())exec.submit(() -> f())with a virtual-thread executor
await SomeIoAsync()someIo()just call it; blocking is fine
Task.WhenAll(a, b)StructuredTaskScopefork subtasks in a scope; still preview in Java 25
Task.Delay(d)Thread.sleep(d)blocks only the virtual thread, so sleeping is cheap
CancellationTokenThread.interrupt()cooperative in both: the target must check, or be blocked in a call that does
IAsyncEnumerable<T>no direct equalno async streams; a producer thread feeding a BlockingQueue is the usual shape
AsyncLocal<T>ScopedValuefinal in Java 25; immutable, and bound to one block rather than ambient
SemaphoreSlimSemaphorestill needed to limit concurrency
ThreadPool.QueueUserWorkItemExecutorService.submita platform-thread pool, for CPU-bound work

Structured concurrency and cancellation#

Part P5 · Concurrency · Chapter 23· 5 min read· Quick reference

Structured concurrency is Java's answer to Task.WhenAll plus a CancellationToken. You start several subtasks inside a scope and wait for them together, and the scope guarantees that none of them outlives it. If one fails, the others are cancelled automatically.

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

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. Blog posts from 2023 or 2024 use an older API shape, with new StructuredTaskScope<>() and ShutdownOnFailure, which no longer matches Java 25. Java 26, the sixth preview, changes it again: for example, anySuccessfulResultOrThrow() is renamed anySuccessfulOrThrow().

The problem it solves#

The order report needs the customer and their orders, from two different services. The two calls should run at the same time. If either fails, the report should fail at once, and nothing may be left running afterwards. Here is that shape in each language:

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

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

        return merge(a.get(), b.get());
    }
}
Reading the code
StructuredTaskScope.open()

Opens a scope. By default it waits for every subtask, and fails as soon as one of them fails.

scope.fork(() -> fetchCustomer(id));

Starts a subtask on its own virtual thread, and returns a handle to its future result.

scope.join();

Waits for the subtasks. If one failed, join throws, and the other subtask is cancelled.

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

Reads each subtask's result, which is ready once join has returned.

The try block is the cancellation scope. Leaving it, whether normally, by an exception or by an interrupt, cancels every subtask still running. There is no token to pass down through your calls, because the scope is the token, and the block bounds it.

C# refresherCancellationTokenSource, CancelAfter and Task.WhenAny

A token source cancels every task that holds its token, by hand or after a timeout, and WhenAny finishes as soon as the first task does.

using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(2));        // cancel automatically after 2 s

try
{
    var first = await Task.WhenAny(PrimaryAsync(cts.Token), BackupAsync(cts.Token));
    cts.Cancel();                                // stop the one still running
    Use(await first);
}
catch (OperationCanceledException) { }          // the 2 s ran out

void Work(CancellationToken ct) => ct.ThrowIfCancellationRequested();

Cancellation itself works differently in Java, and that is the next section. First, the policies a scope can follow.

Completion policies#

open() with no argument waits for every subtask and fails fast on the first failure. Passing a Joiner chooses a different policy, and this is where WhenAll and WhenAny differ. Here the shop asks two exchange-rate services for the same rate, and takes whichever answers first:

// first one home wins, the rest are cancelled
try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<Rate>anySuccessfulResultOrThrow())) {
    scope.fork(() -> primary.fetch(pair));
    scope.fork(() -> secondary.fetch(pair));
    return scope.join();          // the winning value
}
Reading the code
StructuredTaskScope.Joiner.<Rate>anySuccessfulResultOrThrow()

A policy that finishes with the first successful result and cancels the rest, like Task.WhenAny followed by cancelling the losers. <Rate> names the result type, which Java cannot work out here on its own.

return scope.join();

With this policy, join returns the winning value.

These are the policies Java 25 offers:

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

Why a scope rather than a token#

Note

The idea is the same one behind structured programming. A task started with executor.submit() is like a goto: its lifetime has nothing to do with the code that started it, so tasks can leak or be orphaned. A scope ties each subtask's lifetime to the block of code that started it. A subtask cannot outlive that block, and thread dumps show which task started which.

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

In code, the difference looks like this. refreshCache reloads the shop's product cache:

// unstructured: the task can outlive the method that started it
void start() { executor.submit(this::refreshCache); }   // who waits for it? nobody

// structured (preview): the subtask cannot outlive the block
void refresh() throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        scope.fork(this::refreshCache);
        scope.join();                                    // finished before the block ends
    }
}
Reading the code
void start() { executor.submit(this::refreshCache); }

Starts the refresh and returns at once. Nothing waits for it, and nothing stops it if the caller is cancelled.

scope.fork(this::refreshCache);

The refresh runs inside the scope, so the block cannot end until the refresh has finished.

Cancellation is interruption#

Java cancels a thread by interrupting it with Thread.interrupt(). Like a CancellationToken, it is cooperative: it sets a flag on the thread, and blocking calls in the JDK throw InterruptedException when they see the flag. Here a long import works through the order file in chunks, and must stop when it is cancelled:

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

Checks the interrupt flag between chunks, like checking ct.IsCancellationRequested.

Thread.sleep(100);

A blocking call such as sleep throws InterruptedException when the thread is interrupted, instead of waiting out its time.

Thread.currentThread().interrupt();

Catching the exception clears the flag, so this line sets it again for the code above, as the warning below explains.

Gotcha

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

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

Cancellation travels down the thread. Context such as the current user needs to travel too, and Java 25 has a final API for that.

ScopedValue replaces AsyncLocal#

Scoped values Java 25 are final in Java 25, so unlike structured concurrency they are ready for production. They carry context, such as the current user, down a chain of calls without passing it as a parameter, and forked subtasks inherit them:

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

Creates the key. It usually lives in a static final field, like the static AsyncLocal in C#.

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

Binds USER to "ada" while the lambda runs. Code called from inside reads it with USER.get().

When run returns, USER is unbound again, so the value cannot leak into later work. Here is how the two compare:

AsyncLocal<T>ScopedValue<T>
Mutable, assign any timeImmutable, bound for a block
Lifetime is ambientLifetime is the block
Flows to async continuationsInherited by forked subtasks
Can leak if never clearedCannot leak; unbinds on block exit
Note

ScopedValue is also the recommended replacement for ThreadLocal on virtual threads. A ThreadLocal holds one value per thread, so a million virtual threads means a million values.

What to write on Java 25#

Until structured concurrency is final, this is the production-safe version of the order report, using only final APIs. It gives you the first failure and guaranteed cleanup, without preview flags:

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

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

The executor from the previous chapter: one virtual thread per task.

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

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

Gotcha

This pattern does not cancel the other task when one fails. close() waits for it to finish rather than interrupting it. If cancelling matters, call b.cancel(true) in a catch block, or use invokeAny or invokeAll, which follow their own rules. This gap is exactly what structured concurrency exists to close.

What to remember

The order report showed the pattern: open a scope, fork the customer and order calls, join, then read the results. Leaving the block cancels anything still running.

A Joiner such as anySuccessfulResultOrThrow gives WhenAny behaviour: the first success wins, and the rest are cancelled.

Cancellation is interruption: check the flag, and when you catch InterruptedException, restore it with Thread.currentThread().interrupt().

ScopedValue is final in Java 25, but structured concurrency is still a preview, so production code uses a virtual-thread executor and cancels the other task by hand.

Quick reference#

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

CompletableFuture and Task#

Part P5 · Concurrency · Chapter 24· 5 min read· Quick reference

CompletableFuture<T> is Java's Task<T>: a value that arrives later, with methods to chain more work onto it. C# hid the chaining behind await, and Java never did. So code that uses CompletableFuture reads like C# from before await, full of ContinueWith.

Since virtual threads, you need it far less. Use it when you genuinely need a value that completes later, such as the reply from a callback-based API, not just to run two blocking calls at once.

Translation#

The shop's payment provider ships a client library that reports each reply through a callback, not a return value. Turning that into a value you can wait for is the classic job for CompletableFuture. Here is the everyday API in both languages. compute() is some calculation, client is the payment client, and a and b are two tasks already running:

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.completedFuture(42)

A future that is already complete, like Task.FromResult.

CompletableFuture.supplyAsync(() -> compute())

Runs the lambda on another thread, and completes the future with its result, like Task.Run.

new CompletableFuture<String>()

An empty future, like a TaskCompletionSource. Nothing completes it until something calls complete.

client.onReply(reply::complete);

When the reply arrives, the client calls complete, which fills in the future.

reply.join()

Waits for the value, like .Result. On a virtual thread, waiting is cheap.

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

anyOf's result has the type Object, so you have to cast it back to the type you expect.

The quick reference at the end of this chapter lists every Task operation with its CompletableFuture version. If IProgress is hazy, here it is:

C# refresherIProgress: reporting progress from async work
var progress = new Progress<int>(pct => bar.Value = pct);   // runs on the caller's context
await ImportAsync(file, progress);

async Task ImportAsync(string file, IProgress<int> progress)
{
    progress.Report(50);                                     // half way
}

Chaining is where CompletableFuture differs most from await, and the deeper sections start there.

Chaining#

Suppose fetching a customer returns a future, decorating the customer is a plain calculation, and enriching it returns another future. In C# you would await each step. With CompletableFuture you chain the steps:

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)

Runs decorate on the result when it arrives, like ContinueWith with a plain function.

.thenCompose(this::enrichAsync);

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

Report report = f.join();

Waits for the whole chain to finish.

These are the chaining methods, and where each one runs. An executor is an object that supplies the threads:

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

allOf returns CompletableFuture<Void>, not a future of the results. Task.WhenAll gives you a Task<T[]>, but after allOf you go back and read each future yourself. Here ids is a list of order ids, and load(id) fetches one order:

import static java.util.concurrent.CompletableFuture.supplyAsync;

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

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

List<Order> orders = futures.stream()
        .map(CompletableFuture::join)   // safe: all are already complete
        .toList();
Reading the code
.map(id -> supplyAsync(() -> load(id)))

Starts one future for each id.

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

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

.map(CompletableFuture::join)

Collects each result. join returns at once here, 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, and it is shared with parallel streams and everything else in the Java Virtual Machine (JVM). Blocking on it starves everything.

Always pass an executor for anything that blocks. Here httpCall waits for another service:

var exec = Executors.newVirtualThreadPerTaskExecutor();

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

The executor from Threads are cheap now: each task gets its own virtual thread.

.thenApplyAsync(this::transform, exec);

Every *Async step takes the executor as its last argument. Leave it out, and that step moves back to the common pool.

Exceptions#

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

C# refresherAggregateException: several failures wrapped in one
try { Task.WaitAll(a, b); }
catch (AggregateException ex)
{
    foreach (var inner in ex.InnerExceptions) Log(inner);   // the real exceptions
}

Here future is a CompletableFuture<Report>, and log is the class's logger:

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

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

join wraps the task's exception in a CompletionException.

Throwable cause = e.getCause();

The exception the task actually threw.

future.exceptionally(ex -> {

Handles a failure inside the chain. The lambda receives the exception and returns a replacement value, here an empty report, so publish still runs.

Note

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

When to still use it#

With virtual threads, most concurrent code can simply block. These are the cases where CompletableFuture is still the right tool:

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

The callback case looks like this. Here client is a messaging client that reports each reply, or each error, through a callback:

// adapting a callback API: the future is completed by hand
CompletableFuture<Reply> send(Request request) {
    var done = new CompletableFuture<Reply>();
    client.send(request, done::complete, done::completeExceptionally);
    return done;
}
Reading the code
var done = new CompletableFuture<Reply>();

An empty future, to be completed later.

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 value it can wait for or chain onto, like the Task of a TaskCompletionSource.

LegacyFuture, the Java 5 interface

The original Future from Java 5 has no chaining methods at all. You can only get(), which blocks, cancel() and isDone(). It is what ExecutorService.submit returns, and it is perfectly good when you intend to block on a virtual thread anyway. CompletableFuture implements Future, so it does everything Future does, and more.

What to remember

CompletableFuture is Task: completedFuture, supplyAsync and new CompletableFuture<>() play the parts of FromResult, Task.Run and TaskCompletionSource.

thenApply and thenCompose play ContinueWith and Unwrap, and allOf carries no results, so you read each future afterwards.

Pass an executor to every *Async method that may block, or the work lands on the shared common pool.

join wraps failures in CompletionException, and with virtual threads most code can simply block instead.

Quick reference#

.NETJavaNote
Task<T>CompletableFuture<T>
Task (void)CompletableFuture<Void>
Task.FromResult(v)CompletableFuture.completedFuture(v)
Task.Run(f)CompletableFuture.supplyAsync(f)
Task.Run(action)CompletableFuture.runAsync(action)
TaskCompletionSource<T>new CompletableFuture<>()create it empty, then call complete(value) when the result arrives
.ContinueWith(t => f(t.Result)).thenApply(f)
.ContinueWith returning Task.thenCompose(f)flattens a future of a future into one
Task.WhenAll(a, b)CompletableFuture.allOf(a, b)completes when all do, but carries no results; read each future
Task.WhenAny(a, b)CompletableFuture.anyOf(a, b)the result is typed Object, so you must cast it back
combine two results.thenCombine(other, fn)waits for both, then merges; .NET has no single call
.Result / .GetAwaiter().GetResult().join()join() throws an unchecked CompletionException wrapping the cause
await task.join(), or just block on a virtual thread
try/catch around await.exceptionally(fn) or .handle(fn)
Task.DelayCompletableFuture.delayedExecutor(...)an executor that runs a task after a delay; added in Java 9
IProgress<T>no equivalentpass a Consumer<Integer> and call it as the work progresses

Locks, atomics and the memory model#

Part P5 · Concurrency · Chapter 25· 6 min read· Quick reference

The basic tools line up closely: synchronized is lock, AtomicInteger is Interlocked, and ConcurrentHashMap is ConcurrentDictionary.

The keyword to be careful with is volatile. It exists in both languages, but Java's is stronger: every volatile read and write happens in one order that all threads agree on. For the everyday uses, a stop flag and lazy initialisation, the two behave the same.

Mutual exclusion#

The shop keeps a running total of today's sales, and several threads add to it at once. Without a lock, two threads can read the same old total, and one of the additions is lost. In C# you wrap the update in lock. In Java you wrap it in synchronized:

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

A private object used only as the lock, like C#'s _gate. Any object can serve as a lock in Java.

synchronized (gate) {

Only one thread at a time can be inside a block synchronized on gate. The others wait, as they would at lock (_gate).

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

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

Every call to add now takes the lock of the object itself, as if the whole body were inside synchronized (this). That is convenient, and it is also the trap in the next warning.

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, and then your class only works if strangers behave. Use a private final lock object. The same advice applies to lock (this) in C#, for the same reason, but Java's synchronized method modifier makes the mistake much easier to make by accident.

ReentrantLock#

When you need a timeout, a wait that can be interrupted, fairness, or several conditions to wait on, synchronized is not enough:

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

Here a stock update gives up after 200 milliseconds, instead of waiting for the lock forever:

private final ReentrantLock lock = new ReentrantLock();

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

Waits at most 200 milliseconds for the lock, like Monitor.TryEnter with a timeout, and returns false if the lock stayed busy.

lock.unlock();

A ReentrantLock is not released automatically, so the unlock always goes in a finally block.

throw new TimeoutException();

What to do when the lock never came free is up to you.

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 are fine; see Threads are cheap now.

A lock protects a block of code. For a single number, there is something lighter.

Atomics#

For one number, such as a count of the orders placed today, a lock is heavier than you need. Both languages have atomic operations, which update a value in one step that no other thread can interrupt:

C#
private int _count;
Interlocked.Increment(ref _count);
Interlocked.Add(ref _count, 10);
Interlocked.Exchange(ref _count, 0);            // set, returning the old value
Interlocked.CompareExchange(ref _count, 5, 4);  // set to 5 only if it is 4
Java 25
private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();
count.addAndGet(10);
count.getAndSet(0);                             // set, returning the old value
count.compareAndSet(4, 5);                      // set to 5 only if it is 4
Reading the code
new AtomicInteger()

Java has no ref parameters, so the atomic operations live on an object that holds the number, rather than on a static class like Interlocked.

count.incrementAndGet();

Adds one and returns the new value, like Interlocked.Increment.

count.compareAndSet(4, 5);

Sets the value to 5 only if it is currently 4, and returns whether it did. Note the order: the expected value comes first, where CompareExchange puts the new value first.

Note

LongAdder has no .NET equivalent, and it is worth knowing. When many threads update one counter at once, it spreads the count over several cells and adds them up when you read it. Under that load it beats AtomicLong by a wide margin. Use it for metrics and counters that are written constantly and read now and then.

Atomics and locks also make changes visible to other threads. Plain fields do not, and that is where volatile comes in.

volatile means more in Java#

C# refresherVolatile.Read and Volatile.Write

Explicit reads and writes that are not cached or reordered, so another thread sees the latest value.

private bool _stop;

void Run()  { while (!Volatile.Read(ref _stop)) Work(); }
void Stop() => Volatile.Write(ref _stop, true);   // Run sees it on its next check
Gotcha

Both languages have the keyword, but its guarantees differ:

C# volatileJava volatile
Reorderingacquire on read, release on writesequentially consistent: one order that every thread agrees on
Visibilitythe field, plus writes made before a volatile writethe field, plus writes made before a volatile write
Atomicity of 64-bitnot allowed: 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 do the everyday jobs: a stop flag, and double-checked locking. The difference shows only when a thread writes one volatile field and then reads a different one, which Java keeps in order and C# may not. Java 5 repaired Java's memory model in 2004, so older articles that call double-checked locking broken in Java describe a Java Virtual Machine (JVM) that no longer exists.

Here the shop's configuration is loaded once, on first use, by whichever thread gets there first:

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

public Config get() {
    var c = config;
    if (c == null) {
        synchronized (this) {
            c = config;
            if (c == null) config = c = load();
        }
    }
    return c;
}
Reading the code
private volatile Config config;

volatile guarantees that a thread which sees the new Config also sees it fully built.

var c = config;

Reads the field once into a local variable, so the usual path costs a single read.

if (c == null) config = c = load();

The second check, inside the lock, stops two threads that both saw null from loading the configuration twice.

Note

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

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

Holder is not initialised until get first touches Holder.INSTANCE, and the JVM runs that initialisation exactly once, however many threads arrive together.

Java 25 also previews stable values Java 25 preview, which are essentially Lazy<T>. After initialisation, the just-in-time (JIT) compiler can treat them as constants.

C# refresherLazy: create a value on first use
private static readonly Lazy<Config> _config = new(() => Load());   // thread-safe by default
public static Config Config => _config.Value;                     // Load() runs on first read

Most concurrent code needs neither locks nor atomics directly, because a concurrent collection already does the work.

Concurrent collections#

The shop caches exchange rates, counts page hits, and hands jobs from web requests to a worker thread. Here pair names a currency pair, and job is a job waiting to be processed:

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

Returns the cached rate, or calls fetch and stores the result. Java runs the function at most once per key. GetOrAdd may call Fetch twice when two threads race.

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

Stores 1 if the key is new, and otherwise combines the old value with 1 using Integer::sum, like AddOrUpdate.

new ArrayBlockingQueue<>(100)

A queue that holds at most 100 jobs, like a BlockingCollection with a bounded capacity.

jobs.put(job);

put waits while the queue is full, and take waits while it is empty.

C# refresherChannel, CountdownEvent, ManualResetEventSlim and Barrier
var ch = Channel.CreateBounded<Job>(100);        // an async producer-consumer queue
await ch.Writer.WriteAsync(job);
Job next = await ch.Reader.ReadAsync();

using var done = new CountdownEvent(3);          // three Signal() calls release Wait()
using var gate = new ManualResetEventSlim();     // Set() opens it for every waiter
using var round = new Barrier(4);                // four threads meet, then all continue
Gotcha

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

What to remember

The sales total showed that synchronized is lock: keep the lock object private and final, and never lock on this.

ReentrantLock adds timeouts, interruption and fairness, and must be unlocked in a finally block.

AtomicInteger and LongAdder replace Interlocked, and computeIfAbsent runs its function at most once per key.

Java's volatile is sequentially consistent, stronger than C#'s acquire and release, and both are enough for a stop flag or double-checked locking.

Quick reference#

.NETJava
Interlocked.Increment(ref x)atomicInt.incrementAndGet()
Interlocked.Add(ref x, n)atomicInt.addAndGet(n)
Interlocked.Exchange(ref x, v)atomicRef.getAndSet(v)
Interlocked.CompareExchange(ref x, v, c)atomicRef.compareAndSet(c, v)
n/aatomicInt.updateAndGet(fn)
n/aatomicRef.accumulateAndGet(v, fn)
Interlocked for high contentionLongAdder, far better under contention
Volatile.Read / Writevolatile field, or VarHandle
.NETJavaNote
ConcurrentDictionary<K,V>ConcurrentHashMap<K,V>the thread-safe HashMap; reads never lock
GetOrAdd(k, factory)computeIfAbsent(k, fn)atomic: the function runs at most once per key, unlike GetOrAdd
AddOrUpdatecompute(k, fn) / merge(k, v, fn)compute and merge update a value atomically
ConcurrentQueue<T>ConcurrentLinkedQueue<E>an unbounded queue whose operations never wait
BlockingCollection<T>LinkedBlockingQueue<E>optionally bounded; take() waits for an item, put() for space
BlockingCollection with capArrayBlockingQueue<E>a bounded queue with a fixed capacity set when it is created
ConcurrentBag<T>no direct equalno unordered bag; a ConcurrentLinkedQueue usually serves
ImmutableList + swapCopyOnWriteArrayList<E>every write copies the array; only for rare writes
Channel<T>BlockingQueue, or SubmissionPublisherno async channel; a BlockingQueue on virtual threads is the usual answer
CountdownEventCountDownLatchcounts down to zero once; cannot be reset
BarrierCyclicBarrierreleases all waiters, then resets for the next round
ManualResetEventSlimCountDownLatch, or a Conditiona one-shot gate is a CountDownLatch(1); a resettable one needs a Condition
SemaphoreSlimSemaphorea counting semaphore: acquire() takes a permit, release() returns it

Legacy concurrency you will still meet#

Part P5 · Concurrency · Chapter 26· 6 min read· Quick reference

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

What older code looks like#

The order service's older code handles each incoming order on a thread pool. .NET has always pooled threads for you. Java before version 21 made you create the pool yourself, and choose its size:

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)

A pool of exactly 50 platform threads, the ordinary kind, each backed by an operating-system thread. The number 50 is a guess you had to make.

Future<?> f = pool.submit(() -> handle(id));

Submits a task, like Task.Run, and returns a Future, Java's Task. f.get() waits for it to finish.

pool.shutdown();

A pool must be shut down when you have finished with it. Otherwise its threads keep the program running.

The sections below take each older tool in turn: what it does, and what to write instead.

Raw threads#

Legacynew Thread(...).start()

This is the Java 1.0 API. Each platform thread reserves about 1 MB of memory for its stack, and creating one needs a call into the operating system, which is why thread pools exist. Here doWork is the job to run:

Thread t = new Thread(() -> doWork());
t.setName("worker-1");
t.setDaemon(true);
t.start();
t.join();
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. It is .NET's IsBackground = true.

t.join();

Waits for the thread to finish, like Join().

Replace with: Thread.ofVirtual().start(...) for work that waits, or an executor. The Thread class itself is not deprecated: virtual threads are Thread objects too.

Here is the same worker thread in both languages:

C#
var t = new Thread(DoWork) { IsBackground = true, Name = "worker-1" };
t.Start();
t.Join();

// t.Abort() 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

Neither platform can kill a thread safely from outside. Ask it to stop with interrupt(), as Cancellation is interruption showed.

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()shows up in thread dumps and logs, so name your threads
PriorityThread.PrioritysetPriority()only a hint that the OS may ignore; do not rely on it
Wait for completionJoin()join()blocks until the thread ends, exactly like Join()
Kill itAbort(), removedstop(), which only throwscannot be done safely on either platform; ask it to stop instead

Fixed thread pools#

LegacyExecutors.newFixedThreadPool(n)

Before Java 21, this was the standard way to run work that waits. The size trades throughput against memory, and getting it wrong is the classic production incident: too small and requests queue, too large and memory runs out. Here tasks is a list of jobs that each load one order:

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

Runs every task and waits for all of them, returning one Future per task.

pool.shutdown();

Stops the pool accepting new work, and awaitTermination then waits up to 30 seconds for running tasks to finish.

Replace with: Executors.newVirtualThreadPerTaskExecutor() for work that waits, and delete the size. Keep a fixed pool of platform threads only for work that keeps the processor busy.

The Executors class has several factories, and they age differently:

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. In older code you will see shutdown(), awaitTermination() and shutdownNow() in a finally block instead. Forget them, and the pool's threads keep running, so the JVM never exits. That is a common cause of a build or a command-line tool that hangs after finishing its work.

wait, notify and notifyAll#

LegacyObject.wait() / notify()

The original way to wait for a condition, built into every object. It must be called while holding the object's lock, and it is famously easy to get wrong. A missed notify leaves a thread waiting forever. notify instead of notifyAll can wake the wrong waiter. And a waiting thread can wake up for no reason, so the wait must always sit in a loop. Here a worker takes the next order from a shared queue, waiting while it is empty:

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

wait and notify only work while you hold the lock of the object you wait on.

while (queue.isEmpty()) {

A loop, never an if: when the thread wakes, the queue may still be empty.

queue.wait();

Releases the lock and sleeps until another thread calls queue.notify() or notifyAll(), then takes the lock again.

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

Synchronized wrappers#

LegacyCollections.synchronizedMap and friends

These wrappers put a lock around every method. That makes each single call safe, but does nothing for a check followed by an update, so this is still a race. Here stock counts items per product, and productId names one product:

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

if (!stock.containsKey(productId)) {   // another thread can insert here
    stock.put(productId, 0);
}
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 them, another thread can insert the same key, and one thread's value then overwrites the other's.

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

java.util.Timer#

LegacyTimer and TimerTask

A Timer runs all its tasks on one thread, and a single task that throws an unchecked exception kills that thread, silently cancelling every other scheduled task. Replace with: ScheduledExecutorService, or Spring's @Scheduled. Here poll checks the payment provider for updates every 30 seconds:

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

A scheduler with one thread. Unlike Timer, it survives a task that throws.

sched.scheduleAtFixedRate(this::poll, 0, 30, TimeUnit.SECONDS);

Runs poll straight away, and then every 30 seconds.

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

ThreadLocal#

LegacyThreadLocal

Java's ThreadLocal is .NET's ThreadLocal<T>: one value per thread. It is still correct for platform threads, but it has two problems on modern Java. With virtual threads you may have a million threads, and so a million values. And in a pool, a value you forget to remove leaks into the next task that reuses the thread. Here the current user is kept while one request is handled:

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

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

Stores the user for this thread only. Code called later on the same thread reads it with CURRENT.get().

CURRENT.remove();

Clears it. In a pool, the thread goes on to serve another request, which would otherwise see this user.

Replace with: ScopedValue Java 25, which cannot be changed, lasts for one block, and cannot leak. It is the closer match to C#'s AsyncLocal. See Structured concurrency.

Before and after#

Most of these changes are small. The most common one, replacing a fixed pool with virtual threads, looks like this:

// before: a pool sized by guesswork, shut down by hand
ExecutorService pool = Executors.newFixedThreadPool(50);

// after: one virtual thread per task, closed and joined by try-with-resources
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    exec.submit(task);
}
Reading the code
ExecutorService pool = Executors.newFixedThreadPool(50);

The old pool: a guessed size, and a shutdown you must remember.

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

The new executor: no size to guess, and closing it at the end of the block waits for every task.

What to remember

Older code sizes its pools by hand with newFixedThreadPool. On Java 21 and later, use newVirtualThreadPerTaskExecutor for work that waits, and keep a fixed pool only for calculation.

wait and notify become a BlockingQueue, synchronized wrappers become ConcurrentHashMap, and Timer becomes a ScheduledExecutorService.

ThreadLocal needs remove() in a pool, and ScopedValue replaces it on virtual threads.

No thread can be killed safely from outside: interrupt it, and let it stop.

Quick reference#

If you 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: suspend is gone, and stop only throws
CompletableFuture merely to parallelise blocking callsvirtual threads

Reactive: WebFlux and Reactor#

Part P5 · Concurrency · Chapter 27· 7 min read· Quick reference

Reactor is Java's Rx.NET, and WebFlux is a Spring web stack that never blocks, like an ASP.NET Core pipeline written entirely with callbacks. If you have used IObservable or IAsyncEnumerable, the model is familiar.

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

The model#

Reactor is the library behind WebFlux. Its two types map onto .NET types you already know. Here order is an Order, and repo is a repository that can stream orders:

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>. just wraps a value you already have, like Task.FromResult.

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

A Flux is a stream of any number of values arriving over time, like IAsyncEnumerable<T> or IObservable<T>.

Flux.interval(Duration.ofSeconds(1))

Produces 0, 1, 2 and so on, one each second, like Observable.Interval.

.map(Long::intValue);

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

Combining two results shows the difference in style. C# awaits each call; Reactor combines two Monos 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(

Waits for both Monos and pairs their values. The two calls run at the same time.

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

zip pairs the two values into one object, and getT1() and getT2() read them back out.

Note

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

Reactive Java was the standard answer for years. The next section explains why that changed.

Why the case for it collapsed#

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

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: impossiblea few MB: unremarkable
So the advice wasnever block; restructure everything into callbacksblock; 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);

Blocks a virtual thread while it waits, which costs almost nothing.

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

Gotcha

Do not adopt WebFlux for throughput on a new Boot service. That decision made sense in 2018 and is usually wrong now. It costs you readable stack traces, ordinary debugging, many of the libraries that block, and a much steeper learning curve for every future maintainer. In exchange you get a scalability property that Spring's ordinary web framework, Spring Model-View-Controller (MVC), already has on virtual threads.

That leaves a smaller set of cases where reactive code still earns its place.

When it is still the right answer#

Reactive code still fits when the work really is a stream. It also fits when a library you depend on is reactive, such as the reactive database driver, Reactive Relational Database Connectivity (R2DBC):

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

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

Tells Spring to send server-sent events: a response that stays open and delivers many values over time.

.onBackpressureLatest();

If a client reads more slowly than prices change, it gets the latest price, rather than a growing backlog of old ones.

Note

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

The blocking hazard#

Gotcha

A blocking call on an event-loop thread stalls unrelated requests. WebFlux runs on Netty, a network server with a small number of event-loop threads, roughly one per processor core. Each serves many connections, switching between them instead of waiting. Block one, and every request assigned to it stops, including requests that have nothing to do with yours:

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

A blocking database call. It holds the event-loop thread until the database answers.

This is why WebFlux is all or nothing: one blocking library anywhere in the request path undoes the whole model. If you must block, move the call to a separate pool meant for blocking work:

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

Wraps the blocking call, so that it runs only when something subscribes.

.subscribeOn(Schedulers.boundedElastic());

Runs it on Reactor's pool for blocking work, which keeps the event loop free.

Add the BlockHound agent to your tests, and it fails loudly on any blocking call that reaches an event loop.

WebFlux against MVC#

Here is how Spring's two web stacks compare. Spring MVC usually reads a database through Java Database Connectivity (JDBC) or Jakarta Persistence (JPA), and WebFlux through R2DBC:

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

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

Spring MVC: a plain return type. The call blocks a virtual thread while the database answers.

Mono<Order> get(@PathVariable long id) { return reactiveRepo.findById(id); }

WebFlux: the method returns a Mono at once, and Spring subscribes to it and writes the order when it arrives.

Gotcha

There is no reactive JPA. R2DBC is a different driver, with a different API and no object-relational mapper (ORM) at all: no entity graph, no lazy loading, and no Hibernate. Choosing WebFlux for a service that mostly reads and writes a database means giving up Spring Data JPA, which usually costs far more than the thread cost being optimised away.

Reading it#

Even if you never write reactive code, you will read it, because Spring Cloud Gateway, some Kafka integrations and a fair amount of existing service code use it. Here is a typical method, which streams a customer's recent active orders. log is the class's logger:

public Flux<OrderDto> recentOrders(String customerId) {
    return repo.findByCustomer(customerId)          // Flux<Order>
            .filter(Order::isActive)
            .take(20)
            .flatMap(this::enrich)                  // returns Mono<OrderDto> each
            .onErrorResume(e -> Flux.empty())       // swallow and continue
            .timeout(Duration.ofSeconds(3))
            .doOnNext(o -> log.debug("sending {}", o.id()));
}
Reading the code
repo.findByCustomer(customerId)

A Flux of the customer's orders, delivered as the database returns them.

.flatMap(this::enrich)

Turns each order into a Mono<OrderDto> and merges the results as they complete, so they may arrive in a different order.

.onErrorResume(e -> Flux.empty())

If anything fails, sends nothing more instead of an error.

.timeout(Duration.ofSeconds(3))

Fails the stream if three seconds pass without a new element.

These operators come up most often:

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

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

repo.save(order);            // does nothing, never subscribed
return repo.save(order);     // correct
Reading the code
repo.save(order); // does nothing, never subscribed

Builds a Mono that would save the order, then throws it away. Nothing subscribes, so nothing is saved.

return repo.save(order);

Returning it lets Spring subscribe, which runs the save.

LegacyRxJava

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

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

Reactor's Mono and Flux are Task and IAsyncEnumerable: values that arrive later, transformed with map, flatMap and filter.

Virtual threads removed the main reason for reactive Java, so a new service that mostly talks to a database should use Spring MVC and block.

WebFlux still fits streaming, backpressure and very large fan-out, but one blocking call on an event loop stalls unrelated requests.

Nothing happens until something subscribes, so a Mono you build and do not return never runs.

Quick reference#

.NETReactorMeans
Task<T>Mono<T>zero or one value that arrives later, like a Task
IAsyncEnumerable<T>Flux<T>a stream of any number of values that arrive over time
IObservable<T>Flux<T>one type covers both the pull and the push styles
Task.FromResult(v)Mono.just(v)a Mono that already holds its value
Task.CompletedTaskMono.empty()a Mono that completes with no value
.Select.maptransform each value as it arrives
.SelectMany.flatMapeach value becomes a publisher, and the results merge
.Where.filterkeep only the values that match
await.block(), almost always wrongblock() waits for the value; in reactive code it stalls a shared thread
IAsyncEnumerable + ChannelFlux + backpressureReactor lets a slow consumer tell the producer to send less

The week-one gotchas#

Part P6 · Gotchas · Chapter 28· 8 min read· Quick reference

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

1. == on objects#

An incoming order names a product, and the code checks it against the catalogue. In C#, == on two strings compares their text. In Java it asks whether they are the same object:

C#
var a = "mug";
var b = ReadProductId();      // also "mug"

bool same = a == b;           // True: string == compares the text
bool equal = a.Equals(b);     // True
Java 25
String a = "mug";
String b = readProductId();   // also "mug"

boolean same = a == b;               // false: compares references
boolean equal = a.equals(b);         // true
boolean safe = Objects.equals(a, b); // true, and null-safe
Reading the code
boolean same = a == b;

b was built while the program ran, so it is a different object from the literal, and == is false even though both say mug.

Objects.equals(a, b)

Compares the text, and is safe if either side is null.

Gotcha

== compares references for every object type, including String. It often appears to work, because Java reuses one object for identical string literals, and then fails on a string built while the program runs. Use equals, or Objects.equals(a, b) when either side may be null. See Equality and hashing.

2. The Integer cache#

Gotcha

With Integer a = 127, b = 127;, a == b is true. With 128 it is false. Java shares one object for each boxed integer from −128 to 127, so tests with small numbers pass and production fails. Here two order lines store their quantities as Integer:

Integer a = 127, b = 127;
System.out.println(a == b);        // true: both come from the cache
Integer c = 128, d = 128;
System.out.println(c == d);        // false: two separate objects
System.out.println(c.equals(d));   // true: compare values with equals
Reading the code
System.out.println(a == b);

127 is inside the cache, so a and b are the same object.

System.out.println(c == d);

128 is outside it, so c and d are two objects, and == compares objects.

3. Auto-unboxing throws NullPointerException#

The shop counts orders per product in a map, and one product has no orders yet. Reading a missing key fails in both languages, but in different ways:

C#
var counts = new Dictionary<string, int>();
int n = counts["missing"];     // KeyNotFoundException
Java 25
Map<String, Integer> counts = new HashMap<>();
int n = counts.get("missing"); // NullPointerException, not 0
Reading the code
int n = counts.get("missing");

get returns null for a missing key. Assigning it to an int unboxes it, which throws NullPointerException.

Gotcha

A null Integer turns into a NullPointerException when it is unboxed, on a line with no visible method call. C#'s KeyNotFoundException is a clearer failure. In Java, use getOrDefault(key, 0).

4. No modifier means package-private#

Gotcha

In C#, a member with no access modifier is private. In Java it is package-private: visible to the whole package. Your habit is the wrong way round, and nothing warns you. See Access modifiers and packages.

public class Account {
    BigDecimal balance;        // package-private: any class in the package can write it
    private BigDecimal fee;    // what a C# field with no modifier means
}
Reading the code
BigDecimal balance;

No modifier, so any class in the same package can read and change it.

private BigDecimal fee;

What a C# field with no modifier means.

5. protected is wider than you think#

Gotcha

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

// com/acme/billing/Invoice.java
public class Invoice { protected BigDecimal total; }

// com/acme/billing/Auditor.java: same package, not a subclass
class Auditor {
    void reset(Invoice i) { i.total = BigDecimal.ZERO; }   // compiles
}
Reading the code
protected BigDecimal total;

Visible to subclasses, and also to every class in the same package.

void reset(Invoice i) { i.total = BigDecimal.ZERO; }

Auditor is not a subclass, but it is in the same package, so it can change total.

6. Methods are virtual by default#

Gotcha

C# opts in with virtual; Java opts out with final. Any public method you write can be overridden unless you say otherwise. That matters most when a constructor calls an overridable method, because the subclass's override then runs before the subclass's fields are set:

class Discount {
    Discount() { describe(); }        // calls the override...
    void describe() { }
}
class SeasonalDiscount extends Discount {
    private String season = "winter sale";
    @Override void describe() {
        System.out.println(season);   // prints null: season is not set yet
    }
}
Reading the code
Discount() { describe(); }

The base constructor calls describe, which SeasonalDiscount overrides.

System.out.println(season);

Runs while Discount's constructor is still going, before SeasonalDiscount has set season, so it prints null.

A final field set to a constant, such as private final String season = "winter sale", would print the text instead, because the compiler copies constants into the code that uses them. That hides the bug rather than fixing it.

7. Arrays are covariant and it is unsound#

Gotcha

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;

Storing an Integer in what is really a String array is caught only when the line runs, with ArrayStoreException.

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

8. Inner classes capture the outer instance#

Gotcha

A C# nested class is always independent. In Java, a nested class is independent only if you write static. A class nested without it, an inner class, holds a hidden reference to the object that created it. So it cannot be created without one, and it keeps that object alive:

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

new Outer().new Inner();      // the bizarre syntax this forces
new Outer.Nested();           // normal
Reading the code
class Inner { }

An inner class. Each Inner object holds a hidden reference to the Outer object that created it.

new Outer().new Inner();

So creating one needs an Outer first, with this unusual syntax.

static class Nested { }

static makes it independent, like every nested class in C#.

Default to static on every nested class unless you want the outer reference on purpose. Forgetting it is a common cause of memory leaks, especially in listeners and callbacks.

9. Integer division and overflow#

Gotcha

5 / 2 is 2 in both languages. But Java has no checked block: integer overflow always wraps silently, and you cannot switch on throwing. Use Math.addExact, multiplyExact and friends when overflow would be a bug; they throw ArithmeticException.

Java also has no unsigned types: no uint and no ulong. Use the static helpers, such as Integer.divideUnsigned and Long.compareUnsigned.

int big = Integer.MAX_VALUE;
int wrapped = big + 1;               // -2147483648: wraps silently
int safe = Math.addExact(big, 1);    // throws ArithmeticException: integer overflow
Reading the code
int wrapped = big + 1;

Wraps round to the most negative int, with no error at all.

Math.addExact(big, 1)

Adds, and throws ArithmeticException if the result does not fit, like C#'s checked.

C# refresherchecked and unchecked: C#'s overflow control
int big = int.MaxValue;
int wrapped = unchecked(big + 1);    // -2147483648
int safe = checked(big + 1);         // throws OverflowException

10. Checked exceptions do not fit in lambdas#

Gotcha

This is the gotcha Java developers complain about most. A lambda passed to map cannot throw a checked exception, because the interface map expects declares none. Here files is a List<Path>, and Files.readString, from java.nio.file, reads a whole file as text:

List<String> texts = files.stream()
     .map(f -> Files.readString(f))   // does not compile: IOException
     .toList();
Reading the code
.map(f -> Files.readString(f))

readString can throw IOException, a checked exception, which the lambda is not allowed to throw.

You must catch it and throw an unchecked exception instead, which is ugly and universal:

List<String> texts = files.stream()
     .map(f -> {
         try { return Files.readString(f); }
         catch (IOException e) { throw new UncheckedIOException(e); }
     })
     .toList();
Reading the code
catch (IOException e) { throw new UncheckedIOException(e); }

Wraps the IOException in an UncheckedIOException, which a lambda may throw.

See Exceptions and resources.

11. switch on a reference type throws on null#

Gotcha

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

String s = null;
switch (s) {                  // NullPointerException here
    case "a" -> run();
    default  -> skip();
}

switch (s) {
    case null -> skip();      // Java 21: handle null as a case
    case "a"  -> run();
    default   -> skip();
}
Reading the code
switch (s) { // NullPointerException here

The first switch throws as soon as it sees the null, before it looks at any case.

case null -> skip();

From Java 21, null can be a case of its own, and then the switch no longer throws.

12. Date and SimpleDateFormat are traps#

Gotcha

java.util.Date can be changed after it is created, Calendar numbers months from zero, and SimpleDateFormat is not thread-safe. A shared static instance produces silently wrong dates when several threads use it. Use java.time only. See Numbers, money and time.

// shared across request threads: wrong dates under load
static final SimpleDateFormat OLD = new SimpleDateFormat("yyyy-MM-dd");

// immutable and thread-safe: share it freely
static final DateTimeFormatter ISO = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String today = LocalDate.now().format(ISO);
Reading the code
static final SimpleDateFormat OLD

One formatter shared by every thread. SimpleDateFormat keeps working state inside it, so two threads formatting at once corrupt each other's dates.

DateTimeFormatter.ofPattern("yyyy-MM-dd")

The java.time formatter cannot change, so one shared instance is safe.

13. There is no decimal#

Java has no built-in decimal type. An order total adds up exactly with C#'s decimal, and goes wrong with Java's double. Here limit is a BigDecimal spending limit:

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

A double cannot hold 0.1 exactly, so the sum is slightly off.

new BigDecimal("0.1").add(new BigDecimal("0.2"))

BigDecimal stores decimal digits exactly. Build it from a string, and use add instead of +.

right.compareTo(limit) > 0

BigDecimal has no > operator. compareTo returns a negative number, zero or a positive number.

Gotcha

Money in Java is BigDecimal, an object with no operator overloading, so arithmetic becomes method calls and comparison becomes compareTo. Using double for money is the most common correctness bug a C# developer brings to Java.

14. Views are not copies#

Gotcha

subList, keySet, values, entrySet, Arrays.asList and reversed() all return views of the original. Changing the view changes the source, and changing the source can break the view:

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

Arrays.asList is a view of an array, so set writes through to it.

fixed.add(4);

An array cannot grow, so neither can the view: add throws UnsupportedOperationException.

15. finally can swallow exceptions#

Gotcha

A return or throw inside finally silently discards whatever was on its way out of try or catch. C# refuses to compile a return from a finally block. Java allows it, and warns only when you compile with -Xlint:finally. Never return from finally.

int load() {
    try {
        throw new IllegalStateException("real problem");
    } finally {
        return 0;      // compiles; the exception is silently discarded
    }
}
Reading the code
throw new IllegalStateException("real problem");

The real failure starts on its way out of the method.

return 0;

finally runs next, and its return replaces the exception with 0. The caller never learns that anything went wrong.

What to remember

Compare objects with equals: == compares references, even for strings, and for Integers above 127.

A null Integer throws when it is unboxed, and a switch on null throws unless it has case null.

No modifier means package-private, protected includes the package, and every method can be overridden unless it is final.

Use BigDecimal for money, java.time for dates, and Math.addExact where overflow matters.

Checked exceptions do not fit in lambdas, and a return in finally swallows exceptions.

Quick reference#

C#JavaNote
a == b on stringsa.equals(b)== compares references in Java, even for String
counts["missing"]counts.getOrDefault("missing", 0)get returns null, which throws when unboxed to int
a field with no modifiera private fieldJava's default is package-private, not private
checked(a + b)Math.addExact(a, b)Java overflow wraps silently unless you ask it to throw
decimalBigDecimalno operators: use add, multiply and compareTo instead

Numbers, money and time#

Part P6 · Gotchas · Chapter 29· 7 min read· Quick reference

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

Primitive types#

Most number types carry over with the same names. The differences are at the edges: Java's byte is signed, there are no unsigned types apart from char, and there is no decimal. Here are the same values in both languages:

C#
byte b = 200;                  // unsigned: 0 to 255
sbyte sb = -100;               // signed
uint u = 3_000_000_000;
ulong max = ulong.MaxValue;
decimal price = 19.99m;
bool ok = true;
Java 25
byte b = (byte) 200;           // signed: stored as -56
int unsigned = b & 0xFF;       // 200 again
long u = 3_000_000_000L;       // no uint: use a wider type
String max = Long.toUnsignedString(-1L);   // "18446744073709551615"
BigDecimal price = new BigDecimal("19.99");
boolean ok = true;
Reading the code
byte b = (byte) 200;

200 does not fit in Java's signed byte, so the cast wraps it round to −56.

int unsigned = b & 0xFF;

Masking with 0xFF reads the same eight bits as an unsigned number: 200 again.

long u = 3_000_000_000L;

Java has no uint, so a value above about two billion needs a long. The L marks a long literal.

Long.toUnsignedString(-1L)

Reads the 64 bits of a long as an unsigned number, which is how Java handles values the size of a ulong.

BigDecimal price = new BigDecimal("19.99");

There is no decimal. Money is a BigDecimal, covered below.

Gotcha

Java's byte is signed, from −128 to 127. C#'s byte is unsigned, and sbyte is the signed one. So when Java code reads binary data written by C# and prints a byte, it can show a negative number. Mask with b & 0xFF to get the unsigned value.

The quick reference at the end of this chapter lists every C# number type with its Java equivalent.

Wrapper types#

Each primitive type has an object version, called a wrapper type, which Java uses wherever an object is needed, such as in a List<Integer>. The wrapper for int is Integer, and for char it is Character; the others are the type's name with a capital letter, such as Long and Boolean. The quick reference lists them all.

Autoboxing converts between the two implicitly. That is convenient, and it is the source of two bugs already covered: the Integer cache and the exception on unboxing a null.

Integer boxed = 42;          // autoboxing: Integer.valueOf(42)
int plain = boxed;           // unboxing: boxed.intValue()

Integer missing = null;
int oops = missing;          // NullPointerException: nothing to unbox
Reading the code
Integer boxed = 42;

Autoboxing: the compiler writes Integer.valueOf(42) for you.

int plain = boxed;

Unboxing: the compiler writes boxed.intValue().

int oops = missing;

Unboxing null has no number to take, so it throws NullPointerException.

Money is where the missing decimal hurts, so it comes next.

Money is BigDecimal#

An order of three mugs at 19.99 each, plus shipping, must add up to the exact amount, and then be checked against a spending limit. In C# that is decimal arithmetic. In Java it is BigDecimal, with methods instead of operators. Here shipping and limit are amounts of the same type:

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

Creates the price from the text "19.99". Building it from text keeps the value exact; the traps below show what goes wrong with a double.

price.multiply(BigDecimal.valueOf(3))

Java has no * for objects, so you call multiply. It accepts only another BigDecimal, so BigDecimal.valueOf(3) turns the whole number 3 into one first.

.add(shipping)

Adds the shipping charge, which is also a BigDecimal. Each call returns a new value, because a BigDecimal never changes once it is created.

total.compareTo(limit) > 0

Java has no > for objects either. compareTo returns a negative number, zero or a positive number, so > 0 means the total is larger than the limit.

NumberFormat.getCurrencyInstance().format(total)

Formats the amount as money for the computer's current locale, as ToString("C") does in C#. NumberFormat lives in the java.text package.

Every operator you would use on a decimal has a method:

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

Three traps, all of which produce wrong money:

  • new BigDecimal(0.1) is wrong. The double 0.1 is already imprecise, so you get 0.1000000000000000055511151231257827021181583404541015625. Always use the String constructor, or BigDecimal.valueOf(double), which goes through Double.toString.
  • 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 avoid BigDecimal entirely by storing money as a long count of the smallest unit, such as cents, and formatting it only for display. That is a legitimate design, avoids all three traps, and is what many payment systems do.

Dates and times are the other everyday value that Java handles differently.

java.time#

An invoice is due 30 days after it is issued, and the shop keeps the exact moment each payment arrives. Those are different kinds of time, and java.time Java 8 gives each its own type. If you know Noda Time, you know this API: Jon Skeet based Noda Time on Joda-Time, and java.time is Joda-Time's successor, led by the same author. It separates the ideas that DateTime mixes together.

C# refresherNodaTime: separate types for instants and dates
Instant now = SystemClock.Instance.GetCurrentInstant();
LocalDate due = LocalDate.FromDateTime(DateTime.Today).PlusDays(30);
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

Here are the common operations side by side:

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

An exact point on the timeline, in UTC, like DateTimeOffset.UtcNow.

now.plus(30, ChronoUnit.DAYS)

Adds 30 days. An Instant has no calendar or time zone, so each day is exactly 24 hours.

LocalDate.now()

Today's date, with no time and no time zone, like DateOnly.

Duration.between(now, due)

The time between two instants, like subtracting two DateTimeOffsets to get a TimeSpan.

Note

All java.time types are immutable and thread-safe, including DateTimeFormatter. Every method that changes a value returns a new one: plusDays, withYear, truncatedTo. That is the same model as .NET's DateTime, so it should feel natural.

Gotcha

Duration and Period are not interchangeable. Duration is exact time, in seconds and nanoseconds. Period is calendar time, in years, months and days. The difference shows across a daylight-saving change. In London, the clocks go forward on 29 March 2026:

ZoneId london = ZoneId.of("Europe/London");
ZonedDateTime noon = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, london);

noon.plus(Period.ofDays(1));     // 2026-03-29T12:00+01:00: noon the next day
noon.plus(Duration.ofHours(24)); // 2026-03-29T13:00+01:00: exactly 24 hours later
Reading the code
noon.plus(Period.ofDays(1));

A Period counts calendar days, so the result is noon the next day, even though that day is only 23 hours long.

noon.plus(Duration.ofHours(24));

A Duration counts exact time, so 24 hours later is 1 pm, because the clocks went forward in between.

The rest of this chapter covers the old date classes you will still meet, and formatting numbers.

The old date API#

LegacyDate, Calendar and SimpleDateFormat

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

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

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

Convert at the boundary, where your code meets an old library. Here oldDate is a java.util.Date from that library, and instant is an Instant:

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

A Date is really an instant, so it converts directly.

Date.from(instant)

The other direction, for an old API that still wants a Date.

.atZone(ZoneId.systemDefault()).toLocalDate()

A date only makes sense in a time zone, so you choose one before asking for the LocalDate.

Formatting numbers#

Formatting and parsing numbers look like this, where text holds a number typed by a customer:

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");
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");
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, a comma between thousands and a dot before the decimals, whatever the server's settings.

NumberFormat.getPercentInstance(Locale.UK).format(0.25)

A percentage, in British format.

try { v = Integer.parseInt(text); } catch (NumberFormatException e) { v = 0; }

Java has no TryParse, so you catch the exception that parseInt throws for bad input.

Gotcha

Integer.parseInt does not depend on the locale, but NumberFormat and String.format do: they use the default locale unless you pass one. A server set to a locale that uses a comma as the decimal separator formats 1.5 as 1,5. Pass Locale.ROOT explicitly for anything another program will read.

What to remember

Java's number types match C#'s, except that byte is signed, there are no unsigned types apart from char, and there is no decimal.

The order total used BigDecimal: build it from a string, use add, multiply and compareTo, and always give divide a scale and a rounding mode.

java.time separates an Instant, a LocalDate and a ZonedDateTime, and a Period differs from a Duration across a daylight-saving change.

Avoid Date, Calendar and SimpleDateFormat, and pass a Locale when formatting for other programs.

Quick reference#

C#JavaSizeNote
sbytebyte8-bitJava's byte is signed, from -128 to 127, like C#'s sbyte
byteno equivalentno unsigned byte; widen to int with b & 0xFF when reading bytes
shortshort16-bit
ushortchar16-bitchar is the only unsigned type
intint32-bit
uintno equivalentuse long, or Integer.*Unsigned helpers
longlong64-bit
ulongno equivalentno ulong; Long.toUnsignedString and friends treat a long as unsigned
floatfloat32-bit
doubledouble64-bit
decimalno equivalentno decimal type; money is a BigDecimal object, covered below
boolbooleanthe same true or false, spelled boolean in Java
charchar16-bita UTF-16 code unit in both, so an emoji takes two chars
nint / nuintno equivalentnative-sized integers; Java has none, so use int or long
PrimitiveWrapperC# analogy
intIntegerint? (roughly)
longLonglong?
doubleDoubledouble?
booleanBooleanbool?
charCharacterchar?
C#Java
x.ToString("N2")String.format("%,.2f", x)
x.ToString("C")NumberFormat.getCurrencyInstance().format(x)
x.ToString("P")NumberFormat.getPercentInstance().format(x)
x.ToString("X")Integer.toHexString(x)
int.Parse(s)Integer.parseInt(s)
int.TryParse(s, out v)catch NumberFormatException
double.Parse(s, CultureInfo.Invariant)Double.parseDouble(s), always invariant

Exceptions and resources#

Part P6 · Gotchas · Chapter 30· 7 min read· Quick reference

One structural difference: checked exceptions. Java's compiler makes you declare or catch certain exception types. C# has nothing like it; its designers looked at the feature and chose not to copy it.

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

The hierarchy#

Every exception type sits in one family tree, and where a type sits decides whether it is checked:

Throwable
  ├─ Error                    unchecked. JVM problems, do not catch
  │    ├─ OutOfMemoryError
  │    └─ StackOverflowError
  └─ Exception                CHECKED by default
       ├─ IOException         checked
       ├─ SQLException        checked
       └─ RuntimeException    unchecked
            ├─ NullPointerException
            ├─ IllegalArgumentException
            ├─ IllegalStateException
            └─ ...

Anything under Error or RuntimeException is unchecked, like every C# exception. Everything else under Exception, such as IOException and SQLException, is checked: the compiler makes you declare it or catch it.

RuleMeaning
Extends RuntimeException or Errorunchecked: like every C# exception
Extends Exception, not RuntimeExceptionchecked, must be declared or caught

Checked exceptions#

Reading the day's order file can fail, because the file may be missing. In C#, nothing in the method's signature says so. In Java, IOException is a checked exception, so the method must declare it with throws, or catch it:

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

Declares that read may throw IOException. Every caller must now catch it, or declare it in turn.

// may throw; nothing to declare

C# has no such clause: any method may throw anything, and the compiler does not check.

You have two options, and each has a cost. Here p is the path to the order file:

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

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

Option 1 passes the obligation up: every caller now has to deal with IOException.

throw new UncheckedIOException(e);

Option 2 wraps it in an unchecked exception, so the obligation stops here. The original exception travels inside it, as its cause.

Note

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

Keep checked exceptions for expected conditions that a caller can really recover from, in a library API. Propagate them one or two layers at most.

Gotcha

Always pass the cause when wrapping. Write throw new RuntimeException(e), not throw new RuntimeException(e.getMessage()). Dropping the cause throws away the original stack trace, and the resulting production incident cannot be diagnosed from the logs. This is the same as passing the inner exception in C#'s throw new X("...", inner).

Checked exceptions and lambdas#

Gotcha

The functional interfaces in java.util.function declare no checked exceptions, so no lambda passed to map, forEach or Optional.map may throw one. This is the most complained-about corner of modern Java. Here paths is a list of files to read:

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

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

Files::readString can throw IOException, which the function behind map does not allow, so this line does not compile.

catch (IOException e) { throw new UncheckedIOException(e); }

The usual workaround: catch it inside the lambda, and rethrow it unchecked.

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

Most everyday exceptions have a close match in the other language, so throwing and catching them is mostly a matter of names.

Exception mapping#

Guard clauses translate almost line for line. Here order is an order, qty a quantity, and closed says whether the order is already closed:

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

Java's ThrowIfNull. It throws NullPointerException, not an argument exception, which 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 is not an exception in Java: get returns null.

The quick reference at the end of this chapter maps every common .NET exception to its Java equivalent.

try, catch, finally#

Here is a try block that logs a failure, rethrows it, and always cleans up. doIt can throw either an IOException or an SQLException:

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

One catch for two types, joined with | where C# writes or.

throw e;

Rethrows the same exception object, so its stack trace is kept, like C#'s bare throw.

C# refresherException filters: catch ... when

A filter decides whether a catch block applies at all. An exception it rejects is never caught, so it keeps propagating with its stack intact.

try { await CallAsync(); }
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    return null;              // only a 404 is caught; anything else propagates
}

Java has no exception filters. You catch, test, and rethrow whatever you did not want. Here insert saves an order, and duplicate handles an order number that already exists:

try { insert(order); }
catch (SQLException e) {
    if (!"23505".equals(e.getSQLState())) throw e;   // no filter: rethrow the rest
    return duplicate(order);                          // only a unique-key violation
}
Reading the code
if (!"23505".equals(e.getSQLState())) throw e;

23505 is the standard SQL code for a unique-key violation. Anything else is rethrown unchanged.

Gotcha

Never return from a finally block. It silently discards any exception on its way out of try or catch. C# makes this a compile error; Java allows it.

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

Replaces the exception that was on its way out, so the caller gets 0 and no sign of the failure.

try-with-resources#

Closing a database connection and a statement when you have finished with them works like using. Here dataSource hands out connections, and sql is an update statement:

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

Whatever is declared in the brackets is closed when the block ends, even if an exception is thrown.

var stmt = conn.prepareStatement(sql)) {

A second resource, after a semicolon. The statement is closed first, then the connection: the reverse of the order they were opened in.

C# refresherIDisposable and IAsyncDisposable
public sealed class Conn : IDisposable, IAsyncDisposable
{
    public void Dispose() { /* release now */ }
    public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

using var c = new Conn();          // Dispose() at the end of the scope
await using var d = new Conn();    // DisposeAsync() at the end of the scope

Your own class takes part by implementing AutoCloseable, Java's IDisposable:

public final class Conn implements AutoCloseable {
    @Override public void close() { /* release now */ }   // no throws: callers need no catch
}

try (var c = new Conn()) {
    use(c);
}                                  // close() runs here, even on an exception
Reading the code
public final class Conn implements AutoCloseable {

AutoCloseable has one method, close, like Dispose.

try (var c = new Conn()) {

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

Note

AutoCloseable.close() declares throws Exception, and Closeable narrows it to IOException. When your cleanup cannot fail, implement AutoCloseable and override close() without a throws clause. That spares every caller a catch block.

Gotcha

If the body throws and close() throws, Java keeps the body's exception as the main one and attaches the close failure to it as a suppressed exception, which getSuppressed() returns. C# loses the original exception in the same situation. It is worth knowing when a stack trace shows a "Suppressed:" section.

Cleanup without a using block#

Legacyfinalize()

Object.finalize() has been deprecated for removal deprecated since Java 18. It is unpredictable, can bring dead objects back to life, delays garbage collection, and may never run at all. Do not override it, and do not follow older guides that recommend it for releasing native resources.

The supported mechanism for a last-resort cleanup is Cleaner Java 9, roughly a SafeHandle plus a finalizer queue. As in .NET, the normal path should still be closing the object explicitly. Here allocate and free stand for native calls that reserve and release memory:

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

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

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

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

    @Override public void close() { cleanable.clean(); }   // deterministic path
}
Reading the code
CLEANER.register(this, state)

Asks the Cleaner to run state once this Buffer is no longer reachable, if nobody has closed it by then.

private record State(long handle) implements Runnable {

The cleanup action. A nested record is never an inner class, so it holds no hidden reference to the Buffer. Such a reference would keep the Buffer reachable forever.

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

The normal path: close runs the cleanup now, and it will not run again later.

Note

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

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

Java's compiler makes you declare or catch checked exceptions such as IOException. Application code usually wraps them in an unchecked exception, and always keeps the cause.

Checked exceptions do not fit in lambdas, so catch them inside the lambda and rethrow them unchecked.

Most C# exceptions have a close Java match: ArgumentException is IllegalArgumentException, and InvalidOperationException is IllegalStateException.

Try-with-resources is using, AutoCloseable is IDisposable, and a failing close is attached to the main exception as a suppressed one.

Never override finalize. Close objects with try-with-resources, and use a Cleaner only as a safety net.

Quick reference#

C#Java
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
C#JavaNote
catch (A or B)catch (A | B e)one catch for several types, Java 7
throw;throw e;Java rethrows the same object, so the trace is preserved
when (filter)no equivalentfilter inside the catch and rethrow
finallyfinallyidentical: runs whether or not the try block threw
usingtry-with-resourcesthe block form of using, shown in the next section
C#Java
IDisposableAutoCloseable
IAsyncDisposableno equivalent
Dispose()close()
using statementtry-with-resources
using declarationno equivalent, always a block

Maven vs csproj#

Part P7 · Build and tooling · Chapter 31· 7 min read· Quick reference

Maven is not MSBuild. MSBuild is a general build engine that you script. Maven is a fixed sequence of build steps, driven by conventions, that you configure: you tell it what your project is, not how to build it.

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

The standard layout#

The shop's billing service is a typical Maven project. Maven expects every project to use the same folders, and its conventions are the point: go against them, and you pay for it in configuration everywhere. Here is the standard layout:

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

src/main/java holds the code that ships, and src/test/java holds the tests. src/main/resources holds files such as application.yml, which are packaged into the Java archive (JAR) alongside the compiled classes. mvnw is the Maven Wrapper, explained later in this chapter.

Gotcha

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

The file that describes the project sits at the top of that tree.

pom.xml against csproj#

Both files below describe the same kind of project: its identity, the platform version it targets, and one logging library it depends on. The Maven file is the Project Object Model (POM), pom.xml:

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

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

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

  <dependencies>
    <dependency>
      <groupId>org.slf4j</groupId>
      <artifactId>slf4j-api</artifactId>
      <version>2.0.16</version>
    </dependency>
  </dependencies>
</project>
Reading the code
<groupId>com.acme</groupId>

Who publishes the project, written as a reversed domain name, like a package name.

<artifactId>billing</artifactId>

The project's own name. groupId and artifactId together play the part of a PackageId.

<version>1.0.0-SNAPSHOT</version>

SNAPSHOT marks a version that is still in development. Maven may fetch a newer build of a snapshot, but never of a released version.

<maven.compiler.release>25</maven.compiler.release>

The Java release to compile for, like TargetFramework.

<artifactId>slf4j-api</artifactId>

A dependency, named by its group, artifact and version, like a PackageReference.

The quick reference at the end of this chapter maps each csproj setting to its place in pom.xml.

Coordinates#

A NuGet package has one name: Serilog. A Maven artifact has two, plus a version, and together they are called its coordinates:

<dependency>
  <groupId>org.springframework.boot</groupId>      <!-- who publishes it -->
  <artifactId>spring-boot-starter-webmvc</artifactId>  <!-- which artifact -->
  <version>4.0.0</version>
  <scope>compile</scope>                            <!-- optional -->
</dependency>
Reading the code
<groupId>org.springframework.boot</groupId>

The publisher, here the Spring Boot project.

<scope>compile</scope>

When the dependency is available. compile, the default, means everywhere, like an ordinary PackageReference. The import scope is only for importing a bill of materials (BOM), explained later in this chapter.

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

The lifecycle#

Running a phase runs every phase before it. This is the idea MSBuild has no equivalent for, and once it clicks, the command line makes sense.

validate compile test package verify install deploy mvn package runs every phase up to and including package
The Maven lifecycle: validate, compile, test, package, verify, install, deploy. Running a phase runs every phase before it, so mvn package also compiles and tests.

So each command below does everything the one above it does, and more:

./mvnw test       # validate, compile, test
./mvnw package    # all of that, then builds the JAR in target/
./mvnw install    # all of that, then copies the JAR into ~/.m2/repository
Reading the code
./mvnw package

Runs validate, compile and test, then builds the JAR in the target folder.

./mvnw install

All of that, then copies the JAR into the local repository in ~/.m2, where other projects on the same machine can use it.

Note

mvn clean package is the command you will type a thousand times. It is Java's full rebuild, and running clean is far more common than with dotnet build, because Maven does less incremental work.

Here are three .NET commands you will reach for on day one, with their Maven versions:

dotnet test                                  # ./mvnw test
dotnet add package Serilog                   # no command: edit pom.xml by hand
dotnet list package --include-transitive     # ./mvnw dependency:tree

There is no command to add a dependency: you add a dependency element to pom.xml by hand, and Maven downloads it on the next build. The quick reference lists the other everyday commands.

The wrapper#

Commit mvnw, mvnw.cmd and the .mvn/ folder to your repository. Together they fix which Maven version the project uses, and download it on first use. So the build server and every developer build with the same Maven. The nearest .NET equivalent is a global.json that fixes the SDK version.

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

-o works offline, using only what is already in ~/.m2.

./mvnw -pl billing-api -am package

-pl picks one module of a multi-module build, and -am also builds the modules it depends on.

Most Java services are Spring Boot applications, and Boot changes what a pom looks like.

A Spring Boot pom#

Spring Boot supplies a parent POM that sets compatible versions for several hundred libraries, so your dependencies mostly leave out <version>. It is the biggest single convenience in the Java build world. Here is the start of a Boot web service's pom.xml:

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

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

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

In both versions, the parent element makes Boot's POM the parent of yours, so the two starters need no version. A starter is a dependency that brings a whole feature with it: the web starter brings everything a Spring web application needs, and spring-boot-starter-test brings the test libraries. The only difference between the tabs is the web starter's name.

Gotcha

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

Profiles and BOMs#

Two everyday Maven mechanisms have no single csproj equivalent.

A profile is a named block of configuration, switched on by a flag, a property or the environment. It is how one pom builds differently for the build server, for a GraalVM native image, or for a particular database. Here a profile runs the integration tests:

<profiles>
  <profile>
    <id>integration</id>
    <activation>
      <property><name>env.CI</name></property>   <!-- on when $CI is set -->
    </activation>
    <build><plugins>
      <plugin>
        <artifactId>maven-failsafe-plugin</artifactId>
        <executions><execution><goals>
          <goal>integration-test</goal><goal>verify</goal>
        </goals></execution></executions>
      </plugin>
    </plugins></build>
  </profile>
</profiles>
Reading the code
<id>integration</id>

The profile's name, used with -P on the command line.

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

Switches the profile on automatically when the CI environment variable is set, as it is on most build servers.

<artifactId>maven-failsafe-plugin</artifactId>

The plugin that runs integration tests, as part of the verify phase.

You can also switch a profile on by hand, and check which ones are on:

./mvnw -Pintegration verify      # activate explicitly
./mvnw help:active-profiles      # which are actually on
Reading the code
./mvnw -Pintegration verify

-P switches a profile on by name.

./mvnw help:active-profiles

Lists the profiles that are really on, which is the first thing to check when a profile seems to be ignored.

Gotcha

Do not use profiles to build a different artifact for each environment. The point of a deployable JAR is that the same bytes go to every environment, and only the configuration differs, through Spring profiles and environment variables. Maven profiles are for varying the build, not the product.

A BOM, or bill of materials, is a pom that publishes nothing but a set of compatible versions. Importing one sets the versions of a whole family of libraries at once, which is what spring-boot-starter-parent does for you, and what Directory.Packages.props does in .NET:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>4.0.0</version>
      <type>pom</type>
      <scope>import</scope>          <!-- import, not inherit -->
    </dependency>
  </dependencies>
</dependencyManagement>
Reading the code
<type>pom</type>

This dependency is a POM, not a JAR.

<scope>import</scope>

Copies that POM's managed versions into yours, so your own dependencies can leave out their versions.

Note

scope=import with type=pom is the form to remember. Use it when you cannot inherit from spring-boot-starter-parent, which is common in a multi-module build that already has its own parent. It gives you the managed versions without the parent's plugin configuration.

Dependency conflicts#

When two libraries need different versions of a third, Maven picks the declaration nearest to your project in the dependency tree, not the highest version. Faced with two requested versions, NuGet takes the higher one, so this surprises people. The dependency:tree goal shows who brings in what. Here it is for the whole project, and then for one library:

./mvnw dependency:tree
./mvnw dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind
Reading the code
./mvnw dependency:tree

Prints every dependency, and the dependencies they bring in, as a tree.

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

Shows only the paths that lead to jackson-databind, which is how you find who brought in an unexpected version.

To force a version, declare the library directly in your own <dependencies>, because a direct declaration is always nearest, or set it in <dependencyManagement>.

Gotcha

“Nearest wins” means that adding an unrelated dependency can silently downgrade one of its neighbours' dependencies. If something breaks after you add a library, run dependency:tree before anything else.

What to remember

The billing service showed Maven's fixed layout: code in src/main/java, and tests in the same packages under src/test/java.

pom.xml names the project with groupId, artifactId and version, and lists dependencies by the same three coordinates.

Running a lifecycle phase runs every phase before it, so ./mvnw clean package compiles, tests and builds the JAR.

Commit the wrapper, let Spring Boot's parent POM or a BOM supply versions, and run dependency:tree when a version surprises you, because the nearest declaration wins.

Quick reference#

csprojpom.xmlNote
PackageIdgroupId + artifactIdan org prefix plus a name, so vendors never collide
VersionversionSNAPSHOT means "in development"
TargetFrameworkmaven.compiler.releasethe Java release to compile for, as TargetFramework names the .NET one
PackageReferencedependencya dependency element, identified by group, artifact and version
ProjectReferencedependency on a sibling modulewritten exactly like any other dependency, by coordinates
Directory.Build.propsa parent POMchildren inherit from it; it is not textually included
Directory.Packages.propsdependencyManagementsets versions centrally, so child modules can leave them out
nuget.configsettings.xmllives in ~/.m2; lists repositories, mirrors and credentials
dotnet restoreno separate stepMaven downloads what it needs during the build
Task.NETMaven
Compiledotnet buildmvn compile
Run testsdotnet testmvn test
Build a JAR / dlldotnet buildmvn package
Skip testsdotnet buildmvn package -DskipTests
Cleandotnet cleanmvn clean
Install locallydotnet pack + local feedmvn install
Publish to a repodotnet nuget pushmvn deploy
Run the appdotnet runmvn spring-boot:run
Dependency treedotnet list package --include-transitivemvn dependency:tree
Check for updatesdotnet outdatedmvn versions:display-dependency-updates

Gradle and multi-module builds#

Part P7 · Build and tooling · Chapter 32· 4 min read· Quick reference

Gradle is Java's other build tool. Its build files are programs, written in Kotlin or Groovy, rather than an XML document like Maven's. That makes it more powerful, and easier to make a mess with. It is also much faster on large builds, because it redoes only the work that changed and can reuse results from a shared cache.

A multi-module build, called a reactor build in Maven and a multi-project build in Gradle, is your solution file: several modules built together, with dependencies between them.

Gradle against Maven#

The two tools make different trade-offs:

AspectMavenGradle
FormatXML, declarativeKotlin or Groovy DSL, imperative
Learning curveshallow; conventions do the worksteeper; more rope
Speed on big buildsslowermuch faster: incremental + build cache
Customisationwrite or find a pluginwrite code inline
Predictabilityvery highdepends on the author
Ecosystem defaultenterprise, Spring tutorialsAndroid, newer projects

“Write code inline” is meant literally. A custom build step in Gradle is a few lines of Kotlin, where Maven needs a plugin:

// build.gradle.kts: a custom task is ordinary code
tasks.register("printVersion") {
    doLast { println("version ${project.version}") }
}
Reading the code
tasks.register("printVersion")

Adds a new build step, called a task, named printVersion. You run it with ./gradlew printVersion.

doLast { println("version ${project.version}") }

The code the task runs: it prints the project's version.

Note

If you are choosing: pick Maven. Its rigidity is a strength on a team, the Spring documentation assumes it, and a Maven build written by someone else is always readable. Choose Gradle when build times really hurt, or when the project is for Android.

Whichever tool a project uses, the build file says the same things in a different shape.

The same project, both ways#

Here is one project in both tools. It compiles for Java 25, uses the Simple Logging Facade for Java (SLF4J) logging library, and has JUnit tests:

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)

Tells Gradle which Java version to build with, like maven.compiler.release.

implementation("org.slf4j:slf4j-api:2.0.16")

A dependency, written as one string: group, artifact and version, separated by colons.

testImplementation("org.junit.jupiter:junit-jupiter:6.0.3")

Available to the tests only, like Maven's test scope.

Maven's scopes become Gradle configurations:

Maven scopeGradle configurationMeaning
compileimplementationused internally; 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; never shipped
Gotcha

implementation versus api has no Maven equivalent, and it is Gradle's best feature. An implementation dependency does not leak onto the compile classpath of the projects that use yours. That speeds up builds and stops accidental coupling. In Maven, every compile dependency is visible to your users too, which is how projects end up depending on things they never declared.

Most real services are several modules built together, which is where both tools earn their keep.

Multi-module: your solution file#

The billing service grows into three modules: the domain classes, the database code, and the web API that is deployed. Maven lists the modules in an aggregator Project Object Model (POM), and Gradle lists them in settings.gradle.kts. The shape is a .NET solution with project references:

billing/                       ← aggregator; the "solution"
├── pom.xml                    ← <modules> list + <dependencyManagement>
├── billing-domain/
│   └── pom.xml                ← no dependencies on siblings
├── billing-persistence/
│   └── pom.xml                ← depends on billing-domain
└── billing-api/
    └── pom.xml                ← depends on both; the deployable

billing-domain depends on nothing, billing-persistence uses the domain, and billing-api uses both. Only billing-api is deployed. The aggregator's own pom.xml lists the modules, and declares their versions once:

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

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

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

.NET refresherDirectory.Packages.props: NuGet central package management

One file at the solution root sets the version of every NuGet dependency; each project then references them without a version.

<!-- Directory.Packages.props -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Serilog" Version="4.1.0" />
  </ItemGroup>
</Project>
<!-- each .csproj: <PackageReference Include="Serilog" /> -->
Note

-am means “also make”: build the dependencies of the named module too. Without it, Maven expects the siblings to be installed already in your local repository, and you get a confusing “could not resolve” error for your own code. -pl module -am is the combination worth memorising.

The quick reference lists the solution-level operations in both tools.

Command mapping#

The everyday commands are listed in the quick reference. These three are the ones you will use most on a multi-module Gradle build:

./gradlew build                                         # compile, test and package everything
./gradlew :billing-api:bootRun                          # run one module's Boot app
./gradlew dependencies --configuration runtimeClasspath # what actually ships
Reading the code
./gradlew build

Compiles, tests and builds every module, like mvn package at the root.

./gradlew :billing-api:bootRun

The colon path names one module, billing-api, and bootRun starts its Spring Boot application.

./gradlew dependencies --configuration runtimeClasspath

Lists the dependencies that actually ship with the application.

Where artifacts come from#

In a company, every download usually goes through a company repository such as Nexus. You set that up once, in settings.xml:

<!-- ~/.m2/settings.xml: send every download through the company repository -->
<mirrors>
  <mirror>
    <id>company</id>
    <mirrorOf>*</mirrorOf>
    <url>https://nexus.acme.com/repository/maven-public/</url>
  </mirror>
</mirrors>
Reading the code
<mirrorOf>*</mirrorOf>

Sends requests for every repository through this mirror.

<url>https://nexus.acme.com/repository/maven-public/</url>

The company repository's address. It fetches from Maven Central on your behalf, and keeps a copy.

.NET refresherpackages.lock.json: NuGet's lock file
<PropertyGroup>
  <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<!-- restore writes packages.lock.json; CI runs: dotnet restore --locked-mode -->
Gotcha

Maven has no lockfile by default. A repeatable build comes from fixing exact versions and never using version ranges. SNAPSHOT versions are allowed to change: 1.0.0-SNAPSHOT is downloaded again, and can change under you. Never depend on someone else's snapshot from a release build.

What to remember

Gradle build files are Kotlin programs: faster on big builds, and more rope to hang yourself with. If you are choosing, pick Maven.

Maven's scopes become Gradle configurations, and implementation keeps a dependency off your users' classpath, where api exposes it.

A multi-module build is your solution file: an aggregator POM or settings.gradle.kts lists the modules, and -pl with -am builds one module and what it depends on.

Maven has no lockfile by default, so fix exact versions and never depend on someone else's snapshot.

Quick reference#

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
.NETMavenGradle
Solution (.sln)aggregator pom with <modules>settings.gradle.kts
ProjectReferencea normal dependency on the siblingproject(":billing-domain")
Directory.Packages.props<dependencyManagement>version catalog (libs.versions.toml)
Build the solutionmvn package at the root./gradlew build at the root
Build one projectmvn -pl billing-api -am package./gradlew :billing-api:build
.NETJava
nuget.orgMaven Central (repo1.maven.org)
~/.nuget/packages~/.m2/repository
nuget.config~/.m2/settings.xml
Azure Artifacts / GitHub PackagesNexus, Artifactory, GitHub Packages
packages.lock.jsonno true equivalent; set exact versions explicitly

Modules, JPMS and the missing internal#

Part P7 · Build and tooling · Chapter 33· 5 min read· Quick reference

The Java Platform Module System (JPMS) Java 9 is the nearest thing Java has to an assembly boundary. A module declares which packages it exports and which modules it requires, and the Java Virtual Machine (JVM) enforces it.

Most applications do not use it. It matters for the Java Development Kit (JDK) itself, for libraries, and for jlink, and it is optional for a Spring Boot service. Know what module-info.java means when you see one; you need not write one.

The problem it solves#

Before Java 9, the classpath was one flat namespace. Any class could reach any public class in any Java archive (JAR), so public meant public to the whole world. And when two JARs contained a class with the same name, one silently hid the other: the notorious “JAR hell”. C# never had this, because assemblies are real boundaries, with internal inside them.

A module fixes that. Here the billing module lists what it needs and what it offers, in a file called module-info.java at the root of its source folder:

// src/main/java/module-info.java
module com.acme.billing {

    requires java.sql;                       // depend on a JDK module
    requires transitive com.acme.domain;     // and re-export it to my consumers

    exports com.acme.billing.api;            // public to everyone
    exports com.acme.billing.spi
        to com.acme.plugins;                 // public only to a named module

    // com.acme.billing.internal is NOT exported: unreachable outside,
    // even though its classes are public

    provides com.acme.billing.api.PaymentProvider
        with com.acme.billing.internal.StripeProvider;   // service loading
}
Reading the code
requires java.sql;

The module needs java.sql, one of the modules the JDK itself is split into.

requires transitive com.acme.domain;

Needs the domain module, and passes it on: any module that requires billing gets the domain module too.

exports com.acme.billing.api;

The public types in this package can be used outside the module.

to com.acme.plugins;

This package is visible only to the one module named here.

provides com.acme.billing.api.PaymentProvider

Declares an implementation of a service interface, which other modules can find while the program runs, through ServiceLoader: a small built-in form of dependency injection.

Here is the full set of directives, including opens, which frameworks such as Spring, Hibernate and Jackson need:

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

Here is the closest you can get to C#'s internal, side by side:

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. Its public classes cannot be reached from other modules.

This is the closest thing to internal#

So a package that is not exported is the closest thing Java has to internal. The quick reference at the end of this chapter maps each C# visibility to its Java form.

Gotcha

The granularity differs, and that matters. C#'s internal works per type or member. A JPMS export works per package, all or nothing. You cannot export a package and hide one class in it. So a module-based design forces you to put the API and the implementation in different packages. That is good discipline, but it can mean real restructuring.

Reaching into a package that is not exported fails when you compile, even though the class itself is public:

// in module com.acme.app, which requires com.acme.billing
import com.acme.billing.internal.StripeGateway;
// error: package com.acme.billing.internal is not visible
Reading the code
import com.acme.billing.internal.StripeGateway;

The package exists and its class is public, but the billing module does not export it, so another module cannot import it.

The deeper sections cover how modules are found, why frameworks need opens, and when to bother at all.

Classpath vs module path#

ClasspathModule path
Flat namespaceyesno
Access enforcednoyes
Split packages allowedyesno
Needs module-infonoyes (or it becomes an automatic module)
Most Spring Boot appsthis onerarely
Note

A JAR without a module-info.java, placed on the module path, becomes an automatic module: its name comes from the file name, and it exports everything and reads everything. That is the bridge for migrating, and it is where most of the ecosystem still sits.

Here is the same application started both ways:

# classpath: one flat namespace, nothing enforced
java -cp "app.jar:lib/*" com.acme.App

# module path: module boundaries enforced; -m names module/main class
java -p mods -m com.acme.billing/com.acme.App
Reading the code
java -cp "app.jar:lib/*" com.acme.App

The classpath: every JAR in one flat namespace, with nothing enforced.

java -p mods -m com.acme.billing/com.acme.App

-p gives the module path, and -m names the module and its main class, so the module boundaries are enforced.

Why frameworks need opens#

Gotcha

Spring, Hibernate and Jackson all read and set private fields through reflection. Under JPMS that is blocked unless the package is open. This friction is the main reason ordinary applications skip JPMS entirely. If you put an application that uses them into modules, expect this:

java.lang.reflect.InaccessibleObjectException: Unable to make field private java.lang.String com.acme.Customer.name accessible: module com.acme.billing does not "opens com.acme" to unnamed module @3af49f1c#
Means
A framework tried to read a private field by reflection, and the module that owns the class has not opened that package to it.
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; no longer available

The one you are most likely to type lets an old library reach inside the JDK:

# let an old library reflect into java.lang, for code on the classpath
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar
Reading the code
--add-opens java.base/java.lang=ALL-UNNAMED

Opens the java.lang package of the java.base module to all code on the classpath, which the JDK calls the unnamed module.

Gotcha

Strong encapsulation of JDK internals is on by default since Java 17. Libraries that reached into private parts of the JDK, such as private fields of java.lang classes, now fail with InaccessibleObjectException instead of printing a warning. sun.misc.Unsafe is the exception: it is still reachable, but since Java 24 its memory methods print a warning that they will be removed. If an old library fails this way after an upgrade, the short-term fix is --add-opens; the real fix is upgrading the library.

When to bother#

Whether modules are worth it depends on what you build. A library published to Maven Central is the clearest case for them:

SituationUse JPMS?
Spring Boot service in a containerno; the fat JAR is the boundary
Library published to Maven Centralyes, publish at least an Automatic-Module-Name
Desktop app shipped with jlinkyes, jlink requires modules
Large internal platform with many teamsmaybe, enforced boundaries help
Anything on Java 8not available
Note

Even if you skip module-info.java, add an Automatic-Module-Name entry to your JAR's manifest, the file of settings inside every JAR, when you publish a library. It costs one line, and gives your users a stable module name if they do move to modules.

With Maven, the JAR plugin writes the entry for you:

<plugin>
  <artifactId>maven-jar-plugin</artifactId>
  <configuration>
    <archive>
      <manifestEntries>
        <Automatic-Module-Name>com.acme.billing</Automatic-Module-Name>
      </manifestEntries>
    </archive>
  </configuration>
</plugin>
Reading the code
<Automatic-Module-Name>com.acme.billing</Automatic-Module-Name>

The module name your users will refer to if they move to modules, even though the library has no module-info.java.

What to remember

The billing module's module-info.java declared what it requires and what it exports, and the JVM enforces those boundaries.

A package that is not exported is Java's internal, but it works per package, not per type.

Frameworks that read private fields need their packages opened, which is the main reason most applications skip modules.

Since Java 17, JDK internals are strongly encapsulated: --add-opens is the short-term fix, and upgrading the library is the real one.

Quick reference#

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

Testing#

Part P7 · Build and tooling · Chapter 34· 9 min read· Quick reference

JUnit is xUnit, Mockito is Moq, and AssertJ is FluentAssertions. Testcontainers and WireMock are literally the same projects; the .NET libraries are ports.

None of it ships with the Java Development Kit (JDK), so it comes from dependencies, but Spring Boot's spring-boot-starter-test brings all of it in one line.

The stack#

Almost every .NET testing tool has a Java counterpart, often with the same name. The quick reference at the end of this chapter lists them all. The first difference you meet is where the set-up code goes. Integration tests for orders need a database that starts once for the whole class, and a clean state before each test. Here are the lifecycle hooks, side by side:

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
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() { }

Runs once, before any test in the class, like an IClassFixture. It must be static, because it runs before any test object exists.

@BeforeEach void setUp() { }

Runs before every test, as the test class's constructor does in xUnit.

@Test @Tag("slow")

@Test marks a test method, like [Fact], and @Tag labels it, like [Trait], so that a build can include or exclude it.

With the hooks in place, a test itself looks very like its C# twin.

A test#

Adding two amounts of money must give the right total. Here is the same small test in both frameworks:

xUnit
public class MoneyTests
{
    [Fact]
    public void Add_SumsAmounts()
    {
        var a = new Money(10m, "GBP");
        var b = new Money(5m, "GBP");

        var result = a.Add(b);

        result.Amount.Should().Be(15m);
    }
}
JUnit 5
class MoneyTest {

    @Test
    void add_sumsAmounts() {
        var a = new Money(new BigDecimal("10"), "GBP");
        var b = new Money(new BigDecimal("5"), "GBP");

        var result = a.add(b);

        assertThat(result.amount())
            .isEqualByComparingTo("15");
    }
}
Reading the code
class MoneyTest {

No attribute on the class, and no public: JUnit finds the test methods by their @Test annotation.

void add_sumsAmounts() {

A test method, marked @Test like [Fact]. It returns void and takes no arguments.

.isEqualByComparingTo("15");

An AssertJ assertion, like FluentAssertions' Should().Be(...). It compares BigDecimal values with compareTo, so 15 and 15.00 count as equal.

Gotcha

Test classes and methods should be package-private, not public. JUnit 5 and later do not require public, and the convention is to leave the modifier out, which also keeps them out of your published API. JUnit 4 required public, so you will see it in older code.

Gotcha

assertEquals(expected, actual) takes the expected value first, the opposite of some .NET habits. Get it the wrong way round and the test still passes or fails correctly, but the failure message reads backwards. AssertJ's assertThat(actual).isEqualTo(expected) removes the doubt, which is one reason most Java teams prefer it to plain JUnit assertions.

One test often needs to run with many sets of data.

Parameterised tests#

Adding should work for many pairs of numbers, so one test runs once for each row of data, like xUnit's [Theory]:

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

Marks a test that runs once for each set of arguments, like [Theory].

@CsvSource({

Each string is one row, split at the commas into the method's parameters, like one [InlineData].

Data can come from other sources too:

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

Most unit tests also need to replace a dependency with a fake one.

Mocking#

OrderService saves each order it processes through an OrderRepo. The test replaces the repository with a mock, tells it what to return, and checks that save was called once:

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

Creates the mock. It is itself an OrderRepo, so there is no .Object.

.thenReturn(new Order(7));

Stubs find(7), like Setup(...).Returns(...).

verify(repo, times(1)).save(any(Order.class));

Checks that save was called exactly once, with any order, like Verify with Times.Once.

C# refresherMoq Callback and MockBehavior.Strict
var repo = new Mock<IOrderRepo>(MockBehavior.Strict);   // any call without a Setup throws
Order? saved = null;
repo.Setup(r => r.Save(It.IsAny<Order>()))
    .Callback<Order>(o => saved = o);                   // capture the argument

Capturing an argument, and failing on any call you did not expect, look like this in Mockito:

// capture what was passed, the usual job of a Moq Callback
var captor = ArgumentCaptor.forClass(Order.class);
verify(repo).save(captor.capture());
Order saved = captor.getValue();

// fail on any call you did not stub, like MockBehavior.Strict
OrderRepo strict = mock(OrderRepo.class, call -> { throw new AssertionError("unexpected " + call); });
Reading the code
ArgumentCaptor.forClass(Order.class)

Keeps the argument of the verified call, the usual job of a Moq Callback.

Order saved = captor.getValue();

The order that was passed to save.

mock(OrderRepo.class, call -> { throw new AssertionError("unexpected " + call); })

A default answer that throws for every call you have not stubbed, like MockBehavior.Strict.

Gotcha

Older Mockito could not mock final classes or static methods, the same limitation Moq has, for the same reason. Since Mockito 5 the inline mock maker is the default, so final classes can be mocked, and mockStatic exists but should be a last resort. If a class is hard to mock, that is usually a sign that its design needs work.

Mocks replace a dependency. Sometimes you want the real thing, started just for the test.

Testcontainers#

If you have used Testcontainers in .NET, this is the same library, and the Java Virtual Machine (JVM) version is the original. It runs real dependencies in Docker for integration tests. Here the repository test runs against a real PostgreSQL database, started for the test:

@Testcontainers
class OrderRepositoryTest {

    @Container
    static PostgreSQLContainer<?> db =
        new PostgreSQLContainer<>("postgres:16")
            .withDatabaseName("billing");

    @Test
    void findsSavedOrder() {
        // db.getJdbcUrl() points at a real Postgres
    }
}
Reading the code
@Testcontainers

Starts and stops the containers declared in the class.

static PostgreSQLContainer<?> db =

A PostgreSQL 16 container. Because the field is static, one container serves every test in the class.

// db.getJdbcUrl() points at a real Postgres

The container reports where it is listening, so the code under test connects to a real database.

Note

Spring Boot integrates directly: @ServiceConnection on a container field sets up the data source settings automatically, with no properties to override by hand. See Testing Spring Boot.

WireMock: faking an HTTP dependency#

WireMock is the same project as WireMock.Net, and the JVM original. It starts a real HTTP server, so your client code runs end to end instead of being mocked away. Here the exchange-rate client is tested against a fake rates service:

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

Starts a real HTTP server on a free port for the test class, and passes its details into each test.

stubFor(get("/rates/GBPUSD")

Tells the server how to answer a GET for /rates/GBPUSD.

new RatesClient(wm.getHttpBaseUrl())

Points the real client at the fake server.

Note

Spring Boot has a lighter alternative for pure client tests: @RestClientTest with MockRestServiceServer, which intercepts at the RestClient level and needs no port. Use WireMock when you want a real socket, a delay, or an injected fault.

The deeper sections cover benchmarking, architecture tests, coverage, and the JUnit 6 change.

JMH against BenchmarkDotNet#

You cannot benchmark Java with a stopwatch loop. The just-in-time (JIT) compiler compiles busy code only after thousands of calls, and it removes work whose result is never used. So a naive loop measures the optimiser, not your code. The Java Microbenchmark Harness (JMH) handles warmup, separate runs and dead-code removal for you:

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)

Runs the benchmark five times before measuring, so the JIT has compiled it by then.

@Fork(2)

Runs everything in two fresh JVMs, so that one run's optimisations do not colour the other's.

return IntStream.range(0, n).sum();

Returns the result. JMH consumes it, so the JIT cannot remove the work.

Gotcha

Return the result, or JMH's Blackhole must consume it. A benchmark whose value is never used can be deleted entirely by the JIT, and you will measure an empty loop at an impossible speed. The JVM does this far more aggressively than .NET, which is the main reason hand-written Java benchmarks are usually wrong.

ArchUnit: architecture rules as tests#

ArchUnit is the same project as ArchUnitNET: layering rules become ordinary failing tests, rather than review comments. Here the billing code forbids the domain from depending on the web or database layers, keeps controllers away from persistence, and bans field injection:

@AnalyzeClasses(packages = "com.acme.billing")
class ArchitectureTest {

    @ArchTest
    static final ArchRule domainIsPure =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("..api..", "..persistence..");

    @ArchTest
    static final ArchRule controllersAreThin =
        classes().that().areAnnotatedWith(RestController.class)
            .should().onlyDependOnClassesThat()
            .resideOutsideOfPackage("..persistence..");

    @ArchTest
    static final ArchRule noFieldInjection =
        noFields().should().beAnnotatedWith(Autowired.class);
}
Reading the code
@AnalyzeClasses(packages = "com.acme.billing")

Loads every class in com.acme.billing, and runs each rule against them.

noClasses().that().resideInAPackage("..domain..")

A rule that reads almost as English: no class in a domain package may depend on the api or persistence packages. The two dots match any part of a package name.

noFields().should().beAnnotatedWith(Autowired.class);

Bans @Autowired on fields, so dependencies come in through constructors.

JaCoCo against coverlet#

Coverage is a Maven plugin rather than a separate tool, and it can fail the build below a threshold. Here the build fails if less than 80% of lines are covered:

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <executions>
    <execution><goals><goal>prepare-agent</goal></goals></execution>
    <execution>
      <id>check</id>
      <goals><goal>check</goal></goals>
      <configuration>
        <rules><rule><limits><limit>
          <counter>LINE</counter>
          <minimum>0.80</minimum>
        </limit></limits></rule></rules>
      </configuration>
    </execution>
  </executions>
</plugin>
Reading the code
<goal>prepare-agent</goal>

Attaches the JaCoCo agent to the test run, so that it can note which lines run.

<minimum>0.80</minimum>

The threshold: the check goal fails the build if line coverage drops below 80%.

JUnit 5 and JUnit 6#

Gotcha

Spring Framework 7 baselines JUnit 6. If you are on Spring Boot 4 you are on JUnit 6; on Boot 3 you are on JUnit 5. The programming model carries straight over: @Test, @ParameterizedTest and the extension API work as before.

Two things do change. JUnit 6 needs Java 17 or later. It also unifies the version numbers: the Platform, Jupiter and Vintage parts now share one number. Before, the Platform was on 1.x while Jupiter was on 5.x. If you set versions by hand rather than inheriting them from the Boot parent, that is the line to fix. junit-platform-runner and junit-platform-jfr are also gone.

Only if you set JUnit's version yourself, import its bill of materials:

<!-- only when you pin JUnit yourself; the Spring Boot parent already does it -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.junit</groupId>
      <artifactId>junit-bom</artifactId>
      <version>${junit.version}</version>   <!-- JUnit 6: one number for Platform and Jupiter -->
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
Reading the code
<artifactId>junit-bom</artifactId>

JUnit's bill of materials: it sets matching versions for all of JUnit's parts.

<version>${junit.version}</version>

One property for the version, because JUnit 6 uses one number for every part.

LegacyJUnit 4

Recognisable by org.junit.Test rather than org.junit.jupiter.api.Test, public test classes, @Before and @After instead of @BeforeEach and @AfterEach, and @RunWith instead of @ExtendWith. The junit-vintage-engine runs JUnit 4 tests inside newer JUnit, which is how large codebases migrate a little at a time.

Naming#

Java has no single naming convention for tests, but two styles dominate: methodUnderTest_condition_expectedResult, or a readable sentence in @DisplayName. The second style looks like this:

@Test
@DisplayName("rejects an order when the cart is empty")
void rejectsEmptyCart() {
    assertThatThrownBy(() -> service.checkout(emptyCart))
        .isInstanceOf(IllegalStateException.class);
}
Reading the code
@DisplayName("rejects an order when the cart is empty")

The text that test reports show instead of the method name.

assertThatThrownBy(() -> service.checkout(emptyCart))

An AssertJ check that the code throws, like FluentAssertions' Should().Throw<T>().

Check yourself: Build and tooling
1You added a dependency and an unrelated library broke. What is the first command?
mvn dependency:tree. Maven resolves conflicts by nearest wins, not highest version, so a new dependency can silently downgrade a transitive one.
2Where do tests live, and why does the package matter?
src/test/java, in the same package as the code under test. That is how they get package-private access; Java has no InternalsVisibleTo.
3Why can a hand-rolled timing loop give a Java benchmark that is orders of magnitude too fast?
The JIT compiles hot code after thousands of iterations and deletes work whose result you discard. Use JMH, which handles warmup, forking and dead-code elimination.
4What does assertEquals(actual, expected) do?
Passes or fails correctly, but reports the failure backwards. The order is expected first. AssertJ's assertThat(actual).isEqualTo(expected) removes the ambiguity.
What to remember

JUnit, Mockito and AssertJ play the parts of xUnit, Moq and FluentAssertions, and spring-boot-starter-test brings them all.

Test methods are package-private methods marked @Test, with @BeforeEach and @BeforeAll for set-up and @ParameterizedTest for data-driven tests.

A Mockito mock is the object itself: stub it with when(...).thenReturn(...), and check calls with verify.

Testcontainers and WireMock are the original projects. Benchmark only with JMH, and measure coverage with JaCoCo.

Quick reference#

.NETJavaNote
xUnit / NUnit / MSTestJUnit 5 (Jupiter)the test framework nearly every Java project uses
[Fact]@Testmarks a test method; the class needs no attribute
[Theory] + [InlineData]@ParameterizedTest + @ValueSourceone test run for each row of inline data
[Trait]@Taglabels a test so a build can include or exclude it
Constructor / IDisposable@BeforeEach / @AfterEachmethods that run before and after every test
IClassFixture@BeforeAll / @AfterAllruns once per class; the method must be static
Assert.EqualassertEquals, or AssertJassertEquals takes expected first, then actual
FluentAssertionsAssertJfluent assertions that start assertThat(actual), then isEqualTo and friends
MoqMockitothe standard; there is no .Object, because the mock is the object
NSubstituteMockitoMockito covers this style too; there is no separate favourite
AutoFixtureInstancio, EasyRandomfill objects with generated test data
BogusJava Faker, Datafakerfake but realistic names, addresses and so on
TestcontainersTestcontainersthe same project; the .NET library is a port of this one
WireMock.NetWireMockthe same project; the .NET library is a port of this one
BenchmarkDotNetJMHhandles warm-up and dead-code elimination for you
FsCheckjqwikproperty-based testing: many generated inputs checked against one rule
ArchUnitNETArchUnitarchitecture rules, such as layer boundaries, written as tests
coverletJaCoCocode coverage, collected by an agent while the tests run
MoqMockito
new Mock<T>()mock(T.class)
mock.Objectthe mock itself; no .Object
Setup(...).Returns(v)when(...).thenReturn(v)
Setup(...).Throws(e)when(...).thenThrow(e)
Verify(..., Times.Once)verify(mock, times(1))
It.IsAny<T>()any(T.class)
It.Is<T>(p)argThat(predicate)
CallbackthenAnswer(invocation -> ...), or an ArgumentCaptor
MockBehavior.Strictno exact equal: a default answer that throws, or STRICT_STUBS
.NETJavaNote
coverletJaCoCoruns as a java agent during the test phase
ReportGeneratorjacoco:reportwrites an HTML report into target/site/jacoco
threshold gate in CIjacoco:checkfails the build itself when coverage drops below the limit
Stryker.NETPITmutation testing; PIT is the JVM original

Your library, translated#

Part P8 · Ecosystem · Chapter 35· 9 min read

One page, everything you reach for in .NET and what a Java team would use instead. Every row is indexed by the search palette, so ⌘K and the .NET name will bring you here.

Where a row says “same project”, the .NET library is a port of the Java original. Testcontainers, WireMock and Quartz all started on the Java Virtual Machine (JVM).

Web and API#

.NETJavaNote
ASP.NET CoreSpring Bootthe default for Java web services by a wide margin
Minimal APIsSpring Boot, Javalin, HelidonJavalin is the closest in spirit: routes as lambdas, little ceremony
Kestrelembedded Tomcat, Netty, JettyTomcat is what Spring Boot embeds unless you choose another
IIS hostinga JAR with an embedded serverthe JAR carries its own web server, so there is nothing to deploy into
Swashbuckle / NSwagspringdoc-openapigenerates OpenAPI from controllers
SignalRSpring WebSocket + STOMPno direct equivalent; WebSocket plus the STOMP protocol is closest
gRPC for .NETgrpc-java
YARPSpring Cloud Gatewaya reverse proxy and API gateway built on Spring
BlazorVaadin, Thymeleaf, JTEno equivalent model; Vaadin is closest for server-driven UI
Razor / Razor PagesThymeleaf, JTE, Freemarkerserver-side HTML templates; Thymeleaf is the Spring default

Here is the smallest web endpoint in each: a greeting that takes a name from the path.

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}")

Maps GET /hello/{name} to this method, like MapGet.

@PathVariable String name

Takes name from the path. Minimal APIs do the same by matching the parameter's name.

Data access#

.NETJavaNote
Entity Framework CoreHibernate / Spring Data JPAthe default ORM; Spring Data adds repositories on top of Hibernate
DapperJdbcClient, JdbcTemplate, JDBIyou write the SQL; it maps rows to objects
LINQ to SQL, IQueryablejOOQtyped SQL DSL generated from the schema
ADO.NETJDBCthe low-level database API that every Java driver implements
EF MigrationsFlyway, LiquibaseFlyway runs numbered plain-SQL files; Liquibase uses changelogs
DbContextEntityManager, or a Spring Data repositorythe unit of work that tracks the entities you loaded
IDbConnectionDataSource, Connectiona DataSource hands out pooled connections; HikariCP is Boot's default pool
Npgsql / SqlClientthe PostgreSQL / MSSQL JDBC drivera driver JAR on the classpath, chosen by its JDBC URL
StackExchange.RedisLettuce, JedisLettuce is the client Spring Data Redis uses by default
MongoDB.Drivermongodb-driver-sync
Elasticsearch.Netco.elastic.clients

Here each loads a customer's orders with hand-written SQL. customerId holds the customer's id:

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)

Fills in :id in the SQL, like Dapper's anonymous-object parameter.

.query(Order.class)

Maps each row to an Order, matching columns to the record's components by name.

.list();

Runs the query and returns the rows as a List<Order>.

Serialisation#

.NETJavaNote
System.Text.JsonJacksonthe default in Spring Boot, which configures it for you
Newtonsoft.JsonJackson, GsonGson is simpler but less capable; Jackson is the usual choice
JsonSerializer.SerializeobjectMapper.writeValueAsString
[JsonPropertyName]@JsonProperty
[JsonIgnore]@JsonIgnore
JsonSerializerOptionsObjectMapper configurationset once on the mapper; Spring Boot builds it from properties
protobuf-netprotobuf-javacode generated from .proto files, where protobuf-net uses attributes
MessagePackmsgpack-java
YamlDotNetSnakeYAML, Jackson YAML
CsvHelperOpenCSV, Jackson CSV
Gotcha

Spring Boot 4 moves to Jackson 3, whose root package is tools.jackson rather than com.fasterxml.jackson. Every import line changes, and Boot renamed several of its own types with it: @JsonComponent became @JacksonComponent, and @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
AutofacSpring
ILogger<T>SLF4J Loggerthe interface you code against; Logback implements it
SerilogLogback, Log4j2Logback is what Spring Boot logs through unless you switch
NLogLog4j2
structured loggingLogstash encoder, or Boot structured loggingJSON log lines; Spring Boot 3.4 and later writes them with no extra library
IConfigurationSpring Environment
appsettings.jsonapplication.yml / application.properties
IOptions<T>@ConfigurationPropertiesbinds a configuration section to a typed class or record
User Secretsa local profile, or Vaultkeep secrets out of the repository: an ignored local file, or Vault
Azure App ConfigurationSpring Cloud Config

Resilience, messaging, scheduling#

.NETJavaNote
PollyResilience4jthe Boot 3 answer; a separate library
PollySpring Framework @Retryablethe Boot 4 answer; retry moved into the framework
HttpClientFactoryRestClient, HTTP interface clientsRestClient is the modern client; Boot configures its builder for you
Refit@HttpExchange interfacesdeclare an interface, and Spring generates the HTTP client
MediatRSpring ApplicationEvents, Axonno single library covers the same ground
MassTransit / NServiceBusSpring Integration, Camel, Axon
RabbitMQ.ClientSpring AMQPRabbitTemplate to send, @RabbitListener to receive
Confluent.KafkaSpring KafkaKafkaTemplate to send, @KafkaListener to receive
HangfireQuartz, Spring @Scheduled@Scheduled for simple timers; Quartz for persistent, clustered jobs
Quartz.NETQuartzthe original; Quartz.NET is a port of this library
Azure FunctionsSpring Cloud Function

Testing and quality#

.NETJavaNote
xUnit / NUnitJUnit 5 (JUnit 6 on Boot 4)
Moq / NSubstituteMockito
FluentAssertionsAssertJ
TestcontainersTestcontainersthe original; the .NET library is a port of this one
WireMock.NetWireMockthe original; WireMock.Net is a port of this one
AutoFixtureInstancio
BogusDatafaker
BenchmarkDotNetJMH
coverlet + ReportGeneratorJaCoCo
SonarAnalyzerSonarQubethe same Sonar product, with its Java rules
Roslyn analyzersError Pronefinds bug patterns at compile time, inside javac
StyleCopCheckstyle
dotnet formatSpotless
ArchUnitNETArchUnitthe original; ArchUnitNET is a port of this one

Observability and diagnostics#

.NETJavaNote
dotnet-countersJFR, MicrometerJFR records JVM events; Micrometer exposes application metrics
dotnet-trace / PerfViewJFR + JDK Mission ControlJFR records inside the JVM; 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 code
App Insights / PrometheusMicrometer + PrometheusMicrometer is the metrics interface; the backend plugs in
HealthChecksSpring Boot Actuatorhealth, metrics and info endpoints, switched on by one starter
Visual Studio Profilerasync-profiler, JMCasync-profiler draws CPU and allocation flame graphs cheaply

Here are three commands against a running JVM whose process id is 4242:

jcmd 4242 JFR.start duration=60s filename=app.jfr   # record a running JVM for a minute
jcmd 4242 Thread.print                              # a thread dump
jmap -histo 4242 | head                             # which classes take the most heap
Reading the code
jcmd 4242 JFR.start duration=60s filename=app.jfr

Starts a one-minute flight recording, written to app.jfr, without restarting the application.

jcmd 4242 Thread.print

Prints what every thread is doing, like a thread dump from dotnet-dump.

jmap -histo 4242 | head

Counts the objects of each class on the heap, with the most memory first.

Note

JFR, Java Flight Recorder, has no direct .NET equivalent and is worth learning early. It is a production-grade profiler that can stay switched on, built into the Java Development Kit (JDK), with roughly 1% overhead. jcmd <pid> JFR.start on a running process gives you allocation, lock contention, I/O and CPU data with no restart. See The JVM at runtime.

Utility libraries#

.NETJavaNote
LINQStreamsbuilt into the JDK; see Streams vs LINQ
Humanizerno direct equivalentno popular equivalent; format such text by hand
AutoMapperMapStructcompile-time, so mapping problems show up in the build
FluentValidationJakarta Bean Validation@NotNull, @Size, custom validators
System.Collections.ImmutableList.of, Guava immutablesList.of and Map.of are built in; Guava adds richer immutable types
Nito.AsyncExvirtual threadsthe problems it solves largely disappear
CommandLineParserpicocliannotation-driven command-line parsing, with generated help
Scriban / HandlebarsThymeleaf, Mustachetext templates; Mustache is the closest to Handlebars
NodaTimejava.timebuilt into the JDK; both descend from Joda-Time
Guard clauses librariesObjects.requireNonNull, Guava Preconditionsargument checks that throw with a clear message

Jackson against System.Text.Json#

Jackson is the most-used library in Java after the JDK itself. Spring Boot configures it for you, so most of the time you only meet its annotations. When you do build a mapper yourself, here is how the options translate:

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, instead of filling in an options object.

incl -> incl.withValueInclusion(Include.NON_NULL)

Leaves null values out of the JSON, like WhenWritingNull.

mapper.readValue(json, Order.class)

Passes the class, because erasure removes the type argument; see Generics and type erasure.

C# refresherNewtonsoft.Json: JsonConvert
string json = JsonConvert.SerializeObject(order);
var back = JsonConvert.DeserializeObject<Order>(json);
Note

On Boot 3, with Jackson 2, the same code imports from com.fasterxml.jackson.databind, needs .addModule(new JavaTimeModule()) for java.time types, and usually sets nulls aside with .serializationInclusion(Include.NON_NULL). The annotations below are the same in both versions.

Settings for single fields are attributes in C#, and annotations in Java. Here an order renames its id, hides a secret, and formats its total with a custom serializer:

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

Writes the component as order_id, like JsonPropertyName.

@JsonIgnore String secret

Leaves the component out of the JSON entirely.

@JsonSerialize(using = MoneySerializer.class)

Uses a custom serializer, like a JsonConverter. MoneySerializer is your own class.

System.Text.JsonJacksonNote
JsonSerializer.Serializemapper.writeValueAsString
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]@JsonProperty
[JsonIgnore]@JsonIgnore
[JsonConverter]@JsonSerialize / @JsonDeserialize
JsonSerializerOptionsJsonMapper.builder()
CamelCase policyPropertyNamingStrategies.LOWER_CAMEL_CASErarely needed: Java field names are already camelCase
DateTime supportbuilt in (Jackson 3), JavaTimeModule (Jackson 2)Jackson 3 handles java.time itself; Jackson 2 needs the module
Unknown members ignoredFAIL_ON_UNKNOWN_PROPERTIESJackson 2 fails on them by default, Jackson 3 ignores them
Gotcha

Two Jackson defaults changed between Boot 3 and Boot 4. Jackson 2, which Boot 3 uses, throws on an unknown JSON property unless you disable FAIL_ON_UNKNOWN_PROPERTIES, and writes java.time types as unreadable objects unless JavaTimeModule is registered. Jackson 3, which Boot 4 uses, ignores unknown properties and handles java.time itself, as System.Text.Json does. Spring Boot configures the mapper it builds to ignore unknown properties in both, so this bites only when you build a mapper by hand.

MapStruct against AutoMapper#

The difference is when the mapping is worked out. AutoMapper matches properties by reflection while the program runs. MapStruct writes a plain Java class when you compile, so mapping problems show up in the build rather than in production. Here an order is mapped to a data transfer object that flattens the customer's name:

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

Fills customerName from the order's customer's name, like MapFrom. Fields with the same name on both sides need no line at all.

OrderDto toDto(Order order);

A method with no body. MapStruct writes the body when you compile.

Note

componentModel = "spring" makes the generated implementation a Spring bean, so you inject the interface and never see the generated class. Look in target/generated-sources to read it: it is ordinary field-by-field assignment, with no reflection and no startup cost.

Java's built-in serialization, and why to avoid it#

Java has a native binary serialization mechanism, older than JSON's popularity, built into the language through the Serializable marker interface. You will meet it, and you should not choose it. Here an order is written to a file, where p is the file's path:

class Order implements Serializable {          // a marker: no methods
    private static final long serialVersionUID = 1L;
    private transient String secret;           // excluded from the bytes
}

try (var out = new ObjectOutputStream(Files.newOutputStream(p))) {
    out.writeObject(order);                    // opaque binary
}
Reading the code
class Order implements Serializable {

Serializable has no methods. It only marks the class as allowed to be written out.

private transient String secret;

transient leaves a field out of the bytes.

out.writeObject(order);

Writes the whole object, and every object it refers to, in Java's own binary format.

Gotcha

Deserializing untrusted data is remote code execution. The format encodes which classes to create, and the reader creates them before you can inspect anything. Chains of ordinary library classes, called gadget chains, turn a byte array into arbitrary code. This is the most exploited kind of Java vulnerability, and it is the reason so many Java security advisories look alike.

If you must read serialized data you do not control, use a deserialization filter. Java 9 added them, as JDK Enhancement Proposal (JEP) 290. A filter lists the classes that are allowed to appear:

java -Djdk.serialFilter='com.acme.**;java.base/*;!*' -jar app.jar
Reading the code
-Djdk.serialFilter='com.acme.**;java.base/*;!*'

Allows classes under com.acme and in the java.base module, and rejects everything else, before any object is built.

ProblemDetail
Securityuntrusted input can execute code; see above
VersioningserialVersionUID must be managed by hand, or old data stops loading
Couplingthe wire format is your private field layout, so refactoring breaks it
Portabilityonly Java can read it; nothing else on the wire understands it
Bypasses constructorsobjects are reconstructed without running your invariants
Note

Use Jackson. JSON is inspectable, versionable and language-neutral, and it does not create arbitrary classes. Where you really need a compact binary format, use Protobuf, Avro or CBOR, all of which have schemas and none of which have this hazard.

Serializable is still required in a few places, notably session objects in some servlet containers, and older code that uses Remote Method Invocation (RMI) or Jakarta Messaging (JMS). Implementing the interface is harmless. Calling readObject on bytes from outside your system is not.

SLF4J and Logback against ILogger and Serilog#

The Simple Logging Facade for Java (SLF4J) is the interface, and Logback is the implementation. You always code against SLF4J, exactly as you code against ILogger<T> rather than against Serilog. Here the order service logs a placed order, and a warning tagged with a correlation id, cid:

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)

Gets a logger named after the class, like ILogger<OrderService>. It is static, so one logger serves every instance.

log.info("Placed {} for {}", id, total);

The placeholders are {}, filled in order. They have no names.

MDC.put("correlationId", cid);

Adds a value to the mapped diagnostic context (MDC), which every log line on this thread carries until it is removed, like BeginScope.

Gotcha

SLF4J placeholders are positional {}, not named. There is no {OrderId}, so structured logging needs the MDC or a structured encoder. This is a real step backwards from Serilog, and it is why the MDC matters more in Java than logging scopes do in .NET.

Log levels and the line format go in application.yml, which covers most of what a Serilog configuration file does:

# application.yml covers most of what a Serilog config file would
logging:
  level:
    root: INFO
    com.acme.billing: DEBUG
  pattern:
    console: "%d{HH:mm:ss} %-5level [%X{correlationId}] %logger{36} - %msg%n"
Reading the code
com.acme.billing: DEBUG

Logs everything from DEBUG up for the billing code, while everything else 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 through annotations, while the code compiles. Here is the C# that makes most of it unnecessary, 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

Generates a getter and a setter for every field.

@RequiredArgsConstructor

Generates a constructor that takes every final field, which is how Spring passes in dependencies.

Note

For immutable data, a record now beats Lombok and needs no dependency. Lombok still earns its place for mutable Jakarta Persistence (JPA) entities, which cannot be records, and for @Slf4j, which declares the logger field for you. @Builder is the other common survivor.

Build and deploy#

.NETJavaNote
MSBuildMaven, Gradlethe build tool; Maven is the common default
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 to ship with the app
NativeAOTGraalVM native-imagecompiles ahead of time to a native binary; see the packaging chapter
ReadyToRunAOT cache, CDSstartup caches; 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

Spring Boot orientation#

Part P9 · ASP.NET Core to Spring Boot · Chapter 36· 6 min read· Quick reference

Spring Boot is ASP.NET Core: an opinionated application host with dependency injection, configuration, an embedded web server, health endpoints and a production-ready build. If you know Program.cs, builder.Services and appsettings.json, you already know the shape.

The difference is that Boot works out most of its wiring from what is on the classpath. Add the Jakarta Persistence (JPA) starter, and it configures a data source, an entity manager and transactions without you writing a line.

Which version#

Two lines of Spring Boot are in use. Here is how they differ:

LineSpring FrameworkBaselinesUse when
Boot 3.xFramework 6Java 17, Jakarta EE 10existing systems; most tutorials
Boot 4.xFramework 7Java 17 (25 recommended), Jakarta EE 11, Kotlin 2.2, GraalVM 25, JUnit 6greenfield
Note

Spring Boot releases a minor version every six months, in May and November. A minor version is supported for at least 12 months. A major version is supported for at least three years from its release, as long as you are on a supported minor. So staying current means one planned upgrade a year, not one every six months.

Boot 4.0 was the first of the 4.x line, and by September 2026 the line had moved on to 4.1. So check spring.io/projects/spring-boot for the current release, rather than copying the version from any tutorial, including this one.

In your pom.xml, one line decides which line you are on:

<!-- pom.xml: this one line decides which Boot line you are on -->
<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.0.0</version>      <!-- or a 3.5.x release for the Boot 3 line -->
</parent>
Reading the code
<artifactId>spring-boot-starter-parent</artifactId>

Boot's parent Project Object Model (POM): a pom.xml that yours inherits from. It sets compatible versions for hundreds of libraries.

<version>4.0.0</version>

The Boot version. Use a 3.5.x release instead for the Boot 3 line.

This book shows both lines. Where an API differs you get a tabbed block, and where something is new in Boot 4 it carries a Boot 4 badge.

The entry point#

The billing service starts from one class, where an ASP.NET Core service starts from Program.cs:

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

Switches on auto-configuration, and searches this class's package, and every package below it, for your classes. That search is called component scanning.

SpringApplication.run(BillingApplication.class, args);

Starts Boot. It creates the beans, the objects Spring manages for you, starts the embedded web server, and runs until it is stopped, like app.Run().

// services are discovered by annotation, not registered here

There is no builder.Services list. Spring finds your classes by their annotations, such as @Service.

Gotcha

There is no central registration list. @SpringBootApplication implies @ComponentScan over the package containing the class and everything below it. A component in a sibling package is invisible.

Put the application class in your root package, com.acme.billing, and everything under it is scanned. This one convention causes more “bean not found” confusion than anything else in Spring.

Every capability you add, such as a database or security, arrives the same way: as a starter.

Starters#

A starter is a dependency that pulls in a coherent set of libraries and switches on their auto-configuration. It is the unit of “I want this capability”. Here are the starters you will meet first:

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

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

Brings Hibernate, the library that maps objects to database tables, along with Spring Data JPA and a connection pool, and switches on their auto-configuration. It has no version, because the parent POM sets it.

Gotcha

Boot 4 renamed its modules to a uniform spring-boot-<technology> pattern, with root packages org.springframework.boot.<technology>. The rename you hit first is starter-web becoming starter-webmvc, the first dependency in every tutorial you will read.

A starter only adds libraries. The part that sets them up is auto-configuration.

Auto-configuration#

This is the concept with no ASP.NET Core equivalent. Each starter ships configuration classes that switch themselves on only when certain conditions hold. Here, simplified, is how Boot sets up a database connection:

// roughly what Boot does internally, simplified
@AutoConfiguration
@ConditionalOnClass(DataSource.class)          // the JDBC classes are present
@ConditionalOnMissingBean(DataSource.class)    // and you have not defined your own
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
    @Bean
    DataSource dataSource(DataSourceProperties props) { ... }
}
Reading the code
@ConditionalOnClass(DataSource.class)

Applies only if the Java Database Connectivity (JDBC) classes, Java's standard database interface, are on the classpath.

@ConditionalOnMissingBean(DataSource.class)

And only if you have not defined a DataSource yourself.

@Bean

The method's return value becomes a bean that other classes can have injected.

How does Boot find these classes? Each library lists its auto-configuration classes in a file inside its Java archive (JAR), in the META-INF/spring folder, whose name ends in AutoConfiguration.imports. Boot reads every such file on the classpath at startup. This is Spring's version of a pattern called a service provider interface (SPI). A library defines a place where other code can plug in, and finds the pieces at run time from files in META-INF. Java's own ServiceLoader, from the modules chapter, works the same way.

Note

The rule that matters: anything you define yourself wins. Declare your own DataSource bean, and the auto-configured one steps aside, because of @ConditionalOnMissingBean. You override by defining, not by unregistering.

When you want to know what Boot configured, and why, ask it:

# what did Boot configure, and why?
./mvnw spring-boot:run -Dspring-boot.run.arguments=--debug
# prints a "CONDITIONS EVALUATION REPORT": matched and unmatched, with reasons
Reading the code
-Dspring-boot.run.arguments=--debug

Passes --debug to the application. Boot then prints its conditions evaluation report: every auto-configuration that matched or did not, with the reason. java -jar takes the same --debug flag.

The deeper sections cover where the files go, how to run the application, and the alternatives to Boot.

Project layout#

A typical Boot service is laid out like this:

src/main/java/com/acme/billing/
├── BillingApplication.java     ← must be in the root package
├── api/                        ← @RestController
├── domain/                     ← entities, value objects, domain services
├── persistence/                ← repositories
└── config/                     ← @Configuration classes

src/main/resources/
├── application.yml             ← the appsettings.json
├── application-dev.yml         ← per-profile overrides
└── db/migration/               ← Flyway SQL

BillingApplication sits in the root package, com.acme.billing, so component scanning finds everything in the folders below it. application.yml plays the part of appsettings.json, and application-dev.yml overrides it when the dev profile is active.

Running it#

Note

./mvnw package on a Boot project produces a single JAR that you can run: a fat JAR, holding your code, every dependency and an embedded Tomcat. java -jar runs it anywhere a Java Virtual Machine (JVM) exists. It is the closest match to a self-contained dotnet publish, and it is why Java web applications no longer need an application server.

Here are both ways of running the billing service:

./mvnw spring-boot:run -Dspring-boot.run.profiles=dev    # run from source with a profile
java -jar target/billing-1.0.0.jar --server.port=9090    # run the packaged JAR on another port
Reading the code
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

Runs from source, with the dev profile active.

java -jar target/billing-1.0.0.jar --server.port=9090

Runs the packaged JAR. Any setting can be given on the command line after --, here the port.

The quick reference lists the other everyday commands, with their .NET equivalents.

The alternatives#

FrameworkPitchCompared to
Spring Bootthe default; vast ecosystemASP.NET Core
Quarkuscompile-time DI, fast startup, native-firstASP.NET Core + NativeAOT
Micronautcompile-time DI, no runtime reflectionsimilar to Quarkus
Javalintiny, explicit, no magicMinimal APIs
HelidonOracle, MicroProfile and NimaMinimal APIs

Quarkus and Micronaut do their dependency injection while the code compiles, through annotation processors, which removes Spring's classpath scanning and reflection at startup. That gives startup times in the tens of milliseconds, and much better GraalVM native image support: the same trade NativeAOT makes. Spring Boot has closed much of this gap with ahead-of-time (AOT) processing, but not all of it.

Here is a Quarkus endpoint, which uses the Jakarta REST annotations instead of Spring's:

// Quarkus: Jakarta REST annotations instead of Spring's
@Path("/hello")
public class HelloResource {
    @GET
    public String hello() { return "hello"; }
}
Reading the code
@Path("/hello")

The path this class answers, like Spring's @RequestMapping.

@GET

Handles GET requests, like @GetMapping.

What to remember

Spring Boot plays the part of ASP.NET Core: one @SpringBootApplication class starts it, and components are found by scanning that class's package and everything below it.

Starters bring a whole capability in one dependency, and auto-configuration sets it up, unless you define the bean yourself.

Boot releases a version every six months, so label code as Boot 3 or Boot 4, and check spring.io for the current release.

To see what Boot configured and why, run with --debug and read the conditions evaluation report.

Quick reference#

Task.NETSpring Boot
Rundotnet run./mvnw spring-boot:run
Run with a profileASPNETCORE_ENVIRONMENT=Development--spring.profiles.active=dev
Build a deployabledotnet publish./mvnw package
Run the artifactdotnet MyApp.dlljava -jar target/billing-1.0.0.jar
Watch and reloaddotnet watchspring-boot-devtools
Default port5000 / 50018080
Change the port--urls--server.port=9090

Dependency injection and configuration#

Part P9 · ASP.NET Core to Spring Boot · Chapter 37· 10 min read· Quick reference

Spring's container plays the part of ASP.NET Core's IServiceCollection, with one difference: you do not register classes in a list. You mark a class with an annotation such as @Service, Spring finds it, and when another class asks for it as a constructor parameter, Spring passes it in. That is dependency injection, the same idea you use in ASP.NET Core.

Configuration also works as you expect, under new names: application.yml instead of appsettings.json, profiles instead of environments, and @ConfigurationProperties instead of IOptions<T>.

Registration by annotation#

The billing service has an OrderService that needs an OrderRepository to load and save orders. In ASP.NET Core you register both classes in Program.cs, and the service takes the repository as a constructor parameter. In Spring you put an annotation on each class instead:

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; chapter 37 fills it in
}

// OrderService.java: there is no registration line,
// because the annotation is the registration
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final OrderRepository repository;

    // Spring passes in the OrderRepository
    // when it creates the OrderService.
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}
Reading the code
@Repository

Marks OrderRepository as a class Spring should create. @Repository is for classes that talk to the database.

@Service

Does the same for OrderService. This one line replaces the builder.Services line in C#.

private final OrderRepository repository;

final means the field is set once, in the constructor, and never changes, like a C# readonly field.

public OrderService(OrderRepository repository) {

Spring sees that the constructor needs an OrderRepository, finds the one it created, and passes it in.

An object that Spring creates and hands to other objects is called a bean. At startup, Spring searches the application class's package, and every package below it, for classes with these annotations, and creates one bean for each. That search is called component scanning. Handing the job of creating objects to the framework like this is called inversion of control (IoC): your class no longer creates what it needs, it receives it.

These are the annotations that make a class a bean:

AnnotationMeansNote
@Componentany class Spring should create and inject"bean" is Spring's word for an object it creates and injects
@Servicea component that holds business logicthe same as @Component; the name documents intent
@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
Note

A class with one constructor needs no @Autowired annotation; Spring uses that constructor. This style, called constructor injection, with final fields, is the recommended one, and it is what you already do in ASP.NET Core.

Older code often uses field injection instead, so you should recognise it:

import org.springframework.beans.factory.annotation.Autowired;

// the older style: avoid it in new code
@Service
public class OrderService {

    @Autowired
    private OrderRepository repository;
}
Reading the code
@Autowired

Tells Spring to fill the field in after it has created the object. It works, but it hides what the class depends on, and a unit test cannot pass in a fake repository without starting Spring.

Every bean also has a lifetime, and Spring's default lifetime is the opposite of the one you are used to choosing.

Lifetimes#

In ASP.NET Core you choose a lifetime for every service: singleton, scoped or transient. Spring calls a lifetime a bean scope. It has the same three, under different names, and two more for web applications:

ASP.NET CoreSpringNote
AddSingletonsingletonthe default in Spring: one shared instance for the whole application
AddScopedrequestone per HTTP request; declare it 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

Here are the first three side by side: a clock the whole application shares, a shopping cart for each request, and an id generator that is new every time:

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 is a singleton: Spring creates one SystemTime and injects that same object everywhere.

@RequestScope

One Cart for each HTTP request, like AddScoped. A singleton can still take a Cart in its constructor. Spring injects a stand-in object, called a proxy, that finds the current request's cart each time you call it.

@Scope("prototype")

A new IdGenerator every time one is injected, like AddTransient. Spring's word for transient is prototype.

Gotcha

The defaults are opposite. ASP.NET Core makes you choose a lifetime every time. Spring defaults to singleton, meaning one instance for the whole application. So a bean that keeps data in a field shares that data with every request, on every thread, and nothing warns you.

Here is that mistake in its most common form. The payment service keeps the order it is working on in a field:

@Service
public class PaymentService {

    private Order current;      // one field, shared by every request

    public void pay(Order order) {
        current = order;        // another request can replace it here
        charge(current);
    }

    private void charge(Order order) { }
}
Reading the code
private Order current;

There is only one PaymentService, so there is only one current field, and every request reads and writes it.

current = order;

If two customers pay at the same moment, the second request can overwrite the field before the first one reaches charge. Then both requests charge the second customer's order.

The fix is to keep per-request data in local variables and parameters, here by calling charge(order) directly. Keep beans stateless. @RequestScope exists, but stateless is nearly always the right answer.

Some objects cannot carry an annotation at all, because their class comes from a library. For those, you write a method that builds the object.

Beans for types you do not own#

The billing service calls an exchange-rate service over HTTP, with Spring's RestClient. You cannot put @Service on RestClient, because it is not your class. In ASP.NET Core you would register a factory function. In Spring you write a @Bean method inside a @Configuration class:

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

Marks a class that holds @Bean methods. Spring calls them at startup.

@Bean

Whatever the method returns becomes a bean, named after the method: 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

Parameters of a @Bean method are injected, like constructor parameters. This one is a single setting from the configuration; Single values and precedence explains the ${…} form.

return RestClient.create(url);

Builds a client whose requests all start with that address.

Multiple implementations#

The shop takes payment by card and by bank transfer. Each has its own gateway class, and both implement one interface, PaymentGateway. When CheckoutService asks for a PaymentGateway, two beans fit, and Spring must be told which one to use. C# solves this with keyed services; Spring uses bean names:

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

Registers the bean under the name card. Without a name, Spring names a bean after its class, with a lower-case first letter: cardGateway.

@Qualifier("card") PaymentGateway gateway

Asks for the bean named card, like [FromKeyedServices("card")]. Without it, Spring finds two matching beans and fails at startup.

@Qualifier is one of several ways to choose:

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

The List form is like injecting IEnumerable<T> in ASP.NET Core, with one difference: it includes named beans. The .NET version leaves out keyed registrations unless you ask for KeyedService.AnyKey. The Map form has no ASP.NET Core equivalent: it gives you the beans keyed by name. Here the shop uses it to pick a gateway from the payment method the customer chose:

import java.util.Map;

@Service
public class PaymentRouter {

    private final Map<String, PaymentGateway> gateways;

    // Spring fills the map with every PaymentGateway bean, keyed by bean name.
    PaymentRouter(Map<String, PaymentGateway> gateways) {
        this.gateways = gateways;
    }

    void pay(String method, Order order) {
        gateways.get(method).charge(order);   // method is "card" or "bank"
    }
}
Reading the code
PaymentRouter(Map<String, PaymentGateway> gateways) {

A Map parameter with String keys receives every bean of that type, keyed by bean name: card and bank. A List<PaymentGateway> parameter would receive the same beans, without the names.

gateways.get(method).charge(order);

Looks up the gateway for the customer's choice and charges the order. Adding a third gateway, for vouchers, needs a new class and nothing else.

So far every bean has been one of your classes or a library's. The rest of the chapter is about the settings those beans read.

Configuration files#

The billing service needs three kinds of setting: the database address, the address and timeout of the exchange-rate service, and log levels. In ASP.NET Core they live in appsettings.json. In Spring Boot they live in src/main/resources/application.yml, written in YAML, a format where indentation shows nesting instead of braces:

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

The setting spring.datasource.url: each level of indentation adds one part to the name. Settings under spring: belong to Spring Boot itself. This one is the database address, in the form used by Java Database Connectivity (JDBC), Java's standard database interface.

timeout-seconds: 5

Your own settings can have any name. Boot's convention is lower case with hyphens.

com.acme.billing: DEBUG

The log level for one package and everything below it, like a category under LogLevel in .NET.

Many projects use application.properties instead: the same settings, one per line, each with its full name, such as rates.timeout-seconds=5. Boot reads either file.

ASP.NET Core loads extra settings for each environment, such as Development. Spring does the same with profiles.

Profiles are environments#

On a developer's machine the shop must not send real emails. In ASP.NET Core you check the environment in Program.cs. In Spring you mark the fake email sender with the profile it belongs to. A Spring profile is a named set of settings and beans, such as dev or prod, that you switch on at startup:

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

An environment variable that switches on the dev profile, like ASPNETCORE_ENVIRONMENT. Boot then reads application-dev.yml on top of application.yml.

@Profile("dev")

Spring creates this bean only when the dev profile is active. With any other profile, the bean does not exist.

A profile can also be excluded. This fake sender exists everywhere except in production, so no developer or test server has to remember to switch it on:

@Service
@Profile("!prod")           // active in every profile except prod
class FakeEmailSender implements EmailSender {
    public void send(String to, String text) { }
}
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.

Several profiles can be active at once, separated by commas, as in SPRING_PROFILES_ACTIVE=dev,local. The quick reference lists the .NET form of each profile setting.

Reading settings one at a time gets tedious. ASP.NET Core binds a whole section to a class with IOptions<T>; Spring binds it to a record.

IOptions becomes @ConfigurationProperties#

The exchange-rate client needs both of its settings, rates.url and rates.timeout-seconds. Rather than read them one by one, you describe the whole rates section as one type, and Spring fills it in:

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

Binds the rates section of application.yml to this type.

int timeoutSeconds) { }

A record is an immutable data class (see Records). Each component is filled from the setting with the matching name, so timeoutSeconds gets timeout-seconds.

@EnableConfigurationProperties(RatesProperties.class)

Tells Spring to create a RatesProperties bean. On a larger service, put @ConfigurationPropertiesScan on the application class instead, and Spring finds every such type itself.

RatesClient(RatesProperties rates) { }

The record is injected like any other bean.

Note

This improves on IOptions<T> in two ways. You inject the settings type itself, with no wrapper, and it can be an immutable record. Binding is also relaxed: rates.timeout-seconds, rates.timeoutSeconds and RATES_TIMEOUT_SECONDS all fill the same component. This is called relaxed binding, and it lets an environment variable set any property with no extra configuration.

Single values and precedence#

For one setting on its own, @Value injects it directly. It is handy in a @Bean method or a small class:

@Service
class RatesClient {

    RatesClient(@Value("${rates.url}") String url,
                @Value("${rates.timeout-seconds:5}") int timeoutSeconds) {
    }
}
Reading the code
@Value("${rates.url}") String url

${rates.url} is a placeholder: Spring replaces it with the value of the rates.url setting, and fails at startup if there is none.

${rates.timeout-seconds:5}

The text after the colon is a default, used when the setting is missing.

The same setting can come from several places. Here the container platform sets the exchange-rate address as an environment variable, and a startup script passes it again with -D:

export RATES_URL=https://rates.staging.example.com
java -Drates.url=https://rates.example.com -jar billing.jar
Reading the code
export RATES_URL=https://rates.staging.example.com

Sets the environment variable RATES_URL, which relaxed binding reads as rates.url.

java -Drates.url=https://rates.example.com -jar billing.jar

Starts the service with a Java system property of the same name. The system property wins, because it ranks higher in the list below. An argument placed after the name of the Java archive (JAR), such as --rates.url=…, would beat both.

When the same setting is given in several places, the source higher in this list wins:

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. Two more sources sit between it and system properties. One is the servlet container. The other is the Java Naming and Directory Interface (JNDI), a lookup service provided by an old-style application server. They rarely matter in a Boot service. See How Java runs your code for what a system property is.

Gotcha

Environment variables are written in upper case with underscores: RATES_TIMEOUT_SECONDS sets rates.timeout-seconds. ASP.NET Core marks each level of nesting with a double underscore, as in Rates__TimeoutSeconds. Spring needs no such convention: a single underscore does the job.

Secrets#

The billing service's database password must never be committed to the repository. Here is where each .NET option for secrets lands in Spring:

.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

There is no built-in equivalent of dotnet user-secrets. The common pattern is a file called application-local.yml, listed in .gitignore and switched on with a local profile. Set this up on day one, because the alternative, a committed password, is the most common accident in Spring repositories.

Here is the local file for the billing service:

# src/main/resources/application-local.yml, listed in .gitignore
spring:
  datasource:
    password: dev-only-secret
# activate it with: ./mvnw spring-boot:run -Dspring-boot.run.profiles=local
Reading the code
password: dev-only-secret

The development password. The file is listed in .gitignore, so git never commits it.

-Dspring-boot.run.profiles=local

Runs the service with the local profile, so Boot loads application-local.yml on top of application.yml.

What to remember

OrderService became a bean through @Service, and received its OrderRepository through its constructor, with no registration list.

Beans are singletons by default, so keep them stateless: an order kept in a field is shared by every request.

When two beans implement PaymentGateway, @Qualifier picks one by name, and a Map parameter collects them all.

Settings live in application.yml, profiles such as dev replace environments, and a @ConfigurationProperties record replaces IOptions<T>.

Quick reference#

ASP.NET CoreSpring Boot
builder.Services.AddScoped<OrderService>()@Service on the class
AddKeyedScoped<IPaymentGateway, CardGateway>("card")@Service("card"), then @Qualifier("card")
IOptions<RatesOptions>a @ConfigurationProperties record, injected directly
.NETSpringNote
ASPNETCORE_ENVIRONMENTSPRING_PROFILES_ACTIVE
appsettings.Development.jsonapplication-dev.yml
IHostEnvironment.IsDevelopment()@Profile("dev")
--environment Development--spring.profiles.active=dev
Multiple environmentsmultiple active profilesseveral profiles at once, comma-separated: dev,local

How Spring actually works#

Part P9 · ASP.NET Core to Spring Boot · Chapter 38· 10 min read· Quick reference

Later in this part, @Transactional, @PreAuthorize and @Retryable each come with the same warning: calling the method from inside its own class silently does nothing. This chapter explains why, once, so the warning makes sense.

The short version: Spring does not change your class. It wraps it. The bean that other classes receive is often a stand-in object, a proxy, that does the annotation's work, such as starting a transaction, and then calls your object. A call from inside your object never leaves it, so it never passes through the stand-in.

The bean lifecycle#

When the billing service starts, it should load today's exchange rates into memory before the first request arrives. In ASP.NET Core you would write an IHostedService. In Spring, the right place depends on the steps Spring takes with every bean.

The object that creates and holds all the beans is called the application context, Spring's version of the ASP.NET Core service provider. At startup it finds the beans, from component scanning and from @Bean methods. It then creates each one through its constructor, which is where constructor injection happens, and runs it through the rest of these steps:

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
The Spring bean lifecycle: instantiate, populate, run the initialisation callbacks, then post-process. 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 hands it to you. Spring builds it and then shows it to every BeanPostProcessor: a class that can decorate, replace or reject a bean before anyone else sees it. The framework extends itself this way, and the step after your initialisation code is where proxies are made.

Here is where each step lands, next to its nearest ASP.NET Core equivalent:

ASP.NET CoreSpringWhen
constructorconstructordependencies arrive
n/a@PostConstructafter all dependencies are set
n/aInitializingBean.afterPropertiesSet()same point, interface form
n/aBeanPostProcessoraround every bean; how the framework extends itself
IDisposable / IAsyncDisposable@PreDestroycontainer shutdown
IHostedService.StartAsyncApplicationRunner, @EventListener(ApplicationReadyEvent)after the context is up
C# refresherIHostedService: code that runs when the host starts
public class Warmup : IHostedService
{
    public Task StartAsync(CancellationToken ct) => LoadCachesAsync(ct);   // at startup
    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;     // at shutdown
}

builder.Services.AddHostedService<Warmup>();

In Spring, the startup job is a bean that implements ApplicationRunner. Boot calls it once the application context is fully started:

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

@Component
class Warmup implements ApplicationRunner {

    // Boot calls this once, after every bean is ready.
    @Override
    public void run(ApplicationArguments args) {
        loadExchangeRates();
    }

    private void loadExchangeRates() {
        // read today's rates into memory
    }
}
Reading the code
class Warmup implements ApplicationRunner {

Boot finds every bean that implements ApplicationRunner and calls its run method after startup, like StartAsync.

public void run(ApplicationArguments args) {

args holds the command-line arguments the application was started with.

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. Work that needs the whole application, such as loading caches, belongs in an ApplicationRunner, as above.

Work that needs only this bean's own dependencies can go in a method marked @PostConstruct, from the jakarta.annotation package, which runs after injection. But calling one of the bean's own @Transactional methods from there starts no transaction, for the reason the next section shows.

The step that matters most is the one after your initialisation code, where proxies are made.

What a proxy actually is#

The OrderService's place method is marked @Transactional: it must run inside a database transaction. Spring cannot add code to your method. Instead, when it creates the OrderService, a BeanPostProcessor returns a different object in its place: a subclass that Spring generates while the program runs. That object is a proxy. It starts the transaction, calls your place method, and commits. Every class that has the OrderService injected receives the proxy, not your object.

Think of a receptionist at an office. Visitors speak to the receptionist, who signs them in and passes the message on. People already inside the office speak to each other directly, and never pass the desk.

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.helper() OrderService.helper() a plain call inside your object: it never passes through the proxy
A call through a Spring proxy: the caller holds the generated OrderService$$SpringCGLIB subclass, which starts the transaction and delegates to your object. A this.helper() call inside your object never passes through the proxy, so helper's annotations do nothing.

C# has nothing that does this for you. You write the decorator class and register it, so the wrapping is visible in your code. In Spring, the annotation is all you write:

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

Asks Spring to run the method inside a transaction. Spring does it by handing everyone a proxy instead of your object. Data access explains the transaction itself.

Spring can build that proxy in two ways.

Two kinds of proxy#

Java has built-in dynamic proxies, but they only work through an interface. For everything else, Spring uses the Code Generation Library (CGLIB), which generates a subclass of your class while the program runs. Which one you get depends on the bean and on one setting, proxyTargetClass:

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

You can see the proxy for yourself by printing the class of an injected bean:

@Service
public class OrderService {          // no interface, so Spring subclasses it
    @Transactional public void place(Order order) { }
}

// in any bean that has the OrderService injected:
System.out.println(orderService.getClass().getName());
// prints com.acme.billing.OrderService$$SpringCGLIB$$0
Reading the code
System.out.println(orderService.getClass().getName());

getClass() returns the class of the object you actually hold, which is the proxy.

// prints com.acme.billing.OrderService$$SpringCGLIB$$0

A subclass of OrderService that Spring generated. $$SpringCGLIB$$ marks it as Spring's, and the number keeps generated names unique.

Gotcha

A CGLIB proxy is a subclass, so final defeats it silently. A subclass cannot override a final method, so the proxy cannot wrap it. The method runs with no transaction, and the only sign is a warning in the startup log saying the method “cannot get proxied via CGLIB”. Unlike C#, Java methods are virtual by default, so a final added for safety quietly turns your transaction off.

A final class fails loudly instead: the application stops at startup with “Cannot subclass final class”. This is also why Kotlin needs a Spring plugin. Kotlin classes and methods are final by default, and the kotlin-spring plugin opens the classes that carry Spring annotations.

The same picture explains the warning this chapter began with.

Why self-invocation fails#

The billing service places a batch of orders by calling place once for each. The loop sits in the same class as place:

@Service
public class OrderService {

    public void placeAll(List<Order> orders) {
        for (var order : orders) {
            place(order);      // a plain call on this: no proxy, no transaction
        }
    }

    @Transactional
    public void place(Order order) {
        // save the order
    }
}
Reading the code
public void placeAll(List<Order> orders) {

A caller outside the class reaches placeAll through the proxy. But placeAll has no @Transactional, so the proxy simply passes the call on.

place(order);

This is really this.place(order): your object calling itself. The call never reaches the proxy, so no transaction starts, and nothing warns you.

A method calling another method on the same object is called self-invocation. In the office picture, it is one colleague speaking to another: the receptionist never hears it. The fix is to make the call come from outside, through the proxy. Here the loop moves to its own bean:

@Service
public class BatchService {

    private final OrderService orders;

    BatchService(OrderService orders) {
        this.orders = orders;
    }

    public void placeAll(List<Order> batch) {
        for (var order : batch) {
            orders.place(order);   // through the proxy: one transaction per order
        }
    }
}
Reading the code
orders.place(order);

orders is the injected proxy, so this call goes through it, and each order is placed in its own transaction.

Moving the method is the best of four ways out:

FixHowCost
Move the method to another beaninject it and call itbest; usually better design anyway
Inject yourself@Autowired @Lazy OrderService self; then self.place(order)works, reads oddly; without @Lazy, Boot rejects it as a cycle
AopContext.currentProxy()cast the result and call through itneeds exposeProxy=true; obscure
Use AspectJ weaving instead of proxiescompile-time or load-time weavingpowerful, much heavier

AspectJ weaving changes your compiled classes themselves instead of wrapping them, so it covers internal calls too, at the cost of an extra build or startup step.

Note

Every proxy-based annotation shares this behaviour, which is why the same warning appears under Data access, Security and HTTP clients and resilience. Learn it once here.

The deeper sections cover writing your own wrapping behaviour, Spring's expression language, dependency cycles, and the order of beans.

AOP: writing your own cross-cutting behaviour#

Aspect-oriented programming (AOP) means adding the same behaviour around many methods without editing each one, such as timing every service call. Proxies are how Spring does it, and AOP is the API for adding your own. It is Spring's answer to ASP.NET Core middleware, an action filter or a DispatchProxy decorator, but it works on any bean method, not only on controllers.

The class that holds the behaviour is called an aspect. Here the billing service logs how long every service method takes:

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class TimingAspect {

    private static final Logger log = LoggerFactory.getLogger(TimingAspect.class);

    // every public method in a class marked @Service
    @Around("@within(org.springframework.stereotype.Service) && execution(public * *(..))")
    public Object time(ProceedingJoinPoint call) throws Throwable {
        long start = System.nanoTime();
        try {
            return call.proceed();          // run the real method
        } finally {
            log.debug("{} took {} ms", call.getSignature(),
                      (System.nanoTime() - start) / 1_000_000);
        }
    }
}
Reading the code
@Aspect

Marks the class as an aspect. @Component makes it a bean, so Spring finds it.

@Around("@within(org.springframework.stereotype.Service) && execution(public * *(..))")

Which methods to wrap. execution(public * *(..)) means any public method, with any return type, name and parameters. @within(…Service) limits it to classes marked @Service.

return call.proceed();

Runs the real method and returns its result. Without this line, the method never runs.

} finally {

Runs whether the method returned or threw, as in C#, so every call is timed. Each {} in the log message is replaced by the next argument.

@Around is one kind of advice, the name for code an aspect runs. Here are all five:

AdviceRuns.NET analogy
@Beforebefore the methodfilter OnActionExecuting
@AfterReturningafter a successful returnOnActionExecuted
@AfterThrowingonly on exceptionexception filter
@Afteralways, like finallyfinally block
@Aroundwraps the call; you invoke proceed()middleware

The expression in @Around is a pointcut: it picks 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 proxy machinery, so every limitation above applies. Self-invocation skips them, final methods defeat them, and objects you create yourself with new are never wrapped. An aspect that seems not to run is almost always one of those three.

SpEL#

Spring Expression Language (SpEL) is a small expression language that runs inside annotation values, written #{...}. You will meet it in @PreAuthorize for security and @Cacheable for caching, and it also works in @Value. Here it picks a pricing region from a system property:

@Service
class RegionalPricing {

    private final String region;

    // SpEL: the user.region system property, or "eu" when it is not set
    RegionalPricing(@Value("#{systemProperties['user.region'] ?: 'eu'}") String region) {
        this.region = region;
    }
}
Reading the code
#{systemProperties['user.region'] ?: 'eu'}

#{...} marks SpEL. systemProperties['user.region'] reads a Java system property, and ?: supplies 'eu' when it is missing, like C#'s ??.

In the other annotations, SpEL reads the method's parameters by name, with a # in front:

// in a service with method security: only the order's owner may cancel it
@PreAuthorize("#order.owner == authentication.name")
public void cancel(Order order) { }

// in a service with caching: one cache entry per currency pair and date
@Cacheable(value = "rates", key = "#pair + ':' + #date")
public BigDecimal lookup(String pair, LocalDate date) { ... }
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 order's owner is the person calling it.

key = "#pair + ':' + #date"

Builds the cache key from both parameters, such as EURUSD:2026-09-13.

Gotcha

${...} and #{...} are different things. ${...} is a property placeholder; it reads configuration. #{...} is SpEL; it evaluates an expression. Mixing them up gives a literal string or a startup failure, and the error message rarely says which mistake you made.

Circular dependencies#

Say the OrderService needs the BillingService to raise an invoice, and the BillingService needs the OrderService to look up the order. Neither can be created first:

@Service
class OrderService {
    OrderService(BillingService billing) { }
}

@Service
class BillingService {
    BillingService(OrderService orders) { }
}
Reading the code
OrderService(BillingService billing) { }

To create an OrderService, Spring needs a BillingService, which in turn needs an OrderService.

ASP.NET Core throws on a dependency cycle. Spring used to resolve some cycles by injecting a half-built object, which caused hard-to-find bugs. Since Boot 2.6, cycles are rejected by default, and Boot stops at startup with this report:

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  billingService defined in file [.../BillingService.class]
↑     ↓
|  orderService defined in file [.../OrderService.class]
└─────┘
Reading the code
┌─────┐

The box draws the loop: each bean in it needs the next one, and the last needs the first.

There are four ways out, and only the first is a real fix:

FixNote
Extract the shared logic into a third beanthe usual fix: 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; it hides a design problem
spring.main.allow-circular-references=truere-enables the old behaviour; avoid
Setter injectionno longer a way out: since Boot 2.6 a cycle through setters or fields fails too, unless circular references are allowed
Note

A cycle is a design signal, not a container limitation. It nearly always means two beans share a responsibility that wants its own name. The escape hatches exist for legacy code being migrated, not for new work.

Ordering and conditional beans#

Two questions come up once a service has many beans: in what order they run, and whether a bean exists at all. These annotations answer both:

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

import java.util.List;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

interface CheckoutStep { }

@Component @Order(1) class AuditStep implements CheckoutStep { }
@Component @Order(2) class PricingStep implements CheckoutStep { }

@Service
class Checkout {
    Checkout(List<CheckoutStep> steps) { }   // AuditStep first, then PricingStep
}
Reading the code
@Component @Order(1) class AuditStep implements CheckoutStep { }

Lower numbers come first in any injected List of this type.

Checkout(List<CheckoutStep> steps) { }

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 that, and @DependsOn is the only way to add a dependency that the code itself does not have.

What to remember

Spring runs every bean through the same lifecycle, so startup work such as loading exchange rates belongs in an ApplicationRunner, not in a constructor.

The bean other classes receive is often a proxy, a generated subclass such as OrderService$$SpringCGLIB$$0, that starts the transaction and then calls your object.

When placeAll calls place in the same class, the call skips the proxy, so move the loop into another bean such as BatchService.

A final method silently loses its annotation, and a final class stops the application at startup.

Quick reference#

SymptomCauseFix
@Transactional does nothingthe call comes from the same classcall it from another bean
One annotated method is ignoredthe method is finalremove 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
C#Spring
IHostedServicea bean that implements ApplicationRunner
services.Decorate<IOrderService, TransactionalDecorator>()an annotation such as @Transactional; Spring makes the proxy

Controllers, routing and binding#

Part P9 · ASP.NET Core to Spring Boot · Chapter 39· 9 min read· Quick reference

Spring's web framework is Spring MVC, named after the model-view-controller (MVC) pattern, and it plays the part of ASP.NET Core MVC. @RestController is [ApiController], @GetMapping is [HttpGet], and the binding annotations map almost one to one.

The main difference is what a method returns. Spring returns the value itself, or wraps it in ResponseEntity to choose the status and headers, instead of using an IActionResult hierarchy.

A controller#

The shop's API needs two endpoints: one returns an order by its id, and one creates an order. In ASP.NET Core you write a controller class. In Spring you write almost the same class:

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

Marks a controller whose return values are written straight into the response body. Spring turns them into JSON with Jackson, the JSON library it uses by default. It is [ApiController] and ControllerBase together.

@RequestMapping("/api/orders")

The path prefix for every method in the class, like [Route].

public OrderController(OrderService orders) {

Spring passes in the OrderService bean, as Dependency injection and configuration showed.

@PathVariable long id) {

Takes id from the {id} part of the path. Spring converts the text to a long; if it cannot, the request fails with 400 Bad Request.

.map(ResponseEntity::ok)

find returns an Optional, as in the Optional chapter. If an order is there, it becomes a 200 OK response. ResponseEntity::ok is a method reference, short for order -> ResponseEntity.ok(order).

ResponseEntity.notFound().build());

Otherwise, a 404 Not Found with no body.

@RequestBody CreateOrder request) {

Reads the JSON request body into a CreateOrder, like [FromBody].

return ResponseEntity.created(location)

A 201 Created response, with a Location header pointing at the new order, like CreatedAtAction.

Each method in a controller is tied to one HTTP verb and one path. That is the next thing to look at.

Routing and verbs#

Each HTTP verb has its own mapping annotation, and the class-level @RequestMapping supplies the common prefix. Here are three more endpoints on the same controller, with their method bodies left out:

@RestController
@RequestMapping(path = "/api/orders", produces = "application/json")
class OrderController {

    @GetMapping("/{id:\\d+}")                // only digits match
    OrderDto get(@PathVariable long id) { ... }

    @PutMapping("/{id}")
    OrderDto replace(@PathVariable long id, @RequestBody OrderDto order) { ... }

    @DeleteMapping("/{id}")
    void delete(@PathVariable long id) { ... }
}
Reading the code
@RequestMapping(path = "/api/orders", produces = "application/json")

The prefix for every path in the class. produces says every method returns JSON, like [Produces]; in Spring it is an attribute of the mapping, not a separate annotation.

@GetMapping("/{id:\\d+}")

The part after the colon is a regular expression, here one or more digits, so it works like the route constraint {id:int}. A request for /api/orders/abc gets 404 instead of reaching the method. Java strings double the backslash.

@PutMapping("/{id}")

There is one annotation per verb: @GetMapping, @PostMapping, @PutMapping, @PatchMapping and @DeleteMapping. The quick reference lists each one next to its ASP.NET Core attribute.

The path is only one place a value can come from. The next section covers the others.

Model binding#

The shop's search endpoint takes its values from three places: the region from the path, the search text and paging from the query string, and the tenant from a header. Each parameter carries an annotation that says where its value comes from:

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,

From the {region} segment of the path, like [FromRoute].

@RequestParam String q,

From the query string, as in ?q=tea, like [FromQuery]. It is required, so a request without q gets 400 Bad Request.

@RequestParam(defaultValue = "0") int page,

Optional: a missing page becomes 0.

@RequestParam(required = false) String status,

Optional: a missing status becomes null.

@RequestHeader(value = "X-Tenant",

From the X-Tenant header, like [FromHeader]. Headers are required by default too, hence required = false.

Spring Data adds one more kind of parameter, a Pageable, filled from the page, size and sort query parameters. Data access shows it.

Gotcha

@RequestParam is required by default. A missing parameter produces a 400 rather than null, which differs from ASP.NET Core's default of binding to the parameter's default value. Use required = false or supply a defaultValue.

None of these annotations names its parameter: Spring reads the name, such as q, from the compiled class. That only works when the compiler keeps parameter names, and a build that does not gives this error:

java.lang.IllegalArgumentException: Name for argument of type [long] not specified, and parameter name information not available via reflection. Ensure that the compiler uses the '-parameters' flag.#
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
Spring Boot's parent Project Object Model (POM) turns on the compiler's -parameters flag for you, so this appears only in builds that do not use it. Add -parameters to the compiler options, or name the value: @PathVariable("id").
What triggers it
// compiled without -parameters
@GetMapping("/{id}")
public ResponseEntity<OrderDto> get(@PathVariable long id) { ... }

The request body binds to a type of its own, and records are the natural choice.

Records as DTOs#

The create endpoint receives a new order as JSON and returns the saved order. Types that only carry data in and out of an API are called data transfer objects (DTOs). In Java they should be records, because Jackson reads and writes records directly:

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.math.BigDecimal;
import java.util.List;

public record CreateOrder(
        @NotBlank String customerId,
        @NotEmpty List<OrderLine> lines) { }

public record OrderLine(String sku, int quantity) { }

public record OrderDto(long id, String status, BigDecimal total) { }
Reading the code
public record CreateOrder(

The request body. Jackson fills each component from the JSON property with the same name, as in {"customerId": "c1", "lines": [{"sku": "A-1", "quantity": 2}]}.

@NotBlank String customerId,

Bean Validation annotations: rules that Spring checks when the controller parameter is marked @Valid. The next chapter covers them.

public record OrderDto(long id, String status, BigDecimal total) { }

The response. It is written as {"id":1,"status":"PAID","total":19.99}.

Gotcha

A record has no no-argument constructor, and its accessors are id() rather than getId(). Modern Jackson handles this; a library or Jackson version older than roughly 2.12 does not, and fails to deserialise with a confusing message about missing creators. If you hit that, check the Jackson version before rewriting the record as a class.

Returning a record gives a 200. For any other status, you wrap the result.

Returning results#

The invoice endpoint answers in four ways: 400 when the request is invalid, 204 after a delete, an unusual status code, and a PDF file to download. In ASP.NET Core each answer is a helper method on ControllerBase. In Spring each one is a ResponseEntity builder:

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

A 400 with the errors as the JSON body, like BadRequest(errors).

ResponseEntity.noContent().build();

build() finishes a response that has no body: here 204 No Content.

ResponseEntity.status(418).build();

Any status code, by number.

.body(new ByteArrayResource(pdf));

A file download: the bytes wrapped as a Resource, with the Content-Disposition header naming the file, like File(...).

Note

If you do not need to set a status or headers, return the value directly and let Spring serialise it with a 200. Use ResponseEntity<T> only where you actually vary the response. An @ResponseStatus(HttpStatus.CREATED) annotation on the method is a third option for a fixed status other than 200.

The deeper sections cover code that runs around every request, cross-origin calls from a browser, API versions and OpenAPI documents.

Middleware#

C# refresherASP.NET Core middleware and action filters

Middleware sees every request on its way through the pipeline; a filter wraps only controller actions, with access to the action's arguments and result.

app.Use(async (ctx, next) =>            // middleware: every request
{
    ctx.Response.Headers["X-Trace"] = "1";
    await next();
});

public class TimingFilter : IActionFilter   // filter: around controller actions only
{
    public void OnActionExecuting(ActionExecutingContext c) { }
    public void OnActionExecuted(ActionExecutedContext c) { }
}

Java web servers are built on the Jakarta Servlet standard. A servlet is a class that handles HTTP requests, and Spring MVC itself runs as one, the DispatcherServlet, which routes each request to a controller method. Code that must see every request runs before it, as a servlet filter. Code that wraps only controller methods is a HandlerInterceptor:

ASP.NET CoreSpringRuns
app.Use(...) middlewareServlet Filterbefore the dispatcher; sees every request
IActionFilterHandlerInterceptoraround controller methods
IAsyncActionFilterHandlerInterceptor
IExceptionFilter@ControllerAdvice + @ExceptionHandler
IAuthorizationFilterSpring Security filter chain
Endpoint routingDispatcherServlet

The shop tags every request with a correlation id, so that all the log lines for one request can be found together. A filter reads the id from a header, or makes a new one:

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Optional;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;

@Component
public class CorrelationIdFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        var id = Optional.ofNullable(((HttpServletRequest) req).getHeader("X-Correlation-Id"))
                         .orElseGet(() -> UUID.randomUUID().toString());
        MDC.put("correlationId", id);        // shows up in every log line
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.clear();
        }
    }
}
Reading the code
public class CorrelationIdFilter implements Filter {

A servlet filter. Because it is also a @Component, Spring Boot registers it to run for every request.

MDC.put("correlationId", id);

The mapped diagnostic context (MDC) belongs to the Simple Logging Facade for Java (SLF4J), the logging API most Java code uses. It holds values for the current thread that the log format can print on every line, like a logging scope in Serilog.

chain.doFilter(req, res);

Passes the request on to the next filter, and finally to Spring MVC, like await next().

MDC.clear();

Runs in finally, so the id never leaks into the next request that this thread handles.

The equivalent of an action filter is a HandlerInterceptor, which runs around controller methods only:

@Component
class TimingInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        req.setAttribute("t0", System.nanoTime());
        return true;                    // false stops the request here
    }
}
Reading the code
public boolean preHandle(

Runs before the controller method, like OnActionExecuting.

return true;

Lets the request continue. Returning false stops it here.

Unlike a filter, an interceptor must be registered, in a configuration class that implements WebMvcConfigurer. That class is also where cross-origin rules go.

The shop's web front end runs at https://shop.example.com and calls the API at another address. Browsers block such cross-origin calls unless the API says the front end's origin is allowed. That permission system is called cross-origin resource sharing (CORS):

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/**")

Applies the rule to every path under /api. ** matches any number of path segments.

.allowedOrigins("https://shop.example.com")

Only pages from this origin may call the API. The browser first sends a check request, called a preflight, and Spring answers it. A preflight from any other origin gets 403.

registry.addInterceptor(new TimingInterceptor());

Registers the interceptor from above, for every controller method.

Gotcha

Do not put @EnableWebMvc on this class in a Boot application. It tells Spring you are configuring the web layer entirely yourself, which switches off Boot's own web setup. Implementing WebMvcConfigurer is enough. For a single controller, @CrossOrigin on the class sets the same rule.

API versioning#

The shop's order endpoint changed shape, and old mobile apps still call the old one. Spring Framework 7 added built-in API versioning Boot 4. On Boot 3 you roll your own, usually with separate paths:

// Boot 3: versioning by path, the common convention
@RestController
@RequestMapping("/api/v1/orders")
class OrderControllerV1 { }

@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 { }
// Boot 4: the version is part of the mapping
@RestController
@RequestMapping("/api/orders")
class OrderController {

    @GetMapping(version = "1.0")
    OrderDtoV1 getV1() { ... }

    @GetMapping(version = "1.1")
    OrderDtoV2 getV11() { ... }
}
Reading the code
@GetMapping(version = "1.0")

This method answers requests for version 1.0, and the next one answers 1.1. Spring picks the method from the version the request asks for.

On Boot 4, one setting says where the request carries its version, here in a header:

spring:
  mvc:
    apiversion:
      use:
        header: X-API-Version
Reading the code
header: X-API-Version

Read the version from the X-API-Version header. A query parameter, a path segment or a media type parameter work too, through the other use settings.

Unless you set a default version, a request that sends no version is rejected with 400 Bad Request. So is a request for a version that no method supports.

Note

Boot 4 also carries the version to the client side: RestClient, WebClient and HTTP interface clients can all send one. Tests can send one too, through WebTestClient and MockMvc.

OpenAPI#

C# refresherSwashbuckle: OpenAPI for ASP.NET Core
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

app.UseSwagger();       // the document, at /swagger/v1/swagger.json
app.UseSwaggerUI();     // the browsable UI, at /swagger
.NETSpring
Swashbuckle / NSwagspringdoc-openapi
[ProducesResponseType]@ApiResponse
[SwaggerOperation]@Operation
XML doc commentsjavadoc, plus annotations
/swagger/swagger-ui.html

Add the springdoc-openapi-starter-webmvc-ui dependency and the UI appears with no configuration, generated from your controllers, records and Bean Validation annotations. You add annotations only to say more than the code does:

@Operation(summary = "Fetch one order")                      // [SwaggerOperation]
@ApiResponse(responseCode = "404", description = "no such order")   // [ProducesResponseType]
@GetMapping("/{id}")
OrderDto get(@PathVariable long id) { ... }
Reading the code
@Operation(summary = "Fetch one order")

The operation's one-line summary in the generated document.

@ApiResponse(responseCode = "404", description = "no such order")

Documents a response the method can give besides the normal 200.

What to remember

OrderController is a @RestController: @GetMapping and @PostMapping tie methods to verbs, and the record a method returns is written as JSON.

Each parameter says where its value comes from, with @PathVariable, @RequestParam, @RequestHeader or @RequestBody, and @RequestParam is required unless you say otherwise.

Return the value for a 200, or a ResponseEntity to choose the status and headers, as create does with 201 Created.

Request and response types are records, such as CreateOrder and OrderDto, which Jackson reads and writes directly.

Quick reference#

ASP.NET CoreSpringNote
[Route("api/x")]@RequestMapping("/api/x")on the class, as a prefix for every method's path
[HttpGet]@GetMapping
[HttpPost]@PostMapping
[HttpPut]@PutMapping
[HttpPatch]@PatchMapping
[HttpDelete]@DeleteMapping
[HttpGet("{id:int}")]@GetMapping("/{id}")Spring infers the type from the parameter
route constraint :inta regular expression: {id:\d+}without one, a value that cannot be converted produces a 400
[Produces("application/json")]produces = "application/json"an attribute of the mapping annotation, not a separate one
[Consumes(...)]consumes = "..."
ASP.NET CoreSpringBinds from
[FromRoute]@PathVariablethe URL path
[FromQuery]@RequestParamthe query string
[FromBody]@RequestBodythe request body, via Jackson
[FromHeader]@RequestHeadera header
[FromForm]@RequestParam / @ModelAttributeform data
[FromServices]just take a constructor dependencythe container
n/a@CookieValuea cookie
n/a@RequestPartone part of a multipart request
ASP.NET CoreSpring
Ok(value)ResponseEntity.ok(value), or just return the value
NotFound()ResponseEntity.notFound().build()
BadRequest(x)ResponseEntity.badRequest().body(x)
NoContent()ResponseEntity.noContent().build()
Created(uri, x)ResponseEntity.created(uri).body(x)
StatusCode(418)ResponseEntity.status(418).build()
File(...)ResponseEntity with a Resource body
Problem()see Validation and error responses

Validation and error responses#

Part P9 · ASP.NET Core to Spring Boot · Chapter 40· 6 min read· Quick reference

Jakarta Validation, the standard also called Bean Validation, plays the part of DataAnnotations and much of FluentValidation. You put constraint annotations on a record, mark the controller parameter @Valid, and Spring rejects bad input with 400 before your method runs.

For error bodies, Spring supports ProblemDetail, which follows the same internet standard, RFC 9457, as ASP.NET Core's ProblemDetails.

Constraint annotations#

An order sent to the API must name the customer, ask for between 1 and 100 items, and give a valid email address. In C# you put DataAnnotations attributes on the record. In Java you put constraint annotations on the record's components:

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,

At least one character that is not whitespace, and at most 50 characters. A component can carry several constraints, like [Required, StringLength(50)].

@Min(1) @Max(100) int quantity,

A number from 1 to 100, like [Range(1, 100)].

@Email String email

Must look like an email address. Like most constraints, @Email treats a missing value as valid; add @NotBlank as well to require one.

Spring Boot checks these annotations with Hibernate Validator, the usual implementation of the standard. The quick reference lists the DataAnnotations attribute for each one, along with constraints that .NET lacks, such as @Positive and @Past.

Gotcha

@NotNull is not [Required] for strings. @NotNull accepts "", so {"name": ""} passes and only {"name": null} is rejected. Use @NotBlank for a string that must contain something other than whitespace, and @NotEmpty for a collection that must have elements. Getting this wrong is the most common validation mistake.

The annotations only describe the rules. Nothing checks them until you ask.

Triggering validation#

The create endpoint from the previous chapter should turn away a bad order before any of its code runs. One annotation on the parameter does it:

import jakarta.validation.Valid;

@PostMapping
public ResponseEntity<OrderDto> create(
        @Valid @RequestBody CreateOrder request) {
    // runs only when the request passed every constraint
    ...
}
Reading the code
@Valid @RequestBody CreateOrder request) {

@Valid tells Spring to check the CreateOrder after reading it from the body. If any constraint fails, Spring throws MethodArgumentNotValidException, the client gets 400 Bad Request, and your method never runs.

A constraint can also sit directly on a query parameter or path variable. Spring checks those too, with no @Valid:

@GetMapping
public List<OrderDto> list(@RequestParam @Min(0) int page) {
    ...
}
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 different exception from the one @Valid raises, so an error handler should cover both.

Note

Older code, and tutorials written before Spring Framework 6.1, put @Validated on the controller class to get this. Spring's documentation now says to remove it. With it, the check runs through a proxy instead of through the validation built into Spring MVC, Spring's model-view-controller (MVC) web framework.

Gotcha

Forgetting @Valid silently turns validation off for that parameter. The annotations are still on the record, but nothing checks them, and there is no warning. An order with a blank customer and a quantity of 0 goes straight into your method. If invalid input is reaching your code, check for the missing @Valid first.

The built-in constraints cover most rules. For the rest, you write your own.

Custom constraints#

The shop accepts prices only in currencies that exist, and no built-in constraint knows the list. This is where Jakarta Validation covers FluentValidation's territory: a custom constraint is an annotation, plus a class that does the check.

C# refresherFluentValidation: rules in a validator class
public class CreateOrderValidator : AbstractValidator<CreateOrder>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.Currency).Must(c => Currencies.Contains(c));
    }
}
import jakarta.validation.Constraint;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import jakarta.validation.Payload;
import java.lang.annotation.*;
import java.math.BigDecimal;
import java.util.Currency;

@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CurrencyValidator.class)
public @interface ValidCurrency {
    String message() default "unknown currency";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class CurrencyValidator
        implements ConstraintValidator<ValidCurrency, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        return value == null || Currency.getAvailableCurrencies()
                .stream().anyMatch(c -> c.getCurrencyCode().equals(value));
    }
}

// used like any built-in constraint
public record PriceQuote(@ValidCurrency String currency, BigDecimal amount) { }
Reading the code
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})

Where the annotation may be used: on fields, and on record components such as currency.

@Constraint(validatedBy = CurrencyValidator.class)

Connects the annotation to the class that does the check.

public @interface ValidCurrency {

@interface declares a new annotation type, as the annotations chapter showed.

String message() default "unknown currency";

The message used when the check fails. The other two members, groups and payload, are required on every constraint annotation. Copy them as they are.

return value == null || Currency.getAvailableCurrencies()

A missing value counts as valid, as with the built-in constraints. Otherwise the code must be a real currency code, such as EUR; ABC is rejected.

When a check fails, the client needs an error body it can read. Both frameworks use the same standard for it.

ProblemDetail#

RFC 9457 defines one shape for error bodies, called problem details, and both frameworks produce it. ASP.NET Core adds it with one call:

C# refresherProblemDetails in ASP.NET Core
builder.Services.AddProblemDetails();    // RFC 9457 bodies for unhandled errors

app.MapGet("/orders/{id}", (int id) =>
    Results.Problem(title: "Order not found", statusCode: 404));

Spring Boot has it too, switched off by default. One setting turns it on:

# application.yml
spring:
  mvc:
    problemdetails:
      enabled: true
Reading the code
enabled: true

Boot then registers a handler, a subclass of Spring's ResponseEntityExceptionHandler, that turns Spring MVC's own exceptions, such as a failed @Valid, into problem details.

Here is the body each framework sends when the order above fails validation:

C#
// ASP.NET Core, for an [ApiController]
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Email": ["The Email field is not a valid e-mail address."],
    "Quantity": ["The field Quantity must be between 1 and 100."]
  },
  "traceId": "0HNOHL49BIAAL:00000001"
}
Java 25
// Spring Boot, with problemdetails enabled
{
  "detail": "Invalid request content.",
  "instance": "/api/orders",
  "status": 400,
  "title": "Bad Request"
}
Reading the code
"detail": "Invalid request content.",

Spring's default body says what went wrong in general, but does not list the fields. The section on field-level errors adds them.

"instance": "/api/orders",

The path of the request that failed.

"title": "Bad Request"

There is no type member. Spring leaves it out when it is the default, about:blank, which means the status code says it all.

The deeper sections cover problem details for your own exceptions and bodies that name each failing field.

Global exception handling#

Your own exceptions need a problem details body too. When an order does not exist, the service throws an OrderNotFoundException that carries the missing id:

public class OrderNotFoundException extends RuntimeException {

    private final long orderId;

    public OrderNotFoundException(long orderId) {
        super("No order with id " + orderId);
        this.orderId = orderId;
    }

    public long orderId() { return orderId; }
}
Reading the code
super("No order with id " + orderId);

The exception's message, which becomes the detail of the response.

One class then turns that exception into a 404 for every controller. In ASP.NET Core this is exception-handling middleware or a filter:

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

Applies the handlers in this class to every controller. Such a class is called a controller advice.

extends ResponseEntityExceptionHandler {

Inherits Spring's problem details for its own exceptions. Because your class is now the ResponseEntityExceptionHandler, Boot does not register its own.

@ExceptionHandler(OrderNotFoundException.class)

Handles one exception type, thrown from any controller.

problem.setProperty("orderId", e.orderId());

Adds a member of your own, at the top level of the body.

The response for a missing order 99 is:

{
  "detail": "No order with id 99",
  "instance": "/api/orders/99",
  "status": 404,
  "title": "Order not found",
  "type": "https://acme.com/errors/not-found",
  "orderId": 99
}
Reading the code
"type": "https://acme.com/errors/not-found",

A web address that names the kind of problem, so clients can tell errors apart without reading the text.

These are the pieces you combine:

NeedSpring
Handle one exception type@ExceptionHandler(X.class)
Handle several@ExceptionHandler({A.class, B.class})
Apply to all controllers@RestControllerAdvice
Apply to some controllers@RestControllerAdvice(basePackages = "...")
Override framework error shapesextend ResponseEntityExceptionHandler
Add fields to the responseproblemDetail.setProperty(k, v)

Returning field-level errors#

Spring's default body for a validation failure does not name the fields, where ASP.NET Core's does. Most teams add them, by overriding one method in the same ApiExceptionHandler:

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException e, HttpHeaders headers,
        HttpStatusCode status, WebRequest request) {
    var errors = e.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.toMap(
                    FieldError::getField,
                    f -> Objects.requireNonNullElse(f.getDefaultMessage(), "invalid"),
                    (first, second) -> first));

    ProblemDetail problem = e.getBody();
    problem.setTitle("Validation failed");
    problem.setProperty("errors", errors);
    return ResponseEntity.status(status).headers(headers).body(problem);
}
Reading the code
protected ResponseEntity<Object> handleMethodArgumentNotValid(

Spring calls this method when @Valid fails. Overriding it replaces the default body.

.collect(Collectors.toMap(

Builds a map from each field's name to its message, as in Streams.

(first, second) -> first));

Keeps the first message when one field has two errors.

ProblemDetail problem = e.getBody();

The problem details Spring had already built for this exception, which you add to.

The response now names each field that failed:

{
  "detail": "Invalid request content.",
  "instance": "/api/orders",
  "status": 400,
  "title": "Validation failed",
  "errors": {
    "quantity": "must be greater than or equal to 1",
    "customerId": "must not be blank",
    "email": "must be a well-formed email address"
  }
}
Reading the code
"customerId": "must not be blank",

The default messages come from Hibernate Validator. A constraint's message attribute replaces one, as in @NotBlank(message = "customer is required").

Gotcha

Collectors.toMap throws on duplicate keys, and two constraint violations on the same field produce exactly that. The three-argument form with a merge function, as above, is not optional here.

Gotcha

Do not handle the exception in a second advice class. A separate @RestControllerAdvice with @ExceptionHandler(MethodArgumentNotValidException.class) works on its own. But when problem details are switched on, Boot's handler is registered at @Order(0), ahead of yours, so it answers and yours never runs. Override the method in your ResponseEntityExceptionHandler subclass instead, as above.

A failed constraint on a query parameter goes to a sibling method, handleHandlerMethodValidationException, which you override the same way.

What to remember

CreateOrder's rules sit on its components, as @NotBlank, @Min, @Max and @Email, and @NotNull alone still accepts an empty string.

@Valid on the controller parameter makes Spring check them; without it, nothing is checked and nothing warns you.

A constraint directly on a query parameter, such as @Min(0) on page, is checked without @Valid.

With spring.mvc.problemdetails.enabled, a failed check returns a problem details body with status 400, as ASP.NET Core does.

Quick reference#

DataAnnotationsJakarta ValidationNote
[Required]@NotNullrejects null only, so an empty string still passes
[Required] on a string@NotBlankrejects null, empty and whitespace-only strings
n/a@NotEmptynull or empty; works on collections too
[StringLength(n)]@Size(max = n)a length limit; on a collection or map it limits the size
[Range(a, b)]@Min(a) @Max(b)or @Range from Hibernate Validator
[EmailAddress]@Email
[RegularExpression(p)]@Pattern(regexp = p)
[Compare]no equivalentwrite a class-level constraint
n/a@Positive, @Negative, @PositiveOrZero
n/a@Past, @Future, @PastOrPresentdates in the past or future; works on java.time types
n/a@Valid on a nested fieldvalidates the nested object's own constraints too
[CreditCard]@CreditCardNumberfrom Hibernate Validator, not the Jakarta standard set
ASP.NET CoreSpring
[ApiController] automatic 400@Valid on the parameter
AddProblemDetails()spring.mvc.problemdetails.enabled: true
IExceptionFilter@RestControllerAdvice with @ExceptionHandler

Data access, JPA and migrations#

Part P9 · ASP.NET Core to Spring Boot · Chapter 41· 12 min read· Quick reference

Spring Data JPA, built on Jakarta Persistence (JPA), is EF Core with a different philosophy. EF Core builds queries from LINQ expression trees. Java has no expression trees, so Spring Data builds queries from method names, from query strings, or from a criteria API.

Migrations are not part of the object-relational mapper (ORM). A separate tool, Flyway or Liquibase, owns the database schema, and a migration is a plain SQL file rather than generated C#.

The landscape#

EF Core is one product. Its Java equivalent is three layers, each of which you will see named in stack traces:

  • Java Database Connectivity (JDBC) is Java's ADO.NET: the low-level API that sends SQL and reads rows.
  • Jakarta Persistence (JPA) is a standard for an ORM: the annotations and interfaces. Hibernate is the implementation Spring Boot uses, so JPA is the contract and Hibernate does the work.
  • Spring Data JPA sits on top, and writes your repository classes for you.

Where EF Core configures the model in OnModelCreating, JPA uses annotations on the entity classes themselves:

C# refresherOnModelCreating: configuring the model in code
protected override void OnModelCreating(ModelBuilder b)
{
    b.Entity<Order>().ToTable("orders");
    b.Entity<Order>().Property(o => o.CustomerId).IsRequired();
}

Here are the everyday EF Core calls, and where each one went in Spring Data JPA. The quick reference lists the rest of the EF Core vocabulary:

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

Loads the order's lines in the same query, like Include(). Without it, the lines load later, one query at a time.

Optional<Order> findWithLinesById(Long id);

A method in a repository interface. Spring Data reads the name and writes the query for you; the repositories section explains how.

nativeQuery = true)

Plain SQL for your database, like FromSqlRaw. :min is a named parameter, filled from the argument marked @Param("min").

@Transactional(readOnly = true)

Runs the method in a read-only transaction. Hibernate then skips watching the loaded objects for changes, which is the nearest thing to AsNoTracking().

orders.save(newOrder);

Adds the order, like Add(). Hibernate sends the insert when it needs to, at the latest when the transaction commits, the way SaveChanges writes pending changes.

All of this rests on the entity: the class that maps to a table.

An entity#

An order in the database has an id, a customer, a total, a status, a creation time and a list of lines. Here is the same class in both frameworks:

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")
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
}
Reading the code
@Entity

Marks a class that Hibernate maps to a table: an entity. Each object is one row.

@Table(name = "orders")

The table's name. ORDER is a reserved word in SQL, so the table is orders.

@GeneratedValue(strategy = GenerationType.IDENTITY)

The database generates the id, from an identity or serial column, like EF Core's default for a numeric key.

@Column(nullable = false)

Column details, like [Column] and [Required]. A field without @Column still maps to a column, and Boot's default naming turns customerId into customer_id.

@Enumerated(EnumType.STRING)

Stores the enum by its name, such as PAID. The second gotcha below explains why that matters.

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL,

One order has many lines. mappedBy names the field on OrderLine that holds the foreign key, an order field marked @ManyToOne. cascade and orphanRemoval make saving or removing the order do the same to its lines.

protected Order() { }

JPA creates objects through a no-argument constructor and then fills in the fields. protected keeps your own code from calling it. A record cannot work this way, because its fields never change after construction.

Gotcha

Entities cannot be records. JPA needs a no-argument constructor and mutable fields so it can create objects and fill them in, and Hibernate generates subclasses of entities, called proxies, for lazy loading. Records are final and immutable. Use a class for entities and a record for the data transfer object (DTO) you expose, which is better design anyway.

Gotcha

@Enumerated defaults to ORDINAL, which stores the enum's declaration position as an integer. The entity above avoids that with EnumType.STRING. With the default, inserting or reordering a constant later makes every stored row silently mean something different. There is no error, no migration, and no way to tell from the data which mapping was in force when a row was written.

@Enumerated                              // ORDINAL by default: NEW=0, PAID=1, SHIPPED=2
private OrderStatus status;

@Enumerated(EnumType.STRING)             // always do this: stores "NEW", "PAID", ...
private OrderStatus status;

This is the same hazard as persisting ordinal(), described in Enums, except that here the unsafe option is the default. Always write EnumType.STRING, or map an explicit code column with an AttributeConverter if you want short values.

equals and hashCode on entities#

The general contract in Equality and hashing applies, but entities break the usual advice in three specific ways. The last involves Lombok, a library that generates code such as equals while compiling:

ProblemWhy
The id is null before persistan entity put in a HashSet before saving changes its hash when the id is assigned, and is then lost in the set
Hibernate hands you proxiesa lazy association is a generated subclass, so getClass() comparison fails
Lombok @Data on an entitygenerates equals over every field, which triggers lazy loading and can recurse through both sides of an association

Here is an equals and hashCode for the Order entity that survives all three:

@Entity
public class Order {

    @Id @GeneratedValue
    private Long id;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        // instanceof, not getClass(), so a Hibernate proxy still matches
        if (!(o instanceof Order other)) return false;
        // only equal when both have an id, and the ids match
        return id != null && id.equals(other.id);
    }

    @Override
    public int hashCode() {
        // constant, so the hash never changes when the id is assigned
        return getClass().hashCode();
    }
}
Reading the code
if (!(o instanceof Order other)) return false;

A proxy is a subclass of Order, so instanceof accepts it, where a getClass() comparison would not.

return id != null && id.equals(other.id);

Two unsaved orders are never equal, and saved ones are equal when their ids match.

return getClass().hashCode();

The same hash for every order, so an order stays findable in a set when it gets its id.

Gotcha

A constant hashCode looks wrong and is deliberate: it keeps the object findable in a HashSet across the change from unsaved to saved. It degrades a hash set of entities to a linear scan, which is acceptable because entity collections are small. The alternative, hashing the id, breaks the collection the moment the entity is saved.

Never put @Data or @EqualsAndHashCode from Lombok on a JPA entity. These two annotations make Lombok generate equals over every field. That is the most common cause of unexpected lazy loading, and of StackOverflowError from two entities that refer to each other.

With the entity in place, the repository is where the queries live.

Repositories#

The billing service needs orders by customer, by customer and status, and above an amount. In EF Core you would write a LINQ query each time. In Spring Data you declare an interface, and Spring writes the class at startup, working out each query from the method's name:

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface OrderRepository extends JpaRepository<Order, Long> {

    // where customer_id = ?
    List<Order> findByCustomerId(String customerId);

    // where customer_id = ? and status = ? order by created_at desc
    List<Order> findByCustomerIdAndStatusOrderByCreatedAtDesc(
            String customerId, OrderStatus status);

    // where total > ?
    List<Order> findByTotalGreaterThan(BigDecimal amount);

    boolean existsByCustomerId(String customerId);
    long countByStatus(OrderStatus status);

    // when the name would get silly, write the query
    @Query("select o from Order o join fetch o.lines where o.id = :id")
    Optional<Order> findWithLines(@Param("id") Long id);
}
Reading the code
public interface OrderRepository extends JpaRepository<Order, Long> {

An interface with no implementation: Spring Data creates the class at startup. <Order, Long> gives the entity type and the type of its id.

List<Order> findByCustomerId(String customerId);

A derived query. Spring Data parses the name: findBy starts it, CustomerId names the field, and the parameter supplies 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")

A query written by hand. It names the entity Order and its field lines, not the tables, and join fetch loads the lines in the same query.

That query string is written in the Jakarta Persistence Query Language (JPQL), which reads like SQL but works on entities and their fields. Hibernate accepts a larger language of its own, the Hibernate Query Language (HQL), of which JPQL is a subset. Here are the keywords a derived query name can use:

KeywordSQL
findBy / getBy / readByselect
And, Orand / or
Between, LessThan, GreaterThancomparisons
Like, StartingWith, Containinglike
In, NotInin
IsNull, IsNotNullis null
OrderBy...Asc/Descorder by
Top, Firstlimit
Distinctdistinct
existsBy, countBy, deleteByexists / count / delete
Note

JpaRepository already gives you save, findById, findAll, delete, paging and sorting. You only declare the queries that are specific to your domain. There is no need to write a repository class by hand unless you want one.

Queries that load an entity's collection one row at a time lead to the most common JPA problem in production.

The N+1 problem#

A report lists every order with its number of lines. The obvious loop looks harmless:

// one query for the orders, then one more for each order's lines
for (var order : orders.findAll()) {
    order.getLines().size();     // runs a query, every time round the loop
}
Reading the code
order.getLines().size();

The lines are lazy: they load only when first touched. Touching them here runs one query per order, so 100 orders cost 101 queries, hence the name N+1.

It is worse than in EF Core, because lazy loading is the default for collections, and there is no Include() to reach for out of habit. Here are the fixes, in order of preference:

// 1. JOIN FETCH in the query
@Query("select o from Order o join fetch o.lines")
List<Order> findAllWithLines();

// 2. @EntityGraph: declarative, and it works with derived queries
@EntityGraph(attributePaths = "lines")
List<Order> findByStatus(OrderStatus status);

// 3. a projection: fetch only the columns you need
interface OrderSummary { Long getId(); BigDecimal getTotal(); }
List<OrderSummary> findSummariesByStatus(OrderStatus status);
Reading the code
@Query("select o from Order o join fetch o.lines")

Loads every order with its lines in one query.

@EntityGraph(attributePaths = "lines")

Asks for the lines too, on a derived query, without writing JPQL.

List<OrderSummary> findSummariesByStatus(OrderStatus status);

Returns only the id and total, through an interface of getters. No entity is loaded, so no lazy collection can fire. The word between find and By is only a label.

In development, set spring.jpa.properties.hibernate.generate_statistics=true, or add the datasource-proxy or Hypersistence Utils libraries. An N+1 then shows up as a count of queries, rather than as slow pages in production.

Lazy loading has a second trap, which appears once the transaction has ended.

Lazy loading and detached entities#

Inside a transaction, Hibernate keeps every entity it loads in a persistence context, as EF Core's DbContext tracks what it loaded. When the transaction ends, the persistence context closes, and its entities become detached: a lazy field that was never loaded can no longer be fetched. The defaults decide which fields are lazy:

AssociationJPA defaultAdvice
@OneToManyLAZYkeep it lazy; fetch explicitly
@ManyToManyLAZYkeep it lazy
@ManyToOneEAGERset it to LAZY explicitly
@OneToOneEAGERset it to LAZY explicitly

Here an order refers to its customer, and a controller reads the customer's name after the service has returned:

@ManyToOne(fetch = FetchType.LAZY)       // EAGER by default: override it every time
private Customer customer;

// later, in a controller, after the service's transaction has closed:
order.getCustomer().getName();           // LazyInitializationException
Reading the code
@ManyToOne(fetch = FetchType.LAZY)

Many orders share one customer. LAZY loads the customer only when it is first used, instead of with every order.

order.getCustomer().getName();

The customer was never loaded, and the persistence context that could load it has closed, so Hibernate throws.

Gotcha

LazyInitializationException is the JPA rite of passage. Touching a lazy association after the transaction has closed throws it, typically in a controller after the service method has returned. EF Core has the same concept with a disposed DbContext, but JPA hits it far more often because of the defaults above.

The fix is to fetch what you need inside the transaction, or map to a DTO there. Do not reach for spring.jpa.open-in-view. Boot switches it on by default in web applications, and it papers over the problem by keeping the persistence context open for the whole request. That hides N+1 queries and holds database connections longer than necessary. Most experienced teams set it to false and fix the resulting exceptions properly.

Transactions#

Saving an order must succeed or fail as a whole. In EF Core you open a transaction yourself; in Spring you mark the service method @Transactional:

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, commits it when the method returns, and rolls it back when the method throws an unchecked exception.

Gotcha

@Transactional works through a proxy around the bean. Two consequences catch everyone:

  • Self-invocation does not work. Calling this.other() from inside the same class bypasses the proxy, so @Transactional on other() has no effect. How Spring actually works explains why, and Transactions in depth covers propagation and isolation.
  • Only unchecked exceptions roll back by default. A checked exception commits. Use @Transactional(rollbackFor = Exception.class) if you throw checked exceptions, or throw an unchecked exception instead.

Also put it on the service, not the repository: the transaction boundary is the use case.

Entities describe tables, but they do not create or change them. Migrations do.

Migrations#

The shop needs a status column on the orders table. In EF Core you change the model and let the tool generate a migration. With Flyway you write the SQL yourself, in a numbered file. Flyway runs each file once, in order, and records it in a table called flyway_schema_history:

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 file's name is the migration: V3 is its version, and the words after the two underscores describe it. Boot looks in db/migration by default.

ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'NEW';

Plain SQL for your database. Existing orders get the value NEW.

-- applied automatically at startup

With Flyway on the classpath, Boot runs any new migrations before the application starts serving.

AspectEF MigrationsFlyway
Formatgenerated C#plain SQL you write
NamingtimestampedV{version}__{description}.sql
Applieddotnet ef database updateautomatically on app startup
RollbackDown() methodforward-only; write a new migration
Baseline an existing DBpossible, fiddlyflyway.baselineOnMigrate
Checksum enforcementnoyes, editing an applied migration fails the build
Gotcha

Flyway checksums every applied migration. Edit a file that has already run in any environment, even to add a comment, and startup fails. This is a feature: it guarantees that every environment ran identical SQL. But it means the fix for a mistake is always a new migration, never an edit.

Migration checksum mismatch for migration version 3#
Means
A migration file that already ran has been changed since. Flyway stored a checksum of each file it applied, and the file on disk no longer matches.
Fix
Undo the edit, and put the change in a new migration, V4. Only when the edit is harmless, such as a comment, run flyway repair to store the new checksum.
What triggers it
-- V3__add_order_status.sql, edited after it had been applied
ALTER TABLE orders
    ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'NEW';
-- a comment added later

An existing database with tables but no Flyway history fails too, the first time Flyway runs against it:

Found non-empty schema(s) "public" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.#
Means
Flyway found tables it did not create, and will not guess which migrations they 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 it.
What triggers it
-- the database already has an orders table, and db/migration holds V1 and V2
Gotcha

Set spring.jpa.hibernate.ddl-auto=validate in every environment, so that Hibernate only checks that the mapping agrees with the schema Flyway built. Boot's default is none for a real database. For an embedded one such as H2, with no Flyway present, it is create-drop, which builds the schema at startup and drops it at shutdown. Tutorials often set update, which lets Hibernate alter your schema: convenient in a demo, catastrophic in production.

When not to use JPA#

JPA suits an object model you load, change and save. Other jobs suit other tools:

SituationBetter tool
Complex reporting queriesjOOQ, or JdbcTemplate with SQL
You want typed SQL like LINQjOOQ
Simple CRUD, no object graphSpring Data JDBC, much simpler model
Bulk operationsnative SQL; JPA is poor at bulk
Read-heavy projectionsinterface or record projections

For Dapper-style code, Spring's JdbcClient runs your SQL and maps each row to a record, with no entities and no persistence context:

record OrderRow(long id, String customerId, BigDecimal total) { }

List<OrderRow> big = jdbc.sql("select id, customer_id, total from orders where total > :min")
        .param("min", new BigDecimal("100"))
        .query(OrderRow.class)
        .list();
Reading the code
jdbc.sql("select id, customer_id, total from orders where total > :min")

jdbc is a JdbcClient, which Boot creates for you. The SQL is yours, with a named parameter.

.query(OrderRow.class)

Maps each row to an OrderRow. The column customer_id fills the component customerId.

Spring Data JDBC sits between the two: repositories as in Spring Data JPA, but no lazy loading and no proxies. An aggregate is loaded and saved whole:

// Spring Data JDBC: no lazy loading, no proxies, no session to close
record Order(@Id Long id, String customerId, BigDecimal total) { }

interface OrderRepository extends ListCrudRepository<Order, Long> {
    List<Order> findByCustomerId(String customerId);
}
Reading the code
record Order(@Id Long id, String customerId, BigDecimal total) { }

Unlike JPA, Spring Data JDBC can map a record, because it never needs to subclass or fill in an object after creating it.

Note

jOOQ deserves a look if you miss LINQ-to-SQL. It generates a typed domain-specific language (DSL) from your real database schema, so DSL.selectFrom(ORDERS).where(ORDERS.TOTAL.gt(x)) is checked when you compile, and a renamed column breaks the build. It is the closest thing in Java to what EF Core gives you, and it is honest about being SQL rather than pretending to be objects.

What to remember

The Order entity is a mutable class with a no-argument constructor, mapped with @Entity and @Id; records are for the DTOs you return.

OrderRepository is an interface, and Spring Data writes each query from the method's name, such as findByCustomerId, or from a JPQL @Query.

Collections load lazily, so looping over orders and touching their lines runs one query per order; join fetch or @EntityGraph loads them in one.

Flyway owns the schema: numbered SQL files, each applied once at startup, and an edited file stops the application.

Quick reference#

EF CoreJavaNote
DbContextEntityManagertracks loaded entities and flushes changes on commit
DbSet<T>a Spring Data repository
[Table], [Column]@Entity, @Table, @Column
OnModelCreatingannotations, or orm.xml
SaveChangesflush, usually automatic on commit
MigrationsFlyway or Liquibaseseparate tools; the ORM does not own the schema
Include()@EntityGraph, or JOIN FETCH
AsNoTracking()a read-only transaction, or a projection
FromSqlRaw@Query(nativeQuery = true)
DapperJdbcClient, JdbcTemplate, JDBI
LINQ to SQLjOOQgenerates typed Java from your real schema, so renames break the build
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#

Part P9 · ASP.NET Core to Spring Boot · Chapter 42· 7 min read· Quick reference

@Transactional looks like a checkbox, but it is really a policy: seven propagation modes, four isolation levels, and a rollback rule that surprises people. Getting it wrong produces the worst kind of bug: data that is quietly, occasionally wrong.

Hold on to three facts. Only unchecked exceptions roll back by default. Self-invocation bypasses it entirely. And REQUIRES_NEW is a separate transaction, which can wait forever on its own caller's locks.

The default#

Placing an order writes the order and its lines. If anything fails halfway, none of it may stay. In EF Core you open a transaction, commit it, and roll it back in a catch block. In Spring, one annotation does all three:

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, commits it when the method returns, and rolls it back when an unchecked exception escapes. It does this through a proxy around the service.

orders.save(order);

Every database call made while the method runs joins the same transaction, even calls inside other beans.

Put it on the service, not the repository. The transaction boundary is the use case: everything inside one business operation should commit or fail together.

The phrase “rolls back on an unchecked exception” hides the most surprising rule in the chapter, about checked exceptions.

The rollback rule#

Gotcha

A checked exception commits. Spring rolls back for RuntimeException and Error only. Throw a checked exception out of a transactional method and the transaction commits on the way out, which is almost never what the author intended.

Here a transfer takes money from one account, then finds the other account closed and throws a checked exception:

@Transactional
public void transfer(Account from, Account to, BigDecimal amount)
        throws AccountClosed {
    from.debit(amount);
    if (to.isClosed()) throw new AccountClosed();   // the debit COMMITS
}

@Transactional(rollbackFor = Exception.class)          // the fix
public void transfer(Account from, Account to, BigDecimal amount)
        throws AccountClosed { ... }
Reading the code
if (to.isClosed()) throw new AccountClosed();

AccountClosed extends Exception, not RuntimeException, so it is checked. Spring commits as it passes: the money leaves one account and arrives nowhere.

@Transactional(rollbackFor = Exception.class)

Rolls back for every exception, checked or not.

C# has no checked exceptions, so a .NET developer has no instinct for this at all. If your code throws checked exceptions from service methods, set rollbackFor, or convert them to unchecked ones at the boundary. The quick reference lists the other settings.

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 it also skips checking loaded entities for changes.

Rolling back is one policy. The other is what happens when one transactional method calls another.

Propagation#

Propagation answers one question: what should happen if a transaction is already running when this method is called? Think of a meeting already in progress. You can join it, book a separate room, or refuse to start without one:

ModeIf a transaction existsIf none existsUse for
REQUIREDjoin itstart onethe default; almost always right
REQUIRES_NEWsuspend it, start a separate onestart oneaudit rows that must survive a rollback
NESTEDa savepoint inside itstart onepartial rollback, JDBC only
SUPPORTSjoin itrun with noneread-only helpers
NOT_SUPPORTEDsuspend it, run with nonerun with nonelong non-transactional work
MANDATORYjoin itthrowassert a caller opened one
NEVERthrowrun with noneassert nobody opened one

When a payment fails, the order must disappear, but the audit trail must still show that someone tried. So the audit service runs in a transaction of its own:

@Transactional                                     // REQUIRED
public void placeOrder(CreateOrder request) {
    orders.save(new Order(request));
    audit.record("order placed");                  // see below
    throw new PaymentFailed();                     // rolls the order back
}

@Service
class AuditService {
    // survives the caller's rollback, because it is a separate transaction
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String what) {
        auditRows.save(new AuditRow(what));
    }
}
Reading the code
@Transactional // REQUIRED

REQUIRED, the default: placeOrder starts a transaction, or joins one that is already running.

audit.record("order placed");

A call to another bean, so it goes through that bean's proxy, which sees REQUIRES_NEW.

throw new PaymentFailed();

PaymentFailed is unchecked, so the order's transaction rolls back.

@Transactional(propagation = Propagation.REQUIRES_NEW)

Suspends the caller's transaction and runs record in a new one, which commits when record returns. The audit row stays, although the order is rolled back.

Gotcha

REQUIRES_NEW can deadlock against its own caller. The outer transaction is suspended but still holds its locks. If the inner transaction writes a row the outer one has already written, it waits for a lock that the outer transaction will not release until the inner one returns.

The database cannot see this as a deadlock, because the outer transaction is simply idle. So the thread waits until a lock timeout fires, if you have set one; on PostgreSQL the error is “canceling statement due to lock timeout”. Without one it waits forever. Use REQUIRES_NEW for independent writes, such as audit trails, outbox rows and failure logs, and never for the same rows the caller is changing.

Gotcha

REQUIRES_NEW takes a second connection from the pool. A pool of ten and a nested call means five concurrent operations, not ten. Pools sized without accounting for this run dry at a load the arithmetic did not predict. With a pool of one, the inner call cannot get a connection at all:

HikariPool-1 - Connection is not available, request timed out after 1006ms (total=1, active=1, idle=0, waiting=0)#
Means
Every connection in the pool is in use, here by the caller's suspended transaction, and the inner REQUIRES_NEW transaction waited for one until the pool's timeout.
Fix
Size the pool for the connections one request can hold at once, 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 one. Rolling it back undoes only the inner work, but a rollback of the outer transaction still discards everything. It works with Spring's Java Database Connectivity (JDBC) transaction manager. The Jakarta Persistence (JPA) transaction manager allows it only once you switch on its nestedTransactionAllowed flag. Even then, the savepoint does not undo changes to entities already in memory.

How much one transaction sees of another's work is the last policy, and the deepest.

Isolation#

Two customers buy the last item at the same moment. Whether each transaction sees the other's writes is set by its isolation level. The levels have the same names as in .NET:

LevelPreventsStill allows.NET name
READ_UNCOMMITTEDnothingdirty readsReadUncommitted
READ_COMMITTEDdirty readsnon-repeatable reads, phantomsReadCommitted
REPEATABLE_READnon-repeatable readsphantom readsRepeatableRead
SERIALIZABLEeverythingnothing; may abortSerializable
DEFAULTwhatever the database default isdepends on the databaseUnspecified

Each level is defined by the anomalies 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 defers to the database, and the databases disagree. PostgreSQL and Oracle default to READ_COMMITTED, MySQL InnoDB to REPEATABLE_READ, and SQL Server to READ_COMMITTED. Code that is correct on MySQL can be subtly wrong on PostgreSQL. If a business rule depends on isolation, state it explicitly rather than inheriting it.

Note

Prefer optimistic locking to raising the isolation level. A @Version field on the entity makes Hibernate check that the row has not changed since you read it. It scales far better than SERIALIZABLE, and makes the conflict explicit.

Here the order carries a version number:

@Entity
public class Order {
    @Id private Long id;
    @Version private int version;     // Hibernate manages this
}
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 an OptimisticLockException, which Spring passes on as an OptimisticLockingFailureException or a subclass of it.

The proxy trap, again#

Gotcha

@Transactional is applied by a proxy. So it does nothing when the method is called from inside the same class, when the method is private, or when the method is final. How Spring actually works explains the mechanism.

This is the shape it usually takes: a batch method calling the transactional method next to it.

@Service
public class OrderService {
    public void placeAll(List<CreateOrder> requests) {
        requests.forEach(this::place);      // no transaction, every time
    }

    @Transactional
    public void place(CreateOrder request) { ... }
}
Reading the code
requests.forEach(this::place);

this::place calls the method on the object itself, not on the proxy, so no transaction starts.

Doing something after the commit#

Sending a message from inside a transaction is a classic bug. The message goes out, then the transaction rolls back, and a downstream system now believes something that never happened. The fix is to send it after the commit:

@Transactional
public void place(CreateOrder request) {
    var order = orders.save(new Order(request));
    events.publishEvent(new OrderPlaced(order.getId()));
}

@Component
class OrderPlacedListener {

    // only runs if the transaction actually committed
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderPlaced e) {
        messaging.send(e);
    }
}
Reading the code
events.publishEvent(new OrderPlaced(order.getId()));

Publishes an application event. events is Spring's ApplicationEventPublisher, injected like any other bean.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)

Holds the event until the publishing transaction commits, then calls the method. If the transaction rolls back, the method never runs.

Gotcha

An event published when no transaction is running is dropped: the listener does not run at all. Set fallbackExecution = true on the annotation if the same event can come from code outside a transaction.

The phase decides when the listener runs:

PhaseRuns
BEFORE_COMMITbefore the commit; can still fail the transaction
AFTER_COMMITafter a successful commit; the default
AFTER_ROLLBACKonly if it rolled back
AFTER_COMPLETIONeither way
Note

For guaranteed delivery this still is not enough, because the process can die between the commit and the send. The robust pattern is the transactional outbox: write the message to a table in the same transaction, and have a separate poller publish it. It is the same solution as in .NET.

Checklist#

When a transaction misbehaves, the symptom usually points at one of these causes:

SymptomLikely cause
Annotation seems ignoredself-invocation, private method, or final class
Data committed despite an exceptionit was a checked exception; set rollbackFor
Deadlocks under loadREQUIRES_NEW touching rows the caller wrote
Connection pool exhaustedREQUIRES_NEW doubling connection use, or open-in-view holding one for the whole request
Works on MySQL, wrong on PostgreSQLisolation inherited from the database default
Lost update with no errorno @Version; add optimistic locking
Message sent for a rolled-back changepublish in AFTER_COMMIT, or use an outbox

These are the three fixes you will reach for most often:

@Transactional(rollbackFor = Exception.class)   // checked exceptions roll back too
public void transfer(Account from, Account to, BigDecimal amount) throws AccountClosed { ... }

@Version private int version;                   // a lost update becomes an exception

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void on(OrderPlaced e) { messaging.send(e); }   // sent only once the data is committed
Reading the code
@Transactional(rollbackFor = Exception.class)

For the transfer that committed half its work.

@Version private int version;

For two users saving the same order.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)

For the message about an order that was rolled back.

What to remember

@Transactional on place commits when it returns and rolls back on an unchecked exception, but a checked exception such as AccountClosed commits unless you add rollbackFor.

The audit row survived the order's rollback because AuditService.record ran in its own REQUIRES_NEW transaction, on a second connection.

A call from placeAll to place in the same class skips the proxy, so no transaction starts.

An inner REQUIRES_NEW that writes a row its caller has already written waits for a lock that is never released.

Quick reference#

SettingEffect
rollbackFor = Exception.classroll back for checked exceptions too
noRollbackFor = NotFound.classcommit even though this was thrown
readOnly = trueon PostgreSQL, writes fail; with Hibernate, no checking 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

Security#

Part P9 · ASP.NET Core to Spring Boot · Chapter 43· 7 min read· Quick reference

Spring Security is a chain of servlet filters that every request passes through before it reaches your controllers. It does the work of ASP.NET Core's authentication and authorisation, and it is more powerful but less approachable. The defaults lock everything down, and the configuration style takes some getting used to.

The mental model: the filters work out who is calling, and store the answer as an Authentication in the SecurityContext. Then authorisation rules decide what that caller may do.

Concept mapping#

The shop's API has two kinds of caller: customers, who read their own orders, and staff with the admin role, who can delete them. Callers prove who they are with a token from an identity provider, using OAuth 2.0 and OpenID Connect (OIDC), the same standards you use in .NET. The token is a JSON Web Token (JWT): a signed piece of JSON that names the user and lists what they may do.

In ASP.NET Core the signed-in user is a ClaimsPrincipal. In Spring Security it is an Authentication, and its authorities play the part of claims:

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

Where Spring Security keeps the caller of the request being handled.

auth.getName();

The caller's name; for a JWT, its subject.

.contains(new SimpleGrantedAuthority("ROLE_ADMIN"));

An authority is a plain string, such as ROLE_ADMIN. A role is simply an authority whose name starts with ROLE_.

@PreAuthorize("hasAuthority('SCOPE_orders')")

A rule written as an expression on the method, where ASP.NET Core would use a named policy. A JWT's scopes become authorities with the prefix SCOPE_, so the orders scope is SCOPE_orders.

The quick reference maps every other ASP.NET Core security type to its Spring Security counterpart. All of this is set up in one place, the filter chain.

The filter chain#

In ASP.NET Core you add authentication and authorisation services, then middleware. In Spring Security you declare one bean of type SecurityFilterChain. You configure it through a domain-specific language (DSL): a chain of method calls on HttpSecurity, each taking a lambda that sets up one part. Here the shop's API accepts JWTs and keeps admin paths for admins:

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

The URL rules, like AddAuthorization and RequireAuthorization together. Rules are checked in order, and the first one that matches decides.

.requestMatchers("/actuator/health").permitAll()

Anyone may call the health check, like [AllowAnonymous].

.hasRole("ADMIN")

Paths under /api/admin need the authority ROLE_ADMIN.

.anyRequest().authenticated())

Every other request needs a signed-in caller of any kind.

o.jwt(Customizer.withDefaults()))

Accepts JWT bearer tokens, like AddJwtBearer. withDefaults() takes its settings from application.yml, shown below.

.csrf(csrf -> csrf.disable())

Switches off protection against cross-site request forgery (CSRF). That is safe here only because the API uses tokens, not cookies; the second gotcha below explains.

SessionCreationPolicy.STATELESS))

Never creates an HTTP session: every request carries its own token.

One setting tells Spring where the tokens come from. Spring then fetches the provider's signing keys, and checks each token's signature, issuer and expiry:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://login.acme.example/realms/shop
Reading the code
issuer-uri: https://login.acme.example/realms/shop

The identity provider's address, like Authority in the JwtBearer options.

Gotcha

Rules are matched in order and the first match wins. Put anyRequest() last, always. A broad rule placed early silently shadows every rule after it, and the failure mode is an endpoint that is more open than you intended.

Gotcha

CSRF protection is on by default, including for POST requests to a JSON API. The attack it stops is another site making the user's browser send a request with the user's cookies. If your first POST returns 403 with no useful message, this is why. Disable it only for an API that browsers do not call with cookies, as here; keep it for anything cookie-based.

URL rules suit whole areas of the API. Rules about one method come next.

Method-level authorisation#

When a rule depends on the method or its arguments, such as “only the order's customer may read it”, put it on the method instead:

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')")

Checked before the method runs, like [Authorize(Roles = "Admin")]. If it fails, Spring refuses the call, with 403 Forbidden for a signed-in caller.

@PreAuthorize("@orderGuard.owns(#id, authentication)")

The rule calls a bean. @orderGuard is the bean named orderGuard, #id is the method's id parameter, and 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 {

Its bean name, orderGuard, comes from the class name with a lower-case first letter.

.map(order -> order.getCustomerId().equals(caller.getName()))

The caller owns the order when the order's customer id is the caller's name, the token's subject.

Method annotations do nothing until one configuration class switches them on:

@Configuration
@EnableMethodSecurity      // required, method annotations do nothing without it
public class MethodSecurityConfig { }
Reading the code
@EnableMethodSecurity

Makes Spring wrap beans that carry @PreAuthorize in proxies that check the rule. Without it, the annotations are silently ignored.

These are the expressions you will use most:

ExpressionMeans
hasRole('ADMIN')requires the authority ROLE_ADMIN; the prefix is added for you
hasAuthority('orders:write')requires exactly that authority string, with no prefix added
hasAnyRole('A','B')requires at least one of the roles: ROLE_A or ROLE_B
isAuthenticated()any signed-in user; anonymous requests are refused
permitAll() / denyAll()always allowed, or always refused, whoever is calling
#idrefers to the method parameter named id
authenticationthe current user's Authentication object
@beanName.method(...)call a bean, arbitrary policy logic
Gotcha

hasRole('ADMIN') checks for the authority ROLE_ADMIN. Spring adds the prefix for you in hasRole but not in hasAuthority, and mixing them up produces rules that silently never match. A JWT adds a second trap: by default only its scope claim becomes authorities, as SCOPE_…. A "roles": ["ADMIN"] claim is ignored until you map it.

This bean tells Spring to read the roles claim, and to give each role the ROLE_ prefix so that hasRole('ADMIN') matches:

@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    var roles = new JwtGrantedAuthoritiesConverter();
    roles.setAuthoritiesClaimName("roles");
    roles.setAuthorityPrefix("ROLE_");

    var converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(roles);
    return converter;
}
Reading the code
roles.setAuthoritiesClaimName("roles");

Reads authorities from the roles claim instead of scope. It replaces the default, so SCOPE_ authorities are no longer produced.

roles.setAuthorityPrefix("ROLE_");

Turns ADMIN into ROLE_ADMIN, the form hasRole checks.

Gotcha

@PreAuthorize is proxy-based, exactly like @Transactional. A call from inside the same class bypasses it entirely, so a security check that relies on self-invocation is no check at all. How Spring actually works explains the mechanism.

The deeper sections cover reading the current user, storing passwords, and Spring's null-safety annotations.

The current user#

An endpoint that returns “my orders” needs the caller's id. In ASP.NET Core you read a claim from User. In Spring, a controller parameter receives the token:

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 in the caller's validated token. getSubject() reads its sub claim, the user's id.

var auth = SecurityContextHolder.getContext()

Works in any bean, but hides the dependency. Prefer the parameter in controllers.

Gotcha

SecurityContextHolder keeps the caller in a ThreadLocal, a value that belongs to one thread. So it does not follow work to a thread you start yourself, including tasks you submit to an ExecutorService. Wrap the executor in a DelegatingSecurityContextExecutor to carry the caller across whenever you fan work out.

Passwords#

If the shop stores its own users' passwords, it must hash them. One bean chooses the algorithm:

@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
Reading the code
PasswordEncoderFactories.createDelegatingPasswordEncoder();

Hashes new passwords with bcrypt, and can still check hashes made with older algorithms.

Note

The delegating encoder stores a prefix such as {bcrypt} in the hash, so you can migrate algorithms without invalidating existing passwords. It is the equivalent of ASP.NET Identity's versioned password hashes, and it is the right default.

Null-safety in the Spring API#

Note

Spring Framework 7 moved its whole API from the Java Specification Request (JSR) 305 annotations to JSpecify Boot 4. If you use a null checker, Spring's own signatures now say exactly where null is possible, generics included. That makes the checker much more useful against Spring than it was on Boot 3. See Nullability.

Here a configuration lookup may return null, and the checker says so. env is Spring's Environment, injected like any bean:

String url = env.getProperty("rates.url");   // declared @Nullable in Spring Framework 7
int length = url.length();                   // a null checker such as NullAway flags this line
Reading the code
String url = env.getProperty("rates.url");

getProperty returns null when the setting is missing, and Spring 7 declares that with JSpecify's @Nullable.

int length = url.length();

Using url without a null check could throw, so a checker such as NullAway reports this line when you build.

What to remember

One SecurityFilterChain bean holds the URL rules, checked in order with anyRequest() last, and accepts the shop's JSON Web Tokens (JWTs) through oauth2ResourceServer.

Cross-site request forgery (CSRF) protection is on by default, so a POST without a token gets 403. Switch it off only for a token-based API that browsers do not call with cookies.

@PreAuthorize("hasRole('ADMIN')") on delete needs @EnableMethodSecurity, checks for ROLE_ADMIN, and is skipped by calls from the same class.

A JWT's scopes become SCOPE_ authorities; a roles claim counts only once a JwtAuthenticationConverter maps it.

Quick reference#

ASP.NET CoreSpring SecurityNote
AddAuthenticationSecurityFilterChain beanone bean declares how requests are authenticated
AddAuthorizationauthorizeHttpRequests(...)the URL rules, matched in order inside the same bean
[Authorize]@PreAuthorize, or a chain rule@PreAuthorize on methods, or a URL rule in the chain
[Authorize(Roles = "Admin")]@PreAuthorize("hasRole('ADMIN')")hasRole adds the ROLE_ prefix for you
[AllowAnonymous]permitAll() in the chainusually a path rule in the chain, not an annotation
ClaimsPrincipalAuthentication / Principalthe signed-in user; its authorities play the part of claims
User.Identity.Nameauthentication.getName()the principal's name, such as the JWT subject
Policy-based authorisationSpEL in @PreAuthorize, or an AuthorizationManagerwrite the rule as an expression, or as a small bean
IdentityUserUserDetailsthe user record Spring Security loads to check a password
UserManagerUserDetailsServiceloads users by name; account management is yours to build
JwtBeareroauth2ResourceServer().jwt()validates bearer JWTs; set the issuer in application.yml
Cookie authenticationformLogin() + sessionform login backed by an HTTP session
Data protectionno direct equivalentno built-in key ring; use a KMS or Spring Vault
Antiforgery tokenCSRF protection, on by defaulton by default: a POST without the token gets a 403

HTTP clients and resilience#

Part P9 · ASP.NET Core to Spring Boot · Chapter 44· 6 min read· Quick reference

RestClient is HttpClient with a fluent API. Declarative interface clients, marked @HttpExchange, are Refit.

The Polly answer changes between Boot versions. Boot 3 uses the Resilience4j library. Boot 4 adds @Retryable and @ConcurrencyLimit to Spring Framework itself, which covers the two cases most services need; for a circuit breaker you still add Resilience4j.

The client options#

The billing service asks a rates service for today's exchange rate, over HTTP. Spring offers several clients for the job:

ClientStyleUse when
RestClientfluent, blockingthe default on Boot 3.2+
WebClientfluent, reactiveyou are in WebFlux, or need streaming
@HttpExchange interfacedeclarativeyou want Refit-style typed clients
RestTemplatefluent, blockinglegacy; see below
java.net.http.HttpClientJDK built-inno Spring dependency wanted

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)

A URI template: {pair} is filled in, and escaped, from the next argument. The base address was set when the RestClient was built.

.retrieve()

Sends the request. A 4xx or 5xx response becomes an exception, like EnsureSuccessStatusCode.

.body(Rate.class);

Reads the JSON body into a Rate, here a record, record Rate(String pair, double value).

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, not for avoiding thread starvation. See Threads are cheap now.

Writing the URI and the body type at every call gets repetitive. Refit's answer is an interface, and Spring has the same idea.

Declarative clients#

You describe the rates service as an interface, one method per endpoint, and Spring writes the class that makes the calls:

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 Refit's [Get]. The same @PathVariable and @RequestBody annotations as in a controller say where each argument goes.

Quote createQuote(@RequestBody QuoteRequest request);

Sends the QuoteRequest as the JSON body, and reads the reply into a Quote.

Registering it is where the versions differ. Boot 4 added auto-configuration for @HttpExchange interfaces; on Boot 3 you build the client yourself:

// Boot 3: build the proxy factory by hand
@Configuration
class ClientConfig {

    @Bean
    RatesApi ratesApi(RestClient.Builder builder,
                      @Value("${rates.url}") String url) {
        RestClient client = builder.baseUrl(url).build();
        var adapter = RestClientAdapter.create(client);
        var factory = HttpServiceProxyFactory
                .builderFor(adapter).build();
        return factory.createClient(RatesApi.class);
    }
}
// Boot 4: declare the group; Spring registers the client bean
@Configuration
@ImportHttpServices(group = "rates", types = RatesApi.class)
class ClientConfig { }

// configured in application.yml under the group name,
// then injected like any other bean
Reading the code
return factory.createClient(RatesApi.class);

On Boot 3, HttpServiceProxyFactory creates a proxy: an object that implements RatesApi by turning each method call into an HTTP request through the RestClient.

@ImportHttpServices(group = "rates", types = RatesApi.class)

On Boot 4, one annotation does the same, and puts the interface in a group called rates whose settings live in application.yml.

On Boot 4, the group's settings sit under its name:

spring:
  http:
    serviceclient:
      rates:
        base-url: https://rates.example.com
Reading the code
base-url: https://rates.example.com

The address every RatesApi call starts from, like BaseAddress in .NET.

A remote service fails sometimes, and a client should cope. That is Polly's job in .NET.

Resilience: the Polly answer#

The rates service is occasionally unavailable for a moment, and a second try usually works. This is the clearest Boot 3 versus Boot 4 difference in day-to-day code. Spring Framework 7 added retry support to its core, so on Boot 4 @Retryable needs no separate library.

C# refresherPolly: retry, circuit breaker and timeout as a pipeline

Polly composes resilience strategies into one pipeline and runs your call through it.

var pipeline = new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 })
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions())
    .AddTimeout(TimeSpan.FromSeconds(5))
    .Build();

var rate = await pipeline.ExecuteAsync(async ct => await FetchAsync(pair, ct));

Here is the same rates client with a retry, on each Boot line:

// Boot 3: Resilience4j, added as a dependency
@Service
class RatesClient {

    @Retry(name = "rates", fallbackMethod = "cached")
    @CircuitBreaker(name = "rates", fallbackMethod = "cached")
    @Bulkhead(name = "rates")
    public Rate fetch(String pair) {
        return restClient.get()
                .uri("/rates/{p}", pair)
                .retrieve().body(Rate.class);
    }

    private Rate cached(String pair, Throwable t) {
        return cache.get(pair);
    }
}
// Boot 4: @Retryable and @ConcurrencyLimit are in
// Spring Framework core; no extra dependency
@EnableResilientMethods
@Configuration
class ResilienceConfig { }

@Service
class RatesClient {

    // core defaults: 3 retries, 1s delay, retries any exception
    @Retryable(includes = RatesUnavailableException.class,
               maxRetries = 4, delay = 200, multiplier = 2,
               jitter = 20, maxDelay = 2000)
    @ConcurrencyLimit(10)
    public Rate fetch(String pair) {
        return restClient.get()
                .uri("/rates/{p}", pair)
                .retrieve().body(Rate.class);
    }
}
Reading the code
@Retry(name = "rates", fallbackMethod = "cached")

Boot 3, with Resilience4j: the settings for the rates policy live in application.yml, and cached is called once every attempt has failed.

@Retryable(includes = RatesUnavailableException.class,

Boot 4, in core: retry only when this exception is thrown, at most 4 more times. The first wait is 200 ms and each later wait doubles, give or take 20 ms, but never exceeds 2 seconds.

@ConcurrencyLimit(10)

At most 10 calls run at once; the rest wait. This plays the part of a bulkhead, and matters with virtual threads, which have no pool to limit them.

Here is where each Polly strategy lands on each line:

PollyBoot 3 (Resilience4j)Boot 4 (Spring Framework core)
Retry@Retry@Retryable
Bulkhead@Bulkhead@ConcurrencyLimit
Circuit breaker@CircuitBreakernot in core; keep Resilience4j
Rate limiter@RateLimiternot in core; keep Resilience4j
Timeout@TimeLimiter@Retryable(timeout = ...) caps the whole retry; set a request timeout for each call
FallbackfallbackMethodnot in core; catch the exception once the retries are spent
ProgrammaticRetryRegistryRetryTemplate with RetryPolicy.builder()
Gotcha

The attribute is maxRetries, not maxAttempts. Spring Retry, the Boot 3 add-on, uses maxAttempts and counts the first call. Framework 7's core annotation uses maxRetries and counts only the retries. So the default of 3 means four calls in total, one second apart, retrying on any exception. Pasting a Boot 3 snippet into a Boot 4 codebase fails to compile, which is the good outcome. Carrying the wrong idea of the count is the bad one.

Note

Core covers retry and concurrency limiting only. It has no circuit breaker and no rate limiter, so Resilience4j remains the answer for those on both versions. @Retryable's timeout limits the whole sequence of attempts, not one call. What the core additions buy is one less dependency for the two cases most services need.

@Retryable also works on reactive methods, adding a retry to the returned Mono or Flux. Every failed attempt publishes a MethodRetryEvent, which you can listen for to log retries.

Gotcha

@Retryable, like @Transactional and @PreAuthorize, works through a proxy; see How Spring actually works. So self-invocation does not retry. And be careful retrying operations that are not idempotent: a POST that timed out may well have succeeded.

A retry helps only if a failing call ends. Without a timeout, a call may never end at all.

Timeouts#

Gotcha

Set timeouts explicitly. The defaults are effectively infinite. A hung downstream service will otherwise hold a connection until the socket dies. This is true of RestClient, RestTemplate and the JDK client alike.

For one client, set the timeouts on its request factory:

@Bean
RestClient ratesClient(RestClient.Builder builder,
                       @Value("${rates.url}") String url) {
    var factory = new SimpleClientHttpRequestFactory();
    factory.setConnectTimeout(Duration.ofSeconds(2));
    factory.setReadTimeout(Duration.ofSeconds(5));
    return builder.baseUrl(url).requestFactory(factory).build();
}
Reading the code
factory.setConnectTimeout(Duration.ofSeconds(2));

Give up if the connection is not open within 2 seconds.

factory.setReadTimeout(Duration.ofSeconds(5));

Give up if the reply does not arrive within 5 seconds. The call then fails with a ResourceAccessException, caused by a SocketTimeoutException.

On Boot 4, two settings apply the same limits to every client Boot builds:

spring:
  http:
    clients:
      connect-timeout: 2s
      read-timeout: 5s
Reading the code
read-timeout: 5s

Applies to whichever HTTP library Boot finds, trying Apache HttpClient, then Jetty, then Reactor Netty, then the JDK's own clients.

Error handling#

By default, a 4xx or 5xx response becomes a Spring exception. To turn them into your own exceptions instead, handle each status family:

Rate rate = restClient.get()
        .uri("/rates/{p}", pair)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError,
                  (req, res) -> { throw new UnknownPairException(pair); })
        .onStatus(HttpStatusCode::is5xxServerError,
                  (req, res) -> { throw new RatesUnavailableException(); })
        .body(Rate.class);
Reading the code
.onStatus(HttpStatusCode::is4xxClientError,

For any 4xx reply, run this handler instead of the default. Here it throws the shop's own UnknownPairException.

(req, res) -> { throw new RatesUnavailableException(); })

For any 5xx reply, throw the exception that @Retryable above retries on.

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 per status, such as HttpClientErrorException.NotFound for a 404, so you can catch exactly the case you expect.

RestTemplate#

LegacyRestTemplate

The original Spring HTTP client, and still in most existing codebases. It is in maintenance mode: not deprecated, but getting no new features, and the documentation steers you to RestClient, which wraps the same infrastructure with a better API.

Rate rate = restTemplate.getForObject(
        "/rates/{p}", Rate.class, pair);
Reading the code
restTemplate.getForObject(

One method per verb and result: a GET, with the body read into a Rate. The URI template works as in RestClient.

Migration is mostly mechanical, and a RestClient can be built from an existing RestTemplate's configuration with RestClient.create(restTemplate).

What to remember

RestClient fetched a Rate with get().uri(...).retrieve().body(Rate.class), and on a virtual thread a blocking call like that is exactly right.

The RatesApi interface became a working client through @GetExchange and @PostExchange, built by hand on Boot 3 and registered by @ImportHttpServices on Boot 4.

On Boot 4, @Retryable with maxRetries = 4 makes up to five calls and @ConcurrencyLimit caps how many run at once; for a circuit breaker, keep Resilience4j.

Set connect and read timeouts yourself, because the defaults wait forever.

Quick reference#

.NETSpring
httpClientFactory.CreateClient("rates")a RestClient bean, built from RestClient.Builder
GetFromJsonAsync<Rate>(url)get().uri(url).retrieve().body(Rate.class)
Refit interfacean @HttpExchange interface
ConfigureHttpClient(c => c.BaseAddress = ...)spring.http.serviceclient.<group>.base-url
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#

Part P9 · ASP.NET Core to Spring Boot · Chapter 45· 6 min read· Quick reference

Spring's messaging support maps closely to what you know. Listener annotations, such as @KafkaListener and @RabbitListener, are the consumer side. A Template class is the producer side, in the same shape as MassTransit or the raw client libraries.

For push to the browser there is no single SignalR equivalent. Spring gives you raw WebSocket, the Simple Text Oriented Messaging Protocol (STOMP) on top of it, and server-sent events. SignalR's automatic transport fallback and typed hub clients have no counterpart.

Kafka#

When an order is placed, the billing service publishes an OrderPlaced event, and the shipping service consumes it. With Kafka, the producer sends to a topic and the consumer listens on it:

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

Sends the event to the orders topic, keyed by the order's id. Events with the same key go to the same partition, so one order's events stay in order.

@KafkaListener(topics = "orders",

Spring calls this method for each record on the topic, Kafka's word for a message, and turns the JSON into an OrderPlaced. There is no loop to write.

groupId = "billing")

The consumer group. Each record goes to one listener in the group, as in any Kafka client.

Boot builds the template and the listeners from settings like these:

spring:
  kafka:
    bootstrap-servers: localhost:9092
    consumer:
      group-id: billing
      auto-offset-reset: earliest
      properties:
        spring.json.trusted.packages: "com.acme.billing.events"
    producer:
      acks: all
Reading the code
auto-offset-reset: earliest

A new consumer group starts from the oldest record still on the topic, rather than only new ones.

spring.json.trusted.packages: "com.acme.billing.events"

The JSON reader only creates classes from this package, so a message cannot make it build an arbitrary type.

acks: all

A send counts as done only when every in-sync replica has the record.

Gotcha

The listener method commits the offset by returning normally. If it throws, Spring's default error handler redelivers the record up to nine more times, with no pause. Then it logs the record at ERROR level and moves on. So an unhandled exception drops the message, leaving only a log line. Configure a DefaultErrorHandler with a DeadLetterPublishingRecoverer so failures land in a dead-letter topic rather than disappearing.

Ordering, retries and the outbox#

These settings come up once a Kafka consumer meets production traffic:

ConcernSpring Kafka
Orderingguaranteed per partition only; key by aggregate id to keep an entity's events ordered
Concurrencyconcurrency = "3" on @KafkaListener, capped by partition count
Manual acksAckMode.MANUAL plus an Acknowledgment parameter
Retry with backoffDefaultErrorHandler with an ExponentialBackOff
Dead letterDeadLetterPublishingRecoverer, publishes to topic-dlt
Batch consumptionbatch = "true" and a List parameter
TransactionsKafkaTransactionManager, but it does not span Kafka and your database

Here is the dead-letter setup the gotcha above recommends:

@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
    var toDeadLetter = new DeadLetterPublishingRecoverer(template);   // failures go to orders-dlt
    return new DefaultErrorHandler(toDeadLetter, new FixedBackOff(1000L, 3));   // 3 retries, 1 s apart
}
Reading the code
var toDeadLetter = new DeadLetterPublishingRecoverer(template);

Once the retries are spent, publishes the failed record to a topic named after the original with -dlt added, on the same partition. The dead-letter topic therefore needs at least as many partitions as the original.

new FixedBackOff(1000L, 3)

Three retries after the first delivery, one second apart: four deliveries in all.

Gotcha

There is no distributed transaction across Kafka and your database. Publishing inside an @Transactional method that later rolls back still sends the message. The standard fix is the transactional outbox: write the event to a table in the same transaction, and let a separate poller publish it. It is the same problem, and the same solution, as in .NET; Transactions in depth covers it.

RabbitMQ#

RabbitMQ speaks the Advanced Message Queuing Protocol (AMQP), and Spring AMQP is its client. The producer publishes to an exchange with a routing key; the consumer listens on a queue:

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(

Converts the event to a message and publishes it to the orders.exchange exchange with the routing key order.placed. MassTransit chooses the exchange for you; here you name it.

@RabbitListener(queues = "orders.queue")

Spring calls this method for each message on the queue, like IConsumer.

The rest is configuration you declare yourself:

ConceptSpring AMQP
Declare topology@Bean Queue / TopicExchange / Binding
SerialisationJacksonJsonMessageConverter, registered as a bean; 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 a great deal that Spring AMQP leaves to you: saga state, scheduling, conventional routing. The nearest Java equivalents are Spring Integration, Apache Camel and Axon, and none of them is a drop-in replacement.

JMS and JmsClient#

Java also has a standard messaging API, Jakarta Messaging (JMS), used with brokers such as ActiveMQ Artemis and IBM MQ. Spring has long wrapped it in JmsTemplate, and Spring Framework 7 adds a fluent JmsClient on top Boot 4:

jmsClient.destination("orders")
        .send(new OrderPlaced(order.getId()));

Optional<OrderPlaced> next = jmsClient.destination("orders")
        .withReceiveTimeout(1000)
        .receive(OrderPlaced.class);
Reading the code
jmsClient.destination("orders")

Picks the queue or topic, then describes the operation on it.

.receive(OrderPlaced.class);

Waits up to the timeout for one message, and returns it as an Optional, empty if none arrived. For a steady stream, a method marked @JmsListener works like @KafkaListener.

WebSockets and the SignalR gap#

The shop's web page shows live exchange rates, pushed from the server as they change. In ASP.NET Core that is a SignalR hub:

C# refresherSignalR hubs: server push to groups of clients
public class PricesHub : Hub
{
    public Task Subscribe(string pair) =>
        Groups.AddToGroupAsync(Context.ConnectionId, pair);
}

app.MapHub<PricesHub>("/prices");
await hub.Clients.Group("GBPUSD").SendAsync("price", price);   // push to one group
SignalR

There is no Java framework that bundles what SignalR does. SignalR gives you a hub abstraction, automatic transport negotiation and fallback, typed clients, reconnection and backplane scale-out in one library. Spring gives you the pieces:

SignalR featureSpring equivalent
Hub@Controller with @MessageMapping, over STOMP
Strongly typed hub clientnone; you send to a destination by name
Transport fallbacknone built in; WebSocket, with SockJS as an optional fallback
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 thing to a hub is a STOMP broker over WebSocket. Clients subscribe to named destinations, such as /topic/prices, and the server sends to them:

@Configuration
@EnableWebSocketMessageBroker
public class WsConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").setAllowedOriginPatterns("*");
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic");     // in-memory, single instance
        registry.setApplicationDestinationPrefixes("/app");
    }
}

@Controller
class PriceController {

    // client sends to /app/subscribe, replies go to /topic/prices
    @MessageMapping("/subscribe")
    @SendTo("/topic/prices")
    public Price onSubscribe(SubscribeRequest req) {
        return prices.current(req.pair());
    }
}

// push from anywhere in the application
messagingTemplate.convertAndSend("/topic/prices", price);
Reading the code
registry.addEndpoint("/ws").setAllowedOriginPatterns("*");

The address browsers open the WebSocket on, like MapHub. Allow only your own front end's origin in production, not *.

registry.enableSimpleBroker("/topic");

A broker inside the application, which delivers anything sent to a /topic/… destination to every client subscribed to it.

@MessageMapping("/subscribe")

Handles messages the client sends to /app/subscribe, like a hub method. prices is a service that returns the latest price.

messagingTemplate.convertAndSend("/topic/prices", price);

messagingTemplate is Spring's SimpMessagingTemplate, injected like any bean. It pushes to every subscriber, like Clients.Group(...).SendAsync.

Gotcha

enableSimpleBroker is an in-memory broker. It works perfectly on one instance and breaks the moment you scale out, because a message published on instance A never reaches a client connected to instance B. For more than one replica you need enableStompBrokerRelay pointing at RabbitMQ or ActiveMQ, which is the equivalent of adding a SignalR backplane.

Server-sent events, the simpler option#

If you only need push from server to client, and not both directions, server-sent events are far less machinery than WebSocket, and pass through most load balancers and firewalls. ASP.NET Core 10 has them built in, and Spring MVC, Spring's model-view-controller (MVC) web framework, has SseEmitter:

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

An open response that the method returns at once and fills in later. 0L means it never times out.

executor.submit(() -> {

The sending happens on another thread, from an executor of virtual threads, while the request thread is free.

emitter.send(price);

Writes one event, data:{"pair":"GBPUSD","value":1.27}, followed by a blank line. send can throw IOException, hence the try.

emitter.complete();

Ends the stream. A real price feed would keep sending instead.

Note

On WebFlux, Spring's reactive web stack, you simply return a Flux<Price> with the same produces. And on virtual threads, an SseEmitter fed from a blocking loop is now a reasonable design where it once would not have been. See Threads are cheap now.

What to remember

kafkaTemplate.send publishes OrderPlaced keyed by the order's id, and a @KafkaListener method consumes it; returning normally commits the offset.

A listener that keeps throwing has its record logged and skipped after ten deliveries, unless a DeadLetterPublishingRecoverer sends it to a dead-letter topic.

There is no SignalR: a STOMP broker over WebSocket plays the hub, and messagingTemplate.convertAndSend pushes to a destination.

enableSimpleBroker works on one instance only; with more, relay STOMP through RabbitMQ or ActiveMQ, as a SignalR backplane would.

Quick reference#

.NETSpring
ProduceAsync (Confluent.Kafka)kafkaTemplate.send(topic, key, value)
Consume loopa @KafkaListener method
IConsumer<T> (MassTransit)a @RabbitListener method
_bus.Publish(event)rabbitTemplate.convertAndSend(exchange, routingKey, event)
Clients.Group(...).SendAsyncmessagingTemplate.convertAndSend("/topic/...", payload)
TypedResults.ServerSentEventsSseEmitter, or a Flux on WebFlux

Actuator and observability#

Part P9 · ASP.NET Core to Spring Boot · Chapter 46· 7 min read· Quick reference

Actuator is health checks, metrics and diagnostics in one dependency, more than ASP.NET Core gives you out of the box. Micrometer is the metrics library, in the part of System.Diagnostics.Metrics. It is a facade, as the Simple Logging Facade for Java (SLF4J) is for logging.

The tool with no close .NET equivalent is Java Flight Recorder (JFR). It is a production-grade profiler built into the Java Development Kit (JDK), and you can switch it on against a running process.

Actuator endpoints#

The billing service runs in Kubernetes, which must know whether each instance is alive and ready for traffic, and the operations team wants metrics. One starter, spring-boot-starter-actuator, adds a set of HTTP endpoints under /actuator:

EndpointGives you.NET analogy
/actuator/healthliveness and readinessAddHealthChecks
/actuator/metricsevery metric, queryabledotnet-counters
/actuator/prometheusPrometheus scrape formatprometheus-net
/actuator/infobuild and git info
/actuator/envresolved configuration
/actuator/loggersread AND CHANGE log levels at runtimeno equivalent
/actuator/threaddumpevery thread's stackdotnet-dump
/actuator/heapdumpa heap dump filedotnet-gcdump
/actuator/mappingsevery route
/actuator/beansthe whole container
/actuator/configpropsbound @ConfigurationProperties

Most of them are hidden until you list them. Here the service exposes four, and shows health details to signed-in callers:

management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,loggers
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true        # /health/liveness and /health/readiness for Kubernetes
Reading the code
include: health,info,prometheus,loggers

The endpoints reachable over HTTP. Without this line, only health is.

show-details: when-authorized

Anonymous callers see only UP or DOWN; signed-in ones see each check. The default is never.

enabled: true

Adds /actuator/health/liveness and /actuator/health/readiness for Kubernetes probes. Boot 4 adds them by default, so this line matters on Boot 3, which adds them on its own only when it detects Kubernetes.

The same endpoints can also be published over Java Management Extensions (JMX), Java's older built-in management interface, which tools such as JConsole read. JMX is off in Boot unless you set spring.jmx.enabled=true.

Gotcha

Only health is exposed over HTTP by default: everything else must be opted in. Do not expose env, heapdump, beans or configprops publicly; they leak configuration and memory contents. Put Actuator on a separate port (management.server.port) that is not routed from the internet, or secure it.

The loggers endpoint is worth knowing about. Here an engineer raises the log level for the billing code on a running production instance:

curl -X POST localhost:8080/actuator/loggers/com.acme.billing \
     -H 'Content-Type: application/json' -d '{"configuredLevel":"DEBUG"}'
Reading the code
localhost:8080/actuator/loggers/com.acme.billing

The logger for one package, and everything below it. The service answers 204, and the new level applies at once.

-d '{"configuredLevel":"DEBUG"}'

The new level. Post null later to put it back.

Note

You gather what you need and put the level back, with no restart and no redeploy. There is no ASP.NET Core equivalent.

Custom health checks#

The billing service depends on the rates service, so its health should include it. A health check is a bean that reports up or down:

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. Its key is the bean name without the suffix: rates.

.withDetail("latencyMs", ms).build();

Extra information, shown to callers allowed to see details, as in "rates": {"details": {"latencyMs": 12}, "status": "UP"}.

return Health.down(e).build();

Reports the check as down, with the exception's message as a detail.

Note

On Boot 4, HealthIndicator and Health are in the package org.springframework.boot.health.contributor; on Boot 3 they were in org.springframework.boot.actuate.health. Boot registers checks of its own for disk space, the datasource, Redis, Kafka and most other configured infrastructure, so a great deal of this is free.

Metrics#

The billing service counts every order it places. In .NET you create a counter from a Meter. With Micrometer you register a counter with the MeterRegistry bean:

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, which holds every metric and hands them to whichever backend you use, such as Prometheus.

.tag("status", "ok")

A tag, the name for a .NET metric tag. Each distinct tag value is a separate series.

placed.increment();

Adds one each time an order is placed.

Scraped from /actuator/prometheus, the counter looks like this:

orders_placed_total{status="ok"} 1.0
Reading the code
orders_placed_total{status="ok"} 1.0

Micrometer turns the dots into underscores and adds _total, as Prometheus expects of a counter.

Micrometer's meter types match .NET's instruments:

InstrumentMicrometerUse for
CounterCountermonotonic counts
GaugeGaugea current value
HistogramDistributionSummarya distribution of values
Timer / durationTimerlatency
n/aLongTaskTimerin-flight long operations

For latency, an annotation can time a whole method:

// declarative timing, the equivalent of a filter
@Timed(value = "orders.place", percentiles = {0.5, 0.95, 0.99})
public Order place(CreateOrder cmd) { ... }
Reading the code
@Timed(value = "orders.place", percentiles = {0.5, 0.95, 0.99})

Measures how long each call takes, as the metric orders.place, with the median, 95th and 99th percentiles.

Gotcha

@Timed on a service method does nothing by default: no metric appears. Set management.observations.annotations.enabled=true and add spring-boot-starter-aspectj, which brings the AspectJ library the annotation needs. Controllers and repositories are already timed, so annotating them gives you duplicates.

Gotcha

Never tag a metric with something unbounded: a user ID, an order ID, a raw URL. Each distinct tag combination is a separate time series, and a high-cardinality tag will take down your metrics backend before it takes down your application. This is the same rule as in Prometheus generally, but Micrometer makes it easy to break by accident inside a loop.

Logging#

The service logs each order it places. The logger works the way ILogger<T> does, with one difference in the placeholders:

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

One logger per class, kept in a static final field. You code against SLF4J, and Logback, the library Boot uses, does the writing.

log.info("Placed order {} for {}", id, total);

Each {} is filled by the next argument, in order: Placed order 42 for 19.99. The placeholders have no names.

C# refresherILogger scopes and appsettings log levels
using (_log.BeginScope(new Dictionary<string, object> { ["OrderId"] = id }))
{
    _log.LogInformation("charging");    // every line in the scope carries OrderId
}
{ "Logging": { "LogLevel": { "Default": "Information", "Acme.Billing": "Debug" } } }
Gotcha

SLF4J placeholders are {} and positional, not named. Use them rather than concatenation: log.debug("x is " + value) builds the string even when debug is off, and log.debug("x is {}", value) does not. Either way the arguments themselves are evaluated, so for a genuinely expensive one, guard with if (log.isDebugEnabled()) or pass a Supplier.

In a container, logs usually go out as JSON. Boot writes JSON itself, with one setting:

logging:
  structured:
    format:
      console: ecs
Reading the code
console: ecs

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"}. gelf and logstash are the other formats.

The quick reference maps each .NET logging idea to its Java form. The deeper sections cover Logback's own configuration file, tracing and JFR.

logback-spring.xml and JSON logging#

application.yml covers levels, a console pattern and structured output. Anything more, such as per-appender routing or file rotation, needs a Logback configuration file. Name it logback-spring.xml rather than logback.xml: the -spring form is loaded by Boot, which means Spring properties and profiles work inside it.

<!-- src/main/resources/logback-spring.xml -->
<configuration>
  <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

  <springProfile name="!prod">
    <include resource="org/springframework/boot/logging/logback/console-appender.xml"/>
    <root level="INFO"><appender-ref ref="CONSOLE"/></root>
  </springProfile>

  <springProfile name="prod">
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
      <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>correlationId</includeMdcKeyName>
      </encoder>
    </appender>
    <root level="INFO"><appender-ref ref="JSON"/></root>
  </springProfile>
</configuration>
Reading the code
<springProfile name="!prod">

Applies only when the prod profile is not active: plain console output for developers.

<encoder class="net.logstash.logback.encoder.LogstashEncoder">

In production, JSON through the Logstash encoder library, including the correlation id from the mapped diagnostic context (MDC).

Serilog's vocabulary maps onto Logback's:

Serilog 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, shown in the logging section, so JSON no longer needs the Logstash encoder dependency. Prefer it on Boot 3.4 or later, and reach for the XML only when you need routing or rotation it does not cover.

Tracing#

Micrometer Tracing plus OpenTelemetry gives you distributed tracing: one trace id that follows a request across services. Boot 4 adds a dedicated starter Boot 4:

<!-- Boot 4 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>
Reading the code
<artifactId>spring-boot-starter-opentelemetry</artifactId>

Brings Micrometer Tracing with the OpenTelemetry bridge and exporter, and sets them up, so incoming and outgoing HTTP calls carry trace ids.

Note

The alternative, often the better one, is the OpenTelemetry Java agent, attached with -javaagent:opentelemetry-javaagent.jar. It rewrites the bytecode of Spring, Java Database Connectivity (JDBC), HTTP client and Kafka classes as they load, so it instruments them with no code changes. OpenTelemetry offers a similar zero-code option for .NET, set up through environment variables before the process starts.

JFR: the tool with no .NET equivalent#

JDK Flight Recorder is an always-on, low-overhead (roughly 1%) event recorder built into the JDK. You can start it against a running production process without a restart:

# start a recording on a running process
jcmd <pid> JFR.start name=diag settings=profile duration=60s filename=/tmp/rec.jfr

# or from launch
java -XX:StartFlightRecording=duration=60s,filename=rec.jfr -jar app.jar

jcmd <pid> JFR.dump name=diag filename=/tmp/now.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 by its process id. This one starts a 60-second recording with the more detailed profile settings.

jcmd <pid> JFR.dump name=diag filename=/tmp/now.jfr

Writes out what the recording holds so far, without stopping it.

Open the file in JDK Mission Control and you get allocation profiles, lock contention, garbage collection (GC) pauses, I/O, exceptions thrown, and CPU sampling, all on one timeline. Java 25 added CPU-time profiling Java 25 experimental, and events that time and trace chosen methods.

For each profiling job, here is the .NET tool and its Java counterpart:

Need.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*
What to remember

Only /actuator/health is exposed over HTTP by default; add prometheus and loggers to management.endpoints.web.exposure.include, and keep env and heapdump private.

On Boot 4, /actuator/health/liveness and /readiness are there by default, ready for Kubernetes probes.

The orders.placed counter, built with Counter.builder and the MeterRegistry, is scraped as orders_placed_total; never give a metric an unbounded tag.

Log with SLF4J's positional {} placeholders, and set logging.structured.format.console to ecs for JSON logs.

Quick reference#

.NETJavaNote
ILogger<T>SLF4J Loggeryou code against SLF4J; Logback does the actual writing
SerilogLogbackLogback is what Spring Boot uses unless you switch
NLogLog4j2
{Named} placeholders{} positional placeholdersSLF4J placeholders are positional; argument names are not kept
BeginScopeMDCper-thread key/value pairs that every log line can include
appsettings logging levelslogging.level.* in application.yml
SettingUse it to
management.endpoints.web.exposure.includechoose the endpoints reachable over HTTP
management.endpoint.health.show-detailsshow each check: 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: ecs, gelf or logstash

Caching, scheduling and events#

Part P9 · ASP.NET Core to Spring Boot · Chapter 47· 6 min read· Quick reference

Here are three cross-cutting features, meaning features that apply across many classes, and you will meet all of them within weeks. All three are proxy-based, so everything in How Spring actually works applies: no self-invocation, no final methods.

@Cacheable is IMemoryCache with the plumbing removed. @Scheduled is a BackgroundService timer, or Hangfire without the dashboard. ApplicationEventPublisher is in-process MediatR.

Caching#

The billing service asks the rates service for the same exchange rate hundreds of times a minute, and the rate changes only every few minutes. In ASP.NET Core you check a cache, and on a miss you call the service and store the result. In Spring one annotation does all of that:

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, using the argument, pair, as the key. On a hit it returns the stored value and the method does not run at all; on a miss it runs the method and stores what it returns.

These are the cache annotations, with the IMemoryCache call each one replaces:

AnnotationDoes.NET equivalent
@Cacheablereturn the cached value, or call the method and cache itGetOrCreate
@CachePutalways call the method, then update the cacheSet
@CacheEvictremove an entryRemove
@CacheEvict(allEntries = true)clear the cacheClear
@Cachingcombine several of the aboveseveral calls
@EnableCachingswitch the whole mechanism onAddMemoryCache

Here they are together, on the billing service's rates:

@Configuration
@EnableCaching          // without this, every annotation below is inert
class CacheConfig { }

@Service
public class RatesService {

    @Cacheable(value = "rates", key = "#pair + ':' + #date")
    public Rate lookup(String pair, LocalDate date) { ... }

    @CacheEvict(value = "rates", key = "#rate.pair()")
    public void invalidate(Rate rate) { }

    @Cacheable(value = "rates", unless = "#result == null")
    public Rate maybeNull(String pair) { ... }

    @Cacheable(value = "rates", condition = "#pair.length() == 6")
    public Rate onlyValidPairs(String pair) { ... }
}
Reading the code
@EnableCaching

Switches caching on, once, in any configuration class.

key = "#pair + ':' + #date"

A key built from both arguments, such as EURUSD:2026-09-13, written in Spring's expression language, where #pair is the parameter.

@CacheEvict(value = "rates", key = "#rate.pair()")

Removes the entry for this rate's pair. Rate is a record, so its pair is read with pair().

unless = "#result == null"

Runs the method, then stores the result only when it is not null. A missing rate is fetched again next time.

condition = "#pair.length() == 6"

Uses the cache only for six-letter pairs, such as EURUSD. Any other argument skips the cache entirely.

Gotcha

Forgetting @EnableCaching makes every cache annotation do nothing: silently, with no warning, and the code still works because it just calls the method every time. The same is true of @EnableScheduling and @EnableAsync. A caching layer that appears to have no effect is nearly always this.

Gotcha

condition and unless are not the same. condition is evaluated before the call and decides whether caching applies at all; unless is evaluated after and decides whether to store the result. Only unless can see #result.

The annotations work with any cache store, which you choose separately:

ProviderAddNotes
Simple (ConcurrentHashMap)nothing; the defaultno eviction, no size limit, dev only
Caffeinecom.github.ben-manes.caffeinethe default choice for in-process
Redisspring-boot-starter-data-redisone cache shared by every instance of the service
Hazelcast, Infinispantheir startersa cache spread across a cluster of nodes

Here the service uses Caffeine, an in-process cache with a size limit and expiry:

spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=5m
Reading the code
spec: maximumSize=10000,expireAfterWrite=5m

At most 10,000 entries per cache, each dropped 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 and never expires, so it is a memory leak wearing a cache costume. Configure Caffeine or Redis before anything reaches production.

Caching saves work on demand. Scheduling runs work on a timetable.

Scheduling#

Every 30 minutes the billing service reconciles its payments with the bank's records. In ASP.NET Core that is a BackgroundService with a loop and a delay. In Spring it is one annotated method:

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)

Runs reconcile once at startup, then again 30 minutes after each run finishes, like the loop with Task.Delay. It 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 * * *"six-field cron; note the leading seconds field
Gotcha

Spring cron expressions have six fields, not five. The first is seconds, so "0 0 3 * * *" means 3:00:00 every day. Paste a five-field Unix crontab line, such as "0 3 * * *", and the application refuses to start: “Cron expression must consist of 6 fields”. Also, fixedRate never overlaps runs by default: if a run overruns, the next one waits for it.

Gotcha

Every instance runs every schedule. Deploy three replicas and your nightly reconciliation runs three times. There is no built-in leader election. The standard answer is ShedLock, a library that takes a lock in a shared database or Redis.

With ShedLock, the nightly job runs on whichever instance takes the lock first:

@Scheduled(cron = "0 0 3 * * *")
@SchedulerLock(name = "reconcile", lockAtMostFor = "30m")
public void reconcile() { ... }
Reading the code
@SchedulerLock(name = "reconcile", lockAtMostFor = "30m")

Only the instance holding the lock called reconcile runs the job. If that instance dies mid-run, the lock expires after 30 minutes.

For anything richer, with retries, persistence and a dashboard, Quartz is the Hangfire equivalent.

Gotcha

The scheduler runs on a single thread by default, so one slow job delays every other job. Set spring.task.scheduling.pool.size, and on Java 21 or later consider virtual threads for jobs that block.

Scheduled jobs and requests often cause side effects, such as sending an email. Events keep those side effects out of the code that causes them.

Application events#

When an order is placed, the shop sends a confirmation email, but OrderService should not know about email. Spring's in-process publish and subscribe does the job, as MediatR's notifications do in .NET:

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

events is Spring's ApplicationEventPublisher, injected like any bean. The event can be any object; a record is the usual choice.

@EventListener

Spring calls this method for every published OrderPlaced, chosen by the parameter type. There is no interface to implement.

Gotcha

@EventListener is synchronous by default; the publisher blocks until every listener returns, on the same thread, inside the same transaction. That is often what you want, but it means a slow listener slows the request, and a listener that throws fails the caller with its exception.

Add @Async to run it on another thread, but note that this then escapes the transaction; see @TransactionalEventListener in Transactions.

The annotation has variants for when a listener should run:

AnnotationRuns
@EventListenersynchronously, in the caller's thread and transaction
@EventListener(condition = "#e.total > 100")only when the SpEL condition holds
@Async @EventListeneron another thread; outside the transaction
@TransactionalEventListenerafter the transaction commits; the safe default for side effects

The deeper section covers running a method on another thread with @Async.

@Async#

Rebuilding the monthly report takes a minute, and the request that triggers it should not wait. In .NET you would start a task; in Spring you mark the method @Async:

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 caller gets control back at once, while the method runs on another thread.

public CompletableFuture<Report> build() {

To hand back a result, return a CompletableFuture, Java's Task<T>. The caller can wait on it, or chain more work onto it.

Gotcha

@Async is proxy-based like the rest, so a self-invocation runs synchronously. That is a particularly nasty failure: the code still works, just on the wrong thread, and it matters only under load.

It also needs @EnableAsync. An @Async method may return only void or a Future, such as CompletableFuture. One returning a plain value fails when called, with “Invalid return type for async method”.

On Java 21 or later, point the executor at virtual threads, and @Async stops needing a tuned pool size:

spring:
  threads:
    virtual:
      enabled: true
Reading the code
enabled: true

Boot then runs @Async methods and scheduled jobs on virtual threads, instead of its default pool of eight threads for @Async and one for scheduling.

What to remember

@Cacheable("rates") on getRate runs the method once and serves later calls from the cache, but only after @EnableCaching; without it, the annotation is silently ignored.

@Scheduled(fixedDelay = 30, timeUnit = TimeUnit.MINUTES) runs reconcile on a single scheduler thread, and Spring cron strings have six fields, starting with seconds.

Every instance runs every schedule, so a job that must run once needs a lock such as ShedLock.

publishEvent(new OrderPlaced(...)) calls every @EventListener synchronously, on the caller's thread, and a listener that throws fails the caller.

Quick reference#

.NETSpring
_cache.TryGetValue then _cache.Set@Cacheable on the method
BackgroundService with a Task.Delay loop@Scheduled(fixedDelay = ...)
INotification and INotificationHandleran event record and an @EventListener method
_mediator.Publish(event)events.publishEvent(event)
Task.Run(...)an @Async method
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; the default is 1
spring.threads.virtual.enabledrun @Async methods and scheduled jobs on virtual threads

Testing Spring Boot#

Part P9 · ASP.NET Core to Spring Boot · Chapter 48· 6 min read· Quick reference

@SpringBootTest is WebApplicationFactory<T>: it starts the real application context for an integration test. Test slices, such as @WebMvcTest and @DataJpaTest, start only one layer, and have no ASP.NET Core equivalent.

Boot 4 breaks Boot 3 test code in three places. @MockBean is removed. @SpringBootTest no longer provides MockMvc or TestRestTemplate. And the slice annotations moved to one test module per technology.

Test slices#

The billing service has controllers, repositories and plain domain classes. Each deserves a test that starts only what it needs, because starting less makes the test faster. These are the choices:

AnnotationStartsUse for
@SpringBootTestthe whole contextend-to-end integration
@WebMvcTest(X.class)web layer only; no databasecontroller tests
@DataJpaTestJPA and an in-memory or container DBrepository tests
@JdbcTestJDBC onlyJdbcTemplate tests
@JsonTestJackson onlyserialisation tests
@RestClientTestHTTP client + mock serveroutbound client tests
(no annotation)nothing: plain JUnitunit tests, which should be most of them

On Boot 4 each slice comes from its own test module, named after the technology: @WebMvcTest from spring-boot-webmvc-test, in the package org.springframework.boot.webmvc.test.autoconfigure, and @DataJpaTest from spring-boot-data-jpa-test. On Boot 3 they all came from one module, so the imports change when you upgrade.

Note

Slices are fast because they start a fraction of the context, and Spring caches contexts across test classes with the same configuration. The corollary: every distinct configuration creates a new context, so scattering different @TestPropertySource values across many classes silently multiplies your suite's running time.

The slice you will write most often is the one for controllers.

Testing a controller#

The test checks that GET /api/orders/1 returns the order, without a database. In ASP.NET Core you start the app in memory and call it with an HttpClient. In Spring you start only the web layer, and replace the service behind the controller with a mock:

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)

Starts Spring MVC, Spring's model-view-controller (MVC) web framework, with just this controller: no services, repositories or database. It also provides a MockMvc to call it with.

@MockitoBean OrderService orders;

Puts a Mockito mock in the context as the OrderService bean, so the controller receives the mock. An unstubbed method returns an empty value, here Optional.empty(), which the controller turns into a 404.

when(orders.find(1L)).thenReturn(Optional.of(

Mockito stubbing, as in Testing: this call now returns that order.

mvc.perform(get("/api/orders/1"))

Sends a request through Spring MVC without a real server or network, then checks the status and the id in the JSON body.

Gotcha

@MockBean and @SpyBean are removed in Boot 4. Not deprecated, removed. Replace them with @MockitoBean and @MockitoSpyBean, which come from Spring Framework and have been available since Boot 3.4, so you can migrate before upgrading.

Here is the same test class, before and after the upgrade:

// Boot 3
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;

    @MockBean OrderService orders;
    @SpyBean  AuditService audit;
}
// Boot 4: @MockBean / @SpyBean no longer exist
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mvc;

    @MockitoBean    OrderService orders;
    @MockitoSpyBean AuditService audit;
}
Reading the code
@MockitoSpyBean AuditService audit;

A spy wraps the real bean: calls go through to it, and the test can check which calls were made, as with @SpyBean before.

Gotcha

Boot 4 no longer auto-configures MockMvc under @SpringBootTest. You must add @AutoConfigureMockMvc explicitly. Under @WebMvcTest it is still provided. A Boot 3 integration test that injects MockMvc fails to start after upgrading, with a “no qualifying bean” error that does not obviously point at this change. TestRestTemplate is the same: add @AutoConfigureTestRestTemplate.

Here is the difference in an integration test:

// Boot 3: MockMvc arrives automatically
@SpringBootTest
class OrderIntegrationTest {
    @Autowired MockMvc mvc;
}
// Boot 4: must be requested explicitly
@SpringBootTest
@AutoConfigureMockMvc
class OrderIntegrationTest {
    @Autowired MockMvc mvc;
}
Reading the code
@AutoConfigureMockMvc

Adds a MockMvc bean to the full application context, so the test can call every controller without starting a server.

A slice checks one layer. At least one test should check the whole path, from HTTP request to database row.

Full integration tests#

This test starts the whole billing service on a random port, against a real PostgreSQL database in a container, using the Testcontainers library:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
@Testcontainers
class OrderIntegrationTest {

    @Container
    @ServiceConnection                       // wires the datasource automatically
    static PostgreSQLContainer db = new PostgreSQLContainer("postgres:16");

    @Autowired TestRestTemplate rest;

    @Test
    void placesAnOrder() {
        var request = new CreateOrder("c1", List.of(new OrderLine("A-1", 2)));
        var res = rest.postForEntity("/api/orders", request, OrderDto.class);
        assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }
}
Reading the code
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)

Starts the real application, with a real web server on a free port.

@AutoConfigureTestRestTemplate

Boot 4 needs this to provide a TestRestTemplate, which 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 afterwards.

@ServiceConnection

Boot reads the container's address, user name and password, and points the datasource at it. It comes from the spring-boot-testcontainers module.

static PostgreSQLContainer db = new PostgreSQLContainer("postgres:16");

Testcontainers 2 class, from the org.testcontainers.postgresql package. The older PostgreSQLContainer<?> in org.testcontainers.containers is deprecated.

var res = rest.postForEntity("/api/orders", request, OrderDto.class);

A real HTTP POST to the running service, with the reply read into an OrderDto.

Note

@ServiceConnection replaces the old @DynamicPropertySource boilerplate that copied container URLs into properties. Annotate the container and Boot wires the datasource, Redis connection or Kafka bootstrap servers for you. It is one of the best quality-of-life features in modern Boot testing.

Four clients can call the application in a test:

ClientUse for
MockMvcfast; no real server, no network
TestRestTemplatea real HTTP call against a random port
WebTestClientfluent, works for MVC and WebFlux
RestTestClientnew in Spring Framework 7; a fluent test client built on RestClient

Repositories get a slice of their own.

Testing repositories#

This test checks that OrderRepository.findByCustomerId finds a saved order. It starts only Jakarta Persistence (JPA), against the same kind of container:

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)   // use the real DB, not H2
@Testcontainers
class OrderRepositoryTest {

    @Container @ServiceConnection
    static PostgreSQLContainer db = new PostgreSQLContainer("postgres:16");

    @Autowired OrderRepository orders;

    @Test
    void findsByCustomer() {
        orders.save(anOrder("cust-1"));      // anOrder builds an Order for that customer
        assertThat(orders.findByCustomerId("cust-1")).hasSize(1);
    }
}
Reading the code
@DataJpaTest

Starts JPA, the repositories and a datasource, and nothing else. Each test runs in a transaction that is rolled back at the end.

@AutoConfigureTestDatabase(replace = Replace.NONE)

By default the slice swaps in an in-memory database such as H2. Replace.NONE keeps the container's PostgreSQL instead.

assertThat(orders.findByCustomerId("cust-1")).hasSize(1);

An AssertJ check that the derived query found exactly one order.

Gotcha

@DataJpaTest replaces your datasource with an in-memory H2 by default, and wraps each test in a transaction that rolls back. Both defaults cause trouble. H2 does not behave like PostgreSQL for native queries, JSON columns or sequences, and the rollback hides constraint violations that only fire on commit. Use Testcontainers with Replace.NONE, as above.

The deeper sections cover test-only configuration, and why most tests should need no Spring at all.

Test configuration#

These annotations adjust the context a test starts:

NeedAnnotation
Override properties@TestPropertySource(properties = "x=y")
Activate a profile@ActiveProfiles("test")
Add or replace beans@TestConfiguration + @Import
Replace one bean with a mock@MockitoBean
Reset context after a test@DirtiesContext: use sparingly, it is slow

Here a test fixes the clock, so that code depending on today's date gives the same answer every run:

@TestConfiguration
static class TestClock {
    @Bean Clock clock() {
        return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
    }
}
Reading the code
@TestConfiguration

A configuration class for tests only. Import it with @Import(TestClock.class), and its beans join the test's context.

return Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);

A 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 pays the full startup cost again. One careless @DirtiesContext on a frequently run class can add minutes to a suite. Prefer resetting state explicitly.

Keep most tests plain#

Note

The most common mistake a team makes with Spring is testing everything through @SpringBootTest. Context startup dominates, the suite slows to minutes, and the tests tell you little about your logic.

Domain logic should be tested with plain JUnit and no Spring at all. That is possible precisely because constructor injection makes your services ordinary classes you can create with new. Reserve slices for the wiring, and full @SpringBootTest for a handful of end-to-end paths.

Here the pricing rules are tested with no Spring at all:

class PricingTest {                          // no Spring at all: just construct the class
    @Test
    void appliesDiscount() {
        var pricing = new Pricing(new FixedRates(Map.of("GBP", BigDecimal.ONE)));
        assertThat(pricing.total(anOrder())).isEqualByComparingTo("90");
    }
}
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");

Compares BigDecimal values by amount, so 90 equals 90.00.

What to remember

@WebMvcTest(OrderController.class) starts only the web layer and provides MockMvc, and @MockitoBean replaces OrderService with a mock.

On Boot 4, @MockBean is gone, and @SpringBootTest needs @AutoConfigureMockMvc for MockMvc and @AutoConfigureTestRestTemplate for TestRestTemplate.

@DataJpaTest rolls back each test and swaps in an in-memory database unless you set Replace.NONE; with Testcontainers and @ServiceConnection, it runs against real PostgreSQL.

Each distinct test configuration starts a new application context, so keep the number of configurations small.

Quick reference#

ASP.NET CoreSpring Boot
WebApplicationFactory<Program>@SpringBootTest
IClassFixture<...>the cached application context, shared automatically
HttpClient from app.CreateClient()MockMvc, or TestRestTemplate and RestTestClient on a random port
Boot 3Boot 4
@MockBean@MockitoBean
@SpyBean@MockitoSpyBean
MockMvc under @SpringBootTest, automaticallyadd @AutoConfigureMockMvc
TestRestTemplate under @SpringBootTest, automaticallyadd @AutoConfigureTestRestTemplate, or use RestTestClient
@WebMvcTest from spring-boot-test-autoconfigurefrom spring-boot-webmvc-test

Migrating Spring Boot 3 to 4#

Part P9 · ASP.NET Core to Spring Boot · Chapter 49· 5 min read· Quick reference

Spring Boot 4 sits on Spring Framework 7 and Jakarta EE 11. It is not the wrenching change that Boot 2 to 3 was, which renamed javax to jakarta across the whole ecosystem.

Five things still break a build on day one. They are renamed starters, Jackson 3’s package move, removed test annotations, test slices in new modules, and the switch to JSpecify.

Baseline requirements#

The billing service runs on Boot 3 and Java 17, and the team wants Boot 4. First, check what Boot 4 needs underneath it:

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>

The current Boot 4 release when this was written. Check spring.io for a newer one.

<java.version>25</java.version>

The Java version the build compiles for. Boot 4 runs on 17, so change this in a separate step, as the last gotcha in this chapter explains.

The first compile errors after that change come from the starters.

1. Renamed starters#

Modules follow a uniform spring-boot-<technology> pattern, with root packages org.springframework.boot.<technology>. A few starters changed name as a result:

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

The same web stack under its new name. Test starters follow the same pattern, one per technology, such as spring-boot-starter-webmvc-test.

Once the starters resolve, the next wave of errors comes from JSON.

2. Jackson 2 to Jackson 3#

Boot 4 defaults to Jackson 3, whose root package is tools.jackson rather than com.fasterxml.jackson. Jackson 2 support still ships, deprecated. Boot's own Jackson types were renamed to match:

Boot 3Boot 4
com.fasterxml.jackson.*tools.jackson.*
@JsonComponent@JacksonComponent
@JsonMixin@JacksonMixin
JsonObjectSerializerObjectValueSerializer
JsonValueDeserializerObjectValueDeserializer
Jackson2ObjectMapperBuilderCustomizerJsonMapperBuilderCustomizer
Jackson2ObjectMapperBuilderdeprecated for removal in Spring Framework 7; use Jackson's own builders
spring.jackson.read.*spring.jackson.json.read.*
spring.jackson.write.*spring.jackson.json.write.*

The shop's custom JSON for Money shows both kinds of change, the import and Boot's annotation:

import com.fasterxml.jackson.databind.ObjectMapper;

@JsonComponent
class MoneyJson { ... }
import tools.jackson.databind.ObjectMapper;

@JacksonComponent
class MoneyJson { ... }
Reading the code
import tools.jackson.databind.ObjectMapper;

The same class, under Jackson 3's new root package.

@JacksonComponent

Registers the serializers in MoneyJson with Boot's JSON mapper, as @JsonComponent did. It lives in org.springframework.boot.jackson.

Gotcha

The com.fasterxml.jackson to tools.jackson move is a root package rename, so every import line in every file that touches Jackson changes. A find-and-replace handles most of it. But check your dependency tree for libraries that still expose Jackson 2 types in their own APIs, because those pull Jackson 2 back in alongside Jackson 3.

Then the tests stop compiling, for reasons of their own.

3. Test annotations#

Several test annotations were removed or moved. Testing Spring Boot shows each one in code:

Boot 3Boot 4Note
@MockBean@MockitoBeanremoved in Boot 4, not deprecated; switch before you upgrade
@SpyBean@MockitoSpyBeanremoved in Boot 4, not deprecated; switch before you upgrade
MockitoTestExecutionListenerMockito's MockitoExtension
@SpringBootTest gives MockMvcadd @AutoConfigureMockMvcBoot 4 no longer adds MockMvc to @SpringBootTest by itself
@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 spring-boot-webmvc-testnow in org.springframework.boot.webmvc.test.autoconfigure
@SpringBootTest gives TestRestTemplateadd @AutoConfigureTestRestTemplateTestRestTemplate now comes from spring-boot-resttestclient
Note

@MockitoBean and @MockitoSpyBean exist from Boot 3.4 onwards. If you are on 3.4 or 3.5, migrate these before you upgrade; it removes a whole class of breakage from the upgrade commit. The same goes for injecting MockMvc into a @SpringBootTest: add @AutoConfigureMockMvc now, while it is still harmless.

The last compile-level change is to null-safety annotations.

4. JSpecify null-safety#

Spring moved its whole API from the Java Specification Request (JSR) 305 annotations to JSpecify. If your own code used org.springframework.lang.Nullable, switch the import:

// Boot 3: Spring's own annotation, from the JSR-305 days
import org.springframework.lang.Nullable;

// Boot 4: the JSpecify standard
import org.jspecify.annotations.Nullable;
Reading the code
import org.springframework.lang.Nullable;

Still present in Spring Framework 7, but deprecated, so it compiles with a warning.

import org.jspecify.annotations.Nullable;

The replacement: the same meaning, from the JSpecify standard that null checkers understand.

Note

The upside: Spring's signatures now say exactly where null is possible, generic type arguments included. A null checker such as NullAway therefore gives far better results against Spring APIs than it did on Boot 3. See Nullability.

Removals in Spring Framework 7#

Some older APIs are gone, or on their way out:

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; 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 dependency swap rather than a compile error.

In a Maven build, the switch means deleting this dependency:

<!-- remove this before upgrading; Tomcat comes back as the default -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
Reading the code
<artifactId>spring-boot-starter-undertow</artifactId>

Without it, the web starter's default server, Tomcat, is used again. If you had excluded Tomcat from the web starter to make room for Undertow, remove that exclusion too.

What you gain#

The upgrade also brings new features. Each is shown in the chapter that covers it:

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 it no longer knows, so a missed rename fails silently rather than at startup. While you upgrade, Boot's spring-boot-properties-migrator module can report renamed settings at startup:

Boot 3Boot 4
spring.jackson.read.*spring.jackson.json.read.*
spring.jackson.write.*spring.jackson.json.write.*
spring.data.mongodb.* (driver-level)spring.mongodb.*
spring.session.redisspring.session.data.redis

Here are two of them, before and after:

# Boot 3
spring.jackson.read.allow-single-quotes=true
spring.session.redis.namespace=billing

# Boot 4
spring.jackson.json.read.allow-single-quotes=true
spring.session.data.redis.namespace=billing
Reading the code
spring.jackson.json.read.allow-single-quotes=true

The same Jackson setting, with json added to its name.

spring.session.data.redis.namespace=billing

The Redis session settings, now under spring.session.data.redis.

Note

spring.data.mongodb.auto-index-creation stays where it is; it is a Spring Data setting rather than a driver setting, and the split is deliberate.

A suggested order#

Taken together, the changes suggest an order that keeps each step small:

StepDo
1Get to Boot 3.5 and Java 17+ first; fix all deprecation warnings
2Replace @MockBean and @SpyBean with @MockitoBean and @MockitoSpyBean
3Move off Undertow if you are on it
4Bump the parent to Boot 4; fix the starter names
5Run the build; work through Jackson import errors
6Add @AutoConfigureMockMvc wherever @SpringBootTest injected MockMvc
7Check the property renames above against your application.yml
8Consider dropping Resilience4j for core @Retryable where it fits
Gotcha

Do not combine a Boot 4 upgrade with a Java version upgrade in the same change. Each produces its own class of failure, and separating them makes both far easier to diagnose.

Check yourself: ASP.NET Core to Spring Boot
1You added @Transactional and nothing happens. Name three possible causes.
Self-invocation through this, a private method, or a final class or method that the Code Generation Library (CGLIB) cannot subclass. All three defeat the proxy.
2Your service class has a mutable field. What is the risk?
Spring beans are singletons by default, the opposite of ASP.NET Core's explicit lifetime choice. That field is shared across every request thread.
3An audit row must survive the caller's rollback. Which propagation, and what is the danger?
REQUIRES_NEW, a propagation mode that starts a separate transaction. It takes a second connection, and the suspended outer transaction still holds its locks, so touching the same rows makes it wait forever.
4You are upgrading to Boot 4 and your tests will not compile. What changed?
@MockBean and @SpyBean were removed, not deprecated. Use @MockitoBean and @MockitoSpyBean, and add @AutoConfigureMockMvc where @SpringBootTest used to provide MockMvc.
What to remember

The billing service's upgrade starts with the parent version and the starter names: spring-boot-starter-web becomes spring-boot-starter-webmvc.

Jackson 3 moves every import from com.fasterxml.jackson to tools.jackson, and @JsonComponent becomes @JacksonComponent.

@MockBean and @SpyBean are gone, @SpringBootTest needs @AutoConfigureMockMvc, and the test slices come from new modules.

Upgrade Boot and Java in separate steps, and move off Undertow first if you use it.

Quick reference#

ChangeBoot 3Boot 4
Parent version3.5.x4.x
Web starterspring-boot-starter-webspring-boot-starter-webmvc
Jackson importscom.fasterxml.jacksontools.jackson
Mock beans in tests@MockBean@MockitoBean
Nullable annotationorg.springframework.lang.Nullableorg.jspecify.annotations.Nullable

Packaging and native images#

Part P10 · Ship it · Chapter 50· 6 min read· Quick reference

The Java equivalent of dotnet publish is one Java archive (JAR) file with everything inside, called a fat JAR. It holds your code, every dependency and an embedded web server, and java -jar runs it. It needs a Java Virtual Machine (JVM) installed, so it is not self-contained the way a .NET single-file publish is.

For a true standalone binary, GraalVM native-image is NativeAOT: millisecond startup and small memory, at the cost of build time and restrictions on reflection.

The options#

Java has five ways to ship an application, from a bare JAR to a native binary. Each has a .NET counterpart:

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

The billing service is ready to deploy. One command builds it, and one runs it:

./mvnw package
java -jar target/billing-1.0.0.jar
Reading the code
./mvnw package

Compiles, runs the tests and builds target/billing-1.0.0.jar, with every dependency and an embedded Tomcat inside.

java -jar target/billing-1.0.0.jar

Starts the service on any machine with a JVM, like dotnet billing.dll.

Spring Boot's plugin keeps each dependency's JAR intact inside the fat JAR, rather than merging their classes. It builds the classpath from them when it starts. That avoids the classic shading problem, where two dependencies each ship a META-INF/services file and one silently wins. The quick reference lists the everyday commands for running it.

Most services then go into a container.

Containers#

The naive Dockerfile copies the fat JAR in one step, so any code change makes every server download all the dependencies again. Spring Boot fixes that by splitting the JAR into layers: dependencies change rarely, and your own classes change constantly. Each layer becomes a Docker image layer:

# build: compile, then split the jar into layers
FROM eclipse-temurin:25-jdk AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -B dependency:go-offline          # cached unless pom.xml changes
COPY src ./src
RUN ./mvnw -B package -DskipTests
RUN cp target/*.jar application.jar \
 && java -Djarmode=tools -jar application.jar extract --layers --destination extracted

# run: one image layer per Boot layer, the least-changing first
FROM eclipse-temurin:25-jre
WORKDIR /application
COPY --from=build /app/extracted/dependencies/ ./
COPY --from=build /app/extracted/spring-boot-loader/ ./
COPY --from=build /app/extracted/snapshot-dependencies/ ./
COPY --from=build /app/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]
Reading the code
RUN ./mvnw -B dependency:go-offline

Downloads the dependencies in a step of their own, which Docker reuses until pom.xml changes.

java -Djarmode=tools -jar application.jar extract --layers --destination extracted

Spring Boot's own tool splits the JAR into four folders under extracted: dependencies, spring-boot-loader, snapshot-dependencies and application.

COPY --from=build /app/extracted/dependencies/ ./

Each COPY makes one image layer. A code change alters only the last one, so a server pulls a few hundred kilobytes instead of the whole service.

ENTRYPOINT ["java", "-jar", "application.jar"]

The extracted application.jar holds only your code and refers to the extracted libraries beside it. That layout also starts faster than the fat JAR.

Spring Boot can also build an optimised image with no Dockerfile at all, using Cloud Native Buildpacks:

./mvnw spring-boot:build-image
Reading the code
./mvnw spring-boot:build-image

Builds a container image in your local Docker. The buildpacks pick a Java Runtime Environment (JRE), apply the layering above, set memory flags for the container, and run the service as a non-root user.

Note

It is the closest thing to dotnet publish /t:PublishContainer, and a good default for a team that does not want to maintain a Dockerfile.

Two tools in the Java Development Kit (JDK) build a runtime, or an installer, around your application:

ToolProducesUse for
jlinka custom runtime image with only the modules you needshrinking a container
jpackagea native installer: msi, dmg, debdesktop distribution

Here the billing service's runtime keeps only the modules it uses:

# a minimal runtime containing only what the app uses
jlink --add-modules java.base,java.sql,java.naming \
      --strip-debug --no-header-files --no-man-pages \
      --compress=zip-9 --output custom-jre

# a platform installer around it
jpackage --name Billing --input target/ --main-jar billing-1.0.0.jar \
         --runtime-image custom-jre --type dmg
Reading the code
jlink --add-modules java.base,java.sql,java.naming \

Builds a runtime from three modules, plus the modules they depend on, such as java.logging. On JDK 26 the result is about 36 MB, against about 380 MB for the whole JDK.

--runtime-image custom-jre --type dmg

jpackage wraps the JAR and that runtime in a macOS disk image; msi and deb work the same way on Windows and Linux.

Gotcha

jlink requires everything to be modular, or an automatic module. Many libraries still are not, which is why fat JARs and containers dominate on the server. It is much more useful for desktop apps and command-line tools than for Spring Boot services.

When the JVM itself is too slow to start, or too large, the answer is a native binary.

GraalVM native-image#

A billing function that runs for a few seconds on demand cannot wait for a JVM to warm up. GraalVM compiles the whole application ahead of time into one native executable, as NativeAOT does:

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

Runs Spring's ahead-of-time processing, then GraalVM's compiler. It needs a GraalVM JDK installed, and takes minutes rather than seconds.

./target/billing

The result is an ordinary executable, with no JVM needed to run it.

Here is what you trade:

AspectJVM fat JARNative image
Startup1-3 seconds30-60 milliseconds
Memory at rest200-400 MB50-100 MB
Peak throughputhigher; the JIT wins over timelower
Build timesecondsminutes
Reflectionfreemust be registered
Debugging in productionJFR, full toolingmore limited
Gotcha

Native image uses closed-world analysis: everything reachable must be known at build time. Reflection, dynamic proxies, resource loading and the Java Native Interface (JNI) all need explicit registration, or they fail at run time, not at build time.

Spring's own closed-world assumption adds more. Bean definitions are fixed at build time, so beans cannot appear or disappear while the application runs, @Profile is restricted, and @ConditionalOnProperty is not supported. A feature toggle that switches a bean on through configuration works on the JVM. In a native image it silently does not, which is a genuinely nasty way to find out.

Spring Boot's ahead-of-time processing generates most of the required metadata for you, which is why Spring works as a native image at all. A library that does its own reflection may still need a hint:

@RegisterReflectionForBinding(OrderDto.class)
@Configuration
class NativeHints { }
Reading the code
@RegisterReflectionForBinding(OrderDto.class)

Registers OrderDto, a record, for the reflection that JSON binding uses, so it can still be read and written in the native image.

So choose by workload:

Use native image whenUse the JVM when
Serverless, scale-to-zeroLong-running services
CLI toolsPeak throughput matters
Very high instance countsYou need JFR and full diagnostics
Memory is the binding constraintBuild time matters

Startup without going native#

The JVM already shortens startup with class data sharing (CDS): the JDK ships an archive of its own core classes, already parsed, and maps it into memory when it starts. Java 24 and 25 extend the idea to your application, with an ahead-of-time (AOT) cache. A training run records the classes the application loads and links, and later starts reuse them:

# Java 25: one-command AOT cache creation
java -XX:AOTCacheOutput=app.aot -jar app.jar     # training run
java -XX:AOTCache=app.aot -jar app.jar           # subsequent runs start faster
Reading the code
java -XX:AOTCacheOutput=app.aot -jar app.jar

Runs the application once as a training run, and writes the cache when it exits.

java -XX:AOTCache=app.aot -jar app.jar

Every later start reads the cache instead of loading and linking those classes again. Run it with the same JDK and the same JAR that made the cache.

Note

Java 25 finalised ahead-of-time command-line ergonomics, the one-command form above, and ahead-of-time method profiling Java 25. It also made compact object headers a supported option Java 25, which shrinks every object by four bytes. That option is off by default: switch it on with -XX:+UseCompactObjectHeaders. Together these make JVM startup and footprint on 25 noticeably better than on 21, often enough to make native image unnecessary.

LegacyWAR files and application servers

Before embedded servers, you built a web application archive (WAR) and deployed it into a shared Tomcat, JBoss or WebSphere. That model is largely gone: Spring Boot produces a JAR with the server inside it, one process per service, which is what containers want.

You will still meet WARs in older enterprise estates, and Spring Boot can still produce one by setting <packaging>war</packaging> and extending SpringBootServletInitializer. Do not choose it for anything new.

What to remember

./mvnw package builds billing-1.0.0.jar, a fat JAR that runs anywhere with a JVM through java -jar.

In a Dockerfile, split the JAR into layers with -Djarmode=tools, so a code change rebuilds only the application layer; or let spring-boot:build-image do it with buildpacks.

A GraalVM native image starts in milliseconds and uses less memory, but everything reachable must be known at build time, and @ConditionalOnProperty is not supported.

Choose native for serverless functions and command-line tools, and the JVM for long-running services, where peak throughput and diagnostics matter.

Quick reference#

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
.NETJava
dotnet publish, framework-dependent./mvnw package, giving a fat JAR
dotnet publish --self-containedjlink, a trimmed runtime around your app
PublishAotGraalVM native-image
dotnet publish /t:PublishContainer./mvnw spring-boot:build-image

The JVM at runtime: memory, GC and containers#

Part P10 · Ship it · Chapter 51· 6 min read· Quick reference

The Java Virtual Machine (JVM) is more configurable than the CLR, and more likely to need it. The two things to get right are heap sizing in a container and which garbage collector (GC) runs.

Modern defaults are good: the G1 collector, and awareness of container limits. So start by changing nothing, measure with Java Flight Recorder (JFR), and tune only what the data points at.

The memory model#

The billing service's container has a memory limit, and the JVM must fit inside it. A JVM process uses memory in several regions, and -Xmx sets the size of only the first:

RegionLimited byHolds
Heap-Xmx, or -XX:MaxRAMPercentageyour objects: a young generation for new ones, an old one for survivors
Metaspace-XX:MaxMetaspaceSizeclass metadata; unbounded 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 bounds the heap only. A container killed with OOMKill while heap usage looks fine is almost always out of metaspace, the stacks of platform threads, or direct buffers. Budget roughly 25 to 30 percent above -Xmx for everything else, or use -XX:MaxRAMPercentage and let the JVM do the arithmetic.

C# refresherGC.Collect, generations and GCSettings.LatencyMode
GC.Collect();                                   // force a full collection: rarely wise
int gen = GC.GetGeneration(order);              // 0, 1 or 2
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;   // avoid blocking gen 2 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 \

G1, the default collector; the flag only makes the choice visible.

-XX:MaxRAMPercentage=70 \

The heap may grow to 70% of the memory the container is given, like HeapHardLimitPercent.

-XX:MaxMetaspaceSize=256m \

Caps class metadata, which otherwise has no limit at all.

-XX:+ExitOnOutOfMemoryError \

On an OutOfMemoryError the process exits, so the orchestrator restarts it, instead of limping on in a broken state.

The quick reference maps each .NET GC setting to its JVM counterpart. Containers need one more piece of care.

Containers#

The JVM has been container-aware since Java 10: it reads the container's CPU and memory limits rather than the host's. But the default heap is only a quarter of the memory limit. This shows it, then takes a deliberate share instead:

# a 1 GB container: the default max heap is ~25% of it, i.e. ~256 MB
docker run -m 1g eclipse-temurin:25 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize

# take a deliberate share instead
java -XX:MaxRAMPercentage=70 -jar app.jar
Reading the code
docker run -m 1g eclipse-temurin:25 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize

Starts a JVM in a container limited to 1 GB and prints its maximum heap: 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, leaving the rest for metaspace, thread stacks and buffers.

Gotcha

Use -XX:MaxRAMPercentage rather than a fixed -Xmx in a container. A hardcoded -Xmx2g in an image that later runs with a 1 GB limit gets OOMKilled; a percentage adapts. And always set -XX:MaxMetaspaceSize: metaspace is unbounded by default, and a class-loading leak will consume the container.

The quick reference lists the flags worth setting in every container. What remains is which collector to run.

Choosing a collector#

The JVM offers several collectors, each trading pause times against throughput:

CollectorPause timesThroughputUse when
SerialGChighfine for tiny heapssmall containers, CLI tools
ParallelGChigh, but efficienthighestbatch jobs; latency does not matter
G1 (default)~10-200 msvery goodalmost everything
ZGCunder 1 msslightly lowerlarge heaps, latency-critical
Shenandoahunder 1 msslightly loweras ZGC; not in every Java build; generational mode is optional since Java 25

A single flag picks one:

java -XX:+UseG1GC        -jar app.jar    # default; leave it alone
java -XX:+UseZGC         -jar app.jar    # generational by default since Java 23
java -XX:+UseSerialGC    -jar app.jar    # small container, single CPU
Reading the code
java -XX:+UseG1GC -jar app.jar

G1 is chosen anyway, except on a machine with a single CPU or very little memory, where the JVM picks Serial by itself.

java -XX:+UseZGC -jar app.jar

ZGC does almost all its work while your threads keep running. Since Java 24 its generational mode is the only mode.

java -XX:+UseSerialGC -jar app.jar

One GC thread and the least overhead, for tools and tiny containers.

Gotcha

Old start scripts often carry -XX:+ZGenerational. On current versions of the Java Development Kit (JDK), that flag no longer exists, and the JVM refuses to start: “Unrecognized VM option 'ZGenerational'”. Delete it; generational ZGC is what -XX:+UseZGC gives you.

Note

ZGC's pause times are essentially independent of heap size. A 100 GB heap pauses about as long as a 4 GB one, because the work is concurrent. There is no .NET equivalent: even Server GC with background collection has pauses that scale with the heap. If you have a large-heap, latency-sensitive service, this is a genuine reason to prefer the JVM.

The deeper sections cover diagnosing a JVM in production, and why it starts slow and gets faster.

Diagnosing#

When a service misbehaves, these commands inspect the running JVM. jcmd comes with the JDK and finds a JVM by its process id:

jcmd -l                              # list JVMs, like dotnet-counters ps
jcmd <pid> VM.flags                  # what flags are actually in effect
jcmd <pid> GC.heap_info              # heap usage now
jcmd <pid> Thread.print              # full thread dump
jcmd <pid> GC.class_histogram        # what is on the heap, by class
jcmd <pid> GC.heap_dump /tmp/heap.hprof      # heap dump for Eclipse MAT
jcmd <pid> JFR.start duration=60s filename=/tmp/r.jfr

jmap -dump:live,format=b,file=heap.hprof <pid>   # the older way to take a heap dump

java '-Xlog:gc*:file=gc.log:time,uptime' -jar app.jar   # GC logging
Reading the code
jcmd <pid> VM.flags

The flags in effect, including the ones the JVM chose for itself, such as the collector.

jcmd <pid> GC.class_histogram

Counts objects by class, largest first: the quickest way to see what is filling the heap.

jcmd <pid> GC.heap_dump /tmp/heap.hprof

Writes every object on the heap to a file, for analysis in a tool such as Eclipse MAT, like dotnet-gcdump.

java '-Xlog:gc*:file=gc.log:time,uptime' -jar app.jar

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.

Each symptom points at a first place to look:

SymptomLook at
Container OOMKilled, heap looks finemetaspace, thread stacks, direct buffers
Long pausesGC logs; consider ZGC
High CPU, low throughputJFR CPU profile, or async-profiler
Memory grows steadilyheap dump, then Eclipse MAT dominator tree
Threads climbingthread dump; a leaked executor
Slow startupAOT cache, CDS, or the auto-configuration report
Gotcha

Thread stacks are the hidden cost of platform threads. Each platform thread reserves around 1 MB. A pool of 500 threads is 500 MB of address space before your application allocates anything. This is one more reason virtual threads matter: their stacks live on the heap and start at a few hundred bytes. See Threads are cheap now.

The JIT and warmup#

Note

The JVM starts by interpreting your code. Then its just-in-time (JIT) compiler compiles the methods that run often: first quickly, with the C1 compiler, then thoroughly, with C2, for the hottest. Peak performance therefore arrives only after thousands of executions. That is why a Java benchmark that does not warm up is worthless, and why the Java Microbenchmark Harness (JMH) exists. It is BenchmarkDotNet's counterpart, and handles warmup, forking and dead-code elimination for you.

The practical consequence in production: the first few hundred requests after a deploy are measurably slower. Java 25's ahead-of-time (AOT) method profiling reduces this, by carrying profile data from a training run. If you run a load balancer with aggressive health checks, give a new instance a moment before sending it full traffic.

You can watch the compiler work:

# watch the JIT at work: each line is a method being compiled as it gets hot
java -XX:+PrintCompilation -jar app.jar | head
Reading the code
java -XX:+PrintCompilation -jar app.jar | head

Prints one line for each method the JIT compiles, so you can see which of your methods turn hot as the service warms up.

Check yourself: Ship it
1Your container is OOMKilled but the heap graph looks flat. Where is the memory?
Outside the heap: metaspace, thread stacks or direct buffers. -Xmx bounds the heap only, so budget 25 to 30 percent above it for everything else.
2Why prefer -XX:MaxRAMPercentage to a fixed -Xmx in an image?
A hardcoded -Xmx2g in an image later run with a 1 GB limit gets OOMKilled. A percentage adapts to whatever limit the container is given.
3What does UnsupportedClassVersionError: class file version 69.0 mean?
The code was compiled by a newer JDK than the one running it. Subtract 44: 69 is Java 25, 65 is 21, 61 is 17. Check the build JDK against the runtime image.
4What is the Java equivalent of a self-contained dotnet publish?
A Java archive (JAR) with every dependency inside, called a fat JAR, which still needs a JVM present. For a true standalone binary you need jlink, jpackage, or GraalVM native-image.
What to remember

-Xmx bounds only the heap, so a container killed while the heap looks fine has run out of metaspace, thread stacks or direct buffers.

In a container, set -XX:MaxRAMPercentage rather than a fixed -Xmx: the default is 25% of the limit, 256 MB in a 1 GB container.

G1 is the default and right for almost everything; ZGC keeps pauses under a millisecond on large heaps.

Old start scripts with -XX:+ZGenerational no longer start, because generational ZGC is now the only mode.

Quick reference#

.NETJavaNote
Server GCG1 (default)the default, except on very small machines, where Serial is chosen
Workstation GCSerialGCa single-threaded collector, for small heaps and tools
GCHeapHardLimit-Xmx / -XX:MaxRAMPercentagecaps the heap, not the whole process
DOTNET_GCHeapCount-XX:ParallelGCThreadshow many threads the collector uses in parallel
GC.Collect()System.gc()HotSpot runs a full collection; -XX:+DisableExplicitGC turns it off
Gen0/1/2Young / OldG1 is region-based, not strictly generational
LOHhumongous regions in G1large objects get their own regions, and collect poorly
GCSettings.LatencyModechoice of collectorpick ZGC or Shenandoah when pauses matter more than throughput
FlagDoes
-XX:MaxRAMPercentage=70heap as a share of the container limit
-XX:InitialRAMPercentage=70avoid heap resizing churn
-XX:MaxMetaspaceSize=256mbound class metadata
-XX:+ExitOnOutOfMemoryErrordie rather than limp; let the orchestrator restart you
-XX:+HeapDumpOnOutOfMemoryErrorwrite a dump for analysis
-XX:HeapDumpPath=/dumpswhere to write it
-Xss512ksmaller platform thread stacks

Appendix A: Java 9 to 25#

Part APP · Appendices · Chapter 52· 3 min read

What landed when, and, crucially, whether it is final, preview, or was withdrawn. Roughly half the Java features people blog about are still preview-only, and preview features need --enable-preview and can change or vanish between releases.

Releases with long-term support (LTS) are marked. Target an LTS. Every row names the chapter that shows the feature in code; search for the chapter's title to jump there.

Note

Java 21, 22, 24 and 25 below are verified against their openjdk.org project pages. Entries for 9 through 20 and for 23 are widely documented and stable, but were not re-verified for this edition. Check openjdk.org/projects/jdk/<n> if you rely on an exact release number.

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 declarations25finalglobal usingAccess modifiers and packages
Compact source files, instance main25finaltop-level statementsGetting a JDK
Flexible constructor bodies25finalno restriction to removeClasses, 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 23, 24 or 25. Java has no string interpolation, and any tutorial showing STR."..." is describing a feature that no longer exists. See var, strings and text blocks.

Concurrency#

Each feature arrived through a proposal to the Java Development Kit (JDK), called a JDK Enhancement Proposal (JEP). The biggest concurrency changes were virtual threads, now final, and structured concurrency, still a preview:

FeatureReleaseStatusNote
CompletableFuture improvements9finaltimeouts and delayed executors; see CompletableFuture and Task
Virtual threads21finalJava's answer to async and await; see Threads are cheap now
Synchronize virtual threads without pinning24finalJEP 491 removes the synchronized trap; see Threads are cheap now
Scoped values25finalthe AsyncLocal analogue; see Structured concurrency
Structured concurrency25PREVIEWfifth preview, and the API has changed; see Structured concurrency
Stable values25PREVIEWa Lazy<T> analogue the JIT can treat as a constant; see Locks and atomics
Gotcha

Structured concurrency is still preview in Java 25: JEP 505, the fifth preview. Virtual threads are final and safe; the scope API around them is not. Anything you read from 2023 or 2024 uses an earlier API shape.

Library and API#

FeatureReleaseStatusNote
Collection factories List.of, Map.of9finalunmodifiable collections in one call; see Collections
Stream takeWhile, dropWhile, iterate9finalLINQ's TakeWhile and SkipWhile; see Streams vs LINQ
Optional.stream, ifPresentOrElse9finalmore ways to use a value that may be absent; see Nullability
New HTTP client (java.net.http)11finalthe JDK's own HttpClient analogue; see HTTP clients and resilience
String isBlank, lines, strip, repeat11finalIsNullOrWhiteSpace and friends; see var, strings and text blocks
Files.readString, writeString11finalFile.ReadAllText and WriteAllText; see Files and I/O
Collectors.teeing12finalcombines two collectors into one result; see Streams vs LINQ
Stream.toList()16finalreplaces collect(toList()); see Streams vs LINQ
Sequenced collections21finalgetFirst, getLast and reversed(); see Collections
Foreign Function and Memory API22finalthe P/Invoke analogue; see Going deeper
Class-File API24finalthe Reflection.Emit analogue; see Going deeper
Stream gatherers24finalcustom intermediate operations, such as Chunk; see Streams vs LINQ
Ahead-of-Time Class Loading and Linking24finalfaster startup from a training run; see Packaging and native images
Permanently disable the Security Manager24finalit was already deprecated; see The Java you'll inherit
ZGC: remove non-generational mode24finalgenerational is now the only ZGC mode; see 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 API25INCUBATORtenth incubation; System.Numerics.Vector analogue

Runtime, garbage collection (GC) and tooling#

FeatureReleaseStatusNote
jshell (REPL)9finalthe Java REPL, for trying an API quickly; see Getting a JDK
JPMS modules9finalmodule-info.java; see Modules, JPMS and the missing internal
Single-file source launch11finalrun java Foo.java with no compile step; see Getting a JDK
Flight Recorder open-sourced11finalJFR, free to use in production; see Actuator and observability
Helpful NullPointerExceptions14finalthe message names the null expression; on by default since Java 15
Strong encapsulation of JDK internals17finalold libraries that reach into JDK internals now fail; see Modules, JPMS
Deprecate finalization for removal18deprecatednever override finalize(); see Exceptions and resources
Generational ZGC21finalZGC gained generations, cutting 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 ask otherwise
Compact object headers25finalan option, off by default: -XX:+UseCompactObjectHeaders shrinks every object's header
Ahead-of-time command-line ergonomics25finalone flag creates the AOT cache; see Packaging and native images
Ahead-of-time method profiling25finalprofiles from a training run make warm-up faster; see The JVM at runtime
Generational Shenandoah25finala supported option, not the default: -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; lacks almost everything above
Java 92017modules, jshell, collection factories
Java 112018LTSHTTP client, var refinements, single-file launch
Java 172021LTSsealed types, strong encapsulation
Java 212023LTSvirtual threads, pattern matching, sequenced collections
Java 252025LTSscoped values, compact source files, AOT, compact headers
Note

Java ships every six months, in March and September; every fourth release is an LTS, so an LTS lands every two years. Non-LTS releases receive six months of updates. Use an LTS in production, and treat non-LTS releases as a way to try preview features early.

Appendix B: C# to Java, A to Z#

Part APP · Appendices · Chapter 53· 2 min read

Every C# construct, keyword, type and library, alphabetically, with its Java answer. This is the page to reach for when you know the C# word and need the Java one.

Faster still: press ⌘K and type the C# name; every row of every table in this book is indexed.

Rows that point at runtime concepts, such as assembly probing and the classpath, lead to How Java runs your code. Read that chapter once before relying on them.

A to C#

C#JavaChapter
abstractabstractInterfaces and inheritance
Action<T>Consumer<T>Methods and parameters
AggregateExceptionCompletionException / ExecutionExceptionCompletableFuture
AppContext.GetDataSystem.getPropertyHow Java runs your code
asno equivalent; instanceof patternSealed types and patterns
assembly probingthe classpathHow Java runs your code
async / awaitnothing, use virtual threadsThreads are cheap now
AsyncLocal<T>ScopedValueStructured concurrency
AutoMapperMapStructEcosystem
basesuperClasses and members
BenchmarkDotNetJMHTesting
boolbooleanNumbers, money and time
byte (unsigned)no equivalent; byte is signedNumbers, money and time
CancellationTokenThread.interrupt, or a scopeStructured concurrency
checked / uncheckedno equivalent; Math.addExactWeek-one gotchas
classclassClasses and members
conststatic finalFields and properties
ConcurrentDictionaryConcurrentHashMapLocks and atomics
ConfigureAwaitno equivalent; not neededThreads are cheap now

D to F#

C#JavaChapter
DataAnnotationsJakarta Bean ValidationValidation and errors
DateTimeLocalDateTime / InstantNumbers, money and time
DateTimeOffsetOffsetDateTime / InstantNumbers, money and time
DbContextEntityManager / repositoryData access
decimalBigDecimalNumbers, money and time
default(T)null, or a primitive defaultGenerics
delegatea functional interfaceMethods and parameters
deps.jsonthe classpath, or the manifest Class-PathHow Java runs your code
Dictionary<K,V>HashMap<K,V>Collections
dotnet CLImvn / gradleMaven vs csproj
dynamicno equivalentGenerics
Entity FrameworkHibernate / Spring Data JPAData access
enumenum: far more powerfulEnums
Environment.GetEnvironmentVariableSystem.getenvHow Java runs your code
eventa listener list, or a functional interfaceInterfaces and inheritance
Expression<T>no equivalent; no expression treesStreams vs LINQ
extension methoda static utility, or a default methodMethods and parameters
[Flags]EnumSetEnums
FluentAssertionsAssertJTesting
FluentValidationBean ValidationValidation and errors
Func<T,R>Function<T,R>Methods and parameters

G to L#

C#JavaChapter
GetHashCodehashCodeEquality and hashing
GetType()getClass()Classes and members
global usingno equivalentAccess and packages
gotolabelled break / continueSwitch expressions
HttpClientRestClient / java.net.http.HttpClientHTTP clients
IAsyncDisposableno equivalentExceptions and resources
IAsyncEnumerable<T>no equivalent; a BlockingQueueThreads are cheap now
IComparable<T>Comparable<T>Equality and hashing
IDisposableAutoCloseableExceptions and resources
IEnumerable<T>Iterable<E>Collections
IEnumerator<T>Iterator<E>Collections
ILogger<T>SLF4J LoggerActuator and observability
in parameternot neededMethods and parameters
initfinal field, or a recordFields and properties
internalpackage-private, or a moduleAccess and packages
IOptions<T>@ConfigurationPropertiesDI and configuration
is T xinstanceof T xSealed types and patterns
locksynchronized / ReentrantLockLocks and atomics
LINQStreamsStreams vs LINQ
List<T>ArrayList<E>Collections

M to R#

C#JavaChapter
MediatRSpring events / AxonCaching, scheduling and events
MoqMockitoTesting
namespacepackageAccess and packages
NativeAOTGraalVM native-imagePackaging
Newtonsoft.JsonJacksonEcosystem
NodaTimejava.timeNumbers, money and time
NuGetMaven CentralMaven vs csproj
NuGet global packages folder~/.m2/repositoryHow Java runs your code
nameofno equivalentAnnotations
Nullable<T> / T?the wrapper type; Optional for returnsNullability
null-forgiving !no equivalentNullability
null-conditional ?.Optional.map, or an explicit checkNullability
null-coalescing ??Objects.requireNonNullElseNullability
operator overloadingno equivalentMethods and parameters
out parameterreturn a record, or OptionalMethods and parameters
override@Override; an annotation, not a keywordInterfaces and inheritance
paramsvarargs, Object...Methods and parameters
partial classno equivalentClasses and members
PollyResilience4j, or @Retryable on Boot 4HTTP clients
Predicate<T>Predicate<T>Methods and parameters
ProblemDetailsProblemDetailValidation and errors
propertygetX / setX, or a record componentFields and properties
readonlyfinalFields and properties
recordrecordRecords
record structno equivalentRecords
ref parameterno equivalentMethods and parameters
Refit@HttpExchange interfacesHTTP clients

S to Z#

C#JavaChapter
sealed classfinal classInterfaces and inheritance
SerilogLogback via SLF4JEcosystem
SignalRWebSocket + STOMPMessaging and WebSockets
Span<T>ByteBuffer / MemorySegmentMethods and parameters
static classfinal class, private constructorClasses and members
stringStringvar, strings and text blocks
string interpolationnone: concatenation or formatted()var, strings and text blocks
structno equivalent, use a recordClasses and members
Swashbucklespringdoc-openapiControllers and binding
switch expressionswitch expressionSwitch expressions
System.Text.JsonJacksonEcosystem
Task<T>CompletableFuture<T>CompletableFuture
Task.Runexecutor.submitThreads are cheap now
Task.WhenAllStructuredTaskScope, or futuresStructured concurrency
TestcontainersTestcontainers, same projectTesting
this() constructor chainingthis(...) in the bodyClasses and members
ThreadPoolExecutorServiceLegacy concurrency
ToStringtoStringClasses and members
TryParsecatch NumberFormatExceptionNumbers, money and time
typeof(T)T.class, or Class<T>Generics
uint / ulongno equivalentNumbers, money and time
using directiveimportAccess and packages
using statementtry-with-resourcesExceptions and resources
varvarvar, strings and text blocks
virtualthe default; use final to preventInterfaces and inheritance
volatilevolatile, stronger in JavaLocks and atomics
where T : X<T extends X>Generics
with expressionno equivalentRecords
xUnitJUnit 5Testing
yield returnno equivalent; Stream.iterateStreams vs LINQ
Note

The rows worth committing to memory, because they are the ones that change how you design rather than merely how you type:

C#JavaWhy it matters
async / awaitvirtual threadsno function colouring; write blocking code
IEnumerable<T>Iterable<E>not Iterator; the wrong one gives single-use APIs
decimalBigDecimalmethod-call arithmetic; the top money-bug source
sealedfinalJava's sealed is a different, better feature
protectedwider than C#'spackage access comes with it
no modifierpackage-privateinverted from C#
Expression<T>nothingno LINQ-to-SQL is possible

Appendix C: The Java you'll inherit#

Part APP · Appendices · Chapter 54· 15 min read· Quick reference

Java is thirty years old, and it almost never breaks old code: most code written for Java in 1999 still compiles today. So a real codebase grows in layers, like rock in a cliff face, with the oldest style at the bottom and each later era laid on top.

On an older team you may find three layers in one repository: an early web framework called Struts, Spring set up in XML files, and Spring Boot. This appendix is the field guide. For each layer it says what it is, roughly when it was written, and what replaced it.

Three parts matter in your first months: dating a codebase, the rename from javax to jakarta, and the pieces Java has removed. The history of each older technology is folded away; switch to Complete to read it.

Four eras#

Java's history falls into four eras, each with its own house style. The first was built on the Java 2 Platform, Enterprise Edition (J2EE), a set of standards for business software that ran inside large shared servers. The last column names what .NET developers were using at the same time, often the quickest way to picture an era:

EraYearsHouse style.NET contemporary
Applets and J2EE1995-2005XML configuration files, heavyweight application servers.NET Framework 1.x
Spring and annotations2005-2014Spring XML, then annotations; Maven.NET 2.0-4.5, WebForms
Boot and microservices2014-2020Spring Boot, embedded servers, DockerASP.NET Core 1-3
Modern Java2020 onwardrecords, virtual threads, Jakarta.NET 5-10

You rarely need the exact year. What helps is looking at a file and knowing which era's style it was written in, because that tells you whether advice about it is still current.

Dating a codebase at a glance#

You can already date C# code by its style. A class full of ArrayList and Hashtable was written before generics arrived in 2005, and one using async and await was written after 2012. Java code gives itself away in the same manner, and each signal in this table points to an era:

If you 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; 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

Here is one small class written in two eras. It is a Spring service that returns the shop's orders, biggest total first, using an OrderRepository interface whose findAll() method returns a List<Order>. This is how it looked around 2010:

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class OrderReport {

    @Autowired
    private OrderRepository repository;

    public List<Order> biggestFirst() {
        List<Order> orders = new ArrayList<Order>(repository.findAll());
        Collections.sort(orders, new Comparator<Order>() {
            public int compare(Order a, Order b) {
                return b.getTotal().compareTo(a.getTotal());
            }
        });
        return orders;
    }
}
Reading the code
@Autowired

Field injection. Spring creates the OrderReport, then writes the repository straight into the private field. There is no constructor, so the class can be created without a repository, for example in a test. An @Autowired field is typical of Spring's annotation era, about 2007 to 2016.

List<Order> orders = new ArrayList<Order>(repository.findAll());

The type argument <Order> is written twice. Java 7 added the diamond, <>, in 2011, so the compiler can fill it in; code that repeats it is older, or written from habit. Copying into a new list also gives a list that can be sorted in place.

Collections.sort(orders, new Comparator<Order>() {

An anonymous class: a whole class written inline, only to pass one method, compare. Java had no lambdas until Java 8, in 2014, so this was the only way.

return b.getTotal().compareTo(a.getTotal());

Comparing b with a, rather than the other way round, puts the biggest total first. getTotal() is a getter on an ordinary class, the way data was exposed before records.

The same class written today uses constructor injection and a record. Here Order is a record, so its total is read with total() rather than getTotal():

import java.util.Comparator;
import java.util.List;

import org.springframework.stereotype.Service;

@Service
public class OrderReport {

    private final OrderRepository repository;

    public OrderReport(OrderRepository repository) {
        this.repository = repository;
    }

    public List<Order> biggestFirst() {
        return repository.findAll().stream()
                .sorted(Comparator.comparing(Order::total).reversed())
                .toList();
    }
}
Reading the code
private final OrderRepository repository;

Constructor injection: Spring passes the repository to the constructor, so the field can be final and the class cannot exist without it. A class with one constructor needs no @Autowired at all.

.sorted(Comparator.comparing(Order::total).reversed())

Order::total is a method reference to the record's total() method, and it replaces the whole anonymous class. reversed() turns smallest-first into biggest-first.

.toList();

Returns a list that cannot be changed. This method arrived in Java 16.

Neither version is wrong: the older one still runs on Java 25 and Spring 7. But a class like the first tells you its neighbours were probably written in the same era, with the same habits. One signal from the table, the javax import, marks the biggest break of all.

The javax to jakarta rename#

Suppose a Spring Boot 2 service has to move to Spring Boot 3. You change the version number, and nearly every entity, controller and validator stops compiling. The cause is not a bug; it is a trademark.

Java's enterprise standards, the Java Enterprise Edition (Java EE), lived under the javax names. In 2017 Oracle handed Java EE to the Eclipse Foundation, which later renamed it Jakarta EE. Oracle kept the Java trademarks, and the agreement did not let Eclipse change anything under the javax names. So Jakarta EE 9, in 2020, moved every enterprise API package from javax to jakarta:

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)

In code, the change is confined to the import lines. The classes, annotations and methods keep their names; only the package in front of them changes:

// Spring Boot 2 uses the javax names.
import javax.persistence.Entity;
import javax.validation.constraints.NotNull;

// Spring Boot 3 and 4 use the jakarta names for the same classes.
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotNull;

Entity comes from Jakarta Persistence (JPA), the standard behind Hibernate, and NotNull from Jakarta Validation. The java.* packages, such as java.util, were never affected: only the enterprise standards moved.

Gotcha

This is a hard break, not a deprecation. Spring Boot 2 uses javax; Spring Boot 3 and 4 use jakarta, and nothing in the framework translates between them. Upgrading from Boot 2 to Boot 3 means changing the imports in every entity, controller and validator, and replacing any library that still uses javax. Tools such as OpenRewrite and the Eclipse Transformer can do the renaming, but plan it as a project, not an afternoon.

The rename changed names. Java itself has also removed a handful of whole features, and one of those removals causes the other upgrade failure you are likely to meet.

What Java removed#

Here is the typical story. A service that exports invoices as XML has run on Java 8 for years. You move it to Java 11. It starts normally, then fails the first time it exports an invoice, with this error:

java.lang.NoClassDefFoundError: javax/xml/bind/JAXBContext#
Means
The code uses the Java Architecture for XML Binding (JAXB), the library that turns objects into XML and back. Java 8 shipped it inside the Java Development Kit (JDK), and Java 11 removed it. The code was compiled against it long ago, so nothing complains until the line that uses it runs. The next line of the stack trace reads Caused by: java.lang.ClassNotFoundException.
Fix
Add JAXB back as a dependency, as shown below, 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, and Java 11 deleted them from the JDK. The error names a missing class, and gives no hint that the fix is a missing dependency.

The fix is to add JAXB back as an ordinary dependency. In a Spring Boot 3 or 4 project, Spring Boot chooses the versions, and they are the Jakarta ones:

<!-- The JAXB API: JAXBContext and the annotations, now in the package jakarta.xml.bind. -->
<dependency>
  <groupId>jakarta.xml.bind</groupId>
  <artifactId>jakarta.xml.bind-api</artifactId>
</dependency>
<!-- The implementation that does the work at run time. -->
<dependency>
  <groupId>org.glassfish.jaxb</groupId>
  <artifactId>jaxb-runtime</artifactId>
</dependency>
Reading the code
<artifactId>jakarta.xml.bind-api</artifactId>

The classes your code calls. Neither dependency needs a <version>, because Spring Boot supplies it. Spring Boot 4.1 picks version 4, which lives in jakarta.xml.bind, so import javax.xml.bind.JAXBContext must become import jakarta.xml.bind.JAXBContext.

<artifactId>jaxb-runtime</artifactId>

The engine behind the API. Without it the code compiles, then fails when it creates a JAXBContext, with a JAXBException saying 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 26the browser plugin went in Java 11; the API, deprecated in 17, went in 26
Java Web StartJava 11gone; ship an installer built with jpackage instead
CORBA and Java EE modulesJava 11JAXB and JAX-WS became separate dependencies; see above
Nashorn JavaScript engineJava 15gone; use GraalJS to run JavaScript on the JVM
Security ManagerJava 24permanently disabled; it can no longer be switched on
Thread.suspend and resumeJava 23removed outright; they were never safe to call
Thread.stopJava 26has thrown instead of stopping 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 one ZGC has

The rest of this appendix is history: what each older technology was, so you can recognise it when a codebase puts one in front of you. Each section stays folded until you open it.

Language habits from before Java 8#

LegacyAnonymous inner classes as callbacks

Before Java 8, in 2014, passing a piece of behaviour meant writing a whole class inline. C# has had lambdas since 2007, so the same sort looks very different in the two languages:

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 sort orders smallest total first. The Java version creates an unnamed class that implements Comparator, whose one method, compare, does the work. Modern Java writes orders.sort(Comparator.comparing(Order::getTotal)). IntelliJ converts the old form for you with Alt+Enter, or ⌥↩ on a Mac.

LegacyGuava and Apache Commons

Before the JDK had collection factories, Optional and string helpers, two libraries filled the gaps: Google's Guava and Apache Commons. Their calls are all over older code, and most now have a JDK method that does the same job. The Quick reference at the end of this appendix pairs each common call with its replacement.

Neither library is dead. Guava's caches, multimaps and graph types have no JDK equivalent. But new code should not reach for them to do what the JDK now does.

LegacyChecked exceptions everywhere

Early Java APIs declared checked exceptions freely: exceptions the compiler forces every caller to catch or declare. The resulting try/catch blocks are a large part of Java's reputation for wordiness. Libraries have since moved the other way. Spring, Hibernate and the AWS SDK all wrap checked exceptions in unchecked ones before they reach your code. See Exceptions and resources.

Configuration by XML#

LegacySpring XML configuration

In .NET you register services in code, in Program.cs. Before annotations, Spring did the same job in an XML file, usually called applicationContext.xml, and every Spring bean was declared there. Here the order service gets its repository, and the repository gets its data source, the object that hands out database connections:

<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans-3.0.xsd">

  <bean id="orderService" class="com.acme.orders.OrderService">
    <constructor-arg ref="orderRepository"/>
    <property name="auditEnabled" value="true"/>
  </bean>

  <bean id="orderRepository" class="com.acme.orders.JdbcOrderRepository">
    <property name="dataSource" ref="dataSource"/>
  </bean>

  <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
    <property name="url" value="jdbc:postgresql://localhost/shop"/>
  </bean>
</beans>
Reading the code
<beans xmlns="http://www.springframework.org/schema/beans"

The header names the XML schema, so tools can check the file. A version number in the address, here 3.0, dates the file; Spring 7 still accepts it.

class="com.acme.orders.OrderService">

One bean. Spring creates an OrderService and registers it under the name orderService. The class name is a plain string, which is the weakness of the whole approach.

<constructor-arg ref="orderRepository"/>

Passes the bean named orderRepository to the constructor. ref always means another bean.

<property name="auditEnabled" value="true"/>

Calls setAuditEnabled(true) after the object is created. value passes a literal rather than a bean.

Large systems had thousands of lines of this. Renaming a class did not break the build, only the next start-up, because the XML held class names as plain strings. The application loaded the file with new ClassPathXmlApplicationContext("applicationContext.xml"), or through a setting in web.xml. Annotations arrived in Spring 2.5, in 2007, and @Configuration classes in Spring 3, in 2009. XML still works in Spring 7, and you will find it in older modules, often beside annotations in the same application.

LegacyHibernate hbm.xml mappings

If you have used NHibernate in .NET, you know this format already: NHibernate is a port of Hibernate and copied it. Before annotations, Hibernate read the mapping between a class and its table from a separate XML file per class, named like Order.hbm.xml:

<hibernate-mapping>
  <class name="com.acme.orders.Order" table="ORDERS">
    <id name="id" column="ORDER_ID">
      <generator class="native"/>
    </id>
    <property name="total" column="TOTAL"/>
  </class>
</hibernate-mapping>

The class element maps Order to the ORDERS table. id names the key column and lets the database choose how to generate it, and each property maps one field. Renaming a field meant editing two files, and forgetting the second was a failure at run time. The @Entity, @Id and @Column annotations replaced these files in 2006; the engine underneath is the same.

Build tools#

LegacyAnt and Ivy

Ant, from 2000, is a general-purpose task runner configured in XML, closer to MSBuild than to Maven. It has no conventions and no dependency management, so every project invented its own folder layout, and libraries were committed to source control in a lib/ folder. A build.xml file lists targets, and each target is a list of tasks:

<project name="shop" default="compile">
  <property name="src" location="src"/>
  <property name="build" location="build"/>

  <target name="init">
    <mkdir dir="${build}"/>
  </target>

  <target name="compile" depends="init">
    <javac srcdir="${src}" destdir="${build}" classpath="lib/commons-lang-2.6.jar"/>
  </target>
</project>
Reading the code
<property name="src" location="src"/>

Defines a variable, used later as ${src}. Ant assumes nothing about the layout, so every project spells it out.

<target name="compile" depends="init">

A target, like an MSBuild target. depends runs init first.

<javac srcdir="${src}" destdir="${build}" classpath="lib/commons-lang-2.6.jar"/>

Compiles the sources against a Java archive (JAR) checked into the repository. There is no package manager: the file in lib/ is the dependency.

Ivy, a later add-on, gave Ant dependency resolution. Maven's real contribution, in 2004, was not its XML: it was the conventions, such as src/main/java, plus a central repository of libraries. Ant survives in old builds and inside some Gradle scripts.

Web frameworks, in order of extinction#

LegacyApplets

Applets were Java in the browser, from 1995 to about 2015: user-interface code downloaded as a JAR and run by a browser plugin, much as Silverlight was later for .NET. Security flaws killed them, and then browsers dropped the Netscape Plugin API (NPAPI) that the plugin needed. Java 11 removed the browser plugin and Java Web Start. The Applet API itself was deprecated for removal in Java 17 and removed in Java 26.

You will not meet a working applet. You may meet the remains of one in an internal tool nobody dares delete.

LegacyServlets and JSP

A servlet is a class that handles an HTTP request, Java's counterpart of an IHttpHandler in classic ASP.NET. Servlets date from the late 1990s and, unlike applets, are very much alive, just hidden under Spring. This one returns a page of HTML:

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class OrderServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("text/html");
        response.getWriter().println("<h1>Orders</h1>");
    }
}
Reading the code
public class OrderServlet extends HttpServlet {

Every servlet extends HttpServlet. A separate file, web.xml, mapped a URL such as /orders to this class.

protected void doGet(HttpServletRequest request, HttpServletResponse response)

One method per HTTP verb: doGet, doPost and so on. The request and response objects are the whole API, as HttpContext was in classic ASP.NET.

response.getWriter().println("<h1>Orders</h1>");

Writes the page by hand. Nobody does this any more; a template or a JSON library does it.

Spring MVC, Spring's model-view-controller web framework, is built on this same API. Its DispatcherServlet is a servlet that receives every request and hands it to your controllers. So servlets still run under every Spring Boot web application; only writing them by hand stopped.

JavaServer Pages (JSP) are HTML pages with Java mixed in, compiled into a servlet. They are truly legacy: use Thymeleaf templates instead.

LegacyStruts 1 and 2

Struts was the leading model-view-controller framework of the early 2000s, the Java counterpart of what ASP.NET MVC later became. Actions were configured in struts-config.xml, and forms were ActionForm classes. Struts 1 reached end of life in 2013. Struts 2 is still maintained but rarely chosen. It is best known for CVE-2017-5638, the remote-code-execution flaw behind the 2017 Equifax breach.

If you inherit Struts, the migration target is Spring MVC, and treat the version as a security matter, not a preference.

LegacyJSF and Java EE MVC

JavaServer Faces (JSF) was the official Java EE answer: a component-based framework that keeps page state on the server, much like ASP.NET Web Forms, with the same eventual problems. It survives as Jakarta Faces, mostly in large companies with long-lived internal applications.

Some older guides name Java EE MVC, a later standard now called Jakarta MVC, as the equivalent of ASP.NET MVC. Few teams use it. The answer is Spring MVC, through Spring Boot.

EJB and the application server#

LegacyEJB 2.x

Enterprise JavaBeans (EJB) were the component model at the heart of J2EE: objects that lived inside a server, which gave them transactions, security and remote calls. The nearest .NET relatives are COM+ and .NET Remoting. In version 2 every bean needed four pieces: a home interface, a remote interface, an implementation class and an XML deployment descriptor. The two interfaces alone look like this:

import java.rmi.RemoteException;
import javax.ejb.CreateException;
import javax.ejb.EJBHome;
import javax.ejb.EJBObject;

// The home interface is how a client asks the server for an OrderService.
public interface OrderServiceHome extends EJBHome {
    OrderService create() throws RemoteException, CreateException;
}

// The remote interface lists the methods a client may call.
public interface OrderService extends EJBObject {
    Order place(Order order) throws RemoteException;
}
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 RemoteException, and every caller had to handle it.

The weight of this is why Spring exists. Rod Johnson's 2002 book argued that plain objects, in a container that stayed out of the way, could have the same transactions without EJB. EJB 3, in 2006, switched to annotations and became reasonable, but by then Spring had won.

LegacyApplication servers

WebSphere, WebLogic, JBoss and GlassFish were application servers: long-running servers, shared by several applications and managed through a web console, much like IIS hosting several sites. You packaged a web application as a web application archive (WAR), or several of them as an enterprise archive (EAR), and deployed it into the server. Deployment was something done to a server, not a process you started.

Spring Boot turned this inside out. The web server is a library inside your JAR, each service is its own process, and the file you ship never changes after it is built. That is what made Java fit containers. You will still meet WebSphere in banking and insurance.

SOAP and web services#

LegacyJAX-WS, WSDL and SOAP

If you built services with ASMX or WCF in .NET, you know this world. Before REST, a service's contract was a Web Services Description Language (WSDL) document, and each message was a SOAP envelope, an XML document sent over HTTP. Tools generated client code from the WSDL, which gave real type safety across languages. REST gave that up, and OpenAPI later won much of it back.

In Java, a class became a SOAP service through annotations from JAX-WS, the Java API for XML Web Services:

import java.util.HashMap;
import java.util.Map;

import javax.jws.WebMethod;
import javax.jws.WebParam;
import javax.jws.WebService;

@WebService
public class InvoiceService {

    private final Map<Long, Invoice> invoices = new HashMap<Long, Invoice>();

    @WebMethod
    public Invoice findInvoice(@WebParam(name = "orderId") long orderId) {
        return invoices.get(orderId);
    }
}
Reading the code
@WebService

Publishes the class as a SOAP service; the server generates the WSDL from it.

public Invoice findInvoice(@WebParam(name = "orderId") long orderId) {

@WebMethod makes the method an operation, and @WebParam names its parameter in the XML. JAXB turns the Invoice it returns into XML.

The extra standards on top, such as WS-Security and WS-ReliableMessaging, are why “enterprise integration” earned its reputation. SOAP is still current in banking, government and telecoms, and Spring Boot supports it through spring-boot-starter-webservices. In .NET the same journey ran from WCF to ASP.NET Core.

Why any of this matters#

Knowing the history pays off in two ways. First, dating code tells you which advice to trust. A codebase with applicationContext.xml and anonymous classes was probably written before 2014, before virtual threads, records and lambdas existed. Search results and forum answers from that time are just as old. Some still recommend overriding finalize(), which Java 18 deprecated for removal.

Second, Java's promise not to break old code is real. Most of what this appendix describes still runs, so a Java codebase gains layers instead of being rewritten. That makes reading old Java a lasting skill, in a way that reading old JavaScript is not.

What to remember

Old code carries its date: @Autowired on fields, an anonymous Comparator and new ArrayList<Order>() mean around 2010, while constructor injection, Order::total and records mean recent code.

Spring Boot 3 moved every enterprise import from javax to jakarta, so a Boot 2 upgrade touches every entity, controller and validator.

A NoClassDefFoundError for javax/xml/bind after leaving Java 8 means JAXB left the JDK: add jakarta.xml.bind-api and jaxb-runtime, and change the imports.

Spring XML, hbm.xml files, Ant builds and EJBs still turn up in older systems; new code uses annotations, Maven or Gradle, and plain Spring beans.

Quick reference#

Old 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; now 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#

Part APP · Appendices · Chapter 55· 9 min read

This appendix is a map, not the territory. Each topic below needs more than a handbook section, and some need a whole book. Every topic answers four questions:

  • What is it?
  • What symptom tells you that you need it?
  • Where will a C# habit mislead you?
  • What should you read?

Spring internals get short, real explanations with code, because they are conventions you can learn in a few hundred words. The internals of the Java Virtual Machine (JVM) get signposts only: they are behaviour you have to measure, and no summary replaces that.

Start here: the symptom index#

Start from what you are seeing. Each row names a symptom, the mechanism that usually explains it, and where this book covers it:

What you are 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 in depth
Deadlocks that only appear under loadREQUIRES_NEW holds one connection while it waits for a second, and the pool runs dryTransactions in depth
Correct on MySQL, wrong on PostgreSQLthe two databases have different default isolation levelsTransactions in depth
p99 is spiky, the mean is finethe slowest 1% of requests hit garbage-collection pauses, or the JIT recompiling codeThe JVM at runtime; 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: metaspace, direct buffers, thread stacksThe JVM at runtime
Startup is slow and you have no idea whytoo many auto-configurations or classes to load; the conditions report shows which ranSpring Boot orientation; 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 the build could not work outSpring AOT, below; Packaging and native images

Spring internals#

These three topics explain what Spring Boot does at start-up, and each takes an afternoon to learn. The first is how Spring Boot's auto-configuration works from the library's side.

Writing an auto-configuration and a starter#

Say several of the shop's services call the same payment provider. You want to write the client once, as a library, so that each service only adds a dependency and sets a URL. The library needs an auto-configuration: a class that creates its beans, but only when certain conditions hold.

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 turns this around: the library registers itself, and the application does nothing.

The library holds a PaymentGatewayClient, which wraps Spring's RestClient, and a PaymentGatewayProperties record bound from the payment.gateway.* settings. The auto-configuration is the first of two parts:

package com.acme.payments.autoconfigure;

import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestClient;

import com.acme.payments.PaymentGatewayClient;
import com.acme.payments.PaymentGatewayProperties;

@AutoConfiguration
@ConditionalOnClass(RestClient.class)
@EnableConfigurationProperties(PaymentGatewayProperties.class)
public class PaymentGatewayAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    PaymentGatewayClient paymentGatewayClient(PaymentGatewayProperties properties) {
        return new PaymentGatewayClient(RestClient.create(properties.url()));
    }
}
Reading the code
package com.acme.payments.autoconfigure;

A package of its own, which no application will scan. The gotcha below explains why that matters.

@AutoConfiguration

Marks a configuration class that Spring Boot loads only through the registration file, after the application's own configuration. That order is what lets it step aside.

@ConditionalOnClass(RestClient.class)

Skips the whole class unless RestClient is on the classpath, that is, unless the service uses Spring's web client.

@EnableConfigurationProperties(PaymentGatewayProperties.class)

Binds payment.gateway.url to the record and makes the record a bean, ready to be passed to the method below.

@ConditionalOnMissingBean

Creates the client only if the application has not defined its own PaymentGatewayClient. The application always wins.

The second part is the registration file: a plain text file with a fixed folder and name, listing the auto-configuration classes one per line:

# This file lives in src/main/resources/META-INF/spring/ and is named
# org.springframework.boot.autoconfigure.AutoConfiguration.imports.
com.acme.payments.autoconfigure.PaymentGatewayAutoConfiguration

At start-up Spring Boot reads this file from every Java archive (JAR) on the classpath, and loads each class it names, here PaymentGatewayAutoConfiguration. Lines that start with a # are comments.

A starter is the last, optional piece. It is a dependency with no code of its own, which pulls in the library and everything it needs, as spring-boot-starter-webmvc does for web applications. Call yours payment-gateway-spring-boot-starter, because names that begin with spring-boot are kept for Spring Boot's own modules.

Gotcha

Three things bite here. First, the registration file's folder and name must be exact. A typo gives no error at all: the auto-configuration is simply never loaded.

Second, keep the auto-configuration out of every package the application scans. @SpringBootApplication skips classes named in a registration file, but a plain @ComponentScan does not. It loads the class as ordinary configuration, before it has seen the application's own beans, so @ConditionalOnMissingBean misses them and the application ends up with two clients.

Third, libraries written for Spring Boot 2 often list their auto-configurations in a file called spring.factories instead. Spring Boot 3 and 4 no longer read that file for auto-configurations, so such a library is silently ignored.

Note

Read: the Spring Boot reference, “Creating Your Own Auto-configuration”, then the source of any Spring Boot auto-configuration, which is unusually readable.

BeanFactoryPostProcessor and BeanPostProcessor#

Spring has two hooks into its own start-up. A BeanFactoryPostProcessor sees the bean definitions, Spring's recipes, before any bean exists. A BeanPostProcessor sees each bean as it is made, and can wrap or replace it. It is how Spring puts a proxy around a bean, and you can use it the same way.

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 the second kind.
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; Spring's hooks run inside the container and see every bean.

Here a post-processor times every call to the shop's PaymentGateway, an interface whose charge method takes an Invoice and returns a receipt number. It wraps each gateway in a proxy made by the Proxy class that comes with the Java Development Kit (JDK), the counterpart of .NET's DispatchProxy:

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.stereotype.Component;

@Component
public class TimingPostProcessor implements BeanPostProcessor {

    private static final Logger log = LoggerFactory.getLogger(TimingPostProcessor.class);

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        if (!(bean instanceof PaymentGateway gateway)) {
            return bean;
        }
        return Proxy.newProxyInstance(
                PaymentGateway.class.getClassLoader(),
                new Class<?>[] { PaymentGateway.class },
                (proxy, method, args) -> {
                    long start = System.nanoTime();
                    try {
                        return method.invoke(gateway, args);
                    } catch (InvocationTargetException e) {
                        throw e.getCause();
                    } finally {
                        long micros = (System.nanoTime() - start) / 1_000;
                        log.info("{}.{} took {} microseconds", beanName, method.getName(), micros);
                    }
                });
    }
}
Reading the code
public Object postProcessAfterInitialization(Object bean, String beanName) {

Spring calls this for every bean, once the bean is created and initialised. Whatever it returns is what other beans receive.

if (!(bean instanceof PaymentGateway gateway)) {

Every bean that is not a gateway passes through unchanged.

return Proxy.newProxyInstance(

Builds, at run time, an object that implements PaymentGateway and sends every call to the lambda below. The lambda receives the proxy, the method being called and its arguments.

return method.invoke(gateway, args);

Calls the real gateway.

throw e.getCause();

invoke wraps any exception the gateway throws in an InvocationTargetException. Unwrapping it lets the caller catch the original, such as an IllegalArgumentException.

log.info("{}.{} took {} microseconds", beanName, method.getName(), micros);

Runs whether the call succeeded or failed, and logs the bean, the method and the time.

Gotcha

A BeanPostProcessor is itself a bean, and Spring must create it before the beans it processes. So anything it depends on is created very early too, before the other post-processors are ready, and never passes through them. Keep post-processors free of dependencies, or take an ObjectProvider and fetch the bean only when it is first needed.

If TimingPostProcessor took an AuditLog in its constructor, to record the timings, Spring would log this warning at start-up:

Bean 'auditLog' of type [com.acme.shop.AuditLog] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)#
Means
A post-processor depends on this bean, so Spring created it before every post-processor was ready. It gets no proxies, so annotations such as @Transactional on it can silently do nothing.
Fix
Remove the dependency, or take an ObjectProvider<AuditLog> and call getObject() only when the bean is first needed.
Note

Read: Spring Framework reference, "Container Extension Points"

Spring AOT and native processing#

Normally Spring makes all its decisions at start-up: which configuration applies, which beans to create and how to wire them. Ahead-of-time (AOT) processing makes those decisions during the build instead. It is also what makes a native image of a Spring application possible.

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: Spring Boot reference, "Ahead-of-Time Processing"; then Packaging and native images in this book

The rest of this appendix is signposts: what each topic is and when you need it, with no code. Each group is folded until you open it.

JVM internals#

These entries go below Spring, into the JVM itself. They cover how it finds classes, how its just-in-time (JIT) compiler turns bytecode into machine code, and the rules for sharing memory between threads.

Classloaders and the classpath#

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; a library works in tests but not in the deployed server; you are building plugins or hot reload.
The C# instinct.NET has AssemblyLoadContext, and you almost never touch it. In Java the chain of loaders carries real weight, shows up in ordinary bugs, and is what "JAR hell" means.
ReadJava Language Specification 12.2; Oaks, "Java Performance"; the Tomcat class loader documentation for the application-server case
Note

The basics are in How Java runs your code: the classpath as a search list, the order in which loaders are asked, and the two class-not-found errors. This entry is for when those are not enough.

The JIT: tiers, inlining, deoptimisation#

RowDetail
What it isHotSpot, the standard JVM, first interprets your code. It compiles methods that run often with a quick compiler, C1, then recompiles the busiest with an optimising one, C2, guided by what it has seen. It makes bold guesses, and throws the compiled code away when one proves wrong: that is deoptimisation.
You need it whenThroughput changes minutes after start-up; a microbenchmark reports impossible numbers; the slowest requests spike and the garbage-collection logs do not explain it.
The C# instinct.NET also recompiles hot methods using profile data: tiered compilation, with dynamic PGO on by default since .NET 8. What .NET does not do is HotSpot's deoptimisation, which is why a Java method's speed can change long after start-up.
ReadAleksey Shipilev's blog is the reference; -XX:+PrintCompilation and JITWatch show it happening; always measure with JMH, never a hand-rolled loop

The Java Memory Model and happens-before#

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 whenA concurrency bug you cannot reproduce; you are writing a lock-free structure; you need to know whether a field really needs volatile.
The C# instinctThe .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", still the definitive treatment; JSR-133 and its FAQ for the specification

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: plain, opaque, acquire/release or volatile.
You need it whenReflection shows up as hot in a profiler; you are writing a framework or serialiser; you need ordering weaker than volatile but stronger than plain.
The C# instinctExpression trees compiled to delegates, or Unsafe.As. A MethodHandle is closer to a compiled delegate than to reflection.
Readjava.lang.invoke package documentation; JEP 193 for VarHandle

Bytecode manipulation#

RowDetail
What it isGenerating or rewriting classes, at build time or as they load. ByteBuddy is the easy library and ASM the low-level one; since Java 24, the Class-File API does the job inside the JDK itself.
You need it 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, which is how the OpenTelemetry Java agent adds tracing with no code change; .NET's zero-code instrumentation does the same through the CLR profiling API.
ReadByteBuddy tutorial; JEP 484 for the Class-File API; java.lang.instrument for agents

NIO channels, selectors and memory-mapped files#

RowDetail
What it isThe low-level input and output layer under every HTTP server and database driver: buffers, channels, selectors that watch many connections at once, and files mapped straight into memory.
You need it 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, a frequent source of bugs. MemorySegment, from the Foreign Function and Memory API, is the modern replacement.
Readjava.nio package documentation; Netty's source for how it is really used

Platform#

These are parts of the platform most services never touch, until one day they must.

TopicYou need it whenRead
JMX and MBeansyou must read or change a runtime value on a live JVM, or an operations tool demands itjavax.management docs; Actuator exposes endpoints over JMX too
ServiceLoader and the SPI patternyou are building plug-in implementations found on the classpath; it is how JDBC drivers are foundjava.util.ServiceLoader; the provides directive in Modules, JPMS and internal
JCA, JCE, keystores and TLSyou must configure mutual TLS, load a keystore, or pick a cipher suiteJava Security Standard Algorithm Names; keytool documentation
Foreign Function and Memory APIyou need to call native code, or manage memory outside the heap preciselyJEP 454; the modern alternative to JNI and, for off-heap memory, to ByteBuffer
Vector APIyou have measurable number-crunching that suits SIMD instructionsJEP 508: still an incubator in Java 25, so not for production
Locale, charset and i18ntext is garbled, or sorting differs between environmentsalways set the charset explicitly; ICU4J for serious internationalisation

Ecosystem, beyond one service#

These are the projects you reach for once there are many services, or once one service has grown large.

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:

ForRead
Concurrency, properlyGoetz, "Java Concurrency in Practice"
Everyday idiom and API designBloch, "Effective Java"
Performance and the JVMOaks, "Java Performance"; Shipilev's blog for depth
Springthe Spring Boot and Spring Framework reference documentation: genuinely good, and better than most books about them
What is comingthe JEP index at openjdk.org/jeps
Note

One point worth carrying: Java's stale advice problem is severe. The language is thirty years old, and search results do not sort by the version you are on. An answer that was right in 2011 appears beside one that is right today, both stated with confidence. So check the date and the Java version. Prefer a JDK Enhancement Proposal (JEP) or the reference documentation over a blog post. Some old guides still recommend overriding finalize(), which Java 18 deprecated for removal.

Appendix E: If your team uses Kotlin#

Part APP · Appendices · Chapter 56· 7 min read· Quick reference

Kotlin is a second language for the Java Virtual Machine (JVM). It calls Java code and Java code calls it, and Spring Boot supports it as fully as Java. If you join a Kotlin team, the good news is that Kotlin is much closer to C# than Java is. It has properties, null checks built into the type system, extension functions, data classes, string interpolation and operator overloading, none of which Java has.

The bad news is that you still need the Java in this book. The libraries, the JVM, Spring, Maven and every behaviour at run time are exactly the same. Kotlin changes the syntax, not the platform.

What Kotlin gives back that Java took away#

Picture your first day on a Kotlin team. Much of what you missed in Java is back: each C# feature in this table has a direct Kotlin form, where Java has a workaround or nothing:

C# 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 both languages: a customer type with a computed label, a changed copy, and a search that may find nothing:

C# 14
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) {

A data class, Kotlin's record. The compiler writes equals, hashCode, toString and copy from the properties in the header. val makes each property read-only, and each type comes after its name.

val label get() = "$name ($age)"

A computed property, like a C# property written with =>. $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 job of C#'s with. Kotlin has no new keyword: 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 its one parameter.

val name: String? = found?.name

The same safe call as C#'s ?., so name is null here: there is no Bob. C# only warns when you use such a value unchecked; Kotlin refuses to compile the code.

So the code looks like C#. What runs it does not change at all.

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. Type erasure, the equality contract and the traps of Jakarta Persistence (JPA) are unchanged. So are annotations that work through proxies, virtual threads, the class loader, tuning garbage collection (GC) and the whole of Spring. Kotlin is a different front end to the same platform.

Kotlin with Spring Boot#

Spring supports Kotlin properly rather than tolerating it, and Spring Boot 4 needs Kotlin 2.2 or later. Here is the shop's order controller written in Kotlin. It assumes an OrderService whose find method returns an OrderDto?, null when there is no such order, and an OrderNotFound exception that the application turns into a 404 response:

import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/orders")
class OrderController(private val service: OrderService) {

    @GetMapping("/{id}")
    fun get(@PathVariable id: Long): OrderDto =
        service.find(id) ?: throw OrderNotFound(id)

    @PostMapping
    fun create(@RequestBody @Valid command: CreateOrder): OrderDto =
        service.create(command)
}
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 it with no annotation, as it does with a Java class's only 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 C#'s =>.

service.find(id) ?: throw OrderNotFound(id)

?:, the Elvis operator, uses the right-hand side when the left is null, like C#'s ??. In Kotlin throw is an expression, so it can sit there, as it can in C#.

fun create(@RequestBody @Valid command: CreateOrder): OrderDto =

The annotations are the same as in Java, written before each parameter.

A few Kotlin rules change how Spring behaves, and the build needs two compiler plugins:

ConcernWhat to know
Constructor injectionconstructor parameters in the class header; no annotation needed
Final by defaultKotlin classes and functions are final unless marked open, which breaks CGLIB proxies
kotlin-spring pluginopens classes that carry Spring annotations, and their functions; essential
kotlin-jpa plugingenerates the no-argument constructor JPA needs on entities, but leaves them final; Hibernate's lazy loading needs them open too
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. A Kotlin class is final unless marked open, and Code Generation Library (CGLIB) proxies work by subclassing, so start-up fails with Cannot subclass final class. Worse, if someone opens the class by hand but not its functions, start-up succeeds. Each final function is left out of the proxy, so its annotation silently does nothing. The only sign is a warning that the method “cannot get proxied via CGLIB”.

It is the same failure described in How Spring actually works, arriving by a different route. Check the plugin first on any Kotlin Spring project where an annotation seems to be ignored.

Calling Java from Kotlin and back#

Kotlin's null checks stop at the edge of Kotlin code. Suppose the shop still has a Java class, CustomerDirectory, whose findName method returns a customer's name, or null when there is no such customer. Kotlin cannot tell which, so it trusts whatever type you choose:

val directory = CustomerDirectory()

// Declared nullable, so the compiler makes you check the value before you use it.
val maybe: String? = directory.findName(2)
println(maybe?.length ?: 0)

// Declared non-null, so Kotlin checks the value here and throws if Java returned null.
val name: String = directory.findName(2)
Reading the code
val maybe: String? = directory.findName(2)

The safe choice. maybe?.length ?: 0 prints 0, because there is no customer 2.

val name: String = directory.findName(2)

This compiles without complaint, then fails on this very line with NullPointerException: findName(...) must not be null. Kotlin adds that check wherever a value from Java meets a non-null type.

The other direction matters too. Java code sees Kotlin code through a few conventions:

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
Gotcha

Platform types are the hole in Kotlin's null safety. Anything coming from a Java library has unknown nullability, and Kotlin will not force you to check it. A NullPointerException in Kotlin code almost always came across a Java boundary. Spring Framework 7 helps here: its signatures now carry JSpecify annotations, and Kotlin reads them. A Java method marked @Nullable cannot be assigned to a non-null Kotlin type at all, because the compiler reports a type mismatch.

Those are the parts of Kotlin you will meet in your first weeks. Two more topics are folded below: coroutines, Kotlin's answer to C#'s async and await, and whether to choose Kotlin at all.

Coroutines against virtual threads#

Kotlin's way of waiting on the network came before virtual threads, and it looks much like C#'s. Say a report needs a customer from one service and their orders from another, fetched at the same time. customerClient and orderClient are clients for those two services, and their calls are asynchronous:

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 started inside it, which is structured concurrency.

val customer = async { customerClient.find(customerId) }

async starts the call and returns at once with a Deferred, Kotlin's counterpart of a Task. Both calls now run at the same time.

Report(customer.await(), orders.await())

await waits for each result. If either call fails, the scope cancels the other, a job C# needs a CancellationToken for.

C#KotlinJava 21+
async Task<T>suspend funa plain blocking method
awaitjust call the suspend functionjust call the method
Task.WhenAllawaitAll / coroutineScopeStructuredTaskScope, still preview
CancellationTokenthe coroutine Job hierarchythread interrupt, or a scope
Function colouringyes: suspend infects callersno
Note

Coroutines bring back the problem that virtual threads removed. Once a function is suspend, every function that calls it must be suspend too, exactly as async spreads through C# code. In exchange they give you structured concurrency that has been stable for years, while Java's StructuredTaskScope is still a preview. On a Kotlin project, follow the team's existing choice rather than mixing the two.

Should you argue for Kotlin?#

Reason to choose itReason to stay on Java
Genuinely less ceremony, particularly for data and null handlingThe Java talent pool is far larger
Null safety enforced by the compilerRecords, patterns and virtual threads closed much of the gap
Excellent Spring and Gradle supportOne language means one set of build and tooling problems
Your team already knows itKotlin adds a compiler plugin dependency to Spring

The honest position for someone arriving from C#: Kotlin will feel comfortable sooner, and Java has closed much of the distance since version 8. Neither choice is wrong, and on an existing codebase it is rarely yours to make.

What to remember

Kotlin brings back what C# has and Java lacks: val properties, types such as String? that the compiler checks for null, "$name" templates, and data classes with copy.

The platform does not change: the JVM, Spring, JPA and every runtime fact in this book apply to Kotlin as they are.

A Kotlin Spring project needs the kotlin-spring plugin; without it Spring cannot subclass a final OrderService, and @Transactional fails.

A value from Java has a platform type, so declare it nullable, as in val maybe: String? = directory.findName(2), or Kotlin throws at the assignment.

Quick reference#

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#

Part APP · Appendices · Chapter 57· 11 min read

The fastest way to lose confidence in a new platform is a stack trace you cannot read. Each entry here gives the message as you will see it, what it means in plain words, and the fix. Many also have a small example that triggers the error, folded underneath. Paste an error into search (⌘K) and it lands here.

One distinction first. Java separates an Exception, which your code may reasonably catch, from an Error, which means the Java Virtual Machine (JVM) itself is in trouble. If the class name ends in Error, catching it is almost always wrong.

Reading a Java stack trace#

Every entry below relies on one skill, so start there. Say the shop's checkout fails because the payment provider never answers. OrderController calls OrderService, which calls PaymentGatewayClient, and the program prints this:

Exception in thread "main" java.lang.IllegalStateException: payment provider did not answer
    at com.acme.payments.PaymentGatewayClient.charge(PaymentGatewayClient.java:20)
    at com.acme.orders.OrderService.place(OrderService.java:7)
    at com.acme.orders.OrderController.create(OrderController.java:6)
    at com.acme.orders.Main.main(Main.java:8)
Caused by: java.net.SocketTimeoutException: Read timed out
    at java.base/sun.nio.ch.NioSocketImpl.timedRead(NioSocketImpl.java:276)
    at java.base/sun.nio.ch.NioSocketImpl.implRead(NioSocketImpl.java:302)
    at java.base/sun.nio.ch.NioSocketImpl.read(NioSocketImpl.java:360)
    at java.base/sun.nio.ch.NioSocketImpl$1.read(NioSocketImpl.java:802)
    at java.base/java.net.Socket$SocketInputStream.implRead(Socket.java:984)
    at java.base/java.net.Socket$SocketInputStream.read(Socket.java:974)
    at java.base/java.net.Socket$SocketInputStream.read(Socket.java:968)
    at com.acme.payments.PaymentGatewayClient.charge(PaymentGatewayClient.java:17)
    ... 3 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 it happened on.

at com.acme.payments.PaymentGatewayClient.charge(PaymentGatewayClient.java:20)

The top frame is where the exception was thrown, with the file and the line. Each line below it is the method that called the one above, down to main.

Caused by: java.net.SocketTimeoutException: Read timed out

The exception that the first one wrapped. The last Caused by: in a trace is usually the real problem: here, the payment provider did not answer in time.

at java.base/sun.nio.ch.NioSocketImpl.timedRead(NioSocketImpl.java:276)

Frames inside Java's own libraries. You can usually skip down to the first frame from your own code, here PaymentGatewayClient.charge at line 17.

... 3 more

The last three frames are the same as the last three printed above, so Java leaves them out.

ConventionMeaning
Top framewhere the exception was thrown, not where it was caught
Caused bythe underlying exception; the last one is usually the real cause
... N moreframes shared with the enclosing trace, 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, and everything above it is the layers that re-threw it. This is the reverse of the habit most people bring from .NET, where the InnerException is nested rather than printed last.

The entries below are grouped by where you meet them: start-up, run time, the database, the web, the JVM itself and the compiler.

Spring Boot will not start#

Gotcha

Read the summary above the stack trace first. Spring Boot prints a short, readable explanation under a banner reading APPLICATION FAILED TO START, and it is usually the whole answer. In the trace itself the useful line is at the bottom, in the last Caused by:.

Web server failed to start. Port 8080 was already in use.#
Means
Another program is already listening on the port the embedded web server wants, often an earlier run of the same application that is still going.
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, and no bean provides one, so Spring never created it.
Fix
Annotate the class with @Component or @Service. If it already has one, move it into the application class's package, or one below it, so that component scanning finds it.
What triggers it
class RatesClient { }          // It has no annotation, so Spring never creates one.

@Service
class Pricing { Pricing(RatesClient rates) { } }
Parameter 0 of constructor in com.acme.Pricing required a bean of type 'com.acme.RatesClient' that could not be found. Action: Consider defining a bean of type 'com.acme.RatesClient' in your configuration.#
Means
Spring Boot's readable summary of the same problem. It names the class, and the constructor parameter that needed the missing bean.
Fix
The same fix: annotate the class, or declare an @Bean method that returns one.
UnsatisfiedDependencyException: Error creating bean with name 'pricing'#
Means
Spring could not build a bean, because it could not build one of the beans its constructor needs. This exception only wraps the real cause.
Fix
Read the last Caused by: line; it 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 in their constructor, so neither can be built first. The underlying exception is BeanCurrentlyInCreationException.
Fix
Move the logic they 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, and none 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 says which one to inject.
Fix
Mark one @Primary, or name the one you want with @Qualifier("cardPayment") at the injection point.
What triggers it
@Service class CardPayment implements Pay { }
@Service class BankPayment implements Pay { }

@Service class Checkout { Checkout(Pay pay) { } }   // Which one? Spring cannot tell.

Common runtime exceptions#

java.lang.NullPointerException: Cannot invoke "String.length()" because "c.name" is null#
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 where that value should have been set; Nullability covers how to stop nulls spreading.
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 casting, and look further back for a raw type or an unchecked warning.
What triggers it
Object o = "x";
Integer i = (Integer) o;
java.util.ConcurrentModificationException#
Means
A collection was changed while a loop was going through it. Removing the second-to-last element happens not to throw, so a test with two items can miss the bug.
Fix
Use removeIf, or an explicit Iterator and its remove(); see Collections.
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 to, or remove from, a collection that does not allow it, such as one made by List.of or Arrays.asList.
Fix
Copy it into a mutable collection first: 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. There is no TryParse, so the parse throws.
Fix
Catch NumberFormatException where the input comes from a user, or validate it first.
What triggers it
Integer.parseInt("12a");
java.lang.ArrayStoreException: java.lang.Integer#
Means
An array declared as Object[] really holds a narrower type, and a value of the wrong type was stored in it.
Fix
Use a List<T>; generics are checked at compile time, arrays only at run time.
What triggers it
Object[] items = new String[1];
items[0] = 1;
java.lang.IllegalStateException: stream has already been operated upon or closed#
Means
A stream was used twice. A stream can be consumed only once, unlike an IEnumerable, which you can enumerate again.
Fix
Create a new stream for each pass, or collect to a List once and reuse that.
What triggers it
var s = List.of(1, 2).stream();
s.toList();
s.toList();     // The second call throws.
java.lang.ArithmeticException: / by zero#
Means
An integer division by zero. Division on double gives Infinity instead of throwing.
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; its letters differ from .NET's, so check them.
What triggers it
LocalDate.parse("11/09/2026");
LocalDate.parse("11/09/2026", DateTimeFormatter.ofPattern("dd/MM/yyyy"));   // This works.

Persistence#

org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.acme.Order.items: could not initialize proxy - no Session#
Means
Hibernate loads some related data lazily, only when it is first read. Here it was first read after its transaction had closed, usually in a controller. For a single entity the message ends 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 it to a data transfer object (DTO) there; see Lazy loading.
What triggers it
Order order = orderService.find(7);   // The transaction ends when find returns.
order.getItems().size();              // Too late: the session is already closed.
jakarta.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread - cannot reliably process 'persist' call#
Means
A write went through 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: a unique key, a foreign key, or a NOT NULL column.
Fix
Read the Caused by: line for the constraint's name, then fix the data or the mapping.
ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect)#
Means
The @Version check failed: someone else changed the row after you read it.
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 it as a row that already exists.
Fix
Use merge, or save on a Spring Data repository, which chooses between the two. Do not set a generated id by hand.
No identifier specified for entity: com.acme.Order#
Means
A class marked @Entity has no field marked @Id.
Fix
Add an @Id field, usually with @GeneratedValue.

Web and JSON#

HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `int` from String "abc"#
Means
The request body could not be turned into the parameter's type: malformed JSON, or a value of the wrong type.
Fix
Compare the JSON with the record's components; 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 you want. Shape its body 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 it could write for a type: no getters, no public fields, or a lazy Hibernate proxy.
Fix
Return a record or DTO rather than 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 the target type lacks, and the mapper is set to fail on that. Jackson 2 does so by default; Jackson 3 and the mapper Spring Boot builds do not.
Fix
Add the field, annotate the type @JsonIgnoreProperties(ignoreUnknown = true), or disable FAIL_ON_UNKNOWN_PROPERTIES on a hand-built mapper.
HttpMediaTypeNotSupportedException: Content-Type 'text/plain;charset=UTF-8' is not supported#
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 absent, and request parameters are required by default.
Fix
Send it, or declare it with required = false or a defaultValue.
403 Forbidden on a POST, with an empty response body#
Means
Cross-site request forgery (CSRF) protection rejected a request that changes state, because it carried no CSRF token.
Fix
For a stateless, token-authenticated API, disable CSRF in the security chain; otherwise send the token. See Security.

Errors, not exceptions#

The first five are explained with a worked example in How Java runs your code.

java.lang.UnsupportedClassVersionError: com/acme/App has been compiled by a more recent version of the Java Runtime (class file version 69.0)#
Means
The code was built for a newer Java than the one running it. Subtract 44 from the class file version to get the release: 69 is Java 25.
Fix
Align the build's target with the runtime image; see the full entry.
java.lang.NoClassDefFoundError: com/acme/Greeter#
Means
A class that was there at compile time is missing at run time: a packaging problem, or a dependency scope that leaves it out.
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, usually Class.forName or a class named in configuration, found nothing. It is a checked exception, unlike the error above.
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 in a different version from the one your code was compiled against.
Fix
Run mvn dependency:tree to find the two versions, and pin one; 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: a static method became an instance one, or a class became an interface.
Fix
The same cause as NoSuchMethodError: build against, and ship, one version.
What triggers it
# App calls the static Util.go(). Then Util is changed and recompiled on its own.
javac Util.java App.java
sed -i '' 's/static //' Util.java && javac Util.java
java App
java.lang.StackOverflowError#
Means
Recursion went too deep, often two entities calling each other's toString, equals or hashCode.
Fix
Look for 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 raising -Xmx.
What triggers it
var keep = new ArrayList<long[]>();
while (true) keep.add(new long[1_000_000]);    // Run with -Xmx16m to see it quickly.
java.lang.OutOfMemoryError: Metaspace#
Means
Class metadata filled its space, usually a class loader leak from repeated redeploys or generated classes.
Fix
Set -XX:MaxMetaspaceSize so it fails early, then find what keeps loading classes.
java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached#
Means
The operating system refused to create another platform thread: 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: a missing import, a typo, or a class that is not on the classpath.
Fix
Read the symbol: and location: lines under it. In IntelliJ, Alt+Enter, or ⌥↩ on a Mac, usually adds the missing import.
What triggers it
Lst<String> names;        // The compiler reports "symbol: class Lst".
error: unreported exception IOException; must be caught or declared to be thrown#
Means
A checked exception is neither caught nor declared.
Fix
Catch it, add throws to the method, or wrap it; see Checked exceptions.
What triggers it
void load() { Files.readString(Path.of("rates.json")); }
error: incompatible types: possible lossy conversion from long to int#
Means
Java will not narrow a number silently.
Fix
Cast explicitly after checking the value fits, or use Math.toIntExact, which throws if it does not.
What triggers it
long total = 1;
int count = total;
error: variable x might not have been initialized#
Means
A local variable could be read on some path before any value was assigned.
Fix
Assign it 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 restructure so the variable is assigned once.
What triggers it
int n = 0;
Runnable r = () -> n++;
error: non-static variable x cannot be referenced from a static context#
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 sit in a file with exactly its name.
Fix
Rename the file or the class so the two 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 equals to compare them; see Equality and hashing.
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; it finds it faster than javac.

Appendix G: The one-page card#

Part APP · Appendices · Chapter 58· 1 min read

Everything you need in week one, on one side of paper. Print it and put it next to the keyboard.

Syntax you will reach for
C#Java
var x = ...var x = ...
sealed classfinal class
: Baseextends Base
: IFooimplements Foo
override@Override
readonlyfinal
conststatic final
namespacepackage
using X;import X;
stringString
boolboolean
x => x + 1x -> x + 1
Foo.BarFoo::bar
new Foo()Foo::new
nameof(x)no equivalent
$"{a} b"a + " b"
Collections
C#Java
IEnumerable<T>Iterable<E>
IEnumerator<T>Iterator<E>
List<T>ArrayList<E>
Dictionary<K,V>HashMap<K,V>
HashSet<T>HashSet<E>
Queue / StackArrayDeque<E>
ImmutableListList.of(...)
ConcurrentDictionaryConcurrentHashMap
LINQ to Stream
LINQStream
Wherefilter
Selectmap
SelectManyflatMap
OrderBysorted
First()findFirst().orElseThrow()
Any(p) / All(p)anyMatch / allMatch
ToList()toList()
GroupBycollect(groupingBy(f))
Sum()mapToInt(f).sum()
Chunk(n)gather(windowFixed(n))
Traps that cost a day
Looks rightActually
a == b on Stringcompares references; use equals
Integer 128 == 128false; cache stops at 127
no access modifierpackage-private, not private
protectedalso grants package access
methodsvirtual unless final
map.get(missing)null, then NPE on unboxing
switch (nullRef)throws; add case null
stream reusedIllegalStateException
List.of(..).add(..)UnsupportedOperationException
double for moneyuse BigDecimal
new BigDecimal(0.1)imprecise; use the String form
1.0.equals(1.00)false; use compareTo
nested classinner unless static
checked exception in lambdadoes not compile
return in finallyswallows the exception
Spring, day one
ASP.NET CoreSpring
AddScoped@Service, singleton by default
[ApiController]@RestController
[HttpGet("x")]@GetMapping("/x")
[FromBody]@RequestBody
[FromQuery]@RequestParam
[FromRoute]@PathVariable
IOptions<T>@ConfigurationProperties
appsettings.jsonapplication.yml
Environmentsprofiles
ILogger<T>SLF4J Logger
[Authorize]@PreAuthorize
PollyResilience4j, or @Retryable
Commands
.NETMaven
dotnet build./mvnw compile
dotnet test./mvnw test
dotnet run./mvnw spring-boot:run
dotnet publish./mvnw package
dotnet add packageedit pom.xml by hand
list --include-transitivedependency:tree
When something breaks
SymptomLook at
Could not find or load main classclasspath, or package and folder mismatch
Annotation does nothingself-invocation, private, or final
Bean not foundpackage outside the scan root
LazyInitializationExceptionfetch inside the transaction
class file version 69built on 25, run on something older
Container OOMKilledmetaspace, stacks, direct buffers
Note

The rule of thumb behind most of the traps column: Java makes the safe thing explicit and the unsafe thing the default more often than C# does. Reference equality, package-private access, virtual methods and ORDINAL enum mapping are all defaults you have to opt out of.

If a term on the card is unfamiliar, above all the classpath, start with How Java runs your code.

Appendix H: Java words in plain English#

Part APP · Appendices · Chapter 59· 20 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 one or two sentences, and where the book explains it properly. Where Java uses a word differently from .NET, the meaning says so. Abbreviations are listed under their short form, such as JVM (Java Virtual Machine), with the full name first in the meaning.

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
AMQPAdvanced Message Queuing Protocol: the wire protocol that RabbitMQ speaks.RabbitMQ
annotationA marker written with @ before a class, method, field or parameter, such as @Override. It is Java's version of a C# attribute.Syntax
annotation processorA plug-in the Java compiler runs during a build to read annotations and generate extra source files. It fills the role of a C# source generator.Annotation processing vs source generators
annotation retentionHow long an annotation survives: only in the source, in the class file, or at runtime, where reflection can read it. C# attributes are always there at runtime.Retention: the concept C# lacks
AOPAspect-oriented programming: adding behaviour, such as logging or transactions, around many methods without editing each one. Spring does it with proxies.AOP: writing your own cross-cutting behaviour
AOTAhead-of-time: work done when the application is built rather than when it runs, such as compiling to native code or preparing a startup cache.GraalVM native-image
AOT cacheA file the JVM writes during a training run and reads on later starts, so classes load and link faster. It shortens startup without a native image.Startup without going native
application contextSpring's container: the object that creates your beans, wires them together and hands them out. It plays the part of IServiceProvider.The bean lifecycle
application eventA message one part of a Spring application publishes and other parts receive, in the same process, much like a MediatR notification.Application events
application serverA large server, such as WildFly or WebSphere, that runs many deployed applications from web application archive (WAR) or enterprise archive (EAR) files. Spring Boot services usually embed their own server instead.EJB and the application server
aspectA class, marked @Aspect, that holds behaviour Spring applies around many methods, such as timing every service call.AOP: writing your own cross-cutting behaviour
AssertJThe assertion library most Java tests use, written assertThat(actual).isEqualTo(expected), much like FluentAssertions.A test
@AsyncA Spring annotation that runs a method on another thread, so the caller does not wait for it. It works only when the call goes through a Spring proxy.@Async
atomic variableA class such as AtomicInteger whose operations, like incrementAndGet, are safe from many threads at once without a lock, like Interlocked in .NET.Atomics
auto-configurationSpring Boot creating sensible beans for you, such as a database connection pool, because a library is on the classpath and you have not defined one yourself.Auto-configuration
autoboxingJava converting between a primitive, such as int, and its wrapper object, such as Integer, automatically. Unboxing a null wrapper throws a NullPointerException.Wrapper types
@BeanMarks a method in a configuration class whose return value Spring manages as a bean. Use it for types you cannot annotate yourself, such as a library's client.Beans for types you do not own
beanAn object that Spring creates and manages, and can inject into other objects. It is what you would register in a .NET service collection.Registration by annotation
bean scopeHow many instances of a bean Spring makes: one for the whole application by default, called singleton, or a new one each time it is asked for, called prototype.Lifetimes
Bean ValidationThe Java standard for validation annotations such as @NotBlank and @Size, the counterpart of DataAnnotations.Constraint annotations
BeanPostProcessorAn object Spring shows every bean to as it is created, which can change, wrap or replace it. Spring uses one to put proxies around beans with annotations such as @Transactional.The bean lifecycle
BigDecimalThe Java class for exact decimal numbers, used for money. It is a class, so you add with add() and compare with compareTo() instead of + and >.Money is BigDecimal
BOMBill of materials: a Maven file that fixes the versions of many related libraries at once, so they stay compatible. In a text file, BOM can also mean byte order mark.Profiles and BOMs
BuildpacksA tool that turns an application into a container image without a Dockerfile. Spring Boot's build-image task uses it.Containers
bytecodeThe instructions the Java compiler, javac, produces from your source code. The JVM runs bytecode, the way the .NET runtime runs IL.From source to a running program
@CacheableA Spring annotation that stores a method's result, so a second call with the same arguments returns the stored value without running the method.Caching
CASCompare-and-set: change a value only if it still holds what you expected, as one indivisible step. Atomic classes use it instead of a lock.Atomics
CDSClass data sharing: the JVM stores already-parsed classes in a file and maps it into memory at startup, so the program starts faster.Startup without going native
CGLIBCode Generation Library: the library Spring uses to create a proxy as a subclass of your class at runtime, when there is no interface to proxy.Two kinds of proxy
charsetThe encoding that turns text into bytes and back, such as UTF-8. Java 18 and later use UTF-8 by default; passing one explicitly keeps older code correct too.Charsets, and why to always pass one
checked exceptionAn exception the compiler makes you handle: you must catch it, or declare it with throws. C# has nothing like it.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 it, and reads in its bytecode.Class loading, at the level you need
classpathThe ordered list of folders and Java archive (JAR) files the JVM searches when it needs a class. The first match wins.The classpath is a search list
classpath resourceA file packaged with your classes, such as a configuration file or a template, read through the classpath rather than from a path on disk.Resources live on the classpath too
CMEConcurrentModificationException: thrown when a collection is changed while a loop is still going over it, even on a single thread.Modifying while iterating
compact constructorA record constructor written without a parameter list, 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>. A class's own natural order comes from Comparable, like IComparable<T>.Ordering
CompletableFutureJava's closest match to Task<T>: a result that arrives later, with methods such as thenApply that chain more work onto it.Translation
component scanningSpring searching your packages at startup for classes marked @Component, @Service and similar, and registering each one as a bean.Registration by annotation
@ConfigurationPropertiesBinds a group of settings from application.yml to the fields of a class or record, the way IOptions<T> binds a section in ASP.NET Core.IOptions becomes @ConfigurationProperties
constructor injectionGiving a class what it needs through its constructor parameters, which Spring fills in when it creates the class. It is the recommended style, and the one ASP.NET Core uses.Registration by annotation
controller adviceA class of exception handlers that apply to every controller, used to turn exceptions into error responses in one place.Global exception handling
coroutineKotlin's lightweight concurrency: a function marked suspend that can pause without blocking a thread, close to async and await.Coroutines against virtual threads
CORSCross-origin resource sharing: the browser rule that decides whether a web page may call an API on another domain. The server allows it with response headers.Middleware
CSRFCross-site request forgery: another site making a user's browser send a request with their cookies. Spring Security blocks it by default for requests that change data.The filter chain
dependency scopeA setting on a Maven dependency that says when it is available: compile for everywhere, test for tests only, or runtime and provided for the cases between.Coordinates
detached entityAn entity loaded by a persistence context that has since closed. Changes to it are no longer tracked, and its lazy fields can no longer load.Lazy loading and detached entities
DIDependency injection: a class receives the objects it needs, usually through its constructor, instead of creating them. Spring and ASP.NET Core both do it.Dependency injection and configuration
DSLDomain-specific language: a small, focused way of writing one kind of thing, often as a chain of method calls, such as Spring Security's configuration.The filter chain
DTOData transfer object: a simple class, often a record, that carries data in or out of an API without exposing your entities.Records as DTOs
EAREnterprise archive: a file that packages several WAR and JAR files for an application server. You will only meet it in older systems.EJB and the application server
EJBEnterprise JavaBeans: 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, like an Entity Framework entity.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 it manages, such as a fixed pool, or one virtual thread per task. You submit work and get a Future back.Fixed thread pools
fat JAROne JAR file that holds your application and every library it needs, so java -jar app.jar runs it. Spring Boot builds one by default.The fat JAR
final fieldfinal means it cannot change once set: a final field or variable is assigned once, a final class cannot be extended, and a final method cannot be overridden.final is not readonly, quite
FlywayA tool that runs numbered SQL migration scripts against the database at startup, the counterpart of EF Core migrations. Liquibase is the main alternative.Migrations
fully qualified nameA class name with its package in front, such as com.acme.billing.Invoice. It is how the JVM finds the class file.The classpath is a search list
functional interfaceAn interface with exactly one method to implement, such as Runnable or Function<T, R>. A lambda can stand in for it, the way a lambda fills a C# delegate.Lambdas and method references
G1The JVM's default garbage collector on most machines, which balances short pauses against throughput.Choosing a collector
gathererA custom step added to a stream with gather(), for operations the built-in ones lack, such as sliding windows. It is final since Java 24.Gatherers
GCGarbage collector: the part of the JVM that frees memory no object uses any more. The JVM offers several, such as G1 and ZGC.Choosing a collector
GraalVMA Java Development Kit (JDK) that can compile a Java application ahead of time into a native executable, which starts in milliseconds but limits reflection.GraalVM native-image
GradleA Java build tool, and Maven's main alternative, whose build files are written in a small Groovy or Kotlin language rather than XML.Gradle against Maven
happens-beforeThe Java memory model's rule for when one thread is guaranteed to see another thread's writes, for example after a lock is released or a volatile field is written.volatile means more in Java
health indicatorA small class that reports whether one dependency, such as the database, is up. Actuator combines them into /actuator/health, like ASP.NET Core health checks.Custom health checks
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 one Spring Boot uses by default.The landscape
HQLHibernate Query Language: Hibernate's query language, a superset of the Jakarta Persistence Query Language (JPQL), written against entities rather than tables.Repositories
initialiser blockA block of code in a class body that runs when the class is loaded, if it is static, or when an object is created. C# has static constructors instead.Initialiser blocks
inner classA class declared inside another class without static. Each instance holds a hidden reference to the outer object, unlike a nested class in C#.8. Inner classes capture the outer instance
Integer cacheJava keeps ready-made Integer objects for -128 to 127, so == on two small Integers can be true while the same code on larger numbers is false.2. The Integer cache
IoCInversion of control: the framework, not your code, creates objects and calls into them. Dependency injection is the most common form.Registration by annotation
isolation levelHow much one transaction may see of another transaction's unfinished changes, such as READ_COMMITTED. It is the same database idea as in .NET.Isolation
J2EEJava 2 Platform, Enterprise Edition: the old name for enterprise Java, later Java EE and now Jakarta EE.Four eras
JacksonThe JSON library Spring Boot uses by default, the counterpart of System.Text.Json.Jackson against System.Text.Json
Jakarta EEThe set of enterprise Java standards, such as Persistence, Validation and Servlet, formerly called Java EE. Its packages moved from javax to jakarta.The javax to jakarta rename
JARJava archive: a zip file of class files and resources, usually with a manifest. It plays the part a .dll assembly plays in .NET.Running with -cp or -jar
Java moduleA named group of packages, described in a module-info.java file, that states what it exports and what it needs. Most applications never write one.The problem it solves
java.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
JAXBJakarta XML Binding: the standard for turning XML into Java objects and back. It left the JDK in Java 11 and is now a separate library.What Java removed
JDBCJava Database Connectivity: the low-level standard for talking to a database, like ADO.NET. Jakarta Persistence and Spring's JdbcClient are built on it.The landscape
JDKJava Development Kit: the compiler, the JVM and the tools you need to build and run Java, like the .NET SDK.From source to a running program
JEPJDK Enhancement Proposal: the numbered document that describes one change to Java, such as JEP 444 for virtual threads.The release train
JFRJDK Flight Recorder: a low-overhead recorder built into the JVM that captures what a running program is doing, for later analysis.JFR: the tool with no .NET equivalent
JITJust-in-time compiler: the part of the JVM that compiles frequently run bytecode to machine code while the program runs, as .NET's RyuJIT does.The JIT and warmup
JMHJava Microbenchmark Harness: the standard tool for measuring small pieces of Java code, the counterpart of BenchmarkDotNet.JMH against BenchmarkDotNet
JMSJakarta Messaging: a standard Java API for sending and receiving messages through a broker such as ActiveMQ. Kafka and RabbitMQ are usually used through their own Spring clients.Messaging and WebSockets
JMXJava Management Extensions: the JVM's built-in way to expose the metrics and operations of a running program to monitoring tools.Actuator endpoints
JNDIJava Naming and Directory Interface: a lookup service that old application servers used to hand out resources such as database connections.Single values and precedence
JNIJava Native Interface: the way Java code calls native C or C++ code, like P/Invoke in .NET.When virtual threads are the wrong tool
JPAJakarta Persistence: the Java standard for mapping objects to database tables, the counterpart of Entity Framework. Hibernate implements it.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
JPMSJava Platform Module System: the module system added in Java 9, which lets a library hide its internal packages.Modules, JPMS and the missing internal
JPQLJakarta Persistence Query Language: SQL-like queries written against entities and their fields rather than tables and columns.Repositories
JREJava Runtime Environment: a JVM and its libraries without the compiler. Oracle stopped shipping a separate one in Java 11, but some vendors, such as Temurin, still offer it.From source to a running program
JShellThe Java read-eval-print loop (REPL): type a line of Java and see the result at once, without a class or a main method.jshell: the REPL
JSPJavaServer Pages: an old way of writing HTML pages with Java code inside them, like classic ASP. Modern Spring uses templates or a separate front end.Web frameworks, in order of extinction
JSpecifyA standard set of annotations, such as @Nullable, that mark which values may be null, so tools can warn the way C# nullable reference types do.JSpecify: the annotation standard
JSRJava Specification Request: a numbered proposal for a Java standard, such as JSR 305, an older attempt at null annotations.JSpecify: the annotation standard
JUnitThe standard Java testing framework, the counterpart of xUnit. A test is a method marked @Test.A test
JVMJava Virtual Machine: the program that loads bytecode and runs it, as the CLR runs IL.From source to a running program
JWTJSON Web Token: a signed token that carries a user's identity and claims, sent in the Authorization header. It works the same way as in ASP.NET Core.Concept mapping
KotlinA language from JetBrains that runs on the JVM and uses Java libraries directly. Many C# developers find its syntax closer to C# than Java's.What Kotlin gives back that Java took away
lambdaA short unnamed function written with an arrow, such as x -> x * 2. Java writes -> where C# writes =>.Lambdas and method references
lazy loadingLoading an entity's related data only when you first touch it. If the persistence context has closed by then, Hibernate throws LazyInitializationException.Lazy loading and detached entities
lifecycle phaseOne step of a Maven build, such as compile, test or package. Running a phase runs every phase before it first.The lifecycle
local repositoryThe folder ~/.m2/repository, where Maven keeps every library it downloads, like the NuGet global packages folder.Where your dependencies actually live
LogbackThe logging library Spring Boot uses by default. Your code logs through the Simple Logging Facade for Java (SLF4J), and Logback writes the output.logback-spring.xml and JSON logging
LombokA library that generates getters, setters, constructors and similar code from annotations when you compile. Records remove much of the need for it.A word on Lombok
LTSLong-term support: 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, META-INF/MANIFEST.MF, that can name the class holding main and list other JARs to load.Inside a JAR: the manifest
MapStructA library that generates object-to-object mapping code when you compile, the counterpart of AutoMapper.MapStruct against AutoMapper
MavenThe most common Java build tool. It reads a pom.xml file, downloads the libraries listed there, then compiles, tests and packages the project, like MSBuild with NuGet.pom.xml against csproj
Maven coordinatesThe three values that identify a library in Maven: groupId for the organisation, artifactId for the library, and version, like a NuGet package id and version.Coordinates
Maven 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, which downloads and runs the right Maven version, so everyone builds with the same one.The wrapper
MDCMapped diagnostic context: key and value pairs, such as a request id, attached to the current thread so every log line includes them, like ILogger scopes.Logging
metaspaceThe memory, outside the heap, where the JVM keeps class definitions. Loading a great many classes can exhaust it.The memory model
method referenceA short way to pass an existing method where a lambda is expected, written Type::method, such as String::length.Lambdas and method references
MicrometerThe metrics library Spring Boot uses, the counterpart of System.Diagnostics.Metrics. It sends counters and timers to Prometheus and other systems.Metrics
MockitoThe standard Java library for mock objects, the counterpart of Moq.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 the JVM loads when the module system is switched on: the module-aware counterpart of the classpath.Classpath vs module path
MVCModel-view-controller: the pattern behind Spring MVC, Spring's web framework for controllers, the counterpart of ASP.NET Core MVC.Controllers, routing and binding
N+1 problemLoading a list with one query, then running one more query for each item to fetch its related data. It is the same trap as in Entity Framework.The N+1 problem
NPAPINetscape Plugin API: the old browser plug-in interface that Java applets needed. Browsers removed it, which ended applets.Web frameworks, in order of extinction
NPENullPointerException: thrown when code uses a null reference. It is Java's NullReferenceException.The gap, stated plainly
OIDCOpenID Connect: the standard login protocol built on OAuth 2.0, used with identity providers such as Entra ID or Keycloak.Concept mapping
OOMOutOfMemoryError: 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, used as a return type to say a result may be missing. It is not a replacement for every null.Optional is for return values
ORMObject-relational mapper: a library that maps classes to database tables, such as Hibernate or Entity Framework.The landscape
@OverrideAn annotation that says a method replaces one from a parent class or interface. The compiler reports an error if nothing is actually overridden.Ones you will see constantly
packageA named group of classes, such as com.acme.billing. It is like a C# namespace, but its folders are expected to match the name, and it is also an access boundary.Packages are not namespaces
package-privateJava's default access when you write no modifier: visible to every class in the same package, and nowhere else.The mapping
PECSProducer extends, consumer super: the rule for wildcards. Use ? extends T for a collection you read from, and ? super T for one you add to.Variance is at the use site
permitsThe clause in a sealed class or interface that lists the only types allowed to extend it.The rules for permits
persistence contextThe set of entities one EntityManager is tracking, like a DbContext's change tracker. It usually lasts as long as one transaction.Lazy loading and detached entities
pinningA virtual thread staying stuck to its carrier thread while it blocks, so the carrier cannot run other work. Java 24 removed the main cause, synchronized blocks.When virtual threads are the wrong tool
platform threadAn ordinary Java thread backed one-to-one by an operating system thread, as every thread was before virtual threads.The colour problem, and how Java sidestepped it
pointcutAn expression that picks which methods an aspect wraps, such as execution(public * *(..)) for every public method.AOP: writing your own cross-cutting behaviour
POJOPlain old Java object: an ordinary class with fields, getters and setters, and no framework base class.The property gap
POMProject Object Model: the pom.xml file that describes a Maven project, the counterpart of a .csproj file.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, such as IntStream, which avoids boxing each number and adds methods like sum().Primitive streams
ProblemDetailSpring's class for an RFC 9457 error response body, the counterpart of ASP.NET Core's ProblemDetails.ProblemDetail
proxyAn object Spring puts in front of your bean that adds behaviour, such as a transaction, before calling your method. Calls from inside the bean skip it.What a proxy actually is
R2DBCReactive Relational Database Connectivity: a non-blocking database API for reactive applications, the reactive counterpart of Java Database Connectivity (JDBC).When it is still the right answer
raw 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 where code describes a pipeline of asynchronous events, using Reactor's Mono and Flux types. Spring WebFlux is built on it.The model
recordA short class that holds data, with equals, hashCode, toString and accessors generated, like a C# record. 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, with 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 trainJava's schedule of a new version every six months, in March and September, with an LTS release every two years.The release train
REPLRead-eval-print loop: a prompt where you type code and see the result at once. JShell is Java's.jshell: the REPL
RMIRemote Method Invocation: Java's old way of calling methods on objects in another process, like .NET Remoting. It relies on Java serialization.Java's built-in serialization, and why to avoid it
rollback ruleThe rule for which exceptions undo a Spring transaction: unchecked exceptions do by default, checked exceptions do not.The rollback rule
SAMSingle abstract method: an interface with exactly one method to implement, which a lambda can stand in for. Also called a functional interface.Lambdas and method references
@ScheduledA Spring annotation that runs a method on a timer or a cron schedule, like a hosted background service in .NET.Scheduling
ScopedValueA value made available to a block of code and every method it calls, including threads started in a structured scope. It replaces AsyncLocal-style context.ScopedValue replaces AsyncLocal
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.Why self-invocation fails
sequenced collectionA collection with a defined order and methods such as getFirst() and reversed(), added in Java 21.Sequenced collections
servletThe Jakarta standard for handling an HTTP request in Java. Spring MVC runs on top of it, and a servlet filter plays the part of ASP.NET Core middleware.Middleware
SLF4JSimple Logging Facade for Java: the logging interface Java code writes to, like ILogger, while a library such as Logback does the writing.SLF4J and Logback against ILogger and Serilog
SpELSpring Expression Language: small expressions inside annotations and configuration, written #{...}.SpEL
SPIService provider interface: an interface a library defines for others to implement, with the implementations found at runtime from files in META-INF.Auto-configuration
Spring Data repositoryAn interface you declare, such as OrderRepository extends JpaRepository<Order, Long>, for which Spring generates the data-access code.Repositories
Spring profileA named set of configuration, such as dev or prod, that Spring switches on at startup, like an ASP.NET Core environment.Profiles are environments
starterA Spring Boot dependency that pulls in a group of libraries for one job, such as spring-boot-starter-web for a web API.Starters
static importAn import that brings a class's static members into scope, so you can write max(a, b) instead of Math.max(a, b), like C#'s using static.Imports
STOMPSimple Text Oriented Messaging Protocol: a simple messaging protocol that Spring runs over WebSockets for publish and subscribe.WebSockets and the SignalR gap
stream collectorThe object that turns a stream's elements into a final result, such as a list or a map, when you call collect(). Collectors.toList() is one.Collectors
stream pipelineA chain of operations on a sequence, such as filter, map and collect, run once when the final operation is called. It is Java's counterpart of LINQ to Objects.The shape
structured concurrencyRunning related tasks in a scope that ends only when they all finish, and cancels the rest if one fails. It is a preview feature in Java 25.The problem it solves
switch expressionA switch that returns a value, written with case X -> result, like a C# switch expression. It 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, like C#'s lock statement.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
TCKTechnology Compatibility Kit: the test suite a JDK must pass to be called Java compatible, which is why the major distributions behave the same.Which distribution
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, much like a C# raw string literal.Text blocks
ThreadLocalA variable with a separate value for each thread. Scoped values are the modern way to pass context along.ThreadLocal
transaction propagationWhat a transactional method does when a transaction is already running: join it, which is the default, start a new one, or refuse to run.Propagation
@TransactionalA Spring annotation that runs a method inside a database transaction: committed when it returns, rolled back when it throws an unchecked exception.Transactions
try-with-resourcesA try statement that opens resources in brackets after try and closes them automatically at the end, like C#'s using statement.try-with-resources
type erasureJava removing generic type arguments when it compiles, so at runtime a List<String> is just a List. C# keeps them.What erasure takes away
type patternA type check and a cast in one step, such as if (o instanceof String s), like C#'s is string s.Type patterns
unchecked exceptionAn exception the compiler does not make you catch: 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 you do not need, like C#'s discard. Final since Java 22.Nested patterns and unnamed variables
varA keyword that lets the compiler work out a local variable's type from its value, exactly as in C#.var
varargsA last parameter written Type... name, which accepts any number of arguments, like C#'s params.Varargs
virtual threadA lightweight thread 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 at once and stops reordering around it. It does not make ++ safe.volatile means more in Java
WARWeb application archive: a JAR-like file for deploying a web application to an application server. Spring Boot services usually run as a JAR instead.EJB and the application server
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>, meaning some unknown type. It gives Java the flexibility 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 has nothing to do with C#'s yield return.Multi-statement arms
ZGCA JVM garbage collector designed for very short pauses, even on large heaps.Choosing a collector

End of the book