Spring Boot Interview Guide: From Core Concepts to Production

·17 min read
spring-bootjavabackendspring-frameworkrest-apiinterview-preparation

Spring Boot changed how Java developers build applications. Before Spring Boot, setting up a Spring project meant days of XML configuration, dependency version conflicts, and server deployment hassles. Now you can have a production-ready REST API running in minutes.

That convenience comes with hidden complexity. Spring Boot does so much automatically that many developers don't understand what's happening beneath the surface. Interviewers know this. They'll ask you to explain auto-configuration, describe the bean lifecycle, or troubleshoot why your @Transactional annotation isn't working. Surface-level knowledge gets exposed quickly.

This guide covers Spring Boot from fundamentals to production patterns - the concepts that separate developers who use Spring Boot from developers who understand it.


Spring Core Fundamentals

Before Spring Boot, there was Spring Framework. Understanding core Spring concepts is essential because Spring Boot builds directly on them.

Inversion of Control (IoC)

IoC is the foundational principle. Instead of your code controlling object creation, you invert that control to a container. The Spring IoC container creates objects, wires dependencies, and manages lifecycles.

// Without IoC: Your code controls dependencies
public class OrderService {
    private PaymentService paymentService;
 
    public OrderService() {
        // You create the dependency - tight coupling
        this.paymentService = new StripePaymentService();
    }
}
 
// With IoC: Container controls dependencies
@Service
public class OrderService {
    private final PaymentService paymentService;
 
    // Container injects the dependency
    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

Interview question: "Why is IoC beneficial?"

The answer goes beyond "loose coupling." IoC enables:

  • Testability: Inject mock implementations for unit testing
  • Flexibility: Swap implementations via configuration, not code changes
  • Lifecycle management: Container handles creation, initialization, destruction
  • AOP integration: Container can wrap beans with cross-cutting concerns

Dependency Injection Types

Spring supports three injection types. Know when to use each:

// Constructor injection (RECOMMENDED)
@Service
public class OrderService {
    private final PaymentService paymentService;
    private final InventoryService inventoryService;
 
    // All dependencies declared, immutable, testable
    public OrderService(PaymentService paymentService,
                        InventoryService inventoryService) {
        this.paymentService = paymentService;
        this.inventoryService = inventoryService;
    }
}
 
// Setter injection (optional dependencies)
@Service
public class NotificationService {
    private EmailService emailService;
 
    @Autowired(required = false)
    public void setEmailService(EmailService emailService) {
        this.emailService = emailService;
    }
}
 
// Field injection (AVOID in production code)
@Service
public class BadExample {
    @Autowired
    private PaymentService paymentService; // Hidden dependency, hard to test
}

Interview question: "Why is constructor injection preferred?"

  • Dependencies are explicit and documented in the constructor signature
  • Objects are immutable after construction (final fields)
  • Tests can instantiate the class directly without Spring context
  • Prevents circular dependencies at compile time (mostly)
  • Required dependencies are enforced - no NullPointerException surprises

Bean Lifecycle

Understanding the bean lifecycle helps you debug initialization issues and use lifecycle hooks correctly.

Container starts
    ↓
Bean instantiation (constructor called)
    ↓
Dependency injection (setters, fields)
    ↓
@PostConstruct method called
    ↓
InitializingBean.afterPropertiesSet() if implemented
    ↓
Custom init-method if specified
    ↓
Bean is ready for use
    ↓
... application runs ...
    ↓
@PreDestroy method called
    ↓
DisposableBean.destroy() if implemented
    ↓
Custom destroy-method if specified
    ↓
Container shuts down
@Service
public class CacheService {
    private final CacheClient cacheClient;
 
    public CacheService(CacheClient cacheClient) {
        this.cacheClient = cacheClient;
        // DON'T do heavy initialization here
        // Dependencies might not be fully initialized
    }
 
    @PostConstruct
    public void initialize() {
        // Safe to use injected dependencies
        cacheClient.connect();
        cacheClient.warmUp();
    }
 
    @PreDestroy
    public void cleanup() {
        cacheClient.disconnect();
    }
}

@Component vs @Bean

Both create Spring-managed beans, but they serve different purposes:

// @Component: Annotate YOUR classes
@Component
public class MyService {
    // Spring scans and registers this bean
}
 
// @Bean: Create beans from THIRD-PARTY classes or complex construction
@Configuration
public class AppConfig {
 
    @Bean
    public RestTemplate restTemplate() {
        // Can't annotate RestTemplate with @Component - it's not your code
        RestTemplate template = new RestTemplate();
        template.setRequestFactory(new HttpComponentsClientHttpRequestFactory());
        return template;
    }
 
    @Bean
    public ObjectMapper objectMapper() {
        // Complex configuration that doesn't belong in a constructor
        return new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }
}

Interview question: "When would you use @Bean instead of @Component?"

  • Third-party classes you can't annotate
  • Conditional bean creation with complex logic
  • Multiple beans of the same type with different configurations
  • Beans requiring programmatic setup

Spring Boot Auto-Configuration

Auto-configuration is Spring Boot's killer feature - and one of the most misunderstood. Understanding how it works separates competent developers from those who just copy configurations.

How Auto-Configuration Works

When your application starts, Spring Boot:

  1. Scans META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports in all JARs
  2. Evaluates @Conditional annotations on each auto-configuration class
  3. Registers beans for configurations where conditions pass
// Simplified example of what auto-configuration looks like internally
@AutoConfiguration
@ConditionalOnClass(DataSource.class)  // Only if DataSource is on classpath
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
 
    @Bean
    @ConditionalOnMissingBean  // Only if no DataSource bean exists
    public DataSource dataSource(DataSourceProperties properties) {
        return DataSourceBuilder.create()
            .url(properties.getUrl())
            .username(properties.getUsername())
            .password(properties.getPassword())
            .build();
    }
}

The @Conditional annotations are key:

AnnotationCondition
@ConditionalOnClassClass exists on classpath
@ConditionalOnMissingClassClass doesn't exist on classpath
@ConditionalOnBeanBean of type exists in context
@ConditionalOnMissingBeanNo bean of type exists
@ConditionalOnPropertyProperty has specific value
@ConditionalOnWebApplicationRunning as web application

Starter Dependencies

Starters are curated dependency sets that trigger auto-configuration:

<!-- This single dependency brings in: -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
 
<!--
- spring-webmvc (Spring MVC)
- spring-boot-starter-tomcat (embedded server)
- spring-boot-starter-json (Jackson)
- spring-boot-starter (core + logging)
 
And auto-configures:
- DispatcherServlet
- Error handling
- HTTP message converters
- Static resource handling
-->

Interview question: "You add spring-boot-starter-data-jpa but your application fails to start with 'no DataSource configured.' Why?"

The JPA starter brings in DataSource auto-configuration, but auto-configuration needs connection details. Without spring.datasource.url in properties, the @ConditionalOnProperty conditions fail.

Customizing Auto-Configuration

Three ways to customize, from simple to advanced:

1. Properties (most common)

# application.properties
server.port=8081
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.jpa.hibernate.ddl-auto=validate

2. Define your own bean (overrides auto-configured bean)

@Configuration
public class CustomConfig {
 
    @Bean
    public ObjectMapper objectMapper() {
        // Auto-configuration sees this bean exists
        // and skips its own ObjectMapper creation
        return new ObjectMapper()
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }
}

3. Exclude auto-configuration entirely

@SpringBootApplication(exclude = {
    DataSourceAutoConfiguration.class,
    SecurityAutoConfiguration.class
})
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

Debugging Auto-Configuration

When things don't work, enable the debug report:

debug=true

This prints a conditions evaluation report showing:

  • Positive matches: What was auto-configured and why
  • Negative matches: What wasn't configured and why
  • Exclusions: What you explicitly excluded

REST API Development

Spring Boot makes REST API development straightforward, but interviews probe deeper than basic CRUD operations.

Controller Fundamentals

@RestController
@RequestMapping("/api/users")
public class UserController {
 
    private final UserService userService;
 
    public UserController(UserService userService) {
        this.userService = userService;
    }
 
    @GetMapping
    public List<UserDTO> getAllUsers(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        return userService.findAll(PageRequest.of(page, size));
    }
 
    @GetMapping("/{id}")
    public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
        return userService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
 
    @PostMapping
    public ResponseEntity<UserDTO> createUser(
            @Valid @RequestBody CreateUserRequest request) {
        UserDTO created = userService.create(request);
        URI location = URI.create("/api/users/" + created.getId());
        return ResponseEntity.created(location).body(created);
    }
 
    @PutMapping("/{id}")
    public ResponseEntity<UserDTO> updateUser(
            @PathVariable Long id,
            @Valid @RequestBody UpdateUserRequest request) {
        return userService.update(id, request)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
 
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        if (userService.delete(id)) {
            return ResponseEntity.noContent().build();
        }
        return ResponseEntity.notFound().build();
    }
}

Interview question: "What's the difference between @Controller and @RestController?"

@RestController = @Controller + @ResponseBody. Every method in a @RestController automatically serializes return values to the response body (typically JSON). With plain @Controller, you'd need @ResponseBody on each method or return ModelAndView for templates.

Request Validation

Spring Boot integrates with Jakarta Bean Validation (formerly javax.validation):

public class CreateUserRequest {
 
    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;
 
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be 2-100 characters")
    private String name;
 
    @NotBlank(message = "Password is required")
    @Size(min = 8, message = "Password must be at least 8 characters")
    @Pattern(regexp = ".*[A-Z].*", message = "Password must contain uppercase letter")
    private String password;
 
    // getters, setters
}

The @Valid annotation on the controller parameter triggers validation. If validation fails, Spring throws MethodArgumentNotValidException.

Global Exception Handling

Production APIs need consistent error responses. Use @ControllerAdvice:

@ControllerAdvice
public class GlobalExceptionHandler {
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex) {
 
        List<FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new FieldError(
                error.getField(),
                error.getDefaultMessage()))
            .toList();
 
        ErrorResponse response = new ErrorResponse(
            "VALIDATION_ERROR",
            "Request validation failed",
            fieldErrors
        );
 
        return ResponseEntity.badRequest().body(response);
    }
 
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            ResourceNotFoundException ex) {
 
        ErrorResponse response = new ErrorResponse(
            "NOT_FOUND",
            ex.getMessage(),
            null
        );
 
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }
 
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
        // Log the full exception for debugging
        log.error("Unexpected error", ex);
 
        // Return generic message to client (don't leak internals)
        ErrorResponse response = new ErrorResponse(
            "INTERNAL_ERROR",
            "An unexpected error occurred",
            null
        );
 
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(response);
    }
}

ResponseEntity Best Practices

Interview question: "When would you return ResponseEntity vs just the object?"

Return the object directly when you always return 200 OK:

@GetMapping("/health")
public HealthStatus health() {
    return new HealthStatus("UP");  // Always 200
}

Use ResponseEntity when status codes vary:

@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
    return userRepository.findById(id)
        .map(ResponseEntity::ok)           // 200 if found
        .orElse(ResponseEntity.notFound().build());  // 404 if not
}
 
@PostMapping
public ResponseEntity<User> create(@RequestBody User user) {
    User saved = userRepository.save(user);
    URI location = URI.create("/users/" + saved.getId());
    return ResponseEntity.created(location).body(saved);  // 201 with Location header
}

Data Access with Spring Data

Spring Data JPA eliminates boilerplate for database operations. But understanding what happens behind the scenes is crucial for performance and debugging.

Repository Pattern

// Basic repository - you get CRUD for free
public interface UserRepository extends JpaRepository<User, Long> {
    // No implementation needed for basic operations
}
 
// Query methods - Spring generates queries from method names
public interface UserRepository extends JpaRepository<User, Long> {
 
    Optional<User> findByEmail(String email);
 
    List<User> findByStatus(UserStatus status);
 
    List<User> findByCreatedAtAfter(LocalDateTime date);
 
    List<User> findByNameContainingIgnoreCase(String namePart);
 
    // Combining conditions
    List<User> findByStatusAndCreatedAtAfter(
        UserStatus status,
        LocalDateTime date
    );
 
    // Limiting results
    List<User> findTop10ByOrderByCreatedAtDesc();
 
    // Counting
    long countByStatus(UserStatus status);
 
    // Existence check
    boolean existsByEmail(String email);
}

Interview question: "How does Spring Data generate the query from findByEmailAndStatus?"

Spring Data parses the method name:

  • find...By indicates a SELECT query
  • Email maps to the email property
  • And combines conditions
  • Status maps to the status property

It generates: SELECT u FROM User u WHERE u.email = ?1 AND u.status = ?2

Custom Queries

When method names get unwieldy, use @Query:

public interface OrderRepository extends JpaRepository<Order, Long> {
 
    // JPQL query
    @Query("SELECT o FROM Order o WHERE o.user.id = :userId AND o.status = :status")
    List<Order> findUserOrdersByStatus(
        @Param("userId") Long userId,
        @Param("status") OrderStatus status
    );
 
    // Native SQL when you need database-specific features
    @Query(value = """
        SELECT * FROM orders o
        WHERE o.created_at >= NOW() - INTERVAL '30 days'
        AND o.total > :minTotal
        ORDER BY o.total DESC
        """, nativeQuery = true)
    List<Order> findRecentHighValueOrders(@Param("minTotal") BigDecimal minTotal);
 
    // Modifying queries
    @Modifying
    @Query("UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
    int updateStatusForOrders(
        @Param("status") OrderStatus status,
        @Param("ids") List<Long> ids
    );
}

Transaction Management

@Transactional is deceptively simple but frequently misunderstood:

@Service
public class OrderService {
 
    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
 
    @Transactional
    public Order createOrder(CreateOrderRequest request) {
        // All of this happens in one transaction
        Order order = new Order(request);
 
        // Reserve inventory
        inventoryService.reserve(order.getItems());
 
        // Process payment
        paymentService.charge(order.getTotal());
 
        // Save order
        return orderRepository.save(order);
 
        // If anything throws, everything rolls back
    }
}

Interview question: "Your @Transactional method calls another @Transactional method in the same class. Does the inner method get its own transaction?"

No. Spring's @Transactional uses proxies. When you call a method within the same class, you bypass the proxy - the annotation has no effect. This is a common bug.

@Service
public class OrderService {
 
    @Transactional
    public void processOrders(List<Long> orderIds) {
        for (Long id : orderIds) {
            // BUG: This call bypasses the proxy
            // Each order does NOT get its own transaction
            processOrder(id);
        }
    }
 
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processOrder(Long orderId) {
        // This annotation is ignored when called from within the class
    }
}

Solutions:

  1. Inject the service into itself (ugly but works)
  2. Extract to a separate service class (cleaner)
  3. Use TransactionTemplate for programmatic control

N+1 Query Problem

The most common JPA performance issue:

// Entity with lazy relationship
@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
 
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items;
}
 
// This code triggers N+1
@Transactional(readOnly = true)
public void printOrders() {
    List<Order> orders = orderRepository.findAll();  // 1 query
 
    for (Order order : orders) {
        System.out.println(order.getUser().getName());  // N queries!
    }
}

Solutions:

// JOIN FETCH in JPQL
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status")
List<Order> findByStatusWithUser(@Param("status") OrderStatus status);
 
// EntityGraph
@EntityGraph(attributePaths = {"user", "items"})
List<Order> findByStatus(OrderStatus status);

Security with Spring Security

Spring Security is powerful but complex. Interviews focus on understanding the architecture, not memorizing configurations.

Security Filter Chain

Spring Security works through a filter chain. Every request passes through filters that handle authentication and authorization:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
 
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http)
            throws Exception {
 
        return http
            .csrf(csrf -> csrf.disable())  // Disable for REST APIs with JWT
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                .requestMatchers("/api/**").authenticated()
                .anyRequest().permitAll()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

Interview question: "What's the order of security filter evaluation?"

Filters execute in order. For authorization rules, the first matching rule wins. In the example above:

  1. /api/auth/login matches first rule - permitted
  2. /api/admin/users matches second rule - requires ADMIN role
  3. GET /api/products matches third rule - permitted
  4. POST /api/products doesn't match third rule, falls to fourth - requires authentication

JWT Authentication

Modern REST APIs typically use JWT:

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;
 
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
 
        String authHeader = request.getHeader("Authorization");
 
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
 
        String token = authHeader.substring(7);
        String username = jwtService.extractUsername(token);
 
        if (username != null &&
            SecurityContextHolder.getContext().getAuthentication() == null) {
 
            UserDetails userDetails = userDetailsService
                .loadUserByUsername(username);
 
            if (jwtService.isTokenValid(token, userDetails)) {
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                    );
 
                authToken.setDetails(
                    new WebAuthenticationDetailsSource()
                        .buildDetails(request)
                );
 
                SecurityContextHolder.getContext()
                    .setAuthentication(authToken);
            }
        }
 
        filterChain.doFilter(request, response);
    }
}

Method-Level Security

For fine-grained control:

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
 
@Service
public class OrderService {
 
    @PreAuthorize("hasRole('ADMIN')")
    public void deleteOrder(Long orderId) {
        // Only admins can delete
    }
 
    @PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
    public List<Order> getOrdersForUser(Long userId) {
        // Users can only see their own orders (unless admin)
    }
 
    @PostAuthorize("returnObject.user.id == authentication.principal.id")
    public Order getOrder(Long orderId) {
        // Check after fetching - user can only see their own order
    }
}

Testing in Spring Boot

Spring Boot provides excellent testing support, but knowing which tools to use for each scenario is key.

Test Slices

Test slices load only the parts of the context you need:

// @WebMvcTest - Controllers only, mocks services
@WebMvcTest(UserController.class)
class UserControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @MockBean
    private UserService userService;
 
    @Test
    void shouldReturnUser() throws Exception {
        UserDTO user = new UserDTO(1L, "john@example.com", "John");
        when(userService.findById(1L)).thenReturn(Optional.of(user));
 
        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.email").value("john@example.com"));
    }
 
    @Test
    void shouldReturn404WhenUserNotFound() throws Exception {
        when(userService.findById(999L)).thenReturn(Optional.empty());
 
        mockMvc.perform(get("/api/users/999"))
            .andExpect(status().isNotFound());
    }
 
    @Test
    void shouldValidateCreateRequest() throws Exception {
        String invalidRequest = """
            {
                "email": "invalid-email",
                "name": ""
            }
            """;
 
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(invalidRequest))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors").isArray());
    }
}
// @DataJpaTest - Repositories only, embedded database
@DataJpaTest
class UserRepositoryTest {
 
    @Autowired
    private UserRepository userRepository;
 
    @Autowired
    private TestEntityManager entityManager;
 
    @Test
    void shouldFindByEmail() {
        User user = new User("john@example.com", "John");
        entityManager.persistAndFlush(user);
 
        Optional<User> found = userRepository.findByEmail("john@example.com");
 
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("John");
    }
 
    @Test
    void shouldReturnEmptyForNonexistentEmail() {
        Optional<User> found = userRepository.findByEmail("nobody@example.com");
 
        assertThat(found).isEmpty();
    }
}

Integration Tests

When you need the full context:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderIntegrationTest {
 
    @Autowired
    private TestRestTemplate restTemplate;
 
    @Autowired
    private OrderRepository orderRepository;
 
    @Test
    void shouldCreateAndRetrieveOrder() {
        CreateOrderRequest request = new CreateOrderRequest(
            List.of(new OrderItemRequest(1L, 2))
        );
 
        ResponseEntity<OrderDTO> createResponse = restTemplate
            .postForEntity("/api/orders", request, OrderDTO.class);
 
        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
 
        Long orderId = createResponse.getBody().getId();
 
        ResponseEntity<OrderDTO> getResponse = restTemplate
            .getForEntity("/api/orders/" + orderId, OrderDTO.class);
 
        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getResponse.getBody().getItems()).hasSize(1);
    }
 
    @AfterEach
    void cleanup() {
        orderRepository.deleteAll();
    }
}

Testing with Testcontainers

For tests that need real databases:

@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {
 
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");
 
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
 
    @Autowired
    private UserService userService;
 
    @Test
    void shouldPersistUserToRealDatabase() {
        UserDTO created = userService.create(
            new CreateUserRequest("test@example.com", "Test User", "password123")
        );
 
        assertThat(created.getId()).isNotNull();
 
        Optional<UserDTO> retrieved = userService.findById(created.getId());
        assertThat(retrieved).isPresent();
    }
}

Production Readiness

Spring Boot includes features specifically for production deployments.

Actuator Endpoints

Spring Boot Actuator provides operational information:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# Expose specific endpoints
management.endpoints.web.exposure.include=health,info,metrics,prometheus
 
# Health endpoint details
management.endpoint.health.show-details=when_authorized
 
# Custom health indicators contribute to overall health
management.health.diskspace.enabled=true
management.health.db.enabled=true

Key endpoints:

  • /actuator/health - Application health status
  • /actuator/info - Application information
  • /actuator/metrics - Application metrics
  • /actuator/prometheus - Prometheus-format metrics

Custom Health Indicators

@Component
public class ExternalServiceHealthIndicator implements HealthIndicator {
 
    private final ExternalServiceClient client;
 
    @Override
    public Health health() {
        try {
            client.ping();
            return Health.up()
                .withDetail("service", "external-api")
                .withDetail("status", "reachable")
                .build();
        } catch (Exception e) {
            return Health.down()
                .withDetail("service", "external-api")
                .withDetail("error", e.getMessage())
                .build();
        }
    }
}

Configuration Management

Profiles for environment-specific configuration:

# application.properties (defaults)
spring.datasource.url=jdbc:h2:mem:devdb
 
# application-prod.properties
spring.datasource.url=jdbc:postgresql://prod-db:5432/app
spring.jpa.hibernate.ddl-auto=validate
 
# application-test.properties
spring.datasource.url=jdbc:h2:mem:testdb

Activate profiles: --spring.profiles.active=prod

Externalized configuration for sensitive values:

# Reference environment variables
spring.datasource.password=${DB_PASSWORD}
 
# With default fallback
server.port=${PORT:8080}

Logging

Spring Boot uses Logback by default:

# Root level
logging.level.root=INFO
 
# Package-specific levels
logging.level.com.myapp=DEBUG
logging.level.org.springframework.web=DEBUG
logging.level.org.hibernate.SQL=DEBUG
 
# Log to file
logging.file.name=/var/log/myapp/application.log
logging.file.max-size=10MB
logging.file.max-history=30

Structured logging for production (add logstash-encoder):

@Slf4j
@Service
public class OrderService {
 
    public Order createOrder(CreateOrderRequest request) {
        log.info("Creating order",
            kv("userId", request.getUserId()),
            kv("itemCount", request.getItems().size()));
 
        // ... create order
 
        log.info("Order created",
            kv("orderId", order.getId()),
            kv("total", order.getTotal()));
 
        return order;
    }
}

Common Interview Questions

Q: What happens when you start a Spring Boot application?

  1. main() calls SpringApplication.run()
  2. Spring creates the ApplicationContext
  3. Component scanning finds @Component classes
  4. Auto-configuration runs based on classpath and conditions
  5. Beans are instantiated and dependencies injected
  6. @PostConstruct methods execute
  7. ApplicationRunner / CommandLineRunner beans run
  8. Application is ready to handle requests

Q: How do you handle database migrations in Spring Boot?

Use Flyway or Liquibase. Spring Boot auto-configures them:

spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration

Q: What's the difference between @SpringBootTest and @WebMvcTest?

@SpringBootTest loads the full application context - use for integration tests. @WebMvcTest loads only web layer (controllers, filters) - use for fast, focused controller tests with mocked services.

Q: How do you implement caching in Spring Boot?

Enable caching and annotate methods:

@EnableCaching
@SpringBootApplication
public class Application { }
 
@Service
public class ProductService {
 
    @Cacheable("products")
    public Product findById(Long id) {
        // Only called on cache miss
        return repository.findById(id);
    }
 
    @CacheEvict(value = "products", key = "#product.id")
    public Product update(Product product) {
        return repository.save(product);
    }
}

Related Resources

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides