Java / Spring Boot Standard

*Status:* v1.1 authoritative · Owner: Backend team (CTO sign-off) · Last reviewed: 2026-07-03 · Review cadence: quarterly

The standard for FT backend teams (Java 21+/Spring Boot).

It is self-contained — a backend engineer needs only this page (plus the shared FE↔BE contract).

Frontend engineers have their own Angular standard.

This page is the visual form of the authoritative ft-java-standards skill; on conflict with any other Java standard, this wins.

Two standards, two teams, one platform. They diverge only where the stacks genuinely differ (e.g. the interface naming prefix) and they share exactly one thing: the wire contract.

J1 · Packages = the catalog of the code ⭐

The single idea most people miss at first: a package is a folder, and the package tree is how you catalog and find code. You locate a class by walking feature → role, never by scrolling one giant package. So where a new file goes is a design decision, not an afterthought.

The rule: a new type goes into the sub-package that matches its role (handler, service, repository, mapper, entity, config…). If that sub-package doesn’t exist, create it. Never drop a new class into the module/app root package — the root is not a junk drawer.

A feature package, catalogued by role
Figure 1. A feature package, catalogued by role
✅ Do — feature first, role second ❌ Don’t — dumped in the root
com.friendly.device.write
  .handler.WriteTaskHandler
  .service.DeviceWriteService
  .repository.DeviceRepository
  .mapper.RegistrationMapper
com.friendly.device
  .WriteTaskHandler     <-- role lost
  .DeviceWriteService   <-- root = junk drawer
  .DeviceRepository
New role in a feature? Add the sub-package. New feature? Add the feature package, then its role sub-packages. The tree should read like a table of contents.

J2 · Naming — role suffix, no Hungarian

Rule Java

Role suffix

*Service, *Handler, *Repository, *Mapper, *Entity

Enums

end with …​Enum

Interfaces

no I-prefix (idiomatic Java)

Variables/fields

camelCase; booleans read as predicates (enabled, hasPending)

Methods

verb phrases (findBySerial, applyWrite); is/has/can for predicates; get* only for accessors (records expose prefix-less accessors — written(), not getWritten())

Constants

UPPER_SNAKE_CASE

Forbidden

FT-prefix on classes · ❌ Hungarian notation

"Hungarian notation" = baking a variable’s type or scope into its name via a prefix, instead of letting the type system carry that. Name by meaning, not type:

  • strName, iCount, bEnabled, lpszText, m_serial, s_instance

  • name, count, enabled, text, serial, instance

The I-prefix divergence is intentional — Java stays idiomatic (no prefix), TS keeps it. The Angular standard explains its side.
The role suffixes (…​Enum, …​Properties, …​Service) are a deliberate FT convention for greppability and uniform cataloguing — not a claim that suffixing a type’s name is idiomatic Java. Where idiom and convention diverge (e.g. enums), the convention wins on these pages.

J3 · Immutability by default

✅ record DTO ❌ mutable DTO
public record WriteResult(int written, Instant at) {}
@Data class WriteResult { ... }   // @Data anywhere = ❌

DTOs are record`s — or, for a large DTO that needs a builder, an immutable Lombok `@Value
@Builder (records have no builder). On JPA entities use only @Getter/@Setter (they need hydration) — never @Data / @EqualsAndHashCode / @ToString on an entity: generated equals/hashCode/toString over all fields break on lazy associations, generated ids, and bidirectional cycles. @Data is never used on a DTO (it’s mutable).

J4 · Constructor injection only

✅ Do ❌ Don’t
@Service
@RequiredArgsConstructor
class DeviceWriteService {
  private final Sender sender;
}
@Service
class DeviceWriteService {
  @Autowired
  private Sender sender; // field injection
}

J5 · Structured, greppable logs — one file, grep by module & device ⭐ (no MDC)

Everything can go to one log file — as long as you can grep it by module and by device / session id. We do that with explicit key=value fields on each line. We do not use MDC.

Why not MDC? It is thread-bound and FT flows are not.

MDC stores context in a thread-local. FT requests hop across thread pools, Hazelcast queues, MQTT listeners, and CoAP/CWMP sessions — the thread that finishes the work is rarely the one that started it, so MDC context silently vanishes mid-flow. Explicit ids carried on the session/command object survive every hop.

log-flow
✅ Do — explicit key=value ❌ Don’t
log.info("write applied deviceId={} sessionId={} param={}",
         deviceId, sessionId, path);
MDC.put("cpeSerial", serial);                 // lost across queues/sessions
log.info("write applied for cpe " + serial);  // not greppable
Field Meaning

deviceId

the CPE serial — primary grep key; on every device-scoped line

sessionId

when a session / transaction exists

module

the logger name (@Slf4j, one per class); pattern has %logger{36}

Use SLF4J parameterized logging (or 2.x fluent addKeyValue for JSON sinks). A small helper can render the deviceId=… sessionId=… prefix consistently.
Table 1. Levels & frequency
Level When

ERROR

an operation failed and needs attention (context + cause). Log once, at the owning boundary — not log-and-rethrow at every layer. Not for handled/expected outcomes.

WARN

recoverable / degraded — retry, fallback, deprecated path, slow response.

INFO

significant, low-cardinality business events (device registered, task completed). ❌ never per-message / per-loop.

DEBUG

developer detail (payloads, decisions); off in prod by default.

TRACE

very fine, per-iteration.

On a firehose (MQTT/CoAP/telemetry) never log at INFO per message — use DEBUG/TRACE, aggregate (processed N in T ms), or throttle/sample. Guard with log.isDebugEnabled() only when building the argument is itself expensive (parameterized logging already defers toString).

J6 · Outcomes & building blocks

Outcomes, not exceptions. Handlers return sealed OperationResult / AsyncResult and pattern-match:

return switch (asyncResult) {
  case AsyncResult.Success<T>(var v) -> new OperationResult.Success<>(map(v));
  case AsyncResult.Error<T>(var e)   -> toDeviceError(e);
  case AsyncResult.Timeout<T> ignored -> toTimeout();
};

@Slf4j for logging · @UtilityClass for static-only utils · Optional<T> over null · java.time.* for time.

Naming suffix vocabulary

Handler · *Service · *Store (non-JPA → Optional) · *Repository (JPA) · *Dao/*DaoJdbcImpl · *Mapper · *Resolver · *Validator · *Request/*Response (records) · *Result · *Command · *Entity (JPA) · *Properties · *Listener · *Dispatcher · Abstract · *Impl (only with an explicit interface split).

J7 · Tests & TDD

Convention Rule

Method name

should_<expected>_when_<condition> + @DisplayName + @Nested

*Test

unit — mock collaborators

*IT

integrationreal infra (Testcontainers / in-memory / protocol emulator). ❌ never mock the DB

Coverage

new/changed code ≥ 80% line+branch (diff coverage). No global gate — legacy isn’t blocked.

Test quality

❌ never mock the subject under test (mock boundaries only: repository, remote client, time) · deterministic — inject Clock, no Thread.sleep, no real network in *Test · one behaviour per test

Assertions

new test classes use AssertJ (assertThat); inside an existing class match its style

Cover behaviour and edge cases, not lines — an executed line with no assertion doesn’t count. Don’t write tests purely to move the number.
Task graduation — pick the workflow
  • Architecture-led (new module/feature, refactor, cross-cutting) → an approved SDD (Spec-Driven Development) before any implementation. The orchestrator writes it from the SDD template and the CTO signs off; only then does coding start.

  • TDD-led (bug fix, business rule, protocol message with a known spec) → write the failing test first (no full SDD).

@Test @DisplayName("writes a single resource value")
void should_write_value_when_path_is_writable() { ... }

J8 · Protocol spec = source of truth

For any protocol parameter/message work, validate paths & semantics against the authoritative spec before writing code or tests — BBF TR-069 / TR-181 / TR-369, OMA LwM2M. Derive fixtures from the spec’s worked examples.

Use the bundled data-model XML and the bbf_params / bbf_protocol_specs knowledge base, plus the cwmp-xml and oma-lwm2m-expert skills.

J9 · Caching

✅ Do ❌ Don’t
IMap<String, DeviceView> cache =
    hazelcast.getMap("deviceBySerial");
cache.put(serial, view);
@Cacheable("x")                  // Hazelcast is NOT a Spring
DeviceView load(String s) {...}  // CacheManager here

private static final Map<String,X> CACHE = ...;
Configure every IMap with TTL + max-size + eviction. Cache reads, evict on the write path. Don’t cache the per-CPE telemetry firehose (ClickHouse). A cache is never a source of truth.

J10 · Constants & enums

✅ Do ❌ Don’t
private static final int MAX_BATCH = 1000;
enum TaskStateEnum { PENDING, DONE, FAILED }
if (state == 2) ...           // magic number
if ("DONE".equals(s)) ...     // magic string
int timeoutMs = 5000;         // belongs in properties

Shared keys — error codes, header names, protocol paths — are named constants in one place.

J11 · External libraries

  • Prefer the JDK and Spring first, then a library already in the repo. ❌ No second library for a job an existing one does (no two JSON / HTTP / date libs).

  • A new dependency needs approval; pin versions (no +/latest/ranges); manage centrally (BOM / version catalog).

  • Vulnerability scan is part of "done" when deps change — upgrade when safe, flag HIGH/CRITICAL you can’t bump; ❌ never --force / silence the scanner. Respect licenses (no GPL/AGPL shipped without sign-off).

J-DB · Database contract

Customers pick MySQL OR Oracle. No foreign keys. Deliberate indexes. ClickHouse only for QoE. Batch the hot tables.

Naming (tables & columns)

snake_case, singular table names (cpe, cpe_async_task); no reserved words. FK columns end id (a plain app-enforced id); booleans is/has_; timestamps at/_date; indexes <table><cols>_idx. Keep identifiers portable and short — Oracle allows 128 chars since 12.2, but legacy schemas cap at 30.

Audit columns (every business table)

created / creator and updated / updatorcreated/updated are timestamps (insert / every update), creator/updator record the actor. Populate via Spring Data JPA auditing (@CreatedDate/@CreatedBy/@LastModifiedDate/@LastModifiedBy + AuditingEntityListener) or in the JDBC upsert. (Append-only ClickHouse time-series is exempt — created is the partition/sort key.)

Create-or-update = atomic upsert (not read-then-write)

✅ Do — one atomic statement ❌ Don’t — SELECT then branch
-- MySQL
INSERT INTO cpe_parameter (...) VALUES (...)
ON DUPLICATE KEY UPDATE value = ?, status = ?;
-- Oracle (paired, chosen by dbms)
MERGE INTO cpe_parameter a USING (...) incoming
  ON (a.id = incoming.id)
WHEN MATCHED THEN UPDATE SET a.value = ...
WHEN NOT MATCHED THEN INSERT (...) VALUES (...);
var row = repo.findByKey(k);       // TOCTOU race under
if (row == null) repo.insert(...); // concurrency +
else             repo.update(...); // extra round-trip
Keep the …MySQL / …Oracle SQL pair (see CpeParameterAutomationDaoJdbcImpl / CpeEventAutomationDaoJdbcImpl). Needs a unique key/constraint to match on.

Portable MySQL/Oracle (Liquibase)

<property name="number" value="int(11) unsigned" dbms="mysql"/>
<property name="number" value="number(11)"       dbms="oracle"/>
<property name="now"    value="now()"   dbms="mysql"/>
<property name="now"    value="sysdate" dbms="oracle"/>

<changeSet id="seq" author="x" dbms="oracle">
  <createSequence sequenceName="CPE_PARAMETER_0"/>
</changeSet>
Concept MySQL Oracle

boolean

tinyint(1)

number(1)

text

longtext

clob

auto id

AUTO_INCREMENT

sequence TABLE_0

paging

LIMIT

OFFSET/FETCH (12c+); ROWNUM legacy

No foreign keys · deliberate indexes

✅ Do ❌ Don’t
-- relation = plain id, app-enforced
cpe_id  INT
-- named, justified, guarded index
-- idx: lookup by serial+protocol
CREATE INDEX cpe_serial_protocol_idx ...
ALTER TABLE ... ADD FOREIGN KEY (cpe_id) ...
CREATE INDEX idx1 ON cpe(col);  -- unnamed/unjustified
No DB-level FK means referential integrity is app-enforced — guard inserts, clean up orphans in code/jobs, and never let a missing parent corrupt a flow. The trade is portability, not "don’t care".

ClickHouse = QoE / periodic only

CREATE TABLE cpe_data (serial String, name_id UInt32, value String, created DateTime)
ENGINE = MergeTree
PARTITION BY toDate(created)
ORDER BY (serial, name_id, created)
SETTINGS index_granularity = 8192;
-- current-state: ReplacingMergeTree(version) + is_deleted

Batch the overloaded tables

jdbc.batchUpdate(sql, rows, BATCH_SIZE, (ps, row) -> { ... });
// relational ~1000 (+ order_inserts, rewriteBatchedStatements)
// ClickHouse ~100 · drain queues with drainTo + bulk, re-queue on failure
Default persistence = Spring Data JPA (relational); raw JdbcTemplate for ClickHouse & hot paths. Don’t grow new hand-rolled *DaoJdbcImpl.

Transactions · N+1 · pagination

  • @Transactional on the service layer — not controllers, not repositories; readOnly = true on query paths. Self-invocation is not proxied (a @Transactional method called from the same class runs without a transaction) — split the bean. REQUIRES_NEW only deliberately.

  • No repository/remote call inside a loop (N+1) — batch it (IN query, fetch join / @EntityGraph, projection, batch fetch size). Verify hot queries by count (Hibernate statistics or a query counter in an *IT), not by eyeballing.

  • Every new listing endpoint/query over a growing table is boundedPageable or an explicit limit. An unbounded findAll() on a growing table is a defect, not a style choice.

J12 · HTTP APIs — two styles, documented, validated

FT has two API surfaces — match the one you’re in, don’t mix. Full contracts: Internal API (UI↔BE) · External API (REST).

Internal FE↔BE Northbound / public / integration

Style

POST-RPC /{controller}/{action}

REST — @RestController + @RequestMapping("api/<Resource>"), verb by semantics

Verbs

POST read/query · PUT create+update · DELETE delete ({ids} in body) — see contract

GET read · POST create/action · PUT update · DELETE (may carry a body)

DTOs

records *Request / *Response

*RestRequest / *RestResponse (StatusResponse for acks)

Errors

HTTP 200 + FT code in body

external → real HTTP status + FT code in body · internal s2s200 + code

Error catalog

internal 90xx (e.g. 9024)

separate StatusResponse.errorCode (100/20X) — never mix the two

Versioning

n/a

none — additive-only (api/<Resource>, no /v1); breaking = new resource/endpoint

Reference

oneiot-ui / iotw (Internal contract)

ft-provision-api · ft-northbound-api (External contract)

  • Document every public endpoint with springdoc OpenAPI — @Tag (controller), @Operation (method), @Schema (fields). A public API without OpenAPI is incomplete.

  • Validate at the boundary — @Valid/@Validated + jakarta.validation (@NotNull/@NotBlank/@Size) on the request DTO.

  • One global @RestControllerAdvice per service maps exceptions → the error shape above. ❌ No per-controller try/catch; ❌ never leak stack traces.

J13 · Observability

  • Metrics via Micrometer + Actuator, scraped by Prometheus; custom metrics through MeterRegistry / @Timed.

  • Low-cardinality tags only — ❌ never a per-serial tag (cardinality blow-up); the serial belongs in logs (J5).

  • Metrics = rates / latency / counts; logs = events with deviceId/sessionId.

J14 · API security (external surfaces)

  • External APIs: Spring Security SecurityFilterChain + JWT (jjwt); @PreAuthorize for method-level rules.

  • Stateless: CSRF disabled, no server session; auth/authorization failures → 401 / 403 via the global handler.

  • ❌ Never log tokens/secrets; signing keys live in config/secrets, not code.

J15 · Method & class discipline — code that stays readable ⭐

Review norms, not CI gates: exceeding one needs a stated reason in the PR, not silence.

Threshold Rule

Method ≤ 40 lines

linear no-branch method may reach ~60 with a reason

Class ≤ 250 lines / ≤ 20 public methods

split by responsibility

4 parameters

more → a parameter object (record)

Nesting ≤ 3

flatten with guard clauses / early return — happy path unindented

  • One level of abstraction per method — don’t mix SQL/HTTP mechanics with business flow; extract the mechanics.

  • Rule of Three — DRY is about knowledge, not lines: 2 duplicates → leave + mark // DUPE:; the 3rd → extract. Look-alike blocks that change for different reasons are not duplicates. A wrong abstraction costs more than duplication.

  • YAGNI applies to capabilities, never to quality — no "future-proof" params or config knobs without a consumer; but never an excuse to skip tests/error handling/cleanup.

  • No speculative interface+impl — a single-implementation @Service gets no interface (Mockito mocks classes fine); add it when the second implementation arrives. *Impl only with a real split (J6 vocabulary).

  • Composition over inheritance; closed hierarchies = sealed + records (J6).

  • Comments say WHY (constraint, protocol quirk, trade-off) — never narrate what the next line does. ❌ No tutorial comments, no commented-out code in commits.

Simplicity first (KISS)

Solve it the simplest way that passes the tests and reveals intent:

  • Lambdas and method references over anonymous classesitems.forEach(this::process), Comparator.comparing(Device::serial); ❌ no new Comparator<>() { … } boilerplate. A lambda ≤ ~3 lines; longer → extract a named private method and reference it.

  • The smallest construct that works: a method beats a class; a class beats a hierarchy; a hierarchy beats a framework. ❌ No patterns for their own sake — no factory/strategy/builder/ wrapper where a constructor or direct call does the job; a pattern earns its complexity with a real, present need (Rule of Three, not a hunch).

  • No cleverness: nested ternaries, one-liners that need a comment to decode, reflection or generics gymnastics where plain code works. If the reviewer has to pause — too clever.

  • No premature optimization: write the clear version first; complicate only with a measurement in hand (J18 / perf).

J16 · Exceptions & error flow

  • Handlers return outcomes, not exceptions — sealed OperationResult/AsyncResult (J6); exceptions are for boundary precondition violations and truly exceptional states.

  • Never swallow: no empty catch, no blanket catch (Exception e) over a method body — catch the narrowest type you can actually handle; the rest propagates.

  • Rethrow with cause — never drop the original exception; never log and rethrow (one owner per failure — J5).

  • Fail fast at the boundary (@Valid, Objects.requireNonNull(x, "msg")) so deep code assumes sane state.

  • ❌ No exceptions for control flow — expected outcomes (not-found on a lookup) belong in the result type / Optional.

J17 · Modern Java idioms

  • Streams for collection transformations (map/filter/collect) — don’t default to imperative loops out of habit; imperative for is right for side-effect loops and complex early termination. ❌ No side effects in map/filter.

  • Optionalreturn type only: ❌ never a field, parameter, or collection element; ❌ no .get() — use orElse/orElseThrow/map/ifPresent.

  • var when the type is obvious from the right-hand side; never in signatures. Text blocks for multi-line SQL/JSON/XML.

  • Time = java.time.* only (❌ java.util.Date/Calendar); logic that reads "now" takes an injected Clock (J7 testability).

  • Closed sets = sealed interface + pattern-matching switch — exhaustive, no default that hides a missed case (J6).

J18 · Concurrency & async

  • Every remote call has explicit timeouts (connect + read) — no library-default infinities; a hot-path sync call also needs a deliberate failure strategy (retry with backoff / circuit breaker / fallback).

  • ❌ Never block indefinitely on a request thread — no .get()/.join() without a timeout; compose CompletableFuture stages (thenApply/thenCompose/exceptionally).

  • Virtual threads (Java 21+ repos): ❌ no synchronized around I/O (pins the carrier) — ReentrantLock; audit ThreadLocal (context travels on objects — J5).

  • Shared state: immutability first (J3); else java.util.concurrent collections/atomics — ❌ no hand-rolled synchronized patterns.

  • @Scheduled / bulk jobs: protect against overlap (fixedDelay, or a DB lock / FOR UPDATE SKIP LOCKED with multiple instances); make jobs idempotent — they will rerun.

J19 · Security baseline (code-level)

Violations here are blocking review findings, not style notes. API-surface rules — J14.
  • SQL: parameterized only — JPA named params / JdbcTemplate ?. ❌ Never concatenate a value into SQL — including ORDER BY/column names from input (whitelist those).

  • XML/SOAP parsing — XXE off, everywhere (TR-069/CWMP is XML — this is our attack surface): disallow-doctype-decl, external entities off, no external DTD — on every factory (DocumentBuilderFactory, SAX, StAX, TransformerFactory, JAXB source).

  • No Java deserialization of untrusted data (ObjectInputStream.readObject) — JSON/Protobuf instead; Jackson polymorphic typing only with an explicit allowlist validator.

  • Secrets never in code, config defaults, logs, URLs, or exception messages; rotatable without a code change.

  • Security-meaningful randomness (tokens, nonces) = SecureRandom — ❌ never java.util.Random/Math.random().

  • Files from user input: resolve against a base dir + normalize() + verify startsWith(base).

  • Bind requests to DTO records (J3), never to an @Entity — mass assignment.

  • OS commands: ProcessBuilder with an argument list — ❌ never a shell string with input concatenated in.

✅ Review checklist

  • New type in a role sub-package, not the root

  • DTO is a record (or immutable @Value+@Builder if large); @Data never on a DTO, @Getter/@Setter only on JPA entities

  • Constructor injection; no field @Autowired

  • Logs carry deviceId / sessionId as key=value; no MDC; module = logger name; right level, no INFO on hot paths

  • Tests should_X_when_Y + @DisplayName; IT on *real infra; new code ≥ 80% diff coverage

  • No magic numbers/strings; fixed sets are enum`s; tunables in `@ConfigurationProperties

  • Caching on Hazelcast (IMap, TTL+eviction) — no @Cacheable/Spring CacheManager, no hand-rolled Map

  • New dependency justified + pinned + vuln-scanned; no duplicate-purpose library

  • HTTP API: right surface (FE↔BE POST-RPC vs northbound REST); OpenAPI; request @Valid; one global @RestControllerAdvice (errors by layer)

  • Metrics via Micrometer/Actuator (low-cardinality tags); external APIs secured (Spring Security + JWT, @PreAuthorize)

  • Protocol paths validated against the spec first

  • DB: portable, no FK, snake_case singular names, audit cols (created/creator/updated/updator), atomic upsert (no select-then-write), named index, batch hot tables

  • DB use: @Transactional at the service layer (readOnly reads); no call-in-loop N+1; new listings paginated/bounded

  • No I-prefix, no FT-prefix, no Hungarian; camelCase vars, verb methods; enums end …​Enum

  • Size discipline: method ≤ 40 lines, ≤ 4 params (else a record), nesting ≤ 3; no speculative interface+impl; comments say why; no commented-out code

  • Simplicity: lambdas/method references; the smallest construct that works; no patterns without a present need; no clever one-liners; no premature optimization

  • Exceptions: none swallowed; narrowest catch; rethrow with cause; results-not-exceptions in handlers

  • Idioms: streams for transforms; Optional return-only, no .get(); java.time + injected Clock

  • Concurrency: remote calls have timeouts; no blocking .get()/.join() on request paths; no synchronized around I/O; @Scheduled overlap-safe + idempotent

  • Security baseline: parameterized SQL only; XXE off on every XML/SOAP parser; no untrusted Java deserialization; SecureRandom; DTO binding (never @Entity)

Next → the Angular standard · the shared FE↔BE contract.