Spring Boot Best Practices
This guide distills the patterns that separate a demo from a maintainable, production-ready Spring Boot service. Each section states the principle, shows a do / don’t contrast, and links to the page that covers it in depth. None of it is exotic — the value is in applying these consistently across the whole codebase so the application stays predictable as it grows.
Architecture: layer cleanly, package by feature
Keep a clear Controller → Service → Repository flow where each layer has one job: controllers handle HTTP, services own business rules and transactions, repositories handle persistence. Organize packages by feature (order, catalog, user) rather than by technical layer (controllers, services) so related code stays together and modules can later be split out.
| Do | Don’t |
|---|---|
| Put business logic in the service layer | Put logic in controllers or repositories |
Package by feature (com.shop.order) | Package by layer (com.shop.controllers) |
| Let controllers stay thin (map HTTP only) | Call repositories directly from controllers |
See the CRUD API walkthrough for a clean layered example, and Stereotype Annotations for @Service/@Repository/@Controller roles.
Use constructor injection
Inject dependencies through the constructor, not fields. Constructor injection makes dependencies explicit, allows final fields, fails fast on a missing bean, and keeps classes testable without a container. Lombok’s @RequiredArgsConstructor removes the boilerplate.
// Do — constructor injection (final, testable, explicit)
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repo;
private final PaymentClient payments;
}
// Don't — field injection hides dependencies and blocks final fields
@Service
public class OrderService {
@Autowired private OrderRepository repo; // avoid
}
See Dependency Injection and Autowiring.
Always use DTOs — never expose entities
Returning JPA entities from controllers leaks your schema, triggers lazy-loading serialization bugs, and couples the API to the database. Map entities to DTOs (records are ideal) at the service boundary.
| Do | Don’t |
|---|---|
Return record ProductResponse(...) | Return the @Entity Product |
| Accept a validated request DTO | Bind request bodies straight onto entities |
| Map in the service or a mapper | Serialize lazy associations |
See DTO Pattern, Entity vs DTO, Records for DTOs, and MapStruct for compile-time mapping.
Centralize exception handling
Don’t scatter try/catch and ad-hoc status codes across controllers. Use one @RestControllerAdvice to translate exceptions into consistent responses, and prefer the standard ProblemDetail (RFC 7807) body.
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ProblemDetail handle(NotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}
Warning: Never leak stack traces or internal messages to clients. Map known exceptions to clean messages and log the detail server-side.
See Controller Advice, Custom Exceptions, and ProblemDetail.
Externalize configuration and secrets
Bind configuration to typed @ConfigurationProperties classes, use profiles for per-environment values, and pull secrets from the environment — never commit them.
# application.yml — defaults + profile-specific overrides in application-prod.yml
app:
jwt:
secret: ${JWT_SECRET} # injected, never hard-coded
expiration-minutes: 60
| Do | Don’t |
|---|---|
@ConfigurationProperties typed binding | Scatter @Value strings everywhere |
| Secrets via env vars / vault | Commit passwords in application.yml |
| Profiles for dev / test / prod | One config branching on if (env) |
See Configuration Properties, Profiles, and Externalized Configuration.
Validate input at the edge
Annotate request DTOs with Bean Validation constraints and trigger them with @Valid. Fail with 400 and a clear field-level body before any business logic runs.
public record RegisterRequest(
@Email String email,
@NotBlank @Size(min = 8) String password) {}
See Validation Intro, Common Constraints, and Handling Validation Errors.
Put transaction boundaries in the service layer
A transaction should wrap a complete business operation, which lives in the service — not in a controller (too coarse, holds connections during serialization) and not in a repository (too fine, can’t span calls). Mark read paths readOnly = true.
@Service
@Transactional // write transactions by default
public class OrderService {
@Transactional(readOnly = true) // read optimization
public OrderResponse get(Long id) { /* ... */ }
}
Note: Keep
open-in-view: false. The open-session-in-view default papers over lazy-loading boundaries and pushes queries into the view rendering phase, hurting performance and hiding bugs.
See Transactions.
Avoid the N+1 query trap
The most common JPA performance bug: lazily loading a collection in a loop fires one query per parent. Detect it by logging SQL, and fix it with a fetch join, an entity graph, or a batch fetch.
| Do | Don’t |
|---|---|
JOIN FETCH or @EntityGraph for needed associations | Loop over parents touching lazy children |
| Use projections for read-only views | Load full entity graphs to map a few fields |
| Page the parent query | findAll() an unbounded table |
@Query("select p from Post p join fetch p.comments where p.id = :id")
Optional<Post> findWithComments(@Param("id") Long id);
See N+1 Problem, Fetch Types, Projections, and Pagination with JPA.
Test in layers
Match the test type to what you’re verifying so the suite stays fast. Use sliced tests for most coverage and reserve the full context for true integration cases.
| Test type | Loads | Use for |
|---|---|---|
| Plain JUnit + Mockito | nothing | service logic in isolation |
@WebMvcTest | web layer only | controller mapping, validation, status codes |
@DataJpaTest | JPA + embedded DB | repository queries, mappings |
@SpringBootTest | full context | end-to-end wiring |
| Testcontainers | real DB in Docker | integration against production-like infra |
See Testing Intro and Integration Testing.
Secure by default
Lock down first, open up deliberately. Use the Spring Security 6 SecurityFilterChain bean with the lambda DSL, hash passwords with BCrypt, and prefer stateless JWT for APIs.
| Do | Don’t |
|---|---|
SecurityFilterChain + HttpSecurity DSL | WebSecurityConfigurerAdapter (removed) |
BCryptPasswordEncoder for passwords | Store or compare plain-text passwords |
permitAll() only the few public routes | permitAll() everything then patch later |
| Validate JWTs statelessly per request | Server-side sessions for a stateless API |
See Security Config, Password Encoding, Authorization, and JWT Authentication.
Build in observability
You can’t operate what you can’t see. Expose Actuator with liveness/readiness health checks and ship application metrics via Micrometer. Use structured logging and a correlation/trace ID.
management:
endpoints:
web:
exposure:
include: health, info, metrics, prometheus
endpoint:
health:
probes:
enabled: true # /actuator/health/liveness and /readiness
Shut down gracefully
In a containerized deploy, let in-flight requests finish before the process exits so deploys don’t drop traffic.
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
See Graceful Shutdown and Dockerizing.
Production checklist
Before you ship, confirm:
- Constructor injection everywhere; no field injection.
- No entity ever leaves a controller — DTOs only.
- One
@RestControllerAdvice; no stack traces in responses. - Secrets injected from the environment; nothing committed.
-
ddl-auto: validatewith Flyway (or Liquibase) owning the schema. -
open-in-view: false; hot paths checked for N+1. - Service-layer transactions; reads marked
readOnly. - Endpoints validated; pagination on every list endpoint.
- Security locked down; passwords BCrypt-hashed; HTTPS at the edge.
- Actuator health probes and metrics enabled.
- Graceful shutdown configured; image runs as non-root on a pinned JRE.
- Layered tests green in CI before the image build.
Tip: Treat this checklist as a PR template. Most production incidents trace back to one of these being skipped under deadline.