Spring Boot is the standard way to build production-grade Java applications. This cheat sheet covers everything from project setup and REST controllers to JPA, security, and testing — with copy-ready code for Spring Boot 3.x (Java 17+).
Quick reference — annotations
The annotations you'll use in every Spring Boot project.
| Annotation | Where | Purpose |
|---|---|---|
@SpringBootApplication |
Main class | Enables component scan + auto-configuration |
@RestController |
Class | Marks as REST controller; combines @Controller + @ResponseBody |
@RequestMapping("/api") |
Class | Base URL prefix for all endpoints |
@GetMapping |
Method | HTTP GET handler |
@PostMapping |
Method | HTTP POST handler |
@PutMapping |
Method | HTTP PUT handler |
@PatchMapping |
Method | HTTP PATCH handler |
@DeleteMapping |
Method | HTTP DELETE handler |
@PathVariable |
Param | Binds {id} from URL |
@RequestParam |
Param | Binds ?page=1 query param |
@RequestBody |
Param | Deserializes JSON body |
@ResponseStatus |
Method | Sets HTTP status code |
@Service |
Class | Service-layer bean |
@Repository |
Interface | Data-access bean; enables exception translation |
@Component |
Class | Generic Spring-managed bean |
@Configuration |
Class | Java config class |
@Bean |
Method | Registers return value as bean |
@Autowired |
Field/Constructor | Dependency injection (prefer constructor) |
@Value("${key}") |
Field | Injects property value |
@Entity |
Class | JPA entity mapped to table |
@Id |
Field | Primary key |
@GeneratedValue |
Field | Auto-generated ID |
@Column |
Field | Column mapping |
@Transactional |
Method/Class | Wraps in database transaction |
@ControllerAdvice |
Class | Global exception handler |
Project setup
Generate a project at start.spring.io or use the Spring Initializr CLI.
Minimal pom.xml dependencies:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>
<dependencies>
<!-- Web (REST + embedded Tomcat) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Validation (Bean Validation / Jakarta) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Main class:
@SpringBootApplication
public class MyApp {
public static void main(String[] args) {
SpringApplication.run(MyApp.class, args);
}
}
REST controller
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
// Constructor injection (preferred over @Autowired on field)
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<UserDto> getAll(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return userService.findAll(page, size);
}
@GetMapping("/{id}")
public UserDto getById(@PathVariable Long id) {
return userService.findById(id); // throws 404 if missing
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserDto create(@Valid @RequestBody CreateUserRequest req) {
return userService.create(req);
}
@PutMapping("/{id}")
public UserDto update(@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest req) {
return userService.update(id, req);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
}
Request/response DTOs with validation:
public record CreateUserRequest(
@NotBlank String name,
@Email String email,
@Size(min = 8) String password
) {}
public record UserDto(Long id, String name, String email) {}
JPA entity and repository
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(unique = true, nullable = false)
private String email;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
// Constructors, getters, setters or use Lombok @Data
}
Spring Data JPA repository — free CRUD + custom queries:
public interface UserRepository extends JpaRepository<User, Long> {
// Derived query — Spring generates SQL automatically
Optional<User> findByEmail(String email);
List<User> findByNameContainingIgnoreCase(String name);
boolean existsByEmail(String email);
// JPQL query
@Query("SELECT u FROM User u WHERE u.createdAt >= :since")
List<User> findRecentUsers(@Param("since") LocalDateTime since);
// Native SQL
@Query(value = "SELECT * FROM users LIMIT :limit", nativeQuery = true)
List<User> findTopN(@Param("limit") int limit);
// Pagination
Page<User> findAll(Pageable pageable);
}
Service layer:
@Service
@Transactional(readOnly = true) // default: read-only; override per method
public class UserService {
private final UserRepository userRepo;
public UserService(UserRepository userRepo) {
this.userRepo = userRepo;
}
public UserDto findById(Long id) {
return userRepo.findById(id)
.map(u -> new UserDto(u.getId(), u.getName(), u.getEmail()))
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}
@Transactional // write operation needs full transaction
public UserDto create(CreateUserRequest req) {
if (userRepo.existsByEmail(req.email())) {
throw new ConflictException("Email already registered");
}
User user = new User();
user.setName(req.name());
user.setEmail(req.email());
User saved = userRepo.save(user);
return new UserDto(saved.getId(), saved.getName(), saved.getEmail());
}
}
Exception handling
// Custom exceptions
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, Long id) {
super(resource + " not found: " + id);
}
}
public class ConflictException extends RuntimeException {
public ConflictException(String msg) { super(msg); }
}
// Global handler — catches exceptions from all controllers
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(404, ex.getMessage()));
}
@ExceptionHandler(ConflictException.class)
public ResponseEntity<ErrorResponse> handleConflict(ConflictException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(409, ex.getMessage()));
}
// Handles @Valid failures
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(new ErrorResponse(400, msg));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(Exception ex) {
return ResponseEntity.internalServerError()
.body(new ErrorResponse(500, "Internal server error"));
}
}
public record ErrorResponse(int status, String message) {}
application.yml configuration
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USER}
password: ${DB_PASS}
jpa:
hibernate:
ddl-auto: validate # use Flyway/Liquibase in prod; never update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
default_batch_fetch_size: 20 # N+1 fix for collections
security:
user:
name: admin # dev only; replace with real auth in prod
server:
port: 8080
logging:
level:
com.myapp: DEBUG
org.hibernate.SQL: WARN
Spring Security (JWT guard)
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize on methods
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // disable for stateless API
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
// Method-level security
@Service
public class PostService {
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public void deletePost(Long postId, Long userId) { ... }
}
Testing
Slice test — controller only (no DB):
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mvc;
@MockBean UserService userService; // mock the service
@Test
void getById_returnsUser() throws Exception {
given(userService.findById(1L))
.willReturn(new UserDto(1L, "Alice", "alice@example.com"));
mvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Alice"))
.andExpect(jsonPath("$.email").value("alice@example.com"));
}
@Test
void create_invalidBody_returns400() throws Exception {
mvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"\",\"email\":\"bad\"}"))
.andExpect(status().isBadRequest());
}
}
Repository slice test (real DB, in-memory H2):
@DataJpaTest
class UserRepositoryTest {
@Autowired UserRepository repo;
@Test
void findByEmail_returnsUser() {
User user = new User();
user.setName("Bob");
user.setEmail("bob@example.com");
repo.save(user);
Optional<User> found = repo.findByEmail("bob@example.com");
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Bob");
}
}
Full integration test:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = Replace.ANY)
class UserIntegrationTest {
@Autowired TestRestTemplate rest;
@Test
void createAndFetchUser() {
var req = new CreateUserRequest("Alice", "a@b.com", "password123");
var created = rest.postForObject("/api/users", req, UserDto.class);
assertThat(created.id()).isNotNull();
var fetched = rest.getForObject("/api/users/" + created.id(), UserDto.class);
assertThat(fetched.email()).isEqualTo("a@b.com");
}
}
Pagination pattern
// Controller
@GetMapping
public Page<UserDto> getAll(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id") String sort) {
Pageable pageable = PageRequest.of(page, size, Sort.by(sort));
return userService.findAll(pageable);
}
// Service
public Page<UserDto> findAll(Pageable pageable) {
return userRepo.findAll(pageable).map(u -> new UserDto(u.getId(), u.getName(), u.getEmail()));
}
// Response shape: { "content": [...], "totalElements": 100, "totalPages": 5, "number": 0 }
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
@Autowired on field |
Hard to test; hides dependencies | Use constructor injection |
ddl-auto: update in prod |
Schema drift, data loss risk | Use Flyway or Liquibase |
No @Transactional on writes |
Saves may not commit | Add @Transactional to write methods |
Exposing @Entity directly in response |
Leaks internals, circular JSON, lazy-load issues | Always use DTOs |
EAGER fetch everywhere |
N+1 queries, slow endpoints | Use LAZY + @EntityGraph when needed |
Missing spring.jpa.open-in-view=false |
Lazy loads in view layer, performance issues | Set it false in application.yml |
| No global exception handler | Stack traces leak to clients | Add @ControllerAdvice |
FAQ
What's the difference between @Controller and @RestController?@RestController = @Controller + @ResponseBody. Use @RestController for APIs that return JSON. Use @Controller when returning view names (Thymeleaf templates).
When should I use @Transactional(readOnly = true)?
Set it as the class-level default for service classes. Hibernate skips dirty checking on read-only transactions, improving performance. Override individual write methods with plain @Transactional.
What's the N+1 problem and how do I fix it in Spring Data JPA?
N+1 happens when fetching a list of entities and each triggers a separate query for its related collection. Fix it with @EntityGraph, JOIN FETCH in JPQL, or set spring.jpa.properties.hibernate.default_batch_fetch_size=20 in application.yml.
How do I handle database migrations?
Use Flyway: add spring-boot-starter-data-jpa + flyway-core to pom.xml, place SQL scripts in src/main/resources/db/migration/V1__init.sql. Never use ddl-auto: create or update in production.
What's the difference between @MockBean and @Mock?@MockBean (Spring test) replaces a bean in the Spring context — use in @WebMvcTest / @SpringBootTest. @Mock (Mockito) creates a mock without touching Spring — use in plain unit tests with @ExtendWith(MockitoExtension.class).
How do I add CORS support?
@Configuration
public class CorsConfig {
@Bean
public WebMvcConfigurer cors() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://myfrontend.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true);
}
};
}
}