Java developers have a particular problem with AI assistants: the models have seen fifteen years of StackOverflow answers, legacy tutorials, and pre-Java-11 patterns. Without guidance, Claude will write new ArrayList<>() where you want List.of(), null checks where you want Optional, and raw Exception catches where you want specific error types.
A well-written CLAUDE.md fixes this. Here are 13 rules that push AI-generated Java from Java 8-era code to modern, idiomatic production Java.
1. Use modern collection factory methods
// Bad — verbose, mutable
List<String> tags = new ArrayList<>();
tags.add("java");
tags.add("api");
Map<String, Integer> codes = new HashMap<>();
codes.put("OK", 200);
// Good — Java 9+, immutable by default
List<String> tags = List.of("java", "api");
Map<String, Integer> codes = Map.of("OK", 200, "NOT_FOUND", 404);
Rule for CLAUDE.md: "Use List.of(), Set.of(), Map.of() for immutable collections. Use new ArrayList<>(List.of(...)) only when mutation is required."
2. Optional instead of null returns
// Bad
public User findUser(String id) {
return userMap.get(id); // returns null if missing
}
// Good
public Optional<User> findUser(String id) {
return Optional.ofNullable(userMap.get(id));
}
// Usage
findUser(id)
.map(User::getEmail)
.orElseThrow(() -> new UserNotFoundException(id));
Rule: "Never return null from public methods. Return Optional<T> for values that may be absent. Never use Optional.get() without checking — use orElse, orElseThrow, or ifPresent."
3. Records for data carriers (Java 16+)
// Bad — verbose POJO
public class OrderSummary {
private final UUID id;
private final int totalCents;
// constructor, getters, equals, hashCode, toString...
}
// Good
public record OrderSummary(UUID id, int totalCents) {}
Rule: "Use record for immutable data carriers. No manual getters/setters/equals/hashCode for records. Extend with compact constructors for validation."
4. Sealed classes for closed type hierarchies (Java 17+)
// Good — exhaustive, compiler-enforced
public sealed interface PaymentResult
permits PaymentResult.Success, PaymentResult.Failure, PaymentResult.Pending {}
public record Success(UUID transactionId) implements PaymentResult {}
public record Failure(String reason, int errorCode) implements PaymentResult {}
public record Pending(String reference) implements PaymentResult {}
// Pattern matching switch (Java 21)
String message = switch (result) {
case Success s -> "Paid: " + s.transactionId();
case Failure f -> "Failed: " + f.reason();
case Pending p -> "Pending: " + p.reference();
};
Rule: "Use sealed interface + record implementations for closed domain hierarchies. Use pattern matching switch for exhaustive dispatch — no default needed."
5. Stream API over imperative loops for transformations
// Bad
List<String> result = new ArrayList<>();
for (User user : users) {
if (user.isActive()) {
result.add(user.getEmail().toLowerCase());
}
}
// Good
List<String> result = users.stream()
.filter(User::isActive)
.map(user -> user.getEmail().toLowerCase())
.toList(); // Java 16+ — immutable
Rule: "Use streams for filter/map/reduce/collect operations. Use .toList() (Java 16+) for immutable result lists. Reserve imperative loops for side effects."
6. Specific exception types, never raw Exception
// Bad
try {
processOrder(order);
} catch (Exception e) {
log.error("Error", e);
throw new RuntimeException("Failed");
}
// Good
try {
processOrder(order);
} catch (InventoryException e) {
log.warn("Inventory check failed for order {}", order.id(), e);
throw new OrderProcessingException("Inventory unavailable", e);
} catch (PaymentException e) {
log.error("Payment failed for order {}", order.id(), e);
throw new OrderProcessingException("Payment declined", e);
}
Rule: "Catch specific exception types only. Always pass the original exception as the cause. Never swallow exceptions with empty catch blocks."
7. var for local type inference where it improves readability
// Good — type is obvious from the right side
var users = userRepository.findAllActive();
var orderMap = new HashMap<UUID, Order>();
// Bad — type is not obvious
var result = process(data); // What type is result?
Rule: "Use var when the type is obvious from the right-hand side. Never use var for method return types or when the inferred type would be unclear to a reader."
8. Constructor injection, not field injection
// Bad — field injection (not testable without Spring context)
@Service
public class OrderService {
@Autowired
private OrderRepository repo;
@Autowired
private PaymentGateway payment;
}
// Good — constructor injection
@Service
public class OrderService {
private final OrderRepository repo;
private final PaymentGateway payment;
public OrderService(OrderRepository repo, PaymentGateway payment) {
this.repo = repo;
this.payment = payment;
}
}
Rule: "Constructor injection always. No @Autowired on fields. Mark injected fields final. This enables plain-Java unit tests without Spring context."
9. SLF4J with parameterized messages — never string concatenation
// Bad — allocates string even if DEBUG is off
log.debug("Processing order " + order.getId() + " for user " + userId);
// Also bad
System.out.println("Order processed: " + orderId);
// Good
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
log.debug("Processing order {} for user {}", order.getId(), userId);
log.error("Payment failed for order {}", orderId, exception);
Rule: "SLF4J only — no System.out.println. Parameterized log messages ({}) always. Pass exceptions as the last argument to preserve stack traces."
10. Instant and Duration for time — never Date or long milliseconds
// Bad
long createdAt = System.currentTimeMillis();
Date expiresAt = new Date(createdAt + 86400000L);
// Good
Instant createdAt = Instant.now();
Instant expiresAt = createdAt.plus(Duration.ofDays(1));
ZonedDateTime displayTime = createdAt.atZone(ZoneId.of("America/Santiago"));
Rule: "Java Time API (java.time.*) exclusively. Instant for storage/comparison, ZonedDateTime for display, Duration/Period for intervals. Never java.util.Date or raw millisecond longs."
11. Immutability by default
// Bad — mutable everywhere
public class Config {
public String apiKey;
public int timeout;
}
// Good
public final class Config {
private final String apiKey;
private final int timeoutSeconds;
public Config(String apiKey, int timeoutSeconds) {
this.apiKey = Objects.requireNonNull(apiKey, "apiKey required");
this.timeoutSeconds = timeoutSeconds;
}
public String apiKey() { return apiKey; }
public int timeoutSeconds() { return timeoutSeconds; }
}
Rule: "Classes immutable by default: final class, final fields, no setters. Use Builder pattern for objects with many optional fields. Validate in the constructor with Objects.requireNonNull."
12. JUnit 5 with @DisplayName and Arrange-Act-Assert
// Bad
@Test
public void test1() {
assertTrue(service.process(input) != null);
}
// Good
@Test
@DisplayName("processOrder returns SUCCESS when inventory and payment both succeed")
void processOrder_success() {
// Arrange
var order = OrderFixture.validOrder();
when(inventory.check(order)).thenReturn(InStock.of(order));
when(payment.charge(order)).thenReturn(ChargeResult.success("txn-123"));
// Act
var result = orderService.processOrder(order);
// Assert
assertThat(result).isInstanceOf(PaymentResult.Success.class);
assertThat(((PaymentResult.Success) result).transactionId()).isEqualTo("txn-123");
}
Rule: "JUnit 5 with AssertJ assertions. @DisplayName on all tests describing the scenario. Arrange-Act-Assert structure, clearly separated. No assertTrue(x != null) — use assertThat(x).isNotNull()."
13. Checkstyle + SpotBugs + --enable-preview awareness
Rule: "Generated code must pass Checkstyle (Google style) and SpotBugs with no HIGH/MEDIUM bugs. When using Java 21+ preview features (--enable-preview), note the flag explicitly. Never generate deprecated API usage (Date, Vector, StringBuffer) without acknowledging it."
<!-- pom.xml -->
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<configuration>
<effort>Max</effort>
<threshold>Medium</threshold>
</configuration>
</plugin>
Your CLAUDE.md block
## Java Standards
- Target: Java 21 LTS. Use preview features only if `--enable-preview` is documented.
- Collections: `List.of()`, `Set.of()`, `Map.of()` for immutable; mutable only when needed
- No null returns from public methods — use `Optional<T>`
- `record` for immutable data carriers; `sealed interface` for closed hierarchies
- Streams for filter/map/collect; `.toList()` for immutable result (Java 16+)
- Catch specific exceptions; always chain cause; never swallow
- `var` for obvious local types only
- Constructor injection; `final` fields; no `@Autowired` on fields
- SLF4J parameterized logging; no System.out; exceptions as last log arg
- `java.time.*` only — no `java.util.Date` or raw millisecond longs
- Immutable by default: `final` class + fields, `Objects.requireNonNull` in constructor
- JUnit 5 + AssertJ; `@DisplayName`; AAA structure; no `assertTrue(x != null)`
- Code must pass Checkstyle (Google) and SpotBugs Medium threshold
These 13 rules give AI the context it needs to write Java that actually belongs in a 2026 codebase — not a 2012 tutorial. Combined with the rules for other languages, you get consistent AI behavior across your entire stack.
The CLAUDE.md Rules Pack includes 50+ production rules for Java, Python, TypeScript, Go, Rust, Swift, and more — $27 one-time.
Top comments (0)