DEV Community

Olivia Craft
Olivia Craft

Posted on

Cursor Rules for Java — Production Patterns That Actually Ship

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.


1. Spring Boot Scaffolding — Controllers, Services, Repositories

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

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

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

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.


2. Maven Workflow — Dependency Management and Plugin Patterns

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 <dependencyManagement> 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 <scope> for test/provided dependencies.
Enter fullscreen mode Exit fullscreen mode

Bad — unmanaged versions, no structure:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>3.3.1</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.17.0</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
    </dependency>
</dependencies>
Enter fullscreen mode Exit fullscreen mode

Good — BOM-managed, properly scoped:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.1</version>
</parent>

<properties>
    <java.version>21</java.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
    <!-- Starters -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <!-- Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
Enter fullscreen mode Exit fullscreen mode

No version numbers on managed dependencies. Scopes are explicit. The BOM handles compatibility so you don't have to.


3. Error Handling — Checked Exceptions and Custom Wrappers

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

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

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

Every exception has context for debugging. The API returns structured errors. Checked exceptions die at the service boundary.


4. Async and Reactive Patterns — CompletableFuture and Project Reactor

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

Bad — blocking calls, no error handling in async chain:

public OrderSummary getOrderSummary(Long orderId) {
    CompletableFuture<Order> orderFuture = CompletableFuture.supplyAsync(
            () -> orderRepository.findById(orderId).orElseThrow());
    CompletableFuture<List<Payment>> paymentFuture = CompletableFuture.supplyAsync(
            () -> paymentRepository.findByOrderId(orderId));

    Order order = orderFuture.join();       // blocks request thread
    List<Payment> payments = paymentFuture.join(); // blocks again
    return new OrderSummary(order, payments);
}
Enter fullscreen mode Exit fullscreen mode

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<OrderSummary> getOrderSummary(Long orderId) {
        CompletableFuture<Order> orderFuture = CompletableFuture
                .supplyAsync(() -> orderRepository.findById(orderId)
                        .orElseThrow(() -> new OrderNotFoundException(orderId)), ioExecutor);

        CompletableFuture<List<Payment>> paymentFuture = CompletableFuture
                .supplyAsync(() -> paymentRepository.findByOrderId(orderId), ioExecutor);

        return orderFuture.thenCombine(paymentFuture, OrderSummary::new)
                .exceptionally(ex -> {
                    throw new OrderSummaryException(orderId, ex);
                });
    }
}
Enter fullscreen mode Exit fullscreen mode

Both queries run in parallel on a dedicated I/O pool. No thread blocking. Errors propagate through the chain instead of disappearing silently.


5. Test Structure — JUnit 5, Mocking, and Fixtures

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

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

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("ada@test.com")
                .status(UserStatus.ACTIVE).build();
    }
}
Enter fullscreen mode Exit fullscreen mode

Tests describe behavior in their names. Fixtures create consistent, readable test data. Assertions verify outcomes, not implementation details.


6. Documentation — Javadoc and Inline Comments

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

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

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

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.


7. Refactoring Patterns — Rename, Extract, and Generics

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

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

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, ID> {
    T findOrThrow(ID id);
    T saveAndFlush(T entity);
}

public abstract class BaseRepository<T, ID> implements CrudOperations<T, ID> {
    @Override
    public T findOrThrow(ID id) {
        return findById(id).orElseThrow(() ->
                new EntityNotFoundException(getEntityName(), id));
    }
}
Enter fullscreen mode Exit fullscreen mode

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.


8. Performance — Caching, Lazy Loading, and Streams

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

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<OrderItem> items;
}

public List<OrderSummary> getRecentOrders() {
    List<Order> 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());
}
Enter fullscreen mode Exit fullscreen mode

Good — lazy loading, JOIN FETCH, caching, pagination:

@Entity
public class Order {
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "order")
    private List<OrderItem> items;
}

// Repository with JOIN FETCH — one query, no N+1
public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.createdAt > :since")
    List<Order> findRecentWithItems(@Param("since") LocalDateTime since);

    Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}

// Service with caching and pagination
@Service
@RequiredArgsConstructor
public class OrderService {

    @Cacheable(value = "orderSummaries", key = "#status + '-' + #pageable.pageNumber")
    public Page<OrderSummary> 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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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.


Complete .cursorrules File

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

.mdc Rule File

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<DTO> or uses @ResponseStatus
2. Service method is @Transactional and maps between entities and DTOs
3. Repository uses @Query for custom JPQL, Page<T> for list endpoints
4. Exception classes include entity ID, operation context, and error codes
Enter fullscreen mode Exit fullscreen mode

Put These Rules to Work

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.

Top comments (0)