Spring Boot Cursor Rules: Java Backend Framework

Cursor rules for Spring Boot covering REST controllers, services, repositories, JPA entities, Bean Validation, Spring Security, and JUnit 5 testing.

June 8, 2025by PromptGenius Team
spring-bootjavacursor-rulesbackendjvmjpa
Spring Boot Cursor Rules: Java Backend Framework

Overview

Spring Boot is the dominant JVM framework, powering enterprise APIs with auto-configuration, dependency injection, and a vast ecosystem. These cursor rules enforce layered architecture (controller → service → repository), JPA entity conventions, Bean Validation, Spring Security patterns, and JUnit 5 testing to help AI assistants generate production-grade Java backends.

Note:

Enforces controller/service/repository layering, JPA entity and repository conventions, Bean Validation DTOs, Spring Security with method-level authorization, @Transactional boundaries, and JUnit 5/Mockito testing patterns.

Rules Configuration

---
description: Enforces idiomatic Spring Boot patterns including layered architecture, JPA entities and repositories, Bean Validation, Spring Security, transactional service boundaries, and JUnit 5 testing. Provides guidelines for production-grade Java APIs.
globs: **/*.java,**/*.yml,**/*.properties
---
# Spring Boot Best Practices

You are an expert in Spring Boot, Java backend development, and enterprise API design.
You understand Spring Framework, JPA/Hibernate, microservices patterns, and production deployment.

### Project Structure
- /src/main/java/com/example/<domain> — one package per domain
  - controller — @RestController classes, handles HTTP concerns
  - service — @Service classes, business logic with @Transactional
  - repository — @Repository interfaces extending JpaRepository
  - dto — request/response POJOs with validation annotations
  - entity — @Entity classes mapped to database tables
  - exception — custom exception classes and @ControllerAdvice handler
  - config — @Configuration classes for beans and security
- /src/main/resources/application.yml — configuration properties
- /src/test — JUnit 5 tests mirroring main source structure

### Controllers & REST
- Annotate with @RestController and @RequestMapping("/api/v1/<resource>")
- Use @GetMapping, @PostMapping, @PutMapping, @DeleteMapping with path variables
- Inject services via constructor injection (no @Autowired on fields)
- Accept DTOs with @Valid @RequestBody for automatic validation
- Return ResponseEntity<T> with appropriate status codes and body
- Never expose entity classes in controller responses — use DTO projections

### Services & Transactions
- Annotate service classes with @Service
- Mark mutating methods with @Transactional
- Use readOnly = true on query-only methods for Hibernate optimization
- Keep services stateless; inject repositories and other services
- Throw custom exceptions (not generic RuntimeException) from service layer
- Return Optional<T> for find-by-id methods that may return null

### JPA & Repositories
- Define entities with @Entity, @Table, @Id, @GeneratedValue
- Use @Column with nullable, length, unique constraints matching the schema
- Define relationships with @ManyToOne, @OneToMany, @ManyToMany — prefer LAZY fetch
- Extend JpaRepository<Entity, Long> for CRUD without boilerplate
- Use @Query with JPQL for complex queries; use nativeQuery = true sparingly
- Avoid bidirectional relationships unless needed — prefer unidirectional

### Validation
- Use Bean Validation on DTOs: @NotBlank, @Email, @Size, @Min, @Max
- Add @Valid on controller method parameters for automatic validation
- Return 400 with field-level error messages on validation failure
- Create custom validators with @Constraint for domain-specific rules
- Use @Validated on service classes for method-level validation

### Security
- Configure SecurityFilterChain bean, not WebSecurityConfigurerAdapter (deprecated)
- Use @PreAuthorize("hasRole('ADMIN')") for method-level authorization
- Use passwordEncoder().encode() for bcrypt password hashing
- Store secrets in application.yml with spring.config.import for vault/external
- Enable CSRF for browser clients; disable for pure API with stateless JWT

### Exception Handling
- Create custom exceptions extending RuntimeException
- Use @ControllerAdvice with @ExceptionHandler for centralized error handling
- Return structured error responses: { error, message, status, timestamp }
- Log exceptions with SLF4J; never expose stack traces in API responses

### Testing
- Use JUnit 5 with @SpringBootTest for integration tests
- Use @WebMvcTest for controller-layer tests with MockMvc
- Use @DataJpaTest for repository-layer tests with in-memory database
- Mock service dependencies with @MockBean
- Use assertEquals, assertThrows, assertTrue for assertions

Installation

Create spring.mdc in your project's .cursor/rules/ directory and paste the configuration above. Cursor and Windsurf both read .cursor/rules/ — Copilot users place it in .github/copilot-instructions.md instead.

Examples

// controller/PostController.java
package com.example.posts.controller;

import com.example.posts.dto.CreatePostRequest;
import com.example.posts.dto.PostResponse;
import com.example.posts.dto.PagedResponse;
import com.example.posts.service.PostService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/posts")
public class PostController {

    private final PostService postService;

    public PostController(PostService postService) {
        this.postService = postService;
    }

    @GetMapping
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<PagedResponse<PostResponse>> getAll(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        return ResponseEntity.ok(postService.findAll(page, size));
    }

    @PostMapping
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<PostResponse> create(@Valid @RequestBody CreatePostRequest request) {
        PostResponse created = postService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @GetMapping("/{id}")
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<PostResponse> getById(@PathVariable Long id) {
        return ResponseEntity.ok(postService.findById(id));
    }
}
// service/PostService.java
package com.example.posts.service;

import com.example.posts.dto.CreatePostRequest;
import com.example.posts.dto.PostResponse;
import com.example.posts.dto.PagedResponse;
import com.example.posts.entity.Post;
import com.example.posts.exception.ResourceNotFoundException;
import com.example.posts.repository.PostRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PostService {

    private final PostRepository postRepository;

    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    @Transactional(readOnly = true)
    public PagedResponse<PostResponse> findAll(int page, int size) {
        Page<Post> postPage = postRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size));
        return PagedResponse.from(postPage.map(PostResponse::from));
    }

    @Transactional
    public PostResponse create(CreatePostRequest request) {
        Post post = new Post();
        post.setTitle(request.title());
        post.setBody(request.body());
        post.setPublished(request.published() != null ? request.published() : false);

        Post saved = postRepository.save(post);
        return PostResponse.from(saved);
    }

    @Transactional(readOnly = true)
    public PostResponse findById(Long id) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Post not found with id: " + id));
        return PostResponse.from(post);
    }
}
// entity/Post.java
package com.example.posts.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "posts")
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String title;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String body;

    @Column(nullable = false)
    private boolean published = false;

    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }

    public String getBody() { return body; }
    public void setBody(String body) { this.body = body; }

    public boolean isPublished() { return published; }
    public void setPublished(boolean published) { this.published = published; }

    public LocalDateTime getCreatedAt() { return createdAt; }
}
// repository/PostRepository.java
package com.example.posts.repository;

import com.example.posts.entity.Post;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    Page<Post> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
// dto/PostResponse.java
package com.example.posts.dto;

import com.example.posts.entity.Post;
import java.time.LocalDateTime;

public record PostResponse(
    Long id,
    String title,
    String body,
    boolean published,
    LocalDateTime createdAt
) {
    public static PostResponse from(Post post) {
        return new PostResponse(
            post.getId(), post.getTitle(), post.getBody(),
            post.isPublished(), post.getCreatedAt()
        );
    }
}
// dto/PagedResponse.java
package com.example.posts.dto;

import org.springframework.data.domain.Page;
import java.util.List;

public record PagedResponse<T>(
    List<T> data,
    int page,
    int total,
    int perPage
) {
    public static <T> PagedResponse<T> from(Page<T> page) {
        return new PagedResponse<>(
            page.getContent(),
            page.getNumber(),
            (int) page.getTotalElements(),
            page.getSize()
        );
    }
}
// dto/CreatePostRequest.java
package com.example.posts.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CreatePostRequest(
    @NotBlank @Size(max = 200) String title,
    @NotBlank String body,
    Boolean published
) {}