Spring Boot helps developers move fast — but bad patterns kill performance, scalability, and maintainability silently.
Here’s a 2025-ready breakdown of real anti-patterns, why they’re dangerous, and how to fix them with clean, performant code.
⚠️ 1. Overusing @Transactional
on Everything
❌ Anti-Pattern:
@Transactional
public List<User> getAllUsers() {
return userRepository.findAll();
}
🔍 Problem:
Even read-only queries run inside unnecessary transactions, which:
- Lock resources
- Block threads
- Add overhead to the database
✅ Fix:
@Transactional(readOnly = true)
public List<User> getAllUsers() {
return userRepository.findAll();
}
🛠️ Explanation:
The readOnly = true
hint allows the database to optimize the query plan, avoids write locks, and improves throughput for concurrent reads.
⚠️ 2. Business Logic in Controllers
❌ Anti-Pattern:
@PostMapping("/users")
public ResponseEntity<?> create(@RequestBody UserDTO dto) {
if (dto.getAge() < 18) return ResponseEntity.badRequest().build();
userRepository.save(new User(dto.getName(), dto.getAge()));
return ResponseEntity.ok().build();
}
🔍 Problem:
Mixing logic with HTTP code:
- Hard to test
- Difficult to scale
- Breaks SRP (Single Responsibility Principle)
✅ Fix:
@Service
public class UserService {
public User createUser(UserDTO dto) {
if (dto.getAge() < 18) throw new IllegalArgumentException("Underage");
return userRepository.save(new User(dto.getName(), dto.getAge()));
}
}
🛠️ Explanation:
Separating logic into a service layer:
- Promotes clean architecture
- Enables reuse across APIs or scheduled jobs
- Makes unit testing easier
⚠️ 3. Blocking Calls in Reactive Code
❌ Anti-Pattern:
@GetMapping("/pdf")
public Mono<String> generatePdf() {
return Mono.just(createHeavyPdf()); // Blocking!
}
🔍 Problem:
Calling blocking I/O (e.g., file, DB, network) on Netty threads breaks the reactive model.
✅ Fix:
@GetMapping("/pdf")
public Mono<String> generatePdf() {
return Mono.fromCallable(this::createHeavyPdf)
.subscribeOn(Schedulers.boundedElastic());
}
🛠️ Explanation:
Schedulers.boundedElastic()
offloads blocking tasks to a separate thread pool — keeping the event loop free for I/O-bound processing.
⚠️ 4. Not Using Virtual Threads
❌ Anti-Pattern:
@Bean
public Executor asyncExecutor() {
return Executors.newFixedThreadPool(10); // Limited concurrency
}
✅ Fix (Java 22+):
@Bean
public Executor asyncExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
🛠️ Explanation:
Virtual threads are lightweight, memory-efficient, and allow thousands of concurrent tasks without blocking kernel threads — perfect for high-throughput apps.
⚠️ 5. Overusing EntityManager
Manually
❌ Anti-Pattern:
@PersistenceContext
private EntityManager em;
public List<User> getUsers() {
return em.createQuery("FROM User", User.class).getResultList();
}
🔍 Problem:
- Manual queries are error-prone.
- You miss Spring Data’s features (pagination, filters, query derivation).
✅ Fix:
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByStatus(String status);
}
🛠️ Explanation:
Spring Data JPA is optimized, tested, and handles:
- Transactions
- Paging
- Dynamic queries Better abstraction = fewer bugs, more performance.
⚠️ 6. @Cacheable
Without Expiry
❌ Anti-Pattern:
@Cacheable("products")
public Product getProduct(Long id) {
return productRepo.findById(id).orElseThrow();
}
🔍 Problem:
Caches can grow unbounded, consuming memory.
✅ Fix:
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("products");
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000));
return cacheManager;
}
🛠️ Explanation:
Adds TTL (Time to Live) and max entries — preventing memory leaks and stale data. Caffeine is ultra-fast and supports async refresh.
⚠️ 7. Default Tomcat Thread Pool
❌ Anti-Pattern:
# Default max-threads = 200
🔍 Problem:
Your API could underperform under high load — threads wait in queue, requests time out.
✅ Fix:
server.tomcat.max-threads=500
server.tomcat.accept-count=100
server.connection-timeout=10s
🛠️ Explanation:
-
max-threads
: how many concurrent requests can be served -
accept-count
: queued connections -
connection-timeout
: prevents hanging sockets
Tuning Tomcat = faster concurrency handling
⚠️ 8. Overlapping Scheduled Jobs
❌ Anti-Pattern:
@Scheduled(fixedRate = 5000)
public void sendEmails() {
// Takes 10s to run = jobs overlap = CPU spike
}
✅ Fix with ShedLock:
@Scheduled(fixedRate = 5000)
@SchedulerLock(name = "emailJob", lockAtLeastFor = "PT5S")
public void sendEmails() {
// Run once, skip overlap
}
🛠️ Explanation:
ShedLock uses distributed locks (e.g., Redis, DB) to prevent concurrent job runs — crucial for long-running tasks.
⚠️ 9. No Observability
❌ Anti-Pattern:
No logs, no metrics, no traces:
// How do we know if it’s slow? 🤷
✅ Fix:
Micrometer + Prometheus + Grafana
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
management.endpoints.web.exposure.include=health,info,metrics,prometheus
🛠️ Explanation:
Gives real-time visibility into:
- API latency
- JVM memory
- Cache hit/miss
- DB time
Bonus: Add OpenTelemetry for distributed tracing across microservices.
🧠 Final Thoughts
Even in 2025, these old habits still slow down modern Spring Boot apps.
✅ Virtual threads
✅ Reactive practices
✅ Caching strategies
✅ Observability
✅ Clean architecture
Start reviewing your codebase for these traps — and refactor for performance, scalability, and developer sanity.
💬 What Anti-Pattern Do You See Most?
Comment below with real issues you've solved (or still suffer with). Let’s build a community playbook to clean up enterprise Spring Boot!
Top comments (0)