DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Java: 13 Rules That Make AI Write Modern, Production-Ready JVM Code

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);
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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) {}
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)