Beginner
Learn Spring Boot fundamentals through 25 annotated code examples in both Java and Kotlin. Each example is self-contained, runnable, and heavily commented to show what each line does, expected outputs, and key takeaways.
Foundation Knowledge Required: These examples build on Spring Framework concepts. If you're new to Spring, review:
- Spring Framework Dependency Injection - Understanding how Spring manages beans
- Spring Framework Configuration - @Configuration and @Bean patterns
- Spring Framework Component Scanning - How Spring discovers components
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
package com.example.demo;
// => Package declaration
// SpringApplication - launcher utility
// @SpringBootApplication - meta-annotation combining @Configuration, @EnableAutoConfiguration, @ComponentScan
import org.springframework.boot.SpringApplication;
// => Import statement
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 {
// => Begins block
// Main method - Java application entry point
// Spring Boot starts from here like any standard Java program
public static void main(String[] args) {
// => Begins block
// 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);
// => Executes method
// => Console output: "Tomcat started on port(s): 8080 (http)"
// => Application now ready to serve HTTP requests
}
}Auto-Configuration Process Diagram:
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
Classpath["Classpath Scanning"] --> Conditions["Evaluate @Conditional"]
Conditions --> Check1["@ConditionalOnClass"]
Conditions --> Check2["@ConditionalOnMissingBean"]
Conditions --> Check3["@ConditionalOnProperty"]
Check1 --> Decide{All Conditions Met?}
Check2 --> Decide
Check3 --> Decide
Decide -->|Yes| CreateBean["Create Auto-Configured Bean"]
Decide -->|No| Skip["Skip Configuration"]
CreateBean --> Register["Register in ApplicationContext"]
style Classpath fill:#0173B2,color:#fff
style Conditions fill:#DE8F05,color:#fff
style Check1 fill:#029E73,color:#fff
style Check2 fill:#029E73,color:#fff
style Check3 fill:#029E73,color:#fff
style Decide fill:#CC78BC,color:#fff
style CreateBean fill:#CA9161,color:#fff
style Skip fill:#CA9161,color:#fff
style Register fill:#0173B2,color:#fff
Caption: Spring Boot auto-configuration evaluates conditional annotations on classpath classes to determine which beans to create automatically.
Embedded Tomcat Startup Sequence:
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
sequenceDiagram
participant App as SpringApplication
participant Context as ApplicationContext
participant Tomcat as Embedded Tomcat
participant Ready as Application Ready
App->>Context: Create ApplicationContext
Context->>Context: Initialize beans
Context->>Tomcat: Detect spring-boot-starter-web
Tomcat->>Tomcat: Initialize ServletWebServerFactory
Tomcat->>Tomcat: Configure connectors (port 8080)
Tomcat->>Tomcat: Start Tomcat server
Tomcat-->>App: Server started on port 8080
App->>Ready: Publish ApplicationReadyEvent
Note over App,Ready: Total startup typically 2-5 seconds
Caption: Spring Boot automatically starts an embedded Tomcat server when spring-boot-starter-web is detected on the classpath.
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, enabling teams to ship production-ready microservices with minimal boilerplate. This approach allows developers to focus on business logic instead of infrastructure wiring, reducing project setup time from days to minutes. Teams adopting Spring Boot report significantly faster delivery cycles compared to traditional Spring Framework applications that required extensive manual configuration.
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.
package com.example.demo.service;
// => Package declaration
// @Component - Marks class as Spring-managed bean for component scanning
// @Service - Specialization of @Component indicating service layer semantics
import org.springframework.stereotype.Component;
// => Import statement
import org.springframework.stereotype.Service;
// => Import statement
// @Component makes Spring create singleton instance during application startup
// Spring registers this bean in ApplicationContext under name "userRepository"
@Component
class UserRepository {
// => Begins block
// Simple finder method simulating database access
// In real app, this would query database via JPA/JDBC
public String findUser(Long id) {
// => Begins block
String result = "User" + id;
// => Assigns value to variable
// => For id=123, returns "User123"
return result;
// => Returns 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 {
// => Begins block
// final field ensures immutability after construction
// Cannot be reassigned after constructor completes
private final UserRepository userRepository;
// => Declares userRepository field of type final
// 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) {
// => Begins block
// 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) {
// => Begins block
String user = userRepository.findUser(id);
// => Assigns value to variable
// => For id=123, user is "User123"
return user;
// => Returns result
}
}
// 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
package com.example.demo.config;
// => Package declaration
// JSR-250 annotations for lifecycle callbacks
import jakarta.annotation.PostConstruct;
// => Import statement
import jakarta.annotation.PreDestroy;
// => Import statement
// Bean definition and scope configuration
import org.springframework.context.annotation.Bean;
// => Import statement
import org.springframework.context.annotation.Configuration;
// => Import statement
import org.springframework.context.annotation.Scope;
// => Import statement
import org.springframework.stereotype.Component;
// => Import statement
// @Component with default singleton scope
// Spring creates exactly one instance during application startup
// Same instance shared across entire application lifecycle
@Component
// => Annotation applied
public class DatabaseConnection {
// => Begins block
// @PostConstruct called AFTER constructor and AFTER dependency injection
// Use for initialization that requires injected dependencies
// Called exactly once per bean instance
@PostConstruct
// => Annotation applied
public void init() {
// => Begins block
// Initialization logic (connect to database, open resources)
System.out.println("Connecting to database...");
// => Prints to console
// => Output appears during application startup
// => Before any HTTP requests processed
}
// => Block delimiter
// @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() {
// => Begins block
// Cleanup logic executed during graceful shutdown
System.out.println("Closing database connection...");
// => Prints to console
// => 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 {
// => Begins block
// @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")
// => Executes method
public RequestProcessor processor() {
// => Begins block
RequestProcessor instance = new RequestProcessor();
// => Creates new instance
// => New instance created on each injection or ApplicationContext.getBean() call
// => NOT shared across injection points
return instance;
// => Returns result
}
// @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() {
// => Begins block
CacheManager singleton = new CacheManager();
// => Creates new instance
// => Single instance created during startup
// => Cached and reused for entire application lifetime
return singleton;
// => Returns result
}
}
// Placeholder classes for demonstration
class RequestProcessor {
// => Begins block
// In real app: processes individual requests with request-scoped state
}
class CacheManager {
// => Begins block
// 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.
package com.example.demo;
// => Package declaration
// Spring stereotype annotations
import org.springframework.stereotype.Component;
// => Import statement
import org.springframework.stereotype.Repository;
// => Import statement
import org.springframework.stereotype.Service;
// => Import statement
import org.springframework.web.bind.annotation.RestController;
// => Import statement
// @Component - Generic stereotype for any Spring-managed component
// Use when class doesn't fit other stereotypes (Service, Repository, Controller)
@Component
// => Annotation applied
class EmailValidator {
// => Begins block
// Validation logic example
// In real app: comprehensive email format validation
public boolean isValid(String email) {
// => Begins block
boolean valid = email.contains("@");
// => Returns true if email contains '@', false otherwise
return valid;
// => Returns result
}
// => Block delimiter
}
// => Block delimiter
// @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
// => Annotation applied
class UserRepository {
// => Begins block
// In real app: JPA/JDBC code to query database
// Spring translates PersistenceException/SQLException to DataAccessException
}
// => Block delimiter
// @Service - Business logic layer stereotype
// Indicates this class contains business rules and orchestration
// Enables transaction management via @Transactional
@Service
// => Annotation applied
class UserService {
// => Begins block
// In real app: business logic coordinating repositories
// Transaction boundaries typically placed here
}
// => Block delimiter
// @RestController - Web layer stereotype
// Combines @Controller (request handler) + @ResponseBody (JSON serialization)
// Methods return domain objects serialized to JSON automatically
@RestController
class UserController {
// => Begins block
// 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
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 {
// => Begins block
// @GetMapping maps HTTP GET requests to this method
// Equivalent to @RequestMapping(method = RequestMethod.GET)
// Accessible at GET http://localhost:8080/hello
@GetMapping("/hello")
// => Executes method
public String hello() {
// => Begins block
String greeting = "Hello, Spring Boot!";
// => Assigns value to variable
// => Returned string becomes HTTP response body
// => Content-Type: text/plain
return greeting;
// => Returns result
}
// @GetMapping returning domain object
// Jackson automatically serializes User to JSON
// Spring Boot auto-configures ObjectMapper for this
@GetMapping("/user")
// => Executes method
public User getUser() {
// => Begins block
User user = new User("Alice", 30);
// => Creates new instance
// => Jackson serializes to: {"name":"Alice","age":30}
// => Content-Type: application/json
return user;
// => Returns result
}
// @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")
// => Executes method
public User createUser(@RequestBody User user) {
// => Begins block
// 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 result
// => 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) {
// => Executes method
// => 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 reduces JSON-related production bugs by removing manual ObjectMapper configuration errors. Jackson's auto-configuration supports over 20 common Java types out of the box, including Java time types, optional values, and polymorphic objects, removing the need for per-project serialization setup that traditionally caused subtle data contract bugs across API versions.
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.
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")
// => Executes method
public class UserController {
// => Begins block
// @PathVariable extracts {id} from URL path
// GET /api/users/123 maps to getUserById(123)
@GetMapping("/{id}")
// => Executes method
public String getUserById(@PathVariable Long id) {
// => Begins block
// id extracted from URL path segment
// => For /api/users/123, id = 123L
String response = "User ID: " + id;
// => Assigns value to variable
// => Returns "User ID: 123"
return response;
// => Returns result
}
// Multiple path variables in single URL
// GET /api/users/123/posts/456
@GetMapping("/{userId}/posts/{postId}")
// => Executes method
public String getUserPost(@PathVariable Long userId, @PathVariable Long postId) {
// => Begins block
// 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;
// => Assigns value to variable
// => Returns "User 123, Post 456"
return response;
// => Returns result
}
// @RequestParam extracts query parameters from URL
// GET /api/users?page=0&size=20
@GetMapping
public String getUsers(
@RequestParam(defaultValue = "0") int page,
// => Assigns value to variable
@RequestParam(defaultValue = "10") int size
// => Assigns value to variable
) {
// => Begins block
// 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;
// => Assigns value to variable
// => For ?page=0&size=20, returns "Page 0, Size 20"
return response;
// => Returns result
}
// Optional query parameter with required=false
// GET /api/users/search?name=Alice or GET /api/users/search
@GetMapping("/search")
// => Executes method
public String search(@RequestParam(required = false) String name) {
// => Assigns value to variable
if (name == null) {
// => Executes method
// No name parameter provided
String noFilter = "No filter applied";
// => Assigns value to variable
// => Returns when called as /api/users/search
return noFilter;
// => Returns result
}
String filtered = "Searching for: " + name;
// => Assigns value to variable
// => For ?name=Alice, returns "Searching for: Alice"
return filtered;
// => Returns result
}
}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.
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) {
// => Executes method
// => 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) {
// => Executes method
// => Jackson serializes to {"id":1,"username":"alice","email":"alice@example.com"}
}
@RestController
@RequestMapping("/api/users")
// => Executes method
public class UserApiController {
// => Begins block
// POST /api/users with JSON body
// @RequestBody deserializes JSON to CreateUserRequest
@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody CreateUserRequest request) {
// => Begins block
// 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());
// => Creates new instance
// => 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;
// => Returns result
}
// GET /api/users/1
// Returns existing user or 404
@GetMapping("/{id}")
// => Executes method
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
// => Begins block
// Simulate database lookup (in real app: use repository.findById())
UserResponse user = new UserResponse(id, "alice", "alice@example.com");
// => Creates new instance
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;
// => Returns result
}
// DELETE /api/users/1
// Returns 204 No Content (successful deletion with no response body)
@DeleteMapping("/{id}")
// => Executes method
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
// => Begins block
// 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;
// => Returns result
}
}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, significantly reducing unnecessary network traffic 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).
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) {}
// => Executes method
@RestController
@RequestMapping("/api/products")
// => Executes method
public class ProductController {
// => Begins block
// In-memory storage (in real app: use JPA repository)
private final Map<Long, Product> products = new HashMap<>();
// => Creates new instance
private Long nextId = 1L;
// => Assigns value to variable
// POST - Create new resource
// Returns 201 Created with Location header pointing to new resource
@PostMapping
public ResponseEntity<Product> create(@RequestBody Product product) {
// => Begins block
// Generate ID for new product
Long assignedId = nextId++;
// => Assigns value to variable
// => First call: assignedId=1, nextId becomes 2
Product created = new Product(assignedId, product.name(), product.price());
// => Creates new instance
products.put(created.id(), created);
// => Executes method
// => 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;
// => Returns result
}
// GET - Retrieve resource
// Returns 200 OK if found, 404 Not Found if not found
@GetMapping("/{id}")
// => Executes method
public ResponseEntity<Product> get(@PathVariable Long id) {
// => Begins block
Product product = products.get(id);
// => Lookup product by ID in map
if (product == null) {
// => Executes method
// Product not found - return 404
ResponseEntity<Product> notFound = ResponseEntity.notFound().build();
// => HTTP/1.1 404 Not Found
// => No response body
return notFound;
// => Returns result
}
ResponseEntity<Product> found = ResponseEntity.ok(product);
// => HTTP/1.1 200 OK
// => Body: {"id":1,"name":"Laptop","price":999.99}
return found;
// => Returns result
}
// PUT - Update entire resource
// Returns 200 OK with updated resource
@PutMapping("/{id}")
// => Executes method
public ResponseEntity<Product> update(@PathVariable Long id, @RequestBody Product product) {
// => Begins block
// Create updated product with provided ID
Product updated = new Product(id, product.name(), product.price());
// => Creates new instance
// => Replaces entire resource (PUT semantics)
products.put(id, updated);
// => Executes method
// => 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;
// => Returns result
}
// DELETE - Remove resource
// Returns 204 No Content (successful deletion)
@DeleteMapping("/{id}")
// => Executes method
public ResponseEntity<Void> delete(@PathVariable Long id) {
// => Begins block
products.remove(id);
// => Executes method
// => 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;
// => Returns result
}
}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.
package com.example.demo.controller;
// => Package declaration
// Spring MVC annotations
import org.springframework.web.bind.annotation.GetMapping;
// => Import statement
import org.springframework.web.bind.annotation.RequestMapping;
// => Import statement
import org.springframework.web.bind.annotation.RestController;
// => Import statement
// Book DTO
record Book(String title, String author) {}
// => Executes method
@RestController
// => Annotation applied
@RequestMapping("/api/books")
// => Executes method
public class BookController {
// => Begins block
// Default content negotiation
// Spring Boot auto-configures JSON via Jackson
@GetMapping
public Book getBook() {
// => Begins block
Book book = new Book("Spring Boot in Action", "Craig Walls");
// => Creates new instance
// => Jackson serializes to JSON by default
// => Content-Type: application/json
// => {"title":"Spring Boot in Action","author":"Craig Walls"}
return book;
// => Returns result
}
// Explicit JSON production
// produces attribute restricts supported media types
@GetMapping(value = "/json", produces = "application/json")
public Book getBookJson() {
// => Begins block
Book book = new Book("Spring Boot in Action", "Craig Walls");
// => Creates new instance
// => Only responds to requests with Accept: application/json
// => Returns 406 Not Acceptable for other Accept headers
return book;
// => Returns result
}
// 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() {
// => Begins block
Book book = new Book("Spring Boot in Action", "Craig Walls");
// => Creates new instance
// 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;
// => Returns result
}
}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 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
package com.example.demo.model;
// => Package declaration
// JPA annotations for entity mapping
import jakarta.persistence.*;
// => Import statement
// @Entity marks this class as JPA entity
// JPA will map this to database table
@Entity
// => Annotation applied
@Table(name = "users") // Maps to "users" table (optional if class name matches table)
// => Annotation applied
public class User {
// => Begins block
// @Id marks primary key field
// @GeneratedValue tells database to auto-generate values
// IDENTITY strategy uses database auto-increment (MySQL, PostgreSQL)
@Id
// => Annotation applied
@GeneratedValue(strategy = GenerationType.IDENTITY)
// => Annotation applied
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)
// => Annotation applied
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() {}
// => Executes method
// Public constructor for application code
public User(String email, String name) {
// => Begins block
this.email = email;
// => Assigns email to this.email
this.name = name;
// => Assigns name to this.name
}
// Getters and setters required for JPA
public Long getId() { return id; }
// => Begins block
public void setId(Long id) { this.id = id; }
// => Assigns value to variable
public String getEmail() { return email; }
// => Begins block
public void setEmail(String email) { this.email = email; }
// => Assigns value to variable
public String getName() { return name; }
// => Begins block
public void setName(String name) { this.name = name; }
// => Assigns value to variable
}package com.example.demo.repository;
import com.example.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
// JpaRepository<Entity, ID>: Entity=User, ID=Long
// Spring generates complete implementation at runtime via proxy pattern
public interface UserRepository extends JpaRepository<User, Long> {
// Zero boilerplate: save(), findById(), findAll(), deleteById() auto-generated
// save(user) => INSERT when id=null, UPDATE when id exists
// findById(id) => SELECT * FROM users WHERE id=? => Returns Optional<User>
// findAll() => SELECT * FROM users => Returns List<User>
// deleteById(id) => DELETE FROM users WHERE id=?
// count() => SELECT COUNT(*) => Returns total records
}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 the vast majority of boilerplate data access code — JDBC connection handling, ResultSet mapping, and transaction management that traditionally required dozens of 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.
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> {
// => Begins block
// Derived query method - Spring parses method name into query
// Naming convention: findBy<Property><Operation>
List<User> findByEmailContaining(String email);
// => Executes method
// => 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);
// => Executes method
// => 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);
// => Executes method
// => 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
package com.example.demo.model;
// => Package declaration
// JPA relationship annotations
import jakarta.persistence.*;
// => Import statement
// Java collections
import java.util.List;
// => Import statement
// Parent entity in one-to-many relationship
@Entity
// => Annotation applied
@Table(name = "users")
// => Annotation applied
public class User {
// => Begins block
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// => Declares id field of type Long
private String name;
// => Declares name field of type String
// 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; }
// => Begins block
public void setId(Long id) { this.id = id; }
// => Assigns value to variable
public String getName() { return name; }
// => Begins block
public void setName(String name) { this.name = name; }
// => Assigns value to variable
public List<Order> getOrders() { return orders; }
// => Begins block
public void setOrders(List<Order> orders) { this.orders = orders; }
// => Assigns orders to this.orders
}
// Child entity in one-to-many relationship
@Entity
@Table(name = "orders")
public class Order {
// => Begins block
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// => Declares id field of type Long
private String product;
// => Declares product field of type String
// 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; }
// => Begins block
public void setId(Long id) { this.id = id; }
// => Assigns value to variable
public String getProduct() { return product; }
// => Begins block
public void setProduct(String product) { this.product = product; }
// => Assigns value to variable
public User getUser() { return user; }
// => Begins block
public void setUser(User user) { this.user = user; }
// => Assigns user to this.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.
package com.example.demo.repository;
// => Package declaration
// Entity class
import com.example.demo.model.User;
// => Import statement
// Spring Data pagination
import org.springframework.data.domain.Page;
// => Import statement
import org.springframework.data.domain.Pageable;
// Spring Data JPA repository
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
// => Begins block
// Pageable parameter enables pagination and sorting
// Spring generates query with LIMIT/OFFSET (or database equivalent)
Page<User> findByNameContaining(String name, Pageable pageable);
// => Executes method
// => 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;
// => Package declaration
// Entity class
import com.example.demo.model.User;
// => Import statement
// Repository
import com.example.demo.repository.UserRepository;
// => Import statement
// Spring Data pagination classes
import org.springframework.data.domain.*;
// Spring MVC annotations
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
// => Executes method
public class UserPageController {
// => Begins block
private final UserRepository userRepository;
// => Declares userRepository field of type final
// Constructor injection
public UserPageController(UserRepository userRepository) {
// => Begins block
this.userRepository = userRepository;
// => Assigns userRepository to this.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,
// => Assigns value to variable
@RequestParam(defaultValue = "10") int size,
// => Assigns value to variable
@RequestParam(defaultValue = "id") String sortBy
// => Assigns value to variable
) {
// => Begins block
// 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;
// => Returns 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.
-- 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 duplicatesFlyway 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 databasesKey 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.
# 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;
// => Package declaration
// @Value annotation for property injection
import org.springframework.beans.factory.annotation.Value;
// => Import statement
// Spring stereotype
import org.springframework.stereotype.Component;
// => Import statement
@Component
public class AppProperties {
// => Begins block
// @Value injects property value during bean creation
// ${app.name} references property key from application.properties
@Value("${app.name}")
// => Executes method
private String appName;
// => Field populated with "My Spring Boot App" at startup
// => Happens during dependency injection phase
@Value("${app.version}")
// => Executes method
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}")
// => Executes method
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() {
// => Begins block
String info = appName + " v" + version + " (max users: " + maxUsers + ")";
// => Assigns value to variable
// => "My Spring Boot App v1.0.0 (max users: 100)"
return info;
// => Returns result
}
}@ConfigurationProperties Binding Process:
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
Props["application.properties"] --> Scan["Scan @ConfigurationProperties"]
Scan --> Match["Match prefix app.*"]
Match --> Bind1["Bind app.name → name"]
Match --> Bind2["Bind app.version → version"]
Match --> Bind3["Bind app.max-users → maxUsers"]
Bind1 --> Validate["Validate @Validated"]
Bind2 --> Validate
Bind3 --> Validate
Validate --> POJO["Type-Safe POJO Ready"]
style Props fill:#0173B2,color:#fff
style Scan fill:#DE8F05,color:#fff
style Match fill:#029E73,color:#fff
style Bind1 fill:#CC78BC,color:#fff
style Bind2 fill:#CC78BC,color:#fff
style Bind3 fill:#CC78BC,color:#fff
style Validate fill:#CA9161,color:#fff
style POJO fill:#0173B2,color:#fff
Caption: Spring Boot binds properties with matching prefix to POJO fields, with automatic type conversion and optional validation.
Application Properties Loading Hierarchy Diagram:
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
Defaults["Built-in Defaults"] --> AppProps["application.properties"]
AppProps --> AppYml["application.yml"]
AppYml --> ProfileProps["application-{profile}.props"]
ProfileProps --> EnvVars["Environment Variables"]
EnvVars --> CmdArgs["Command Line Arguments"]
CmdArgs --> FinalConfig["Final Configuration"]
Note1["Lowest Priority"] -.-> Defaults
Note2["Highest Priority"] -.-> CmdArgs
style Defaults fill:#0173B2,color:#fff
style AppProps fill:#DE8F05,color:#fff
style AppYml fill:#029E73,color:#fff
style ProfileProps fill:#CC78BC,color:#fff
style EnvVars fill:#CA9161,color:#fff
style CmdArgs fill:#0173B2,color:#fff
style FinalConfig fill:#DE8F05,color:#fff
Caption: Spring Boot loads properties from multiple sources, with later sources overriding earlier ones (command-line arguments have highest priority).
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 many 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.
package com.example.demo.config;
// => Package declaration
// Configuration and bean definition annotations
import org.springframework.context.annotation.Bean;
// => Import statement
import org.springframework.context.annotation.Configuration;
// => Import statement
// Third-party libraries
import org.springframework.web.client.RestTemplate;
// => Import statement
import com.fasterxml.jackson.databind.ObjectMapper;
// => Import statement
import com.fasterxml.jackson.databind.SerializationFeature;
// @Configuration marks class as source of bean definitions
// Spring processes this during component scanning
@Configuration
public class AppConfig {
// => Begins block
// @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() {
// => Begins block
RestTemplate client = new RestTemplate();
// => Creates new instance
// => Creates HTTP client
// => Spring manages lifecycle (creation, destruction)
// => Singleton by default (one instance shared)
return client;
// => Returns result
}
// Custom ObjectMapper bean
// Replaces Spring Boot's auto-configured ObjectMapper
@Bean
public ObjectMapper objectMapper() {
// => Begins block
ObjectMapper mapper = new ObjectMapper();
// => Creates new instance
// Enable pretty-printing for JSON output
mapper.enable(SerializationFeature.INDENT_OUTPUT);
// => Executes method
// => 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;
// => Returns result
// => 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) {
// => Begins block
// restTemplate parameter injected by Spring
// => Spring finds RestTemplate bean defined above
// => Passes it as constructor argument
ApiClient client = new ApiClient(restTemplate);
// => Creates new instance
// => Creates ApiClient with injected RestTemplate
return client;
// => Returns result
}
}
// Example third-party integration class
class ApiClient {
// => Begins block
private final RestTemplate restTemplate;
// => Declares restTemplate field of type final
// Constructor accepting RestTemplate dependency
public ApiClient(RestTemplate restTemplate) {
// => Begins block
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.
# 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 loggedpackage com.example.demo.config;
// => Package declaration
// Configuration and bean annotations
import org.springframework.context.annotation.Bean;
// => Import statement
import org.springframework.context.annotation.Configuration;
// => Import statement
import org.springframework.context.annotation.Profile;
// => Import statement
@Configuration
// => Annotation applied
public class DataSourceConfig {
// => Begins block
// Bean active only when "dev" profile active
// @Profile annotation controls bean creation based on active profiles
@Bean
@Profile("dev")
// => Executes method
public DataSource devDataSource() {
// => Begins block
DataSource ds = new H2DataSource();
// => Creates new instance
// => Only created when --spring.profiles.active=dev
// => Not created in prod profile
return ds;
// => Returns result
}
// Bean active only when "prod" profile active
@Bean
@Profile("prod")
// => Executes method
public DataSource prodDataSource() {
// => Begins block
DataSource ds = new PostgresDataSource();
// => Creates new instance
// => Only created when --spring.profiles.active=prod
// => Not created in dev profile
return ds;
// => Returns result
}
// 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 {}
// => Begins block
class PostgresDataSource {}
// => Begins block
class DataSource {}
// => Begins block# 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 activationKey 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
package com.example.demo.exception;
// => Package declaration
// Custom domain exception
// Extends RuntimeException for unchecked exception (no throws declaration needed)
public class ResourceNotFoundException extends RuntimeException {
// => Begins block
// Constructor accepting error message
public ResourceNotFoundException(String message) {
// => Begins block
super(message);
// => Executes method
// => Sets exception message accessible via getMessage()
}
// => Block delimiter
}package com.example.demo.exception;
// => Package declaration
// HTTP response classes
import org.springframework.http.HttpStatus;
// => Import statement
import org.springframework.http.ResponseEntity;
// => Import statement
// 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.)
// => Executes method
LocalDateTime timestamp // When error occurred
) {}
// => Begins block
// @ControllerAdvice applies to ALL controllers globally
// Centralized exception handling instead of try-catch in each controller
@ControllerAdvice
public class GlobalExceptionHandler {
// => Begins block
// @ExceptionHandler catches specific exception type
// When any controller throws ResourceNotFoundException, this method handles it
@ExceptionHandler(ResourceNotFoundException.class)
// => Executes method
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
// => Begins block
// Create error response with exception details
ErrorResponse error = new ErrorResponse(
// => Creates new instance
ex.getMessage(), // => "User not found with id: 123"
HttpStatus.NOT_FOUND.value(), // => 404
LocalDateTime.now() // => "2026-01-02T06:21:48"
// => Executes method call
);
// => Executes statement
ResponseEntity<ErrorResponse> response = ResponseEntity
.status(HttpStatus.NOT_FOUND) // => HTTP 404
.body(error); // => JSON error response
// => Assigns > JSON error response to //
// => HTTP/1.1 404 Not Found
// => {"message":"User not found","status":404,"timestamp":"2026-01-02T06:21:48"}
return response;
// => Returns result
}
// Handle validation errors (IllegalArgumentException)
@ExceptionHandler(IllegalArgumentException.class)
// => Executes method
public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex) {
// => Begins block
ErrorResponse error = new ErrorResponse(
// => Creates new instance
ex.getMessage(),
// => Executes method
HttpStatus.BAD_REQUEST.value(), // => 400
// => Executes method call
LocalDateTime.now()
// => Executes method
);
// => Executes statement
ResponseEntity<ErrorResponse> response = ResponseEntity
.status(HttpStatus.BAD_REQUEST) // => HTTP 400
// => Executes method call
.body(error);
// => Executes method
// => HTTP/1.1 400 Bad Request
return response;
// => Returns result
}
// Catch-all handler for unexpected exceptions
// Prevents stack traces leaking to clients
@ExceptionHandler(Exception.class)
// => Executes method
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
// => Begins block
// Log full exception for debugging (not shown to client)
// logger.error("Unexpected error", ex);
ErrorResponse error = new ErrorResponse(
// => Creates new instance
"Internal server error", // => Generic message (hide implementation details)
HttpStatus.INTERNAL_SERVER_ERROR.value(), // => 500
// => Executes method call
LocalDateTime.now()
// => Executes method
);
// => Executes statement
ResponseEntity<ErrorResponse> response = ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR) // => HTTP 500
// => Executes method call
.body(error);
// => Executes method
// => HTTP/1.1 500 Internal Server Error
// => Hides exception details (security best practice)
return response;
// => Returns result
}
}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.
<!-- 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;
// => Package declaration
// JSR-380 validation annotations
import jakarta.validation.constraints.*;
// => Import statement
// Request DTO with validation constraints
record CreateUserRequest(
// => Code line
// @NotBlank: not null, not empty, not whitespace
// More strict than @NotNull (rejects "", " ")
@NotBlank(message = "Username is required")
// => Annotation applied
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
// => Annotation applied
String username,
// => Validates: username != null && !username.isBlank() && 3 <= username.length() <= 50
@NotBlank(message = "Email is required")
// => Annotation applied
@Email(message = "Email must be valid")
// => Annotation applied
String email,
// => Validates: email != null && !email.isBlank() && matches email pattern
@Min(value = 18, message = "Age must be at least 18")
// => Annotation applied
@Max(value = 120, message = "Age must be at most 120")
// => Annotation applied
int age
// => Validates: 18 <= age <= 120
) {}
// => Begins blockpackage 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;
// => Returns result
// 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.
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})
// => Executes method
@Retention(RetentionPolicy.RUNTIME)
// => Executes method
@Constraint(validatedBy = PasswordValidator.class)
// => Assigns value to variable
public @interface ValidPassword {
// => Begins block
// Default error message (can be overridden in @ValidPassword annotation)
String message() default "Password must contain at least one uppercase, one lowercase, and one digit";
// => Executes method
// Required by JSR-380 spec (for validation groups)
Class<?>[] groups() default {};
// => Executes method
// Required by JSR-380 spec (for custom payload)
Class<? extends Payload>[] payload() default {};
// => Executes method
}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> {
// => Begins block
// Called once during validator initialization
// Can extract annotation parameters for configuration
@Override
public void initialize(ValidPassword constraintAnnotation) {
// => Begins block
// 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) {
// => Begins block
if (password == null) {
// => Executes method
// null values handled by @NotNull separately
// Return false to fail validation, or true to allow null
return false;
// => Returns result
}
// 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;
// => Returns result
}
}package com.example.demo.dto;
// => Package declaration
// Custom validator annotation
import com.example.demo.validation.ValidPassword;
// => Import statement
// Standard validation annotations
import jakarta.validation.constraints.NotBlank;
// => Import statement
// Request DTO using custom validator
record ChangePasswordRequest(
// => Code line
@NotBlank
// => Annotation applied
String oldPassword,
// => Standard validation: not null, not blank
@NotBlank
// => Annotation applied
@ValidPassword // Custom validator applied
// => Annotation applied
String newPassword
// => Validated by PasswordValidator
// => Must be: not blank, have uppercase, have lowercase, have digit
) {}
// => Begins blockKey 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.
package com.example.demo.exception;
// => Package declaration
// Java collections for field errors
import java.util.Map;
// => Import statement
// Base domain exception
// Abstract class forces subclasses (cannot instantiate directly)
public abstract class DomainException extends RuntimeException {
// => Begins block
// Error code for API responses (e.g., "USER_001", "PAYMENT_FAILED")
private final String errorCode;
// => Declares errorCode field of type final
// Constructor accepting error code and message
public DomainException(String errorCode, String message) {
// => Begins block
super(message);
// => Executes method
// => Sets exception message
this.errorCode = errorCode;
// => Stores error code for structured error responses
}
// => Block delimiter
// Public getter for error code
public String getErrorCode() {
// => Begins block
return errorCode;
// => Returns result
// => Used by @ControllerAdvice to build error responses
}
// => Block delimiter
}
// => Block delimiter
// Resource not found exception (404 errors)
// Extends DomainException to inherit error code functionality
public class ResourceNotFoundException extends DomainException {
// => Begins block
// Constructor accepting resource type and ID
public ResourceNotFoundException(String resource, Long id) {
// => Begins block
super(
// => Code line
"NOT_FOUND", // Error code
// => Code line
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 {
// => Begins block
// Map of field names to error messages
// e.g., {"email": "Invalid format", "age": "Must be 18+"}
private final Map<String, String> fieldErrors;
// => Declares fieldErrors field of type final
// Constructor accepting field errors map
public ValidationException(Map<String, String> fieldErrors) {
// => Begins block
super("VALIDATION_ERROR", "Validation failed");
// => Executes method
// => 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() {
// => Begins block
return fieldErrors;
// => Returns result
// => Used by @ControllerAdvice to include field details in response
}
}
// Business rule violation exception (422 errors)
public class BusinessRuleException extends DomainException {
// => Begins block
// Constructor for business rule violations
public BusinessRuleException(String message) {
// => Begins block
super("BUSINESS_RULE", message);
// => Executes method
// => new BusinessRuleException("Insufficient balance")
// Creates exception with:
// - errorCode = "BUSINESS_RULE"
// - message = "Insufficient balance"
}
}package com.example.demo.service;
// => Package declaration
// Custom exceptions
import com.example.demo.exception.*;
// => Import statement
// Spring stereotype
import org.springframework.stereotype.Service;
// => Import statement
// Java math for decimal calculations
import java.math.BigDecimal;
// => Import statement
@Service
// => Annotation applied
public class OrderService {
// => Begins block
// Business method with domain exception handling
public void placeOrder(Long userId, BigDecimal amount) {
// => Begins block
// Validate order amount
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
// => Executes method
throw new BusinessRuleException("Order amount must be positive");
// => Executes method
// => Throws with errorCode="BUSINESS_RULE"
// => Results in 422 Unprocessable Entity response
}
// Find user (simulate repository call)
User user = userRepository.findById(userId)
// => Retrieves data
.orElseThrow(() -> new ResourceNotFoundException("User", userId));
// => Executes method
// => If user not found, throws ResourceNotFoundException
// => Results in 404 Not Found response
// Check business rule: sufficient balance
if (user.getBalance().compareTo(amount) < 0) {
// => Executes method
throw new BusinessRuleException("Insufficient balance for order");
// => Executes method
// => Business logic violation
}
// Process order (simulation)
// ...
}
}
// Placeholder classes
class User {
// => Begins block
public BigDecimal getBalance() { return BigDecimal.valueOf(1000); }
// => Begins block
}
interface UserRepository {
// => Begins block
java.util.Optional<User> findById(Long id);
// => Executes method
}package com.example.demo.exception;
// => Package declaration
// HTTP response
import org.springframework.http.HttpStatus;
// => Import statement
import org.springframework.http.ResponseEntity;
// => Import statement
// Exception handling
import org.springframework.web.bind.annotation.*;
// => Import statement
// 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)
// => Executes method
) {}
// => Begins block
// Global handler for domain exceptions
@ControllerAdvice
// => Annotation applied
public class DomainExceptionHandler {
// => Class definition begins
// => Begins block
// Handle resource not found (404)
@ExceptionHandler(ResourceNotFoundException.class)
// => Annotation applied
// => Executes method
public ResponseEntity<ErrorDetail> handleNotFound(ResourceNotFoundException ex) {
// => Method definition
// => Begins block
ErrorDetail error = new ErrorDetail(
// => Creates new instance
// => Creates new instance
ex.getErrorCode(), // => "NOT_FOUND"
ex.getMessage(), // => "User not found with id: 123"
null // => No field details
);
// => Executes statement
ResponseEntity<ErrorDetail> response = ResponseEntity
.status(HttpStatus.NOT_FOUND) // => 404
// => Executes method call
.body(error);
// => Executes method
// => HTTP/1.1 404 Not Found
// => {"errorCode":"NOT_FOUND","message":"User not found...","details":null}
return response;
// => Returns value to caller
// => Returns result
}
// Handle validation errors (400)
@ExceptionHandler(ValidationException.class)
// => Annotation applied
// => Executes method
public ResponseEntity<ErrorDetail> handleValidation(ValidationException ex) {
// => Method definition
// => Begins block
ErrorDetail error = new ErrorDetail(
// => Creates new instance
// => Creates new instance
ex.getErrorCode(), // => "VALIDATION_ERROR"
ex.getMessage(), // => "Validation failed"
ex.getFieldErrors() // => {"email":"Invalid","age":"Must be 18+"}
// => Retrieves data
);
// => Executes statement
ResponseEntity<ErrorDetail> response = ResponseEntity
.status(HttpStatus.BAD_REQUEST) // => 400
// => Executes method call
.body(error);
// => Executes method
// => HTTP/1.1 400 Bad Request
// => {"errorCode":"VALIDATION_ERROR","message":"...","details":{...}}
return response;
// => Returns value to caller
// => Returns result
}
// Handle business rule violations (422)
@ExceptionHandler(BusinessRuleException.class)
// => Annotation applied
// => Executes method
public ResponseEntity<ErrorDetail> handleBusinessRule(BusinessRuleException ex) {
// => Method definition
// => Begins block
ErrorDetail error = new ErrorDetail(
// => Creates new instance
// => Creates new instance
ex.getErrorCode(), // => "BUSINESS_RULE"
ex.getMessage(), // => "Insufficient balance"
null // => No field details
);
// => Executes statement
ResponseEntity<ErrorDetail> response = ResponseEntity
.status(HttpStatus.UNPROCESSABLE_ENTITY) // => 422
// => Executes method call
.body(error);
// => Executes method
// => HTTP/1.1 422 Unprocessable Entity
// => Indicates request valid but business rule failed
return response;
// => Returns value to caller
// => Returns result
}
}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.
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")
// => Executes method
public class FileController {
// => Begins block
// Upload directory path
private final Path uploadDir = Paths.get("uploads");
// => Sets uploadDir to string value
// Constructor ensures upload directory exists
public FileController() throws IOException {
// => Begins block
Files.createDirectories(uploadDir);
// => Executes method
// => 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")
// => Executes method
public ResponseEntity<Map<String, String>> uploadFile(
@RequestParam("file") MultipartFile file
// => Executes method
) throws IOException {
// => Begins block
// @RequestParam("file") extracts file from multipart request
// MultipartFile provides methods: getOriginalFilename(), getSize(), getBytes(), transferTo()
if (file.isEmpty()) {
// => Executes method
// File part present but no content
Map<String, String> error = Map.of("error", "File is empty");
// => Assigns value to variable
return ResponseEntity.badRequest().body(error);
// => Returns value to caller
// => HTTP 400 Bad Request
}
// Generate unique filename to prevent collisions
String filename = System.currentTimeMillis() + "_" + file.getOriginalFilename();
// => Assigns value to variable
// => 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);
// => Executes method
// => Saves uploaded file to disk
// => Efficient streaming (doesn't load entire file into memory)
Map<String, String> response = Map.of(
// => Assigns value to variable
"filename", filename,
"size", String.valueOf(file.getSize()), // => "15360" (bytes)
"contentType", file.getContentType() // => "application/pdf"
);
return ResponseEntity.ok(response);
// => Returns value to caller
// => HTTP 200 OK
// => {"filename":"1703433600000_document.pdf","size":"15360","contentType":"application/pdf"}
}
// Multiple file upload endpoint
@PostMapping("/upload-multiple")
// => Executes method
public ResponseEntity<List<String>> uploadMultipleFiles(
@RequestParam("files") MultipartFile[] files
// => Executes method
) throws IOException {
// => Begins block
// MultipartFile[] accepts multiple files with same form field name
List<String> uploadedFiles = new ArrayList<>();
// => Creates new instance
for (MultipartFile file : files) {
// => Executes method
if (!file.isEmpty()) {
// => Executes method
// Generate unique filename for each file
String filename = System.currentTimeMillis() + "_" + file.getOriginalFilename();
// => Assigns value to variable
Path filePath = uploadDir.resolve(filename);
// => Calls resolve()
// => Stores result in filePath
file.transferTo(filePath);
// => Executes method
uploadedFiles.add(filename);
// => Executes method
// => Adds filename to result list
}
}
return ResponseEntity.ok(uploadedFiles);
// => Returns value to caller
// => HTTP 200 OK
// => ["1703433600000_file1.jpg", "1703433601000_file2.png"]
}
// File download endpoint
// Streams file back to client
@GetMapping("/download/{filename}")
// => Executes method
public ResponseEntity<Resource> downloadFile(@PathVariable String filename) throws IOException {
// => Begins block
Path filePath = uploadDir.resolve(filename).normalize();
// => resolve() constructs path: uploads/filename
// => normalize() removes ".." to prevent directory traversal attacks
if (!Files.exists(filePath)) {
// => Executes method
// File not found on disk
return ResponseEntity.notFound().build();
// => Returns value to caller
// => HTTP 404 Not Found
}
Resource resource = new UrlResource(filePath.toUri());
// => Creates new instance
// => Wraps file as Spring Resource for streaming
// => Doesn't load entire file into memory
return ResponseEntity.ok()
// => Returns value to caller
.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);
// => Executes method
// => HTTP 200 OK
// => Content-Disposition: attachment; filename="document.pdf"
// => Streams file content
}
// List all uploaded files
@GetMapping("/list")
// => Executes method
public ResponseEntity<List<String>> listFiles() throws IOException {
// => Begins block
List<String> files = Files.list(uploadDir)
// => Assigns value to variable
// => Returns Stream<Path> of files in upload directory
.map(Path::getFileName)
// => Executes method
// => Extracts filename from full path
.map(Path::toString)
// => Executes method
// => Converts Path to String
.toList();
// => Executes method
// => Collects to List<String>
return ResponseEntity.ok(files);
// => Returns value to caller
// => 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 uploadsKey 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 large files—buffering entire files in memory causes heap exhaustion under concurrent uploads. Production file services use streaming APIs to handle large volumes 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.
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")
// => Executes method
public class LoggingController {
// => Begins block
// 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")
// => Executes method
public String process(@RequestParam String data) {
// => Begins block
// 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);
// => Executes method
// => 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);
// => Executes method
// => 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);
// => Executes method
// => 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);
// => Executes method
// => Logged in all environments
// => Use for: deprecated API usage, performance degradation, missing configs
try {
// => Begins block
if (data.equals("error")) {
// => Executes method
throw new IllegalArgumentException("Invalid data");
// => Executes method
}
} catch (Exception e) {
// => Executes method
// 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);
// => Executes method
// => Logged in all environments with stack trace
// => Use for: caught exceptions, recoverable errors, failed operations
}
return "Processed: " + data;
// => Returns result
}
}# 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 logsKey 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 multiple services in production. Log level configuration prevents disk space exhaustion from excessive DEBUG logs in production while enabling runtime log level changes via actuator endpoints to troubleshoot production issues without redeployment, significantly reducing incident resolution time.
Example 24: Request/Response Interceptors
Intercept HTTP requests and responses for cross-cutting concerns like logging, authentication, and metrics.
package com.example.demo.interceptor;
// => Package declaration
// Servlet API
import jakarta.servlet.http.HttpServletRequest;
// => Import statement
import jakarta.servlet.http.HttpServletResponse;
// => Import statement
// SLF4J logging
import org.slf4j.Logger;
// => Import statement
import org.slf4j.LoggerFactory;
// => Import statement
// Spring stereotype
import org.springframework.stereotype.Component;
// => Import statement
// Spring MVC interceptor
import org.springframework.web.servlet.HandlerInterceptor;
// => Import statement
import org.springframework.web.servlet.ModelAndView;
// => Import statement
@Component
// => Annotation applied
public class RequestLoggingInterceptor implements HandlerInterceptor {
// => Begins block
private static final Logger log = LoggerFactory.getLogger(RequestLoggingInterceptor.class);
// => Calls getLogger()
// => Stores result in log
// preHandle called BEFORE controller method execution
// Return true to continue processing, false to stop
@Override
// => Annotation applied
public boolean preHandle(
// => Declaration
HttpServletRequest request,
// => Code execution
HttpServletResponse response,
// => Code execution
Object handler
// => Code execution
) {
// => Begins block
// Record request start time
long startTime = System.currentTimeMillis();
// => Calls currentTimeMillis()
// => Stores result in startTime
request.setAttribute("startTime", startTime);
// => Executes method
// => 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
// => Retrieves data
);
// => "==> Incoming request: GET /api/users from 127.0.0.1"
return true;
// => Returns result
// => true continues to next interceptor or controller
// => false stops processing (returns response immediately)
}
// => Block delimiter
// postHandle called AFTER controller method execution, BEFORE view rendering
// Only called if controller succeeds (no exception thrown)
@Override
// => Annotation applied
public void postHandle(
// => Declaration
HttpServletRequest request,
// => Code line
HttpServletResponse response,
// => Code line
Object handler,
// => Code line
ModelAndView modelAndView
// => Code line
) {
// => Begins block
log.debug("Controller method completed, status: {}", response.getStatus());
// => Executes method
// => Called only if controller executed successfully
// => Not called if exception thrown in controller
}
// => Block delimiter
// afterCompletion called AFTER response is sent
// ALWAYS executed (even if exception occurred in controller)
@Override
// => Annotation applied
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
) {
// => Begins block
// Retrieve start time from request scope
long startTime = (Long) request.getAttribute("startTime");
// => Assigns value to variable
long duration = System.currentTimeMillis() - startTime;
// => Calculate request processing time
log.info("<== Completed: {} {} - Status: {} - Duration: {}ms",
request.getMethod(),
// => Executes method
request.getRequestURI(),
// => Executes method
response.getStatus(), // => 200, 404, 500, etc.
// => Retrieves data
duration
);
// => "<== Completed: GET /api/users - Status: 200 - Duration: 45ms"
if (ex != null) {
// => Executes method
// Exception occurred during processing
log.error("Request failed with exception", ex);
// => Executes method
// => Logs full stack trace
}
}
}package com.example.demo.interceptor;
// => Package declaration
// Servlet API
import jakarta.servlet.http.HttpServletRequest;
// => Import statement
import jakarta.servlet.http.HttpServletResponse;
// => Import statement
// Spring stereotype
import org.springframework.stereotype.Component;
// => Import statement
// Spring MVC interceptor
import org.springframework.web.servlet.HandlerInterceptor;
// => Import statement
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
// => Begins block
@Override
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler
) {
// => Begins block
// Extract Authorization header
String authHeader = request.getHeader("Authorization");
// => Assigns value to variable
// => "Bearer eyJhbGciOiJIUzI1..." or null if not present
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
// => Executes method
// No authorization header or wrong format
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// => Executes method
// => Sets HTTP status to 401 Unauthorized
return false;
// => Returns result
// => Stops processing, returns 401 immediately
// => Controller never executed
}
// Extract token from header
String token = authHeader.substring(7);
// => Assigns value to variable
// => "Bearer " is 7 characters
// => Extracts token part: "eyJhbGciOiJIUzI1..."
if (!isValidToken(token)) {
// => Executes method
// Token validation failed (expired, invalid signature, etc.)
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
// => Executes method
// => 403 Forbidden (authenticated but not authorized)
return false;
// => Returns result
}
// Token valid - extract user information
String userId = extractUserId(token);
// => Assigns value to variable
request.setAttribute("userId", userId);
// => Executes method
// => Store userId in request scope for controller access
return true;
// => Returns result
// => Continue to controller with authenticated request
}
// Simplified token validation (in real app: verify JWT signature, expiry)
private boolean isValidToken(String token) {
// => Begins block
return token != null && !token.isEmpty();
// => Calls isEmpty()
// => Stores result in !
}
// Simplified user ID extraction (in real app: parse JWT claims)
private String extractUserId(String token) {
// => Begins block
return "user123";
// => Returns result
}
}package com.example.demo.config;
// => Package declaration
// Interceptor classes
import com.example.demo.interceptor.*;
// => Import statement
// Spring configuration
import org.springframework.context.annotation.Configuration;
// => Import statement
// Spring MVC configuration
import org.springframework.web.servlet.config.annotation.*;
@Configuration
public class WebConfig implements WebMvcConfigurer {
// => Begins block
private final RequestLoggingInterceptor loggingInterceptor;
// => Declares loggingInterceptor field of type final
private final AuthenticationInterceptor authInterceptor;
// => Declares authInterceptor field of type final
// Constructor injection of interceptors
public WebConfig(
RequestLoggingInterceptor loggingInterceptor,
AuthenticationInterceptor authInterceptor
) {
// => Begins block
this.loggingInterceptor = loggingInterceptor;
// => Assigns loggingInterceptor to this.loggingInterceptor
this.authInterceptor = authInterceptor;
// => Assigns authInterceptor to this.authInterceptor
}
// Register interceptors with Spring MVC
@Override
public void addInterceptors(InterceptorRegistry registry) {
// => Begins block
// Register logging interceptor for all requests
registry.addInterceptor(loggingInterceptor)
// => Executes method
.addPathPatterns("/**");
// => Executes method
// => /** 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)
// => Executes method
.addPathPatterns("/api/**") // Include /api/** paths
// => Executes method
.excludePathPatterns("/api/public/**"); // Exclude /api/public/** paths
// => Executes method
// => /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.
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 {
// => Begins block
@Bean
public CorsFilter corsFilter() {
// => Begins block
CorsConfiguration config = new CorsConfiguration();
// => Creates new instance
// Allow specific origins (NEVER use "*" with credentials in production!)
config.addAllowedOrigin("http://localhost:3000");
// => Executes method
// => React dev server allowed
// => Browser allows requests from http://localhost:3000
config.addAllowedOrigin("http://localhost:4200");
// => Executes method
// => Angular dev server allowed
config.addAllowedOrigin("https://myapp.com");
// => Executes method
// => 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");
// => Executes method
config.addAllowedMethod("POST");
// => Executes method
config.addAllowedMethod("PUT");
// => Executes method
config.addAllowedMethod("DELETE");
// => Executes method
config.addAllowedMethod("OPTIONS");
// => Executes method
// => OPTIONS required for preflight requests
// => Browser sends OPTIONS before POST/PUT/DELETE for security
// Allow specific request headers
config.addAllowedHeader("Authorization");
// => Executes method
// => Allows Authorization: Bearer <token> header
config.addAllowedHeader("Content-Type");
// => Executes method
// => Allows Content-Type: application/json header
config.addAllowedHeader("X-Requested-With");
// => Executes method
// => Standard header sent by AJAX libraries
// Expose headers to frontend JavaScript
config.addExposedHeader("X-Total-Count");
// => Executes method
// => Frontend can read response.headers.get("X-Total-Count")
// => Used for pagination metadata
config.addExposedHeader("X-Custom-Header");
// => Executes method
// => 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;
// => Package declaration
// Spring configuration
import org.springframework.context.annotation.Configuration;
// => Import statement
// Spring MVC configuration
import org.springframework.web.servlet.config.annotation.*;
// => Import statement
@Configuration
// => Annotation applied
public class WebMvcConfig implements WebMvcConfigurer {
// => Begins block
// Alternative CORS configuration via WebMvcConfigurer
// Simpler than CorsFilter for basic cases
@Override
// => Annotation applied
public void addCorsMappings(CorsRegistry registry) {
// => Begins block
registry.addMapping("/api/**") // Apply to /api/** paths only
// => Executes method
.allowedOrigins("http://localhost:3000", "https://myapp.com")
// => Executes method
// => Only /api/** endpoints have CORS enabled
// => Other endpoints (/, /health) not affected
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
// => Executes method
.allowedHeaders("*")
// => Executes method
// => Allows all request headers (less secure than specific list)
.exposedHeaders("X-Total-Count")
// => Executes method
.allowCredentials(true)
// => Executes method
.maxAge(3600);
// => Executes method
}
}package com.example.demo.controller;
// => Package declaration
// Spring MVC annotations
import org.springframework.web.bind.annotation.*;
// => Import statement
// Java collections
import java.util.List;
// => Import statement
// Controller-level CORS configuration
// Overrides global CORS configuration for this controller
@RestController
// => Annotation applied
@RequestMapping("/api/products")
// => Executes method
@CrossOrigin(
// => Annotation applied
origins = {"http://localhost:3000"},
// => Code line
methods = {RequestMethod.GET, RequestMethod.POST},
// => Code line
maxAge = 3600,
// => Code line
allowCredentials = "true"
// => Code line
)
// => Code line
public class ProductController {
// => Begins block
@GetMapping
// => Annotation applied
public List<Product> getProducts() {
// => Begins block
// 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"));
// => Returns value to caller
}
// Method-level CORS (most specific - overrides controller-level)
@PostMapping
@CrossOrigin(origins = "*") // Less restrictive for this endpoint only
// => Assigns value to variable
public Product createProduct(@RequestBody Product product) {
// => Begins block
// This endpoint allows ALL origins
// => Access-Control-Allow-Origin: *
// => No credentials allowed when origin is *
return product;
// => Returns result
}
}
// Product DTO
record Product(Long id, String name) {}
// => Executes method# 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: 3600Key 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 January 1, 2026