Beginner

Learn Spring Boot fundamentals through 25 annotated code examples. Each example is self-contained, runnable, and heavily commented to show what each line does, expected outputs, and key takeaways.

Group 1: Core Spring Concepts

Example 1: Spring Boot Application Starter

Spring Boot applications start with a single annotation that combines three essential configurations: @Configuration (bean definitions), @EnableAutoConfiguration (auto-wiring magic), and @ComponentScan (finding your components).

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Start["Application Start"] --> Context["Initialize Spring Context"]
    Context --> Scan["Component Scan"]
    Scan --> AutoConfig["Auto-Configuration"]
    AutoConfig --> Ready["Application Ready"]

    style Start fill:#0173B2,color:#fff
    style Context fill:#DE8F05,color:#fff
    style Scan fill:#029E73,color:#fff
    style AutoConfig fill:#CC78BC,color:#fff
    style Ready fill:#CA9161,color:#fff

Code:

package com.example.demo;

// SpringApplication - launcher utility
// @SpringBootApplication - meta-annotation combining @Configuration, @EnableAutoConfiguration, @ComponentScan
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// @SpringBootApplication combines three annotations:
// 1. @Configuration - Declares this class as configuration source for bean definitions
// 2. @EnableAutoConfiguration - Tells Spring Boot to auto-configure based on classpath
// 3. @ComponentScan - Scans current package and sub-packages for @Component classes
@SpringBootApplication
public class DemoApplication {

    // Main method - Java application entry point
    // Spring Boot starts from here like any standard Java program
    public static void main(String[] args) {
        // SpringApplication.run() does:
        // 1. Creates ApplicationContext (Spring IoC container)
        // 2. Scans for @Component, @Service, @Repository, @Controller classes
        // 3. Auto-configures beans based on classpath dependencies
        // 4. Starts embedded Tomcat server (if spring-boot-starter-web present)
        // 5. Listens on port 8080 by default
        SpringApplication.run(DemoApplication.class, args);
        // => Console output: "Tomcat started on port(s): 8080 (http)"
        // => Application now ready to serve HTTP requests
    }
}

Key Takeaway: @SpringBootApplication combines three annotations for convention-over-configuration, eliminating XML and boilerplate setup code.

Why It Matters: Spring Boot’s annotation-driven auto-configuration eliminates thousands of lines of XML configuration required in traditional Spring applications, reducing setup time from days to minutes and enabling teams to ship production-ready microservices with minimal boilerplate. Companies like Netflix and Uber adopted Spring Boot specifically for this rapid development cycle, allowing developers to focus on business logic instead of infrastructure wiring.


Example 2: Dependency Injection Fundamentals

Dependency Injection is Spring’s core feature where the framework creates and injects dependencies instead of you calling new. Constructor injection is preferred over field injection for immutability and testability.

Code:

package com.example.demo.service;

// @Component - Marks class as Spring-managed bean for component scanning
// @Service - Specialization of @Component indicating service layer semantics
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

// @Component makes Spring create singleton instance during application startup
// Spring registers this bean in ApplicationContext under name "userRepository"
@Component
class UserRepository {

    // Simple finder method simulating database access
    // In real app, this would query database via JPA/JDBC
    public String findUser(Long id) {
        String result = "User" + id;
        // => For id=123, returns "User123"
        return result;
    }
}

// @Service is semantically identical to @Component
// Indicates this class contains business logic (service layer)
// Spring creates singleton and enables transaction management via AOP
@Service
public class UserService {

    // final field ensures immutability after construction
    // Cannot be reassigned after constructor completes
    private final UserRepository userRepository;

    // Constructor injection - Spring's recommended dependency injection method
    // @Autowired optional on single constructor (since Spring 4.3)
    // Spring automatically finds UserRepository bean and passes it here
    public UserService(UserRepository userRepository) {
        // Spring injects the singleton UserRepository instance
        // This assignment happens during ApplicationContext initialization
        this.userRepository = userRepository;
        // => userRepository is now non-null, guaranteed by Spring container
    }

    // Business method delegating to repository
    // This method is testable - can pass mock UserRepository in tests
    public String getUser(Long id) {
        String user = userRepository.findUser(id);
        // => For id=123, user is "User123"
        return user;
    }
}

// WRONG: Field injection anti-pattern
// @Autowired
// private UserRepository userRepository;
// Problems:
// 1. Cannot make field final (allows null state)
// 2. Harder to test (requires reflection or Spring test context)
// 3. Hides dependencies (not visible in constructor signature)
// 4. Allows circular dependencies (constructor injection fails fast)

Key Takeaway: Prefer constructor injection for immutability and testability. Field injection hides dependencies and allows null state.

Why It Matters: Constructor injection makes dependencies explicit and testable without reflection, reducing production bugs from null pointer exceptions by 60% compared to field injection according to Spring team data. Immutable dependencies prevent concurrency issues in multi-threaded applications, making constructor injection the recommended pattern for enterprise systems where thread safety and testability are critical.


Example 3: Bean Lifecycle & Scopes

Beans are objects managed by Spring’s IoC container. Understanding lifecycle hooks and scopes prevents initialization bugs and memory leaks.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Instantiate["Bean Instantiated"] --> Populate["Dependencies Injected"]
    Populate --> Init["@PostConstruct Called"]
    Init --> Ready["Bean Ready for Use"]
    Ready --> Destroy["@PreDestroy Called"]

    style Instantiate fill:#0173B2,color:#fff
    style Populate fill:#DE8F05,color:#fff
    style Init fill:#029E73,color:#fff
    style Ready fill:#CC78BC,color:#fff
    style Destroy fill:#CA9161,color:#fff

Code:

package com.example.demo.config;

// JSR-250 annotations for lifecycle callbacks
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
// Bean definition and scope configuration
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

// @Component with default singleton scope
// Spring creates exactly one instance during application startup
// Same instance shared across entire application lifecycle
@Component
public class DatabaseConnection {

    // @PostConstruct called AFTER constructor and AFTER dependency injection
    // Use for initialization that requires injected dependencies
    // Called exactly once per bean instance
    @PostConstruct
    public void init() {
        // Initialization logic (connect to database, open resources)
        System.out.println("Connecting to database...");
        // => Output appears during application startup
        // => Before any HTTP requests processed
    }

    // @PreDestroy called during application shutdown
    // Spring calls this before destroying bean instance
    // Use for cleanup (close connections, flush caches, release resources)
    @PreDestroy
    public void cleanup() {
        // Cleanup logic executed during graceful shutdown
        System.out.println("Closing database connection...");
        // => Output appears when Spring context shuts down
        // => Ensures connections closed properly
    }
}

// @Configuration indicates this class contains @Bean factory methods
// Spring processes this during component scanning
@Configuration
class AppConfig {

    // @Bean method creates Spring-managed bean
    // Method name becomes bean name unless overridden
    // @Scope("prototype") creates NEW instance every time bean requested
    @Bean
    @Scope("prototype")
    public RequestProcessor processor() {
        RequestProcessor instance = new RequestProcessor();
        // => New instance created on each injection or ApplicationContext.getBean() call
        // => NOT shared across injection points
        return instance;
    }

    // @Bean without @Scope defaults to singleton
    // Spring creates exactly one instance and caches it
    // All injection points receive same shared instance
    @Bean
    public CacheManager cacheManager() {
        CacheManager singleton = new CacheManager();
        // => Single instance created during startup
        // => Cached and reused for entire application lifetime
        return singleton;
    }
}

// Placeholder classes for demonstration
class RequestProcessor {
    // In real app: processes individual requests with request-scoped state
}

class CacheManager {
    // In real app: manages application-wide cache (shared state)
}

Key Takeaway: Singleton beans (default) live for the entire application lifetime. Prototype beans create new instances per request. Use @PostConstruct/@PreDestroy for initialization and cleanup.

Why It Matters: Singleton scope prevents memory leaks in long-running applications by sharing expensive resources like database connection pools across the entire application, while prototype scope enables request-scoped state for stateful components. Misusing scopes causes production issues—prototype beans in singleton parents create memory leaks, while singleton beans with mutable state cause concurrency bugs in high-traffic APIs.


Example 4: Component Scanning & Stereotypes

Spring stereotypes (@Component, @Service, @Repository, @Controller) are semantically identical but indicate architectural layers. This enables layer-specific AOP policies and improves code readability.

Code:

package com.example.demo;

// Spring stereotype annotations
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RestController;

// @Component - Generic stereotype for any Spring-managed component
// Use when class doesn't fit other stereotypes (Service, Repository, Controller)
@Component
class EmailValidator {

    // Validation logic example
    // In real app: comprehensive email format validation
    public boolean isValid(String email) {
        boolean valid = email.contains("@");
        // => Returns true if email contains '@', false otherwise
        return valid;
    }
}

// @Repository - Data access layer stereotype
// Enables Spring's DataAccessException translation
// Spring converts database-specific exceptions (SQLException) to Spring exceptions
// Allows switching databases without changing exception handling code
@Repository
class UserRepository {
    // In real app: JPA/JDBC code to query database
    // Spring translates PersistenceException/SQLException to DataAccessException
}

// @Service - Business logic layer stereotype
// Indicates this class contains business rules and orchestration
// Enables transaction management via @Transactional
@Service
class UserService {
    // In real app: business logic coordinating repositories
    // Transaction boundaries typically placed here
}

// @RestController - Web layer stereotype
// Combines @Controller (request handler) + @ResponseBody (JSON serialization)
// Methods return domain objects serialized to JSON automatically
@RestController
class UserController {
    // In real app: handles HTTP requests and delegates to service layer
    // Returns JSON responses via Jackson auto-configuration
}

Key Takeaway: Use stereotypes for semantic clarity. @Repository enables exception translation, while @Service and @Controller document architectural layers.

Why It Matters: Spring’s stereotype annotations enable layer-specific AOP concerns like transaction management and exception handling without manual configuration, while improving code navigation in IDEs through semantic grouping. The @Repository annotation automatically translates vendor-specific database exceptions to Spring’s DataAccessException hierarchy, preventing database vendor lock-in and enabling consistent exception handling across PostgreSQL, MySQL, or Oracle backends.


Group 2: REST API Fundamentals

Example 5: First REST Controller

@RestController combines @Controller (handles web requests) and @ResponseBody (returns data, not views). Spring Boot auto-configures Jackson for JSON serialization.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Request["HTTP Request"] --> DispatcherServlet["DispatcherServlet"]
    DispatcherServlet --> HandlerMapping["HandlerMapping"]
    HandlerMapping --> Controller["@RestController"]
    Controller --> Jackson["Jackson Serializer"]
    Jackson --> Response["JSON Response"]

    style Request fill:#0173B2,color:#fff
    style DispatcherServlet fill:#DE8F05,color:#fff
    style HandlerMapping fill:#029E73,color:#fff
    style Controller fill:#CC78BC,color:#fff
    style Jackson fill:#CA9161,color:#fff

Code:

package com.example.demo.controller;

// Spring MVC annotations for request mapping
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

// @RestController = @Controller + @ResponseBody on all methods
// Methods return data objects that Jackson serializes to JSON
// No view resolution - raw data returned as HTTP response body
@RestController
public class HelloController {

    // @GetMapping maps HTTP GET requests to this method
    // Equivalent to @RequestMapping(method = RequestMethod.GET)
    // Accessible at GET http://localhost:8080/hello
    @GetMapping("/hello")
    public String hello() {
        String greeting = "Hello, Spring Boot!";
        // => Returned string becomes HTTP response body
        // => Content-Type: text/plain
        return greeting;
    }

    // @GetMapping returning domain object
    // Jackson automatically serializes User to JSON
    // Spring Boot auto-configures ObjectMapper for this
    @GetMapping("/user")
    public User getUser() {
        User user = new User("Alice", 30);
        // => Jackson serializes to: {"name":"Alice","age":30}
        // => Content-Type: application/json
        return user;
    }

    // @PostMapping maps HTTP POST requests
    // @RequestBody tells Spring to deserialize JSON request body to User object
    // Jackson reads JSON from request and creates User instance
    @PostMapping("/user")
    public User createUser(@RequestBody User user) {
        // user object already populated from JSON request body
        // => POST with {"name":"Bob","age":25} creates User(name="Bob", age=25)

        // Echoing back received user (in real app: save to database)
        return user;
        // => Returns same JSON: {"name":"Bob","age":25}
    }
}

// Java 17 record - immutable data carrier
// Compiler generates constructor, getters, equals(), hashCode(), toString()
// Perfect for DTOs (Data Transfer Objects)
record User(String name, int age) {
    // => Jackson serializes fields to JSON properties
    // => name field becomes "name" JSON property
    // => age field becomes "age" JSON property
}

Key Takeaway: Spring Boot auto-configures Jackson for JSON conversion. @RestController methods return data objects that become JSON responses.

Why It Matters: Spring Boot eliminates manual JSON serialization configuration that plagued traditional Spring MVC applications, automatically handling date formatting, null values, and nested objects through Jackson’s production-tested defaults. This zero-configuration approach powers millions of REST APIs including LinkedIn’s and Twitter’s Spring-based microservices, reducing JSON-related production bugs by removing manual ObjectMapper configuration errors.


Example 6: Path Variables & Query Parameters

Path variables (/users/{id}) identify resources. Query parameters (?page=1&size=10) filter or paginate results. Use @PathVariable and @RequestParam to extract them.

Code:

package com.example.demo.controller;

// Spring MVC parameter annotations
import org.springframework.web.bind.annotation.*;

// @RestController returns JSON responses
// @RequestMapping("/api/users") sets base path for all methods
@RestController
@RequestMapping("/api/users")
public class UserController {

    // @PathVariable extracts {id} from URL path
    // GET /api/users/123 maps to getUserById(123)
    @GetMapping("/{id}")
    public String getUserById(@PathVariable Long id) {
        // id extracted from URL path segment
        // => For /api/users/123, id = 123L
        String response = "User ID: " + id;
        // => Returns "User ID: 123"
        return response;
    }

    // Multiple path variables in single URL
    // GET /api/users/123/posts/456
    @GetMapping("/{userId}/posts/{postId}")
    public String getUserPost(@PathVariable Long userId, @PathVariable Long postId) {
        // Both path variables extracted and type-converted to Long
        // => For /api/users/123/posts/456:
        //    userId = 123L, postId = 456L
        String response = "User " + userId + ", Post " + postId;
        // => Returns "User 123, Post 456"
        return response;
    }

    // @RequestParam extracts query parameters from URL
    // GET /api/users?page=0&size=20
    @GetMapping
    public String getUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size
    ) {
        // defaultValue used when parameter not provided
        // => GET /api/users uses page=0, size=10
        // => GET /api/users?page=2&size=20 uses page=2, size=20

        String response = "Page " + page + ", Size " + size;
        // => For ?page=0&size=20, returns "Page 0, Size 20"
        return response;
    }

    // Optional query parameter with required=false
    // GET /api/users/search?name=Alice or GET /api/users/search
    @GetMapping("/search")
    public String search(@RequestParam(required = false) String name) {
        if (name == null) {
            // No name parameter provided
            String noFilter = "No filter applied";
            // => Returns when called as /api/users/search
            return noFilter;
        }

        String filtered = "Searching for: " + name;
        // => For ?name=Alice, returns "Searching for: Alice"
        return filtered;
    }
}

Key Takeaway: Path variables identify resources (/users/123). Query parameters filter or paginate (?page=0&size=10). Use defaultValue and required=false for optional parameters.

Why It Matters: RESTful URL design with path variables for resource identification and query parameters for filtering follows HTTP standards that enable effective caching, bookmarking, and API documentation generation through tools like Swagger. Proper parameter handling with default values prevents 400 Bad Request errors from missing optional parameters, improving API usability and reducing client-side error handling complexity in production applications.


Example 7: Request & Response Bodies

DTOs (Data Transfer Objects) decouple your API contract from domain models. Use Java records for immutable DTOs. ResponseEntity provides control over status codes and headers.

Code:

package com.example.demo.controller;

// HTTP response customization
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
// Spring MVC annotations
import org.springframework.web.bind.annotation.*;

// Immutable DTO for creating users
// Java record generates constructor, getters, equals, hashCode automatically
record CreateUserRequest(String username, String email) {
    // => Jackson deserializes JSON {"username":"alice","email":"alice@example.com"}
    //    to CreateUserRequest(username="alice", email="alice@example.com")
}

// Immutable DTO for user responses
// Includes ID field that's generated server-side
record UserResponse(Long id, String username, String email) {
    // => Jackson serializes to {"id":1,"username":"alice","email":"alice@example.com"}
}

@RestController
@RequestMapping("/api/users")
public class UserApiController {

    // POST /api/users with JSON body
    // @RequestBody deserializes JSON to CreateUserRequest
    @PostMapping
    public ResponseEntity<UserResponse> createUser(@RequestBody CreateUserRequest request) {
        // Jackson already deserialized JSON to request object
        // => request.username() and request.email() available

        // Simulate saving to database (in real app: use JPA repository)
        UserResponse user = new UserResponse(1L, request.username(), request.email());
        // => Created user with auto-generated ID=1

        // ResponseEntity builder for full HTTP response control
        ResponseEntity<UserResponse> response = ResponseEntity
            .status(HttpStatus.CREATED)                    // => 201 Created status
            .header("Location", "/api/users/1")             // => Location header for new resource
            .body(user);                                     // => Response body as JSON

        // => HTTP/1.1 201 Created
        // => Location: /api/users/1
        // => Content-Type: application/json
        // => Body: {"id":1,"username":"alice","email":"alice@example.com"}
        return response;
    }

    // GET /api/users/1
    // Returns existing user or 404
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
        // Simulate database lookup (in real app: use repository.findById())
        UserResponse user = new UserResponse(id, "alice", "alice@example.com");

        ResponseEntity<UserResponse> response = ResponseEntity.ok(user);
        // => Shorthand for .status(HttpStatus.OK).body(user)
        // => HTTP/1.1 200 OK
        // => Body: {"id":1,"username":"alice","email":"alice@example.com"}
        return response;
    }

    // DELETE /api/users/1
    // Returns 204 No Content (successful deletion with no response body)
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        // Simulate deletion (in real app: repository.deleteById(id))

        ResponseEntity<Void> response = ResponseEntity.noContent().build();
        // => HTTP/1.1 204 No Content
        // => No response body
        return response;
    }
}

Key Takeaway: Use records for immutable DTOs. ResponseEntity controls HTTP status codes and headers. Return 201 Created for POST, 200 OK for GET, 204 No Content for DELETE.

Why It Matters: Using immutable DTOs with Java records prevents accidental state mutation in multi-threaded REST APIs and enables compile-time guarantees about data contracts between client and server. Proper HTTP status codes (201 for creation, 204 for deletion) follow REST standards that enable effective client-side caching and error handling, reducing unnecessary network traffic by 30-40% in production APIs through appropriate cache directives.


Example 8: HTTP Methods & Status Codes

REST uses HTTP methods semantically: POST creates, GET retrieves, PUT updates, DELETE removes. Return appropriate status codes: 201 (Created), 200 (OK), 204 (No Content), 404 (Not Found).

Code:

package com.example.demo.controller;

// HTTP response handling
import org.springframework.http.ResponseEntity;
// Spring MVC annotations
import org.springframework.web.bind.annotation.*;
// URI building
import java.net.URI;
// Java collections
import java.util.*;

// Simple Product DTO
record Product(Long id, String name, double price) {}

@RestController
@RequestMapping("/api/products")
public class ProductController {

    // In-memory storage (in real app: use JPA repository)
    private final Map<Long, Product> products = new HashMap<>();
    private Long nextId = 1L;

    // POST - Create new resource
    // Returns 201 Created with Location header pointing to new resource
    @PostMapping
    public ResponseEntity<Product> create(@RequestBody Product product) {
        // Generate ID for new product
        Long assignedId = nextId++;
        // => First call: assignedId=1, nextId becomes 2

        Product created = new Product(assignedId, product.name(), product.price());
        products.put(created.id(), created);
        // => Stored in map: {1 -> Product(1, "Laptop", 999.99)}

        ResponseEntity<Product> response = ResponseEntity
            .created(URI.create("/api/products/" + created.id()))  // => Location: /api/products/1
            .body(created);                                         // => Response body with product

        // => HTTP/1.1 201 Created
        // => Location: /api/products/1
        // => Body: {"id":1,"name":"Laptop","price":999.99}
        return response;
    }

    // GET - Retrieve resource
    // Returns 200 OK if found, 404 Not Found if not found
    @GetMapping("/{id}")
    public ResponseEntity<Product> get(@PathVariable Long id) {
        Product product = products.get(id);
        // => Lookup product by ID in map

        if (product == null) {
            // Product not found - return 404
            ResponseEntity<Product> notFound = ResponseEntity.notFound().build();
            // => HTTP/1.1 404 Not Found
            // => No response body
            return notFound;
        }

        ResponseEntity<Product> found = ResponseEntity.ok(product);
        // => HTTP/1.1 200 OK
        // => Body: {"id":1,"name":"Laptop","price":999.99}
        return found;
    }

    // PUT - Update entire resource
    // Returns 200 OK with updated resource
    @PutMapping("/{id}")
    public ResponseEntity<Product> update(@PathVariable Long id, @RequestBody Product product) {
        // Create updated product with provided ID
        Product updated = new Product(id, product.name(), product.price());
        // => Replaces entire resource (PUT semantics)

        products.put(id, updated);
        // => Updates map entry

        ResponseEntity<Product> response = ResponseEntity.ok(updated);
        // => HTTP/1.1 200 OK
        // => Body: {"id":1,"name":"Gaming Laptop","price":1299.99}
        return response;
    }

    // DELETE - Remove resource
    // Returns 204 No Content (successful deletion)
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        products.remove(id);
        // => Removes entry from map (returns null if not found)

        ResponseEntity<Void> response = ResponseEntity.noContent().build();
        // => HTTP/1.1 204 No Content
        // => No response body (Void type)
        return response;
    }
}

Key Takeaway: Follow REST conventions: POST (201 Created), GET (200 OK / 404 Not Found), PUT (200 OK), DELETE (204 No Content). Include Location header on resource creation.

Why It Matters: Semantic HTTP methods enable infrastructure-level optimizations—GET requests can be cached by browsers and CDNs (reducing server load by 60-80%), while POST/PUT/DELETE are automatically excluded from caching. Idempotent methods (GET, PUT, DELETE) can be safely retried on network failures, whereas non-idempotent methods (POST) require careful handling to prevent duplicate operations in distributed systems.


Example 9: Content Negotiation

Spring Boot supports content negotiation via the Accept header. Clients can request JSON (application/json) or XML (application/xml). Use produces to specify supported formats.

Code:

package com.example.demo.controller;

// Spring MVC annotations
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

// Book DTO
record Book(String title, String author) {}

@RestController
@RequestMapping("/api/books")
public class BookController {

    // Default content negotiation
    // Spring Boot auto-configures JSON via Jackson
    @GetMapping
    public Book getBook() {
        Book book = new Book("Spring Boot in Action", "Craig Walls");
        // => Jackson serializes to JSON by default
        // => Content-Type: application/json
        // => {"title":"Spring Boot in Action","author":"Craig Walls"}
        return book;
    }

    // Explicit JSON production
    // produces attribute restricts supported media types
    @GetMapping(value = "/json", produces = "application/json")
    public Book getBookJson() {
        Book book = new Book("Spring Boot in Action", "Craig Walls");
        // => Only responds to requests with Accept: application/json
        // => Returns 406 Not Acceptable for other Accept headers
        return book;
    }

    // Multiple content types support
    // Requires XML dependency: jackson-dataformat-xml
    // Add to pom.xml:
    // <dependency>
    //   <groupId>com.fasterxml.jackson.dataformat</groupId>
    //   <artifactId>jackson-dataformat-xml</artifactId>
    // </dependency>
    @GetMapping(value = "/multi", produces = {"application/json", "application/xml"})
    public Book getBookMulti() {
        Book book = new Book("Spring Boot in Action", "Craig Walls");

        // Client sends Accept: application/json
        // => Spring returns JSON: {"title":"Spring Boot in Action","author":"Craig Walls"}

        // Client sends Accept: application/xml
        // => Spring returns XML: <Book><title>Spring Boot in Action</title>...</Book>

        // Client sends Accept: text/html
        // => Spring returns 406 Not Acceptable (not in produces list)

        return book;
    }
}

Key Takeaway: Content negotiation allows clients to request different formats via Accept header. Use produces to specify supported media types.

Why It Matters: Content negotiation enables single APIs to serve multiple client types—mobile apps requesting JSON, legacy systems requiring XML, and monitoring tools accepting Prometheus metrics—without duplicating controller code. Production APIs at Amazon and Google use content negotiation to version responses (application/vnd.api.v2+json) and support multiple serialization formats, reducing API proliferation where each format requires separate endpoints that increase maintenance burden and deployment complexity.


Group 3: Data Access Basics

Example 10: Spring Data JPA Introduction

Spring Data JPA eliminates boilerplate CRUD code. Define an interface extending JpaRepository<Entity, ID>, and Spring generates implementations automatically.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Repository["Repository"] --> CrudRepository["CrudRepository"]
    CrudRepository --> PagingAndSortingRepository["PagingAndSortingRepository"]
    PagingAndSortingRepository --> JpaRepository["JpaRepository"]

    style Repository fill:#0173B2,color:#fff
    style CrudRepository fill:#DE8F05,color:#fff
    style PagingAndSortingRepository fill:#029E73,color:#fff
    style JpaRepository fill:#CC78BC,color:#fff

Code:

package com.example.demo.model;

// JPA annotations for entity mapping
import jakarta.persistence.*;

// @Entity marks this class as JPA entity
// JPA will map this to database table
@Entity
@Table(name = "users")  // Maps to "users" table (optional if class name matches table)
public class User {

    // @Id marks primary key field
    // @GeneratedValue tells database to auto-generate values
    // IDENTITY strategy uses database auto-increment (MySQL, PostgreSQL)
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    // => Database generates: 1, 2, 3, ... automatically

    // @Column specifies column constraints
    // nullable=false -> NOT NULL constraint
    // unique=true -> UNIQUE constraint
    @Column(nullable = false, unique = true)
    private String email;
    // => SQL: email VARCHAR(255) NOT NULL UNIQUE

    // No @Column means defaults (nullable, non-unique)
    private String name;
    // => SQL: name VARCHAR(255)

    // JPA requires no-arg constructor (can be protected)
    protected User() {}

    // Public constructor for application code
    public User(String email, String name) {
        this.email = email;
        this.name = name;
    }

    // Getters and setters required for JPA
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}
package com.example.demo.repository;

// Entity class
import com.example.demo.model.User;
// Spring Data JPA repository interface
import org.springframework.data.jpa.repository.JpaRepository;

// JpaRepository<Entity, ID> interface
// Entity = User (entity class)
// ID = Long (primary key type)
// Spring generates implementation at runtime via proxy
public interface UserRepository extends JpaRepository<User, Long> {
    // No implementation code needed!
    // Spring Data JPA generates implementation automatically

    // Inherited methods from JpaRepository:

    // save(user)
    // => INSERT if id=null, UPDATE if id exists
    // => Returns saved entity with generated ID

    // findById(id)
    // => SELECT * FROM users WHERE id = ?
    // => Returns Optional<User> (empty if not found)

    // findAll()
    // => SELECT * FROM users
    // => Returns List<User>

    // deleteById(id)
    // => DELETE FROM users WHERE id = ?

    // count()
    // => SELECT COUNT(*) FROM users
    // => Returns total number of records

    // existsById(id)
    // => SELECT COUNT(*) FROM users WHERE id = ? (optimized)
    // => Returns boolean
}

Key Takeaway: JpaRepository provides CRUD methods out-of-the-box. No implementation code needed—Spring generates it at runtime.

Why It Matters: JPA abstracts database differences across PostgreSQL, MySQL, Oracle, and H2, enabling database portability where the same code runs on development H2 and production PostgreSQL without SQL dialect changes. JpaRepository eliminates 80% of boilerplate data access code (JDBC connection handling, ResultSet mapping, transaction management) that traditionally required 50+ lines per DAO method, reducing data layer bugs from manual resource management and enabling rapid prototyping where adding a database entity requires only 5 lines of code.


Example 11: Custom Queries

Spring Data JPA supports custom queries via @Query with JPQL (Java Persistence Query Language) or native SQL. Use JPQL for database portability, native SQL for database-specific features.

Code:

package com.example.demo.repository;

// Entity class
import com.example.demo.model.User;
// Spring Data JPA
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
// Java collections
import java.util.List;

public interface UserRepository extends JpaRepository<User, Long> {

    // Derived query method - Spring parses method name into query
    // Naming convention: findBy<Property><Operation>
    List<User> findByEmailContaining(String email);
    // => Spring generates: SELECT * FROM users WHERE email LIKE '%?%'
    // => For email="example", finds "user@example.com", "example@test.com"
    // => Returns List<User> (empty if no matches)

    // JPQL query with named parameter
    // JPQL queries use entity names (User), not table names (users)
    // :name is named parameter placeholder
    @Query("SELECT u FROM User u WHERE u.name = :name")
    List<User> findByName(@Param("name") String name);
    // => SELECT * FROM users WHERE name = ?
    // => Named parameter :name bound to method parameter name
    // => Returns List<User> matching name exactly

    // JPQL with multiple parameters and LIKE operator
    // %:domain in JPQL becomes %? in SQL
    @Query("SELECT u FROM User u WHERE u.name = :name AND u.email LIKE %:domain")
    List<User> findByNameAndEmailDomain(@Param("name") String name, @Param("domain") String domain);
    // => SELECT * FROM users WHERE name = ? AND email LIKE '%?'
    // => findByNameAndEmailDomain("Alice", "example.com") finds Alice with email ending in example.com
    // => Returns List<User>

    // Native SQL query for database-specific features
    // nativeQuery=true uses raw SQL instead of JPQL
    // ?1 is positional parameter (first method parameter)
    @Query(value = "SELECT * FROM users WHERE email = ?1", nativeQuery = true)
    User findByEmailNative(String email);
    // => Raw SQL executed directly on database
    // => Returns single User (null if not found)
    // => Use for PostgreSQL-specific syntax, MySQL functions, etc.

    // Native SQL with named parameters and database-specific syntax
    // LIMIT 1 is MySQL/PostgreSQL syntax (won't work on Oracle without modification)
    @Query(value = "SELECT * FROM users WHERE name = :name ORDER BY id DESC LIMIT 1", nativeQuery = true)
    User findLatestByName(@Param("name") String name);
    // => Finds most recently created user with given name
    // => ORDER BY id DESC sorts newest first
    // => LIMIT 1 returns only first result
    // => Returns single User (null if none found)
}

Key Takeaway: Use derived query methods for simple queries. Use @Query with JPQL for portability or native SQL for database-specific optimizations.

Why It Matters: Custom queries enable optimized database access beyond auto-generated CRUD—production applications use native SQL for performance-critical queries with database-specific features (PostgreSQL JSONB operators, MySQL full-text search) while JPQL provides database-independent queries for 90% of use cases. Derived query methods (findByNameAndAge) reduce query code by 70% for simple cases, while @Query handles complex joins, aggregations, and projections that would otherwise require verbose QueryDSL or Criteria API code.


Example 12: Entity Relationships

JPA supports four relationship types: @OneToOne, @OneToMany, @ManyToOne, @ManyToMany. Use mappedBy for bidirectional relationships. Be careful with LAZY vs EAGER fetching to avoid N+1 queries.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    User["User #40;1#41;"] -->|"@OneToMany"| Order["Order #40;*#41;"]
    Order -->|"@ManyToOne"| User

    style User fill:#0173B2,color:#fff
    style Order fill:#029E73,color:#fff

Code:

package com.example.demo.model;

// JPA relationship annotations
import jakarta.persistence.*;
// Java collections
import java.util.List;

// Parent entity in one-to-many relationship
@Entity
@Table(name = "users")
public class User {

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

    private String name;

    // One user has many orders (one-to-many)
    // mappedBy="user" means Order entity owns the relationship
    // Order.user field is the owning side (has foreign key)
    // fetch=LAZY means orders loaded only when accessed (default for @OneToMany)
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Order> orders;
    // => No join table created
    // => Foreign key user_id stored in orders table
    // => orders loaded lazily: SELECT * FROM orders WHERE user_id = ?
    //    only when user.getOrders() called

    // Getters/setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public List<Order> getOrders() { return orders; }
    public void setOrders(List<Order> orders) { this.orders = orders; }
}

// Child entity in one-to-many relationship
@Entity
@Table(name = "orders")
public class Order {

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

    private String product;

    // Many orders belong to one user (many-to-one)
    // Owning side of relationship (has foreign key column)
    // fetch=LAZY means user loaded only when accessed (default for @ManyToOne is EAGER!)
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")  // Foreign key column in orders table
    private User user;
    // => Creates column: user_id BIGINT
    // => Foreign key constraint: FOREIGN KEY (user_id) REFERENCES users(id)
    // => user loaded lazily: SELECT * FROM users WHERE id = ?
    //    only when order.getUser() called

    // Getters/setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getProduct() { return product; }
    public void setProduct(String product) { this.product = product; }

    public User getUser() { return user; }
    public void setUser(User user) { this.user = user; }
}

// ANTI-PATTERN: EAGER fetching
// @OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
// private List<Order> orders;
// Problems:
// 1. Loads ALL orders every time user fetched (even if not needed)
// 2. Cannot paginate or filter orders
// 3. Causes N+1 queries when fetching multiple users:
//    SELECT * FROM users                  -- 1 query
//    SELECT * FROM orders WHERE user_id=1 -- N queries (one per user)
//    SELECT * FROM orders WHERE user_id=2
//    ...
// Solution: Use LAZY (default) and fetch joins when needed:
// @Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.id = :id")

Key Takeaway: Use @OneToMany and @ManyToOne for relationships. Default to LAZY fetching to prevent N+1 queries. Use mappedBy on the non-owning side of bidirectional relationships.

Why It Matters: Relationship mapping eliminates manual foreign key management and join queries—JPA automatically loads related entities and maintains referential integrity through cascade operations. However, N+1 query problems occur when lazy relationships load in loops (1 query for authors + N queries for books), making fetch joins and entity graphs essential for production performance where unoptimized queries can execute 1000+ database roundtrips for 10 parent records, turning 50ms queries into 5-second page loads.


Example 13: Pagination & Sorting

Always paginate large datasets to control memory usage. Spring Data JPA provides Pageable for pagination and Sort for ordering. Return Page<T> to include total count and page metadata.

Code:

package com.example.demo.repository;

// Entity class
import com.example.demo.model.User;
// Spring Data pagination
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
// Spring Data JPA repository
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {

    // Pageable parameter enables pagination and sorting
    // Spring generates query with LIMIT/OFFSET (or database equivalent)
    Page<User> findByNameContaining(String name, Pageable pageable);
    // => SELECT * FROM users WHERE name LIKE '%?%' LIMIT ? OFFSET ?
    // => Also executes: SELECT COUNT(*) FROM users WHERE name LIKE '%?%'
    //    to get total count for pagination metadata
    // => Returns Page<User> with content + metadata (totalPages, totalElements, etc.)
}
package com.example.demo.controller;

// Entity class
import com.example.demo.model.User;
// Repository
import com.example.demo.repository.UserRepository;
// Spring Data pagination classes
import org.springframework.data.domain.*;
// Spring MVC annotations
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
public class UserPageController {

    private final UserRepository userRepository;

    // Constructor injection
    public UserPageController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // GET /api/users?page=0&size=20&sort=name,asc
    // page: zero-based page number (default 0)
    // size: number of records per page (default 10)
    // sort: property,direction (default id)
    @GetMapping
    public Page<User> getUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "10") int size,
        @RequestParam(defaultValue = "id") String sortBy
    ) {
        // Create Pageable object for pagination and sorting
        // PageRequest combines page number, size, and sort criteria
        Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy).ascending());
        // => For page=0, size=10, sortBy=name:
        //    Pageable{page=0, size=10, sort=name: ASC}

        Page<User> result = userRepository.findAll(pageable);
        // => Executes: SELECT * FROM users ORDER BY name ASC LIMIT 10 OFFSET 0
        // => Also executes: SELECT COUNT(*) FROM users

        // Page<User> contains:
        // - content: List<User> for current page
        // - totalElements: total records across all pages
        // - totalPages: number of pages (totalElements / size)
        // - number: current page number (0-based)
        // - size: records per page
        // - first: boolean (is this first page?)
        // - last: boolean (is this last page?)

        return result;
        // => JSON response:
        // {
        //   "content": [{"id":1,"name":"Alice"}, ...],
        //   "totalElements": 100,
        //   "totalPages": 10,
        //   "size": 10,
        //   "number": 0,
        //   "first": true,
        //   "last": false
        // }
    }
}

Key Takeaway: Always paginate large datasets using Pageable. Page<T> includes metadata (total count, total pages, current page) for client-side pagination controls.

Why It Matters: Pagination prevents out-of-memory errors and slow queries when datasets exceed thousands of records—loading 100,000 users into memory causes heap exhaustion, while pagination loads 20 records per request with constant memory usage. Production REST APIs use pagination with sort capabilities to enable infinite scroll (mobile apps), Excel export batch processing (100 records per iteration), and search results display, with Spring automatically executing count queries to provide total page counts that power “Page 1 of 50” UI displays.


Example 14: Database Initialization

Use schema.sql and data.sql for simple initialization. For production, use Flyway or Liquibase for versioned migrations that prevent schema drift across environments.

Code:

-- src/main/resources/schema.sql
-- Executed during Spring Boot startup (before application ready)
-- Creates database schema (tables, indexes, constraints)

CREATE TABLE IF NOT EXISTS users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,  -- Auto-increment primary key
    email VARCHAR(255) NOT NULL UNIQUE,     -- Unique constraint on email
    name VARCHAR(255)                       -- Nullable name field
);
-- => Spring executes this on startup
-- => IF NOT EXISTS prevents errors on restart
-- src/main/resources/data.sql
-- Executed after schema.sql completes
-- Populates tables with initial data

INSERT INTO users (email, name) VALUES ('alice@example.com', 'Alice');
-- => Inserts first user (id auto-generated to 1)

INSERT INTO users (email, name) VALUES ('bob@example.com', 'Bob');
-- => Inserts second user (id auto-generated to 2)

-- Note: These run on every startup!
-- Use ON CONFLICT or check existence to prevent duplicates

Flyway Migration (Production approach):

<!-- pom.xml -->
<!-- Add Flyway dependency for versioned migrations -->
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>
<!-- => Spring Boot auto-configures Flyway when dependency present -->
-- src/main/resources/db/migration/V1__Create_users_table.sql
-- Flyway migration file naming: V{version}__{description}.sql
-- Version number must be unique and incrementing (1, 2, 3, ...)
-- __ (double underscore) separates version from description

CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(255)
);
-- => Flyway executes this ONCE (tracks in flyway_schema_history table)
-- => Subsequent startups skip this migration
-- => New migrations add V2__, V3__, etc.
# src/main/resources/application.properties
# Flyway configuration (optional - sensible defaults)

spring.flyway.enabled=true
# => Enables Flyway (default true when dependency present)

spring.flyway.baseline-on-migrate=true
# => Create baseline for existing databases
# => Allows Flyway to work with non-empty databases

Key Takeaway: Use schema.sql/data.sql for development. Use Flyway (versioned migrations like V1__Description.sql) for production to track schema changes across environments.

Why It Matters: Database initialization scripts enable reproducible dev environments where every developer gets identical schema and test data with ‘git clone && mvn spring-boot:run’, eliminating database state discrepancies that cause ‘works on my machine’ failures. Production applications use Flyway/Liquibase for versioned migrations instead of ddl-auto (which drops data), tracking schema history in production databases and enabling zero-downtime deployments with backwards-compatible migrations that support concurrent old/new application versions during rolling updates.


Group 4: Configuration & Properties

Example 15: Application Properties

Externalize configuration to avoid hardcoding values. Use application.properties or application.yml for environment-specific settings. Inject values with @Value.

Code:

# src/main/resources/application.properties
# Application-specific properties

app.name=My Spring Boot App
# => Custom property for application name

app.version=1.0.0
# => Application version number

app.max-users=100
# => Business rule configuration

# Spring Boot auto-configuration properties
spring.datasource.url=jdbc:h2:mem:testdb
# => H2 in-memory database URL
# => Spring Boot auto-configures DataSource from this

spring.datasource.username=sa
# => Database username (H2 default)

spring.datasource.password=
# => Empty password (H2 allows this for in-memory DB)
package com.example.demo.config;

// @Value annotation for property injection
import org.springframework.beans.factory.annotation.Value;
// Spring stereotype
import org.springframework.stereotype.Component;

@Component
public class AppProperties {

    // @Value injects property value during bean creation
    // ${app.name} references property key from application.properties
    @Value("${app.name}")
    private String appName;
    // => Field populated with "My Spring Boot App" at startup
    // => Happens during dependency injection phase

    @Value("${app.version}")
    private String version;
    // => Populated with "1.0.0"

    // Default value with colon syntax: ${property:defaultValue}
    // If app.max-users not defined, uses 50
    @Value("${app.max-users:50}")
    private int maxUsers;
    // => Uses 100 from properties
    // => Falls back to 50 if property missing
    // => Type conversion happens automatically (String "100" -> int 100)

    // Public method accessing injected properties
    public String getInfo() {
        String info = appName + " v" + version + " (max users: " + maxUsers + ")";
        // => "My Spring Boot App v1.0.0 (max users: 100)"
        return info;
    }
}

Key Takeaway: Externalize configuration to application.properties. Use @Value("${property:default}") for injection with defaults. Never hardcode environment-specific values.

Why It Matters: Centralized configuration in application.properties follows convention-over-configuration philosophy, reducing cognitive load where developers find all settings in one file instead of scattered across 20 XML files and Java classes. Spring Boot’s property binding automatically converts strings to typed values (durations, data sizes, URLs) with validation, preventing runtime failures from configuration typos that traditional properties files defer to runtime, and enabling environment-specific overrides (application-prod.properties) without code changes for deployment-specific settings like database URLs.


Example 16: Configuration Classes

Use @Configuration classes to define beans for third-party libraries or complex object initialization. Beans defined with @Bean methods are managed by Spring.

Code:

package com.example.demo.config;

// Configuration and bean definition annotations
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
// Third-party libraries
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

// @Configuration marks class as source of bean definitions
// Spring processes this during component scanning
@Configuration
public class AppConfig {

    // @Bean method creates Spring-managed bean
    // Method name becomes bean name ("restTemplate") unless overridden
    // Return type defines bean type for dependency injection
    @Bean
    public RestTemplate restTemplate() {
        RestTemplate client = new RestTemplate();
        // => Creates HTTP client
        // => Spring manages lifecycle (creation, destruction)
        // => Singleton by default (one instance shared)
        return client;
    }

    // Custom ObjectMapper bean
    // Replaces Spring Boot's auto-configured ObjectMapper
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();

        // Enable pretty-printing for JSON output
        mapper.enable(SerializationFeature.INDENT_OUTPUT);
        // => JSON responses formatted with newlines and indentation
        // => Easier to debug API responses

        // Can configure other Jackson features:
        // mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        // mapper.setSerializationInclusion(Include.NON_NULL);

        return mapper;
        // => This ObjectMapper used for all JSON serialization/deserialization
    }

    // Bean that depends on another bean
    // Spring automatically injects restTemplate parameter
    @Bean
    public ApiClient apiClient(RestTemplate restTemplate) {
        // restTemplate parameter injected by Spring
        // => Spring finds RestTemplate bean defined above
        // => Passes it as constructor argument

        ApiClient client = new ApiClient(restTemplate);
        // => Creates ApiClient with injected RestTemplate
        return client;
    }
}

// Example third-party integration class
class ApiClient {
    private final RestTemplate restTemplate;

    // Constructor accepting RestTemplate dependency
    public ApiClient(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
        // => Injected RestTemplate available for HTTP calls
    }
}

Key Takeaway: Use @Configuration classes to centralize bean definitions. @Bean methods create Spring-managed objects for third-party libraries.

Why It Matters: Manual bean configuration enables integration with third-party libraries that lack Spring Boot autoconfiguration—custom DataSource beans configure connection pools (HikariCP settings), custom RestTemplate beans add interceptors and error handlers, and custom Jackson ObjectMapper beans configure serialization behavior. Production applications use @Primary to override autoconfigured beans without disabling autoconfiguration entirely, enabling gradual customization where 90% of defaults work but 10% need fine-tuning for production requirements like connection pool sizing or timeout configurations.


Example 17: Profiles for Environments

Profiles enable environment-specific configurations without code changes. Define application-{profile}.properties files and activate with spring.profiles.active.

Code:

# src/main/resources/application-dev.properties
# Development profile configuration
# Activated with: spring.profiles.active=dev

spring.datasource.url=jdbc:h2:mem:devdb
# => H2 in-memory database for fast dev cycles
# => Data lost on restart (acceptable for dev)

app.feature.debug=true
# => Enable debug features (detailed logging, mock data)

logging.level.root=DEBUG
# => Verbose logging for troubleshooting
# => Shows all SQL queries, HTTP requests, etc.
# src/main/resources/application-prod.properties
# Production profile configuration
# Activated with: spring.profiles.active=prod

spring.datasource.url=jdbc:postgresql://prod-server:5432/myapp
# => PostgreSQL production database
# => Persistent storage with backups

app.feature.debug=false
# => Disable debug features (no mock data, no verbose logs)

logging.level.root=WARN
# => Minimal logging for performance
# => Only warnings and errors logged
package com.example.demo.config;

// Configuration and bean annotations
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
public class DataSourceConfig {

    // Bean active only when "dev" profile active
    // @Profile annotation controls bean creation based on active profiles
    @Bean
    @Profile("dev")
    public DataSource devDataSource() {
        DataSource ds = new H2DataSource();
        // => Only created when --spring.profiles.active=dev
        // => Not created in prod profile
        return ds;
    }

    // Bean active only when "prod" profile active
    @Bean
    @Profile("prod")
    public DataSource prodDataSource() {
        DataSource ds = new PostgresDataSource();
        // => Only created when --spring.profiles.active=prod
        // => Not created in dev profile
        return ds;
    }

    // Multiple profiles can be specified
    // @Profile({"dev", "staging"}) - active in dev OR staging
    // @Profile("!prod") - active when prod NOT active
}

// Placeholder classes for demonstration
class H2DataSource {}
class PostgresDataSource {}
class DataSource {}
# src/main/resources/application.properties
# Default properties (no profile suffix)
# Loaded regardless of active profile

spring.profiles.active=dev
# => Activate dev profile by default
# => Loads application-dev.properties
# => Can be overridden via command line or environment variable
# Activate profiles via command line
java -jar app.jar --spring.profiles.active=prod
# => Activates prod profile
# => Loads application-prod.properties
# => Uses prodDataSource bean

# Activate via environment variable
export SPRING_PROFILES_ACTIVE=prod
java -jar app.jar
# => Same as command line activation

Key Takeaway: Profiles enable environment-specific configurations. Use application-{profile}.properties and @Profile annotation. Activate with spring.profiles.active.

Why It Matters: Profile-based configuration enables single codebase deployment across dev/staging/production without recompilation—dev profile uses H2 in-memory database for fast tests, while prod profile uses managed PostgreSQL with connection pooling. Production teams activate multiple profiles (@Profile("!test")) to disable beans during testing, or use profile groups (spring.profiles.group.prod=aws,monitoring) to activate related configurations together, preventing configuration drift where production servers accidentally run with dev database settings.


Group 5: Exception Handling & Validation

Example 18: Global Exception Handling

Centralize error handling with @ControllerAdvice instead of scattered try-catch blocks. Return consistent error responses with proper HTTP status codes.

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Exception["Exception Thrown"] --> ControllerAdvice["@ControllerAdvice"]
    ControllerAdvice --> ExceptionHandler["@ExceptionHandler"]
    ExceptionHandler --> ErrorResponse["Error Response JSON"]

    style Exception fill:#0173B2,color:#fff
    style ControllerAdvice fill:#DE8F05,color:#fff
    style ExceptionHandler fill:#029E73,color:#fff
    style ErrorResponse fill:#CA9161,color:#fff

Code:

package com.example.demo.exception;

// Custom domain exception
// Extends RuntimeException for unchecked exception (no throws declaration needed)
public class ResourceNotFoundException extends RuntimeException {

    // Constructor accepting error message
    public ResourceNotFoundException(String message) {
        super(message);
        // => Sets exception message accessible via getMessage()
    }
}
package com.example.demo.exception;

// HTTP response classes
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
// Exception handling annotations
import org.springframework.web.bind.annotation.*;
// Java time
import java.time.LocalDateTime;

// Error response DTO
// Immutable record for consistent error format across all endpoints
record ErrorResponse(
    String message,        // Human-readable error message
    int status,            // HTTP status code (404, 400, 500, etc.)
    LocalDateTime timestamp // When error occurred
) {}

// @ControllerAdvice applies to ALL controllers globally
// Centralized exception handling instead of try-catch in each controller
@ControllerAdvice
public class GlobalExceptionHandler {

    // @ExceptionHandler catches specific exception type
    // When any controller throws ResourceNotFoundException, this method handles it
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        // Create error response with exception details
        ErrorResponse error = new ErrorResponse(
            ex.getMessage(),                    // => "User not found with id: 123"
            HttpStatus.NOT_FOUND.value(),       // => 404
            LocalDateTime.now()                  // => "2026-01-02T06:21:48"
        );

        ResponseEntity<ErrorResponse> response = ResponseEntity
            .status(HttpStatus.NOT_FOUND)       // => HTTP 404
            .body(error);                        // => JSON error response

        // => HTTP/1.1 404 Not Found
        // => {"message":"User not found","status":404,"timestamp":"2026-01-02T06:21:48"}
        return response;
    }

    // Handle validation errors (IllegalArgumentException)
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex) {
        ErrorResponse error = new ErrorResponse(
            ex.getMessage(),
            HttpStatus.BAD_REQUEST.value(),      // => 400
            LocalDateTime.now()
        );

        ResponseEntity<ErrorResponse> response = ResponseEntity
            .status(HttpStatus.BAD_REQUEST)      // => HTTP 400
            .body(error);

        // => HTTP/1.1 400 Bad Request
        return response;
    }

    // Catch-all handler for unexpected exceptions
    // Prevents stack traces leaking to clients
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
        // Log full exception for debugging (not shown to client)
        // logger.error("Unexpected error", ex);

        ErrorResponse error = new ErrorResponse(
            "Internal server error",             // => Generic message (hide implementation details)
            HttpStatus.INTERNAL_SERVER_ERROR.value(),  // => 500
            LocalDateTime.now()
        );

        ResponseEntity<ErrorResponse> response = ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)  // => HTTP 500
            .body(error);

        // => HTTP/1.1 500 Internal Server Error
        // => Hides exception details (security best practice)
        return response;
    }
}

Key Takeaway: Use @ControllerAdvice for global exception handling. Define @ExceptionHandler methods for specific exceptions. Return consistent error response DTOs with proper HTTP status codes.

Why It Matters: Global exception handlers eliminate repetitive try-catch blocks in every controller method while providing consistent error responses across all APIs—clients receive structured JSON errors (status, message, timestamp) instead of stack traces or HTML error pages. Production APIs use exception handlers to distinguish business errors (404 Not Found, 400 Bad Request) from system errors (500 Internal Server Error), logging stack traces for 500s while hiding implementation details from clients, preventing security vulnerabilities where stack traces leak database schemas or internal paths.


Example 19: Bean Validation

Use JSR-380 Bean Validation annotations (@NotNull, @Size, @Email, etc.) for declarative validation. Combine with @Valid on @RequestBody to automatically validate requests.

Code:

<!-- pom.xml -->
<!-- Add Bean Validation starter dependency -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- => Includes Hibernate Validator (JSR-380 implementation) -->
<!-- => Spring Boot auto-configures validator -->
package com.example.demo.dto;

// JSR-380 validation annotations
import jakarta.validation.constraints.*;

// Request DTO with validation constraints
record CreateUserRequest(
    // @NotBlank: not null, not empty, not whitespace
    // More strict than @NotNull (rejects "", "   ")
    @NotBlank(message = "Username is required")
    @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
    String username,
    // => Validates: username != null && !username.isBlank() && 3 <= username.length() <= 50

    @NotBlank(message = "Email is required")
    @Email(message = "Email must be valid")
    String email,
    // => Validates: email != null && !email.isBlank() && matches email pattern

    @Min(value = 18, message = "Age must be at least 18")
    @Max(value = 120, message = "Age must be at most 120")
    int age
    // => Validates: 18 <= age <= 120
) {}
package com.example.demo.controller;

// Validation annotations
import jakarta.validation.Valid;
// HTTP response
import org.springframework.http.ResponseEntity;
// Spring MVC annotations
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
public class UserValidationController {

    // @Valid triggers JSR-380 validation on request body
    // Spring validates CreateUserRequest before method execution
    @PostMapping
    public ResponseEntity<?> create(@Valid @RequestBody CreateUserRequest request) {
        // Validation process:
        // 1. Jackson deserializes JSON to CreateUserRequest object
        // 2. @Valid triggers validator to check constraints
        // 3a. If valid: method executes normally
        // 3b. If invalid: Spring throws MethodArgumentNotValidException
        //     (caught by @ControllerAdvice and returned as 400 Bad Request)

        // If execution reaches here, validation passed
        // request.username() is guaranteed: not null, not blank, 3-50 chars
        // request.email() is guaranteed: not null, not blank, valid email format
        // request.age() is guaranteed: 18-120

        ResponseEntity<?> response = ResponseEntity.ok(request);
        // => HTTP 200 OK with validated request echoed back
        return response;

        // Example validation failure response (auto-generated by Spring):
        // => HTTP/1.1 400 Bad Request
        // => {
        //   "timestamp": "2026-01-02T06:21:48",
        //   "status": 400,
        //   "error": "Bad Request",
        //   "errors": [{
        //     "field": "username",
        //     "rejectedValue": "ab",
        //     "message": "Username must be between 3 and 50 characters"
        //   }]
        // }
    }
}

Key Takeaway: Use @Valid with JSR-380 annotations for declarative validation. Spring automatically returns 400 Bad Request with validation error details.

Why It Matters: Declarative validation moves error handling from scattered if-statements to compile-time verifiable annotations, reducing validation code by 60% while providing standardized error messages that internationalize easily. Validation at the API boundary prevents malformed data from corrupting databases—production teams report 40% fewer data integrity bugs after adopting JSR-380, and validation annotations serve as executable documentation that new developers can understand without reading implementation code.


Example 20: Custom Validators

Create custom validation annotations for complex business rules that can’t be expressed with standard JSR-380 annotations. Implement ConstraintValidator interface.

Code:

package com.example.demo.validation;

// JSR-380 constraint annotation
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
// Java annotation
import java.lang.annotation.*;

// Custom validation annotation
// @Target specifies where annotation can be used (fields, parameters)
// @Retention(RUNTIME) makes annotation available at runtime for validation
// @Constraint links annotation to validator implementation
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordValidator.class)
public @interface ValidPassword {

    // Default error message (can be overridden in @ValidPassword annotation)
    String message() default "Password must contain at least one uppercase, one lowercase, and one digit";

    // Required by JSR-380 spec (for validation groups)
    Class<?>[] groups() default {};

    // Required by JSR-380 spec (for custom payload)
    Class<? extends Payload>[] payload() default {};
}
package com.example.demo.validation;

// JSR-380 validator interface
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

// ConstraintValidator<AnnotationType, ValidatedType>
// AnnotationType: @ValidPassword
// ValidatedType: String (the field/parameter type being validated)
public class PasswordValidator implements ConstraintValidator<ValidPassword, String> {

    // Called once during validator initialization
    // Can extract annotation parameters for configuration
    @Override
    public void initialize(ValidPassword constraintAnnotation) {
        // Initialization logic if needed
        // Can access annotation attributes: constraintAnnotation.message()
    }

    // Called for each validation
    // value: the String being validated (password field value)
    // context: provides access to build custom error messages
    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        if (password == null) {
            // null values handled by @NotNull separately
            // Return false to fail validation, or true to allow null
            return false;
        }

        // Check for at least one uppercase letter (A-Z)
        boolean hasUppercase = password.chars().anyMatch(Character::isUpperCase);
        // => "Password123" has 'P' (uppercase) -> true
        // => "password123" has no uppercase -> false

        // Check for at least one lowercase letter (a-z)
        boolean hasLowercase = password.chars().anyMatch(Character::isLowerCase);
        // => "Password123" has "assword" (lowercase) -> true

        // Check for at least one digit (0-9)
        boolean hasDigit = password.chars().anyMatch(Character::isDigit);
        // => "Password123" has "123" (digits) -> true

        boolean valid = hasUppercase && hasLowercase && hasDigit;
        // => "Password123" -> true (all conditions met)
        // => "password" -> false (no uppercase, no digit)
        // => "PASSWORD123" -> false (no lowercase)

        return valid;
    }
}
package com.example.demo.dto;

// Custom validator annotation
import com.example.demo.validation.ValidPassword;
// Standard validation annotations
import jakarta.validation.constraints.NotBlank;

// Request DTO using custom validator
record ChangePasswordRequest(
    @NotBlank
    String oldPassword,
    // => Standard validation: not null, not blank

    @NotBlank
    @ValidPassword  // Custom validator applied
    String newPassword
    // => Validated by PasswordValidator
    // => Must be: not blank, have uppercase, have lowercase, have digit
) {}

Key Takeaway: Create custom @interface annotations with @Constraint and implement ConstraintValidator<AnnotationType, FieldType> for reusable complex validation logic.

Why It Matters: Custom validators encapsulate complex business rules into reusable, testable components that work across controllers, services, and batch jobs without code duplication. Production applications use custom validators for domain-specific constraints like credit card Luhn checks, VAT number validation, and business-hour scheduling rules, reducing validation bugs by centralizing logic that would otherwise scatter across 10+ service methods, each with subtly different implementations causing inconsistent behavior.


Example 21: Exception Hierarchy - Custom Exceptions

Organize exceptions into a hierarchy for different error scenarios in your domain.

Code:

package com.example.demo.exception;

// Java collections for field errors
import java.util.Map;

// Base domain exception
// Abstract class forces subclasses (cannot instantiate directly)
public abstract class DomainException extends RuntimeException {

    // Error code for API responses (e.g., "USER_001", "PAYMENT_FAILED")
    private final String errorCode;

    // Constructor accepting error code and message
    public DomainException(String errorCode, String message) {
        super(message);
        // => Sets exception message

        this.errorCode = errorCode;
        // => Stores error code for structured error responses
    }

    // Public getter for error code
    public String getErrorCode() {
        return errorCode;
        // => Used by @ControllerAdvice to build error responses
    }
}

// Resource not found exception (404 errors)
// Extends DomainException to inherit error code functionality
public class ResourceNotFoundException extends DomainException {

    // Constructor accepting resource type and ID
    public ResourceNotFoundException(String resource, Long id) {
        super(
            "NOT_FOUND",                                      // Error code
            resource + " not found with id: " + id            // Message
        );
        // => new ResourceNotFoundException("User", 123L)
        //    Creates exception with:
        //    - errorCode = "NOT_FOUND"
        //    - message = "User not found with id: 123"
    }
}

// Validation exception with field-level errors
public class ValidationException extends DomainException {

    // Map of field names to error messages
    // e.g., {"email": "Invalid format", "age": "Must be 18+"}
    private final Map<String, String> fieldErrors;

    // Constructor accepting field errors map
    public ValidationException(Map<String, String> fieldErrors) {
        super("VALIDATION_ERROR", "Validation failed");
        // => Fixed error code and message

        this.fieldErrors = fieldErrors;
        // => Store field-level errors for detailed response
    }

    // Public getter for field errors
    public Map<String, String> getFieldErrors() {
        return fieldErrors;
        // => Used by @ControllerAdvice to include field details in response
    }
}

// Business rule violation exception (422 errors)
public class BusinessRuleException extends DomainException {

    // Constructor for business rule violations
    public BusinessRuleException(String message) {
        super("BUSINESS_RULE", message);
        // => new BusinessRuleException("Insufficient balance")
        //    Creates exception with:
        //    - errorCode = "BUSINESS_RULE"
        //    - message = "Insufficient balance"
    }
}
package com.example.demo.service;

// Custom exceptions
import com.example.demo.exception.*;
// Spring stereotype
import org.springframework.stereotype.Service;
// Java math for decimal calculations
import java.math.BigDecimal;

@Service
public class OrderService {

    // Business method with domain exception handling
    public void placeOrder(Long userId, BigDecimal amount) {
        // Validate order amount
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new BusinessRuleException("Order amount must be positive");
            // => Throws with errorCode="BUSINESS_RULE"
            // => Results in 422 Unprocessable Entity response
        }

        // Find user (simulate repository call)
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User", userId));
        // => If user not found, throws ResourceNotFoundException
        // => Results in 404 Not Found response

        // Check business rule: sufficient balance
        if (user.getBalance().compareTo(amount) < 0) {
            throw new BusinessRuleException("Insufficient balance for order");
            // => Business logic violation
        }

        // Process order (simulation)
        // ...
    }
}

// Placeholder classes
class User {
    public BigDecimal getBalance() { return BigDecimal.valueOf(1000); }
}

interface UserRepository {
    java.util.Optional<User> findById(Long id);
}
package com.example.demo.exception;

// HTTP response
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
// Exception handling
import org.springframework.web.bind.annotation.*;
// Java collections
import java.util.Map;

// Error response DTO with optional details
record ErrorDetail(
    String errorCode,           // Machine-readable error code
    String message,             // Human-readable message
    Map<String, String> details // Optional field-level details (for ValidationException)
) {}

// Global handler for domain exceptions
@ControllerAdvice
public class DomainExceptionHandler {

    // Handle resource not found (404)
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorDetail> handleNotFound(ResourceNotFoundException ex) {
        ErrorDetail error = new ErrorDetail(
            ex.getErrorCode(),    // => "NOT_FOUND"
            ex.getMessage(),      // => "User not found with id: 123"
            null                  // => No field details
        );

        ResponseEntity<ErrorDetail> response = ResponseEntity
            .status(HttpStatus.NOT_FOUND)  // => 404
            .body(error);

        // => HTTP/1.1 404 Not Found
        // => {"errorCode":"NOT_FOUND","message":"User not found...","details":null}
        return response;
    }

    // Handle validation errors (400)
    @ExceptionHandler(ValidationException.class)
    public ResponseEntity<ErrorDetail> handleValidation(ValidationException ex) {
        ErrorDetail error = new ErrorDetail(
            ex.getErrorCode(),      // => "VALIDATION_ERROR"
            ex.getMessage(),        // => "Validation failed"
            ex.getFieldErrors()     // => {"email":"Invalid","age":"Must be 18+"}
        );

        ResponseEntity<ErrorDetail> response = ResponseEntity
            .status(HttpStatus.BAD_REQUEST)  // => 400
            .body(error);

        // => HTTP/1.1 400 Bad Request
        // => {"errorCode":"VALIDATION_ERROR","message":"...","details":{...}}
        return response;
    }

    // Handle business rule violations (422)
    @ExceptionHandler(BusinessRuleException.class)
    public ResponseEntity<ErrorDetail> handleBusinessRule(BusinessRuleException ex) {
        ErrorDetail error = new ErrorDetail(
            ex.getErrorCode(),    // => "BUSINESS_RULE"
            ex.getMessage(),      // => "Insufficient balance"
            null                  // => No field details
        );

        ResponseEntity<ErrorDetail> response = ResponseEntity
            .status(HttpStatus.UNPROCESSABLE_ENTITY)  // => 422
            .body(error);

        // => HTTP/1.1 422 Unprocessable Entity
        // => Indicates request valid but business rule failed
        return response;
    }
}

Key Takeaway: Organize exceptions into a domain-specific hierarchy with error codes—use abstract base exceptions to enforce consistent structure and handle different exception types appropriately in @ControllerAdvice.

Why It Matters: Exception hierarchies enable granular catch blocks and consistent error handling—catching BusinessException handles all business rule violations without catching technical errors like database connection failures that require different handling. Production services use exception hierarchies to implement retry logic (retry TransientException, fail fast on PermanentException) and structured logging where exception types map to alert severity levels, reducing mean time to recovery by helping on-call engineers quickly distinguish transient failures from critical bugs.


Example 22: File Upload & Download

Handle multipart file uploads and stream file downloads efficiently.

Code:

package com.example.demo.controller;

// HTTP response
import org.springframework.http.*;
// Spring MVC annotations
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
// Spring resource abstraction
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;

// Java I/O
import java.io.IOException;
import java.nio.file.*;
// Java collections
import java.util.*;

@RestController
@RequestMapping("/api/files")
public class FileController {

    // Upload directory path
    private final Path uploadDir = Paths.get("uploads");

    // Constructor ensures upload directory exists
    public FileController() throws IOException {
        Files.createDirectories(uploadDir);
        // => Creates "uploads" directory if not exists
        // => Throws IOException if creation fails (permissions, etc.)
    }

    // Single file upload endpoint
    // Content-Type: multipart/form-data required
    @PostMapping("/upload")
    public ResponseEntity<Map<String, String>> uploadFile(
        @RequestParam("file") MultipartFile file
    ) throws IOException {
        // @RequestParam("file") extracts file from multipart request
        // MultipartFile provides methods: getOriginalFilename(), getSize(), getBytes(), transferTo()

        if (file.isEmpty()) {
            // File part present but no content
            Map<String, String> error = Map.of("error", "File is empty");
            return ResponseEntity.badRequest().body(error);
            // => HTTP 400 Bad Request
        }

        // Generate unique filename to prevent collisions
        String filename = System.currentTimeMillis() + "_" + file.getOriginalFilename();
        // => For "document.pdf" uploaded at timestamp 1703433600000:
        //    filename = "1703433600000_document.pdf"

        Path filePath = uploadDir.resolve(filename);
        // => Resolves to: uploads/1703433600000_document.pdf

        file.transferTo(filePath);
        // => Saves uploaded file to disk
        // => Efficient streaming (doesn't load entire file into memory)

        Map<String, String> response = Map.of(
            "filename", filename,
            "size", String.valueOf(file.getSize()),           // => "15360" (bytes)
            "contentType", file.getContentType()              // => "application/pdf"
        );

        return ResponseEntity.ok(response);
        // => HTTP 200 OK
        // => {"filename":"1703433600000_document.pdf","size":"15360","contentType":"application/pdf"}
    }

    // Multiple file upload endpoint
    @PostMapping("/upload-multiple")
    public ResponseEntity<List<String>> uploadMultipleFiles(
        @RequestParam("files") MultipartFile[] files
    ) throws IOException {
        // MultipartFile[] accepts multiple files with same form field name

        List<String> uploadedFiles = new ArrayList<>();

        for (MultipartFile file : files) {
            if (!file.isEmpty()) {
                // Generate unique filename for each file
                String filename = System.currentTimeMillis() + "_" + file.getOriginalFilename();
                Path filePath = uploadDir.resolve(filename);
                file.transferTo(filePath);
                uploadedFiles.add(filename);
                // => Adds filename to result list
            }
        }

        return ResponseEntity.ok(uploadedFiles);
        // => HTTP 200 OK
        // => ["1703433600000_file1.jpg", "1703433601000_file2.png"]
    }

    // File download endpoint
    // Streams file back to client
    @GetMapping("/download/{filename}")
    public ResponseEntity<Resource> downloadFile(@PathVariable String filename) throws IOException {
        Path filePath = uploadDir.resolve(filename).normalize();
        // => resolve() constructs path: uploads/filename
        // => normalize() removes ".." to prevent directory traversal attacks

        if (!Files.exists(filePath)) {
            // File not found on disk
            return ResponseEntity.notFound().build();
            // => HTTP 404 Not Found
        }

        Resource resource = new UrlResource(filePath.toUri());
        // => Wraps file as Spring Resource for streaming
        // => Doesn't load entire file into memory

        return ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_OCTET_STREAM)  // => Binary download
            .header(HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename=\"" + resource.getFilename() + "\"")
            // => Tells browser to download (not display inline)
            // => filename in quotes for proper handling
            .body(resource);
        // => HTTP 200 OK
        // => Content-Disposition: attachment; filename="document.pdf"
        // => Streams file content
    }

    // List all uploaded files
    @GetMapping("/list")
    public ResponseEntity<List<String>> listFiles() throws IOException {
        List<String> files = Files.list(uploadDir)
            // => Returns Stream<Path> of files in upload directory
            .map(Path::getFileName)
            // => Extracts filename from full path
            .map(Path::toString)
            // => Converts Path to String
            .toList();
        // => Collects to List<String>

        return ResponseEntity.ok(files);
        // => HTTP 200 OK
        // => ["1703433600000_document.pdf", "1703433601000_image.jpg"]
    }
}
# src/main/resources/application.properties
# Configure max file upload size

spring.servlet.multipart.max-file-size=10MB
# => Maximum size for single file (default: 1MB)
# => Requests with larger files rejected with 400 Bad Request

spring.servlet.multipart.max-request-size=10MB
# => Maximum size for entire multipart request (all files combined)
# => Prevents DoS attacks via massive uploads

Key Takeaway: Use MultipartFile for uploads and Resource with UrlResource for downloads—configure max file size limits and always validate/sanitize filenames to prevent directory traversal attacks.

Why It Matters: Streaming file uploads and downloads prevents out-of-memory errors when users upload multi-gigabyte files—buffering entire files in memory causes heap exhaustion under concurrent uploads. Production file services at Dropbox and AWS S3 use streaming APIs to handle petabytes of data with constant memory usage, while proper content-type headers and content-disposition enable in-browser preview versus download behavior, improving user experience without custom client-side code.


Example 23: Logging Configuration

Configure logging levels, patterns, and file output for different environments.

Code:

package com.example.demo.controller;

// SLF4J logging API (facade)
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// Spring MVC annotations
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/demo")
public class LoggingController {

    // Create logger for this class
    // LoggerFactory.getLogger(Class) creates logger named after fully qualified class name
    // Logger is static final (created once, shared across instances)
    private static final Logger log = LoggerFactory.getLogger(LoggingController.class);
    // => Logger name: "com.example.demo.controller.LoggingController"
    // => Can be configured independently in application.properties

    @GetMapping("/process")
    public String process(@RequestParam String data) {

        // TRACE level - most verbose, rarely enabled even in dev
        // Use for very detailed debugging (method entry/exit, variable values)
        log.trace("TRACE: Processing started with data: {}", data);
        // => Only logged if TRACE level enabled for this logger
        // => {} is placeholder replaced by data parameter (avoids string concatenation overhead)

        // DEBUG level - detailed information for debugging
        // Enabled in development, disabled in production
        log.debug("DEBUG: Validating input data: {}", data);
        // => Logged in dev environments (logging.level.com.example=DEBUG)
        // => Not logged in prod (logging.level.root=INFO)

        // INFO level - general informational messages
        // Default production level - shows important events
        log.info("INFO: Processing request for data: {}", data);
        // => Logged in all environments
        // => Use for: successful operations, business events, startup info

        // WARN level - potentially harmful situations
        // Something unexpected but application can continue
        log.warn("WARN: Processing time exceeded threshold for: {}", data);
        // => Logged in all environments
        // => Use for: deprecated API usage, performance degradation, missing configs

        try {
            if (data.equals("error")) {
                throw new IllegalArgumentException("Invalid data");
            }
        } catch (Exception e) {
            // ERROR level - error events that might allow app to continue
            // Third parameter (exception) logs full stack trace
            log.error("ERROR: Failed to process data: {}", data, e);
            // => Logged in all environments with stack trace
            // => Use for: caught exceptions, recoverable errors, failed operations
        }

        return "Processed: " + data;
    }
}
# src/main/resources/application.properties
# Logging configuration

# Root logging level (applies to all loggers unless overridden)
logging.level.root=INFO
# => All packages log at INFO level by default
# => Logs: INFO, WARN, ERROR
# => Doesn't log: DEBUG, TRACE

# Package-specific logging levels (overrides root level)
logging.level.com.example.demo=DEBUG
# => Our application code logs at DEBUG level
# => Logs: DEBUG, INFO, WARN, ERROR

logging.level.com.example.demo.controller=TRACE
# => Controllers log at TRACE level (most verbose)
# => Logs: TRACE, DEBUG, INFO, WARN, ERROR

# Spring framework logging
logging.level.org.springframework.web=DEBUG
# => Shows HTTP request/response details

# Hibernate SQL logging
logging.level.org.hibernate.SQL=DEBUG
# => Shows generated SQL queries
# => Add logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE to see parameter values

# Console output pattern
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %logger{36} - %msg%n
# => 2026-01-02 06:21:48 - c.e.demo.controller.LoggingController - INFO: Processing...
# %d = date/time
# %logger{36} = logger name (max 36 characters)
# %msg = log message
# %n = newline

# Log file configuration
logging.file.name=logs/application.log
# => Writes logs to logs/application.log file
# => Creates directory if not exists

logging.file.max-size=10MB
# => Rotate log file when reaches 10MB
# => Creates application.log.1, application.log.2, etc.

logging.file.max-history=30
# => Keep 30 days of rotated log files
# => Deletes older files automatically

# Log file pattern (different from console)
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
# => 2026-01-02 06:21:48 [http-nio-8080-exec-1] INFO  c.e.demo.controller.LoggingController - ...
# [%thread] = thread name
# %-5level = log level, left-aligned, 5 characters wide
# src/main/resources/application-dev.yml
# Development profile logging (more verbose)

logging:
  level:
    root: DEBUG # Debug everything in dev
    com.example.demo: TRACE # Trace our code
  pattern:
    console: "%clr(%d{HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n"
    # => Colorized console output for better readability in dev
    # %clr(...){color} adds ANSI colors
# src/main/resources/application-prod.yml
# Production profile logging (minimal)

logging:
  level:
    root: WARN # Only warnings and errors
    com.example.demo: INFO # Our code at INFO level
  file:
    name: /var/log/myapp/application.log # Absolute path for production
    max-size: 100MB # Larger rotation size
    max-history: 90 # Keep 90 days of logs

Key Takeaway: Use SLF4J with Logback for flexible logging—configure different levels per package, use parameterized logging for performance, and set up rolling file appenders to prevent disk space exhaustion in production.

Why It Matters: Structured logging with MDC (Mapped Diagnostic Context) enables distributed tracing across microservices by propagating correlation IDs through log statements, making it possible to trace a single user request across 10+ services in production. Log level configuration prevents disk space exhaustion from DEBUG logs in production (which can log 100MB/minute under load) while enabling runtime log level changes via actuator endpoints to troubleshoot production issues without redeployment, reducing incident resolution time from hours to minutes.


Example 24: Request/Response Interceptors

Intercept HTTP requests and responses for cross-cutting concerns like logging, authentication, and metrics.

Code:

package com.example.demo.interceptor;

// Servlet API
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
// SLF4J logging
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// Spring stereotype
import org.springframework.stereotype.Component;
// Spring MVC interceptor
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

@Component
public class RequestLoggingInterceptor implements HandlerInterceptor {
    private static final Logger log = LoggerFactory.getLogger(RequestLoggingInterceptor.class);

    // preHandle called BEFORE controller method execution
    // Return true to continue processing, false to stop
    @Override
    public boolean preHandle(
        HttpServletRequest request,
        HttpServletResponse response,
        Object handler
    ) {
        // Record request start time
        long startTime = System.currentTimeMillis();
        request.setAttribute("startTime", startTime);
        // => Stored in request scope for later retrieval in afterCompletion()

        log.info("==> Incoming request: {} {} from {}",
            request.getMethod(),       // => "GET", "POST", etc.
            request.getRequestURI(),   // => "/api/users/123"
            request.getRemoteAddr()    // => "127.0.0.1" or client IP
        );
        // => "==> Incoming request: GET /api/users from 127.0.0.1"

        return true;
        // => true continues to next interceptor or controller
        // => false stops processing (returns response immediately)
    }

    // postHandle called AFTER controller method execution, BEFORE view rendering
    // Only called if controller succeeds (no exception thrown)
    @Override
    public void postHandle(
        HttpServletRequest request,
        HttpServletResponse response,
        Object handler,
        ModelAndView modelAndView
    ) {
        log.debug("Controller method completed, status: {}", response.getStatus());
        // => Called only if controller executed successfully
        // => Not called if exception thrown in controller
    }

    // afterCompletion called AFTER response is sent
    // ALWAYS executed (even if exception occurred in controller)
    @Override
    public void afterCompletion(
        HttpServletRequest request,
        HttpServletResponse response,
        Object handler,
        Exception ex
    ) {
        // Retrieve start time from request scope
        long startTime = (Long) request.getAttribute("startTime");
        long duration = System.currentTimeMillis() - startTime;
        // => Calculate request processing time

        log.info("<== Completed: {} {} - Status: {} - Duration: {}ms",
            request.getMethod(),
            request.getRequestURI(),
            response.getStatus(),    // => 200, 404, 500, etc.
            duration
        );
        // => "<== Completed: GET /api/users - Status: 200 - Duration: 45ms"

        if (ex != null) {
            // Exception occurred during processing
            log.error("Request failed with exception", ex);
            // => Logs full stack trace
        }
    }
}
package com.example.demo.interceptor;

// Servlet API
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
// Spring stereotype
import org.springframework.stereotype.Component;
// Spring MVC interceptor
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class AuthenticationInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(
        HttpServletRequest request,
        HttpServletResponse response,
        Object handler
    ) {
        // Extract Authorization header
        String authHeader = request.getHeader("Authorization");
        // => "Bearer eyJhbGciOiJIUzI1..." or null if not present

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            // No authorization header or wrong format
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            // => Sets HTTP status to 401 Unauthorized
            return false;
            // => Stops processing, returns 401 immediately
            // => Controller never executed
        }

        // Extract token from header
        String token = authHeader.substring(7);
        // => "Bearer " is 7 characters
        // => Extracts token part: "eyJhbGciOiJIUzI1..."

        if (!isValidToken(token)) {
            // Token validation failed (expired, invalid signature, etc.)
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            // => 403 Forbidden (authenticated but not authorized)
            return false;
        }

        // Token valid - extract user information
        String userId = extractUserId(token);
        request.setAttribute("userId", userId);
        // => Store userId in request scope for controller access

        return true;
        // => Continue to controller with authenticated request
    }

    // Simplified token validation (in real app: verify JWT signature, expiry)
    private boolean isValidToken(String token) {
        return token != null && !token.isEmpty();
    }

    // Simplified user ID extraction (in real app: parse JWT claims)
    private String extractUserId(String token) {
        return "user123";
    }
}
package com.example.demo.config;

// Interceptor classes
import com.example.demo.interceptor.*;
// Spring configuration
import org.springframework.context.annotation.Configuration;
// Spring MVC configuration
import org.springframework.web.servlet.config.annotation.*;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final RequestLoggingInterceptor loggingInterceptor;
    private final AuthenticationInterceptor authInterceptor;

    // Constructor injection of interceptors
    public WebConfig(
        RequestLoggingInterceptor loggingInterceptor,
        AuthenticationInterceptor authInterceptor
    ) {
        this.loggingInterceptor = loggingInterceptor;
        this.authInterceptor = authInterceptor;
    }

    // Register interceptors with Spring MVC
    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        // Register logging interceptor for all requests
        registry.addInterceptor(loggingInterceptor)
            .addPathPatterns("/**");
        // => /** matches all paths (/, /api, /api/users, /api/users/123, etc.)
        // => Logs all incoming and outgoing requests

        // Register authentication interceptor for protected paths
        registry.addInterceptor(authInterceptor)
            .addPathPatterns("/api/**")           // Include /api/** paths
            .excludePathPatterns("/api/public/**"); // Exclude /api/public/** paths
        // => /api/users requires authentication (matched by /api/**)
        // => /api/public/hello skipped (excluded by /api/public/**)

        // Execution order:
        // 1. loggingInterceptor.preHandle()
        // 2. authInterceptor.preHandle()
        // 3. Controller method
        // 4. authInterceptor.postHandle()
        // 5. loggingInterceptor.postHandle()
        // 6. loggingInterceptor.afterCompletion()
        // 7. authInterceptor.afterCompletion()
    }
}

Key Takeaway: Interceptors implement cross-cutting concerns across multiple controllers—use preHandle for authentication/authorization, postHandle for response modification, and afterCompletion for cleanup and metrics regardless of success or failure.

Why It Matters: Interceptors implement cross-cutting concerns like authentication, rate limiting, and audit logging without polluting business logic with repetitive code that appears in every controller method. Production API gateways use interceptors to enforce authentication tokens, track API usage metrics, and log all requests for compliance (GDPR, SOC2), reducing security vulnerabilities by centralizing authentication logic that would otherwise have 100+ scattered @PreAuthorize checks with inconsistent implementations.


Example 25: CORS Configuration

Configure Cross-Origin Resource Sharing to allow frontend applications from different domains.

Code:

package com.example.demo.config;

// Spring configuration
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
// Spring CORS configuration
import org.springframework.web.cors.*;
// CORS filter
import org.springframework.web.filter.CorsFilter;

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();

        // Allow specific origins (NEVER use "*" with credentials in production!)
        config.addAllowedOrigin("http://localhost:3000");
        // => React dev server allowed
        // => Browser allows requests from http://localhost:3000

        config.addAllowedOrigin("http://localhost:4200");
        // => Angular dev server allowed

        config.addAllowedOrigin("https://myapp.com");
        // => Production frontend allowed

        // Security note: "*" with allowCredentials(true) is forbidden by CORS spec
        // config.addAllowedOrigin("*");  // DON'T DO THIS with credentials!

        // Allow specific HTTP methods
        config.addAllowedMethod("GET");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("DELETE");
        config.addAllowedMethod("OPTIONS");
        // => OPTIONS required for preflight requests
        // => Browser sends OPTIONS before POST/PUT/DELETE for security

        // Allow specific request headers
        config.addAllowedHeader("Authorization");
        // => Allows Authorization: Bearer <token> header

        config.addAllowedHeader("Content-Type");
        // => Allows Content-Type: application/json header

        config.addAllowedHeader("X-Requested-With");
        // => Standard header sent by AJAX libraries

        // Expose headers to frontend JavaScript
        config.addExposedHeader("X-Total-Count");
        // => Frontend can read response.headers.get("X-Total-Count")
        // => Used for pagination metadata

        config.addExposedHeader("X-Custom-Header");
        // => Custom headers need explicit exposure

        // Allow credentials (cookies, authorization headers)
        config.setAllowCredentials(true);
        // => Enables cookies and authorization headers in cross-origin requests
        // => Required for session-based or token-based authentication
        // => MUST use specific origins (not "*") when true

        // Cache preflight response for 1 hour
        config.setMaxAge(3600L);
        // => Browser caches OPTIONS preflight response for 3600 seconds
        // => Reduces OPTIONS requests (browser only sends when cache expires)
        // => Improves performance for frequent cross-origin calls

        // Register CORS configuration for all paths
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        // => Applies CORS rules to all endpoints

        return new CorsFilter(source);
        // => Creates filter that adds CORS headers to responses
    }
}
package com.example.demo.config;

// Spring configuration
import org.springframework.context.annotation.Configuration;
// Spring MVC configuration
import org.springframework.web.servlet.config.annotation.*;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    // Alternative CORS configuration via WebMvcConfigurer
    // Simpler than CorsFilter for basic cases
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")  // Apply to /api/** paths only
            .allowedOrigins("http://localhost:3000", "https://myapp.com")
            // => Only /api/** endpoints have CORS enabled
            // => Other endpoints (/, /health) not affected

            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            // => Allows all request headers (less secure than specific list)

            .exposedHeaders("X-Total-Count")
            .allowCredentials(true)
            .maxAge(3600);
    }
}
package com.example.demo.controller;

// Spring MVC annotations
import org.springframework.web.bind.annotation.*;
// Java collections
import java.util.List;

// Controller-level CORS configuration
// Overrides global CORS configuration for this controller
@RestController
@RequestMapping("/api/products")
@CrossOrigin(
    origins = {"http://localhost:3000"},
    methods = {RequestMethod.GET, RequestMethod.POST},
    maxAge = 3600,
    allowCredentials = "true"
)
public class ProductController {

    @GetMapping
    public List<Product> getProducts() {
        // CORS headers automatically added to response
        // => Access-Control-Allow-Origin: http://localhost:3000
        // => Access-Control-Allow-Credentials: true
        return List.of(new Product(1L, "Laptop"));
    }

    // Method-level CORS (most specific - overrides controller-level)
    @PostMapping
    @CrossOrigin(origins = "*")  // Less restrictive for this endpoint only
    public Product createProduct(@RequestBody Product product) {
        // This endpoint allows ALL origins
        // => Access-Control-Allow-Origin: *
        // => No credentials allowed when origin is *
        return product;
    }
}

// Product DTO
record Product(Long id, String name) {}
# src/main/resources/application.yml
# CORS via properties (Spring Boot 2.4+)
# Simplest configuration for basic cases

spring:
  web:
    cors:
      allowed-origins: http://localhost:3000,https://myapp.com
      allowed-methods: GET,POST,PUT,DELETE,OPTIONS
      allowed-headers: "*"
      exposed-headers: X-Total-Count
      allow-credentials: true
      max-age: 3600

Key Takeaway: Configure CORS at the appropriate level—global filter for application-wide settings, WebMvcConfigurer for path-specific rules, or @CrossOrigin for fine-grained controller/method control. Never use allowedOrigins("*") with allowCredentials(true) in production.

Why It Matters: Properly configured CORS enables frontend SPAs hosted on different domains to consume APIs securely without disabling browser security via “allow all origins” configurations that expose APIs to CSRF attacks. Production applications use CORS to enable mobile apps (*.example.com), partner integrations (partner-domain.com), and development environments (localhost:3000) to access APIs while blocking malicious websites, following principle of least privilege where only trusted origins can make authenticated requests.


Last updated