Look, I love Spring Boot. I really do. But after three years of inheriting other people's "quick prototypes" that somehow made it to production, I need to get some things off my chest.
As much as there is no one best way to write a Spring Boot application, you should not put ALL the business logic in controllers. Not some of it. ALL of it. 300-line controller methods with database queries, email sending, file processing, and probably their grocery list somewhere in there too.
If you're doing this, please stop. Your teammates are talking about you behind your back, and not in a good way.
Here's how to write Spring Boot code that won't make people question your life choices.
Your Package Structure Shouldn't Look Like a Tornado Hit It
I've seen Spring Boot projects where everything lives in one package. Just… everything. Controllers next to entities next to random utility classes. It's like walking into someone's apartment and finding their underwear in the kitchen.
Here's what sane people do:
com.yourcompany.yourapp
├── config/ # Spring configurations
├── controller/ # HTTP stuff only
├── service/ # Your actual business logic
├── repository/ # Database operations
├── model/ # Your data structures
├── exception/ # Custom exceptions
└── util/ # Helper methodsThis isn't rocket science. When I need to understand how user registration works, I shouldn't have to play hide-and-seek across your entire codebase.
Your controller should handle HTTP requests and responses. That's it. If you're writing SQL queries in your controller, you're doing it wrong. If you're sending emails from your controller, you're doing it wrong. If your controller knows what a database is, you're probably doing it wrong.
Stop Using @Autowired on Fields (Seriously)
@Service
public class UserService {
@Autowired
private UserRepository userRepository; // NO
@Autowired
private EmailService emailService; // STOP
@Autowired
private PasswordEncoder encoder; // WHY ARE YOU LIKE THIS
}This makes my eye twitch. You know what happens when you write tests for this? Nothing. Well, nothing good anyway. You either end up with integration tests for everything (slow) or you discover that Spring's magic isn't so magical in your test environment.
Do this instead:
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
private final PasswordEncoder encoder;
public UserService(UserRepository userRepository,
EmailService emailService,
PasswordEncoder encoder) {
this.userRepository = userRepository;
this.emailService = emailService;
this.encoder = encoder;
}
}Now I can actually test your code without starting up half of Spring. Your dependencies are explicit, your class is immutable, and I don't hate you anymore.
Configuration Hell is Real
I once worked on a project where configuration values were scattered across:
- Property files
- Environment variables
- Hard-coded strings in the code
- A random JSON file someone found on StackOverflow
- Probably cave paintings
Don't be that person.
# application.yml
server:
port: ${SERVER_PORT:8080}
app:
name: ${APP_NAME:MyAwesomeApp}
email:
enabled: ${EMAIL_ENABLED:true}
smtp-host: ${SMTP_HOST:localhost}
from-address: ${FROM_EMAIL:noreply@example.com}And then create a proper configuration class:
@ConfigurationProperties(prefix = "app")
@Data
@Component
public class AppConfig {
private String name;
private Email email = new Email();
@Data
public static class Email {
private boolean enabled = true;
private String smtpHost = "localhost";
private String fromAddress = "noreply@example.com";
}
}Your IDE will love you. Your deployment scripts will love you. The person who has to debug your app at 2 AM will love you.
Your Controllers Are Not God Objects
Here's an actual controller method I found in production code:
@PostMapping("/users")
public ResponseEntity<?> createUser(@RequestBody Map<String, Object> request) {
// 47 lines of validation logic
// 23 lines of database queries
// 15 lines of email sending
// 8 lines of file processing
// 1 line of actual HTTP response
return ResponseEntity.ok("User created maybe?");
}This isn't a controller method. This is a small novel that happens to respond to HTTP requests.
Controllers should be boring:
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
User user = userService.createUser(request);
UserResponse response = UserResponse.from(user);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(user -> ResponseEntity.ok(UserResponse.from(user)))
.orElse(ResponseEntity.notFound().build());
}
}See how boring that is? That's good boring. I can read it without getting a headache.
Exception Handling That Doesn't Suck
Nothing says "amateur hour" like random exception stacktraces leaking to your API responses. Your users don't care about your NullPointerException on line 47 of UserServiceImpl.java.
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
log.info("User not found: {}", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("USER_NOT_FOUND", "User not found"));
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidation(ValidationException ex) {
log.warn("Validation failed: {}", ex.getMessage());
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION_ERROR", ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "Something went wrong"));
}
}Now your API responds with consistent error messages, you're logging the important stuff, and your users aren't seeing Java stacktraces. Everyone wins.
Testing Is Not Optional
"We don't have time for tests" is what someone says right before they spend three weeks debugging production issues that a simple unit test would have caught.
I don't want to see this:
@SpringBootTest
public class UserServiceTest {
@Test
public void testEverything() {
// 200 lines of test code that tests everything at once
// Good luck figuring out what broke when this fails
}
}I want to see this:
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock private UserRepository userRepository;
@Mock private EmailService emailService;
@InjectMocks private UserService userService;
@Test
void shouldCreateUserWithValidRequest() {
// Given
CreateUserRequest request = new CreateUserRequest("John", "john@example.com");
User savedUser = new User(1L, "John", "john@example.com");
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// When
User result = userService.createUser(request);
// Then
assertThat(result.getName()).isEqualTo("John");
verify(emailService).sendWelcomeEmail(savedUser);
}
@Test
void shouldThrowExceptionWhenEmailAlreadyExists() {
// Given
CreateUserRequest request = new CreateUserRequest("John", "john@example.com");
when(userRepository.existsByEmail("john@example.com")).thenReturn(true);
// When & Then
assertThatThrownBy(() -> userService.createUser(request))
.isInstanceOf(EmailAlreadyExistsException.class);
}
}Fast, focused, and actually useful when something breaks.
Performance Isn't Just an Afterthought
I've seen applications that fetch individual users in a loop instead of using a single query. I've seen apps that hit the database 100 times to render one page. I've seen caching strategies that would make a Comp Science professor cry.
Your database connection pool shouldn't be the default settings you copied from a tutorial:
spring:
datasource:
hikari:
maximum-pool-size: 20 # Not 100
minimum-idle: 5 # Not 10
connection-timeout: 30000 # 30 seconds, not forever
idle-timeout: 600000 # 10 minutesUse caching, but use it wisely:
@Service
public class UserService {
@Cacheable(value = "users", key = "#id")
public Optional<User> findById(Long id) {
log.debug("Fetching user {} from database", id);
return userRepository.findById(id);
}
@CacheEvict(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
}Don't cache everything. Don't cache nothing. Cache the right things.
Security Isn't Magic Pixie Dust
Putting @PreAuthorize("hasRole('USER')") on every method doesn't make your app secure any more than putting a "Security Enabled" sticker on your laptop makes it hack-proof.
Configure security properly:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Only for APIs
.authorizeHttpRequests(auth -> auth
.requestMatchers("/health", "/metrics").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/**").hasRole("USER")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()))
.build();
}
}And please, for the love of all that's holy, don't put passwords in your application.yml file.
Logging That Actually Helps
log.info("User created"); // Uselessvs
log.info("Created user {} with email {} in {}ms", user.getId(), user.getEmail(), duration);When your app breaks at 3 AM, you'll thank me for the second one.
Also, use the right log levels:
- ERROR: Something broke and needs immediate attention
- WARN: Something's not right but the app is still working
- INFO: Important business events (user created, order processed)
- DEBUG: Detailed flow information for debugging
Don't log everything at INFO level. Your log files will become useless noise.
Database Migrations Aren't Optional
I don't care if you're "just prototyping." Use Flyway or Liquibase. Future you will thank present you when you need to deploy changes without losing data.
-- V1__Create_user_table.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- V2__Add_user_status.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'ACTIVE';Version your database changes. Always.
Documentation That Doesn't Lie
Your README should have:
- How to run the app locally
- How to run tests
- What the app actually does
- API documentation (use OpenAPI/Swagger)
If I clone your repo and can't get it running in 10 minutes, your documentation is bad.
@Operation(summary = "Create a new user")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "User created"),
@ApiResponse(responseCode = "400", description = "Invalid request")
})
@PostMapping
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
// Your boring controller logic here
}The Bottom Line
Spring Boot is a fantastic framework when used properly. But it's not magic. It won't fix bad architecture, poor error handling, or nonexistent testing.
The difference between a junior developer and a senior developer isn't how many frameworks they know. It's whether they can build maintainable systems that don't make their coworkers want to quit.
Write code like the person who maintains it is a violent psychopath who knows where you live. Because that person might be you in six months, and you'll be really angry at past you.
Your future self, your teammates, and anyone who has to debug your code at 2 AM will thank you for following these practices. And if they don't, well, at least you'll be able to sleep better knowing you didn't contribute to the world's collection of terrible Spring Boot applications.
Now go refactor that 300-line controller method. You know the one.
Have you inherited Spring Boot code that made you question humanity? Share your horror stories in the comments. Misery loves company.