11 - Spring Boot & Spring Framework

Why Spring?

The Core Problem: In large applications, objects depend on other objects. Without a framework, you end up manually wiring everything together, leading to rigid, hard-to-test code.

Spring solves this through Inversion of Control (IoC) and Dependency Injection (DI):

Without Spring

public class OrderService {
    // Tightly coupled - OrderService creates its own dependencies
    private OrderRepository repo = new OrderRepositoryImpl();
    private PaymentService payment = new StripePaymentService();
    private EmailService email = new SmtpEmailService();

    // Hard to test - can't swap implementations
    // Hard to change - payment provider change = code change
}

With Spring

@Service
public class OrderService {
    // Loosely coupled - dependencies injected
    private final OrderRepository repo;
    private final PaymentService payment;
    private final EmailService email;

    public OrderService(OrderRepository repo,
                        PaymentService payment,
                        EmailService email) {
        this.repo = repo;
        this.payment = payment;
        this.email = email;
    }
}
Spring's DI isn't just convenience - it enables testability (inject mocks), configurability (swap implementations via config), and modularity (components don't know about each other's implementations). This is crucial at scale.

Spring vs Spring Boot

Spring Framework Spring Boot
Core framework - IoC container, DI, AOP Opinionated layer on top of Spring
Requires manual configuration Auto-configuration based on classpath
Need to configure web server separately Embedded Tomcat/Jetty/Undertow
XML or Java config for everything Sensible defaults, minimal config
Fine-grained control Convention over configuration

For new projects, use Spring Boot. You get Spring Framework underneath with sensible defaults.

Core Concepts: Beans and the Container

A Bean is an object managed by Spring's IoC container. The container handles:

┌─────────────────────────────────────────────────────────────────┐ │ Spring IoC Container │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ OrderService│ │ PaymentSvc │ │ EmailSvc │ (Beans) │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ │ │ └────────────────┼────────────────┘ │ │ ▼ │ │ ┌───────────────────────┐ │ │ │ ApplicationContext │ (The Container) │ │ │ - Creates beans │ │ │ │ - Wires dependencies │ │ │ │ - Manages lifecycle │ │ │ └───────────────────────┘ │ └─────────────────────────────────────────────────────────────────┘

Defining Beans

Annotation-Based
Java Config
Python Comparison
// Component scanning - Spring finds these automatically
@Component      // Generic bean
@Service       // Business logic layer
@Repository    // Data access layer
@Controller    // Web controller
@RestController // REST API controller

@Service
public class UserService {

    private final UserRepository userRepository;

    // Constructor injection - preferred approach
    // Spring auto-injects UserRepository bean
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User findById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(id));
    }
}
// Explicit Java configuration - useful for third-party classes
@Configuration
public class AppConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplateBuilder()
            .setConnectTimeout(Duration.ofSeconds(5))
            .setReadTimeout(Duration.ofSeconds(10))
            .build();
    }

    @Bean
    @Profile("production")  // Only create in production
    public PaymentService stripePaymentService() {
        return new StripePaymentService(stripeApiKey);
    }

    @Bean
    @Profile("development")  // Mock for development
    public PaymentService mockPaymentService() {
        return new MockPaymentService();
    }
}
# Python (FastAPI with dependency injection)
# Similar concept but more manual

from fastapi import Depends

class UserRepository:
    def find_by_id(self, id: int) -> User:
        ...

class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo

    def find_by_id(self, id: int) -> User:
        return self.repo.find_by_id(id)

# Manual wiring with FastAPI's Depends
def get_user_repository() -> UserRepository:
    return UserRepository()

def get_user_service(
    repo: UserRepository = Depends(get_user_repository)
) -> UserService:
    return UserService(repo)

@app.get("/users/{id}")
def get_user(
    id: int,
    service: UserService = Depends(get_user_service)
):
    return service.find_by_id(id)

# Spring does this automatically via component scanning
# and constructor injection - less boilerplate

Spring Boot Application Structure

my-app/ ├── src/main/java/com/example/myapp/ │ ├── MyApplication.java # @SpringBootApplication entry point │ ├── controller/ │ │ └── UserController.java # @RestController - HTTP endpoints │ ├── service/ │ │ └── UserService.java # @Service - business logic │ ├── repository/ │ │ └── UserRepository.java # @Repository - data access │ ├── model/ │ │ └── User.java # Entity/domain objects │ ├── dto/ │ │ └── UserDTO.java # Data transfer objects │ └── config/ │ └── SecurityConfig.java # @Configuration classes ├── src/main/resources/ │ ├── application.yml # Configuration │ └── application-prod.yml # Production overrides └── pom.xml or build.gradle # Dependencies
// The entry point
@SpringBootApplication  // Combines @Configuration, @EnableAutoConfiguration, @ComponentScan
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

REST Controllers

Spring Boot
FastAPI
Flask
@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;

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

    @GetMapping
    public List<UserDTO> getAllUsers() {
        return userService.findAll();
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
        return userService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserDTO createUser(@Valid @RequestBody CreateUserRequest request) {
        return userService.create(request);
    }

    @PutMapping("/{id}")
    public UserDTO updateUser(
            @PathVariable Long id,
            @Valid @RequestBody UpdateUserRequest request) {
        return userService.update(id, request);
    }

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

    // Query parameters
    @GetMapping("/search")
    public Page<UserDTO> searchUsers(
            @RequestParam String query,
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        return userService.search(query, PageRequest.of(page, size));
    }
}
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel

app = FastAPI()

@app.get("/api/v1/users")
def get_all_users():
    return user_service.find_all()

@app.get("/api/v1/users/{id}")
def get_user(id: int):
    user = user_service.find_by_id(id)
    if not user:
        raise HTTPException(status_code=404)
    return user

@app.post("/api/v1/users", status_code=201)
def create_user(request: CreateUserRequest):
    return user_service.create(request)

@app.get("/api/v1/users/search")
def search_users(
    query: str,
    page: int = Query(default=0),
    size: int = Query(default=20)
):
    return user_service.search(query, page, size)
from flask import Flask, jsonify, request

app = Flask(__name__)

@app.route("/api/v1/users", methods=["GET"])
def get_all_users():
    return jsonify(user_service.find_all())

@app.route("/api/v1/users/<int:id>", methods=["GET"])
def get_user(id):
    user = user_service.find_by_id(id)
    if not user:
        return jsonify({"error": "Not found"}), 404
    return jsonify(user)

@app.route("/api/v1/users", methods=["POST"])
def create_user():
    data = request.get_json()
    return jsonify(user_service.create(data)), 201

Request Validation

// Using Jakarta Bean Validation (formerly javax.validation)
public record CreateUserRequest(
    @NotBlank(message = "Email is required")
    @Email(message = "Must be valid email")
    String email,

    @NotBlank
    @Size(min = 2, max = 50)
    String name,

    @NotNull
    @Min(18)
    Integer age
) {}

// Global exception handler for validation errors
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidation(MethodArgumentNotValidException ex) {
        return ex.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.toMap(
                FieldError::getField,
                FieldError::getDefaultMessage
            ));
    }
}

Spring Data JPA

Spring Data JPA eliminates boilerplate for database access. Define an interface, Spring implements it.

Entity
Repository
Service
Python (SQLAlchemy)
@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String name;

    @Enumerated(EnumType.STRING)
    private UserStatus status = UserStatus.ACTIVE;

    @CreatedDate
    private Instant createdAt;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
    private List<Order> orders = new ArrayList<>();

    // Getters, setters, constructors...
}
// Just an interface - Spring generates the implementation!
public interface UserRepository extends JpaRepository<User, Long> {

    // Derived queries - method name becomes SQL
    Optional<User> findByEmail(String email);

    List<User> findByStatus(UserStatus status);

    List<User> findByNameContainingIgnoreCase(String name);

    // Custom JPQL query
    @Query("SELECT u FROM User u WHERE u.createdAt > :since AND u.status = :status")
    List<User> findRecentActiveUsers(
        @Param("since") Instant since,
        @Param("status") UserStatus status
    );

    // Native SQL when needed
    @Query(value = "SELECT * FROM users WHERE email LIKE %:domain", nativeQuery = true)
    List<User> findByEmailDomain(@Param("domain") String domain);

    // Pagination built-in
    Page<User> findByStatus(UserStatus status, Pageable pageable);

    // Modifying queries
    @Modifying
    @Query("UPDATE User u SET u.status = :status WHERE u.id = :id")
    int updateStatus(@Param("id") Long id, @Param("status") UserStatus status);
}
@Service
@Transactional(readOnly = true)  // Read-only by default
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public Optional<UserDTO> findById(Long id) {
        return userRepository.findById(id)
            .map(this::toDTO);
    }

    @Transactional  // Write operation - override read-only
    public UserDTO create(CreateUserRequest request) {
        if (userRepository.findByEmail(request.email()).isPresent()) {
            throw new EmailAlreadyExistsException(request.email());
        }

        var user = new User();
        user.setEmail(request.email());
        user.setName(request.name());

        return toDTO(userRepository.save(user));
    }

    public Page<UserDTO> findAll(Pageable pageable) {
        return userRepository.findAll(pageable).map(this::toDTO);
    }

    private UserDTO toDTO(User user) {
        return new UserDTO(user.getId(), user.getEmail(), user.getName());
    }
}
# Python SQLAlchemy equivalent
from sqlalchemy import Column, Integer, String, Enum
from sqlalchemy.orm import Session

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    email = Column(String, unique=True, nullable=False)
    name = Column(String, nullable=False)
    status = Column(Enum(UserStatus), default=UserStatus.ACTIVE)

# Manual repository - Spring generates this for you
class UserRepository:
    def __init__(self, db: Session):
        self.db = db

    def find_by_id(self, id: int) -> User | None:
        return self.db.query(User).filter(User.id == id).first()

    def find_by_email(self, email: str) -> User | None:
        return self.db.query(User).filter(User.email == email).first()

    def save(self, user: User) -> User:
        self.db.add(user)
        self.db.commit()
        return user

    # Every query method must be manually implemented
    # Spring Data JPA generates all of this from method names!
Spring Data's query derivation (method name → SQL) is a key productivity feature. findByStatusAndCreatedAtAfter becomes SELECT * FROM users WHERE status = ? AND created_at > ?. Know the naming conventions: findBy, countBy, existsBy, deleteBy, with operators like And, Or, Between, LessThan, Like, In, OrderBy.

Configuration & Profiles

# application.yml - default configuration
spring:
  application:
    name: my-service
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
    username: ${DB_USERNAME:postgres}    # Environment variable with default
    password: ${DB_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: validate                  # Never use 'create' in production!
    show-sql: false

server:
  port: 8080

logging:
  level:
    com.example: DEBUG
    org.springframework: INFO

# Custom properties
app:
  feature-flags:
    new-checkout: true
  external-service:
    url: https://api.example.com
    timeout: 5s
# application-prod.yml - production overrides
spring:
  datasource:
    url: jdbc:postgresql://prod-db:5432/mydb
    hikari:
      maximum-pool-size: 20

logging:
  level:
    com.example: INFO

app:
  feature-flags:
    new-checkout: false  # Not ready for prod yet

Accessing Configuration

// Type-safe configuration with @ConfigurationProperties
@ConfigurationProperties(prefix = "app.external-service")
public record ExternalServiceConfig(
    String url,
    Duration timeout
) {}

// Enable in main class
@SpringBootApplication
@ConfigurationPropertiesScan
public class MyApplication { ... }

// Use it
@Service
public class ExternalApiClient {
    private final ExternalServiceConfig config;

    public ExternalApiClient(ExternalServiceConfig config) {
        this.config = config;
    }

    public Response call() {
        return webClient
            .get()
            .uri(config.url())
            .retrieve()
            .bodyToMono(Response.class)
            .timeout(config.timeout())
            .block();
    }
}

Profile-Specific Beans

@Configuration
public class MessagingConfig {

    @Bean
    @Profile("production")
    public MessageQueue kafkaQueue() {
        return new KafkaMessageQueue();
    }

    @Bean
    @Profile("!production")  // Any profile except production
    public MessageQueue inMemoryQueue() {
        return new InMemoryMessageQueue();
    }
}

// Activate profile: java -jar app.jar --spring.profiles.active=production
// Or: SPRING_PROFILES_ACTIVE=production

Spring Security Basics

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())  // Disable for APIs (use tokens instead)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.GET, "/api/users/**").hasAnyRole("USER", "ADMIN")
                .requestMatchers(HttpMethod.POST, "/api/users/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 ->
                oauth2.jwt(Customizer.withDefaults()))  // JWT validation
            .build();
    }

    @Bean
    public JwtDecoder jwtDecoder() {
        return JwtDecoders.fromIssuerLocation("https://auth.example.com");
    }
}

// Access current user in controller
@GetMapping("/me")
public UserDTO getCurrentUser(@AuthenticationPrincipal Jwt jwt) {
    String userId = jwt.getSubject();
    return userService.findById(userId);
}

// Method-level security
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.subject")
public UserDTO getUser(String userId) {
    return userService.findById(userId);
}
Spring Security is filter-based. Requests pass through a chain of filters before reaching your controller. Understanding this architecture helps debug auth issues. Key filters: SecurityContextPersistenceFilter, UsernamePasswordAuthenticationFilter, BearerTokenAuthenticationFilter, ExceptionTranslationFilter, FilterSecurityInterceptor.

Testing

Unit Test
Integration Test
Slice Test
// Pure unit test - no Spring context
class UserServiceTest {

    private UserRepository userRepository;
    private UserService userService;

    @BeforeEach
    void setUp() {
        userRepository = mock(UserRepository.class);
        userService = new UserService(userRepository);
    }

    @Test
    void findById_existingUser_returnsUser() {
        // Given
        var user = new User(1L, "test@example.com", "Test User");
        when(userRepository.findById(1L)).thenReturn(Optional.of(user));

        // When
        var result = userService.findById(1L);

        // Then
        assertThat(result).isPresent();
        assertThat(result.get().email()).isEqualTo("test@example.com");
    }

    @Test
    void create_duplicateEmail_throwsException() {
        // Given
        when(userRepository.findByEmail("exists@example.com"))
            .thenReturn(Optional.of(new User()));

        var request = new CreateUserRequest("exists@example.com", "Name");

        // When/Then
        assertThatThrownBy(() -> userService.create(request))
            .isInstanceOf(EmailAlreadyExistsException.class);
    }
}
// Full integration test - loads entire Spring context
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }

    @Test
    void createUser_validRequest_returns201() {
        // Given
        var request = new CreateUserRequest("new@example.com", "New User");

        // When
        var response = restTemplate.postForEntity(
            "/api/v1/users", request, UserDTO.class);

        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().email()).isEqualTo("new@example.com");
        assertThat(userRepository.findByEmail("new@example.com")).isPresent();
    }
}

// With Testcontainers for real database
@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");

    @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);
    }
}
// Slice tests - load only relevant parts of context

// Web layer only - no service/repository beans
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean  // Spring's mock that integrates with context
    private UserService userService;

    @Test
    void getUser_notFound_returns404() throws Exception {
        when(userService.findById(999L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/v1/users/999"))
            .andExpect(status().isNotFound());
    }

    @Test
    void createUser_invalidEmail_returns400() throws Exception {
        mockMvc.perform(post("/api/v1/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"email": "not-an-email", "name": "Test"}
                    """))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.email").exists());
    }
}

// JPA layer only
@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void findByEmail_existingEmail_returnsUser() {
        // Given
        entityManager.persist(new User(null, "test@example.com", "Test"));
        entityManager.flush();

        // When
        var result = userRepository.findByEmail("test@example.com");

        // Then
        assertThat(result).isPresent();
    }
}
Test Performance Tip: @SpringBootTest loads the full context (slow). Use slice tests (@WebMvcTest, @DataJpaTest) when possible - they only load relevant beans. For unit tests, don't use Spring at all.

Common Spring Boot Starters

Starter Purpose
spring-boot-starter-web REST APIs with embedded Tomcat
spring-boot-starter-data-jpa JPA + Hibernate + Spring Data
spring-boot-starter-security Authentication and authorization
spring-boot-starter-validation Bean validation (@Valid, @NotNull, etc.)
spring-boot-starter-actuator Health checks, metrics, monitoring
spring-boot-starter-cache Caching abstraction
spring-boot-starter-webflux Reactive web framework
spring-boot-starter-test JUnit, Mockito, AssertJ, MockMvc

Spring Boot Actuator (Production Readiness)

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus
  endpoint:
    health:
      show-details: when_authorized
      probes:
        enabled: true  # Kubernetes readiness/liveness probes
  metrics:
    tags:
      application: ${spring.application.name}

Available endpoints:

// Custom health indicator
@Component
public class ExternalServiceHealthIndicator implements HealthIndicator {

    private final ExternalApiClient client;

    @Override
    public Health health() {
        try {
            client.ping();
            return Health.up()
                .withDetail("service", "external-api")
                .build();
        } catch (Exception e) {
            return Health.down()
                .withException(e)
                .build();
        }
    }
}

Interview Quick Reference

Concept Key Points
IoC/DI Container creates and wires objects. Enables testability, loose coupling, configurability.
Bean Scopes singleton (default), prototype, request, session
@Transactional AOP-based. Proxy wraps method, manages commit/rollback. Don't call transactional method from same class!
N+1 Problem Lazy loading causes extra queries. Fix with @EntityGraph or JOIN FETCH in JPQL.
Auto-configuration Spring Boot examines classpath and configures beans automatically. Override with your own @Bean definitions.
Spring vs Spring Boot Spring = core framework. Spring Boot = opinionated defaults + embedded server + auto-config.
@Primary vs @Qualifier @Primary = default when multiple beans match. @Qualifier = explicit selection by name.
Constructor vs Field Injection Constructor = required deps, immutable, testable. Field (@Autowired) = harder to test, hidden deps.
For Twilio's scale, know: how Spring handles high concurrency (thread pools, async), reactive programming with WebFlux for non-blocking I/O, and how to tune connection pools and timeouts for resilience.