Why Do Spring Boot Projects Get Messier? 10 Common Anti‑Patterns Teams Fall Into
The article dissects ten frequent Spring Boot anti‑patterns—such as bloated controllers, SQL in services, overused @Autowired, exposing entities, misuse of @Transactional, N+1 queries, missing exception handling, hard‑coded configs, lack of caching, and careless logging—explaining why they degrade maintainability, performance, and testability, and shows how to refactor each with proper layered architecture, DTOs, repository abstraction, constructor injection, scoped transactions, eager fetching, global exception handling, externalized configuration, caching annotations, and structured logging.
Why Spring Boot projects become messy
Rapid feature delivery often leads teams to write code directly in controllers, call repositories everywhere, and skip a unified architecture. The short‑term speed creates long‑term problems: reduced maintainability, performance bottlenecks, difficult testing, and architectural decay.
Anti‑Pattern 1: Business logic in Controllers
@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();
}The controller performs four responsibilities—parameter validation, business rule checking, database access, and HTTP response—violating the Single Responsibility Principle (SRP). Over time this produces massive, untestable controllers and scattered business logic.
Correct approach: Controllers only handle HTTP and delegate to services.
/src/main/java/com/icoderoad
├── controller
│ └── UserController.java
├── service
│ └── UserService.java
├── repository
│ └── UserRepository.java
├── domain
│ └── User.java
└── dto
└── UserDTO.javaAnti‑Pattern 2: Writing SQL in Services
@Autowired
JdbcTemplate jdbcTemplate;
public List<User> listUsers(){
return jdbcTemplate.query("SELECT * FROM users", mapper);
}SQL is scattered, hard to maintain, and not reusable.
Correct approach: Use a Repository layer.
package com.icoderoad.repository;
import com.icoderoad.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {}Anti‑Pattern 3: Overusing @Autowired
@Autowired
private UserService userService;Field injection hides dependencies, makes testing harder, and violates immutability.
Recommended: Constructor injection.
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
}Anti‑Pattern 4: Exposing Entities Directly
@GetMapping
public List<User> list(){
return userRepository.findAll();
}Returning entities leaks database structure, couples API to internal fields, and introduces security risks.
Correct approach: Return DTOs.
public class UserDTO {
private String name;
private Integer age;
}
return users.stream()
.map(u -> new UserDTO(u.getName(), u.getAge()))
.toList();Anti‑Pattern 5: Misusing @Transactional
@Transactional
public void process(){
callApi();
saveDb();
}Remote calls inside a transaction extend lock time and degrade performance.
Correct flow: Open a transaction only for database writes.
Anti‑Pattern 6: N+1 Query Problem
List<User> users = userRepository.findAll();
for (User u : users) {
u.getOrders().size();
}This generates one query for users plus N queries for orders.
Solution: Use a fetch join.
@Query("select u from User u join fetch u.orders")
List<User> findAllWithOrders();Anti‑Pattern 7: No Global Exception Handling
try {
service.run();
} catch (Exception e) {
// swallow
}Scattered try‑catch blocks make error handling tangled.
Recommended: Centralized @RestControllerAdvice.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handle(Exception e) {
return ResponseEntity.internalServerError().body(e.getMessage());
}
}Anti‑Pattern 8: Hard‑Coded Configuration
String url = "http://localhost:8080/api";Correct: Externalize to application.yml and inject with @Value.
# application.yml
app:
api:
url: http://localhost:8080/api
@Value("${app.api.url}")
private String apiUrl;Anti‑Pattern 9: No Caching Strategy
public List<Product> list(){
return productRepository.findAll();
}Optimization: Add Spring Cache.
@Cacheable("products")
public List<Product> list(){
return productRepository.findAll();
}Anti‑Pattern 10: Unstructured Logging
System.out.println("user created");Correct: Use SLF4J logger with placeholders.
private static final Logger log = LoggerFactory.getLogger(UserService.class);
log.info("User created {}", userId);Recommended Spring Boot Architecture
/src/main/java/com/icoderoad
├── controller
├── service
├── repository
├── domain
├── dto
├── config
├── exception
└── utilEach layer has a clear responsibility: controllers handle HTTP, services contain business logic, repositories manage data access, DTOs hide entity details, and configuration/exception utilities support cross‑cutting concerns.
Key Takeaways
Controllers handle only HTTP.
Services contain business logic.
Repositories manage data access.
DTOs hide entity details from the API.
Transactions wrap only database writes.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
LuTiao Programming
LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
