DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Java: 6 Rules That Make AI Write Production-Grade Java

Cursor Rules for Java: 6 Rules That Make AI Write Production-Grade Java

If you use Cursor or Claude Code for Java development, you've watched the AI generate code that compiles but makes senior engineers wince. Checked exceptions swallowed silently. Services with 12 constructor parameters and no interface. Raw HashMap where a record or value object belongs. @Autowired on fields instead of constructor injection.

The fix isn't better prompting. It's better rules.

Here are 6 cursor rules for Java that make your AI assistant write code that passes code review the first time. Each one includes a before/after example so you can see exactly what changes.


1. Enforce Constructor Injection — Ban Field Injection

Without this rule, AI sprinkles @Autowired on fields everywhere. Your dependencies become invisible, your classes are untestable without a full Spring context, and you can't tell what a class needs by looking at its constructor.

The rule:

Always use constructor injection for Spring beans. Never use @Autowired on fields.
Mark the constructor with @RequiredArgsConstructor (Lombok) or write it explicitly.
All injected fields must be private final.
Enter fullscreen mode Exit fullscreen mode

Bad — what the AI generates without the rule:

@Service
public class OrderService {
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private PaymentGateway paymentGateway;
    @Autowired
    private NotificationService notificationService;

    public Order createOrder(OrderRequest request) {
        User user = userRepository.findById(request.getUserId()).orElseThrow();
        paymentGateway.charge(user, request.getTotal());
        notificationService.send(user, "Order created");
        return new Order(user, request.getTotal());
    }
}
Enter fullscreen mode Exit fullscreen mode

Good — what the AI generates with the rule:

@Service
@RequiredArgsConstructor
public class OrderService {
    private final UserRepository userRepository;
    private final PaymentGateway paymentGateway;
    private final NotificationService notificationService;

    public Order createOrder(OrderRequest request) {
        User user = userRepository.findById(request.getUserId())
                .orElseThrow(() -> new EntityNotFoundException("User not found: " + request.getUserId()));
        paymentGateway.charge(user, request.getTotal());
        notificationService.send(user, "Order created");
        return new Order(user, request.getTotal());
    }
}
Enter fullscreen mode Exit fullscreen mode

Now you can instantiate OrderService in a unit test with plain constructor arguments. No Spring context required. Dependencies are explicit and immutable.


2. Use Records for Data Transfer — Ban Public Getter/Setter DTOs

AI loves generating 80-line DTOs with getters, setters, equals, hashCode, and toString. Java 16+ records eliminate all of that boilerplate and make intent clear: this is immutable data, not a mutable entity.

The rule:

Use Java records for DTOs, API responses, and value objects.
Never generate getter/setter boilerplate for data-only classes.
Use records with compact canonical constructors for validation.
Reserve classes for entities with behavior or mutable state.
Enter fullscreen mode Exit fullscreen mode

Bad — boilerplate DTO:

public class CreateOrderRequest {
    private Long userId;
    private BigDecimal total;
    private List<String> items;

    public Long getUserId() { return userId; }
    public void setUserId(Long userId) { this.userId = userId; }
    public BigDecimal getTotal() { return total; }
    public void setTotal(BigDecimal total) { this.total = total; }
    public List<String> getItems() { return items; }
    public void setItems(List<String> items) { this.items = items; }
}
Enter fullscreen mode Exit fullscreen mode

Good — record with validation:

public record CreateOrderRequest(
        @NotNull Long userId,
        @NotNull @Positive BigDecimal total,
        @NotEmpty List<String> items
) {
    public CreateOrderRequest {
        items = List.copyOf(items); // defensive copy, truly immutable
    }
}
Enter fullscreen mode Exit fullscreen mode

Five lines replace forty. The data is immutable. Validation is declarative. equals, hashCode, and toString are automatic.


3. Enforce Structured Error Handling — Ban Generic Exceptions

Without this rule, AI catches Exception, wraps everything in RuntimeException, or worse — swallows errors silently. Your production logs become useless.

The rule:

Never catch or throw generic Exception or RuntimeException.
Define domain-specific exceptions extending RuntimeException with meaningful fields.
Use @ControllerAdvice with @ExceptionHandler for REST API error responses.
Always include context (entity ID, operation name) in exception messages.
Enter fullscreen mode Exit fullscreen mode

Bad — generic exceptions, no context:

public User getUser(Long id) {
    try {
        return userRepository.findById(id).orElseThrow(() -> new Exception("Not found"));
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}
Enter fullscreen mode Exit fullscreen mode

Good — domain exceptions with context:

public User getUser(Long id) {
    return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
}

// Domain exception
public class UserNotFoundException extends RuntimeException {
    private final Long userId;

    public UserNotFoundException(Long userId) {
        super("User not found: " + userId);
        this.userId = userId;
    }

    public Long getUserId() { return userId; }
}

// Global handler
@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ProblemDetail> handleUserNotFound(UserNotFoundException ex) {
        ProblemDetail detail = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(detail);
    }
}
Enter fullscreen mode Exit fullscreen mode

Your API returns structured error responses. Your logs have context. Your callers can handle specific failure cases.


4. Enforce Package-by-Feature — Ban Package-by-Layer

AI trained on tutorials organizes code into controller/, service/, repository/ packages. This scatters every feature across the entire tree, makes navigation painful, and couples unrelated code through shared packages.

The rule:

Organize code by feature, not by layer. Each feature gets its own package
containing its controller, service, repository, DTOs, and exceptions.
Cross-feature communication goes through public interfaces, not direct class access.
Shared infrastructure (config, security) lives in a common package.
Enter fullscreen mode Exit fullscreen mode

Bad — package-by-layer:

com.app.controller/
    UserController.java
    OrderController.java
com.app.service/
    UserService.java
    OrderService.java
com.app.repository/
    UserRepository.java
    OrderRepository.java
Enter fullscreen mode Exit fullscreen mode

Good — package-by-feature:

com.app.user/
    UserController.java
    UserService.java
    UserRepository.java
    UserNotFoundException.java
    CreateUserRequest.java
com.app.order/
    OrderController.java
    OrderService.java
    OrderRepository.java
    CreateOrderRequest.java
com.app.common.config/
    SecurityConfig.java
Enter fullscreen mode Exit fullscreen mode

Now you can understand an entire feature by looking at one package. Deleting a feature is deleting one directory. Package-private visibility actually means something.


5. Enforce Optional Handling — Ban Null Returns from Queries

Without this rule, AI returns null from repository methods, and null checks spread like weeds through your codebase. One missed check and you get a NullPointerException in production.

The rule:

Repository methods that may return no result must return Optional<T>.
Never return null from any public method. Use Optional for absence.
Use Optional's map/flatMap/orElseThrow chain — never call .get() without .isPresent().
For collections, return empty collections, never null.
Enter fullscreen mode Exit fullscreen mode

Bad — null returns, null checks everywhere:

public User findByEmail(String email) {
    User user = userRepository.findByEmail(email);
    if (user == null) {
        return null;
    }
    return user;
}

// Caller
User user = userService.findByEmail(email);
if (user != null) {
    sendWelcomeEmail(user);
}
Enter fullscreen mode Exit fullscreen mode

Good — Optional chain, no nulls:

public Optional<User> findByEmail(String email) {
    return userRepository.findByEmail(email);
}

// Caller
userService.findByEmail(email)
        .map(user -> {
            sendWelcomeEmail(user);
            return user;
        })
        .orElseThrow(() -> new UserNotFoundException("email", email));
Enter fullscreen mode Exit fullscreen mode

The type system now tells you a value might be absent. The compiler helps you handle it. No more surprise NullPointerException at 2 AM.


6. Enforce Builder Pattern for Complex Objects — Ban Telescoping Constructors

AI generates constructors with 8+ parameters. You can't tell which null goes where, parameter order bugs are invisible, and adding a field means updating every call site.

The rule:

Use @Builder (Lombok) for any class with more than 3 constructor parameters.
Never write constructors with more than 3 parameters.
Use @Builder.Default for fields with sensible defaults.
For required fields, use a static factory method that takes only the required params.
Enter fullscreen mode Exit fullscreen mode

Bad — telescoping constructor:

Order order = new Order(userId, items, total, currency, "pending",
        LocalDateTime.now(), null, false, "standard");
Enter fullscreen mode Exit fullscreen mode

Good — builder with defaults:

@Builder
public class Order {
    private final Long userId;
    private final List<OrderItem> items;
    private final BigDecimal total;
    private final Currency currency;
    @Builder.Default private final String status = "pending";
    @Builder.Default private final LocalDateTime createdAt = LocalDateTime.now();
    @Builder.Default private final String shippingMethod = "standard";
}

Order order = Order.builder()
        .userId(userId)
        .items(items)
        .total(total)
        .currency(Currency.USD)
        .build();
Enter fullscreen mode Exit fullscreen mode

Every field is named at the call site. Defaults are explicit. Adding a new optional field doesn't break existing code.


Put These Rules to Work

These 6 rules cover the patterns where AI coding assistants fail most often in Java projects. Add them to your .cursorrules or CLAUDE.md and the difference is immediate — fewer review comments, idiomatic code from the first generation, and less time rewriting AI output.

I've packaged these rules (plus 44 more covering Spring Boot, microservices, testing, and JPA patterns) into a ready-to-use rules pack: Cursor Rules Pack v2

Drop it into your project directory and stop fighting your AI assistant.

Top comments (0)