Java Coding Standards
How Java itself is written in a Spring Boot service, from naming through to Javadoc. Java 21 LTS is the minimum and Java 25 LTS, released September 2025, is the recommended target, so records, sealed types, pattern matching, and virtual threads are all available and expected.
When to activate
- Writing or reviewing Java in a Spring Boot project.
- Deciding between a record, a class, and a sealed hierarchy.
- Enforcing naming, immutability, or exception conventions in review.
- Reviewing use of
Optional, streams, or generics. - Structuring packages, or setting up the static analysis that guards this style.
When not to activate
- Spring Boot structure, controllers, DTO contracts, and bean wiring, use
springboot-patterns. - Entity mapping, queries, transaction boundaries, and security wiring, use
springboot-patterns. - Writing the tests, and running the analysis pipeline before a pull request, use
springboot-patterns. - Version catalogs, BOM imports, and dependency admission, use
build-dependency-management. - Ports and adapters layering across a service, use
hexagonal-architecture.
Name things after what they are
Classes and records are PascalCase, methods and fields camelCase, constants UPPER_SNAKE_CASE. A boolean starts with
is, has, or can. A method name is a verb phrase specific enough that a reader does not have to open the body.
Pass:
public record Money(BigDecimal amount, Currency currency) {}
private static final int MAX_PAGE_SIZE = 100;
public List<User> findActiveUsersByTenant(TenantId tenantId) { ... }
Fail: getData(), flag, process(), and a constant spelled maxPageSize.
Inject through the constructor, log through Lombok
Lombok removes boilerplate that carries no decisions. @RequiredArgsConstructor for injection, @Slf4j for the
logger, @Builder where construction is genuinely complex, @Value for immutable value objects, and @Getter or
@Setter only where neither a record nor @Value fits.
Pass: final fields, one generated constructor, no annotation on the field.
@Service
@Slf4j
@RequiredArgsConstructor
public class MarketService {
private final MarketRepository marketRepository;
}
Fail: field injection, which hides the dependency from every constructor call and from every test.
@Autowired
private MarketRepository marketRepository;
Make it immutable unless something has to change
Records for data, final fields elsewhere, and no setter that exists only because a framework once needed one. Immutable objects are safe to share across threads and cannot be half-updated by a failed operation.
Pass:
public record MarketDto(Long id, String name, MarketStatus status) {}
Fail: a mutable bean with a no-argument constructor and a setter per field, passed between threads.
Return Optional, never store it
Optional is a return type for a lookup that can legitimately find nothing. It is not a field type, not a
parameter type, and not a collection element.
Pass: map or flatMap the value, and end with orElseThrow.
return marketRepository.findBySlug(slug)
.map(MarketResponse::from)
.orElseThrow(() -> new MarketNotFoundException(slug));
Fail: .get() after .isPresent(), or an Optional<String> field on an entity.
Keep stream pipelines short and named
A stream expresses a transformation. When the pipeline needs more than a few stages, or a stage needs a comment, extract it into a method whose name says what the transformation produces.
Pass:
List<String> names = markets.stream()
.map(Market::name)
.filter(Objects::nonNull)
.toList();
Fail: a twelve-stage pipeline with a nested flatMap and a lambda spanning six lines. Extract the lambda, or the
whole pipeline, into a named private method.
Write for the next reader
One public top-level type per file. Members in order: constants, fields, constructors, public methods, protected, private. Methods short enough to hold in your head, with helpers extracted. A lambda longer than one line becomes a named private method. Never write a fully qualified class name in code, import it.
Prefer an explicit type over var. The narrow allowance is a right-hand side that already names the type, such as
var list = new ArrayList<String>(). Everywhere else, the type is the documentation.
Pass: MarketResponse response = service.findBySlug(slug);
Fail: var result = service.process(input);, where a reader has to open two methods to learn what result is.
Indent with 2 or 4 spaces, matching the project. Consistency beats preference.
Fail with a domain exception, handle it centrally
Domain errors are unchecked exceptions named after the thing that went wrong. Technical exceptions get wrapped with
context rather than swallowed. One @ControllerAdvice translates them into responses, so business methods stay
free of try-catch blocks.
Pass:
throw new MarketNotFoundException(slug);
Fail: catch (Exception ex) { log.error("error", ex); } in the middle of a service method, which turns a failure
into a wrong answer.
Declare your generics
No raw types. Bound a type parameter when a reusable utility needs a capability from it. A raw type turns a
compile-time error into a ClassCastException at runtime.
Pass:
public <T extends Identifiable> Map<Long, T> indexById(Collection<T> items) { ... }
Fail: public Map indexById(Collection items).
Say where null is allowed
Annotate public API signatures with @NonNull and @Nullable, from JSpecify or jakarta.annotation, and enforce
them with NullAway or the JSpecify processor in CI. Accept @Nullable only where the absence is genuinely part of
the contract. Validate inbound values with Bean Validation so a null never travels deeper than the edge.
Pass: an annotated signature with a CI check that fails on a violation.
Fail: annotations added for documentation with nothing enforcing them, which drift within a release.
Log through SLF4J with structured messages
Use SLF4J as the API, and never import Logback or Log4j2 in business code. Take the logger from @Slf4j. Message
keys are stable tokens with the variable parts as parameters, so a log aggregator can group them.
Pass: log.info("fetch_market slug={}", slug); inside a class annotated @Slf4j.
Fail: log.info("Fetching market " + slug), or a logger built by hand with
LoggerFactory.getLogger(MarketService.class).
Levels: ERROR for unhandled exceptions, WARN for recoverable problems, INFO for significant domain events,
DEBUG for diagnostics. Structured JSON output and the production logging stack belong to
observability-and-logging.
Javadoc
Default to none. A Javadoc block is usually a sign that the code failed to explain itself. Before writing one, extract the unclear block into a well-named method, rename the parameters so they carry their own meaning, and tighten the types. Do that first and most Javadoc blocks have nothing left to say, which is the outcome you want. Code that explains itself cannot go stale, a comment can.
When one is still genuinely needed, the prose is capped at five lines and is usually one. Every tag line is capped at
one line, @param and @return and @throws alike, and only appears when it genuinely adds something: if the note
does not fit on a single line, shorten it or drop the tag. Four rules decide what goes in.
- Prose. One sentence saying what it does, then only what a caller cannot infer from the signature. Nothing more.
@paramonly when the name and the type do not already convey it, meaning units, nullability, a valid range, or who owns the argument afterwards.@param orderId the wholesale order identifieris noise, delete it.@returnonly when it is non-obvious.@throwsalways, for every exception a caller can act on. Unchecked exceptions never appear in the signature, so this one is genuinely contract rather than decoration.
Going past the five-line prose cap is allowed only when the contract genuinely cannot be stated in fewer lines, for example a documented state machine, an ordering requirement, or a concurrency guarantee. It is an exception you justify in review, not a budget to spend. The one-line cap on a tag line has no exception at all: shorten it or delete it.
// GOOD: one sentence, then only what the signature cannot say
/**
* Reserves stock for an order and holds it until the payment window closes.
*
* @param holdFor how long the reservation survives, at most 15 minutes
* @throws InsufficientStockException when the warehouse cannot cover the order
*/
public Reservation reserve(OrderId orderId, Duration holdFor) { ... }
// BAD: restates the signature, and the first tag wraps onto a second line
/**
* Reserves stock.
*
* @param orderId the identifier of the order that stock is being reserved
* against, taken from the inbound request
* @param holdFor the hold duration
* @return the reservation
*/
public Reservation reserve(OrderId orderId, Duration holdFor) { ... }
// BEST: naming and types carry it, no Javadoc needed
public Reservation reserveStockUntilPaymentWindowCloses(OrderId orderId, Duration holdFor) { ... }
Reference material
| Open this | For |
|---|---|
| references/concurrency.md | Virtual threads, executors, CompletableFuture composition, and thread-safety documentation. |
| references/serialization-and-config.md | Jackson setup, typed @ConfigurationProperties, and entity to DTO mapping. |
| references/static-analysis.md | Checkstyle, SpotBugs, and PMD wiring, and keeping build output readable. |
| references/project-layout.md | Package structure for a layered project, and the code smells to fix on sight. |
Related skills
| Skill | What it owns |
|---|---|
springboot-patterns |
Spring Boot structure, REST contracts, security, JPA, tests, and the analysis pipeline. |
build-dependency-management |
Version catalog and BOM discipline for the plugins above. |
hexagonal-architecture |
Ports and adapters layering when the project uses it. |
observability-and-logging |
Structured log output, metrics, and tracing. |
coding-standards |
The cross-language principles this skill applies to Java. |
Checklist
- Names say what the thing is, and booleans read as questions.
- Every dependency arrives through the constructor, and no field carries
@Autowired. - Data types are records or final-field classes, with no setter that nothing needs.
-
Optionalappears only as a return type, and never after.isPresent()plus.get(). - Stream pipelines are short, or extracted into named methods.
- Explicit types instead of
var, except where the right-hand side already names the type. - Domain exceptions are unchecked, named, and handled in one
@ControllerAdvice. - No raw types anywhere.
- Nullability annotations are present on public signatures and enforced in CI.
- Logging goes through
@Slf4jwith parameterised messages, never concatenation. - Javadoc is absent by default, and every surviving block clears the caps above.