AI News Hub Logo

AI News Hub

Cursor Rules for Java — Production Patterns That Actually Ship

DEV Community
Olivia Craft

Cursor Rules for Java — Production Patterns That Actually Ship Cursor with Java is a different beast than Cursor with Python or TypeScript. Java projects have deep type hierarchies, annotation-driven frameworks, XML build files, and a ceremony level that trips up AI assistants constantly. But when you configure it right, Cursor becomes an enterprise Java power tool — IntelliSense-grade completions, class navigation that understands your Spring context, and refactoring that respects your architecture. The gap between "AI that writes Java" and "AI that writes Java I'd merge" comes down to rules. Without rules, Cursor generates tutorial-grade code: controllers that do business logic, services with no error handling, tests that mock everything into meaninglessness. With the right rules, it generates code that passes your team's review on the first try. Here are 8 production-tested Cursor rules for Java covering Spring Boot, Maven, error handling, async patterns, testing, documentation, refactoring, and performance. Each one includes concrete examples so you can see exactly what changes. Without rules, AI generates fat controllers that call repositories directly, services that return entities to the API layer, and repositories with custom query methods that belong in the service. The layers blur into mud. The rule: Spring Boot layer separation: - Controllers handle HTTP concerns only: request validation, response mapping, status codes. Controllers never contain business logic. Return DTOs, never entities. - Services contain all business logic. Accept and return domain objects or DTOs. Services are the transaction boundary (@Transactional). - Repositories are Spring Data interfaces. Custom queries use @Query with JPQL. Never put business logic in default repository methods. - Use record DTOs for request/response. Map between entities and DTOs in the service layer. Bad — fat controller with business logic: @RestController @RequestMapping("/api/orders") public class OrderController { @Autowired private OrderRepository orderRepository; @Autowired private UserRepository userRepository; @PostMapping public Order createOrder(@RequestBody Map body) { Long userId = Long.valueOf(body.get("userId").toString()); User user = userRepository.findById(userId).orElse(null); if (user == null) throw new RuntimeException("User not found"); Order order = new Order(); order.setUser(user); order.setTotal(new BigDecimal(body.get("total").toString())); order.setStatus("PENDING"); order.setCreatedAt(LocalDateTime.now()); return orderRepository.save(order); } } Good — layered with DTOs: // Controller — HTTP concerns only @RestController @RequestMapping("/api/orders") @RequiredArgsConstructor public class OrderController { private final OrderService orderService; @PostMapping @ResponseStatus(HttpStatus.CREATED) public OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) { return orderService.createOrder(request); } } // Service — business logic and transactions @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final UserRepository userRepository; @Transactional public OrderResponse createOrder(CreateOrderRequest request) { User user = userRepository.findById(request.userId()) .orElseThrow(() -> new UserNotFoundException(request.userId())); Order order = Order.builder() .user(user) .total(request.total()) .status(OrderStatus.PENDING) .build(); Order saved = orderRepository.save(order); return OrderResponse.from(saved); } } // DTOs — records, immutable public record CreateOrderRequest( @NotNull Long userId, @NotNull @Positive BigDecimal total, @NotEmpty List itemIds ) {} public record OrderResponse(Long id, BigDecimal total, String status, LocalDateTime createdAt) { public static OrderResponse from(Order order) { return new OrderResponse(order.getId(), order.getTotal(), order.getStatus().name(), order.getCreatedAt()); } } The controller is five lines. The service owns the transaction. DTOs protect your entities from leaking to the API. Each layer has exactly one responsibility. AI adds dependencies with version numbers inline, skips the BOM, and never configures plugins beyond defaults. Your pom.xml becomes a version conflict minefield. The rule: Maven dependency management: - Use spring-boot-starter-parent or a BOM for version management. Never hardcode dependency versions that are managed by the BOM. - Group dependencies: starters first, then domain libraries, then test dependencies. - Always use in multi-module projects for version consistency. - Configure maven-compiler-plugin to target Java 21. Set encoding to UTF-8. - Use maven-surefire-plugin for unit tests, maven-failsafe-plugin for integration tests. - Never add dependencies without specifying the for test/provided dependencies. Bad — unmanaged versions, no structure: org.springframework.boot spring-boot-starter-web 3.3.1 com.fasterxml.jackson.core jackson-databind 2.17.0 org.junit.jupiter junit-jupiter 5.10.2 Good — BOM-managed, properly scoped: org.springframework.boot spring-boot-starter-parent 3.3.1 21 UTF-8 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-validation org.springframework.boot spring-boot-starter-test test No version numbers on managed dependencies. Scopes are explicit. The BOM handles compatibility so you don't have to. AI catches Exception, wraps everything in RuntimeException, or silently swallows errors. Your production logs become useless and your API returns 500 for every failure. The rule: Error handling patterns: - Define a base domain exception extending RuntimeException. All domain exceptions extend from it. - Each exception includes context: entity ID, operation, and a machine-readable error code. - Use @RestControllerAdvice for global exception mapping to RFC 7807 ProblemDetail. - Map domain exceptions to appropriate HTTP status codes. Never expose stack traces in responses. - Checked exceptions from libraries must be caught at the boundary and wrapped in a domain exception — never let them propagate up the stack. Bad — generic exceptions leak everywhere: public PaymentResult processPayment(Long orderId) { try { Order order = orderRepository.findById(orderId).orElseThrow(); return paymentGateway.charge(order.getTotal()); } catch (Exception e) { throw new RuntimeException("Payment failed", e); } } Good — domain exceptions with context and RFC 7807 responses: // Base domain exception public abstract class DomainException extends RuntimeException { private final String errorCode; protected DomainException(String message, String errorCode) { super(message); this.errorCode = errorCode; } public String getErrorCode() { return errorCode; } } // Specific exception with context public class PaymentFailedException extends DomainException { private final Long orderId; private final String gatewayResponse; public PaymentFailedException(Long orderId, String gatewayResponse) { super("Payment failed for order " + orderId + ": " + gatewayResponse, "PAYMENT_FAILED"); this.orderId = orderId; this.gatewayResponse = gatewayResponse; } } // Service — catches checked exceptions at the boundary public PaymentResult processPayment(Long orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); try { return paymentGateway.charge(order.getTotal()); } catch (GatewayTimeoutException e) { throw new PaymentFailedException(orderId, "Gateway timeout: " + e.getMessage()); } } // Global handler — RFC 7807 @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(PaymentFailedException.class) public ProblemDetail handlePaymentFailed(PaymentFailedException ex) { ProblemDetail detail = ProblemDetail.forStatusAndDetail( HttpStatus.PAYMENT_REQUIRED, ex.getMessage()); detail.setProperty("errorCode", ex.getErrorCode()); return detail; } } Every exception has context for debugging. The API returns structured errors. Checked exceptions die at the service boundary. Without rules, AI blocks threads, ignores error propagation in async chains, and mixes reactive and imperative styles randomly. Your application handles 50 concurrent requests when it should handle 5000. The rule: Async and reactive patterns: - Use CompletableFuture for async operations that are naturally imperative. Always provide a custom executor — never use the common ForkJoinPool for I/O. - Chain with thenApply/thenCompose — never call .get() or .join() in request threads. - Use @Async with a configured ThreadPoolTaskExecutor, not the default SimpleAsyncTaskExecutor. - For reactive: use Mono/Flux consistently. Never call .block() outside of tests. - Handle errors in async chains with exceptionally() or onErrorResume() — never let exceptions silently disappear. Bad — blocking calls, no error handling in async chain: public OrderSummary getOrderSummary(Long orderId) { CompletableFuture orderFuture = CompletableFuture.supplyAsync( () -> orderRepository.findById(orderId).orElseThrow()); CompletableFuture> paymentFuture = CompletableFuture.supplyAsync( () -> paymentRepository.findByOrderId(orderId)); Order order = orderFuture.join(); // blocks request thread List payments = paymentFuture.join(); // blocks again return new OrderSummary(order, payments); } Good — non-blocking with custom executor and error handling: @Configuration public class AsyncConfig { @Bean("ioExecutor") public Executor ioExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setQueueCapacity(100); executor.setThreadNamePrefix("io-"); executor.initialize(); return executor; } } @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final PaymentRepository paymentRepository; @Qualifier("ioExecutor") private final Executor ioExecutor; public CompletableFuture getOrderSummary(Long orderId) { CompletableFuture orderFuture = CompletableFuture .supplyAsync(() -> orderRepository.findById(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)), ioExecutor); CompletableFuture> paymentFuture = CompletableFuture .supplyAsync(() -> paymentRepository.findByOrderId(orderId), ioExecutor); return orderFuture.thenCombine(paymentFuture, OrderSummary::new) .exceptionally(ex -> { throw new OrderSummaryException(orderId, ex); }); } } Both queries run in parallel on a dedicated I/O pool. No thread blocking. Errors propagate through the chain instead of disappearing silently. AI generates tests that test nothing — mocking every dependency, verifying mock calls instead of behavior, and using random magic values that obscure intent. The rule: Testing patterns (JUnit 5 + Mockito): - Name tests: methodName_givenCondition_expectedResult. - Use @ExtendWith(MockitoExtension.class), not @SpringBootTest, for unit tests. Reserve @SpringBootTest for integration tests only. - Mock external boundaries (repositories, clients). Never mock the class under test. - Use test fixtures with builder methods for creating test data — no random inline values. - Assert behavior and return values, not mock interactions. Verify mock calls only when side effects are the point of the test. - Integration tests use @SpringBootTest + Testcontainers for real databases. Bad — testing mock interactions, not behavior: @Test void testCreateOrder() { when(userRepository.findById(1L)).thenReturn(Optional.of(new User())); when(orderRepository.save(any())).thenReturn(new Order()); orderService.createOrder(new CreateOrderRequest(1L, BigDecimal.TEN, List.of(1L))); verify(userRepository).findById(1L); verify(orderRepository).save(any()); } Good — testing behavior with meaningful assertions: @ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock private OrderRepository orderRepository; @Mock private UserRepository userRepository; @InjectMocks private OrderService orderService; @Test void createOrder_givenValidRequest_returnsOrderWithPendingStatus() { User user = TestFixtures.activeUser(); CreateOrderRequest request = new CreateOrderRequest(user.getId(), new BigDecimal("99.99"), List.of(1L)); when(userRepository.findById(user.getId())).thenReturn(Optional.of(user)); when(orderRepository.save(any(Order.class))).thenAnswer(inv -> { Order order = inv.getArgument(0); order.setId(1L); return order; }); OrderResponse result = orderService.createOrder(request); assertThat(result.status()).isEqualTo("PENDING"); assertThat(result.total()).isEqualByComparingTo("99.99"); } @Test void createOrder_givenUnknownUser_throwsUserNotFoundException() { when(userRepository.findById(999L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> orderService.createOrder( new CreateOrderRequest(999L, BigDecimal.TEN, List.of(1L)))) .isInstanceOf(UserNotFoundException.class); } } // Shared test fixtures public final class TestFixtures { public static User activeUser() { return User.builder().id(1L).name("Ada Lovelace").email("[email protected]") .status(UserStatus.ACTIVE).build(); } } Tests describe behavior in their names. Fixtures create consistent, readable test data. Assertions verify outcomes, not implementation details. AI either generates zero documentation or generates noise: /** Gets the user. */ public User getUser(). Neither helps your team. The rule: Documentation patterns: - Write Javadoc on all public classes and methods. Focus on WHY and WHEN, not WHAT (the signature already tells you what). - Include @param, @return, and @throws tags. Document what exceptions mean for the caller. - For complex business logic, add inline comments explaining the business rule — not the code. - Never write comments that repeat the code: "// get user" above getUser() is noise. - Use @see to link related classes. Document thread-safety for shared state. Bad — useless Javadoc that adds nothing: /** * Order service. */ public class OrderService { /** * Creates an order. * @param request the request * @return the order */ public OrderResponse createOrder(CreateOrderRequest request) { ... } } Good — Javadoc explains WHY and documents contracts: /** * Handles order lifecycle from creation through fulfillment. * All methods are transactional — partial order creation is not possible. * * @see PaymentService for payment processing after order creation * @see InventoryService for stock reservation during order creation */ public class OrderService { /** * Creates a new order and reserves inventory for all items. * The order starts in PENDING status and must be confirmed via * {@link #confirmOrder(Long)} within 30 minutes or it auto-cancels. * * @param request validated order details including user ID and item list * @return the created order with generated ID and current status * @throws UserNotFoundException if the user ID does not exist * @throws InsufficientStockException if any item cannot be reserved */ @Transactional public OrderResponse createOrder(CreateOrderRequest request) { // Business rule: orders over $10,000 require manager approval // and start in REVIEW status instead of PENDING if (request.total().compareTo(REVIEW_THRESHOLD) > 0) { return createOrderForReview(request); } ... } } The class-level doc tells you what this service covers and where to look for related logic. The method doc explains the 30-minute auto-cancel — information you can't get from reading the method signature. The inline comment explains a business rule, not a code mechanic. When AI generates code, it often creates long methods, duplicated logic, and concrete types where generics belong. Without refactoring rules, asking Cursor to "clean this up" produces cosmetic changes that don't improve the design. The rule: Refactoring patterns: - Extract methods when a block has a distinct purpose — name the method after the purpose, not the implementation. - Use generics to eliminate duplicated logic across types. Prefer bounded wildcards (? extends T) in method parameters. - When renaming, update all references including test classes and documentation. - Replace type-switching (instanceof chains) with polymorphism. - Extract shared behavior into abstract base classes or interfaces with default methods only when 3+ classes share it — not for 2 classes. Bad — long method with duplicated notification logic: public void processOrder(Order order) { order.setStatus(OrderStatus.PROCESSING); orderRepository.save(order); String message = "Order " + order.getId() + " is being processed"; if (order.getUser().getNotificationPreference() == NotificationPreference.EMAIL) { emailService.send(order.getUser().getEmail(), "Order Update", message); } else if (order.getUser().getNotificationPreference() == NotificationPreference.SMS) { smsService.send(order.getUser().getPhone(), message); } // 50 more lines of fulfillment logic... } Good — extracted methods, polymorphic notifications: public void processOrder(Order order) { updateStatus(order, OrderStatus.PROCESSING); notifyUser(order.getUser(), buildOrderUpdateMessage(order)); fulfillOrder(order); } private void notifyUser(User user, String message) { notificationStrategy.forPreference(user.getNotificationPreference()) .send(user, message); } // Generic repository base for common operations public interface CrudOperations { T findOrThrow(ID id); T saveAndFlush(T entity); } public abstract class BaseRepository implements CrudOperations { @Override public T findOrThrow(ID id) { return findById(id).orElseThrow(() -> new EntityNotFoundException(getEntityName(), id)); } } The method reads like a business process description. Notification logic is polymorphic — adding a new channel doesn't touch existing code. The generic base eliminates repeated findById().orElseThrow() patterns across repositories. AI generates code that works in development and dies under load. N+1 queries, uncached repeated lookups, eager loading of entire object graphs, and streams that materialize collections multiple times. The rule: Performance patterns: - Use @Cacheable for read-heavy, rarely-changing data. Define cache names and TTL explicitly. Always implement @CacheEvict on mutation methods. - Use @EntityGraph or JOIN FETCH for relationships needed in the query — prevent N+1. Default all @OneToMany and @ManyToMany to FetchType.LAZY. - Use Stream API for transformations but never for side effects. Prefer toList() over collect(Collectors.toList()). Use parallelStream() only for CPU-bound work with 10,000+ elements — never for I/O. - For pagination, always use Pageable with reasonable defaults. Never load unbounded collections. Bad — N+1 queries, no caching, eager loading everything: @Entity public class Order { @OneToMany(fetch = FetchType.EAGER) // loads ALL items on every query private List items; } public List getRecentOrders() { List orders = orderRepository.findAll(); // unbounded, loads all items eagerly return orders.stream() .map(order -> new OrderSummary( order.getId(), order.getItems().size(), // N+1 if lazy, wasteful if eager order.getTotal())) .collect(Collectors.toList()); } Good — lazy loading, JOIN FETCH, caching, pagination: @Entity public class Order { @OneToMany(fetch = FetchType.LAZY, mappedBy = "order") private List items; } // Repository with JOIN FETCH — one query, no N+1 public interface OrderRepository extends JpaRepository { @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.createdAt > :since") List findRecentWithItems(@Param("since") LocalDateTime since); Page findByStatus(OrderStatus status, Pageable pageable); } // Service with caching and pagination @Service @RequiredArgsConstructor public class OrderService { @Cacheable(value = "orderSummaries", key = "#status + '-' + #pageable.pageNumber") public Page getOrders(OrderStatus status, Pageable pageable) { return orderRepository.findByStatus(status, pageable) .map(OrderSummary::from); } @CacheEvict(value = "orderSummaries", allEntries = true) @Transactional public OrderResponse updateOrderStatus(Long orderId, OrderStatus newStatus) { Order order = orderRepository.findOrThrow(orderId); order.setStatus(newStatus); return OrderResponse.from(orderRepository.save(order)); } } Lazy loading prevents loading object graphs you don't need. JOIN FETCH loads what you do need in one query. Caching prevents repeated database hits. Pagination prevents unbounded memory growth. Drop this into your project root as .cursorrules: # Java Production Rules ## Spring Boot Architecture - Controllers: HTTP concerns only. Request validation, response mapping, status codes. Never contain business logic. Return DTOs (records), never entities. - Services: All business logic. @Transactional boundary. Accept/return DTOs or domain objects. - Repositories: Spring Data interfaces. Custom queries use @Query JPQL. - Use constructor injection (@RequiredArgsConstructor). All injected fields: private final. - Use Java records for DTOs. Use compact constructors for validation. ## Maven - Use spring-boot-starter-parent BOM. Never hardcode managed dependency versions. - Explicit for test/provided. Group: starters, domain libs, test. - Target Java 21. UTF-8 encoding. Surefire for unit tests, Failsafe for integration. ## Error Handling - Domain exceptions extend a base DomainException (RuntimeException subclass). - Include context: entity ID, operation, machine-readable error code. - @RestControllerAdvice maps domain exceptions to RFC 7807 ProblemDetail. - Catch checked exceptions at service boundary, wrap in domain exceptions. - Never catch/throw generic Exception or RuntimeException. ## Async - CompletableFuture with custom executor for I/O — never ForkJoinPool. - Chain with thenApply/thenCompose — never .get()/.join() on request threads. - @Async requires configured ThreadPoolTaskExecutor. - Handle errors in chains: exceptionally() or onErrorResume(). ## Testing - Unit tests: @ExtendWith(MockitoExtension.class). Mock boundaries only. - Name: methodName_givenCondition_expectedResult. - Assert behavior and return values, not mock interactions. - Integration tests: @SpringBootTest + Testcontainers. - Shared test fixtures with builder methods. ## Documentation - Javadoc on public classes and methods. Focus on WHY and WHEN, not WHAT. - @param, @return, @throws with meaningful descriptions. - Inline comments for business rules only — never restate the code. ## Refactoring - Extract methods named after purpose, not implementation. - Use generics to eliminate duplication. Bounded wildcards in params. - Replace instanceof chains with polymorphism. - Abstract shared behavior only when 3+ classes share it. ## Performance - @Cacheable for read-heavy data. @CacheEvict on mutations. Explicit TTL. - FetchType.LAZY default. JOIN FETCH or @EntityGraph for needed relations. - Stream for transformations, not side effects. toList() over Collectors.toList(). - Always paginate with Pageable. Never load unbounded collections. - parallelStream() only for CPU-bound work with 10,000+ elements. For Cursor's .cursor/rules/ directory, save this as java-production.mdc: --- description: Production Java patterns for Spring Boot applications globs: ["**/*.java", "pom.xml"] alwaysApply: false --- You are working on a Spring Boot 3.x application with Java 21. Follow these production patterns: - Layer separation: Controllers (HTTP only) → Services (business logic, @Transactional) → Repositories (data access) - Constructor injection with @RequiredArgsConstructor, all fields private final - Java records for DTOs with bean validation annotations - Domain exceptions extending a base DomainException, mapped to RFC 7807 via @RestControllerAdvice - CompletableFuture with custom executors for async I/O — never block request threads - JUnit 5 + MockitoExtension for unit tests, @SpringBootTest + Testcontainers for integration - @Cacheable with explicit eviction, FetchType.LAZY default, JOIN FETCH where needed - Javadoc on public API: explain WHY and contracts, not WHAT the code does When generating Spring components: 1. Controller returns ResponseEntity or uses @ResponseStatus 2. Service method is @Transactional and maps between entities and DTOs 3. Repository uses @Query for custom JPQL, Page for list endpoints 4. Exception classes include entity ID, operation context, and error codes These 8 rules cover the production patterns where AI coding assistants fail hardest in Java projects. Spring scaffolding that respects layer boundaries. Maven builds that don't create version conflicts. Error handling that helps you debug at 2 AM. Async code that doesn't block your thread pool. Tests that actually catch bugs. Documentation that tells you why. Refactoring that improves design. Performance that survives load testing. Add them to your .cursorrules or .cursor/rules/ directory and the difference is immediate — fewer review comments, production-grade code from the first generation, and less time rewriting AI output. Better rules mean faster time to production. If you're shipping Java professionally, the rules pay for themselves on the first feature. I've packaged these rules (plus 40+ more covering microservices, JPA, security, and observability patterns across 10 languages) into a ready-to-use rules pack: Cursor Rules Pack v2 Drop it into your project directory and stop fighting your AI assistant.