Skip to content
Spring Boot best practices 6 min read

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.

DoDon’t
Put business logic in the service layerPut 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.

DoDon’t
Return record ProductResponse(...)Return the @Entity Product
Accept a validated request DTOBind request bodies straight onto entities
Map in the service or a mapperSerialize 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
DoDon’t
@ConfigurationProperties typed bindingScatter @Value strings everywhere
Secrets via env vars / vaultCommit passwords in application.yml
Profiles for dev / test / prodOne 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.

DoDon’t
JOIN FETCH or @EntityGraph for needed associationsLoop over parents touching lazy children
Use projections for read-only viewsLoad full entity graphs to map a few fields
Page the parent queryfindAll() 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 typeLoadsUse for
Plain JUnit + Mockitonothingservice logic in isolation
@WebMvcTestweb layer onlycontroller mapping, validation, status codes
@DataJpaTestJPA + embedded DBrepository queries, mappings
@SpringBootTestfull contextend-to-end wiring
Testcontainersreal DB in Dockerintegration 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.

DoDon’t
SecurityFilterChain + HttpSecurity DSLWebSecurityConfigurerAdapter (removed)
BCryptPasswordEncoder for passwordsStore or compare plain-text passwords
permitAll() only the few public routespermitAll() everything then patch later
Validate JWTs statelessly per requestServer-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: validate with 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.

Last updated June 13, 2026
Was this helpful?