Java (Spring Boot)
Spring Boot services.
What this is
Spring Boot services. Persistence correctness dominates the BLOCKER tier — entities leaking out of controllers, N+1 queries, @Transactional silently doing nothing on self-invocation, string-concatenated JPQL. The rest is Spring's DI and concurrency model done wrong: field injection over constructor injection, mutable state in singleton beans, blocking calls inside a reactive WebFlux pipeline.
How to onboard it
A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:
$ npx redlinegate init # detects the profile $ npx redlinegate init --profile service-java # or name one
One profile pulls these rules in: service-java.
Once onboarded, your files match this stack when they fit any of these globs:
**/*.java
How to use it — 28 rules
Nothing to run. Once your profile includes Java (Spring Boot), redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.
| Rule id | Severity | Catches |
|---|---|---|
java/entity-in-controller-response | BLOCKER | JPA entities returned from controllers. Endpoints return DTOs; entities leak lazy-loading proxies, internal fields, and cause serialization surprises. |
java/n-plus-one-queries | BLOCKER | N+1 queries. Lazy association accessed in a loop or during serialization — require fetch join, `@EntityGraph`, or a projection. |
java/transactional-self-invocation | BLOCKER | `@Transactional` on private/self-invoked methods — proxy is bypassed, annotation silently does nothing. |
java/broad-catch | BLOCKER | Catching `Exception`/`Throwable` broadly and continuing — catch specific types; rethrow or fail the operation. |
java/field-injection | BLOCKER | Field injection (`@Autowired` on fields) in new code — constructor injection only; field injection breaks testability and hides dependencies. |
java/mutable-singleton-state | BLOCKER | Mutable shared state in singleton beans without synchronization — services/components are singletons; instance fields must be immutable or thread-safe. |
java/blocking-in-reactive | BLOCKER | Blocking calls inside reactive pipelines (WebFlux: `block()`, JDBC, `RestTemplate` inside `Mono`/`Flux` chains). |
java/jpql-string-concatenation | BLOCKER | String-concatenated JPQL/SQL with external input — parameterized queries / criteria API only. |
java/reference-equality | BLOCKER | `==`/`!=` on `String`, boxed numbers or other value objects. It compares identity, so it passes in tests on interned literals and cached small boxes and fails in production — `status == "ACTIVE"` is false for a deserialised value, `Long` 128 `==` 128 is false. Use `equals`/`Objects.equals`, or unbox. Not for enums or a deliberate identity check against a sentinel. |
java/bigdecimal-value-semantics | BLOCKER | `new BigDecimal(double)` or `BigDecimal.equals` for amounts. The double constructor captures binary rounding error (`new BigDecimal(0.1)` is `0.1000000000000000055…`), and `equals` compares scale, so `10.0` is not `10.00`. Use `new BigDecimal("0.1")` or `BigDecimal.valueOf(d)`, and `compareTo(…) == 0`. |
java/optional-misuse | HIGH | `Optional` misuse: `Optional.get()` without presence check, `Optional` as field or method parameter. |
java/entity-equals-hashcode | HIGH | `equals`/`hashCode` on JPA entities using generated IDs incorrectly (breaks in sets before persist) — or missing entirely on value objects. |
java/missing-bean-validation | HIGH | Missing `@Valid` + Bean Validation on request DTOs at controller boundary. |
java/transaction-scope-too-wide | HIGH | Transaction scope too wide: external HTTP calls or message publishing inside `@Transactional` — holds connections, couples commit to remote latency. |
java/async-without-executor | HIGH | `@Async`/`CompletableFuture` work without dedicated executor — default pool starvation. |
java/exception-detail-in-response | HIGH | Exception details (stack traces, SQL) returned in API error responses — map to problem-detail responses. |
java/legacy-date-api | HIGH | New Date/`Calendar`/`SimpleDateFormat` in new code — `java.time` only; `SimpleDateFormat` is not thread-safe. |
java/stream-side-effects | HIGH | Streams with side effects (`forEach` mutating external collections) — collect instead. |
java/dropped-exception-cause | HIGH | A new exception rethrown without the caught one as its cause. `throw new ServiceException(e.getMessage())` cuts the trace at the catch, so a pool timeout and a network fault look identical in the log. Pass the original: `throw new ServiceException("context", e)`. Not when the parameter is named `unused`/`ignored` and the drop is deliberate. |
java/swallowed-interrupt | HIGH | `InterruptedException` caught — directly or via `catch (Exception)` — without restoring the interrupt. The cancellation signal is erased, so `shutdownNow()` cannot stop the worker and the pod hangs until SIGKILL, dropping in-flight work. Call `Thread.currentThread().interrupt()` and stop, or rethrow. |
java/unclosed-resource | HIGH | An `AutoCloseable` (stream, `Connection`, `Statement`, `ResultSet`, HTTP response) not opened in try-with-resources. An exception between open and `close()` leaks it — a pooled connection per bad row until every endpoint times out. Use `try (var rs = …)`. Not when ownership passes to the caller or a framework. |
java/test-without-assertion | HIGH | A test that cannot fail — no assertion or verify on the production result, an expected-exception `try` with no `fail()`/`assertThrows`, a `catch (Throwable|AssertionError)` that swallows the assertion, or mocks stubbed but never handed to the code under test. Assert on the real output; use `assertThrows`. |
java/print-stack-trace | HIGH | `e.printStackTrace()` in a catch. It writes to stderr outside the logging pipeline — no level, correlation id or alert — and almost always marks a swallowed failure, so the request returns 200 with partial data. Log through the logger with context, then rethrow or fail the operation. |
java/ignored-pure-result | HIGH | The result of a method on an immutable value discarded — `s.trim();`, `s.toLowerCase();`, `bd.add(x);`, `date.plusDays(1);`, `stream.filter(…);`. The call does nothing and the unchanged value is used: `email.toLowerCase();` then `findByEmail(email)` misses `User@X.com`. Assign or return the result. |
java/lombok-data-on-entity | SUGGESTION | Lombok `@Data` on entities (equals/hashCode/toString pitfalls) — prefer `@Getter` + explicit methods, or records for DTOs. |
java/records-for-dtos | SUGGESTION | Records for immutable DTOs where Java version allows. |
java/var-usage | SUGGESTION | `var` for obviously-typed locals; explicit types where inference hurts readability. |
java/magic-values | SUGGESTION | Magic numbers/strings for business values — extract named constants. |
Expected output
A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:
Redline/BLOCKER [java/entity-in-controller-response]: <one-line problem>Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 28 rules here, 10 BLOCKER, 14 HIGH and 4 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.
If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.
How to edit it
Rules are edited in standards/stacks/java.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.
- Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
- node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
- node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
- Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
- Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
- node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.
Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.
The full file
# Java (Spring Boot) Review Rules
## BLOCKER — request changes
- `java/entity-in-controller-response` — **JPA entities returned from controllers.** Endpoints return DTOs; entities leak lazy-loading proxies, internal fields, and cause serialization surprises.
- `java/n-plus-one-queries` — **N+1 queries.** Lazy association accessed in a loop or during serialization — require fetch join, `@EntityGraph`, or a projection.
- `java/transactional-self-invocation` — **`@Transactional` on private/self-invoked methods** — proxy is bypassed, annotation silently does nothing.
- `java/broad-catch` — **Catching `Exception`/`Throwable` broadly and continuing** — catch specific types; rethrow or fail the operation.
- `java/field-injection` — **Field injection (`@Autowired` on fields)** in new code — constructor injection only; field injection breaks testability and hides dependencies.
- `java/mutable-singleton-state` — **Mutable shared state in singleton beans** without synchronization — services/components are singletons; instance fields must be immutable or thread-safe.
- `java/blocking-in-reactive` — **Blocking calls inside reactive pipelines** (WebFlux: `block()`, JDBC, `RestTemplate` inside `Mono`/`Flux` chains).
- `java/jpql-string-concatenation` — **String-concatenated JPQL/SQL with external input** — parameterized queries / criteria API only.
- `java/reference-equality` — **`==`/`!=` on `String`, boxed numbers or other value objects.** It compares
identity, so it passes in tests on interned literals and cached small boxes and fails in production — `status
== "ACTIVE"` is false for a deserialised value, `Long` 128 `==` 128 is false. Use `equals`/`Objects.equals`,
or unbox. Not for enums or a deliberate identity check against a sentinel.
- `java/bigdecimal-value-semantics` — **`new BigDecimal(double)` or `BigDecimal.equals` for amounts.** The
double constructor captures binary rounding error (`new BigDecimal(0.1)` is `0.1000000000000000055…`), and
`equals` compares scale, so `10.0` is not `10.00`. Use `new BigDecimal("0.1")` or `BigDecimal.valueOf(d)`, and
`compareTo(…) == 0`.
## HIGH
- `java/optional-misuse` — `Optional` misuse: `Optional.get()` without presence check, `Optional` as field or method parameter.
- `java/entity-equals-hashcode` — `equals`/`hashCode` on JPA entities using generated IDs incorrectly (breaks in sets before persist) — or missing entirely on value objects.
- `java/missing-bean-validation` — Missing `@Valid` + Bean Validation on request DTOs at controller boundary.
- `java/transaction-scope-too-wide` — Transaction scope too wide: external HTTP calls or message publishing inside `@Transactional` — holds connections, couples commit to remote latency.
- `java/async-without-executor` — `@Async`/`CompletableFuture` work without dedicated executor — default pool starvation.
- `java/exception-detail-in-response` — Exception details (stack traces, SQL) returned in API error responses — map to problem-detail responses.
- `java/legacy-date-api` — New Date/`Calendar`/`SimpleDateFormat` in new code — `java.time` only; `SimpleDateFormat` is not thread-safe.
- `java/stream-side-effects` — Streams with side effects (`forEach` mutating external collections) — collect instead.
- `java/dropped-exception-cause` — **A new exception rethrown without the caught one as its cause.** `throw new
ServiceException(e.getMessage())` cuts the trace at the catch, so a pool timeout and a network fault look
identical in the log. Pass the original: `throw new ServiceException("context", e)`. Not when the parameter is
named `unused`/`ignored` and the drop is deliberate.
- `java/swallowed-interrupt` — **`InterruptedException` caught — directly or via `catch (Exception)` — without
restoring the interrupt.** The cancellation signal is erased, so `shutdownNow()` cannot stop the worker and
the pod hangs until SIGKILL, dropping in-flight work. Call `Thread.currentThread().interrupt()` and stop, or
rethrow.
- `java/unclosed-resource` — **An `AutoCloseable` (stream, `Connection`, `Statement`, `ResultSet`, HTTP
response) not opened in try-with-resources.** An exception between open and `close()` leaks it — a pooled
connection per bad row until every endpoint times out. Use `try (var rs = …)`. Not when ownership passes to
the caller or a framework.
- `java/test-without-assertion` — **A test that cannot fail** — no assertion or verify on the production result,
an expected-exception `try` with no `fail()`/`assertThrows`, a `catch (Throwable|AssertionError)` that
swallows the assertion, or mocks stubbed but never handed to the code under test. Assert on the real output;
use `assertThrows`.
- `java/print-stack-trace` — **`e.printStackTrace()` in a catch.** It writes to stderr outside the logging
pipeline — no level, correlation id or alert — and almost always marks a swallowed failure, so the request
returns 200 with partial data. Log through the logger with context, then rethrow or fail the operation.
- `java/ignored-pure-result` — **The result of a method on an immutable value discarded** — `s.trim();`,
`s.toLowerCase();`, `bd.add(x);`, `date.plusDays(1);`, `stream.filter(…);`. The call does nothing and the
unchanged value is used: `email.toLowerCase();` then `findByEmail(email)` misses `User@X.com`. Assign or
return the result.
## SUGGESTION
- `java/lombok-data-on-entity` — Lombok `@Data` on entities (equals/hashCode/toString pitfalls) — prefer `@Getter` + explicit methods, or records for DTOs.
- `java/records-for-dtos` — Records for immutable DTOs where Java version allows.
- `java/var-usage` — `var` for obviously-typed locals; explicit types where inference hurts readability.
- `java/magic-values` — Magic numbers/strings for business values — extract named constants.