Spring Boot

Spring Boot is Spring without the XML. Where plain Spring required hundreds of lines of configuration to wire up a web application, Spring Boot detects what’s on your classpath and configures it automatically. Add spring-boot-starter-web and you have an embedded Tomcat, Jackson for JSON, and a DispatcherServlet — configured correctly out of the box.

The trade-off for that convenience is magic. Something is auto-configured, it doesn’t work quite right, and you’re in the documentation trying to figure out what spring.jpa.open-in-view does. Understanding the conventions helps far more than fighting them.


🟢 Junior

Project setup

The fastest way to start is start.spring.io — select your dependencies and it generates a project. The resulting pom.xml or build.gradle handles the rest.

The entry point is a class with @SpringBootApplication:

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

@SpringBootApplication is shorthand for three annotations: @Configuration (this class defines beans), @EnableAutoConfiguration (trigger auto-config based on classpath), and @ComponentScan (find components in this package and subpackages).

REST controllers

@RestController combines @Controller and @ResponseBody — every method return value is serialized to JSON and written directly to the HTTP response.

@RestController
@RequestMapping("/api/v1/users")
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public List<UserDto> list() {
        return userService.listAll();
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserDto> get(@PathVariable Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserDto create(@Valid @RequestBody CreateUserRequest req) {
        return userService.create(req);
    }

    @PatchMapping("/{id}")
    public UserDto update(@PathVariable Long id, @Valid @RequestBody UpdateUserRequest req) {
        return userService.update(id, req);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        userService.delete(id);
    }
}

Prefer constructor injection over @Autowired on fields. Constructor injection makes dependencies explicit, allows immutable fields, and works correctly in tests without reflection.

Validation

Add spring-boot-starter-validation and annotate your request DTOs:

public class CreateUserRequest {
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100)
    private String name;

    @Email(message = "Must be a valid email")
    @NotBlank
    private String email;

    @NotBlank
    @Size(min = 8, message = "Password must be at least 8 characters")
    private String password;
}

@Valid on the @RequestBody parameter triggers validation. Validation failures throw MethodArgumentNotValidException, which Spring’s default error handling turns into a 400 response. Add a @ControllerAdvice to customize the error shape.

Configuration properties

Put application config in application.yml:

server:
  port: 8080

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: ${DB_USER}
    password: ${DB_PASS}
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false

Map custom properties to a type-safe configuration class:

@ConfigurationProperties(prefix = "app")
@Configuration
public class AppProperties {
    private String apiKey;
    private Duration sessionTimeout = Duration.ofMinutes(30);
    private List<String> allowedOrigins = List.of();

    // getters and setters
}
app:
  api-key: ${API_KEY}
  session-timeout: 60m
  allowed-origins:
    - https://app.example.com
    - https://admin.example.com

🟡 Medior

Data access with Spring Data JPA

JpaRepository provides CRUD and pagination out of the box. Add query methods by following the naming convention, or write JPQL:

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
    List<User> findByActiveTrueOrderByCreatedAtDesc();

    @Query("SELECT u FROM User u WHERE u.department = :dept AND u.salary > :min")
    List<User> findHighEarnersInDept(@Param("dept") String dept, @Param("min") double min);

    @Modifying
    @Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :cutoff")
    int deactivateInactive(@Param("cutoff") LocalDateTime cutoff);

    Page<User> findAll(Pageable pageable);
}

Pagination: pass PageRequest.of(page, size, Sort.by("createdAt").descending()) to any method that accepts Pageable. The result is a Page<T> with content, total count, and metadata.

Transactions

Mark service methods that write to the database with @Transactional. Spring wraps the method in a transaction — if it throws a RuntimeException, the transaction rolls back:

@Service
@Transactional(readOnly = true)
public class UserService {
    private final UserRepository userRepo;
    private final ApplicationEventPublisher events;

    public UserService(UserRepository userRepo, ApplicationEventPublisher events) {
        this.userRepo = userRepo;
        this.events = events;
    }

    public UserDto findById(Long id) {
        return userRepo.findById(id)
            .map(UserDto::from)
            .orElseThrow(() -> new UserNotFoundException(id));
    }

    @Transactional
    public UserDto create(CreateUserRequest req) {
        if (userRepo.existsByEmail(req.getEmail())) {
            throw new ConflictException("Email already registered");
        }
        User saved = userRepo.save(new User(req));
        events.publishEvent(new UserCreatedEvent(saved));
        return UserDto.from(saved);
    }
}

readOnly = true on the class sets a default — all methods are read-only unless overridden. Read-only transactions hint to the JPA provider to skip dirty checking and can use read replicas.

Error handling

A @ControllerAdvice is the standard way to handle exceptions across all controllers in one place:

@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(UserNotFoundException ex) {
        return ResponseEntity.status(404)
            .body(new ErrorResponse("NOT_FOUND", ex.getMessage()));
    }

    @ExceptionHandler(ConflictException.class)
    public ResponseEntity<ErrorResponse> handleConflict(ConflictException ex) {
        return ResponseEntity.status(409)
            .body(new ErrorResponse("CONFLICT", ex.getMessage()));
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
        MethodArgumentNotValidException ex,
        HttpHeaders headers,
        HttpStatusCode status,
        WebRequest request
    ) {
        Map<String, String> errors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors()
            .forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
        return ResponseEntity.badRequest()
            .body(new ValidationErrorResponse("VALIDATION_FAILED", errors));
    }
}

Spring Security basics

spring-boot-starter-security auto-configures HTTP Basic auth with a generated password. Override it by providing a SecurityFilterChain bean:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**", "/actuator/health").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

🔴 Senior

Spring Events for decoupled side effects

Publishing an event from within a transaction is a clean way to trigger side effects — sending an email, creating audit records, updating a cache — without coupling those concerns to your service:

public record UserCreatedEvent(User user) {}

@Service
public class UserService {
    @Transactional
    public User create(CreateUserRequest req) {
        User user = userRepo.save(new User(req));
        events.publishEvent(new UserCreatedEvent(user));
        return user;
    }
}

@Component
public class UserCreatedListener {
    @EventListener
    @Async
    public void sendWelcomeEmail(UserCreatedEvent event) {
        emailService.sendWelcome(event.user().getEmail());
    }

    @TransactionalEventListener(phase = AFTER_COMMIT)
    public void updateSearchIndex(UserCreatedEvent event) {
        searchService.index(event.user());
    }
}

@TransactionalEventListener with AFTER_COMMIT only fires if the transaction that published the event committed successfully. Use this when the side effect shouldn’t run if the main transaction rolled back.

Actuator and observability

spring-boot-starter-actuator exposes health, metrics, and application info endpoints:

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus
  endpoint:
    health:
      show-details: when-authorized
  metrics:
    export:
      prometheus:
        enabled: true

Integrate with Micrometer for custom metrics:

@Service
public class OrderService {
    private final Counter ordersCreated;
    private final Timer orderProcessingTime;

    public OrderService(MeterRegistry registry) {
        this.ordersCreated = Counter.builder("orders.created")
            .description("Total orders created")
            .register(registry);
        this.orderProcessingTime = Timer.builder("order.processing.time")
            .register(registry);
    }

    public Order create(CreateOrderRequest req) {
        return orderProcessingTime.record(() -> {
            Order order = processOrder(req);
            ordersCreated.increment();
            return order;
        });
    }
}

Senior Gotchas

@Transactional on private methods is silently ignored. Spring uses proxy-based AOP — private methods bypass the proxy. The transaction annotation has no effect. Extract to a separate bean if you need a transaction on a private-like operation.

Self-invocation breaks @Transactional. Calling a @Transactional method from another method in the same class bypasses the proxy and runs outside a transaction. Inject self (a reference to the proxied bean) or extract the method to a separate class.

open-in-view: false is correct for APIs. The default is true, which keeps the JPA session open for the entire HTTP request lifecycle. This causes lazy loading to work in unexpected places, masks N+1 problems, and holds database connections longer than necessary. Always set it to false.

ddl-auto: create-drop in production will delete your data. The safe setting is validate — it checks that the schema matches your entities but doesn’t modify it. Use Flyway or Liquibase for schema migrations.

@SpringBootTest loads the full application context. It’s slow. For unit tests, use @ExtendWith(MockitoExtension.class) and mock dependencies directly. Use @WebMvcTest to test just the web layer. Reserve @SpringBootTest for actual integration tests.

Bean overriding is disabled by default since Spring Boot 2.1. If you register two beans of the same type and expect one to override the other, you’ll get a startup failure. Set spring.main.allow-bean-definition-overriding=true or redesign to avoid the conflict.