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 methods

This 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 minutes

Use 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"); // Useless

vs

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.