Developer Field Handbook
Spring Bootfrom first bean to production
// Convention over configuration. Opinionated. Production-ready.
A practical guide for developers learning Spring Boot — from wiring your first REST endpoint to writing tests, managing databases with JPA, and shipping a containerized service. No XML required.
Spring Boot 3.x
Java 17+
REST APIs
Spring Data JPA
Spring Security
Spring Boot is an opinionated framework built on top of the Spring Framework. It auto-configures your application based on the dependencies on your classpath, embeds a web server (Tomcat by default), and gets you from zero to running in minutes — without XML, without boilerplate application server config, without manual wiring.
Auto-Configuration
Spring Boot detects what's on your classpath and configures beans automatically. Add spring-boot-starter-web and Tomcat is configured. Add a JPA dependency and a DataSource is wired. You override what you need, ignore the rest.
Starters
Starters are curated dependency bundles. Instead of hunting compatible versions of 8 libraries, you pull one starter. spring-boot-starter-data-jpa brings in Hibernate, Spring Data, and JDBC driver support — pre-wired and version-matched.
Embedded Server
Your application is a standalone JAR — the server lives inside it. No WAR deployment, no separate Tomcat install. Run with java -jar app.jar. Switch to Jetty or Undertow by excluding Tomcat and adding a different starter.
- Manual XML or Java config for every bean
- Manage library version compatibility yourself
- Deploy WAR to external app server
- Configure DataSource, Transaction Manager, MVC manually
- Hundreds of lines of boilerplate before business logic
- Auto-configured from classpath and properties
- Starters manage version-compatible dependency trees
- Embedded server — run as plain JAR
- DataSource, Hibernate, MVC configured automatically
- Focus on business logic from line one
The fastest way to start is start.spring.io — Spring Initializr generates a ready-to-run project. Select your build tool, Java version, and starters. Below is a typical pom.xml for a REST API with a database.
<!-- Parent handles all Spring Boot version management -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<dependencies>
<!-- Web: REST controllers, embedded Tomcat, Jackson JSON -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Data: Spring Data JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Validation: @Valid, @NotNull, @Size -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- DB driver (H2 for dev, swap to Postgres in prod) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Testing: JUnit 5, Mockito, MockMvc -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
@SpringBootApplication // = @Configuration + @EnableAutoConfiguration + @ComponentScan
public class BookstoreApplication {
public static void main(String[] args) {
SpringApplication.run(BookstoreApplication.class, args);
}
// That's it. Spring Boot wires everything else from the classpath.
}
▸
Dev Tools: Add spring-boot-devtools as a dependency during development. It enables automatic restarts when classpath files change — no manual restart loop. It's automatically excluded from production builds.
Spring Boot doesn't enforce a folder structure, but the layered architecture below is the community standard. Each layer has a clear responsibility. Keeping them separate makes testing, maintenance, and onboarding dramatically easier.
src/
├── main/
│ ├── java/com/example/bookstore/
│ │ ├── BookstoreApplication.java // entry point
│ │ │
│ │ ├── controller/ // @RestController — HTTP layer
│ │ │ └── BookController.java
│ │ │
│ │ ├── service/ // @Service — business logic
│ │ │ └── BookService.java
│ │ │
│ │ ├── repository/ // @Repository — data access
│ │ │ └── BookRepository.java
│ │ │
│ │ ├── model/ // @Entity — JPA domain objects
│ │ │ └── Book.java
│ │ │
│ │ ├── dto/ // Data Transfer Objects (API shapes)
│ │ │ ├── BookRequest.java
│ │ │ └── BookResponse.java
│ │ │
│ │ └── exception/ // Custom exceptions + global handler
│ │ ├── BookNotFoundException.java
│ │ └── GlobalExceptionHandler.java
│ │
│ └── resources/
│ ├── application.yml // config (db, port, logging)
│ ├── application-dev.yml // profile-specific overrides
│ └── db/migration/ // Flyway migration scripts
│ └── V1__init.sql
│
└── test/
└── java/com/example/bookstore/
├── controller/ BookControllerTest.java
└── service/ BookServiceTest.java
HTTP IN
Controller
Map routes, parse input, return responses
BUSINESS
Service
Orchestrate logic, transactions, rules
DATA
Repository
Query & persist via JPA
STORAGE
Database
PostgreSQL, MySQL, H2…
Inversion of Control (IoC) means Spring manages object creation and wiring — not your code. You declare what you need via Dependency Injection and Spring provides it. This decouples components, makes testing trivial, and keeps classes focused on one responsibility.
Constructor Injection Recommended
Inject dependencies via the constructor. Makes dependencies explicit, supports immutability (final fields), and makes the class easy to test without a Spring context — just pass mocks to the constructor.
Field Injection Avoid
Using @Autowired directly on a field. Hides dependencies, makes testing harder (requires reflection or a Spring context), and breaks with final. Lombok's @RequiredArgsConstructor makes constructor injection painless.
@Service
public class BookService {
private final BookRepository bookRepository;
private final NotificationService notificationService;
// Spring sees ONE constructor — auto-wires without @Autowired
public BookService(BookRepository bookRepository,
NotificationService notificationService) {
this.bookRepository = bookRepository;
this.notificationService = notificationService;
}
// With Lombok — exactly equivalent, less noise:
// @RequiredArgsConstructor on the class generates this constructor
}
A bean is any object managed by the Spring ApplicationContext. You declare beans by annotating classes. Spring instantiates them, injects their dependencies, and manages their lifecycle.
| Annotation | Used On | Purpose |
@SpringBootApplication | Main class | Enables auto-config, component scanning, configuration |
@Component | Any class | Generic Spring-managed bean |
@Service | Service layer class | Semantic alias for @Component — marks business logic |
@Repository | Data access class | Alias + wraps SQL exceptions into Spring's DataAccessException |
@RestController | HTTP handler class | @Controller + @ResponseBody — JSON returned from every method |
@Configuration | Config class | Declares @Bean methods — factory for Spring beans |
@Bean | Method in @Configuration | Return value registered as a Spring bean |
@Autowired | Constructor / field | Injects a dependency (optional on single-constructor classes) |
@Value | Field / param | Injects a value from properties: @Value("${app.name}") |
@Profile | Class / method | Activate bean only for specific profiles (dev, prod) |
@Configuration
public class AppConfig {
// Declare a bean for a third-party class you can't annotate directly
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
// Profile-specific bean — only loaded when profile = "dev"
@Bean
@Profile("dev")
public DataSource h2DataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.build();
}
}
Spring Boot reads application.yml (or .properties) from src/main/resources. Profiles let you maintain different configs for development, staging, and production without changing code.
# application.yml — base config (all environments)
spring:
application:
name: bookstore-api
datasource:
url: ${DB_URL:jdbc:h2:mem:devdb} # env var with fallback
username: ${DB_USER:sa}
password: ${DB_PASS:}
jpa:
hibernate:
ddl-auto: validate # NEVER 'create' or 'update' in prod
show-sql: false
properties:
hibernate:
format_sql: true
server:
port: 8080
logging:
level:
com.example.bookstore: DEBUG
org.springframework.web: INFO
# Custom properties — bind to @ConfigurationProperties class
bookstore:
max-results-per-page: 50
cache-ttl-minutes: 10
@ConfigurationProperties(prefix = "bookstore")
@Component
public class BookstoreProperties {
private int maxResultsPerPage = 20; // default if not set
private int cacheTtlMinutes = 5;
// getters + setters (or use Lombok @Data)
}
// Inject and use:
@Service
public class BookService {
private final BookstoreProperties props;
// props.getMaxResultsPerPage() → 50 (from yml)
}
⚠
ddl-auto in production: Never use spring.jpa.hibernate.ddl-auto: create or update on a production database. Use validate (checks schema matches entities) or none, and manage schema with Flyway or Liquibase migrations.
@RestController combines @Controller and @ResponseBody, meaning every method's return value is serialized to JSON automatically. Map HTTP methods to Java methods with @GetMapping, @PostMapping, @PutMapping, and @DeleteMapping.
@RestController
@RequestMapping("/api/v1/books")
@RequiredArgsConstructor // Lombok — generates constructor injection
public class BookController {
private final BookService bookService;
// GET /api/v1/books
@GetMapping
public ResponseEntity<List<BookResponse>> getAllBooks(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(bookService.findAll(page, size));
}
// GET /api/v1/books/{id}
@GetMapping("/{id}")
public ResponseEntity<BookResponse> getBook(@PathVariable Long id) {
return ResponseEntity.ok(bookService.findById(id));
}
// POST /api/v1/books
@PostMapping
public ResponseEntity<BookResponse> createBook(
@Valid @RequestBody BookRequest request) {
BookResponse created = bookService.create(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
return ResponseEntity.created(location).body(created); // 201 Created
}
// PUT /api/v1/books/{id}
@PutMapping("/{id}")
public ResponseEntity<BookResponse> updateBook(
@PathVariable Long id,
@Valid @RequestBody BookRequest request) {
return ResponseEntity.ok(bookService.update(id, request));
}
// DELETE /api/v1/books/{id}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
bookService.delete(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
}
| Annotation | Source | Example |
@PathVariable | URL path segment | GET /books/{id} → @PathVariable Long id |
@RequestParam | Query string | GET /books?page=2&size=10 |
@RequestBody | JSON request body | POST /books with JSON payload |
@RequestHeader | HTTP header | @RequestHeader("X-API-Key") String key |
@CookieValue | Cookie | @CookieValue("sessionId") String session |
@ModelAttribute | Form data / multiple params | Binds form fields to a POJO |
DTOs — never expose your entities directly
Return DTOs (Data Transfer Objects) from your controllers, not JPA entities. Entities may contain sensitive fields, lazy-loaded associations, or Hibernate proxy objects that serialize poorly. DTOs give you full control over the API contract.
// Input DTO — what the client sends
public record BookRequest(
@NotBlank(message = "Title is required")
String title,
@NotBlank
String author,
@NotNull
@DecimalMin("0.01")
BigDecimal price,
@ISBN // validates ISBN format
String isbn
) {}
// Output DTO — what the API returns
public record BookResponse(
Long id,
String title,
String author,
BigDecimal price,
String isbn,
LocalDateTime createdAt
) {
// Static factory — convert entity to DTO
public static BookResponse from(Book book) {
return new BookResponse(book.getId(), book.getTitle(),
book.getAuthor(), book.getPrice(), book.getIsbn(), book.getCreatedAt());
}
}
Spring Boot integrates Bean Validation (Jakarta Validation). Add @Valid before a @RequestBody parameter and Spring automatically validates the object — returning a 400 if any constraint fails. Customize error responses with a @ControllerAdvice.
@RestControllerAdvice // applies to all @RestController classes
public class GlobalExceptionHandler {
// 400 — Validation failures (@Valid)
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, Object> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(err ->
fieldErrors.put(err.getField(), err.getDefaultMessage()));
return Map.of("status", 400, "errors", fieldErrors);
}
// 404 — Custom not-found exception
@ExceptionHandler(BookNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Map<String, Object> handleNotFound(BookNotFoundException ex) {
return Map.of("status", 404, "message", ex.getMessage());
}
// 500 — Catch-all for unexpected errors
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map<String, Object> handleGeneral(Exception ex) {
return Map.of("status", 500, "message", "An unexpected error occurred");
// Never expose ex.getMessage() in production — reveals internals
}
}
⚠
Never expose stack traces: Spring Boot's default error response at /error includes the exception message and sometimes a stack trace. Disable this in production with server.error.include-stacktrace=never and server.error.include-message=never in your production profile.
Spring Data JPA removes the boilerplate of writing DAO classes. Define an interface extending JpaRepository and Spring generates the implementation at runtime. All CRUD operations — save, findById, findAll, delete — are available out of the box.
@Entity
@Table(name = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
private String title;
@Column(nullable = false)
private String author;
@Column(precision = 10, scale = 2)
private BigDecimal price;
@Column(unique = true, length = 13)
private String isbn;
@CreationTimestamp // Hibernate sets this on INSERT
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY) // LAZY — don't auto-join unless needed
@JoinColumn(name = "category_id")
private Category category;
// getters + setters (or use Lombok @Getter @Setter)
}
ℹ
FetchType.LAZY: Always use LAZY for @OneToMany and @ManyToOne associations. EAGER loading (the default for @ManyToOne) fires additional SQL queries automatically, even when you don't need the associated data — a common source of N+1 query problems.
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
// 1. Derived query — Spring generates SQL from the method name
List<Book> findByAuthorIgnoreCase(String author);
List<Book> findByPriceLessThanEqual(BigDecimal maxPrice);
Optional<Book> findByIsbn(String isbn);
boolean existsByIsbn(String isbn);
// 2. JPQL — object-oriented query language, references entity/field names
@Query("SELECT b FROM Book b WHERE b.title LIKE %:keyword% OR b.author LIKE %:keyword%")
List<Book> search(@Param("keyword") String keyword);
// 3. Native SQL — escape hatch when JPA won't do
@Query(value = "SELECT * FROM books WHERE price BETWEEN :min AND :max ORDER BY price",
nativeQuery = true)
List<Book> findInPriceRange(@Param("min") BigDecimal min,
@Param("max") BigDecimal max);
// 4. Pagination — built-in with Pageable
Page<Book> findByCategory_Name(String categoryName, Pageable pageable);
// 5. Custom update with @Modifying
@Modifying
@Query("UPDATE Book b SET b.price = b.price * :factor WHERE b.category.id = :catId")
int applyPriceMultiplier(@Param("factor") BigDecimal factor,
@Param("catId") Long catId);
}
Using Pageable
// Service method
public Page<BookResponse> findAll(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("title").ascending());
return bookRepository.findAll(pageable).map(BookResponse::from);
}
// Controller maps it — Page<T> serializes to JSON with metadata:
// { "content": [...], "totalElements": 143, "totalPages": 8,
// "number": 0, "size": 20, "first": true, "last": false }
Never let Hibernate manage your production schema with ddl-auto. Use Flyway for version-controlled, repeatable database migrations. Add spring-boot-starter-flyway and Spring Boot auto-runs migrations at startup.
-- src/main/resources/db/migration/V1__init.sql
CREATE TABLE categories (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE books (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
author VARCHAR(255) NOT NULL,
price DECIMAL(10,2) NOT NULL,
isbn VARCHAR(13) UNIQUE,
category_id BIGINT REFERENCES categories(id),
created_at TIMESTAMP DEFAULT NOW()
);
-- src/main/resources/db/migration/V2__add_book_index.sql
CREATE INDEX idx_books_author ON books(author);
CREATE INDEX idx_books_price ON books(price);
-- Naming: V{version}__{description}.sql
-- Once run, migration files are IMMUTABLE — never edit a ran migration
The service layer is where business logic lives. Controllers delegate to services; services call repositories. @Transactional ensures database operations within a method run in a single transaction — if anything fails, everything rolls back.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // default: read-only (optimization)
public class BookService {
private final BookRepository bookRepository;
public Page<BookResponse> findAll(int page, int size) {
return bookRepository.findAll(PageRequest.of(page, size))
.map(BookResponse::from);
}
public BookResponse findById(Long id) {
return bookRepository.findById(id)
.map(BookResponse::from)
.orElseThrow(() -> new BookNotFoundException("Book not found: " + id));
}
@Transactional // write operation — overrides class-level readOnly
public BookResponse create(BookRequest request) {
if (bookRepository.existsByIsbn(request.isbn())) {
throw new IllegalArgumentException("ISBN already exists");
}
Book book = new Book();
book.setTitle(request.title());
book.setAuthor(request.author());
book.setPrice(request.price());
book.setIsbn(request.isbn());
return BookResponse.from(bookRepository.save(book));
}
@Transactional
public void delete(Long id) {
if (!bookRepository.existsById(id)) {
throw new BookNotFoundException("Book not found: " + id);
}
bookRepository.deleteById(id);
}
}
Spring Boot provides test annotations that load only the slices you need — avoiding a full application context when you don't need one. @WebMvcTest for controllers, @DataJpaTest for repositories, and @SpringBootTest for full integration tests.
Unit Tests Fastest
Test a single class in isolation. Mock all dependencies with Mockito. No Spring context. Run in milliseconds. The bulk of your test suite.
Slice Tests Targeted
@WebMvcTest loads only the web layer. @DataJpaTest loads only JPA. Faster than full context, more integration than pure unit tests.
Integration Tests Slowest
@SpringBootTest loads the full application context. Use Testcontainers for a real database. Validates the entire stack together.
@ExtendWith(MockitoExtension.class)
class BookServiceTest {
@Mock
private BookRepository bookRepository;
@InjectMocks
private BookService bookService;
@Test
void findById_existingBook_returnsResponse() {
// Arrange
Book book = new Book();
book.setId(1L); book.setTitle("Clean Code"); book.setAuthor("Martin");
when(bookRepository.findById(1L)).thenReturn(Optional.of(book));
// Act
BookResponse result = bookService.findById(1L);
// Assert
assertThat(result.title()).isEqualTo("Clean Code");
verify(bookRepository).findById(1L);
}
@Test
void findById_missingBook_throwsException() {
when(bookRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> bookService.findById(99L))
.isInstanceOf(BookNotFoundException.class);
}
}
@WebMvcTest(BookController.class)
class BookControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private BookService bookService;
@Autowired private ObjectMapper objectMapper;
@Test
void getBook_found_returns200() throws Exception {
BookResponse resp = new BookResponse(1L, "Clean Code", "Martin",
new BigDecimal("39.99"), null, null);
when(bookService.findById(1L)).thenReturn(resp);
mockMvc.perform(get("/api/v1/books/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Clean Code"))
.andExpect(jsonPath("$.author").value("Martin"));
}
}
Add spring-boot-starter-security and every endpoint requires authentication immediately. Configure the security rules in a SecurityFilterChain bean. For REST APIs, stateless JWT-based auth is the standard.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // stateless API — no CSRF
.sessionManagement(sm -> sm
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll() // public endpoints
.requestMatchers(HttpMethod.GET, "/api/v1/books/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN") // role-guarded
.anyRequest().authenticated() // everything else needs auth
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // always use BCrypt for passwords
}
}
▸
Method-level security: Enable @EnableMethodSecurity on your config class to use @PreAuthorize("hasRole('ADMIN')") directly on service methods — a fine-grained alternative to URL-level rules that keeps authorization logic close to the business logic it protects.
Spring Boot Actuator exposes production-ready endpoints for health checks, metrics, and application info. Add spring-boot-starter-actuator. By default only /actuator/health and /actuator/info are exposed — configure carefully.
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus # expose only what you need
# NEVER include 'env' or 'heapdump' in production
endpoint:
health:
show-details: when-authorized # DB, disk, external services
metrics:
tags:
application: ${spring.application.name}
environment: ${APP_ENV:development}
# Result:
# GET /actuator/health → {"status":"UP","components":{...}}
# GET /actuator/metrics → list of available metric names
# GET /actuator/metrics/http.server.requests → request count, duration
# GET /actuator/prometheus → Prometheus scrape format
| Endpoint | URL | Useful For |
| health | /actuator/health | Kubernetes liveness/readiness probes |
| info | /actuator/info | App version, git commit, build info |
| metrics | /actuator/metrics/{name} | JVM, HTTP, custom Micrometer metrics |
| prometheus | /actuator/prometheus | Prometheus scrape target for Grafana dashboards |
| loggers | /actuator/loggers | Change log levels at runtime without restart |
# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline # cache deps layer
COPY src ./src
RUN ./mvnw package -DskipTests
# Stage 2: Runtime — lean image, no JDK
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Use layered JAR for better Docker cache utilization
ARG JAR_FILE=target/*.jar
COPY --from=build /app/${JAR_FILE} app.jar
# Non-root user for security
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java",
"-XX:+UseContainerSupport",
"-XX:MaxRAMPercentage=75.0",
"-jar",
"app.jar"]
# Activated with: -Dspring.profiles.active=prod OR SPRING_PROFILES_ACTIVE=prod
spring:
datasource:
url: ${DB_URL} # required env vars — no fallback in prod
username: ${DB_USER}
password: ${DB_PASS}
hikari:
maximum-pool-size: 20
minimum-idle: 5
jpa:
show-sql: false
hibernate:
ddl-auto: validate
server:
error:
include-stacktrace: never
include-message: never
logging:
level:
root: WARN
com.example.bookstore: INFO
Common Pitfalls Avoid
- Field injection — use constructor injection
- Exposing entities as API responses — use DTOs
- ddl-auto: update in production — use Flyway
- N+1 queries — LAZY + JOIN FETCH or projections
- No @Transactional on write operations — data may not persist
- Catching and swallowing exceptions — use @ControllerAdvice
- Logging secrets/tokens — review all log statements
- Hardcoded passwords in application.yml — use env vars
- Missing input validation — always use @Valid
- Returning 200 for created resources — use 201 with Location
Best Practices Follow
- Layer separation — Controller → Service → Repository
- Constructor injection with final fields
- Record DTOs for immutable request/response objects
- @Transactional(readOnly=true) at class level; override for writes
- Custom exceptions + GlobalExceptionHandler
- Profiles for dev/staging/prod configuration
- Flyway for schema migrations
- Pagination on all list endpoints (never return unbounded lists)
- Actuator health wired to K8s readiness/liveness probes
- Secrets from environment variables, not config files
Solving the N+1 Problem
The N+1 problem occurs when fetching N books also fires N extra queries to fetch each book's category. Fix it with a JOIN FETCH in JPQL or with an @EntityGraph.
// ❌ N+1: each book triggers a SELECT for its category
List<Book> books = bookRepository.findAll(); // 1 query + N queries for categories
// ✅ Fix 1: JPQL JOIN FETCH — single query
@Query("SELECT b FROM Book b JOIN FETCH b.category")
List<Book> findAllWithCategory();
// ✅ Fix 2: @EntityGraph — declarative eager loading
@EntityGraph(attributePaths = {"category"})
List<Book> findAll();
// ✅ Fix 3: Projections — only fetch what you need
public interface BookSummary {
String getTitle();
String getAuthor();
BigDecimal getPrice();
// No category — no extra join at all
}
List<BookSummary> findAllProjectedBy(); // SELECT title, author, price only
▸
Enable SQL logging in dev: Set spring.jpa.show-sql: true and spring.jpa.properties.hibernate.format_sql: true during development to see every query. Use p6spy or datasource-proxy to also see bind parameters. Review the output — N+1 patterns are immediately visible when you look at the logs.
| Topic | Quick Reference |
| HTTP Status codes | 200 OK · 201 Created · 204 No Content · 400 Bad Request · 401 Unauthorized · 403 Forbidden · 404 Not Found · 422 Unprocessable · 500 Internal Server Error |
| Common starters | web · data-jpa · security · validation · test · actuator · cache · mail · data-redis |
| Useful Spring annotations | @Scheduled (cron jobs) · @Async (async methods) · @Cacheable (caching) · @EventListener (app events) |
| Test annotations | @SpringBootTest · @WebMvcTest · @DataJpaTest · @MockBean · @TestPropertySource |
| Reference docs | docs.spring.io/spring-boot · start.spring.io · spring.io/guides · baeldung.com/spring-boot |