| name | spring-boot-java |
| description | Spring Boot (Java) framework guardrails, patterns, and best practices.
Use when working with Spring Boot Java projects, or when the user mentions Spring Boot.
Provides JPA, Security, REST, WebFlux, and enterprise Java guidelines.
|
| license | MIT |
| metadata | {"author":"samuel","version":"1.0","category":"framework","language":"java","extensions":".java"} |
Spring Boot (Java) Guide
Applies to: Spring Boot 3.x, Java 17+, REST APIs, Microservices, Enterprise Applications
Core Principles
- Convention Over Configuration: Leverage Spring Boot auto-configuration; override only when necessary
- Layered Architecture: Controller -> Service -> Repository with clear separation of concerns
- DTOs at Boundaries: Never expose JPA entities in API responses; use records as DTOs
- Constructor Injection: Use
@RequiredArgsConstructor or explicit constructors; never field injection
- Externalized Config: All configuration via
application.yml with profile-specific overrides
- Database Migrations: Schema changes through Flyway or Liquibase; never
ddl-auto=update in production
Guardrails
Architecture Rules
- Controllers handle HTTP concerns only (validation, status codes, response mapping)
- Services contain business logic and transaction boundaries
- Repositories handle data access; use Spring Data JPA query derivation first
- Use
@Transactional(readOnly = true) at service class level, @Transactional on write methods
- Keep
spring.jpa.open-in-view=false to prevent lazy loading surprises
- Use
ProblemDetail (RFC 7807) for all error responses
Dependency Injection
- Prefer constructor injection with
@RequiredArgsConstructor (Lombok) or explicit constructors
- Never use
@Autowired on fields
- Use
@Bean methods in @Configuration classes for third-party types
- One
@Configuration class per concern (SecurityConfig, WebConfig, CacheConfig)
REST API Conventions
- Versioned paths:
/api/v1/resources
- Use proper HTTP methods: GET (read), POST (create), PUT (full update), PATCH (partial), DELETE
- Return
201 Created with Location header for POST
- Return
204 No Content for DELETE
- Always validate request bodies with
@Valid and Jakarta Bean Validation
- Use
@PageableDefault for list endpoints; never return unbounded collections
- Document APIs with SpringDoc OpenAPI annotations (
@Operation, @Tag, @ApiResponse)
Configuration
- Use
application.yml over application.properties
- Environment-specific files:
application-dev.yml, application-prod.yml
- Reference secrets via environment variables:
${DB_PASSWORD:default}
- Configure HikariCP pool sizes explicitly (do not rely on defaults)
- Set
server.error.include-message=never in production profiles
Project Structure
myproject/
โโโ src/main/java/com/example/myproject/
โ โโโ MyProjectApplication.java # @SpringBootApplication entry point
โ โโโ config/
โ โ โโโ SecurityConfig.java # Spring Security filter chain
โ โ โโโ WebConfig.java # CORS, interceptors, converters
โ โโโ controller/
โ โ โโโ UserController.java # REST endpoints
โ โโโ service/
โ โ โโโ UserService.java # Interface
โ โ โโโ impl/
โ โ โโโ UserServiceImpl.java # Implementation
โ โโโ repository/
โ โ โโโ UserRepository.java # JpaRepository interface
โ โโโ model/
โ โ โโโ entity/
โ โ โ โโโ User.java # JPA entity
โ โ โโโ dto/
โ โ โโโ UserRequest.java # Input record with validation
โ โ โโโ UserResponse.java # Output record
โ โโโ exception/
โ โ โโโ GlobalExceptionHandler.java # @RestControllerAdvice
โ โ โโโ ResourceNotFoundException.java
โ โโโ mapper/
โ โโโ UserMapper.java # MapStruct interface
โโโ src/main/resources/
โ โโโ application.yml
โ โโโ application-dev.yml
โ โโโ application-prod.yml
โ โโโ db/migration/
โ โโโ V1__create_users_table.sql # Flyway migration
โโโ src/test/java/com/example/myproject/
โ โโโ controller/
โ โ โโโ UserControllerTest.java # MockMvc tests
โ โโโ service/
โ โ โโโ UserServiceTest.java # Mockito unit tests
โ โโโ integration/
โ โโโ UserIntegrationTest.java # Testcontainers
โโโ pom.xml
โโโ Dockerfile
- Service interfaces are optional for small projects; use them when multiple implementations exist
- Place MapStruct mappers in a dedicated
mapper/ package
- Separate
entity/ and dto/ under model/ to reinforce the boundary
Controller Pattern
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Tag(name = "Users", description = "User management APIs")
public class UserController {
private final UserService userService;
@PostMapping
@Operation(summary = "Create a new user")
@ApiResponse(responseCode = "201", description = "User created")
@ApiResponse(responseCode = "409", description = "Email conflict")
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody UserRequest request) {
UserResponse response = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{id}")
@Operation(summary = "Get user by ID")
public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserById(id));
}
@GetMapping
@Operation(summary = "List users with pagination")
public ResponseEntity<Page<UserResponse>> getAllUsers(
@PageableDefault(size = 20, sort = "id") Pageable pageable) {
return ResponseEntity.ok(userService.getAllUsers(pageable));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Delete user (admin only)")
public ResponseEntity<Void> {
userService.deleteUser(id);
ResponseEntity.noContent().build();
}
}
Service Pattern
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
@Override
@Transactional
public UserResponse createUser(UserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateResourceException("User", "email", request.email());
}
User user = userMapper.toEntity(request);
user.setPassword(passwordEncoder.encode(request.password()));
User saved = userRepository.save(user);
return userMapper.toResponse(saved);
}
@Override
public UserResponse getUserById(Long id) {
return userRepository.findById(id)
.map(userMapper::toResponse)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
}
@Override
public Page<UserResponse> getAllUsers {
userRepository.findAll(pageable).map(userMapper::toResponse);
}
{
(!userRepository.existsById(id)) {
(, , id);
}
userRepository.deleteById(id);
}
}
Repository Pattern
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
List<User> findByActiveTrue();
Page<User> findByRole(User.Role role, Pageable pageable);
@Query("SELECT u FROM User u WHERE u.active = true AND u.role = :role")
List<User> findActiveUsersByRole(@Param("role") User.Role role);
}
- Prefer Spring Data derived queries for simple lookups
- Use
@Query with JPQL for joins and complex filters
- Use native queries only when JPQL is insufficient (bulk operations, database-specific functions)
- Always return
Optional for single-entity lookups
- Use
Page/Slice for paginated results
JPA Entity
@Entity
@Table(name = "users")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
@Builder.Default
private Role role = Role.USER;
@CreationTimestamp
@Column(updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
public enum Role { USER, ADMIN }
}
DTO Records with Validation
@Builder
public record UserRequest(
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
String email,
@NotBlank(message = "Password is required")
@Size(min = 8, max = 100)
String password,
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100)
String name
) {}
@Builder
public record UserResponse(
Long id,
String email,
String name,
String role,
LocalDateTime createdAt
) {}
Error Handling
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
log.warn("Resource not found: {}", ex.getMessage());
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Resource Not Found");
problem.setProperty("timestamp", Instant.now());
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String field = ((FieldError) error).getField();
errors.put(field, error.getDefaultMessage());
});
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Validation failed");
problem.setTitle("Validation Error");
problem.setProperty("timestamp", Instant.now());
problem.setProperty("errors", errors);
return problem;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
log.error("Unexpected error", ex);
ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, );
problem.setTitle();
problem.setProperty(, Instant.now());
problem;
}
}
Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/users").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/v1/users/**").hasRole("ADMIN")
.anyRequest().authenticated())
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
Testing Overview
Unit Tests (Mockito)
@ExtendWith(MockitoExtension.class)
@DisplayName("UserService")
class UserServiceTest {
@Mock private UserRepository userRepository;
@Mock private UserMapper userMapper;
@Mock private PasswordEncoder passwordEncoder;
@InjectMocks private UserServiceImpl userService;
@Test
@DisplayName("should create user with valid data")
void shouldCreateUser() {
when(userRepository.existsByEmail("test@example.com")).thenReturn(false);
when(userMapper.toEntity(any())).thenReturn(new User());
when(userRepository.save(any())).thenReturn(new User());
when(userMapper.toResponse(any())).thenReturn(
new UserResponse(1L, "test@example.com", "Test", "USER", null));
UserResponse result = userService.createUser(
new UserRequest("test@example.com", "Pass123!", "Test"));
assertThat(result.email()).isEqualTo("test@example.com");
verify(userRepository).save(any(User.class));
}
}
Integration Tests (Testcontainers + MockMvc)
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class UserIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Test
void shouldCreateUserViaApi() throws Exception {
var request = new UserRequest("test@example.com", "Pass123!", "Test");
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.email").value("test@example.com"));
}
}
Commands
./mvnw clean package
./mvnw spring-boot:run
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
./mvnw test
./mvnw verify
./mvnw spotless:apply
./mvnw checkstyle:check
./mvnw spring-boot:build-image
./mvnw -Pnative native:compile
Best Practices
| Do | Do Not |
|---|
Constructor injection (@RequiredArgsConstructor) | @Autowired on fields |
@Transactional(readOnly = true) at class level | @Transactional without readOnly at class level |
| DTOs (records) for API input/output | Expose JPA entities in responses |
| MapStruct for entity-DTO mapping | Manual mapping boilerplate |
| Pagination for all list endpoints | Unbounded collection returns |
| ProblemDetail (RFC 7807) errors | Custom error formats |
| Flyway/Liquibase for migrations | ddl-auto=update in production |
| Testcontainers for integration tests | H2 as production substitute |
open-in-view=false | Lazy loading outside transactions |
JPA batch inserts (hibernate.jdbc.batch_size) | Individual saves in loops |
Advanced Topics
For detailed patterns and advanced configurations, see:
- references/patterns.md -- JPA advanced patterns, Security with JWT, WebFlux reactive, testing strategies, Actuator, deployment
External References