Spring Boot best practices and Spring pitfalls
These are some of the best practices we collected in our Spring projects.
You can find a checklist at the end of the post.
These notes originate from real-world experience in business-critical projects.
They are primarily addressed to backend teams and can be used as a production readiness checklist.
Architecture and transaction boundaries
Disable Open Session in View: spring.jpa.open-in-view=false
By default, Spring Boot registers an OpenEntityManagerInViewInterceptor (spring.jpa.open-in-view=true).
With this option, Spring Boot binds a JPA EntityManager to the thread for the entire processing of the request.
This practice was useful in the past (using JSP and other view renderers) to avoid the LazyInitializationException when some entities were retrieved in a transaction and, in subsequent code, the application tried to access some lazy loaded entities linked to the result.
At this point the transaction was already closed and the entities detached.
For most applications this feature is not necessary anymore, some developers wanted to disable it by default in Spring Boot 2 already.
This triggered a debate between 'java gurus' regarding the utility and the performance impact of this binding.
I recommend starting a project setting
# Disable OSIV to prevent connection holding and N+1 issues
spring.jpa.open-in-view=false
and activate it only if really necessary. For REST applications with a transaction managed at service level, this interceptor should not be necessary.
De-activating open-in-view will avoid to bind an entity manager to the thread at every request and unbind it at the end of the request. The code concerned is in the org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptorclass.
Risks of keeping open-in-view enabled:
- Cause accidental N+1 queries in controllers
- Hide missing transactional boundaries
- Make performance debugging harder
Understand proxies and transactional behavior
Spring uses proxies for features like @Transactional.
Important implications:
- Self-invocation does not trigger transactional behavior
finalmethods cannot be proxied (with CGLIB that creates subclasses)- Multiple internal calls to
@Transactionalmethods won’t start new transactions
Understanding how proxies work prevents subtle production bugs.
Use Interfaces when is appropriate to avoid CGLIB
Interfaces allow clean code debugging and a faster and smaller deployment. Here you can find more details: https://marmo.dev/spring-interfaces-cglib
Benefits:
- Simpler debugging
- Fewer proxy limitations
- Cleaner separation of responsibilities
It’s not mandatory — but in layered architectures, it improves clarity.
In many projects we had big debates about interfaces, the team should decide the best approach for the project.
Configuration & Dependency Injection
Use Constructor Injection
Good
- the beans cannot be null;
- the object is immutable;
- the object can be defined final;
- in case the bean has only one constructor you can omit @Autowired;
- forces better design decisions and discourages oversized service classes.
If you are using lombok you can use @AllArgsConstructor or @RequiredArgsConstructor and simply declare your components as final fields (this is my favorite approach).
Lombok will create the constructor that will be used by Spring to inject the components.
Avoid Field Injection
Field injection (@Autowired):
- Hides dependencies
- Complicates testing
- Breaks immutability
- Encourages oversized classes
In unit tests field injection can be practical and justified.
Constructor injection forces better design decisions.
Prefer @ConfigurationProperties Over @Value
Spring Boot introduced the `@ConfigurationProperties annotation that is 'far more superior than the basic @Value approach' according to Stéphane Nicoll (Pivotal).
The advantages:
- You inject only an object a POJO and not a list of fields
- There is less risk to do typos in the declaration of the property
- The POJO is TypeSafe and can contain complex structures (e.g. 'database.configuration.mysql.connection')
- Adding Bean Validation (
spring-boot-starter-validation) you can use@Validatedon your config properties.
Here you can find the documentation:
- Spring Boot: Type-safe Configuration Properties
Instead of @Value("${app.timeout}") use @ConfigurationProperties(prefix = "app")
Always Explicitly Name Parameters for @RequestParam, @PathVariable and @Param
When working with Spring MVC and Spring Data JPA, it's a best practice to explicitly declare the names of your parameters in annotations like @RequestParam, @PathVariable, and @Param.
Spring MVC Example
@GetMapping("/users")
public String getUserById(@RequestParam("id") String userId) {
return "User ID: " + userId;
}
By specifying the parameter name ("id"), you're making the binding explicit. This ensures Spring correctly maps the request parameter to your method argument—regardless of how the code is compiled or whether the -parameters flag is used.
If you omit the name, Spring will attempt to infer it from the method signature, which only works if your code is compiled with -parameters.
While enabling -parameters allows Spring to infer names via reflection, it’s still safer and clearer to be explicit—especially in larger or collaborative codebases.
To enable '-parameters' in Maven:
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
Spring Data JPA Example
Not good (positional binding):
@Query("SELECT u FROM User u WHERE u.username = ?1 AND u.code = ?2")
List<User> findByUsernameAndCode(String username, String code);
Better (explicit named binding):
@Query("SELECT u FROM User u WHERE u.username = :username AND u.code = :code")
List<User> findByUsernameAndCode(@Param("username") String username, @Param("code") String code);
Using @Param with named placeholders makes it obvious which method argument maps to which part.
What is -parameters?
Java, by default, strips method parameter names during compilation unless you explicitly tell it not to.
public void greet(String name) { ... }
Without -parameters, reflection sees:
greet(java.lang.String arg0)
With -parameters, it sees:
greet(java.lang.String name)
This matters because Spring MVC and Spring Data JPA use reflection to map method parameters to inputs from HTTP requests or database queries.
Performance and scalability
Virtual Threads
If you are using Java 21 or later and Spring 3.2 you can activate Virtual Threads in Spring using: spring.threads.virtual.enabled=true
Spring will:
- configure Tomcat/Jetty to use virtual threads
- configure `@Async to use a virtual thread executor
If your application has a lot of web requests or is using @Async methods you could see an improvement in the performances.
Traditionally, each web request opens a new thread at OS level; this operation is expensive. With virtual threads the Java Runtime will create a light thread with improved utilization of the resources.
Virtual threads reduce the need to switch to a reactive stack (and co-routines in Kotlin) for many I/O-bound applications.
Server compression
This feature can be useful if your deployment doesn't use a proxy (e.g. nginx), we can ask Spring to compress the static assets and reduce the size of the files sent to the client.
Compression improves bandwidth usage but increases CPU usage — always measure under load.
# Enable response compression
server.compression.enabled=true
# Mime types that should be compressed
server.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
# Only compress responses larger than 8KB
server.compression.min-response-size=8192
# Caching for static resources
spring.web.resources.cache.cachecontrol.max-age=15768000
This works with embedded Tomcat, Jetty, or Undertow.
The best solution is still to use a dedicated proxy server (nginx, apache) or cloud providers (CloudFront, Cloudflare) with caching features.
You can find an example in my post: Docker with Angular and Nginx
Cache
Check if your view template engine supports caching in Spring and use it in case of benefits. Example, this blog mustache and the caching has to be activated: spring.mustache.servlet.cache=true
Monitor slow queries
You can easily log slow queries with Spring adding a threshold in the configuration:
# define the millisecons that will triggert the log
spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=300
logging.level.org.hibernate.SQL_SLOW=INFO
Document your REST APIs and the errors
Your APIs could be used by other teams, add some documentation to them. You can use Swagger or Spring Rest Doc.
In version 6.0 spring added the ProblemDetail class for an extended error response.
APIs
API Versioning
Spring Framework 6.2 (Spring Boot 3.4) introduced built-in API versioning support.
You can configure a versioning strategy (header, query param, or path) via WebMvcConfigurer.configureApiVersioning() and annotate handlers with @RequestMapping(version = "1.0").
This avoids home-grown versioning hacks and integrates cleanly with content negotiation.
Prefer header-based versioning (Api-Version: 2) to keep URLs stable; use path-based (/v2/) only if your API is consumed by clients that can't set headers.
API Rate Limiting
Unprotected APIs are vulnerable to abuse, scraping, and denial-of-service attacks.
Add rate limiting at the gateway level (Spring Cloud Gateway, nginx, Cloudflare) or inside the application using Bucket4j with a HandlerInterceptor.
Define limits per IP, per authenticated user, or per API key depending on your threat model.
Return 429 Too Many Requests with a Retry-After header so clients can back off gracefully.
Always apply stricter limits on write endpoints (POST, PUT, DELETE) than on read endpoints.
Java and code quality practices
Use records instead of beans for immutable data
Java records are ideal for:
- DTOs
- API responses
- Projection results
They:
- Reduce boilerplate
- Enforce immutability
- Improve readability
Hibernate doesn't support yet completely record for entities, limit them to immutable objects.
Null Safety with JSpecify (Spring Boot 4.0+)
Spring Boot 4.0 adopts JSpecify as its null annotation standard, replacing org.springframework.lang annotations. Annotating your packages with @NullMarked and using @Nullable on fields and return types that can be null allows your IDE and tools like NullAway to catch null dereferences at compile time. See the dedicated post: Spring Boot 4.0 Null Safety.
Stay up-to-date with Spring releases
You can find the dates of the releases here: https://calendar.spring.io, it's useful to check when some new releases are planned.
Don't expect that the latest bugfix release won't create issues, before deploying to production runs all the usual tests.
Even patch releases may introduce behavioral changes due to dependency upgrades. Spring in minor releases updated the dependent libraries, this could fix some CVE issue but break your application.
If you have some tools that check your code against CVEs, often you will desperately wait for the latest update of the libraries to close the internal tickets and escalations.
Monitor:
- Minor releases
- Dependency upgrades
- CVE patches
Best practice:
- Upgrade early in non-production environments
- Run integration tests
- Lock dependency versions
- Monitor breaking changes
Dependency management in Spring Boot can introduce indirect changes — always validate upgrades carefully.
Spring Boot Production Checklist
Use this checklist before going live or during architecture reviews.
Architecture & Transactions
- [ ]
spring.jpa.open-in-view=falseis explicitly configured - [ ] Transactions are defined at the service layer, not controllers
- [ ] No lazy-loading happens in controllers
- [ ] N+1 queries are checked (logs / Hibernate statistics)
- [ ] Fetch joins or DTO projections are used where appropriate
- [ ] Self-invocation of
@Transactionalmethods is avoided - [ ] Final methods are not annotated with
@Transactional
Dependency Injection & Configuration
- [ ] Constructor injection is used (no field injection in production code)
- [ ] All dependencies are
finalwhere possible - [ ] Single-constructor classes omit unnecessary
@Autowired - [ ]
@ConfigurationPropertiesis used instead of scattered@Value - [ ] Configuration classes are validated (
@Validatedwhen needed) - [ ] No configuration values are duplicated across services
API & Controller Layer
- [ ] All
@RequestParam,@PathVariable, and@Paramnames are explicit - [ ] Global error handling via
@ControllerAdviceis implemented - [ ] Error responses follow a consistent structure (e.g.
ProblemDetail) - [ ] APIs are documented (OpenAPI / Spring REST Docs)
- [ ] Sensitive data is never exposed in responses
- [ ] DTOs are used instead of exposing JPA entities
Performance & Scalability
- [ ] Virtual threads evaluated (Java 21+, Spring Boot 3.2+)
- [ ] Connection pool size matches expected concurrency
- [ ] Compression enabled (application or proxy level)
- [ ] Static resources cached
- [ ] Large responses tested with and without compression
- [ ] Caching strategy defined (if
@Cacheableis used) - [ ] Cache invalidation rules documented
Database & Persistence
- [ ] Proper indexes defined for frequently queried columns
- [ ] Slow query logging enabled
- [ ] Pagination used for list endpoints
- [ ] Entities are not directly returned in REST APIs
- [ ] Lazy vs eager loading strategy reviewed
Security & Stability
- [ ] Security configuration reviewed (Spring Security defaults validated)
- [ ] CSRF configuration verified (if applicable)
- [ ] Secrets not stored in plain configuration files
- [ ] Sensitive logs masked
- [ ] Rate limiting considered (if public API)
Observability & Monitoring
- [ ] Spring Boot Actuator enabled
- [ ] Health endpoints integrated with monitoring system
- [ ] Metrics exported (Micrometer / Prometheus)
- [ ] Request tracing enabled (correlation IDs)
- [ ] Logs centralized
- [ ] JVM memory and thread metrics monitored
Testing & Release Process
- [ ] Integration tests cover main flows
- [ ] Testcontainers used for DB integration tests (if possible)
- [ ] CI pipeline runs full test suite
- [ ] Dependency upgrades tested in staging
- [ ] CVE scanning integrated in CI
- [ ] Rollback strategy defined
Dependency & Version Management
- [ ] Spring Boot version explicitly defined
- [ ] Minor updates reviewed before upgrade
- [ ] Breaking changes checked in release notes
- [ ] Dependency tree reviewed after upgrades
- [ ] No unused dependencies
Code Quality
- [ ] No circular dependencies
- [ ] No business logic in controllers
- [ ] Records used for immutable DTOs (Java 16+)
- [ ] No oversized service classes (> ~300 lines)
- [ ] Clear package structure (controller/service/repository/domain)