Java 21 Code Standards
Overview
Full rules: .agents/standards/java/java-code-style.md. This covers the decisions that come up
while writing.
When to use
- Writing a new Java class
- Reviewing a Java diff
- Deciding:
Optionalor exception? Record or class? Checked or unchecked? - Cleaning up code that is hard to read
Formatting is machine-enforced
./mvnw spotless:apply # before committing
Do not argue about formatting in review. Spotless has already decided.
Process
Record or class?
Record for anything carrying data without identity: DTOs, parameter objects, return values, events. This is the default.
Class only when mutable identity is required (a JPA entity) or the behaviour does not fit a record.
public record CreateVendorRequest(
@NotBlank @Size(max = 200) String name,
@NotBlank @Pattern(regexp = "\\d{15,16}") String taxId,
@NotNull VendorType type) {}
A record may use a compact constructor for normalisation — but business validation stays in the service:
public record PageRequest(int page, int size, String sortField, String sortDir) {
public PageRequest {
if (page < 0) page = 0;
size = Math.clamp(size, 1, 200); // server-enforced cap
}
}
Optional or an exception?
| Situation | Use |
|---|---|
| "Look it up, it may not exist" — an ordinary outcome | Optional<T> |
| "Fetch by an ID that should exist" — absence is wrong | throw NotFoundException |
| An empty collection | List.of() — never null |
Optional is a return type only. Not a field, not a parameter.
public Optional<Vendor> findByTaxId(String taxId) { ... } // right
public Vendor getById(Long id) { ... } // right, throws if absent
public void update(Long id, Optional<String> name) { ... } // wrong
Which exception?
Three, all extending AppException, all carrying an ErrorCode:
throw new NotFoundException(ErrorCode.VENDOR_NOT_FOUND, "vendor id=" + id);
throw new ValidationException(ErrorCode.VENDOR_TAX_ID_DUPLICATE, "taxId=" + taxId);
throw new ConflictException(ErrorCode.ORDER_ALREADY_APPROVED, "id=" + id);
ErrorCode is an enum that is stable forever — the frontend branches on it. The message
may change; the code may not.
Exception messages target the developer and carry debugging context (IDs, values), but never sensitive data (passwords, confidential prices, document contents).
Expected patterns
Early returns, not nesting
// WRONG — four levels deep
public void process(Order p) {
if (p != null) {
if (p.status == DRAFT) {
if (p.amount != null) {
if (p.amount.compareTo(BigDecimal.ZERO) > 0) {
send(p);
}
}
}
}
}
// RIGHT — flat, every rejection states its reason
public void process(Order p) {
Objects.requireNonNull(p, "order");
if (p.status != DRAFT) {
throw new ConflictException(ErrorCode.ORDER_NOT_DRAFT, "id=" + p.id);
}
if (p.amount == null || p.amount.signum() <= 0) {
throw new ValidationException(ErrorCode.AMOUNT_INVALID, "id=" + p.id);
}
send(p);
}
Pattern-matching switch for state
String label = switch (status) {
case DRAFT -> "Draft";
case SUBMITTED -> "Awaiting approval";
case APPROVED -> "Approved";
case REJECTED, CANCELLED -> "Not proceeding";
};
A switch over an enum without default makes the compiler flag any newly added enum
constant that is not handled. Do not add a default just to satisfy the compiler —
that throws away the safety net.
Text blocks for SQL
private static final String SQL_SUMMARY = """
SELECT order_type, COUNT(*) AS total, SUM(amount) AS amount
FROM order
WHERE transaction_date BETWEEN ?1 AND ?2
AND deleted_at IS NULL
GROUP BY order_type
""";
Money
BigDecimal, always. Never double or float.
BigDecimal total = price.multiply(BigDecimal.valueOf(quantity))
.setScale(2, RoundingMode.HALF_UP);
// compare with compareTo, not equals
if (amount.compareTo(BigDecimal.ZERO) > 0) { ... }
equals on BigDecimal takes scale into account: new BigDecimal("1.0") does not equal
new BigDecimal("1.00"). This is a comparison bug that is easy to miss in review.
Logging
private static final Logger log = Logger.getLogger(VendorService.class);
log.infof("vendor created id=%d taxId=%s", id, maskTaxId(taxId));
Parameterised, not concatenated. Never System.out. Never sensitive data.
Naming
- Identifiers are English.
- The exception is Indonesian statutory order vocabulary with no precise English
equivalent. That glossary lives in the organisation's domain skill under
context/skills/. - Do not abbreviate:
orderType, notprocType. - Booleans read as assertions:
active,approved— notflag,status1.
Red flags
| Item | Threshold | Usually means |
|---|---|---|
| Method length | > 40 lines | More than one responsibility |
| Parameters | > 4 | Needs a record parameter object |
| Class length | > 400 lines | Needs splitting |
| Nesting | > 3 | Needs early returns |
| "and" in a method name | — | It is two methods |
Verification
- DTOs are
records with no setters - No
nullreturned for a collection -
Optionalused only as a return type - Domain exceptions with
ErrorCode, not bareRuntimeException - No empty
catchorprintStackTrace - Money uses
BigDecimal, compared withcompareTo - No
System.out - No sensitive data in logs or exception messages
- Every
TODOcarries a ticket ID -
./mvnw spotless:checkpasses