Java development has high ceremony — annotations, configuration, application context setup, verbose boilerplate. Claude Code cuts through this: it reads your Spring Boot project structure and generates properly annotated, injection-ready services rather than generic Java. It understands the Spring ecosystem (Security, Data JPA, Validation, Testing) and writes code that integrates without requiring manual wiring.
This guide covers Java development with Claude Code: Spring Boot REST APIs, JPA patterns, security configuration, and testing.
Setting Up Claude Code for Spring Boot
Your CLAUDE.md prevents configuration guesswork:
# Spring Boot Project Context
## Stack
- Java 21, Spring Boot 3.2
- Spring Data JPA + Hibernate, PostgreSQL
- Spring Security 6 with JWT (no sessions — stateless)
- Validation: Jakarta Validation (Bean Validation 3.0)
- Build: Gradle (not Maven)
- Testing: JUnit 5 + Mockito + Spring Boot Test
## Conventions
- Package structure: com.example.{feature}.{layer} (not layer-first)
- DTOs: separate request/response records (Java records, not classes)
- Exceptions: custom exceptions extending RuntimeException, handled in @ControllerAdvice
- IDs: Long (not UUID) — we're on PostgreSQL with sequences
- Timestamps: Instant (not LocalDateTime) — always UTC
## Never
- Fetch-all queries without pagination
- Returning JPA entities directly from controllers (always map to DTOs)
- Lombok on JPA entities (causes proxy issues) — use standard getters/setters
- Mutable DTOs — use Java records for request/response
See the CLAUDE.md setup guide for full configuration.
Spring Boot REST APIs
Controller and Service Layer
Create a REST API for managing orders.
Operations: list (with pagination), get by ID, create, update status, cancel.
Multi-tenant: every operation must filter by the authenticated user's organizationId.
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@GetMapping
public Page<OrderResponse> listOrders(
@AuthenticationPrincipal UserPrincipal user,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) OrderStatus status) {
return orderService.findByOrganization(user.getOrganizationId(), status,
PageRequest.of(page, size, Sort.by("createdAt").descending()));
}
@GetMapping("/{id}")
public OrderResponse getOrder(
@PathVariable Long id,
@AuthenticationPrincipal UserPrincipal user) {
return orderService.findByIdAndOrganization(id, user.getOrganizationId());
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse createOrder(
@Valid @RequestBody CreateOrderRequest request,
@AuthenticationPrincipal UserPrincipal user) {
return orderService.create(request, user.getOrganizationId());
}
}
Claude generates all layers without hand-holding: the controller delegates to services, @AuthenticationPrincipal injects the authenticated user from the security context, and all queries include organizationId for tenant isolation.
Request/Response Records
Define the request and response DTOs for orders.
Response should include line items with product details.
Use Java records.
// Request DTO
public record CreateOrderRequest(
@NotEmpty(message = "At least one item required")
List<@Valid OrderItemRequest> items,
@NotBlank
String shippingAddress
) {}
public record OrderItemRequest(
@NotNull Long productId,
@Min(1) Integer quantity
) {}
// Response DTO
public record OrderResponse(
Long id,
String status,
List<OrderItemResponse> items,
int totalCents,
String shippingAddress,
Instant createdAt
) {}
public record OrderItemResponse(
Long id,
Long productId,
String productName,
int quantity,
int unitPriceCents,
int subtotalCents
) {}
Java records are immutable, concise, and implement equals(), hashCode(), and toString() automatically — the right choice for DTOs. Claude uses them consistently instead of verbose POJOs.
Bean Validation
Add validation to the CreateOrderRequest.
Business rules: max 50 items, each quantity between 1-100.
Custom validation: product IDs must exist in the database.
For the database validation, Claude generates a custom constraint annotation:
@Constraint(validatedBy = ProductExistsValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ProductExists {
String message() default "Product not found";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
public class ProductExistsValidator implements ConstraintValidator<ProductExists, Long> {
@Autowired
private ProductRepository productRepository;
@Override
public boolean isValid(Long productId, ConstraintValidatorContext context) {
if (productId == null) return true; // Let @NotNull handle nulls
return productRepository.existsById(productId);
}
}
Custom validators are injected by Spring, so they can hit the database. Claude generates the annotation, the validator class, and updates the DTO to use it.
JPA and Database Patterns
Repository Queries
Write a Spring Data JPA repository for Orders.
I need: find by organization with optional status filter,
total revenue per organization for the last 30 days,
and count of orders by status.
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// Dynamic query with optional status filter
@Query("""
SELECT o FROM Order o
WHERE o.organizationId = :orgId
AND (:status IS NULL OR o.status = :status)
ORDER BY o.createdAt DESC
""")
Page<Order> findByOrganization(
@Param("orgId") Long orgId,
@Param("status") OrderStatus status,
Pageable pageable
);
// Aggregation query
@Query("""
SELECT COALESCE(SUM(o.totalCents), 0)
FROM Order o
WHERE o.organizationId = :orgId
AND o.status = 'COMPLETED'
AND o.createdAt >= :since
""")
long sumRevenueByOrganization(
@Param("orgId") Long orgId,
@Param("since") Instant since
);
// Count by status
@Query("""
SELECT o.status, COUNT(o)
FROM Order o
WHERE o.organizationId = :orgId
GROUP BY o.status
""")
List<Object[]> countByStatus(@Param("orgId") Long orgId);
}
Claude uses JPQL (not raw SQL) where possible to keep queries database-portable, adds COALESCE for sum queries that might return null, and uses Java 15+ text blocks for readable multi-line queries.
Avoiding N+1 Queries
My order list endpoint is making 51 queries to load 50 orders with their items.
Fix the N+1 problem.
Claude diagnoses the JPA lazy loading pattern and fixes it:
// Problem: lazy loading triggers N queries for items
@Query("SELECT o FROM Order o WHERE o.organizationId = :orgId")
Page<Order> findByOrganization(...);
// Fix: JOIN FETCH eagerly loads items in a single query
@Query(value = """
SELECT DISTINCT o FROM Order o
LEFT JOIN FETCH o.items i
LEFT JOIN FETCH i.product
WHERE o.organizationId = :orgId
""",
countQuery = "SELECT COUNT(o) FROM Order o WHERE o.organizationId = :orgId")
Page<Order> findByOrganizationWithItems(
@Param("orgId") Long orgId,
Pageable pageable
);
The separate countQuery is required when using JOIN FETCH with Page — Spring Data JPA can’t derive the count from a fetch join. Claude adds it correctly.
Entity Design
Design the Order JPA entity.
One-to-many with OrderItems.
Soft deletes. Optimistic locking to prevent concurrent modifications.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long organizationId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id ASC")
private List<OrderItem> items = new ArrayList<>();
@Column
private Instant deletedAt;
@Version // Optimistic locking — prevents concurrent updates
private Long version;
@CreationTimestamp
@Column(updatable = false)
private Instant createdAt;
@UpdateTimestamp
private Instant updatedAt;
// Standard getters/setters (no Lombok on JPA entities)
}
@Version enables optimistic locking — if two transactions try to update the same order, one gets an OptimisticLockException. Claude avoids Lombok on JPA entities as noted in CLAUDE.md (Lombok-generated methods can interfere with Hibernate proxies).
Spring Security
JWT Authentication
Configure Spring Security 6 for stateless JWT authentication.
Public endpoints: /api/auth/**, /actuator/health
All others require authentication.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Spring Security 6 uses a lambda-style DSL (not method chaining). Claude generates the current syntax — not the deprecated Spring Security 5 patterns that most Stack Overflow answers still show.
Method Security
Add method-level security so that users can only access
their own organization's data.
@EnableMethodSecurity
@Configuration
public class MethodSecurityConfig {}
// On service methods:
@PreAuthorize("@securityService.isOrgMember(#orgId, authentication)")
public OrderResponse findByIdAndOrganization(Long id, Long orgId) { ... }
Testing
Write integration tests for the POST /api/v1/orders endpoint.
Use @SpringBootTest with a real test database.
Cover: success, validation errors, auth required.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@Transactional
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void createOrder_success() throws Exception {
var request = new CreateOrderRequest(
List.of(new OrderItemRequest(1L, 2)),
"123 Main St"
);
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
.header("Authorization", "Bearer " + getTestToken()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void createOrder_requiresAuthentication() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
}
@Transactional on the test class rolls back after each test — no cleanup code needed, no test data pollution. Claude generates this pattern by default for Spring Boot integration tests.
Exception Handling
Add a @ControllerAdvice that maps our custom exceptions to HTTP responses.
Map: EntityNotFoundException → 404, ValidationException → 422,
AccessDeniedException → 403.
Never expose stack traces.
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(EntityNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(EntityNotFoundException ex) {
return new ErrorResponse("not_found", ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
return new ErrorResponse("validation_failed", "Validation failed", fieldErrors);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpected(Exception ex, HttpServletRequest request) {
log.error("Unhandled exception at {}", request.getRequestURI(), ex);
return new ErrorResponse("internal_error", "An unexpected error occurred");
}
}
The unhandled exception handler logs the full stack trace server-side (so it’s in your logs) without including it in the response. Claude generates this separation consistently.
Java with Claude Code: What Works Well
Java’s verbosity is exactly where Claude Code provides the most leverage. Generating a full feature in Spring Boot — controller, service, repository, entity, DTOs, tests — by hand takes 45-60 minutes of boilerplate. Claude Code generates all of it from a feature description in under a minute, correctly wired and using current Spring Boot 3.x patterns.
The annotation-heavy Spring ecosystem is where context matters most. When you specify in CLAUDE.md that you’re using Spring Security 6, JPA with Hibernate, and Java records for DTOs, Claude generates code that runs rather than code that looks right but uses deprecated APIs.
For comprehensive Java patterns — Kafka integration, Spring Batch, Spring Cloud configuration, microservices communication — the Claude Skills 360 bundle includes enterprise Java skill sets covering the patterns that take the most boilerplate to write. See the testing guide for test container patterns and the database guide for query optimization that applies to JPA’s generated SQL. Start with the free tier to try the scaffolding patterns.