spring-boot-java
SKILL.md
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
@RequiredArgsConstructoror explicit constructors; never field injection - Externalized Config: All configuration via
application.ymlwith profile-specific overrides - Database Migrations: Schema changes through Flyway or Liquibase; never
ddl-auto=updatein 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,@Transactionalon write methods - Keep
spring.jpa.open-in-view=falseto 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
@Autowiredon fields - Use
@Beanmethods in@Configurationclasses for third-party types - One
@Configurationclass 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 Createdwith Location header for POST - Return
204 No Contentfor DELETE - Always validate request bodies with
@Validand Jakarta Bean Validation - Use
@PageableDefaultfor list endpoints; never return unbounded collections - Document APIs with SpringDoc OpenAPI annotations (
@Operation,@Tag,@ApiResponse)
Configuration
- Use
application.ymloverapplication.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=neverin 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/anddto/undermodel/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> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return 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(Pageable pageable) {
return userRepository.findAll(pageable).map(userMapper::toResponse);
}
@Override
@Transactional
public void deleteUser(Long id) {
if (!userRepository.existsById(id)) {
throw new ResourceNotFoundException("User", "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
@Querywith JPQL for joins and complex filters - Use native queries only when JPQL is insufficient (bulk operations, database-specific functions)
- Always return
Optionalfor single-entity lookups - Use
Page/Slicefor 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
// Request DTO
@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
) {}
// Response DTO
@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 problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
problem.setTitle("Internal Server Error");
problem.setProperty("timestamp", Instant.now());
return problem;
}
}
Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize for method-level access control
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // disable for stateless REST APIs
.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); // cost factor 12: security/performance balance
}
}
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
# Create project via Spring Initializr
# https://start.spring.io/
# Build
./mvnw clean package
# Run
./mvnw spring-boot:run
# Run with profile
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
# Test
./mvnw test
# Test with coverage
./mvnw verify
# Format (with spotless plugin)
./mvnw spotless:apply
# Static analysis
./mvnw checkstyle:check
# Build Docker image (Spring Boot Buildpacks)
./mvnw spring-boot:build-image
# Native executable (GraalVM)
./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
Weekly Installs
6
Repository
ar4mirez/samuelGitHub Stars
3
First Seen
Mar 1, 2026
Security Audits
Installed on
opencode6
gemini-cli6
github-copilot6
codex6
kimi-cli6
amp6