Purpose and scope
Every Java project that adopts this guide targets Java 23, formats with google-java-format, and builds with zero compiler warnings. The goal is that any two projects read as if one person wrote them.
The guide covers language level, layout, naming, formatting, modern language features, null handling, errors, concurrency, testing, documentation, builds and commits. It does not cover per-project architecture; each project documents that in its own docs/ folder.
Rule levels. Each rule carries one of these words:
| Level | Meaning | Breaking it |
|---|---|---|
| MUST MUST NOT | Non-negotiable. Tooling or review enforces it. | Needs a written reason in the code and the commit. |
| SHOULD | The default. Expected in nearly all code. | Fine with a short comment saying why. |
| MAY | Allowed when it helps; neither encouraged nor discouraged. | Nothing to break. |
| AVOID | Allowed but a smell. | Fine when the alternative is clearly worse. |
Examples. Code samples use com.example.project as the base package and module name, and Project as the project name. Both are placeholders; substitute your own.
Google Java Style Guide. Formatting, naming, imports, source file structure and Javadoc rules are adapted from the Google Java Style Guide (CC BY 3.0). Its examples are rewritten here.
When the guide is silent, match the surrounding code, then the Google Java Style Guide, then Effective Java (3rd edition), in that order.
Language baseline and toolchain
The language level is Java 23, compiled with --release 23, and production code uses only final (non-preview) features. Preview features change between releases, and code that depends on them breaks on the next JDK.
- MUSTJAVA-02.1 compile with
--release 23, not-source/-target, so the build cannot accidentally use APIs newer than 23. - MUSTJAVA-02.2 build warning-free with
-Xlint:all -Werror(individual lints may be turned off in the build file with a comment saying why). - MUST NOTJAVA-02.3 enable
--enable-previewin production modules. Preview and incubator features are allowed only in throwaway prototypes and benchmark modules. - MUSTJAVA-02.4 pin the JDK vendor and version in
.java-version/.sdkmanrcand in CI. Use a current Java 23 update build of a mainstream distribution (Temurin, Corretto, Zulu). - SHOULDJAVA-02.5 plan the move to the next LTS (Java 25) as one tracked task rather than drifting. Java 23 is a non-LTS release with a short public support window.
What Java 23 adds, and the policy for each (JDK 23 project page, GA 17 September 2024)
| JEP | Feature | Status in 23 | Policy |
|---|---|---|---|
| 467 | Markdown documentation comments (///) |
Final | Use for all new doc comments (section 18) |
| 474 | Generational ZGC by default | Final | Default collector choice for low-latency services |
| 471 | Deprecate sun.misc.Unsafe memory access |
Deprecation | Do not use; prefer VarHandle and the FFM API |
| 455 | Primitive types in patterns | Preview | Not in production |
| 466 | Class-File API | 2nd preview | Not in production |
| 473 | Stream gatherers | 2nd preview | Not in production |
| 476 | Module import declarations | Preview | Not in production |
| 477 | Implicitly declared classes, instance main |
3rd preview | Scripts and teaching only |
| 480 | Structured concurrency | 3rd preview | Not in production |
| 481 | Scoped values | 3rd preview | Not in production |
| 482 | Flexible constructor bodies | 2nd preview | Not in production |
| 469 | Vector API | 8th incubator | Not in production |
Final features from earlier releases that this guide builds on: records, sealed classes, pattern matching for instanceof and switch, record patterns, switch expressions, text blocks, var, virtual threads, sequenced collections, the Foreign Function & Memory API, and unnamed variables and patterns (_).
Repository and project layout
Every repository is a Maven multi-module build with one parent POM, a core module holding the domain, thin modules on top for each way the code is delivered, and packages organized by feature rather than by layer.
project/
├── pom.xml # parent: versions, plugins, compiler flags
├── .editorconfig
├── .java-version # pinned JDK (e.g. 23)
├── mvnw, mvnw.cmd, .mvn/ # Maven wrapper, committed
├── docs/ # architecture notes, ADRs
├── project-core/ # domain logic, no framework imports
│ ├── pom.xml
│ └── src/
│ ├── main/java/
│ │ ├── module-info.java
│ │ └── com/example/project/
│ │ ├── billing/ # one package per feature
│ │ │ ├── Invoice.java
│ │ │ ├── InvoiceService.java
│ │ │ └── InvoiceRepository.java
│ │ └── shipping/
│ ├── main/resources/
│ └── test/java/
│ └── com/example/project/billing/
│ └── InvoiceServiceTest.java
├── project-api/ # HTTP or RPC adapter
├── project-cli/ # command-line entry point
└── project-it/ # integration tests (Testcontainers)
- MUSTJAVA-03.1 follow the standard Maven directory layout (
src/main/java,src/test/java,src/main/resources). Every tool, IDE and plugin assumes it. - MUSTJAVA-03.2 put each test class in the same package as the class it tests, under
src/test/java, named<Name>Test:InvoiceServiceis tested byInvoiceServiceTest. Same package gives tests package-private access without widening visibility. - SHOULDJAVA-03.3 organize packages by feature (
billing,shipping), not by layer (controllers,services,models). A feature package holds its types, its service and its repository together. - SHOULDJAVA-03.4 keep
project-corefree of framework imports (Spring, Jakarta, Micronaut). Adapters depend on core, never the reverse. - MUSTJAVA-03.5 commit the Maven wrapper so a fresh clone builds with
./mvnw verifyand no global install. - AVOIDJAVA-03.6
util,commonorhelperspackages. A helper belongs to the feature that needs it; promote it to a shared package only when three features need it. - MUST NOTJAVA-03.7 check in generated sources,
target/directories or IDE files (.idea/,*.iml,.vscode/).
Source file structure
A source file holds exactly one top-level type, is named after it, and is laid out in a fixed order: license header, package, imports, the type. Sections are separated by exactly one blank line.
- MUSTJAVA-04.1 name the file after its single top-level type, case-sensitively:
InvoiceService.java. - MUSTJAVA-04.2 encode source files in UTF-8 and indent only with ASCII spaces, never tabs.
- MUSTJAVA-04.3 keep the
packageandimportlines unwrapped, whatever their length. - MUST NOTJAVA-04.4 use wildcard imports (
import java.util.*;), static or otherwise, or module imports. - MUSTJAVA-04.5 put static imports in one group, then a blank line, then all other imports in a second group, each sorted in ASCII order. google-java-format does this.
- MUSTJAVA-04.6 import nested classes with a normal import, not a static one.
- SHOULDJAVA-04.7 use static imports only where they read well and are idiomatic: test assertions,
Collectors.*methods, and well-known constants. Avoid static imports of ordinary methods from your own code. - MUSTJAVA-04.8 use special escape sequences (
\n,\t,\") rather than octal or Unicode escapes for them. - SHOULDJAVA-04.9 write non-ASCII characters literally when that is clearer, and as a Unicode escape (with a comment) when the character is invisible or confusable.
// Copyright notice, if the project uses one.
package com.example.project.billing;
import static java.util.Objects.requireNonNull;
import com.example.project.money.Money;
import java.time.Clock;
import java.util.List;
import org.jspecify.annotations.NullMarked;
/// Creates invoices for completed orders.
@NullMarked
public final class InvoiceService {
// ...
}Naming
Names follow standard Java conventions with no prefixes or suffixes: UpperCamelCase for types, lowerCamelCase for members and locals, and UPPER_SNAKE_CASE only for true constants. A reader should know what kind of thing a name is from its shape alone.
| Kind | Style | Example |
|---|---|---|
| Package, module | all lowercase, words run together, no underscores | com.example.project.billing |
| Class, record, enum, interface | UpperCamelCase noun or noun phrase | Invoice, PaymentGateway |
| Interface describing a capability | UpperCamelCase adjective allowed | Comparable, Retryable |
| Test class | Name of the class under test + Test |
InvoiceServiceTest |
| Method | lowerCamelCase verb or verb phrase | issueInvoice(), isOverdue() |
| Test method | lowerCamelCase; _ may separate logical parts |
issue_rejectsEmptyOrder() |
Constant (static final, deeply immutable) |
UPPER_SNAKE_CASE | MAX_RETRIES, DEFAULT_TIMEOUT |
| Non-constant field | lowerCamelCase, no m / _ prefix |
retryPolicy |
| Parameter, local variable | lowerCamelCase | orderId, lineItems |
| Unused variable or pattern | _ |
catch (NumberFormatException _) |
| Type variable | One capital letter (+ optional digit), or a name ending in T |
T, E2, RequestT |
| Enum constant | UPPER_SNAKE_CASE | PaymentStatus.PARTIALLY_REFUNDED |
Rules
- MUSTJAVA-05.1 treat a field as a constant only if it is
static finaland its contents are deeply immutable. Astatic final List<String>fromArrayListis not a constant; astatic final Loggeris not a constant. Neither gets UPPER_SNAKE_CASE. - MUST NOTJAVA-05.2 style
finallocals or parameters as constants. - MUSTJAVA-05.3 use only ASCII letters and digits in identifiers, with underscores only where the table allows.
- SHOULDJAVA-05.4 name booleans as predicates:
isActive,hasLineItems,canRetry. - SHOULDJAVA-05.5 name methods that return a value without side effects as nouns or
get/isqueries, and methods that change state as verbs. - AVOIDJAVA-05.6 one-character parameter names in public methods.
Choosing names
A name should make its purpose clear to a new reader, even one from outside the project. Do not save horizontal space at the cost of that clarity.
- SHOULDJAVA-05.7 make a name as descriptive as its scope is wide. A public method carries its domain in the name; a counter in a five-line loop can be
i. - SHOULD NOTJAVA-05.8 repeat what the context already says. An
Invoicefield istotal, notinvoiceTotal. - MUST NOTJAVA-05.9 abbreviate by deleting letters inside a word:
customerId, notcstmrId. - MUSTJAVA-05.10 write acronyms as one capitalized word:
XmlHttpRequest,customerId,supportsIpv6, notXMLHTTPRequestorcustomerID. - AVOIDJAVA-05.11 abbreviations a reader outside the project would not know. Universal ones (
id,url,json,i,T) are fine.
Formatting
google-java-format decides formatting, run through Spotless on every build, and nobody argues with it in review. The rules below are what it enforces, so a reader knows what to expect.
| Topic | Rule |
|---|---|
| Indentation | 2 spaces per block level, never tabs. |
| Continuation lines | At least +4 spaces from the line they continue. |
| Column limit | 100 characters. |
| Braces | K&R: { at the end of the line, } on its own line; } else { and } catch (...) { together. |
| Control statements | if, else, for, do, while always braced, even for one statement. |
| Statements | One per line. One variable per declaration. |
| Blank lines | One between members. Multiple blank lines allowed but discouraged. |
| Arrays | Brackets on the type: String[] args, never String args[]. |
long literals |
Uppercase L: 3000000000L. |
| Modifier order | public protected private abstract default static final sealed non-sealed transient volatile synchronized native strictfp |
| Alignment | Never align columns by hand. |
Column limit
Every line is at most 100 characters. A line may run longer only when it is a package or import line, a line inside a text block, a URL or shell command in a comment that cannot be split, or a very long identifier.
Line wrapping
- SHOULDJAVA-06.1 break at the highest syntactic level available.
- MUSTJAVA-06.2 break before an operator, a
., a::, the&in a type bound, or the|in a multi-catch; breaking after=is also allowed. - MUSTJAVA-06.3 keep a method or constructor name attached to its
(, and a comma attached to the token before it. - MUST NOTJAVA-06.4 break next to a lambda or switch arrow
->, except right after it when a single unbraced expression follows.
List<Invoice> overdue =
invoices.stream()
.filter(invoice -> invoice.dueDate().isBefore(today))
.sorted(Comparator.comparing(Invoice::dueDate))
.toList();
Braces and empty blocks
- MUSTJAVA-06.5 brace every
if,else,for,doandwhilebody. Lambda bodies may omit braces. - MAYJAVA-06.6 write an empty block as
{}, except in a multi-block statement (if/else,try/catch/finally), where each empty block is written out.
if (invoice.isPaid()) {
archive(invoice);
} else if (invoice.isOverdue(today)) {
sendReminder(invoice);
} else {
scheduleCheck(invoice);
}
try {
publish(event);
} catch (TransientException e) {
retryQueue.offer(event);
}
Switch
- MUSTJAVA-06.7 use arrow (
->) switches in new code; they never fall through. - MUSTJAVA-06.8 make every switch exhaustive, adding
defaultwhen the compiler does not require it. Switches over sealed types and enums need nodefaultwhen every case is listed. - MAYJAVA-06.9 put a short arrow case on one line; a braced case body breaks after
{.
String label = switch (status) {
case DRAFT -> "Draft";
case ISSUED, SENT -> "Awaiting payment";
case PAID -> "Paid";
case PARTIALLY_REFUNDED -> {
log.debug("Partial refund on {}", id);
yield "Partially refunded";
}
};
Annotations
- MUSTJAVA-06.10 put class, method and constructor annotations on their own lines after the doc comment. A single annotation with no arguments may share the method's line.
- MUSTJAVA-06.11 place type-use annotations (such as
@Nullable) immediately before the type:@Nullable String note. - MAYJAVA-06.12 put several field annotations on one line.
@Override
public String toString() { ... }
@Override public int hashCode() { ... }
@JsonProperty("due_date")
@NotNull
private LocalDate dueDate;
Text blocks
- MUSTJAVA-06.13 put the opening
"""and the closing"""on their own lines, with the closing delimiter at the same indentation as the opening one.
String query =
"""
SELECT id, total
FROM invoice
WHERE due_date < ?
""";Modern language features
Model data with records, model alternatives with sealed interfaces, and take them apart with pattern-matching switch. Together these replace most getter-setter beans, visitor patterns and instanceof chains.
Records
- SHOULDJAVA-07.1 use a
recordfor every immutable data carrier: DTOs, value objects, events, map keys, multi-value returns. - MUSTJAVA-07.2 validate and normalize in a compact constructor; a record never holds an invalid state.
- MUSTJAVA-07.3 make defensive copies of mutable components (
List.copyOf,Map.copyOf) in the compact constructor. - AVOIDJAVA-07.4 records for entities with identity or mutable state; use a class.
public record Money(BigDecimal amount, Currency currency) {
public Money {
requireNonNull(amount, "amount");
requireNonNull(currency, "currency");
amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_EVEN);
}
public Money plus(Money other) {
checkSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
}
Sealed types and pattern matching
- SHOULDJAVA-07.5 model a closed set of alternatives as a
sealed interfacewithrecordimplementations. - SHOULDJAVA-07.6 handle sealed hierarchies with a
switchover the type, with nodefault, so adding a variant breaks the build everywhere it is not handled. - SHOULDJAVA-07.7 use record patterns to deconstruct when it makes the case clearer, and
_for components you do not use. - MUSTJAVA-07.8 use
instanceofpatterns (if (shape instanceof Circle c)) instead of casting after a check.
public sealed interface PaymentResult
permits PaymentResult.Approved, PaymentResult.Declined, PaymentResult.Pending {
record Approved(String transactionId, Money amount) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record Pending(Duration retryAfter) implements PaymentResult {}
}
String describe(PaymentResult result) {
return switch (result) {
case Approved(var id, var amount) -> "Approved " + amount + " as " + id;
case Declined(var reason) -> "Declined: " + reason;
case Pending(_) -> "Pending";
};
}
var
- SHOULDJAVA-07.9 use
varwhen the type is obvious from the right-hand side: a constructor call, a factory method that names its type, or a literal. - SHOULD NOTJAVA-07.10 use
varwhen the type is unclear without an IDE, such as the result of a call to an unfamiliar method. - MUST NOTJAVA-07.11 use
varfor fields, parameters or return types (the language forbids most of these anyway), or with a diamond:var list = new ArrayList<>()infersArrayList<Object>.
var invoices = new ArrayList<Invoice>(); // obvious
var timeout = Duration.ofSeconds(30); // obvious
InvoiceSummary summary = reportBuilder.build(); // not obvious: spell it out
Unnamed variables and patterns
- SHOULDJAVA-07.12 use
_for any variable or pattern component that is required by the syntax but never read: unused catch parameters, lambda parameters, loop variables, record components.
try {
return Integer.parseInt(text);
} catch (NumberFormatException _) {
return defaultValue;
}
map.forEach((_, value) -> process(value));
Enums
- SHOULDJAVA-07.13 use an
enumfor a fixed set of constants with no data per instance beyond a few fields. - MUST NOTJAVA-07.14 depend on
ordinal()for persistence or logic; store and compare by name or by an explicit field. - SHOULDJAVA-07.15 use
EnumSetandEnumMapinstead of hash-based collections keyed by an enum.
Classes and interfaces
Classes are small, final unless designed for extension, immutable unless they have a reason to change, and no more visible than they need to be. Most of what a class needs to say is said by its modifiers.
Visibility and extension
- SHOULDJAVA-08.1 declare classes
finalunless they are designed and documented for inheritance. Prefer composition over inheritance. - SHOULDJAVA-08.2 default to package-private for classes and members; make something
publiconly when another package needs it. - MUSTJAVA-08.3 keep fields
private(constants excepted). Expose state through methods. - MUSTJAVA-08.4 annotate every override with
@Override, unless the overridden method is@Deprecated. - MUST NOTJAVA-08.5 override
Object.finalize(). UseCleaneror try-with-resources.
Immutability
- SHOULDJAVA-08.6 make fields
finaland assign them once in the constructor. - SHOULDJAVA-08.7 make value types immutable; use a
recordwhere it fits (section 7). - MUSTJAVA-08.8 copy mutable inputs on the way in and never hand out mutable internals: return
List.copyOf(items)or an unmodifiable view.
Construction
- SHOULDJAVA-08.9 use static factory methods (
Invoice.draft(...),Money.of(...)) when a name clarifies intent or the method may return a cached or subtype instance. - SHOULDJAVA-08.10 use a builder when a type has more than about four optional parameters. Do not build one for two or three.
- MUST NOTJAVA-08.11 call overridable methods from a constructor.
- MUSTJAVA-08.12 give a utility class of static methods a
privateconstructor and mark itfinal. Before writing one, check whether the methods belong on a type.
Member order
There is no single required order, but members follow a logical order the author could explain. The default:
- Static constants
- Static fields
- Instance fields
- Constructors and static factories
- Public methods, grouped by what they do
- Private helpers, placed after the first method that uses them
- Nested types
- MUSTJAVA-08.13 keep overloads (methods or constructors with the same name) together, with nothing in between.
- MUST NOTJAVA-08.14 order members by when they were added. New methods go where they belong, not at the end.
Interfaces
- SHOULDJAVA-08.15 introduce an interface when there are two or more implementations, or when the boundary must be faked in tests. Do not create
FooImplfor everyFoo. - SHOULDJAVA-08.16 name implementations by what makes them different:
PostgresInvoiceRepository,InMemoryInvoiceRepository. - MAYJAVA-08.17 use
defaultmethods for behavior that is genuinely the same for every implementation; avoid them as a way to add state-like behavior.
equals, hashCode, toString
- MUSTJAVA-08.18 override
equalsandhashCodetogether, or not at all. Records do this for you. - SHOULDJAVA-08.19 give every class that appears in logs a
toStringthat is useful and never prints secrets. - MUSTJAVA-08.20 qualify static member access with the class name, never with an instance:
Duration.ofSeconds(5), nottimeout.ofSeconds(5).
public final class InvoiceService {
private static final Duration GRACE_PERIOD = Duration.ofDays(14);
private final InvoiceRepository repository;
private final Clock clock;
public InvoiceService(InvoiceRepository repository, Clock clock) {
this.repository = requireNonNull(repository);
this.clock = requireNonNull(clock);
}
public Invoice issue(Order order) {
var invoice = Invoice.draft(order, dueDate());
repository.save(invoice);
return invoice;
}
private LocalDate dueDate() {
return LocalDate.now(clock).plus(GRACE_PERIOD);
}
}Methods
Methods are short, do one thing, take few parameters, and return results instead of mutating arguments. A method that needs comments to separate its sections should be several methods.
Write short methods
Prefer small, focused methods. There is no hard limit, but once a method passes about 40 lines, ask whether it can be split without hurting the structure. Short methods are easier to read, change and test.
Parameters and return values
- SHOULDJAVA-09.1 keep to about 4 parameters. Past that, group related ones into a record:
issue(InvoiceRequest request). - AVOIDJAVA-09.2
booleanparameters that read as a mystery at the call site (send(invoice, true)); use anenumor two named methods. - MUST NOTJAVA-09.3 reassign parameters. Declare a new local instead.
- MUSTJAVA-09.4 return an empty collection, never
null, when there is nothing to return. - SHOULDJAVA-09.5 return
Optional<T>from a lookup that may find nothing (section 10). Never returnnullfrom a method declared to returnOptional. - SHOULDJAVA-09.6 prefer guard clauses and early returns over nested
ifblocks. - SHOULDJAVA-09.7 keep overloads semantically identical; a reader should not need to know which overload ran.
- AVOIDJAVA-09.8 varargs on public APIs except where a fixed list of values is the natural call shape (
List.of, logging).
Local variables
- MUSTJAVA-09.9 declare each local in the narrowest scope possible, as close to its first use as possible, and initialize it where it is declared.
- SHOULDJAVA-09.10 start collections initialized (
List.of(a, b)) rather than building them with a series ofaddcalls when the contents are known. - MAYJAVA-09.11 mark locals
final. Neither required nor discouraged; be consistent within a file.
Lambdas and method references
- SHOULDJAVA-09.12 prefer a method reference when it is clearer than the lambda:
.map(Invoice::total). - SHOULDJAVA-09.13 keep lambdas to a few lines. A longer body becomes a named private method referenced by
this::validateLine. - MUST NOTJAVA-09.14 mutate captured state from a lambda passed to a stream or another thread; collect a result instead.
// Guard clauses, a request record, an Optional lookup.
public Invoice issue(InvoiceRequest request) {
requireNonNull(request);
if (request.lines().isEmpty()) {
throw new IllegalArgumentException("An invoice needs at least one line");
}
Customer customer =
customers
.findById(request.customerId())
.orElseThrow(() -> new CustomerNotFoundException(request.customerId()));
return Invoice.draft(customer, request.lines(), dueDate());
}Null handling
Everything is non-null unless its type says @Nullable, and a checker proves it at compile time. Null is allowed only where it is declared, and Optional is used only as a return type.
Annotations and checking
- MUSTJAVA-10.1 mark every package
@NullMarked(JSpecify) in itspackage-info.java, making non-null the default for the whole package. - MUSTJAVA-10.2 mark the exceptions with
@Nullablefromorg.jspecify.annotations, placed directly before the type:@Nullable String middleName. - MUSTJAVA-10.3 run NullAway on Error Prone in every build, with JSpecify mode on, failing the build on any null-safety error.
- MUST NOTJAVA-10.4 mix nullness annotation libraries (
javax.annotation, JetBrains, Checker Framework) in the same codebase. Use JSpecify only. - MUSTJAVA-10.5 check arguments from outside the null-checked code (public API entry points, deserialized data, framework callbacks) with
Objects.requireNonNull(arg, "arg")at the boundary.
// src/main/java/com/example/project/billing/package-info.java
@NullMarked
package com.example.project.billing;
import org.jspecify.annotations.NullMarked;
Optional
- SHOULDJAVA-10.6 return
Optional<T>from a method that can legitimately find nothing, such as a repository lookup. - MUST NOTJAVA-10.7 use
Optionalfor fields, method parameters, collection elements or map values. Use@Nullableor an empty collection instead. - MUST NOTJAVA-10.8 call
Optional.get(). UseorElseThrow(),orElse,maporifPresentOrElse, so the empty case is handled where it is visible. - SHOULDJAVA-10.9 use the primitive specializations (
OptionalInt,OptionalLong) for primitive results.
public Optional<Customer> findById(CustomerId id) { ... }
String name = customers.findById(id).map(Customer::displayName).orElse("Unknown customer");
Collections and strings
- MUSTJAVA-10.10 use empty collections and empty strings rather than
nullto mean "none". - MUST NOTJAVA-10.11 put
nullin a collection.List.of,Set.ofandMap.ofalready reject it.
Exceptions and errors
Unchecked exceptions signal failures the caller cannot reasonably handle; expected, recoverable outcomes are part of the return type. No exception is ever silently swallowed.
| Situation | Tool |
|---|---|
| Caller passed a bad argument | IllegalArgumentException, NullPointerException via requireNonNull |
| Object is in the wrong state for the call | IllegalStateException |
| A lookup found nothing | Optional<T> return |
| Several expected outcomes the caller must handle (approved, declined, pending) | Sealed result type (section 7) |
| Failure the caller cannot fix (I/O down, bug, broken config) | Unchecked domain exception, such as PaymentGatewayException extends RuntimeException |
| A condition that cannot happen | throw new AssertionError("...") |
Rules
- SHOULDJAVA-11.1 throw unchecked exceptions from your own code. Declare a checked exception only when callers are genuinely expected to recover from that specific failure.
- MUST NOTJAVA-11.2 ignore a caught exception. Handle it, log it with context, or rethrow it. If doing nothing is truly correct, name the variable
_and write a comment saying why. - MUSTJAVA-11.3 keep the cause when translating:
throw new InvoiceStorageException("Saving " + id, e);. - SHOULDJAVA-11.4 translate low-level exceptions at the layer boundary into exceptions that make sense to that layer's callers. A billing service does not leak
SQLException. - MUSTJAVA-11.5 catch the most specific type available. MUST NOT catch
Exception,ThrowableorErrorexcept at a top-level boundary (a request handler, a thread's run loop,main) that logs and reports. - MUSTJAVA-11.6 close every
AutoCloseablewith try-with-resources. - MUST NOTJAVA-11.7 use exceptions for ordinary control flow, or
return/throwfrom afinallyblock. - MUSTJAVA-11.8 write exception messages that say what failed and with which input, without secrets:
"Invoice INV-1042 has no lines", not"Invalid state". - SHOULDJAVA-11.9 log an exception once, at the place that decides what to do about it, not at every layer it passes through.
- SHOULDJAVA-11.10 use multi-catch for identical handling:
catch (TimeoutException | ConnectException e).
public Invoice load(InvoiceId id) {
try (var connection = dataSource.getConnection();
var statement = connection.prepareStatement(SELECT_BY_ID)) {
statement.setString(1, id.value());
try (var rows = statement.executeQuery()) {
if (!rows.next()) {
throw new InvoiceNotFoundException(id);
}
return mapper.map(rows);
}
} catch (SQLException e) {
throw new InvoiceStorageException("Loading invoice " + id, e);
}
}Collections and streams
Collections are immutable by default, typed by interface, and never null. Streams are for transforming data in a readable pipeline; a plain loop is fine when it reads better.
Collections
- SHOULDJAVA-12.1 create fixed collections with
List.of,Set.of,Map.ofand copy incoming ones withList.copyOf, which are immutable and reject nulls. - MUSTJAVA-12.2 declare variables, fields, parameters and return types by interface:
List<Invoice>, notArrayList<Invoice>. - SHOULDJAVA-12.3 use the sequenced-collection methods (
getFirst(),getLast(),reversed(),addFirst()) instead of index arithmetic likelist.get(list.size() - 1). - SHOULDJAVA-12.4 pick the implementation deliberately:
ArrayListby default,ArrayDequefor stacks and queues,LinkedHashMapwhen insertion order matters,EnumMap/EnumSetfor enum keys. AVOIDLinkedList,Vector,HashtableandStack. - SHOULDJAVA-12.5 pre-size collections when the final size is known:
new ArrayList<>(orders.size()). - MUST NOTJAVA-12.6 modify a collection while iterating over it; use
removeIf, an iterator'sremove, or build a new collection.
Streams
- SHOULDJAVA-12.7 put each stream operation on its own line once the pipeline has more than two steps.
- MUST NOTJAVA-12.8 cause side effects inside
map,filterorpeek. Side effects go in a terminalforEach, or better, in a loop. - SHOULDJAVA-12.9 use
toList()(unmodifiable) to finish a stream. UseCollectors.toCollection(...)only when the result must be mutable. - SHOULDJAVA-12.10 use a
forloop when the logic needsbreak, checked exceptions, several accumulators or index access. Forcing those into a stream makes it harder to read. - AVOIDJAVA-12.11 parallel streams unless a benchmark shows a gain on the real workload; they share the common fork-join pool.
- MUST NOTJAVA-12.12 use Stream Gatherers (
Stream.gather) in production code; they are a preview API in Java 23.
Map<CustomerId, Money> totalsByCustomer =
invoices.stream()
.filter(Invoice::isIssued)
.collect(
Collectors.groupingBy(
Invoice::customerId,
Collectors.reducing(Money.zero(EUR), Invoice::total, Money::plus)));
Invoice latest = invoices.getLast();Concurrency
Blocking I/O runs on virtual threads, CPU-bound work runs on a bounded platform-thread pool, and shared mutable state is the exception, guarded explicitly. Prefer immutable data handed between threads over locks.
Virtual threads
- SHOULDJAVA-13.1 run I/O-bound tasks (HTTP calls, database queries, file reads) on virtual threads:
Executors.newVirtualThreadPerTaskExecutor(). - MUST NOTJAVA-13.2 pool virtual threads. Create one per task; they are cheap.
- SHOULDJAVA-13.3 limit concurrency against a scarce resource with a
Semaphore, not with a small thread pool. - MUSTJAVA-13.4 guard blocking calls in code that runs on virtual threads with
ReentrantLockrather thansynchronized. In Java 23 a virtual thread that blocks insidesynchronizedpins its carrier thread and can starve the scheduler. - AVOIDJAVA-13.5 heavy
ThreadLocaluse with virtual threads; millions of threads each holding a cached object add up. Pass context explicitly. - MUST NOTJAVA-13.6 use structured concurrency or scoped values in production code; both are preview APIs in Java 23.
Executors and threads
- MUSTJAVA-13.7 close every
ExecutorServicewith try-with-resources (it isAutoCloseableand waits for tasks), or shut it down explicitly. - MUSTJAVA-13.8 name long-lived platform threads with a
Thread.ofPlatform().name("invoice-export-", 0)factory so they show in thread dumps and JFR. - SHOULDJAVA-13.9 size CPU-bound pools to the number of cores, and keep CPU-bound work off virtual threads.
- MUST NOTJAVA-13.10 call
Thread.stop,suspendorresume, or rely onThread.sleepfor coordination. Use latches, futures and queues. - MUSTJAVA-13.11 restore the interrupt flag when catching
InterruptedExceptionand not rethrowing it:Thread.currentThread().interrupt();.
Shared state
- SHOULDJAVA-13.12 share only immutable objects (records,
List.copyOf) between threads. - MUSTJAVA-13.13 guard every piece of shared mutable state with one documented mechanism: a lock, an atomic, or a concurrent collection.
- SHOULDJAVA-13.14 use
java.util.concurrenttypes (ConcurrentHashMap,AtomicLong,LongAdder,BlockingQueue) instead of hand-rolled synchronization. - MUSTJAVA-13.15 document thread safety on every class that may be shared:
/// Thread-safe.or/// Not thread-safe; confine to one thread. - AVOIDJAVA-13.16
volatileexcept for a single flag or reference published by one writer.
List<Receipt> sendAll(List<Invoice> invoices) throws InterruptedException {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Receipt>> futures =
invoices.stream().map(invoice -> executor.submit(() -> mailer.send(invoice))).toList();
var receipts = new ArrayList<Receipt>(futures.size());
for (Future<Receipt> future : futures) {
receipts.add(getResult(future));
}
return receipts;
}
}Generics and API design
Generic code is fully typed with no raw types, and public APIs are designed to be hard to misuse: narrow, immutable, and explicit about ownership and nullness.
Generics
- MUST NOTJAVA-14.1 use raw types (
List,Comparable) except ininstanceofchecks and class literals. - SHOULDJAVA-14.2 follow PECS for API parameters:
? extends Tfor inputs the method reads from (producers),? super Tfor outputs it writes to (consumers). - MUST NOTJAVA-14.3 use wildcards in return types; they push complexity onto every caller.
- MUSTJAVA-14.4 suppress unchecked warnings with
@SuppressWarnings("unchecked")on the narrowest declaration possible, usually a single local variable, with a comment explaining why the cast is safe. - SHOULDJAVA-14.5 prefer
List<T>overT[]in APIs; arrays and generics do not mix well.
static <T extends Comparable<? super T>> T max(Collection<? extends T> items) { ... }
void copyAll(Collection<? extends Invoice> source, Collection<? super Invoice> target) {
target.addAll(source);
}
Public API rules
- SHOULDJAVA-14.6 expose the smallest surface that does the job. Every public type and method is a promise; keep the rest package-private.
- MUSTJAVA-14.7 accept and return interfaces and immutable types (
List, records), never mutable internals. - SHOULDJAVA-14.8 prefer domain types to primitives once a value crosses a package boundary:
InvoiceId,Money,Durationrather thanString,BigDecimal,long millis. - MUSTJAVA-14.9 use
java.timetypes for all dates and times (Instant,LocalDate,Duration), neverDate,Calendarorlongtimestamps in APIs. - MUSTJAVA-14.10 use
BigDecimal(or aMoneytype) for money, neverdouble. - MUSTJAVA-14.11 inject time and randomness (
Clock,RandomGenerator) instead of callingInstant.now()ornew Random()inside logic, so tests can control them. - SHOULDJAVA-14.12 mark an API being phased out with
@Deprecated(since = "1.4", forRemoval = true)plus a doc comment naming the replacement, and remove it in the next major version.
Modules and packages
Each Maven module that is a library declares a module-info.java that exports only its API packages; everything else stays encapsulated. Inside a module, package-private is the default visibility.
- SHOULDJAVA-15.1 give every library module a
module-info.java. Applications built on frameworks that do not support the module path may skip it, with a note in the README. - MUSTJAVA-15.2 name the Java module after its root package:
module com.example.project.billing. - MUSTJAVA-15.3 export only packages that other modules are meant to use. Put implementation details in an unexported package such as
com.example.project.billing.internal. - SHOULDJAVA-15.4 use
opensonly for packages a framework must reach by reflection (JSON mapping, dependency injection), and preferopens ... to <module>over opening to everyone. - SHOULDJAVA-15.5 use
requires transitiveonly for modules whose types appear in your exported API. - MUSTJAVA-15.6 write
requiresmodifiers in the ordertransitive, thenstatic. - MUST NOTJAVA-15.7 create split packages (the same package in two modules).
- MUST NOTJAVA-15.8 use module import declarations (
import module java.base;); they are a preview feature in Java 23. - SHOULDJAVA-15.9 mark each package
@NullMarkedinpackage-info.java, alongside its package doc comment (section 10).
/// Billing domain: invoices, payments and credit notes.
module com.example.project.billing {
requires transitive com.example.project.money;
requires org.slf4j;
requires static org.jspecify;
exports com.example.project.billing;
opens com.example.project.billing.json to com.fasterxml.jackson.databind;
}Logging and diagnostics
Libraries log through the SLF4J API only; the application chooses the backend. Log messages are parameterized, levelled consistently, and never contain secrets.
| Level | Use for | Enabled in production |
|---|---|---|
ERROR |
A failure that needs a person: a request failed, data could not be saved | Yes |
WARN |
Something unexpected the system recovered from: a retry, a fallback, a deprecated call | Yes |
INFO |
Lifecycle and business events: startup, shutdown, an invoice issued | Yes |
DEBUG |
Detail for diagnosing a specific problem | Off by default, on per package |
TRACE |
Very fine-grained flow, payloads | Never in production |
Rules
- MUSTJAVA-16.1 use the SLF4J API (
org.slf4j.Logger) in library code, never a backend directly,System.out,System.errorprintStackTrace(). - MUSTJAVA-16.2 declare the logger as
private static final Logger log = LoggerFactory.getLogger(InvoiceService.class);. It is not a constant, so it is not UPPER_SNAKE_CASE. - MUSTJAVA-16.3 use
{}placeholders, never string concatenation:log.info("Issued invoice {} for {}", id, customerId);. - MUSTJAVA-16.4 pass the exception as the last argument so the stack trace is kept:
log.error("Could not send invoice {}", id, e);. - MUST NOTJAVA-16.5 log passwords, tokens, API keys, full card numbers or personal data, at any level.
- SHOULDJAVA-16.6 put request-scoped context (request ID, tenant) in the MDC rather than repeating it in every message, and clear it when the request ends.
- SHOULDJAVA-16.7 log state changes and decisions, not method entry and exit.
- SHOULDJAVA-16.8 emit JSON logs from services so they can be searched by field.
Diagnostics
- SHOULDJAVA-16.9 keep Java Flight Recorder available in production (
-XX:StartFlightRecordingwith a bounded, rolling recording) and add customjdk.jfr.Events around key operations. - SHOULDJAVA-16.10 expose health and metrics through the application framework (Micrometer or equivalent) rather than through logs.
Testing
Tests use JUnit 5 and AssertJ, live in the same package as the code they test under src/test/java, and are fast and deterministic. Integration tests that need real infrastructure live in their own module and run against Testcontainers.
Layout and naming
- MUSTJAVA-17.1 name a test class after the class it tests plus
Test, in the same package:com.example.project.billing.InvoiceServiceTest. - SHOULDJAVA-17.2 name test methods as behavior:
methodUnderTest_condition_expectedResultor a plain sentence in lowerCamelCase. Underscores between parts are allowed in test names only. - SHOULDJAVA-17.3 give each test the shape arrange, act, assert, separated by blank lines.
- MUSTJAVA-17.4 keep unit tests in
src/test/javaof the module they test, and tests that start containers, servers or databases in theproject-itmodule (run by the Failsafe plugin).
Rules
- MUSTJAVA-17.5 write assertions with AssertJ (
assertThat(...)), not JUnit'sassertEquals, for readable failures. - MUSTJAVA-17.6 add a failing test that reproduces a bug before fixing it.
- SHOULDJAVA-17.7 test through the public (or package-private) API. A test that needs reflection to reach a private member is a sign the class wants splitting.
- MUSTJAVA-17.8 make tests deterministic: inject a fixed
Clock, seed any randomness, and never sleep to wait; poll with Awaitility if you must wait. - SHOULDJAVA-17.9 use
@ParameterizedTestfor the same behavior over several inputs instead of copying the test. - SHOULDJAVA-17.10 prefer real objects and simple in-memory fakes over mocks. Use Mockito for awkward collaborators (HTTP clients, clocks you cannot inject), and do not mock types you own just because you can.
- MUST NOTJAVA-17.11 share mutable state between tests; each test sets up what it needs.
- SHOULDJAVA-17.12 keep the unit test suite of a module under about 10 seconds, so it runs on every save.
class InvoiceServiceTest {
private final Clock clock = Clock.fixed(Instant.parse("2026-10-19T12:00:00Z"), ZoneOffset.UTC);
private final InMemoryInvoiceRepository repository = new InMemoryInvoiceRepository();
private final InvoiceService service = new InvoiceService(repository, clock);
@Test
void issue_setsDueDateFourteenDaysOut() {
Order order = Orders.withOneLine("Widget", Money.of("19.99", EUR));
Invoice invoice = service.issue(order);
assertThat(invoice.dueDate()).isEqualTo(LocalDate.parse("2026-11-02"));
assertThat(repository.findById(invoice.id())).contains(invoice);
}
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t"})
void issue_rejectsBlankReference(String reference) {
assertThatThrownBy(() -> service.issue(Orders.withReference(reference)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("reference");
}
}Documentation
Doc comments use the Markdown form (///) that became final in Java 23, describe what a member is for and how to use it, and start with a short summary fragment. Implementation comments explain why, never what.
Doc comments
- MUSTJAVA-18.1 write new doc comments with
///Markdown syntax. Existing/** */comments may stay until the file is next edited substantially. - MUSTJAVA-18.2 document every public type and every public or protected member of a public type. Skip it only when there is truly nothing to add (a trivial getter) and for
@Overridemethods that keep the parent's contract. - MUSTJAVA-18.3 start with a summary fragment: a noun or verb phrase, capitalized and ending with a period, not a full sentence. Write "Returns the invoice total.", not "This method returns the invoice total."
- SHOULDJAVA-18.4 say what callers need: valid inputs, what is returned when nothing is found, which exceptions are thrown and when, thread safety, and whether the method keeps references to its arguments.
- MUSTJAVA-18.5 order block tags
@param,@return,@throws,@deprecated, and never leave a tag's description empty. - SHOULDJAVA-18.6 separate paragraphs with a blank
///line and use Markdown for code (`name`), lists and links ([Invoice],[Invoice#total()]).
/// Issues an invoice for a completed order and stores it.
///
/// The due date is fourteen days after today, using the injected clock.
/// Issuing the same order twice creates two invoices; callers that need
/// idempotency must check [InvoiceRepository#findByOrder(OrderId)] first.
///
/// @param order the completed order; must have at least one line
/// @return the stored invoice, in [InvoiceStatus#ISSUED] state
/// @throws IllegalArgumentException if the order has no lines
public Invoice issue(Order order) { ... }
Implementation comments
- MUSTJAVA-18.7 write implementation comments with
//. Block comments (/* */) are allowed for long notes; align their*and never draw boxes. - MUSTJAVA-18.8 explain why when the code does something surprising: a workaround, a performance trick, an ordering requirement. Leave the what to the code.
- MUSTJAVA-18.9 write TODOs as
// TODO: <issue link> - <what needs doing>. A TODO that depends on time names a date or event:// TODO: #318 - remove after the 2027-01 API migration. - SHOULDJAVA-18.10 use
// NOTE:for surprising but correct code and// HACK:for knowingly wrong code with a plan to remove it. - AVOIDJAVA-18.11 commented-out code. Git remembers it.
- SHOULDJAVA-18.12 record significant decisions (a framework choice, a persistence model, a concurrency approach) as short ADRs in
docs/adr/NNNN-title.md.
Build and static analysis
Maven builds every project through the committed wrapper, and ./mvnw verify runs formatting checks, compilation with Error Prone and NullAway, unit tests and integration tests. A green verify means the change is ready for review.
What verify runs
| Step | Tool | Fails the build when |
|---|---|---|
| Enforce environment | maven-enforcer-plugin | JDK is not 23, Maven too old, dependency versions conflict |
| Check formatting | Spotless + google-java-format | Any file is not formatted |
| Compile | maven-compiler-plugin, --release 23, -Xlint:all -Werror |
Any compiler warning |
| Bug patterns | Error Prone | Any error-level finding |
| Null safety | NullAway (JSpecify mode) | Any possible null dereference |
| Unit tests | Surefire + JUnit 5 | Any test fails |
| Integration tests | Failsafe + Testcontainers (project-it) |
Any test fails |
Rules
- MUSTJAVA-19.1 set every plugin and dependency version in the parent POM's
<pluginManagement>and<dependencyManagement>, importing BOMs (JUnit, Jackson, Testcontainers) where they exist. Child modules never state versions. - MUSTJAVA-19.2 run
./mvnw spotless:applybefore committing (or install the pre-commit hook); CI only runsspotless:check. - MUSTJAVA-19.3 treat warnings as errors in CI. A warning that cannot be fixed is suppressed at the narrowest scope with a comment.
- SHOULDJAVA-19.4 enable reproducible builds by setting
project.build.outputTimestamp. - SHOULDJAVA-19.5 let Dependabot or Renovate propose dependency updates weekly, merged as their own commits.
- MUST NOTJAVA-19.6 add a dependency for something the JDK already does well (
java.net.http.HttpClient,java.time,java.util.concurrent) without a reason in the commit message. - SHOULDJAVA-19.7 use Gradle (Kotlin DSL) only where a project already depends on it; the same checks apply.
The full parent POM is in appendix A.
Performance
Write the clear version first, measure with JFR or JMH, then optimize the hot spot the measurement found, with a comment saying what it showed. The JIT handles most micro-optimizations better than hand-tuning.
Measuring
- MUSTJAVA-20.1 back any performance claim in a commit or review with a measurement: a JMH benchmark, a JFR recording or a load-test result, with before and after numbers.
- SHOULDJAVA-20.2 write micro-benchmarks with JMH in a separate
project-benchmodule, never withSystem.nanoTime()loops in tests. - SHOULDJAVA-20.3 profile production-like load with JFR and JDK Mission Control before changing code.
Common costs worth knowing
- SHOULDJAVA-20.4 avoid autoboxing in hot loops: use
intoverInteger,IntStreamoverStream<Integer>, and primitive-specialized collections where a profile shows boxing matters. - SHOULDJAVA-20.5 build strings in loops with
StringBuilder; simple concatenation outside loops is fine (the compiler optimizes it). - SHOULDJAVA-20.6 pre-size
ArrayList,HashMapandStringBuilderwhen the size is known. - MUSTJAVA-20.7 compile a
Patternonce into astatic finalfield rather than callingString.matchesorPattern.compileper call in hot code. - AVOIDJAVA-20.8 exceptions for control flow on hot paths; filling in stack traces is expensive.
- SHOULDJAVA-20.9 use streaming APIs for large inputs (files, query results) instead of loading everything into memory.
JVM settings
- SHOULDJAVA-20.10 use Generational ZGC (the default ZGC mode in Java 23:
-XX:+UseZGC) for latency-sensitive services, and the default G1 for batch and throughput work, unless measurement says otherwise. - SHOULDJAVA-20.11 set an explicit maximum heap (
-Xmxor-XX:MaxRAMPercentagein containers) and record the choice next to the deployment config. - MUST NOTJAVA-20.12 use
sun.misc.Unsafememory-access methods (deprecated for removal in Java 23). UseVarHandleor the Foreign Function & Memory API.
Git and review
Commits are small, focused and written in Conventional Commits form. One commit does one thing, builds on its own, and can be reverted on its own.
<type>(<scope>): <imperative summary, under 72 characters>
<optional body: why the change was needed, what it trades off>
| Type | Use for | Example |
|---|---|---|
feat |
New capability | feat(billing): add credit notes |
fix |
Bug fix | fix(billing): round VAT per line, not per invoice |
perf |
Measured speedup | perf(export): stream rows, 2.1 GB to 180 MB heap |
refactor |
No behavior change | refactor(shipping): extract RateCalculator |
test |
Tests only | test(billing): cover zero-amount invoices |
build |
Maven, plugins, dependencies | build: bump junit-bom |
style |
Formatter runs, renames with no logic change | style: apply google-java-format |
docs |
Docs and ADRs | docs(adr): choose jOOQ over JPA |
chore |
Everything else | chore: ignore .idea |
- MUSTJAVA-21.1 use the feature package or module as the scope (
billing,shipping,api). - MUSTJAVA-21.2 keep formatting-only and rename-only changes in their own commits so real diffs stay readable.
- SHOULDJAVA-21.3 mark breaking API changes with
!and aBREAKING CHANGE:footer.
Review checklist
-
./mvnw verifypasses: format, compile with no warnings, Error Prone, NullAway, tests - Names follow section 5; no abbreviations by deleted letters
- New types are
final, immutable or records where they can be - Nullable values are marked
@Nullable;Optionalonly as a return type - No swallowed exceptions; causes kept when translating
- No preview features in production code
- Shared mutable state is guarded and its thread safety documented
- Public types and members have
///doc comments - New behavior has tests; a bug fix has a regression test
- Commit messages are Conventional Commits
Appendix
Drop-in files for a new repository. Replace com.example.project and project with your own names, and set each *.version property to the current release of that tool (Renovate or Dependabot keeps them current afterwards).
A. Parent pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example.project</groupId>
<artifactId>project-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>project-core</module>
<module>project-api</module>
<module>project-it</module>
</modules>
<properties>
<maven.compiler.release>23</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-01-01T00:00:00Z</project.build.outputTimestamp>
<!-- Pin each to the current release. -->
<errorprone.version>CURRENT</errorprone.version>
<nullaway.version>CURRENT</nullaway.version>
<jspecify.version>CURRENT</jspecify.version>
<junit.version>CURRENT</junit.version>
<assertj.version>CURRENT</assertj.version>
<spotless.version>CURRENT</spotless.version>
<google-java-format.version>CURRENT</google-java-format.version>
<maven-compiler-plugin.version>CURRENT</maven-compiler-plugin.version>
<maven-enforcer-plugin.version>CURRENT</maven-enforcer-plugin.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>${jspecify.version}</version>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven-compiler-plugin.version}</version>
<configuration>
<release>23</release>
<compilerArgs>
<arg>-Xlint:all</arg>
<arg>-Werror</arg>
<arg>-parameters</arg>
<arg>-XDcompilePolicy=simple</arg>
<arg>--should-stop=ifError=FLOW</arg>
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true -XepOpt:NullAway:JSpecifyMode=true</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_core</artifactId>
<version>${errorprone.version}</version>
</path>
<path>
<groupId>com.uber.nullaway</groupId>
<artifactId>nullaway</artifactId>
<version>${nullaway.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>${spotless.version}</version>
<configuration>
<java>
<googleJavaFormat>
<version>${google-java-format.version}</version>
</googleJavaFormat>
</java>
</configuration>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>${maven-enforcer-plugin.version}</version>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireJavaVersion>
<version>[23,24)</version>
</requireJavaVersion>
<requireMavenVersion>
<version>[3.9,)</version>
</requireMavenVersion>
<dependencyConvergence/>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Error Prone needs access to compiler internals on modern JDKs. Add this .mvn/jvm.config beside the POM:
--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED
B. .editorconfig
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.java]
max_line_length = 100
[*.{xml,yml,yaml,json}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
C. package-info.java
/// Invoices, payments and credit notes.
///
/// Every type in this package is non-null by default; nullable values are
/// marked with [org.jspecify.annotations.Nullable].
@NullMarked
package com.example.project.billing;
import org.jspecify.annotations.NullMarked;
D. Starting a new project
- Copy the parent POM,
.mvn/jvm.configand.editorconfig; replacecom.example.projectandproject. - Pin the JDK in
.java-versionand in CI, and commit the Maven wrapper. - Set every
*.versionproperty to the current release. - Add
package-info.javawith@NullMarkedto the first package. - Run
./mvnw verifyand confirm a clean build with no warnings. - Write
docs/adr/0001-project-scope.mdsaying what the project is and is not.