The Ultimate Spring Boot Guide: From Fundamentals to Production-Ready REST APIs
Introduction
Welcome to the most comprehensive Spring Boot guide you'll find! This isn't just another tutorial that skims the surface. We're going to dive deep into every concept, explain every annotation, walk through every line of code, and understand the "why" behind the "how."
By the end of this guide, you'll understand:
The revolutionary changes Spring Boot brought to Java development
How auto-configuration works under the hood
The complete bean lifecycle from creation to destruction
How to build production-grade REST APIs
How to implement JWT authentication from scratch
How to handle exceptions like a pro
And much, much more!
Understanding Spring Framework
What is Spring? - A Historical Perspective
Back in the early 2000s, Java Enterprise Edition (J2EE) was the standard for building enterprise applications. However, it was notoriously complex, required extensive XML configuration, and forced developers to write tons of boilerplate code.
In 2003, Rod Johnson published his book "Expert One-on-One J2EE Design and Development" which included code for what would become Spring Framework. The framework emerged as a breath of fresh air, offering a simpler, more elegant approach to enterprise Java development.
At its core, Spring is an Inversion of Control (IoC) container - but what does that really mean?
Core Concepts - Understanding IoC and DI
Inversion of Control (IoC) - The Big Picture
Traditional programming follows this pattern:
Your class needs a dependency
Your class creates that dependency using
newYour class manages the dependency's lifecycle
// Traditional approach - YOU control everything
public class OrderService {
private EmailService emailService;
private PaymentService paymentService;
private InventoryService inventoryService;
// You create the dependencies
public OrderService() {
this.emailService = new EmailService();
this.paymentService = new PaymentService();
this.inventoryService = new InventoryService();
}
public void createOrder(Order order) {
// Process order...
emailService.sendConfirmation(order);
paymentService.processPayment(order);
inventoryService.updateStock(order);
}
}
Problems with this approach:
Tight Coupling:
OrderServiceis tightly coupled to specific implementations ofEmailService,PaymentService, etc.Hard to Test: You can't easily mock dependencies for unit testing
Inflexible: Changing implementations requires changing
OrderServicecodeResource Management: You're responsible for managing object lifecycle
IoC inverts this control - instead of YOU creating and managing dependencies, the Spring container does it for you:
// Spring approach - SPRING controls object creation
public class OrderService {
// These dependencies will be provided BY SPRING
private final EmailService emailService;
private final PaymentService paymentService;
private final InventoryService inventoryService;
// Spring will call this constructor and provide the dependencies
public OrderService(EmailService emailService,
PaymentService paymentService,
InventoryService inventoryService) {
this.emailService = emailService;
this.paymentService = paymentService;
this.inventoryService = inventoryService;
}
public void createOrder(Order order) {
// Same logic, but dependencies are managed by Spring
emailService.sendConfirmation(order);
paymentService.processPayment(order);
inventoryService.updateStock(order);
}
}
Benefits of IoC:
Loose Coupling:
OrderServicedoesn't know or care about concrete implementationsEasy Testing: You can pass mock objects in tests
Flexible: Change implementations without touching
OrderServiceCentralized Configuration: Spring manages all object lifecycles
Dependency Injection (DI) - The How of IoC
Dependency Injection is the pattern/technique used to achieve IoC. There are three main types:
1. Constructor Injection (Recommended)
@Service // Tells Spring this is a service bean
public class UserService {
// Mark fields as final for immutability
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
// Spring will automatically call this constructor
// and inject the required dependencies
public UserService(UserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
}
Why constructor injection is best:
Fields can be
final(immutable)Dependencies are required (constructor won't compile without them)
Easy to test (just call constructor with mocks)
Prevents circular dependencies
Clear at compile-time what dependencies exist
2. Setter Injection (For Optional Dependencies)
@Service
public class NotificationService {
private EmailService emailService; // Optional
private SmsService smsService; // Optional
// Spring will call these setters if beans are available
@Autowired(required = false)
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}
@Autowired(required = false)
public void setSmsService(SmsService smsService) {
this.smsService = smsService;
}
public void sendNotification(String message) {
// Use whichever services are available
if (emailService != null) {
emailService.send(message);
}
if (smsService != null) {
smsService.send(message);
}
}
}
3. Field Injection (Not Recommended - Included for Completeness)
@Service
public class ProductService {
@Autowired // Spring injects directly into the field
private ProductRepository productRepository;
// No constructor needed, but this is BAD PRACTICE
// - Can't make fields final
// - Hard to test (need Spring context)
// - Hidden dependencies
}
The "Spring Problem" - Why Spring Boot Was Needed
Traditional Spring (pre-Boot) required extensive XML configuration. Let's see what configuring a simple database connection looked like:
<!-- applicationContext.xml - The old nightmare -->
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<!-- Step 1: Configure the database connection pool -->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource">
<property name="driverClassName" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/mydb"/>
<property name="username" value="root"/>
<property name="password" value="password"/>
<property name="initialSize" value="5"/>
<property name="maxActive" value="10"/>
</bean>
<!-- Step 2: Configure JPA/Hibernate -->
<bean id="sessionFactory"
class="org.springframework.orm.hibernate4.LocalSessionFactoryBean">
<property name="dataSource" ref="dataSource"/>
<property name="packagesToScan" value="com.example.model"/>
<property name="hibernateProperties">
<props>
<prop key="hibernate.dialect">org.hibernate.dialect.MySQL5Dialect</prop>
<prop key="hibernate.show_sql">true</prop>
<prop key="hibernate.hbm2ddl.auto">update</prop>
</props>
</property>
</bean>
<!-- Step 3: Configure transaction manager -->
<bean id="transactionManager"
class="org.springframework.orm.hibernate4.HibernateTransactionManager">
<property name="sessionFactory" ref="sessionFactory"/>
</bean>
<!-- Step 4: Enable transaction annotations -->
<tx:annotation-driven transaction-manager="transactionManager"/>
<!-- Step 5: Configure component scanning -->
<context:component-scan base-package="com.example"/>
<!-- ... hundreds more lines for other beans -->
</beans>
Additionally, you needed:
web.xmlfor servlet configurationMultiple property files
Manual dependency version management
Complex deployment descriptors
This led to:
Configuration Bloat: More XML than actual code
Difficult Maintenance: Changes required editing multiple XML files
Steep Learning Curve: New developers spent weeks just understanding configuration
Boilerplate Everywhere: Same configuration repeated across projects
Version Conflicts: Managing compatible dependency versions was a nightmare
Why Spring Boot is Revolutionary
Spring Boot, released in April 2014, fundamentally changed how we build Spring applications. The key principle: Convention over Configuration.
The Three Pillars of Spring Boot
1. Auto-Configuration - The Magic
Spring Boot automatically configures your application based on:
JARs present in your classpath
Existing beans you've defined
Properties you've configured
Example: The Simplest Spring Boot Application
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication // This ONE annotation does it all!
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
Let's break down what @SpringBootApplication does:
// @SpringBootApplication is actually a meta-annotation combining three annotations:
@SpringBootConfiguration // 1. Marks this as a configuration class
@EnableAutoConfiguration // 2. Enables Spring Boot's auto-configuration
@ComponentScan // 3. Scans for components in this package and sub-packages
public class DemoApplication {
// Application entry point
}
Detailed explanation of each component:
1. @SpringBootConfiguration
Extends Spring's
@ConfigurationannotationIndicates that this class contains Spring bean definitions
Allows you to define
@Beanmethods if needed
2. @EnableAutoConfiguration - The Real Magic
This annotation tells Spring Boot to:
Look at your classpath
Check what libraries are present
Intelligently configure beans based on what it finds
Example auto-configuration logic (simplified):
// This is what Spring Boot does internally
@Configuration
@ConditionalOnClass(DataSource.class) // Only if DataSource class exists
@ConditionalOnMissingBean(DataSource.class) // Only if you haven't defined one
public class DataSourceAutoConfiguration {
@Bean
public DataSource dataSource() {
// If H2 is in classpath, configure H2
// If MySQL driver in classpath, configure MySQL
// Use application.properties for connection details
return configuredDataSource;
}
}
Let's see auto-configuration in action:
<!-- If you add this dependency to pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Spring Boot automatically configures:
Embedded Tomcat server (no need to install Tomcat separately)
Spring MVC (DispatcherServlet, ViewResolvers, etc.)
Jackson for JSON serialization/deserialization
HTTP message converters
Error handling with sensible defaults
Static resource handling (for /static, /public, /resources)
All from ONE dependency! In traditional Spring, this would require:
20+ separate dependencies
Careful version matching
Extensive XML configuration
Manual server setup
3. @ComponentScan
Automatically scans the package of the annotated class and all sub-packages
Finds all classes annotated with
@Component,@Service,@Repository,@ControllerRegisters them as Spring beans
com.example.demo/ <- @SpringBootApplication scans from here
βββ DemoApplication.java
βββ controller/ <- All classes here are scanned
β βββ UserController.java <- @RestController found and registered
βββ service/ <- All classes here are scanned
β βββ UserService.java <- @Service found and registered
βββ repository/ <- All classes here are scanned
βββ UserRepository.java <- @Repository found and registered
2. Starter Dependencies - Dependency Management Made Easy
The Old Way: Dependency Hell
<!-- Before Spring Boot - you had to manage all this manually -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.3.10</version> <!-- Make sure version matches! -->
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>5.3.10</version> <!-- Same version! -->
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.5</version> <!-- Compatible version? -->
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.12.5</version> <!-- Must match jackson-databind! -->
</dependency>
<!-- ... 20+ more dependencies, all with potential version conflicts -->
The Spring Boot Way:
<!-- One starter dependency = everything you need -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!-- No version needed! Inherited from spring-boot-starter-parent -->
</dependency>
This ONE dependency brings:
Spring Web
Spring MVC
Jackson (all modules)
Tomcat (embedded)
Validation
All in compatible versions!
Common Starter Dependencies:
| Starter | What It Provides |
spring-boot-starter-web | Web applications (Tomcat, Spring MVC, Jackson) |
spring-boot-starter-data-jpa | JPA with Hibernate |
spring-boot-starter-security | Spring Security |
spring-boot-starter-test | Testing libraries (JUnit, Mockito, AssertJ) |
spring-boot-starter-data-mongodb | MongoDB support |
spring-boot-starter-validation | Bean validation (Hibernate Validator) |
spring-boot-starter-actuator | Production monitoring |
3. Embedded Servers - No External Server Needed
Traditional Deployment:
Install Tomcat/JBoss/WebLogic separately
Configure server
Package app as WAR
Deploy WAR to server
Configure server to run WAR
Pray it works
Spring Boot Approach:
// Just run your main method!
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
Your application becomes a self-contained executable JAR:
# Build
mvn clean package
# Run anywhere (just needs Java)
java -jar myapp.jar
# Application starts with embedded Tomcat on port 8080
How it works:
Spring Boot packages an embedded servlet container (Tomcat by default) inside your JAR
When you run the JAR, it starts the embedded Tomcat
Your application is deployed to this embedded Tomcat
Everything runs in a single process
Benefits:
Consistent development and production environments
Easy horizontal scaling (just run more JARs)
No server configuration drift
Cloud-friendly (containers, Kubernetes)
4. Production-Ready Features - Built-In Monitoring
Add one dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
You instantly get:
Health checks:
GET /actuator/healthMetrics:
GET /actuator/metricsEnvironment info:
GET /actuator/envBean info:
GET /actuator/beansHTTP trace:
GET /actuator/httptraceAnd many more!
Getting Started with Spring Boot
Using Spring Initializr - Your Project Bootstrap Tool
Spring Initializr (start.spring.io) is a web-based tool to bootstrap Spring Boot projects.
Step-by-Step Guide:
Navigate to https://start.spring.io
Choose Project Type:
Project: Maven (build tool)
Maven uses
pom.xmlfor configurationGradle alternative uses
build.gradle
Choose Language:
Java (most common)
Kotlin or Groovy are alternatives
Choose Spring Boot Version:
Select latest stable (avoid SNAPSHOT or M1/RC versions)
As of writing: 3.2.x is current
Project Metadata:
Group: com.example (your organization) Artifact: blog-api (project name) Name: blog-api (display name) Description: Blog REST API (project description) Package name: com.example.blog (base package) Packaging: Jar (NOT War - we use embedded Tomcat) Java: 17 or 21 (LTS versions)Add Dependencies (click "Add Dependencies"):
Spring Web: REST APIs and web applications
Spring Data JPA: Database persistence
H2 Database: In-memory database (for dev/testing)
PostgreSQL Driver: Production database
Spring Security: Authentication and authorization
Validation: Bean validation with Hibernate validator
Lombok: Reduces boilerplate (getters, setters, etc.)
Click "Generate" - downloads a ZIP file
Extract and Open:
unzip blog-api.zip cd blog-api # Open in your IDE (IntelliJ IDEA, Eclipse, VS Code)
Understanding the Generated Project Structure
blog-api/
βββ src/
β βββ main/
β β βββ java/ <- Your Java source code
β β β βββ com/example/blog/
β β β βββ BlogApiApplication.java <- Main application class
β β βββ resources/ <- Configuration and static files
β β βββ application.properties <- Configuration
β β βββ static/ <- Static web content (CSS, JS, images)
β β βββ templates/ <- HTML templates (if using Thymeleaf)
β βββ test/
β βββ java/ <- Your test code
β βββ com/example/blog/
β βββ BlogApiApplicationTests.java
βββ target/ <- Compiled code (auto-generated)
βββ pom.xml <- Maven configuration
βββ README.md <- Project documentation
Recommended folder structure for a real project:
blog-api/
βββ src/main/java/com/example/blog/
β βββ BlogApiApplication.java <- Main class
β βββ config/ <- Configuration classes
β β βββ SecurityConfig.java
β β βββ AppConfig.java
β β βββ AppProperties.java
β βββ controller/ <- REST controllers
β β βββ AuthController.java
β β βββ UserController.java
β β βββ ArticleController.java
β βββ service/ <- Business logic
β β βββ UserService.java
β β βββ ArticleService.java
β βββ repository/ <- Database access
β β βββ UserRepository.java
β β βββ ArticleRepository.java
β βββ model/ <- JPA entities
β β βββ User.java
β β βββ Article.java
β β βββ Role.java
β βββ dto/ <- Data Transfer Objects
β β βββ request/
β β β βββ CreateUserRequest.java
β β β βββ UpdateUserRequest.java
β β βββ response/
β β βββ UserDTO.java
β β βββ ArticleDTO.java
β βββ exception/ <- Custom exceptions
β β βββ ResourceNotFoundException.java
β β βββ GlobalExceptionHandler.java
β βββ security/ <- Security-related classes
β βββ JwtTokenProvider.java
β βββ JwtAuthenticationFilter.java
βββ src/main/resources/
βββ application.yml <- Main configuration
βββ application-dev.yml <- Dev environment config
βββ application-prod.yml <- Production config
βββ schema.sql <- Database schema (optional)
βββ data.sql <- Seed data (optional)
The Heart of Spring Boot: @SpringBootApplication
Let's dissect the main application class:
package com.example.blog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BlogApiApplication {
public static void main(String[] args) {
SpringApplication.run(BlogApiApplication.class, args);
}
}
Line-by-line explanation:
@SpringBootApplication
Meta-annotation that combines three annotations
Acts as a convenience wrapper
Equivalent to using @Configuration + @EnableAutoConfiguration + @ComponentScan
What it actually means:
// This is what @SpringBootApplication does behind the scenes:
@Configuration // This class can define Spring beans using @Bean methods
@EnableAutoConfiguration // Enable Spring Boot auto-configuration magic
@ComponentScan(basePackages = "com.example.blog") // Scan this package for components
public class BlogApiApplication {
// ...
}
Detailed breakdown:
@ConfigurationIndicates this class contains bean definitions
Methods annotated with
@Beanwill create Spring-managed objectsExample:
@SpringBootApplication
public class BlogApiApplication {
public static void main(String[] args) {
SpringApplication.run(BlogApiApplication.class, args);
}
// You can define beans here if needed
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
@EnableAutoConfigurationThe MAGIC of Spring Boot
Tells Spring Boot to automatically configure your application
Based on classpath contents and existing beans
Uses many conditional annotations internally
How Auto-Configuration Works - Deep Dive:
When you start a Spring Boot application:
Classpath Scanning: Spring Boot scans all JARs in your classpath
Checks META-INF/spring.factories in each JAR for auto-configuration classes
Evaluates Conditions: Each auto-configuration class has conditions
Registers Beans: If conditions pass, beans are registered
Example: DataSource Auto-Configuration
// Simplified version of Spring Boot's DataSourceAutoConfiguration
@Configuration
@ConditionalOnClass(DataSource.class) // Only if DataSource.class is present
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
@Bean
@ConditionalOnMissingBean // Only if user hasn't defined DataSource
public DataSource dataSource(DataSourceProperties properties) {
// Creates DataSource using properties from application.properties
// Example: spring.datasource.url, spring.datasource.username, etc.
return properties.initializeDataSourceBuilder().build();
}
}
Key Conditional Annotations:
| Annotation | Condition |
@ConditionalOnClass(DataSource.class) | Bean created only if DataSource class is on classpath |
@ConditionalOnMissingBean(DataSource.class) | Bean created only if no DataSource bean exists |
@ConditionalOnProperty(name="spring.datasource.url") | Bean created only if property is set |
@ConditionalOnResource(resources="classpath:schema.sql") | Bean created only if file exists |
@ConditionalOnWebApplication | Bean created only in web applications |
Real Example - Web MVC Auto-Configuration:
When you add spring-boot-starter-web:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Spring Boot automatically configures:
// WebMvcAutoConfiguration (simplified)
@Configuration
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class })
@ConditionalOnWebApplication(type = Type.SERVLET)
public class WebMvcAutoConfiguration {
@Bean
public DispatcherServlet dispatcherServlet() {
// The front controller for Spring MVC
return new DispatcherServlet();
}
@Bean
public ViewResolver viewResolver() {
// Resolves view names to actual views
return new InternalResourceViewResolver();
}
@Bean
public RequestMappingHandlerMapping requestMappingHandlerMapping() {
// Maps HTTP requests to @RequestMapping methods
return new RequestMappingHandlerMapping();
}
@Bean
public RequestMappingHandlerAdapter requestMappingHandlerAdapter() {
// Handles request execution
return new RequestMappingHandlerAdapter();
}
// Many more beans...
}
You get ALL of this without writing a single line of configuration!
@ComponentScanScans for Spring components in current package and sub-packages
Registers classes annotated with stereotype annotations:
@Component- generic Spring-managed component@Service- service layer component@Repository- data access component@Controller- web controller@RestController- REST API controller@Configuration- configuration class
Example of component scanning:
// Assume this structure:
com.example.blog/
βββ BlogApiApplication.java <- @SpringBootApplication here
βββ controller/
β βββ UserController.java <- @RestController
βββ service/
β βββ UserService.java <- @Service
βββ repository/
βββ UserRepository.java <- @Repository
// All classes in controller/, service/, repository/ are automatically found and registered!
The main() Method:
public static void main(String[] args) {
SpringApplication.run(BlogApiApplication.class, args);
}
What SpringApplication.run() does:
Creates Application Context: The IoC container
Registers All Beans: Scans and creates all Spring beans
Runs Auto-Configuration: Applies all auto-configuration
Starts Embedded Server: Launches Tomcat/Jetty/Undertow
Makes Application Ready: Ready to handle requests
You can customize startup:
public static void main(String[] args) {
SpringApplication app = new SpringApplication(BlogApiApplication.class);
// Customize
app.setBannerMode(Banner.Mode.OFF); // Disable Spring Boot banner
app.setAdditionalProfiles("dev"); // Activate dev profile
// Run
app.run(args);
}
Spring Beans and Dependency Injection
What are Spring Beans? - The Building Blocks
A Spring Bean is simply a Java object that is managed by the Spring IoC container.
Think of it this way:
Regular Java Object: You create it with
new, you manage it, you destroy itSpring Bean: Spring creates it, Spring manages it, Spring destroys it
Why use beans?
Dependency Management: Spring handles object dependencies
Lifecycle Management: Spring controls creation and destruction
Configuration Management: Centralized configuration
Singleton Pattern: By default, beans are singletons (one instance per application)
Bean Lifecycle - Complete Journey
The lifecycle of a Spring bean is complex. Let's understand every phase:
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Spring Container Startup β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 1: Bean Definition Loading β
β - Spring scans @Component classes β
β - Reads @Configuration classes β
β - Loads bean definitions β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 2: Bean Instantiation β
β - Constructor is called β
β - Bean object created in memory β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 3: Dependency Injection β
β - @Autowired fields set β
β - @Autowired setters called β
β - Constructor injection already done β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 4: Awareness Interfaces (if implemented) β
β - BeanNameAware.setBeanName() β
β - BeanFactoryAware.setBeanFactory() β
β - ApplicationContextAware.setApplicationContext()β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 5: Pre-Initialization β
β - BeanPostProcessor.postProcessBeforeInitialization()β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 6: Initialization β
β - @PostConstruct method called β
β - InitializingBean.afterPropertiesSet() called β
β - Custom init-method called β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 7: Post-Initialization β
β - BeanPostProcessor.postProcessAfterInitialization()β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 8: Bean Ready to Use β
β - Bean is fully initialized β
β - Available for dependency injection β
β - Can service requests β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
(Application runs)
β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Phase 9: Destruction (shutdown) β
β - @PreDestroy method called β
β - DisposableBean.destroy() called β
β - Custom destroy-method called β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Spring Container Shutdown β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
Complete Example Demonstrating Lifecycle:
package com.example.lifecycle;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
@Component // Tell Spring this is a bean
public class LifecycleBean implements BeanNameAware, BeanFactoryAware,
ApplicationContextAware, InitializingBean,
DisposableBean {
// Dependencies will be injected here
private SomeDependency dependency;
// 1. Constructor - FIRST method called
public LifecycleBean() {
System.out.println("1. Constructor called - Bean instantiated");
}
// 2. Dependency Injection - Dependencies are set
@Autowired
public void setDependency(SomeDependency dependency) {
this.dependency = dependency;
System.out.println("2. Dependency injected - SomeDependency set");
}
// 3. BeanNameAware - Spring tells bean its name
@Override
public void setBeanName(String name) {
System.out.println("3. BeanNameAware.setBeanName() - Bean name is: " + name);
}
// 4. BeanFactoryAware - Spring passes BeanFactory reference
@Override
public void setBeanFactory(BeanFactory beanFactory) {
System.out.println("4. BeanFactoryAware.setBeanFactory() - BeanFactory available");
}
// 5. ApplicationContextAware - Spring passes ApplicationContext
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
System.out.println("5. ApplicationContextAware.setApplicationContext() - Context available");
}
// 6. @PostConstruct - Custom initialization logic
@PostConstruct
public void postConstruct() {
System.out.println("6. @PostConstruct called - Performing custom initialization");
// Perfect place for:
// - Opening resources
// - Starting background threads
// - Loading cache data
}
// 7. InitializingBean - Alternative to @PostConstruct
@Override
public void afterPropertiesSet() throws Exception {
System.out.println("7. InitializingBean.afterPropertiesSet() called");
}
// Bean is now READY and can be used
public void doSomething() {
System.out.println("Bean is working! Using dependency: " + dependency);
}
// DESTRUCTION PHASE (when application shuts down)
// 8. @PreDestroy - Called before bean is destroyed
@PreDestroy
public void preDestroy() {
System.out.println("8. @PreDestroy called - Cleaning up resources");
// Perfect place for:
// - Closing file handles
// - Closing database connections
// - Stopping background threads
}
// 9. DisposableBean - Alternative to @PreDestroy
@Override
public void destroy() throws Exception {
System.out.println("9. DisposableBean.destroy() called");
}
}
Console Output:
1. Constructor called - Bean instantiated
2. Dependency injected - SomeDependency set
3. BeanNameAware.setBeanName() - Bean name is: lifecycleBean
4. BeanFactoryAware.setBeanFactory() - BeanFactory available
5. ApplicationContextAware.setApplicationContext() - Context available
6. @PostConstruct called - Performing custom initialization
7. InitializingBean.afterPropertiesSet() called
(Application runs...)
8. @PreDestroy called - Cleaning up resources
9. DisposableBean.destroy() called
Best Practices:
Use
@PostConstructinstead ofInitializingBean(more modern, less coupling)Use
@PreDestroyinstead ofDisposableBeanAwareness interfaces are rarely needed in modern Spring apps
Bean Scopes - Understanding Instance Management
Spring supports multiple bean scopes that control how many instances of a bean are created and how long they live.
1. Singleton Scope (Default)
Definition: ONE instance per Spring container (per application)
@Component
@Scope("singleton") // This is the DEFAULT, so optional
public class SingletonBean {
private int counter = 0;
public void increment() {
counter++;
}
public int getCounter() {
return counter;
}
}
Behavior:
@RestController
public class TestController {
@Autowired
private SingletonBean bean1;
@Autowired
private SingletonBean bean2;
@GetMapping("/test")
public String test() {
bean1.increment(); // counter = 1
bean2.increment(); // counter = 2 (same instance!)
return "bean1 == bean2: " + (bean1 == bean2); // true
// Both references point to the SAME object
}
}
When to use:
Stateless services (most services)
Repositories
Controllers
Any bean without state
Memory diagram:
βββββββββββββββββββββββ
β Spring Container β
β β
β SingletonBean βββββΌββββ> [One Instance]
β (only one exists) β β
βββββββββββββββββββββββ β
β
ββββββββββββ¬βββββββββ΄βββββββββ¬βββββββββββ
β β β β
bean1 bean2 bean3 bean4
(all point to same instance)
2. Prototype Scope
Definition: NEW instance EVERY time the bean is requested
@Component
@Scope("prototype") // New instance each time!
public class PrototypeBean {
private int counter = 0;
public void increment() {
counter++;
}
public int getCounter() {
return counter;
}
}
Behavior:
@RestController
public class TestController {
@Autowired
private PrototypeBean bean1;
@Autowired
private PrototypeBean bean2;
@GetMapping("/test")
public String test() {
bean1.increment(); // bean1 counter = 1
bean2.increment(); // bean2 counter = 1 (different instance!)
return "bean1 == bean2: " + (bean1 == bean2); // false
// Each is a different object
}
}
When to use:
Stateful beans (beans that hold user-specific data)
Beans representing temporary operations
When you need clean state for each operation
Important Note: Spring creates prototype beans but does NOT manage their destruction. You must clean them up yourself!
3. Request Scope (Web Applications Only)
Definition: ONE instance per HTTP request
@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestScopedBean {
private String userId; // Different for each HTTP request
public void setUserId(String userId) {
this.userId = userId;
}
public String getUserId() {
return userId;
}
}
Why proxyMode = ScopedProxyMode.TARGET_CLASS?
When a singleton bean depends on a request-scoped bean, Spring needs to create a proxy. The proxy intercepts method calls and delegates to the correct request-scoped instance.
@Service // Singleton by default
public class UserService {
@Autowired
private RequestScopedBean requestBean; // Injected as proxy
public void processRequest(String userId) {
// Each HTTP request gets its own RequestScopedBean instance
requestBean.setUserId(userId);
String id = requestBean.getUserId(); // Same userId
}
}
Request Flow:
HTTP Request 1 (User A) HTTP Request 2 (User B)
β β
RequestScopedBean instance A RequestScopedBean instance B
userId = "userA" userId = "userB"
β β
(Instance destroyed after request) (Instance destroyed after request)
4. Session Scope (Web Applications Only)
Definition: ONE instance per HTTP session (per user)
@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ShoppingCart {
private List<Item> items = new ArrayList<>();
public void addItem(Item item) {
items.add(item);
}
public List<Item> getItems() {
return items;
}
public void clear() {
items.clear();
}
}
Behavior:
@RestController
public class CartController {
@Autowired
private ShoppingCart cart; // Different instance per user session
@PostMapping("/cart/add")
public void addToCart(@RequestBody Item item) {
// User A's session β User A's ShoppingCart instance
// User B's session β User B's ShoppingCart instance
cart.addItem(item);
}
@GetMapping("/cart")
public List<Item> getCart() {
return cart.getItems(); // Returns user-specific cart
}
}
Session Flow:
User A's Session (JSESSIONID=ABC123)
β
ShoppingCart A
βββ Item 1
βββ Item 2
βββ Item 3
(Lives until session expires)
User B's Session (JSESSIONID=XYZ789)
β
ShoppingCart B
βββ Item 5
βββ Item 6
(Different instance, lives until session expires)
5. Application Scope
Definition: ONE instance per ServletContext (shared across all sessions)
@Component
@Scope(value = WebApplicationContext.SCOPE_APPLICATION, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ApplicationMetrics {
private AtomicLong requestCount = new AtomicLong(0);
private AtomicLong activeUsers = new AtomicLong(0);
public void incrementRequests() {
requestCount.incrementAndGet();
}
public long getRequestCount() {
return requestCount.get();
}
}
When to use:
Application-wide metrics
Shared caches
Configuration that's the same for all users
Scope Comparison Table:
| Scope | Instances | Lifecycle | Use Case |
| Singleton | One per app | App startup β shutdown | Stateless services, repositories |
| Prototype | New each time | Create β manual cleanup | Stateful operations |
| Request | One per HTTP request | Request start β end | Request-specific data |
| Session | One per user session | Session start β expire | User-specific data (cart, preferences) |
| Application | One per web app | App start β shutdown | Global shared data |
Creating Beans - Multiple Methods
Method 1: Stereotype Annotations
// 1. @Component - Generic bean
@Component
public class EmailUtil {
public void sendEmail(String to, String message) {
// Implementation
}
}
// 2. @Service - Business logic layer
@Service // Specialized @Component for services
public class UserService {
@Autowired
private UserRepository repository;
public User createUser(User user) {
// Business logic
return repository.save(user);
}
}
// 3. @Repository - Data access layer
@Repository // Specialized @Component for repositories
public interface UserRepository extends JpaRepository<User, Long> {
// Spring Data JPA generates implementation
}
// 4. @Controller - Web MVC controller
@Controller // Specialized @Component for web controllers
public class HomeController {
@GetMapping("/home")
public String home(Model model) {
return "home"; // Returns view name
}
}
// 5. @RestController - REST API controller
@RestController // @Controller + @ResponseBody
public class UserRestController {
@GetMapping("/api/users")
public List<User> getUsers() {
return userService.getAllUsers(); // Returns JSON
}
}
Why different annotations if they're all @Component?
Semantic Clarity: Code is more readable
Exception Translation:
@Repositoryenables automatic exception translationFuture Enhancements: Spring might add special behavior to specific stereotypes
Tooling: IDEs can provide better support
Method 2: @Configuration and @Bean
@Configuration // This class contains bean definitions
public class AppConfig {
// Method 1: Simple bean creation
@Bean
public PasswordEncoder passwordEncoder() {
// This object becomes a Spring bean
return new BCryptPasswordEncoder();
}
// Method 2: Bean with dependencies
@Bean
public JwtTokenProvider jwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expiration) {
return new JwtTokenProvider(secret, expiration);
}
// Method 3: Conditional bean
@Bean
@ConditionalOnProperty(name = "cache.enabled", havingValue = "true")
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("users", "articles");
}
// Method 4: Bean with custom initialization
@Bean(initMethod = "init", destroyMethod = "cleanup")
public DataSource dataSource() {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:postgresql://localhost/mydb");
// More config...
return ds;
}
// Method 5: Primary bean (used when multiple candidates exist)
@Bean
@Primary // This one will be used by default
public RestTemplate primaryRestTemplate() {
return new RestTemplate();
}
@Bean
public RestTemplate alternativeRestTemplate() {
RestTemplate template = new RestTemplate();
// Different configuration
return template;
}
}
Dependency Injection Patterns - In-Depth
Constructor Injection (Recommended)
@Service
public class ArticleService {
// 1. Declare dependencies as final (immutable)
private final ArticleRepository articleRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
// 2. Single constructor - @Autowired is optional (Spring auto-detects)
public ArticleService(ArticleRepository articleRepository,
UserRepository userRepository,
NotificationService notificationService) {
// 3. Assign dependencies
this.articleRepository = articleRepository;
this.userRepository = userRepository;
this.notificationService = notificationService;
}
public Article createArticle(Long userId, String title, String content) {
// All dependencies are guaranteed to be available
User author = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException());
Article article = new Article();
article.setTitle(title);
article.setContent(content);
article.setAuthor(author);
Article saved = articleRepository.save(article);
notificationService.notifyFollowers(author, saved);
return saved;
}
}
Why Constructor Injection is Best:
Immutability: Fields are
final, can't be changed after creationRequired Dependencies: Constructor won't compile without all deps
Easy Testing: Just call constructor in tests
// Test example
@Test
public void testCreateArticle() {
ArticleRepository mockRepo = mock(ArticleRepository.class);
UserRepository mockUserRepo = mock(UserRepository.class);
NotificationService mockNotif = mock(NotificationService.class);
// Easy to create with mocks!
ArticleService service = new ArticleService(mockRepo, mockUserRepo, mockNotif);
// Test service...
}
Prevents Circular Dependencies: Spring will error at startup if circular
Null Safety: No risk of NullPointerException
Setter Injection (For Optional Dependencies)
@Service
public class NotificationService {
// Required dependencies via constructor
private final NotificationRepository repository;
// Optional dependencies via setters
private EmailService emailService;
private SmsService smsService;
private PushNotificationService pushService;
// Constructor for required deps
public NotificationService(NotificationRepository repository) {
this.repository = repository;
}
// Setter for optional dependency
@Autowired(required = false) // Won't fail if EmailService doesn't exist
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}
@Autowired(required = false)
public void setSmsService(SmsService smsService) {
this.smsService = smsService;
}
@Autowired(required = false)
public void setPushService(PushNotificationService pushService) {
this.pushService = pushService;
}
public void sendNotification(User user, String message) {
Notification notif = new Notification(user, message);
repository.save(notif);
// Use whichever services are available
if (emailService != null) {
emailService.send(user.getEmail(), message);
}
if (smsService != null) {
smsService.send(user.getPhone(), message);
}
if (pushService != null) {
pushService.send(user.getDeviceToken(), message);
}
}
}
Field Injection (Not Recommended - Shown for Completeness)
@Service
public class ProductService {
// Spring injects directly into fields
@Autowired
private ProductRepository productRepository;
@Autowired
private InventoryService inventoryService;
// NO CONSTRUCTOR NEEDED
public Product createProduct(Product product) {
// Looks clean but has problems:
// 1. Can't make fields final
// 2. Hard to test (need Spring context)
// 3. Hidden dependencies
// 4. Risk of NullPointerException
return productRepository.save(product);
}
}
Why Field Injection is Bad:
Can't use
final: Fields are mutableTesting Nightmare: Need Spring context or reflection to set fields
Hidden Dependencies: Not clear what class needs
Tight Coupling: Coupled to Spring
Practical Example: Complete Blog Application
Let's build a mini blog system showing all concepts:
1. Entity (Model Layer)
package com.example.blog.model;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity // JPA annotation - this class maps to database table
@Table(name = "posts") // Table name is "posts"
public class Post {
@Id // Primary key
@GeneratedValue(strategy = GenerationType.IDENTITY) // Auto-increment
private Long id;
@Column(nullable = false, length = 200) // NOT NULL column, max 200 chars
private String title;
@Column(columnDefinition = "TEXT") // TEXT type for long content
private String content;
@CreationTimestamp // Hibernate automatically sets timestamp on creation
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY) // Many posts belong to one author
@JoinColumn(name = "author_id") // Foreign key column
private User author;
// Constructors
public Post() {
}
public Post(String title, String content, User author) {
this.title = title;
this.content = content;
this.author = author;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public User getAuthor() { return author; }
public void setAuthor(User author) { this.author = author; }
}
2. Repository (Data Access Layer)
package com.example.blog.repository;
import com.example.blog.model.Post;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository // Marks this as a repository bean
public interface PostRepository extends JpaRepository<Post, Long> {
// JpaRepository provides:
// - save(entity)
// - findById(id)
// - findAll()
// - deleteById(id)
// - count()
// And many more!
// Custom query methods - Spring Data generates implementation
List<Post> findByTitleContaining(String keyword);
List<Post> findByAuthor_Username(String username);
long countByAuthor_Id(Long authorId);
}
3. Service (Business Logic Layer)
package com.example.blog.service;
import com.example.blog.model.Post;
import com.example.blog.repository.PostRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service // Marks this as a service bean
@Transactional // All methods run in transactions
public class PostService {
// Constructor injection - immutable, testable
private final PostRepository postRepository;
public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}
// Read-only transaction (optimization)
@Transactional(readOnly = true)
public List<Post> getAllPosts() {
return postRepository.findAll();
}
@Transactional(readOnly = true)
public Post getPostById(Long id) {
return postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException("Post not found: " + id));
}
// Write transaction
public Post createPost(Post post) {
// Validation
if (post.getTitle() == null || post.getTitle().trim().isEmpty()) {
throw new IllegalArgumentException("Title cannot be empty");
}
// Save
return postRepository.save(post);
}
public Post updatePost(Long id, Post updateData) {
Post existing = getPostById(id);
existing.setTitle(updateData.getTitle());
existing.setContent(updateData.getContent());
return postRepository.save(existing);
}
public void deletePost(Long id) {
if (!postRepository.existsById(id)) {
throw new PostNotFoundException("Post not found: " + id);
}
postRepository.deleteById(id);
}
@Transactional(readOnly = true)
public List<Post> searchPosts(String keyword) {
return postRepository.findByTitleContaining(keyword);
}
}
4. Controller (Web Layer)
package com.example.blog.controller;
import com.example.blog.model.Post;
import com.example.blog.service.PostService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController // @Controller + @ResponseBody (returns JSON)
@RequestMapping("/api/posts") // Base path for all endpoints
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
// GET /api/posts - Get all posts
@GetMapping
public ResponseEntity<List<Post>> getAllPosts() {
List<Post> posts = postService.getAllPosts();
return ResponseEntity.ok(posts); // HTTP 200 with posts as JSON
}
// GET /api/posts/123 - Get specific post
@GetMapping("/{id}")
public ResponseEntity<Post> getPostById(@PathVariable Long id) {
Post post = postService.getPostById(id);
return ResponseEntity.ok(post);
}
// POST /api/posts - Create new post
@PostMapping
public ResponseEntity<Post> createPost(@RequestBody Post post) {
Post created = postService.createPost(post);
return ResponseEntity.status(HttpStatus.CREATED).body(created); // HTTP 201
}
// PUT /api/posts/123 - Update post
@PutMapping("/{id}")
public ResponseEntity<Post> updatePost(
@PathVariable Long id,
@RequestBody Post post) {
Post updated = postService.updatePost(id, post);
return ResponseEntity.ok(updated);
}
// DELETE /api/posts/123 - Delete post
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
postService.deletePost(id);
return ResponseEntity.noContent().build(); // HTTP 204
}
// GET /api/posts/search?q=spring - Search posts
@GetMapping("/search")
public ResponseEntity<List<Post>> searchPosts(@RequestParam("q") String keyword) {
List<Post> results = postService.searchPosts(keyword);
return ResponseEntity.ok(results);
}
}
How They Work Together:
HTTP Request: GET /api/posts
β
PostController.getAllPosts()
β
PostService.getAllPosts()
β
PostRepository.findAll()
β
Database Query: SELECT * FROM posts
β
Database Returns: List of Post rows
β
PostRepository β List<Post> entities
β
PostService β List<Post>
β
PostController β ResponseEntity<List<Post>>
β
Spring MVC β Convert to JSON
β
HTTP Response: JSON array of posts
Working with SQL Databases - Complete Guide
Spring Data JPA - What and Why
JPA (Java Persistence API) is a specification for accessing, persisting, and managing data between Java objects and relational databases. Hibernate is the most popular implementation of JPA.
Spring Data JPA is a layer on top of JPA that provides:
Repository interfaces (no need to write implementation!)
Query methods generated from method names
Pagination and sorting support
Custom query support with
@QueryAuditing capabilities
Much more!
Add Dependencies:
<dependencies>
<!-- Spring Data JPA - includes Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2 Database - for development/testing -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- PostgreSQL - for production -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
Creating JPA Entities - Deep Dive
An Entity is a Java class that maps to a database table.
Complete Entity Example with Explanations:
package com.example.blog.model;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.HashSet;
import java.util.Set;
@Entity // Marks this class as a JPA entity (maps to a table)
@Table( // Customize table details
name = "users", // Table name (defaults to class name if not specified)
uniqueConstraints = {
@UniqueConstraint(columnNames = {"username"}), // Unique constraint
@UniqueConstraint(columnNames = {"email"})
},
indexes = {
@Index(name = "idx_username", columnList = "username"), // Index for performance
@Index(name = "idx_email", columnList = "email")
}
)
public class User {
// PRIMARY KEY FIELD
@Id // Marks this as the primary key
@GeneratedValue(strategy = GenerationType.IDENTITY) // Auto-increment strategy
// Other strategies:
// - GenerationType.AUTO - Let JPA choose
// - GenerationType.SEQUENCE - Use database sequence
// - GenerationType.TABLE - Use a table to generate IDs
// - GenerationType.UUID - Generate UUID
private Long id;
// BASIC COLUMNS
@Column(
name = "username", // Column name (defaults to field name)
nullable = false, // NOT NULL constraint
unique = true, // UNIQUE constraint
length = 50 // VARCHAR(50)
)
private String username;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(nullable = false)
private String password; // Should be encrypted!
@Column(length = 500) // Optional field
private String bio;
// ENUM FIELD
@Enumerated(EnumType.STRING) // Store enum as string in database
// EnumType.ORDINAL stores as integer (0, 1, 2...) - NOT RECOMMENDED
// EnumType.STRING stores actual enum name - RECOMMENDED
@Column(nullable = false)
private Role role; // Enum: USER, ADMIN, MODERATOR
@Column(name = "is_enabled", nullable = false)
private boolean enabled = true;
// TEMPORAL FIELDS
@CreationTimestamp // Hibernate sets this automatically on creation
@Column(name = "created_at", updatable = false) // Cannot be updated after creation
private LocalDateTime createdAt;
@UpdateTimestamp // Hibernate updates this automatically on any update
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// ONE-TO-MANY RELATIONSHIP
// One user can have many articles
@OneToMany(
mappedBy = "author", // Field name in Article entity that owns the relationship
cascade = CascadeType.ALL, // Operations on User cascade to Articles
// CascadeType.PERSIST - cascade save
// CascadeType.MERGE - cascade update
// CascadeType.REMOVE - cascade delete
// CascadeType.REFRESH - cascade refresh
// CascadeType.DETACH - cascade detach
// CascadeType.ALL - all of the above
orphanRemoval = true, // Delete articles if removed from collection
fetch = FetchType.LAZY // Don't load articles until accessed
// FetchType.EAGER - load immediately (can cause N+1 problem)
// FetchType.LAZY - load on demand (recommended)
)
private List<Article> articles = new ArrayList<>();
// MANY-TO-MANY RELATIONSHIP
// User can have many roles, role can be assigned to many users
@ManyToMany(fetch = FetchType.EAGER) // Eager because roles are usually small
@JoinTable(
name = "user_roles", // Join table name
joinColumns = @JoinColumn(name = "user_id"), // FK to this entity
inverseJoinColumns = @JoinColumn(name = "role_id") // FK to other entity
)
private Set<RoleEntity> roles = new HashSet<>();
// CONSTRUCTORS
public User() {
// JPA requires a no-arg constructor
}
public User(String username, String email, String password, Role role) {
this.username = username;
this.email = email;
this.password = password;
this.role = role;
}
// HELPER METHODS for bidirectional relationships
public void addArticle(Article article) {
articles.add(article);
article.setAuthor(this); // Maintain both sides of relationship
}
public void removeArticle(Article article) {
articles.remove(article);
article.setAuthor(null);
}
// GETTERS AND SETTERS
public Long getId() { return id; }
// Note: Typically no setId() - let database handle it
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getBio() { return bio; }
public void setBio(String bio) { this.bio = bio; }
public Role getRole() { return role; }
public void setRole(Role role) { this.role = role; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public LocalDateTime getCreatedAt() { return createdAt; }
// Note: No setCreatedAt() - set automatically
public LocalDateTime getUpdatedAt() { return updatedAt; }
// Note: No setUpdatedAt() - set automatically
public List<Article> getArticles() { return articles; }
public void setArticles(List<Article> articles) { this.articles = articles; }
public Set<RoleEntity> getRoles() { return roles; }
public void setRoles(Set<RoleEntity> roles) { this.roles = roles; }
// EQUALS AND HASHCODE (based on business key, not id)
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return username != null && username.equals(user.getUsername());
}
@Override
public int hashCode() {
return getClass().hashCode();
}
// TOSTRING (exclude collections to avoid lazy loading issues)
@Override
public String toString() {
return "User{" +
"id=" + id +
", username='" + username + '\'' +
", email='" + email + '\'' +
", role=" + role +
", enabled=" + enabled +
'}';
}
}
Article Entity - Demonstrating Relationships:
package com.example.blog.model;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "articles")
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
private String title;
@Column(columnDefinition = "TEXT") // Use TEXT type for large content
// columnDefinition lets you use database-specific types
private String content;
@Lob // Large Object - for very large text/binary data
private String fullContent;
// MANY-TO-ONE (Article belongs to one User)
@ManyToOne(
fetch = FetchType.LAZY, // Don't load author unless needed
optional = false // Author is required (NOT NULL)
)
@JoinColumn(
name = "author_id", // Foreign key column name
nullable = false,
foreignKey = @ForeignKey(name = "fk_article_author") // FK constraint name
)
private User author;
// MANY-TO-MANY (Article can have many tags, tag can be on many articles)
@ManyToMany
@JoinTable(
name = "article_tags",
joinColumns = @JoinColumn(name = "article_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();
// ONE-TO-MANY (Article can have many comments)
@OneToMany(mappedBy = "article", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Comment> comments = new HashSet<>();
@Column(name = "view_count", nullable = false)
private int viewCount = 0;
@Column(name = "published", nullable = false)
private boolean published = false;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "published_at")
private LocalDateTime publishedAt;
// EMBEDDABLE TYPE
@Embedded // Embed another class's fields into this table
private ArticleMetadata metadata;
// Constructors
public Article() {}
public Article(String title, String content, User author) {
this.title = title;
this.content = content;
this.author = author;
}
// Helper methods
public void addTag(Tag tag) {
tags.add(tag);
tag.getArticles().add(this);
}
public void removeTag(Tag tag) {
tags.remove(tag);
tag.getArticles().remove(this);
}
public void addComment(Comment comment) {
comments.add(comment);
comment.setArticle(this);
}
// Getters and setters...
public Long getId() { return id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public User getAuthor() { return author; }
public void setAuthor(User author) { this.author = author; }
public Set<Tag> getTags() { return tags; }
public void setTags(Set<Tag> tags) { this.tags = tags; }
public int getViewCount() { return viewCount; }
public void setViewCount(int viewCount) { this.viewCount = viewCount; }
public boolean isPublished() { return published; }
public void setPublished(boolean published) { this.published = published; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getPublishedAt() { return publishedAt; }
public void setPublishedAt(LocalDateTime publishedAt) { this.publishedAt = publishedAt; }
}
Embeddable Class Example:
package com.example.blog.model;
import jakarta.persistence.Embeddable;
@Embeddable // This class's fields will be embedded in parent table
public class ArticleMetadata {
private String metaTitle;
private String metaDescription;
private String metaKeywords;
// These fields appear in articles table as:
// meta_title, meta_description, meta_keywords
public ArticleMetadata() {}
public ArticleMetadata(String metaTitle, String metaDescription, String metaKeywords) {
this.metaTitle = metaTitle;
this.metaDescription = metaDescription;
this.metaKeywords = metaKeywords;
}
// Getters and setters...
}
Generated Database Schema:
-- Users table
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
bio VARCHAR(500),
role VARCHAR(20) NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP,
updated_at TIMESTAMP,
INDEX idx_username (username),
INDEX idx_email (email)
);
-- Articles table
CREATE TABLE articles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT,
full_content LONGTEXT,
author_id BIGINT NOT NULL,
view_count INT NOT NULL DEFAULT 0,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP,
published_at TIMESTAMP,
meta_title VARCHAR(255),
meta_description VARCHAR(255),
meta_keywords VARCHAR(255),
CONSTRAINT fk_article_author FOREIGN KEY (author_id) REFERENCES users(id)
);
-- Tags table
CREATE TABLE tags (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL
);
-- Join table for Many-to-Many
CREATE TABLE article_tags (
article_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
PRIMARY KEY (article_id, tag_id),
FOREIGN KEY (article_id) REFERENCES articles(id),
FOREIGN KEY (tag_id) REFERENCES tags(id)
);
Spring Data JPA Repositories - No Implementation Needed!
Repository Hierarchy:
Repository (marker interface)
β
CrudRepository (basic CRUD)
β
PagingAndSortingRepository (pagination & sorting)
β
JpaRepository (JPA-specific methods)
Basic Repository:
package com.example.blog.repository;
import com.example.blog.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository // Optional - Spring Data auto-detects interfaces extending Repository
public interface UserRepository extends JpaRepository<User, Long> {
// JpaRepository<Entity, IDType>
// Inherited methods from JpaRepository:
// - save(entity) - insert or update
// - saveAll(entities) - bulk insert/update
// - findById(id) - returns Optional<Entity>
// - findAll() - get all entities
// - findAll(Sort) - get all with sorting
// - findAll(Pageable) - get page of entities
// - count() - count all entities
// - deleteById(id) - delete by ID
// - delete(entity) - delete entity
// - deleteAll() - delete all
// - existsById(id) - check if exists
// - flush() - flush pending changes to DB
// - saveAndFlush(entity) - save and flush immediately
// - getOne(id) - get reference (lazy)
// - getById(id) - get reference (lazy) - deprecated, use getReferenceById
// - getReferenceById(id) - get reference without loading (lazy proxy)
}
Query Methods - Method Name Conventions
Spring Data JPA generates queries based on method names!
Complete Query Method Examples:
package com.example.blog.repository;
import com.example.blog.model.User;
import com.example.blog.model.Role;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// ========== FIND BY SINGLE PROPERTY ==========
// SELECT * FROM users WHERE username = ?
Optional<User> findByUsername(String username);
// SELECT * FROM users WHERE email = ?
Optional<User> findByEmail(String email);
// SELECT * FROM users WHERE role = ?
List<User> findByRole(Role role);
// SELECT * FROM users WHERE is_enabled = ?
List<User> findByEnabled(boolean enabled);
// ========== FIND WITH MULTIPLE CONDITIONS ==========
// SELECT * FROM users WHERE username = ? AND email = ?
Optional<User> findByUsernameAndEmail(String username, String email);
// SELECT * FROM users WHERE username = ? OR email = ?
List<User> findByUsernameOrEmail(String username, String email);
// SELECT * FROM users WHERE role = ? AND is_enabled = ?
List<User> findByRoleAndEnabled(Role role, boolean enabled);
// ========== COMPARISON OPERATORS ==========
// SELECT * FROM users WHERE created_at < ?
List<User> findByCreatedAtBefore(LocalDateTime date);
// SELECT * FROM users WHERE created_at > ?
List<User> findByCreatedAtAfter(LocalDateTime date);
// SELECT * FROM users WHERE created_at BETWEEN ? AND ?
List<User> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
// SELECT * FROM users WHERE id < ?
List<User> findByIdLessThan(Long id);
// SELECT * FROM users WHERE id <= ?
List<User> findByIdLessThanEqual(Long id);
// SELECT * FROM users WHERE id > ?
List<User> findByIdGreaterThan(Long id);
// SELECT * FROM users WHERE id >= ?
List<User> findByIdGreaterThanEqual(Long id);
// ========== STRING OPERATIONS ==========
// SELECT * FROM users WHERE username LIKE '%?%'
List<User> findByUsernameContaining(String substring);
// SELECT * FROM users WHERE username LIKE '?%'
List<User> findByUsernameStartingWith(String prefix);
// SELECT * FROM users WHERE username LIKE '%?'
List<User> findByUsernameEndingWith(String suffix);
// SELECT * FROM users WHERE LOWER(username) = LOWER(?)
Optional<User> findByUsernameIgnoreCase(String username);
// SELECT * FROM users WHERE LOWER(username) LIKE LOWER('%?%')
List<User> findByUsernameContainingIgnoreCase(String substring);
// SELECT * FROM users WHERE username LIKE ?
// User must provide wildcards: findByUsernameLike("%john%")
List<User> findByUsernameLike(String pattern);
// SELECT * FROM users WHERE username NOT LIKE ?
List<User> findByUsernameNotLike(String pattern);
// ========== NULL CHECKS ==========
// SELECT * FROM users WHERE bio IS NULL
List<User> findByBioIsNull();
// SELECT * FROM users WHERE bio IS NOT NULL
List<User> findByBioIsNotNull();
// ========== COLLECTION OPERATIONS ==========
// SELECT * FROM users WHERE role IN (?)
List<User> findByRoleIn(List<Role> roles);
// SELECT * FROM users WHERE role NOT IN (?)
List<User> findByRoleNotIn(List<Role> roles);
// ========== BOOLEAN OPERATIONS ==========
// SELECT * FROM users WHERE is_enabled = TRUE
List<User> findByEnabledTrue();
// SELECT * FROM users WHERE is_enabled = FALSE
List<User> findByEnabledFalse();
// ========== ORDERING ==========
// SELECT * FROM users WHERE role = ? ORDER BY created_at DESC
List<User> findByRoleOrderByCreatedAtDesc(Role role);
// SELECT * FROM users WHERE role = ? ORDER BY username ASC
List<User> findByRoleOrderByUsernameAsc(Role role);
// SELECT * FROM users ORDER BY username ASC, created_at DESC
List<User> findAllByOrderByUsernameAscCreatedAtDesc();
// ========== LIMITING RESULTS ==========
// SELECT * FROM users WHERE role = ? LIMIT 1
Optional<User> findFirstByRole(Role role);
Optional<User> findTopByRole(Role role); // Same as above
// SELECT * FROM users WHERE is_enabled = ? ORDER BY created_at DESC LIMIT 10
List<User> findTop10ByEnabledOrderByCreatedAtDesc(boolean enabled);
// SELECT * FROM users WHERE role = ? ORDER BY created_at DESC LIMIT 5
List<User> findFirst5ByRoleOrderByCreatedAtDesc(Role role);
// ========== DISTINCT ==========
// SELECT DISTINCT * FROM users WHERE role = ?
List<User> findDistinctByRole(Role role);
// ========== COUNTING ==========
// SELECT COUNT(*) FROM users WHERE role = ?
long countByRole(Role role);
// SELECT COUNT(*) FROM users WHERE is_enabled = ?
long countByEnabled(boolean enabled);
// SELECT COUNT(DISTINCT username) FROM users WHERE role = ?
long countDistinctByRole(Role role);
// ========== EXISTS ==========
// SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)
boolean existsByUsername(String username);
// SELECT EXISTS(SELECT 1 FROM users WHERE email = ?)
boolean existsByEmail(String email);
// SELECT EXISTS(SELECT 1 FROM users WHERE username = ? AND is_enabled = ?)
boolean existsByUsernameAndEnabled(String username, boolean enabled);
// ========== DELETE QUERIES ==========
// DELETE FROM users WHERE username = ?
// Note: Returns number of deleted rows
long deleteByUsername(String username);
// DELETE FROM users WHERE role = ?
long deleteByRole(Role role);
// DELETE FROM users WHERE is_enabled = ? AND created_at < ?
long deleteByEnabledAndCreatedAtBefore(boolean enabled, LocalDateTime date);
// Same as delete but returns deleted entities
List<User> removeByRole(Role role);
// ========== PAGINATION AND SORTING ==========
// Pageable allows pagination and sorting
// Usage: repository.findByRole(Role.USER, PageRequest.of(0, 10, Sort.by("username")))
Page<User> findByRole(Role role, Pageable pageable);
// Return List instead of Page (no count query)
List<User> findByEnabled(boolean enabled, Pageable pageable);
// Just sorting, no pagination
List<User> findByRole(Role role, Sort sort);
// ========== NESTED PROPERTY QUERIES ==========
// If User has Address object with city field:
// SELECT * FROM users WHERE address_city = ?
// List<User> findByAddress_City(String city);
// For relationships - query by related entity's property
// SELECT * FROM users u JOIN articles a ON u.id = a.author_id WHERE a.title = ?
List<User> findByArticles_Title(String articleTitle);
// SELECT * FROM users u JOIN articles a ON u.id = a.author_id WHERE a.published = ?
List<User> findByArticles_Published(boolean published);
}
Query Method Keywords Reference:
| Keyword | Example | JPQL |
And | findByNameAndEmail | ... WHERE x.name = ?1 AND x.email = ?2 |
Or | findByNameOrEmail | ... WHERE x.name = ?1 OR x.email = ?2 |
Is, Equals | findByName | ... WHERE x.name = ?1 |
Between | findByDateBetween | ... WHERE x.date BETWEEN ?1 AND ?2 |
LessThan | findByAgeLessThan | ... WHERE x.age < ?1 |
LessThanEqual | findByAgeLessThanEqual | ... WHERE x.age <= ?1 |
GreaterThan | findByAgeGreaterThan | ... WHERE x.age > ?1 |
GreaterThanEqual | findByAgeGreaterThanEqual | ... WHERE x.age >= ?1 |
After | findByDateAfter | ... WHERE x.date > ?1 |
Before | findByDateBefore | ... WHERE x.date < ?1 |
IsNull, Null | findByAgeIsNull | ... WHERE x.age IS NULL |
IsNotNull, NotNull | findByAgeIsNotNull | ... WHERE x.age IS NOT NULL |
Like | findByNameLike | ... WHERE x.name LIKE ?1 |
NotLike | findByNameNotLike | ... WHERE x.name NOT LIKE ?1 |
StartingWith | findByNameStartingWith | ... WHERE x.name LIKE '?1%' |
EndingWith | findByNameEndingWith | ... WHERE x.name LIKE '%?1' |
Containing | findByNameContaining | ... WHERE x.name LIKE '%?1%' |
OrderBy | findByAgeOrderByNameDesc | ... WHERE x.age = ?1 ORDER BY x.name DESC |
Not | findByNameNot | ... WHERE x.name <> ?1 |
In | findByAgeIn(Collection<Age>) | ... WHERE x.age IN ?1 |
NotIn | findByAgeNotIn(Collection<Age>) | ... WHERE x.age NOT IN ?1 |
True | findByActiveTrue() | ... WHERE x.active = TRUE |
False | findByActiveFalse() | ... WHERE x.active = FALSE |
IgnoreCase | findByNameIgnoreCase | ... WHERE LOWER(x.name) = LOWER(?1) |
Custom Queries with @Query
For complex queries that can't be expressed with method names:
package com.example.blog.repository;
import com.example.blog.model.Article;
import com.example.blog.dto.ArticleSummary;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
public interface ArticleRepository extends JpaRepository<Article, Long> {
// ========== JPQL QUERIES ==========
// JPQL uses entity names and properties, not table/column names
@Query("SELECT a FROM Article a WHERE a.author.username = :username")
List<Article> findByAuthorUsername(@Param("username") String username);
// Multiple parameters
@Query("SELECT a FROM Article a WHERE a.published = :published AND a.viewCount > :minViews")
List<Article> findPublishedWithMinViews(
@Param("published") boolean published,
@Param("minViews") int minViews
);
// JOIN queries
@Query("SELECT a FROM Article a JOIN a.tags t WHERE t.name = :tagName")
List<Article> findByTagName(@Param("tagName") String tagName);
// LEFT JOIN
@Query("SELECT a FROM Article a LEFT JOIN FETCH a.author WHERE a.published = true")
// FETCH tells JPA to load the relationship immediately (avoid N+1)
List<Article> findAllPublishedWithAuthors();
// Aggregate functions
@Query("SELECT COUNT(a) FROM Article a WHERE a.author.id = :authorId")
long countArticlesByAuthor(@Param("authorId") Long authorId);
@Query("SELECT SUM(a.viewCount) FROM Article a WHERE a.author.id = :authorId")
long getTotalViewsByAuthor(@Param("authorId") Long authorId);
@Query("SELECT AVG(a.viewCount) FROM Article a WHERE a.published = true")
Double getAverageViewsForPublishedArticles();
@Query("SELECT MAX(a.createdAt) FROM Article a WHERE a.author.id = :authorId")
LocalDateTime getLatestArticleDate(@Param("authorId") Long authorId);
// Pagination with JPQL
@Query("SELECT a FROM Article a WHERE a.author.username = :username")
Page<Article> findByAuthorUsername(@Param("username") String username, Pageable pageable);
// DISTINCT
@Query("SELECT DISTINCT a FROM Article a JOIN a.tags t WHERE t.name IN :tagNames")
List<Article> findArticlesByTags(@Param("tagNames") List<String> tagNames);
// Subquery
@Query("SELECT a FROM Article a WHERE a.viewCount > " +
"(SELECT AVG(a2.viewCount) FROM Article a2)")
List<Article> findArticlesAboveAverageViews();
// DTO Projection - fetch only specific fields
@Query("SELECT new com.example.blog.dto.ArticleSummary(a.id, a.title, a.author.username, a.viewCount) " +
"FROM Article a WHERE a.published = true")
List<ArticleSummary> findPublishedArticleSummaries();
// Constructor expression with functions
@Query("SELECT new com.example.blog.dto.ArticleStats(a.author.username, COUNT(a), SUM(a.viewCount)) " +
"FROM Article a GROUP BY a.author.username")
List<ArticleStats> getArticleStatsByAuthor();
// ========== NATIVE SQL QUERIES ==========
// Use native SQL when JPQL is not enough
@Query(
value = "SELECT * FROM articles WHERE title LIKE %:keyword% OR content LIKE %:keyword%",
nativeQuery = true
)
List<Article> fullTextSearch(@Param("keyword") String keyword);
// Native query with pagination
@Query(
value = "SELECT * FROM articles WHERE published = true ORDER BY view_count DESC",
countQuery = "SELECT COUNT(*) FROM articles WHERE published = true",
nativeQuery = true
)
Page<Article> findMostPopular(Pageable pageable);
// Database-specific features (PostgreSQL full-text search)
@Query(
value = "SELECT * FROM articles WHERE " +
"to_tsvector('english', title || ' ' || content) @@ to_tsquery('english', :query)",
nativeQuery = true
)
List<Article> fullTextSearchPostgres(@Param("query") String query);
// Native query with named parameters
@Query(
value = "SELECT * FROM articles a " +
"WHERE a.author_id = :authorId " +
"AND a.created_at BETWEEN :startDate AND :endDate",
nativeQuery = true
)
List<Article> findByAuthorAndDateRange(
@Param("authorId") Long authorId,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate
);
// ========== MODIFYING QUERIES (UPDATE/DELETE) ==========
@Modifying // Required for INSERT, UPDATE, DELETE
@Transactional // Must be in a transaction
@Query("UPDATE Article a SET a.viewCount = a.viewCount + 1 WHERE a.id = :id")
int incrementViewCount(@Param("id") Long id);
// Returns number of affected rows
@Modifying
@Transactional
@Query("UPDATE Article a SET a.published = true, a.publishedAt = :publishedAt WHERE a.id = :id")
int publishArticle(@Param("id") Long id, @Param("publishedAt") LocalDateTime publishedAt);
@Modifying
@Transactional
@Query("DELETE FROM Article a WHERE a.published = false AND a.createdAt < :date")
int deleteUnpublishedOlderThan(@Param("date") LocalDateTime date);
// Bulk update
@Modifying
@Transactional
@Query("UPDATE Article a SET a.viewCount = 0 WHERE a.author.id = :authorId")
int resetViewCountByAuthor(@Param("authorId") Long authorId);
// Native modifying query
@Modifying
@Transactional
@Query(
value = "UPDATE articles SET view_count = view_count + :increment WHERE id IN :ids",
nativeQuery = true
)
int bulkIncrementViewCount(@Param("ids") List<Long> ids, @Param("increment") int increment);
// ========== NAMED QUERIES (defined in Entity class) ==========
// Defined in Article entity:
// @NamedQuery(name = "Article.findByTitleContaining",
// query = "SELECT a FROM Article a WHERE a.title LIKE %:title%")
List<Article> findByTitleContaining(@Param("title") String title);
}
DTO Projection Class:
package com.example.blog.dto;
public class ArticleSummary {
private Long id;
private String title;
private String authorUsername;
private int viewCount;
// Constructor matching @Query projection
public ArticleSummary(Long id, String title, String authorUsername, int viewCount) {
this.id = id;
this.title = title;
this.authorUsername = authorUsername;
this.viewCount = viewCount;
}
// Getters
public Long getId() { return id; }
public String getTitle() { return title; }
public String getAuthorUsername() { return authorUsername; }
public int getViewCount() { return viewCount; }
}
Pagination and Sorting - In Detail
package com.example.blog.service;
import com.example.blog.model.Article;
import com.example.blog.repository.ArticleRepository;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ArticleService {
private final ArticleRepository articleRepository;
public ArticleService(ArticleRepository articleRepository) {
this.articleRepository = articleRepository;
}
// ========== SIMPLE PAGINATION ==========
public Page<Article> getArticles(int pageNumber, int pageSize) {
// PageRequest.of(page, size)
// Page numbers are 0-indexed: 0 = first page
Pageable pageable = PageRequest.of(pageNumber, pageSize);
Page<Article> page = articleRepository.findAll(pageable);
// Page object provides:
// page.getContent() - List of entities in current page
// page.getTotalElements() - Total number of entities
// page.getTotalPages() - Total number of pages
// page.getNumber() - Current page number (0-indexed)
// page.getSize() - Page size
// page.hasNext() - Is there a next page?
// page.hasPrevious() - Is there a previous page?
// page.isFirst() - Is this the first page?
// page.isLast() - Is this the last page?
return page;
}
// ========== PAGINATION WITH SORTING ==========
public Page<Article> getArticlesSorted(int pageNumber, int pageSize, String sortBy) {
// Sort by single field, descending
Sort sort = Sort.by(sortBy).descending();
Pageable pageable = PageRequest.of(pageNumber, pageSize, sort);
return articleRepository.findAll(pageable);
}
public Page<Article> getArticlesMultiSort(int pageNumber, int pageSize) {
// Sort by multiple fields
Sort sort = Sort.by(
Sort.Order.desc("published"), // First by published (desc)
Sort.Order.desc("viewCount"), // Then by viewCount (desc)
Sort.Order.asc("title") // Then by title (asc)
);
Pageable pageable = PageRequest.of(pageNumber, pageSize, sort);
return articleRepository.findAll(pageable);
}
public Page<Article> getArticlesDynamicSort(int pageNumber, int pageSize,
String sortBy, String direction) {
// Dynamic sorting based on parameters
Sort sort = direction.equalsIgnoreCase("asc")
? Sort.by(sortBy).ascending()
: Sort.by(sortBy).descending();
Pageable pageable = PageRequest.of(pageNumber, pageSize, sort);
return articleRepository.findAll(pageable);
}
// ========== EXAMPLE: REST CONTROLLER USAGE ==========
// This shows typical pagination in a controller
public Page<Article> searchPublishedArticles(
String keyword,
int page,
int size,
String sortBy,
String sortDir) {
Sort sort = sortDir.equalsIgnoreCase(Sort.Direction.ASC.name())
? Sort.by(sortBy).ascending()
: Sort.by(sortBy).descending();
Pageable pageable = PageRequest.of(page, size, sort);
// Use with custom query method
return articleRepository.findByTitleContaining(keyword, pageable);
}
}
REST Controller with Pagination:
@RestController
@RequestMapping("/api/articles")
public class ArticleController {
private final ArticleService articleService;
public ArticleController(ArticleService articleService) {
this.articleService = articleService;
}
@GetMapping
public ResponseEntity<Page<Article>> getArticles(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String sortDir) {
Page<Article> articles = articleService.getArticlesDynamicSort(page, size, sortBy, sortDir);
return ResponseEntity.ok(articles);
}
}
// Example request: GET /api/articles?page=0&size=10&sortBy=viewCount&sortDir=desc
// Response:
// {
// "content": [...], // Array of articles
// "pageable": {...}, // Pagination details
// "totalPages": 5, // Total number of pages
// "totalElements": 47, // Total number of articles
// "last": false, // Is this the last page?
// "first": true, // Is this the first page?
// "size": 10, // Page size
// "number": 0, // Current page number
// "numberOfElements": 10, // Number of elements in current page
// "empty": false // Is the page empty?
// }
Database Initialization - All Strategies Explained
Strategy 1: schema.sql and data.sql
When to use: Development, testing, simple applications
How it works: Spring Boot automatically executes SQL scripts on startup.
src/main/resources/schema.sql:
-- This file creates database schema
-- Runs BEFORE data.sql
-- Drop tables if they exist (for development)
DROP TABLE IF EXISTS article_tags;
DROP TABLE IF EXISTS articles;
DROP TABLE IF EXISTS users;
-- Create users table
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
bio VARCHAR(500),
role VARCHAR(20) NOT NULL DEFAULT 'USER',
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Create articles table
CREATE TABLE articles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT,
author_id BIGINT NOT NULL,
view_count INT NOT NULL DEFAULT 0,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMP NULL,
CONSTRAINT fk_article_author FOREIGN KEY (author_id)
REFERENCES users(id) ON DELETE CASCADE
);
-- Create tags table
CREATE TABLE tags (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL
);
-- Create join table
CREATE TABLE article_tags (
article_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
PRIMARY KEY (article_id, tag_id),
CONSTRAINT fk_article_tags_article FOREIGN KEY (article_id)
REFERENCES articles(id) ON DELETE CASCADE,
CONSTRAINT fk_article_tags_tag FOREIGN KEY (tag_id)
REFERENCES tags(id) ON DELETE CASCADE
);
-- Create indexes for performance
CREATE INDEX idx_articles_author ON articles(author_id);
CREATE INDEX idx_articles_published ON articles(published);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
src/main/resources/data.sql:
-- This file inserts seed data
-- Runs AFTER schema.sql
-- Insert users (passwords should be BCrypt hashed in real apps)
INSERT INTO users (username, email, password, role) VALUES
('john_doe', 'john@example.com', '$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG', 'USER'),
('jane_smith', 'jane@example.com', '$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG', 'USER'),
('admin', 'admin@example.com', '$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG', 'ADMIN');
-- Insert tags
INSERT INTO tags (name) VALUES
('Spring Boot'),
('Java'),
('Tutorial'),
('REST API'),
('Database');
-- Insert articles
INSERT INTO articles (title, content, author_id, published, view_count) VALUES
('Getting Started with Spring Boot', 'This is a comprehensive guide...', 1, TRUE, 150),
('REST API Best Practices', 'Learn how to build great APIs...', 1, TRUE, 200),
('JPA and Hibernate Deep Dive', 'Understanding ORM...', 2, TRUE, 300),
('Draft Article', 'Work in progress...', 2, FALSE, 0);
-- Link articles to tags
INSERT INTO article_tags (article_id, tag_id) VALUES
(1, 1), (1, 2), (1, 3), -- Article 1 has tags 1, 2, 3
(2, 1), (2, 4), -- Article 2 has tags 1, 4
(3, 1), (3, 2), (3, 5); -- Article 3 has tags 1, 2, 5
Configuration (application.properties):
# ===== SQL SCRIPT EXECUTION =====
# Mode: always, embedded, never
# - always: Always run scripts
# - embedded: Only for embedded databases (H2, HSQL, Derby)
# - never: Never run scripts
spring.sql.init.mode=always
# Continue on error (useful for development)
spring.sql.init.continue-on-error=true
# Script encoding
spring.sql.init.encoding=UTF-8
# Platform-specific scripts
# Will look for schema-h2.sql, schema-postgresql.sql, etc.
spring.sql.init.platform=h2
# ===== IMPORTANT: Disable Hibernate DDL auto =====
# If using schema.sql, you must set this to 'none'
spring.jpa.hibernate.ddl-auto=none
Strategy 2: Hibernate DDL Auto
When to use: Development (NOT production!)
How it works: Hibernate generates/updates schema based on @Entity classes.
Configuration:
# ===== HIBERNATE DDL AUTO MODES =====
# CREATE - Drop existing schema, create new (DESTROYS DATA!)
spring.jpa.hibernate.ddl-auto=create
# Use case: Development, starting fresh each time
# What happens:
# 1. Application starts
# 2. Hibernate drops all tables
# 3. Hibernate creates tables from @Entity classes
# 4. All data is LOST
# CREATE-DROP - Create on start, drop on shutdown (DESTROYS DATA!)
spring.jpa.hibernate.ddl-auto=create-drop
# Use case: Integration tests
# What happens:
# 1. Application starts β Create tables
# 2. Application runs
# 3. Application stops β Drop all tables
# UPDATE - Update schema, keep data (CAN CAUSE ISSUES!)
spring.jpa.hibernate.ddl-auto=update
# Use case: Development (use with caution)
# What happens:
# - Adds new tables/columns
# - NEVER removes tables/columns
# - Can lead to inconsistent schema
# WARNING: Can create unexpected schema changes!
# VALIDATE - Validate schema, no changes (PRODUCTION SAFE)
spring.jpa.hibernate.ddl-auto=validate
# Use case: Production
# What happens:
# - Checks if schema matches entities
# - Throws error if mismatch
# - Makes NO changes to database
# NONE - Do nothing (PRODUCTION RECOMMENDED)
spring.jpa.hibernate.ddl-auto=none
# Use case: Production with migrations (Flyway/Liquibase)
# What happens: Nothing, you manage schema manually
# ===== ADDITIONAL HIBERNATE SETTINGS =====
# Show SQL queries in console
spring.jpa.show-sql=true
# Format SQL for readability
spring.jpa.properties.hibernate.format_sql=true
# Show bind parameter values
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
# Hibernate dialect (auto-detected usually)
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
Environment-Specific Configuration:
# application-dev.yml (Development)
spring:
jpa:
hibernate:
ddl-auto: create-drop # Fresh schema on each restart
show-sql: true
sql:
init:
mode: always # Run data.sql for seed data
# application-test.yml (Testing)
spring:
jpa:
hibernate:
ddl-auto: create-drop # Fresh schema for each test
show-sql: false # Less noise in test output
# application-prod.yml (Production)
spring:
jpa:
hibernate:
ddl-auto: validate # Validate only, no changes
show-sql: false
sql:
init:
mode: never # Never run SQL scripts in production
Strategy 3: Flyway Migrations (PRODUCTION RECOMMENDED!)
When to use: Production, team environments, version-controlled schemas
Why Flyway?
Version-controlled database changes
Repeatable, automated migrations
Works across all environments
Rollback support
Team collaboration (no schema conflicts)
Add Dependency:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
Configuration:
# ===== FLYWAY CONFIGURATION =====
# Enable Flyway
spring.flyway.enabled=true
# Migration scripts location
spring.flyway.locations=classpath:db/migration
# Baseline on migrate (for existing databases)
spring.flyway.baseline-on-migrate=true
# Baseline version
spring.flyway.baseline-version=0
# Validate migrations on startup
spring.flyway.validate-on-migrate=true
# Clean database (DANGEROUS - development only!)
# spring.flyway.clean-on-validation-error=true
# Disable Hibernate DDL auto when using Flyway
spring.jpa.hibernate.ddl-auto=none
Migration File Naming Convention:
src/main/resources/db/migration/
βββ V1__Create_users_table.sql
βββ V2__Create_articles_table.sql
βββ V3__Create_tags_and_join_table.sql
βββ V4__Add_bio_column_to_users.sql
βββ V5__Add_indexes.sql
βββ V6__Insert_initial_data.sql
Format: V{version}__{description}.sql
- V = Versioned migration
- {version} = Numeric version (1, 2, 3 or 1.0, 1.1, etc.)
- __ = Double underscore separator
- {description} = Description with underscores
Example Migrations:
V1__Create_users_table.sql:
-- Flyway migration: Create users table
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY, -- PostgreSQL auto-increment
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'USER',
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
V2__Create_articles_table.sql:
-- Flyway migration: Create articles table
CREATE TABLE articles (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT,
author_id BIGINT NOT NULL,
view_count INT NOT NULL DEFAULT 0,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMP,
CONSTRAINT fk_article_author
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_articles_author ON articles(author_id);
CREATE INDEX idx_articles_published ON articles(published);
V3__Add_bio_column.sql:
-- Flyway migration: Add bio column to users
ALTER TABLE users ADD COLUMN bio VARCHAR(500);
V4__Insert_seed_data.sql:
-- Flyway migration: Insert initial data
INSERT INTO users (username, email, password, role)
VALUES ('admin', 'admin@example.com', '$2a$10$...', 'ADMIN')
ON CONFLICT DO NOTHING; -- PostgreSQL syntax
How Flyway Works:
1. Application Starts
β
2. Flyway checks for flyway_schema_history table
β
3. Creates table if it doesn't exist
β
4. Reads all migration files (V1, V2, V3...)
β
5. Checks which migrations already ran (from flyway_schema_history)
β
6. Runs only NEW migrations in order
β
7. Records each migration in flyway_schema_history
β
8. Application Ready
flyway_schema_history table:
SELECT * FROM flyway_schema_history;
| installed_rank | version | description | type | script | checksum | installed_on | success |
|----------------|---------|------------------------|------|----------------------------|-------------|---------------------|---------|
| 1 | 1 | Create users table | SQL | V1__Create_users_table.sql | 1234567890 | 2024-01-15 10:00:00 | true |
| 2 | 2 | Create articles table | SQL | V2__Create_articles... | 9876543210 | 2024-01-15 10:00:01 | true |
Best Practices:
Never modify executed migrations - Create new migration instead
Always test migrations on dev/staging before production
Keep migrations small - One logical change per migration
Use transactions - Most changes should be in a transaction
Backup before migrating production databases
Comparison Table:
| Strategy | Pros | Cons | Use Case |
| schema.sql | Simple, easy to understand | No versioning, not team-friendly | Small projects, prototypes |
| Hibernate DDL | Zero configuration, auto-generates | Dangerous in production, no control | Development only |
| Flyway | Version control, team-friendly, production-ready | Requires discipline, more setup | Production, team projects |
Working with NoSQL Databases - MongoDB Example
Why NoSQL?
NoSQL databases are designed for:
Flexible schemas: No fixed table structure
Horizontal scaling: Distribute data across many servers
High performance: Optimized for specific use cases
Document storage: Store complex nested data
MongoDB is a document-oriented NoSQL database that stores data in JSON-like documents.
Spring Data MongoDB
Spring Data MongoDB provides the same repository abstraction as Spring Data JPA, but for MongoDB.
Add Dependencies:
<dependencies>
<!-- Spring Data MongoDB -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<!-- Embedded MongoDB for testing (optional) -->
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Configuration (application.properties):
# ===== MONGODB CONNECTION =====
# Single MongoDB instance
spring.data.mongodb.host=localhost
spring.data.mongodb.port=27017
spring.data.mongodb.database=blogdb
# Or use connection URI (recommended for production)
spring.data.mongodb.uri=mongodb://localhost:27017/blogdb
# With authentication
spring.data.mongodb.uri=mongodb://username:password@localhost:27017/blogdb
# MongoDB Atlas (cloud)
spring.data.mongodb.uri=mongodb+srv://username:password@cluster0.mongodb.net/blogdb
# Connection pool settings
spring.data.mongodb.auto-index-creation=true # Auto-create indexes from @Indexed
MongoDB Documents - Entity Classes
In MongoDB, entities are called Documents. They're stored as JSON-like BSON (Binary JSON).
Complete Document Example:
package com.example.blog.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;
import org.springframework.data.mongodb.core.mapping.DBRef;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.TextIndexed;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
import java.util.HashMap;
@Document(collection = "users") // Collection name (like table name in SQL)
// Compound index on multiple fields
@CompoundIndex(name = "username_email_idx", def = "{'username': 1, 'email': 1}")
public class User {
@Id // MongoDB uses String IDs (ObjectId internally)
// MongoDB generates this automatically: "507f1f77bcf86cd799439011"
private String id;
@Indexed(unique = true) // Create unique index on this field
// Equivalent to SQL: CREATE UNIQUE INDEX ON users(username)
private String username;
@Indexed(unique = true)
private String email;
private String password; // Store BCrypt hash
@Field("bio") // Custom field name in MongoDB (optional)
// By default, field name matches property name
private String biography;
// Enum stored as string
private Role role; // Stored as "USER", "ADMIN", etc.
// List of strings (native JSON array)
private List<String> interests = new ArrayList<>();
// Nested object (embedded document)
private Address address;
// Map (flexible key-value pairs)
private Map<String, Object> metadata = new HashMap<>();
@CreatedDate // Automatically set on creation (requires @EnableMongoAuditing)
private LocalDateTime createdAt;
@LastModifiedDate // Automatically updated on modification
private LocalDateTime updatedAt;
@DBRef // Reference to another document (like foreign key)
// Stores reference as: { "$ref": "articles", "$id": ObjectId("...") }
private List<Article> articles = new ArrayList<>();
@TextIndexed // Enable text search on this field
private String bio;
// Transient field (not stored in MongoDB)
@Transient
private String temporaryData;
// Constructors
public User() {}
public User(String username, String email, String password, Role role) {
this.username = username;
this.email = email;
this.password = password;
this.role = role;
}
// Getters and setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getBiography() { return biography; }
public void setBiography(String biography) { this.biography = biography; }
public Role getRole() { return role; }
public void setRole(Role role) { this.role = role; }
public List<String> getInterests() { return interests; }
public void setInterests(List<String> interests) { this.interests = interests; }
public Address getAddress() { return address; }
public void setAddress(Address address) { this.address = address; }
public Map<String, Object> getMetadata() { return metadata; }
public void setMetadata(Map<String, Object> metadata) { this.metadata = metadata; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public List<Article> getArticles() { return articles; }
public void setArticles(List<Article> articles) { this.articles = articles; }
public String getBio() { return bio; }
public void setBio(String bio) { this.bio = bio; }
}
// Embedded document (no @Document annotation)
// This will be stored inside the parent document
public class Address {
private String street;
private String city;
private String state;
private String zipCode;
private String country;
// Constructors, getters, setters
public Address() {}
public Address(String street, String city, String state, String zipCode, String country) {
this.street = street;
this.city = city;
this.state = state;
this.zipCode = zipCode;
this.country = country;
}
// Getters and setters...
}
// Enum
public enum Role {
USER, ADMIN, MODERATOR
}
How this looks in MongoDB:
{
"_id": ObjectId("507f1f77bcf86cd799439011"),
"username": "john_doe",
"email": "john@example.com",
"password": "$2a$10$dXJ3SW6G7P50lGmMkkmwe...",
"bio": "Software developer passionate about Spring Boot",
"role": "USER",
"interests": ["Java", "Spring", "MongoDB"],
"address": {
"street": "123 Main St",
"city": "New York",
"state": "NY",
"zipCode": "10001",
"country": "USA"
},
"metadata": {
"lastLogin": "2024-01-15T10:30:00",
"loginCount": 42,
"preferences": {
"theme": "dark",
"notifications": true
}
},
"createdAt": ISODate("2024-01-01T00:00:00Z"),
"updatedAt": ISODate("2024-01-15T10:30:00Z"),
"articles": [
{ "$ref": "articles", "$id": ObjectId("507f1f77bcf86cd799439012") },
{ "$ref": "articles", "$id": ObjectId("507f1f77bcf86cd799439013") }
]
}
Article Document:
@Document(collection = "articles")
public class Article {
@Id
private String id;
@Indexed // Index for faster queries
private String title;
@TextIndexed(weight = 2) // Higher weight for title in text search
private String content;
@DBRef(lazy = true) // Lazy load author (not loaded until accessed)
private User author;
// Array of strings
private List<String> tags = new ArrayList<>();
// Embedded documents (comments stored inside article)
private List<Comment> comments = new ArrayList<>();
private int viewCount = 0;
private boolean published = false;
@CreatedDate
private LocalDateTime createdAt;
private LocalDateTime publishedAt;
// Nested object for SEO metadata
private SeoMetadata seo;
// Constructors
public Article() {}
public Article(String title, String content, User author) {
this.title = title;
this.content = content;
this.author = author;
}
// Getters and setters...
}
// Embedded comment (no separate collection)
public class Comment {
private String authorName;
private String content;
private LocalDateTime createdAt;
public Comment() {}
public Comment(String authorName, String content) {
this.authorName = authorName;
this.content = content;
this.createdAt = LocalDateTime.now();
}
// Getters and setters...
}
// Embedded SEO metadata
public class SeoMetadata {
private String metaTitle;
private String metaDescription;
private List<String> keywords = new ArrayList<>();
// Getters and setters...
}
MongoDB Article Document:
{
"_id": ObjectId("507f1f77bcf86cd799439012"),
"title": "Getting Started with Spring Boot",
"content": "This is a comprehensive guide to Spring Boot...",
"author": { "$ref": "users", "$id": ObjectId("507f1f77bcf86cd799439011") },
"tags": ["Spring Boot", "Java", "Tutorial"],
"comments": [
{
"authorName": "Jane Smith",
"content": "Great article!",
"createdAt": ISODate("2024-01-15T14:30:00Z")
},
{
"authorName": "Bob Johnson",
"content": "Very helpful, thanks!",
"createdAt": ISODate("2024-01-16T09:15:00Z")
}
],
"viewCount": 150,
"published": true,
"createdAt": ISODate("2024-01-10T08:00:00Z"),
"publishedAt": ISODate("2024-01-10T10:00:00Z"),
"seo": {
"metaTitle": "Complete Spring Boot Guide | Learn Spring Boot",
"metaDescription": "A comprehensive guide to getting started with Spring Boot",
"keywords": ["spring boot", "java", "tutorial", "beginner"]
}
}
MongoDB Repositories
MongoDB repositories work similarly to JPA repositories:
package com.example.blog.repository;
import com.example.blog.model.User;
import com.example.blog.model.Role;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public interface UserRepository extends MongoRepository<User, String> {
// MongoRepository<Entity, ID Type>
// Note: ID is String for MongoDB (not Long like JPA)
// Inherited methods (same as JPA):
// - save(user)
// - findById(id)
// - findAll()
// - deleteById(id)
// - count()
// - existsById(id)
// ========== METHOD NAME QUERIES (similar to JPA) ==========
// Find by single field
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
List<User> findByRole(Role role);
// Multiple conditions
Optional<User> findByUsernameAndEmail(String username, String email);
List<User> findByRoleAndCreatedAtAfter(Role role, LocalDateTime date);
// String operations
List<User> findByUsernameContaining(String substring);
List<User> findByUsernameStartingWith(String prefix);
List<User> findByUsernameIgnoreCase(String username);
// Array operations (specific to MongoDB)
// Find users who have "Java" in their interests array
List<User> findByInterestsContaining(String interest);
// Find users who have ANY of these interests
List<User> findByInterestsIn(List<String> interests);
// Nested field queries (query embedded documents)
// Find users in a specific city
List<User> findByAddress_City(String city);
List<User> findByAddress_StateAndAddress_City(String state, String city);
// Map queries (query flexible metadata)
// Note: This requires custom query for complex map operations
// Boolean operations
List<User> findByMetadataExists(String key); // Check if map key exists
// Sorting and limiting
List<User> findTop10ByRoleOrderByCreatedAtDesc(Role role);
Optional<User> findFirstByRoleOrderByCreatedAtDesc(Role role);
// Pagination
Page<User> findByRole(Role role, Pageable pageable);
// ========== CUSTOM MONGODB QUERIES ==========
// MongoDB JSON query syntax
@Query("{ 'username': ?0 }")
Optional<User> findByUsernameCustom(String username);
// Query with multiple conditions
@Query("{ 'role': ?0, 'createdAt': { $gte: ?1 } }")
List<User> findByRoleAndCreatedAfter(Role role, LocalDateTime date);
// Query nested fields
@Query("{ 'address.city': ?0 }")
List<User> findByCityCustom(String city);
// Query arrays - check if array contains value
@Query("{ 'interests': ?0 }")
List<User> findByInterest(String interest);
// Query arrays - check if array contains ALL values
@Query("{ 'interests': { $all: ?0 } }")
List<User> findByAllInterests(List<String> interests);
// Query maps - check if map has specific key-value
@Query("{ 'metadata.?0': ?1 }")
List<User> findByMetadataField(String key, Object value);
// Regex query (pattern matching)
@Query("{ 'username': { $regex: ?0, $options: 'i' } }")
// $options: 'i' makes it case-insensitive
List<User> findByUsernameRegex(String pattern);
// Complex query with multiple operators
@Query("{ $or: [ { 'username': ?0 }, { 'email': ?0 } ] }")
Optional<User> findByUsernameOrEmail(String value);
// Range query
@Query("{ 'createdAt': { $gte: ?0, $lte: ?1 } }")
List<User> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
// Projection - return only specific fields
@Query(value = "{ 'role': ?0 }", fields = "{ 'username': 1, 'email': 1 }")
// fields: 1 = include, 0 = exclude
List<User> findUsernameAndEmailByRole(Role role);
// Sort in query
@Query(value = "{ 'role': ?0 }", sort = "{ 'createdAt': -1 }")
// -1 = descending, 1 = ascending
List<User> findByRoleSortedByCreatedAt(Role role);
// Count query
@Query(value = "{ 'role': ?0 }", count = true)
long countByRoleCustom(Role role);
// Delete query
@Query(value = "{ 'createdAt': { $lt: ?0 } }", delete = true)
List<User> deleteByCreatedAtBefore(LocalDateTime date);
// Text search (requires text index)
@Query("{ $text: { $search: ?0 } }")
List<User> fullTextSearch(String searchTerm);
// Aggregation-like query
@Query("{ 'interests': { $size: { $gte: ?0 } } }")
List<User> findByInterestsCountGreaterThan(int count);
}
Article Repository with MongoDB-specific queries:
@Repository
public interface ArticleRepository extends MongoRepository<Article, String> {
// Method name queries
List<Article> findByAuthor(User author);
List<Article> findByTagsContaining(String tag);
List<Article> findByPublishedTrue();
List<Article> findByPublishedTrueAndViewCountGreaterThan(int minViews);
// Query embedded array of objects
// Find articles with comments by specific author
@Query("{ 'comments.authorName': ?0 }")
List<Article> findByCommentAuthor(String authorName);
// Count embedded documents
@Query("{ 'comments': { $exists: true, $not: { $size: 0 } } }")
List<Article> findArticlesWithComments();
// Query by array size
@Query("{ 'tags': { $size: ?0 } }")
List<Article> findByTagCount(int tagCount);
// Update query (using MongoTemplate in service)
// Note: @Query doesn't support updates directly
// Use MongoTemplate for update operations
// Text search on multiple fields
@Query("{ $text: { $search: ?0 } }")
List<Article> searchArticles(String keyword);
// Geospatial query (if you have location data)
// @Query("{ 'location': { $near: { $geometry: { type: 'Point', coordinates: [?0, ?1] }, $maxDistance: ?2 } } }")
// List<Article> findNearLocation(double longitude, double latitude, double maxDistance);
}
MongoTemplate - For Advanced Operations
For operations not supported by repository methods, use MongoTemplate:
package com.example.blog.service;
import com.example.blog.model.Article;
import com.example.blog.model.User;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class MongoArticleService {
private final MongoTemplate mongoTemplate;
public MongoArticleService(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
// ========== FIND OPERATIONS ==========
public List<Article> findPublishedArticles() {
// Create query
Query query = new Query();
query.addCriteria(Criteria.where("published").is(true));
// Execute query
return mongoTemplate.find(query, Article.class);
}
public List<Article> findByMultipleCriteria(String tag, int minViews) {
Query query = new Query();
query.addCriteria(
Criteria.where("tags").in(tag)
.and("viewCount").gte(minViews)
.and("published").is(true)
);
return mongoTemplate.find(query, Article.class);
}
public List<Article> findWithOrCondition(String tag1, String tag2) {
Query query = new Query();
query.addCriteria(
new Criteria().orOperator(
Criteria.where("tags").in(tag1),
Criteria.where("tags").in(tag2)
)
);
return mongoTemplate.find(query, Article.class);
}
// ========== UPDATE OPERATIONS ==========
public void incrementViewCount(String articleId) {
Query query = new Query(Criteria.where("_id").is(articleId));
Update update = new Update();
update.inc("viewCount", 1); // Increment by 1
mongoTemplate.updateFirst(query, update, Article.class);
// updateFirst - updates first matching document
// updateMulti - updates all matching documents
}
public void addTag(String articleId, String tag) {
Query query = new Query(Criteria.where("_id").is(articleId));
Update update = new Update();
update.addToSet("tags", tag); // Add to array if not exists
mongoTemplate.updateFirst(query, update, Article.class);
}
public void removeTag(String articleId, String tag) {
Query query = new Query(Criteria.where("_id").is(articleId));
Update update = new Update();
update.pull("tags", tag); // Remove from array
mongoTemplate.updateFirst(query, update, Article.class);
}
public void addComment(String articleId, String authorName, String content) {
Query query = new Query(Criteria.where("_id").is(articleId));
// Create comment object
Comment comment = new Comment(authorName, content);
Update update = new Update();
update.push("comments", comment); // Add to array
mongoTemplate.updateFirst(query, update, Article.class);
}
public void updateNestedField(String userId, String newCity) {
Query query = new Query(Criteria.where("_id").is(userId));
Update update = new Update();
update.set("address.city", newCity); // Update nested field
mongoTemplate.updateFirst(query, update, User.class);
}
// ========== AGGREGATION OPERATIONS ==========
public long getTotalViewsByAuthor(User author) {
Query query = new Query(Criteria.where("author.$id").is(author.getId()));
List<Article> articles = mongoTemplate.find(query, Article.class);
return articles.stream()
.mapToLong(Article::getViewCount)
.sum();
}
// ========== DELETE OPERATIONS ==========
public void deleteUnpublishedOldArticles(LocalDateTime beforeDate) {
Query query = new Query();
query.addCriteria(
Criteria.where("published").is(false)
.and("createdAt").lt(beforeDate)
);
mongoTemplate.remove(query, Article.class);
}
}
Key Differences: MongoDB vs SQL (JPA)
| Feature | MongoDB (NoSQL) | SQL (JPA) |
| ID Type | String (ObjectId) | Long or custom |
| Schema | Flexible, schema-less | Fixed schema |
| Relationships | @DBRef or embedded | @OneToMany, @ManyToOne, etc. |
| Joins | Limited, use @DBRef | Efficient joins |
| Arrays | Native support | Requires separate table |
| Nested Objects | Native (embedded docs) | Requires @Embedded or separate table |
| Transactions | Limited (replica sets) | Full ACID |
| Queries | JSON-based | SQL or JPQL |
| Indexes | @Indexed annotation | @Index in @Table |
| Scaling | Horizontal (sharding) | Vertical (more powerful server) |
When to Use MongoDB vs SQL
Use MongoDB when:
Schema changes frequently
Need to store hierarchical/nested data
Need horizontal scaling
Working with semi-structured data
High write throughput required
Use SQL when:
Complex relationships between entities
Need ACID transactions
Data integrity is critical
Complex queries with joins
Structured data with fixed schema
Enable Auditing (Auto-set createdAt/updatedAt)
package com.example.blog.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.config.EnableMongoAuditing;
@Configuration
@EnableMongoAuditing // Enable automatic auditing
public class MongoConfig {
// This enables @CreatedDate and @LastModifiedDate annotations
}
Building REST APIs - Everything You Need
REST Fundamentals - Deep Understanding
REST (Representational State Transfer) is an architectural style for building web services.
Core Principles:
Client-Server Architecture: Separation of concerns
Stateless: Each request contains all information needed
Cacheable: Responses should indicate if they can be cached
Uniform Interface: Consistent way to interact with resources
Layered System: Client doesn't know if connected directly to server
Code on Demand (optional): Server can send executable code
Resources: Everything is a resource (user, article, comment)
Identified by URIs:
/api/users/123Manipulated through representations (JSON, XML)
Self-descriptive messages
HTTP Methods (Verbs):
| Method | Purpose | Idempotent? | Safe? | Request Body? | Response Body? |
| GET | Retrieve resource(s) | Yes | Yes | No | Yes |
| POST | Create new resource | No | No | Yes | Yes |
| PUT | Update/replace resource | Yes | No | Yes | Yes |
| PATCH | Partial update | No | No | Yes | Yes |
| DELETE | Delete resource | Yes | No | No | Optional |
| HEAD | GET without body | Yes | Yes | No | No |
| OPTIONS | Get allowed methods | Yes | Yes | No | Yes |
Idempotent: Multiple identical requests have same effect as single request Safe: Doesn't modify server state
HTTP Status Codes:
| Code | Meaning | When to Use |
| 200 OK | Success | GET, PUT, PATCH successful |
| 201 Created | Resource created | POST successful |
| 204 No Content | Success, no body | DELETE successful |
| 400 Bad Request | Invalid request | Validation failed |
| 401 Unauthorized | Authentication required | No/invalid credentials |
| 403 Forbidden | No permission | Authenticated but not authorized |
| 404 Not Found | Resource doesn't exist | Resource not found |
| 409 Conflict | Conflict with current state | Duplicate resource |
| 500 Internal Server Error | Server error | Unexpected server error |
Creating REST Controllers - Complete Guide
Basic Controller Structure:
package com.example.blog.controller;
import com.example.blog.model.User;
import com.example.blog.dto.*;
import com.example.blog.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
@RestController // @Controller + @ResponseBody
// @Controller: Marks this as a Spring MVC controller
// @ResponseBody: All methods return data (not view names)
// @RestController combines both
@RequestMapping("/api/users") // Base path for all endpoints in this controller
// All methods will be prefixed with /api/users
@CrossOrigin(origins = "*") // Enable CORS (Cross-Origin Resource Sharing)
// Allow requests from different origins (for frontend apps)
public class UserController {
// Dependency injection (constructor injection - recommended)
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
// ========== GET REQUESTS ==========
/**
* GET /api/users
* Get all users
* Returns: 200 OK with list of users
*/
@GetMapping // Shortcut for @RequestMapping(method = RequestMethod.GET)
public ResponseEntity<List<UserDTO>> getAllUsers() {
// ResponseEntity allows you to control:
// - HTTP status code
// - Response headers
// - Response body
List<UserDTO> users = userService.getAllUsers();
return ResponseEntity.ok(users);
// ResponseEntity.ok() = 200 OK status
// Equivalent to: new ResponseEntity<>(users, HttpStatus.OK)
}
/**
* GET /api/users/123
* Get user by ID
* @param id - extracted from URL path
* Returns: 200 OK with user, or 404 if not found
*/
@GetMapping("/{id}") // {id} is a path variable
public ResponseEntity<UserDTO> getUserById(@PathVariable Long id) {
// @PathVariable extracts value from URL path
// /api/users/123 β id = 123
UserDTO user = userService.getUserById(id);
return ResponseEntity.ok(user);
// If user not found, service throws exception (handled globally)
}
/**
* GET /api/users/username/john_doe
* Get user by username
*/
@GetMapping("/username/{username}")
public ResponseEntity<UserDTO> getUserByUsername(@PathVariable String username) {
UserDTO user = userService.getUserByUsername(username);
return ResponseEntity.ok(user);
}
/**
* GET /api/users/search?username=john&email=john@example.com
* Search users with query parameters
* @param username - optional query parameter
* @param email - optional query parameter
*/
@GetMapping("/search")
public ResponseEntity<List<UserDTO>> searchUsers(
@RequestParam(required = false) String username,
// @RequestParam extracts from query string
// required = false makes it optional
// /api/users/search?username=john
@RequestParam(required = false) String email) {
List<UserDTO> results = userService.searchUsers(username, email);
return ResponseEntity.ok(results);
}
/**
* GET /api/users?page=0&size=10&sort=username
* Get users with pagination
*/
@GetMapping
public ResponseEntity<Page<UserDTO>> getUsersPaginated(
@RequestParam(defaultValue = "0") int page,
// defaultValue used if parameter not provided
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "username") String sort) {
Page<UserDTO> users = userService.getUsers(page, size, sort);
return ResponseEntity.ok(users);
}
// ========== POST REQUESTS ==========
/**
* POST /api/users
* Create new user
* Request body: JSON representation of CreateUserRequest
* Returns: 201 Created with created user and Location header
*/
@PostMapping
public ResponseEntity<UserDTO> createUser(
@Valid @RequestBody CreateUserRequest request) {
// @RequestBody: Deserialize JSON from request body to Java object
// @Valid: Trigger Bean Validation on the request object
// If validation fails, throws MethodArgumentNotValidException
UserDTO created = userService.createUser(request);
// Build Location header: /api/users/123
URI location = ServletUriComponentsBuilder
.fromCurrentRequest() // Current request URI: /api/users
.path("/{id}") // Append /{id}
.buildAndExpand(created.getId()) // Replace {id} with actual ID
.toUri(); // Convert to URI object
return ResponseEntity
.created(location) // 201 Created status with Location header
.body(created); // Response body
}
// ========== PUT REQUESTS ==========
/**
* PUT /api/users/123
* Update user (full replacement)
* Request body: Complete user data
* Returns: 200 OK with updated user
*/
@PutMapping("/{id}")
public ResponseEntity<UserDTO> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
UserDTO updated = userService.updateUser(id, request);
return ResponseEntity.ok(updated);
}
// ========== PATCH REQUESTS ==========
/**
* PATCH /api/users/123
* Partial update (update only provided fields)
* Request body: Fields to update
* Returns: 200 OK with updated user
*/
@PatchMapping("/{id}")
public ResponseEntity<UserDTO> patchUser(
@PathVariable Long id,
@RequestBody Map<String, Object> updates) {
// Map allows flexible updates
// Client sends only fields to update:
// { "email": "newemail@example.com", "bio": "New bio" }
UserDTO patched = userService.patchUser(id, updates);
return ResponseEntity.ok(patched);
}
// ========== DELETE REQUESTS ==========
/**
* DELETE /api/users/123
* Delete user
* Returns: 204 No Content (success with no response body)
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
// 204 No Content - successful deletion, no response body
// Void type indicates no response body
}
// ========== CUSTOM OPERATIONS ==========
/**
* POST /api/users/123/activate
* Custom action on resource
*/
@PostMapping("/{id}/activate")
public ResponseEntity<UserDTO> activateUser(@PathVariable Long id) {
UserDTO activated = userService.activateUser(id);
return ResponseEntity.ok(activated);
}
/**
* POST /api/users/123/deactivate
*/
@PostMapping("/{id}/deactivate")
public ResponseEntity<UserDTO> deactivateUser(@PathVariable Long id) {
UserDTO deactivated = userService.deactivateUser(id);
return ResponseEntity.ok(deactivated);
}
// ========== REQUEST HEADER EXAMPLES ==========
/**
* Extract custom header from request
*/
@GetMapping("/with-header")
public ResponseEntity<String> handleWithHeader(
@RequestHeader("X-Custom-Header") String customHeader) {
// Extract value from request header
return ResponseEntity.ok("Header value: " + customHeader);
}
/**
* Extract authorization header
*/
@GetMapping("/protected")
public ResponseEntity<String> protectedEndpoint(
@RequestHeader("Authorization") String authHeader) {
// authHeader = "Bearer eyJhbGc..."
return ResponseEntity.ok("Authorized");
}
// ========== COOKIE EXAMPLES ==========
/**
* Extract cookie value
*/
@GetMapping("/with-cookie")
public ResponseEntity<String> handleWithCookie(
@CookieValue("sessionId") String sessionId) {
return ResponseEntity.ok("Session ID: " + sessionId);
}
// ========== RESPONSE CUSTOMIZATION ==========
/**
* Custom response with headers
*/
@GetMapping("/custom-response")
public ResponseEntity<UserDTO> customResponse() {
UserDTO user = userService.getSampleUser();
return ResponseEntity
.ok() // 200 OK
.header("X-Custom-Header", "Custom Value") // Add custom header
.header("X-Total-Count", "100") // Add another header
.body(user); // Response body
}
/**
* Different status codes based on condition
*/
@GetMapping("/conditional/{id}")
public ResponseEntity<UserDTO> conditionalResponse(@PathVariable Long id) {
Optional<UserDTO> user = userService.findUserById(id);
if (user.isPresent()) {
return ResponseEntity.ok(user.get()); // 200 OK
} else {
return ResponseEntity.notFound().build(); // 404 Not Found
}
}
// ========== FILE UPLOAD ==========
/**
* Upload user profile picture
*/
@PostMapping("/{id}/profile-picture")
public ResponseEntity<String> uploadProfilePicture(
@PathVariable Long id,
@RequestParam("file") MultipartFile file) {
// MultipartFile handles file uploads
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("File is empty");
}
String fileUrl = userService.uploadProfilePicture(id, file);
return ResponseEntity.ok(fileUrl);
}
}
Complete Article Controller Example
@RestController
@RequestMapping("/api/articles")
public class ArticleController {
private final ArticleService articleService;
public ArticleController(ArticleService articleService) {
this.articleService = articleService;
}
/**
* GET /api/articles?page=0&size=10&sort=createdAt&dir=desc
*/
@GetMapping
public ResponseEntity<Page<ArticleDTO>> getArticles(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sort,
@RequestParam(defaultValue = "desc") String dir) {
Page<ArticleDTO> articles = articleService.getArticles(page, size, sort, dir);
return ResponseEntity.ok(articles);
}
/**
* GET /api/articles/123
*/
@GetMapping("/{id}")
public ResponseEntity<ArticleDTO> getArticle(@PathVariable Long id) {
ArticleDTO article = articleService.getArticleById(id);
// Increment view count asynchronously
articleService.incrementViewCountAsync(id);
return ResponseEntity.ok(article);
}
/**
* POST /api/articles
*/
@PostMapping
public ResponseEntity<ArticleDTO> createArticle(
@Valid @RequestBody CreateArticleRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
// @AuthenticationPrincipal extracts authenticated user from security context
ArticleDTO created = articleService.createArticle(request, userDetails.getUsername());
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
return ResponseEntity.created(location).body(created);
}
/**
* PUT /api/articles/123
*/
@PutMapping("/{id}")
public ResponseEntity<ArticleDTO> updateArticle(
@PathVariable Long id,
@Valid @RequestBody UpdateArticleRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
ArticleDTO updated = articleService.updateArticle(id, request, userDetails.getUsername());
return ResponseEntity.ok(updated);
}
/**
* DELETE /api/articles/123
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteArticle(
@PathVariable Long id,
@AuthenticationPrincipal UserDetails userDetails) {
articleService.deleteArticle(id, userDetails.getUsername());
return ResponseEntity.noContent().build();
}
/**
* POST /api/articles/123/publish
*/
@PostMapping("/{id}/publish")
public ResponseEntity<ArticleDTO> publishArticle(
@PathVariable Long id,
@AuthenticationPrincipal UserDetails userDetails) {
ArticleDTO published = articleService.publishArticle(id, userDetails.getUsername());
return ResponseEntity.ok(published);
}
/**
* GET /api/articles/search?q=spring boot
*/
@GetMapping("/search")
public ResponseEntity<List<ArticleDTO>> searchArticles(
@RequestParam("q") String query) {
List<ArticleDTO> results = articleService.searchArticles(query);
return ResponseEntity.ok(results);
}
/**
* GET /api/articles/tag/spring-boot
*/
@GetMapping("/tag/{tagName}")
public ResponseEntity<List<ArticleDTO>> getArticlesByTag(
@PathVariable String tagName) {
List<ArticleDTO> articles = articleService.getArticlesByTag(tagName);
return ResponseEntity.ok(articles);
}
/**
* GET /api/articles/author/123
*/
@GetMapping("/author/{authorId}")
public ResponseEntity<List<ArticleDTO>> getArticlesByAuthor(
@PathVariable Long authorId) {
List<ArticleDTO> articles = articleService.getArticlesByAuthor(authorId);
return ResponseEntity.ok(articles);
}
}
DTO Pattern and Bean Validation - Complete Guide
DTO (Data Transfer Object) is a pattern used to transfer data between layers.
Why use DTOs?
Separation of Concerns: API contract separate from database schema
Security: Don't expose internal entity structure
Flexibility: API can change independently from database
Validation: Centralized validation rules
Hide Sensitive Data: Never expose passwords, internal IDs, etc.
Complete DTO Examples:
package com.example.blog.dto.request;
import jakarta.validation.constraints.*;
import java.time.LocalDate;
/**
* DTO for creating a new user
* Used in POST /api/users
*/
public class CreateUserRequest {
@NotBlank(message = "Username is required")
// @NotBlank: String cannot be null, empty, or whitespace only
// Different from @NotNull (allows empty) and @NotEmpty (allows whitespace)
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
// @Size: Validates string length or collection size
@Pattern(regexp = "^[a-zA-Z0-9_]+$",
message = "Username can only contain letters, numbers, and underscores")
// @Pattern: Regular expression validation
private String username;
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
// @Email: Validates email format using RFC 5322 standard
// Checks format like: user@domain.com
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=]).*$",
message = "Password must contain at least one digit, one lowercase, one uppercase, and one special character")
// Password strength validation
private String password;
@Size(max = 500, message = "Bio cannot exceed 500 characters")
// Optional field - no @NotBlank
private String bio;
@Past(message = "Birth date must be in the past")
// @Past: Date must be in the past
// @Future: Date must be in the future
private LocalDate birthDate;
@Min(value = 18, message = "Must be at least 18 years old")
@Max(value = 120, message = "Age cannot exceed 120")
// @Min/@Max: Numeric range validation
private Integer age;
@NotNull(message = "Terms acceptance is required")
@AssertTrue(message = "You must accept the terms and conditions")
// @AssertTrue: Boolean must be true
// @AssertFalse: Boolean must be false
private Boolean acceptTerms;
@NotEmpty(message = "At least one interest is required")
// @NotEmpty: Collection/Array cannot be null or empty
private List<String> interests;
// Nested object validation
@Valid // IMPORTANT: Triggers validation on nested object
@NotNull(message = "Address is required")
private AddressDTO address;
// Constructors
public CreateUserRequest() {}
// Getters and setters
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getBio() { return bio; }
public void setBio(String bio) { this.bio = bio; }
public LocalDate getBirthDate() { return birthDate; }
public void setBirthDate(LocalDate birthDate) { this.birthDate = birthDate; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
public Boolean getAcceptTerms() { return acceptTerms; }
public void setAcceptTerms(Boolean acceptTerms) { this.acceptTerms = acceptTerms; }
public List<String> getInterests() { return interests; }
public void setInterests(List<String> interests) { this.interests = interests; }
public AddressDTO getAddress() { return address; }
public void setAddress(AddressDTO address) { this.address = address; }
}
/**
* Nested DTO for address
*/
public class AddressDTO {
@NotBlank(message = "Street is required")
private String street;
@NotBlank(message = "City is required")
private String city;
@NotBlank(message = "State is required")
@Size(min = 2, max = 2, message = "State must be 2 characters")
private String state;
@NotBlank(message = "Zip code is required")
@Pattern(regexp = "^\\d{5}(-\\d{4})?$", message = "Invalid zip code format")
// Matches: 12345 or 12345-6789
private String zipCode;
// Getters and setters...
}
/**
* Response DTO - what the API returns
* NEVER include sensitive data like passwords!
*/
public class UserDTO {
private Long id;
private String username;
private String email;
// NO password field - NEVER expose passwords
private String bio;
private LocalDate birthDate;
private String role;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<String> interests;
private AddressDTO address;
// Constructors
public UserDTO() {}
// Full constructor for easy creation
public UserDTO(Long id, String username, String email, String bio,
LocalDate birthDate, String role, LocalDateTime createdAt) {
this.id = id;
this.username = username;
this.email = email;
this.bio = bio;
this.birthDate = birthDate;
this.role = role;
this.createdAt = createdAt;
}
// Getters and setters...
}
/**
* DTO for updating user
* All fields optional - only update what's provided
*/
public class UpdateUserRequest {
@Email(message = "Email must be valid")
// No @NotBlank - email is optional in update
private String email;
@Size(max = 500, message = "Bio cannot exceed 500 characters")
private String bio;
@Past(message = "Birth date must be in the past")
private LocalDate birthDate;
@Valid
private AddressDTO address;
private List<String> interests;
// Getters and setters...
}
Complete Validation Annotations Reference:
| Annotation | Validates | Example |
@NotNull | Value is not null | @NotNull Integer age |
@NotEmpty | String/Collection not null or empty | @NotEmpty List<String> tags |
@NotBlank | String not null/empty/whitespace | @NotBlank String username |
@Size(min, max) | String/Collection size | @Size(min=3, max=50) |
@Min(value) | Number β₯ value | @Min(18) int age |
@Max(value) | Number β€ value | @Max(120) int age |
@Email | Valid email format | @Email String email |
@Pattern(regexp) | Matches regex pattern | @Pattern(regexp="[A-Z].*") |
@Past | Date is in past | @Past LocalDate birthDate |
@Future | Date is in future | @Future LocalDate expiryDate |
@PastOrPresent | Date is past or today | @PastOrPresent LocalDate date |
@FutureOrPresent | Date is future or today | @FutureOrPresent |
@Positive | Number > 0 | @Positive int quantity |
@PositiveOrZero | Number β₯ 0 | @PositiveOrZero int stock |
@Negative | Number < 0 | @Negative int debt |
@NegativeOrZero | Number β€ 0 | @NegativeOrZero |
@DecimalMin(value) | Decimal β₯ value | @DecimalMin("0.0") |
@DecimalMax(value) | Decimal β€ value | @DecimalMax("100.0") |
@Digits(integer, fraction) | Number format | @Digits(integer=3, fraction=2) |
@AssertTrue | Boolean is true | @AssertTrue Boolean accepted |
@AssertFalse | Boolean is false | @AssertFalse Boolean spam |
@Valid | Trigger validation on nested object | @Valid Address address |
Custom Validation Annotation:
package com.example.blog.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* Custom annotation to validate that username is unique
*/
@Documented
@Constraint(validatedBy = UniqueUsernameValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueUsername {
String message() default "Username already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
/**
* Validator implementation
*/
@Component
public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> {
private final UserRepository userRepository;
public UniqueUsernameValidator(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public void initialize(UniqueUsername constraintAnnotation) {
// Initialization logic if needed
}
@Override
public boolean isValid(String username, ConstraintValidatorContext context) {
if (username == null) {
return true; // Let @NotNull handle null validation
}
// Check if username exists in database
return !userRepository.existsByUsername(username);
}
}
// Usage:
public class CreateUserRequest {
@NotBlank
@UniqueUsername // Custom validation
private String username;
// Other fields...
}
Validation Groups (Advanced):
/**
* Marker interfaces for validation groups
*/
public interface ValidationGroups {
interface Create {}
interface Update {}
}
/**
* DTO with group-specific validation
*/
public class UserRequest {
@Null(groups = Create.class, message = "ID must be null when creating")
// When creating, ID must be null (server generates it)
@NotNull(groups = Update.class, message = "ID is required when updating")
// When updating, ID must be provided
private Long id;
@NotBlank(groups = {Create.class, Update.class})
private String username;
@NotBlank(groups = Create.class, message = "Password required for new users")
// Password required only when creating
@Null(groups = Update.class, message = "Use password change endpoint")
// Password cannot be updated via regular update
private String password;
// Getters and setters...
}
// Controller usage:
@PostMapping
public ResponseEntity<UserDTO> createUser(
@Validated(Create.class) @RequestBody UserRequest request) {
// Only Create.class validations are checked
}
@PutMapping("/{id}")
public ResponseEntity<UserDTO> updateUser(
@PathVariable Long id,
@Validated(Update.class) @RequestBody UserRequest request) {
// Only Update.class validations are checked
}
Service Layer with DTO Conversion:
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final ModelMapper modelMapper; // For DTO conversion
public UserService(UserRepository userRepository,
PasswordEncoder passwordEncoder,
ModelMapper modelMapper) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.modelMapper = modelMapper;
}
@Transactional
public UserDTO createUser(CreateUserRequest request) {
// 1. Check for duplicates
if (userRepository.existsByUsername(request.getUsername())) {
throw new DuplicateResourceException("Username already exists");
}
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateResourceException("Email already exists");
}
// 2. Convert DTO to Entity
User user = new User();
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setBio(request.getBio());
user.setBirthDate(request.getBirthDate());
user.setRole(Role.USER);
// 3. Save entity
User saved = userRepository.save(user);
// 4. Convert Entity to DTO
return convertToDTO(saved);
}
@Transactional(readOnly = true)
public UserDTO getUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found: " + id));
return convertToDTO(user);
}
@Transactional
public UserDTO updateUser(Long id, UpdateUserRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
// Update only provided fields (null check)
if (request.getEmail() != null) {
user.setEmail(request.getEmail());
}
if (request.getBio() != null) {
user.setBio(request.getBio());
}
if (request.getBirthDate() != null) {
user.setBirthDate(request.getBirthDate());
}
User updated = userRepository.save(user);
return convertToDTO(updated);
}
// Manual DTO conversion
private UserDTO convertToDTO(User user) {
UserDTO dto = new UserDTO();
dto.setId(user.getId());
dto.setUsername(user.getUsername());
dto.setEmail(user.getEmail());
dto.setBio(user.getBio());
dto.setBirthDate(user.getBirthDate());
dto.setRole(user.getRole().name());
dto.setCreatedAt(user.getCreatedAt());
dto.setUpdatedAt(user.getUpdatedAt());
// Note: NO password field in DTO!
return dto;
}
// Or use ModelMapper for automatic conversion
private UserDTO convertToDTOWithMapper(User user) {
return modelMapper.map(user, UserDTO.class);
}
}
Application Configuration - Mastering Profiles
Configuration Files - application.properties vs application.yml
Spring Boot supports two formats for configuration:
# Simple key=value format
# Good for simple configurations
# Server configuration
server.port=8080
server.servlet.context-path=/api
# Database
spring.datasource.url=jdbc:postgresql://localhost:5432/blogdb
spring.datasource.username=postgres
spring.datasource.password=secret
# JPA
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
# Logging
logging.level.root=INFO
logging.level.com.example.blog=DEBUG
application.yml (YAML format - recommended for complex configs):
# Hierarchical structure
# More readable for complex configurations
server:
port: 8080
servlet:
context-path: /api
compression:
enabled: true
mime-types: application/json,application/xml,text/html
error:
include-message: always
include-binding-errors: always
spring:
datasource:
url: jdbc:postgresql://localhost:5432/blogdb
username: postgres
password: secret
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
jpa:
hibernate:
ddl-auto: validate
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
use_sql_comments: true
open-in-view: false # Disable for better performance
jackson:
serialization:
write-dates-as-timestamps: false
indent-output: true
time-zone: UTC
default-property-inclusion: non_null
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
logging:
level:
root: INFO
com.example.blog: DEBUG
org.springframework.web: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
file:
name: logs/application.log
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
Configuration Profiles - Environment-Specific Settings
Profiles allow different configurations for different environments.
Profile naming convention:
application.yml- Base configuration (always loaded)application-dev.yml- Development profileapplication-test.yml- Test profileapplication-prod.yml- Production profile
application.yml (Base configuration):
# Common configuration for all environments
spring:
application:
name: Blog API
jackson:
serialization:
write-dates-as-timestamps: false
time-zone: UTC
logging:
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
# Default profile (if none specified)
spring:
profiles:
active: dev
application-dev.yml (Development):
# Development environment configuration
server:
port: 8080
spring:
datasource:
url: jdbc:h2:mem:devdb
# H2 in-memory database for development
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true # Enable H2 web console
path: /h2-console
jpa:
hibernate:
ddl-auto: create-drop # Recreate schema on each restart
show-sql: true
properties:
hibernate:
format_sql: true
sql:
init:
mode: always # Always run schema.sql and data.sql
logging:
level:
root: INFO
com.example.blog: DEBUG
org.springframework.web: DEBUG
# Custom application properties
app:
security:
jwt-secret: dev-secret-key-not-for-production
jwt-expiration-ms: 86400000 # 24 hours
cors:
allowed-origins: http://localhost:3000,http://localhost:4200
application-test.yml (Testing):
# Test environment configuration
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop # Fresh database for each test
show-sql: false # Less noise in test output
sql:
init:
mode: never # Don't run SQL scripts in tests
logging:
level:
root: WARN
com.example.blog: INFO
app:
security:
jwt-secret: test-secret-key
jwt-expiration-ms: 3600000 # 1 hour
application-prod.yml (Production):
# Production environment configuration
server:
port: ${SERVER_PORT:8080} # Use environment variable or default to 8080
compression:
enabled: true
spring:
datasource:
url: ${DATABASE_URL} # Read from environment variable
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 10
jpa:
hibernate:
ddl-auto: validate # NEVER auto-generate in production!
show-sql: false
properties:
hibernate:
format_sql: false
open-in-view: false
sql:
init:
mode: never # NEVER run SQL scripts in production
logging:
level:
root: WARN
com.example.blog: INFO
file:
name: /var/log/blog-api/application.log
app:
security:
jwt-secret: ${JWT_SECRET} # MUST be environment variable in production
jwt-expiration-ms: 3600000 # 1 hour
cors:
allowed-origins: https://yourdomain.com,https://www.yourdomain.com
Activating Profiles:
# Method 1: Command line argument
java -jar app.jar --spring.profiles.active=prod
# Method 2: Environment variable
export SPRING_PROFILES_ACTIVE=prod
java -jar app.jar
# Method 3: In application.yml
spring:
profiles:
active: dev
# Method 4: Multiple profiles
java -jar app.jar --spring.profiles.active=prod,monitoring
# Method 5: In IDE (IntelliJ IDEA)
# Run Configuration β Environment Variables β SPRING_PROFILES_ACTIVE=dev
Type-Safe Configuration with @ConfigurationProperties
Instead of using @Value for every property, use @ConfigurationProperties for type-safe configuration:
package com.example.blog.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
import java.util.List;
import java.util.Map;
/**
* Type-safe configuration properties
* Maps to properties with prefix "app"
*/
@Configuration
@ConfigurationProperties(prefix = "app")
@Validated // Enable validation on configuration properties
public class AppProperties {
private String name;
private String version;
@NotNull
private Security security = new Security();
@NotNull
private Storage storage = new Storage();
@NotNull
private Cors cors = new Cors();
private Pagination pagination = new Pagination();
// Nested class for security properties
public static class Security {
@NotBlank(message = "JWT secret is required")
private String jwtSecret;
@Min(value = 3600000, message = "JWT expiration must be at least 1 hour")
@Max(value = 604800000, message = "JWT expiration cannot exceed 7 days")
private long jwtExpirationMs;
private boolean enableCsrf = false;
// Getters and setters
public String getJwtSecret() { return jwtSecret; }
public void setJwtSecret(String jwtSecret) { this.jwtSecret = jwtSecret; }
public long getJwtExpirationMs() { return jwtExpirationMs; }
public void setJwtExpirationMs(long jwtExpirationMs) { this.jwtExpirationMs = jwtExpirationMs; }
public boolean isEnableCsrf() { return enableCsrf; }
public void setEnableCsrf(boolean enableCsrf) { this.enableCsrf = enableCsrf; }
}
// Nested class for storage properties
public static class Storage {
@NotBlank(message = "Upload directory is required")
private String uploadDir = "./uploads";
@Min(1024) // Minimum 1KB
@Max(10485760) // Maximum 10MB
private long maxFileSize = 5242880; // 5MB default
private List<String> allowedExtensions = List.of("jpg", "jpeg", "png", "pdf");
// Getters and setters
public String getUploadDir() { return uploadDir; }
public void setUploadDir(String uploadDir) { this.uploadDir = uploadDir; }
public long getMaxFileSize() { return maxFileSize; }
public void setMaxFileSize(long maxFileSize) { this.maxFileSize = maxFileSize; }
public List<String> getAllowedExtensions() { return allowedExtensions; }
public void setAllowedExtensions(List<String> allowedExtensions) {
this.allowedExtensions = allowedExtensions;
}
}
// Nested class for CORS properties
public static class Cors {
private List<String> allowedOrigins = List.of("http://localhost:3000");
private List<String> allowedMethods = List.of("GET", "POST", "PUT", "PATCH", "DELETE");
private List<String> allowedHeaders = List.of("*");
private boolean allowCredentials = true;
private long maxAge = 3600;
// Getters and setters...
}
// Nested class for pagination
public static class Pagination {
@Min(1)
@Max(100)
private int defaultPageSize = 10;
@Min(1)
@Max(1000)
private int maxPageSize = 100;
// Getters and setters...
}
// Main class getters and setters
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
public Security getSecurity() { return security; }
public void setSecurity(Security security) { this.security = security; }
public Storage getStorage() { return storage; }
public void setStorage(Storage storage) { this.storage = storage; }
public Cors getCors() { return cors; }
public void setCors(Cors cors) { this.cors = cors; }
public Pagination getPagination() { return pagination; }
public void setPagination(Pagination pagination) { this.pagination = pagination; }
}
application.yml configuration:
app:
name: Blog API
version: 1.0.0
security:
jwt-secret: mySecretKey123456789012345678901234567890
jwt-expiration-ms: 86400000 # 24 hours
enable-csrf: false
storage:
upload-dir: ./uploads
max-file-size: 5242880 # 5MB
allowed-extensions:
- jpg
- jpeg
- png
- pdf
- doc
- docx
cors:
allowed-origins:
- http://localhost:3000
- http://localhost:4200
allowed-methods:
- GET
- POST
- PUT
- PATCH
- DELETE
allowed-headers: "*"
allow-credentials: true
max-age: 3600
pagination:
default-page-size: 10
max-page-size: 100
Using Configuration Properties:
@Service
public class FileUploadService {
private final AppProperties appProperties;
public FileUploadService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public String uploadFile(MultipartFile file) {
// Access configuration
String uploadDir = appProperties.getStorage().getUploadDir();
long maxSize = appProperties.getStorage().getMaxFileSize();
List<String> allowedExt = appProperties.getStorage().getAllowedExtensions();
// Validate file size
if (file.getSize() > maxSize) {
throw new FileTooLargeException("File exceeds maximum size of " + maxSize);
}
// Validate file extension
String filename = file.getOriginalFilename();
String extension = filename.substring(filename.lastIndexOf(".") + 1);
if (!allowedExt.contains(extension.toLowerCase())) {
throw new InvalidFileTypeException("File type not allowed: " + extension);
}
// Save file
Path uploadPath = Paths.get(uploadDir);
// ... save logic
}
}
@Value Annotation - Simple Property Injection
For simple use cases, use @Value:
@Service
public class EmailService {
@Value("${app.name}")
// Inject single property
private String appName;
@Value("${app.email.from:noreply@example.com}")
// Provide default value after colon
// If app.email.from not found, use noreply@example.com
private String fromEmail;
@Value("${app.email.enabled:true}")
private boolean emailEnabled;
@Value("${server.port}")
private int serverPort;
@Value("#{${app.email.templates}}")
// SpEL expression for Map
// In yml: app.email.templates: {welcome: 'template1.html', reset: 'template2.html'}
private Map<String, String> emailTemplates;
@Value("#{'${app.admin.emails}'.split(',')}")
// Convert comma-separated string to List
// In yml: app.admin.emails: admin@example.com,support@example.com
private List<String> adminEmails;
public void sendWelcomeEmail(String to) {
if (!emailEnabled) {
return;
}
String template = emailTemplates.get("welcome");
// Send email logic...
}
}
Profile-Specific Beans
@Configuration
public class DataSourceConfig {
/**
* Development datasource - H2 in-memory
*/
@Bean
@Profile("dev")
// This bean only created when 'dev' profile is active
public DataSource devDataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.setName("devdb")
.build();
}
/**
* Test datasource - H2 in-memory
*/
@Bean
@Profile("test")
public DataSource testDataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.setName("testdb")
.build();
}
/**
* Production datasource - PostgreSQL with connection pooling
*/
@Bean
@Profile("prod")
public DataSource prodDataSource(
@Value("${spring.datasource.url}") String url,
@Value("${spring.datasource.username}") String username,
@Value("${spring.datasource.password}") String password) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
config.setMaximumPoolSize(20);
config.setMinimumIdle(10);
config.setConnectionTimeout(30000);
config.setIdleTimeout(600000);
config.setMaxLifetime(1800000);
return new HikariDataSource(config);
}
/**
* Bean for dev OR test profiles
*/
@Bean
@Profile({"dev", "test"})
public MockEmailService mockEmailService() {
// Use mock email service in dev/test
return new MockEmailService();
}
/**
* Bean for production profile
*/
@Bean
@Profile("prod")
public RealEmailService realEmailService() {
// Use real email service in production
return new RealEmailService();
}
/**
* Bean for NOT production
*/
@Bean
@Profile("!prod")
// ! means NOT - active for any profile except prod
public DebugService debugService() {
return new DebugService();
}
}
10. Spring Security & Authentication π
Spring Security is the de-facto standard for securing Spring applications. It provides comprehensive security services for Java applications, including authentication, authorization, and protection against common attacks.
10.1 Understanding Spring Security
What is Spring Security?
A powerful and highly customizable authentication and access-control framework
Handles authentication (who you are) and authorization (what you can do)
Protects against common security vulnerabilities (CSRF, session fixation, clickjacking, etc.)
Supports various authentication mechanisms (username/password, JWT, OAuth2, LDAP, etc.)
How Spring Security Works:
Incoming HTTP Request
β
Security Filter Chain (15+ filters)
β
Authentication Manager
β
Authentication Provider
β
UserDetailsService (loads user)
β
Authentication successful/failed
β
SecurityContext (stores authentication)
β
Your Controller/Service
10.2 Adding Spring Security Dependency
Maven Dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- For JWT token handling -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
Important: Once you add spring-boot-starter-security, Spring Boot automatically:
Secures ALL endpoints (requires authentication)
Creates a default login page at
/loginGenerates a random password in console (username is
user)Enables CSRF protection
Adds security headers to responses
10.3 User Entity and Repository
User Entity:
package com.example.demo.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
@Entity // JPA entity - maps to 'users' table
@Table(name = "users") // Explicit table name
@Getter // Lombok: generates getters
@Setter // Lombok: generates setters
@NoArgsConstructor // Lombok: generates no-args constructor
@AllArgsConstructor // Lombok: generates all-args constructor
@Builder // Lombok: enables builder pattern
public class User {
@Id // Primary key
@GeneratedValue(strategy = GenerationType.IDENTITY) // Auto-increment
private Long id;
@Column(unique = true, nullable = false)
// unique = true: Adds UNIQUE constraint in database
// nullable = false: Adds NOT NULL constraint
private String username;
@Column(nullable = false)
// Password will be stored as BCrypt hash (never plain text!)
private String password;
@Column(unique = true, nullable = false)
private String email;
@ElementCollection(fetch = FetchType.EAGER)
// @ElementCollection: Creates a separate table for roles
// fetch = EAGER: Load roles immediately with user (needed for authentication)
@CollectionTable(
name = "user_roles", // Name of the separate table
joinColumns = @JoinColumn(name = "user_id") // Foreign key column
)
@Column(name = "role") // Column name in user_roles table
private Set<String> roles = new HashSet<>();
// Stores roles like "ROLE_USER", "ROLE_ADMIN"
// Set ensures no duplicate roles
@Column(name = "created_at", updatable = false)
// updatable = false: Cannot be changed after creation
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "enabled")
// For account activation/deactivation
private boolean enabled = true;
@PrePersist
// Called automatically BEFORE saving new entity to database
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
// Called automatically BEFORE updating existing entity
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
User Repository:
package com.example.demo.repository;
import com.example.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository // Spring Data JPA repository
public interface UserRepository extends JpaRepository<User, Long> {
// Spring Data JPA automatically implements this method
// Translates to: SELECT * FROM users WHERE username = ?
Optional<User> findByUsername(String username);
// Returns Optional to handle case when user doesn't exist
// Check if username already exists
// Translates to: SELECT COUNT(*) > 0 FROM users WHERE username = ?
boolean existsByUsername(String username);
// Check if email already exists
// Translates to: SELECT COUNT(*) > 0 FROM users WHERE email = ?
boolean existsByEmail(String email);
// Find by email
Optional<User> findByEmail(String email);
}
10.4 UserDetailsService Implementation
Spring Security uses UserDetailsService to load user-specific data during authentication.
Custom UserDetailsService:
package com.example.demo.security;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.stream.Collectors;
@Service // Spring service component
@RequiredArgsConstructor // Lombok: generates constructor for final fields
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
// Injected via constructor by @RequiredArgsConstructor
@Override
@Transactional(readOnly = true)
// @Transactional: Manages database transaction
// readOnly = true: Optimizes read-only operations
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
// This method is called by Spring Security during authentication
// 1. Find user by username in database
User user = userRepository.findByUsername(username)
.orElseThrow(() ->
new UsernameNotFoundException(
"User not found with username: " + username
)
);
// If user doesn't exist, throw exception (authentication fails)
// 2. Convert our User entity to Spring Security's UserDetails
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
// Set username
.password(user.getPassword())
// Set password (should be BCrypt hash)
.authorities(getAuthorities(user))
// Set roles/authorities (ROLE_USER, ROLE_ADMIN, etc.)
.accountExpired(false)
// Account expiration flag (false = not expired)
.accountLocked(false)
// Account lock flag (false = not locked)
.credentialsExpired(false)
// Credentials expiration flag (false = not expired)
.disabled(!user.isEnabled())
// Disabled flag (inverse of enabled)
.build();
// Build the UserDetails object
}
/**
* Convert user roles to Spring Security authorities
*/
private Collection<? extends GrantedAuthority> getAuthorities(User user) {
// Spring Security needs authorities as GrantedAuthority objects
return user.getRoles().stream()
// Stream through the Set<String> of roles
.map(SimpleGrantedAuthority::new)
// Convert each role string to SimpleGrantedAuthority
// e.g., "ROLE_USER" -> SimpleGrantedAuthority("ROLE_USER")
.collect(Collectors.toSet());
// Collect as Set<GrantedAuthority>
}
}
Important Notes:
UserDetailsis Spring Security's representation of user informationAuthorities/Roles must start with
ROLE_prefix (e.g.,ROLE_USER,ROLE_ADMIN)Password must be BCrypt encoded (never store plain text passwords!)
Spring Security calls
loadUserByUsername()automatically during authentication
10.5 Password Encoding
Never store plain text passwords! Always use BCrypt or another strong hashing algorithm.
Password Encoder Bean:
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration // Configuration class for beans
public class PasswordEncoderConfig {
@Bean
// Creates a PasswordEncoder bean in Spring context
// This bean will be injected wherever password encoding is needed
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
// BCrypt is a strong adaptive hashing function
// - Automatically salts passwords (prevents rainbow table attacks)
// - Configurable cost factor (work factor)
// - One-way hash (cannot be decrypted)
// - Same input produces different hash each time (due to random salt)
}
}
Using Password Encoder in Service:
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; // Injected automatically
public User registerUser(RegisterRequest request) {
// 1. Check if username already exists
if (userRepository.existsByUsername(request.getUsername())) {
throw new RuntimeException("Username already exists!");
}
// 2. Create new user
User user = User.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
// CRITICAL: Encode password before saving!
// encode() creates BCrypt hash with random salt
// e.g., "password123" -> "$2a$10$N9qo8..."
.roles(Set.of("ROLE_USER"))
// Default role for new users
.enabled(true)
// Enable account immediately (or send activation email)
.build();
// 3. Save to database
return userRepository.save(user);
}
}
10.6 JWT (JSON Web Token) Implementation
JWT is a stateless authentication mechanism. Instead of storing session on server, authentication info is stored in token sent with each request.
JWT Structure:
Header.Payload.Signature
Header (Base64): {"alg": "HS256", "typ": "JWT"}
Payload (Base64): {"sub": "john", "roles": ["ROLE_USER"], "exp": 1234567890}
Signature: HMACSHA256(header + "." + payload, secret-key)
JWT Configuration Properties:
package com.example.demo.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "jwt")
// Binds properties starting with "jwt." from application.yml
@Getter
@Setter
public class JwtProperties {
private String secretKey;
// jwt.secret-key from application.yml
// Used to sign and verify JWT tokens
// MUST be kept secret and strong (256+ bits)
private long expiration;
// jwt.expiration from application.yml
// Token validity duration in milliseconds
// e.g., 86400000 = 24 hours
}
application.yml:
jwt:
secret-key: your-256-bit-secret-key-change-this-in-production
# IMPORTANT: Use a strong random key in production
# Generate with: openssl rand -base64 32
expiration: 86400000 # 24 hours in milliseconds
JWT Token Provider:
package com.example.demo.security.jwt;
import com.example.demo.config.JwtProperties;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.stream.Collectors;
@Component // Spring component for JWT operations
@RequiredArgsConstructor // Constructor injection
@Slf4j // Lombok: adds logging
public class JwtTokenProvider {
private final JwtProperties jwtProperties;
// Injected configuration properties
/**
* Generate JWT token from Authentication object
*/
public String generateToken(Authentication authentication) {
// Called after successful authentication to create JWT
// 1. Get user details from authentication
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// Principal = authenticated user
// 2. Extract username
String username = userDetails.getUsername();
// 3. Extract roles/authorities
String roles = userDetails.getAuthorities().stream()
// Get all authorities (roles)
.map(GrantedAuthority::getAuthority)
// Extract authority string (e.g., "ROLE_USER")
.collect(Collectors.joining(","));
// Join with comma: "ROLE_USER,ROLE_ADMIN"
// 4. Calculate expiration time
Date now = new Date();
// Current time
Date expiryDate = new Date(now.getTime() + jwtProperties.getExpiration());
// Current time + expiration duration
// 5. Build and sign JWT token
return Jwts.builder()
// Start building JWT
.subject(username)
// "sub" claim: identifies the subject (user)
.claim("roles", roles)
// Custom claim: store user roles
// Will be in payload as: "roles": "ROLE_USER,ROLE_ADMIN"
.issuedAt(now)
// "iat" claim: issued at time
.expiration(expiryDate)
// "exp" claim: expiration time
// Token becomes invalid after this time
.signWith(getSigningKey())
// Sign with secret key using HMAC-SHA256
// Signature prevents token tampering
.compact();
// Serialize to compact string: header.payload.signature
}
/**
* Extract username from JWT token
*/
public String getUsernameFromToken(String token) {
// Parse token and extract username from "sub" claim
Claims claims = Jwts.parser()
// Create JWT parser
.verifyWith(getSigningKey())
// Set signing key for signature verification
// Will throw exception if signature is invalid
.build()
// Build the parser
.parseSignedClaims(token)
// Parse and verify the token
// Throws exceptions if:
// - Signature is invalid
// - Token is expired
// - Token is malformed
.getPayload();
// Get the claims (payload)
return claims.getSubject();
// Return "sub" claim (username)
}
/**
* Validate JWT token
*/
public boolean validateToken(String token) {
// Check if token is valid (not expired, not tampered)
try {
Jwts.parser()
.verifyWith(getSigningKey())
// Verify signature with secret key
.build()
.parseSignedClaims(token);
// Parse token - throws exception if invalid
return true;
// Token is valid
} catch (SignatureException ex) {
// Signature doesn't match (token was tampered)
log.error("Invalid JWT signature: {}", ex.getMessage());
} catch (MalformedJwtException ex) {
// Token structure is invalid
log.error("Invalid JWT token: {}", ex.getMessage());
} catch (ExpiredJwtException ex) {
// Token has expired
log.error("Expired JWT token: {}", ex.getMessage());
} catch (UnsupportedJwtException ex) {
// Token format not supported
log.error("Unsupported JWT token: {}", ex.getMessage());
} catch (IllegalArgumentException ex) {
// Token is empty or null
log.error("JWT claims string is empty: {}", ex.getMessage());
}
return false;
// Token is invalid
}
/**
* Get signing key from secret string
*/
private SecretKey getSigningKey() {
// Convert secret key string to SecretKey object for HMAC-SHA256
byte[] keyBytes = jwtProperties.getSecretKey()
.getBytes(StandardCharsets.UTF_8);
// Convert string to bytes
return Keys.hmacShaKeyFor(keyBytes);
// Create HMAC-SHA key from bytes
// Throws exception if key is too weak (< 256 bits)
}
}
10.7 JWT Authentication Filter
This filter intercepts every request, extracts JWT token, and sets authentication in SecurityContext.
JWT Authentication Filter:
package com.example.demo.security.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component // Spring component
@RequiredArgsConstructor // Constructor injection
@Slf4j // Logging
public class JwtAuthenticationFilter extends OncePerRequestFilter {
// OncePerRequestFilter: Guarantees single execution per request
private final JwtTokenProvider jwtTokenProvider;
// For token validation and parsing
private final UserDetailsService userDetailsService;
// For loading user details
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// This method is called for EVERY request
try {
// 1. Extract JWT token from request
String jwt = getJwtFromRequest(request);
// Gets token from "Authorization: Bearer <token>" header
// 2. Validate token and set authentication
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
// Token exists and is valid
// 3. Extract username from token
String username = jwtTokenProvider.getUsernameFromToken(jwt);
// 4. Load user details from database
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// Loads user with authorities/roles
// 5. Create authentication object
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, // Principal (authenticated user)
null, // Credentials (not needed after auth)
userDetails.getAuthorities() // Roles/authorities
);
// 6. Set additional details
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// Adds IP address, session ID, etc.
// 7. Set authentication in SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
// Now Spring Security knows user is authenticated
// All @PreAuthorize, @Secured annotations will work
// All SecurityContext.getContext().getAuthentication() calls return this
}
} catch (Exception ex) {
// If any error occurs, log but don't stop request
log.error("Could not set user authentication in security context", ex);
// Request continues but user is NOT authenticated
}
// 8. Continue filter chain
filterChain.doFilter(request, response);
// Pass request to next filter or controller
}
/**
* Extract JWT token from Authorization header
*/
private String getJwtFromRequest(HttpServletRequest request) {
// JWT should be in header as: "Authorization: Bearer <token>"
String bearerToken = request.getHeader("Authorization");
// Get Authorization header value
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
// Check if header exists and starts with "Bearer "
return bearerToken.substring(7);
// Remove "Bearer " prefix (7 characters)
// Returns just the token string
}
return null;
// No token found
}
}
How JWT Filter Works:
Request arrives
β
JWT Filter intercepts
β
Extract "Authorization: Bearer <token>" header
β
Validate JWT token (signature, expiration)
β
Extract username from token
β
Load user from database (with roles)
β
Create Authentication object
β
Set in SecurityContext
β
Continue to Controller
β
Controller can access authenticated user
10.8 Security Configuration
This is where we configure Spring Security, disable CSRF (for JWT), configure authentication, and set up filter chain.
Security Configuration:
package com.example.demo.config;
import com.example.demo.security.jwt.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration // Configuration class
@EnableWebSecurity // Enable Spring Security
@EnableMethodSecurity // Enable @PreAuthorize, @Secured, @RolesAllowed annotations
@RequiredArgsConstructor // Constructor injection
public class SecurityConfig {
private final UserDetailsService userDetailsService;
// Our CustomUserDetailsService
private final PasswordEncoder passwordEncoder;
// BCryptPasswordEncoder
private final JwtAuthenticationFilter jwtAuthenticationFilter;
// Our JWT filter
/**
* Security Filter Chain - Main security configuration
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
// Disable CSRF protection
// CSRF protection is needed for session-based auth (cookies)
// With JWT (stateless), CSRF is not needed
// Token is in Authorization header (not cookie), so CSRF doesn't apply
.cors(AbstractHttpConfigurer::disable)
// Disable CORS (enable if you have frontend on different domain)
// For production, configure proper CORS settings
.authorizeHttpRequests(auth -> auth
// Configure URL authorization
.requestMatchers("/api/auth/**").permitAll()
// Allow unauthenticated access to /api/auth/**
// Includes: /api/auth/register, /api/auth/login
// Anyone can register or login without authentication
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
// Allow unauthenticated GET requests to /api/products/**
// Anyone can view products without logging in
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Only users with ROLE_ADMIN can access /api/admin/**
// Note: Use "ADMIN" not "ROLE_ADMIN" (Spring adds ROLE_ prefix)
.requestMatchers(HttpMethod.POST, "/api/products/**")
.hasAnyRole("ADMIN", "MANAGER")
// Only ADMIN or MANAGER can create products
// hasAnyRole() accepts multiple roles
.anyRequest().authenticated()
// All other requests require authentication
// User must be logged in (have valid JWT token)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// STATELESS: Don't create HTTP sessions
// Spring Security won't create or use sessions
// Perfect for JWT (stateless authentication)
// Each request must include JWT token
)
.authenticationProvider(authenticationProvider())
// Set authentication provider (how to authenticate)
// Uses our DaoAuthenticationProvider with UserDetailsService
.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class
)
// Add JWT filter BEFORE UsernamePasswordAuthenticationFilter
// Our filter runs first, extracts JWT, sets authentication
// Order matters in filter chain!
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
// Called when unauthenticated user tries to access protected resource
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write(
"{\"error\": \"Unauthorized\", \"message\": \""
+ authException.getMessage() + "\"}"
);
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
// Called when authenticated user lacks required authority
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter().write(
"{\"error\": \"Forbidden\", \"message\": \""
+ accessDeniedException.getMessage() + "\"}"
);
})
);
return http.build();
}
/**
* Authentication Provider - How to authenticate users
*/
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
// DaoAuthenticationProvider: Authentication using database
provider.setUserDetailsService(userDetailsService);
// Set how to load user details (our CustomUserDetailsService)
provider.setPasswordEncoder(passwordEncoder);
// Set how to encode/verify passwords (BCryptPasswordEncoder)
return provider;
// Spring Security uses this to authenticate users
}
/**
* Authentication Manager - Entry point for authentication
*/
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config
) throws Exception {
// AuthenticationManager is used in login endpoint
// It coordinates authentication process
return config.getAuthenticationManager();
// Get default authentication manager
// Uses our authenticationProvider()
}
}
Security Configuration Explained:
CSRF Disabled: JWT is stateless, doesn't use cookies, so CSRF doesn't apply
Stateless Sessions: No HTTP sessions created, each request must have JWT
URL Authorization: Configure which endpoints require which roles
JWT Filter: Runs before every request, extracts and validates JWT
Exception Handling: Custom JSON responses for 401 (Unauthorized) and 403 (Forbidden)
10.9 Authentication DTOs
Login Request DTO:
package com.example.demo.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data // Lombok: getters, setters, toString, equals, hashCode
public class LoginRequest {
@NotBlank(message = "Username is required")
// Validates field is not null and not empty/whitespace
private String username;
@NotBlank(message = "Password is required")
private String password;
}
Register Request DTO:
package com.example.demo.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class RegisterRequest {
@NotBlank(message = "Username is required")
@Size(min = 3, max = 20, message = "Username must be between 3 and 20 characters")
private String username;
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 6, message = "Password must be at least 6 characters")
private String password;
}
JWT Authentication Response DTO:
package com.example.demo.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JwtAuthenticationResponse {
private String accessToken;
// The JWT token
private String tokenType = "Bearer";
// Token type (always "Bearer" for JWT)
private String username;
// Authenticated user's username
private Set<String> roles;
// User's roles
}
10.10 Authentication Controller
Auth Controller:
package com.example.demo.controller;
import com.example.demo.dto.*;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import com.example.demo.security.jwt.JwtTokenProvider;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.Set;
import java.util.stream.Collectors;
@RestController // REST controller
@RequestMapping("/api/auth") // Base path: /api/auth
@RequiredArgsConstructor // Constructor injection
public class AuthController {
private final AuthenticationManager authenticationManager;
// For authenticating users
private final UserRepository userRepository;
// For database operations
private final PasswordEncoder passwordEncoder;
// For encoding passwords
private final JwtTokenProvider jwtTokenProvider;
// For generating JWT tokens
/**
* POST /api/auth/register
* Register a new user
*/
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest request) {
// @Valid: Validates request body (triggers @NotBlank, @Email, etc.)
// @RequestBody: Deserializes JSON to RegisterRequest object
// 1. Check if username already exists
if (userRepository.existsByUsername(request.getUsername())) {
return ResponseEntity
.badRequest()
.body("Error: Username is already taken!");
}
// 2. Check if email already exists
if (userRepository.existsByEmail(request.getEmail())) {
return ResponseEntity
.badRequest()
.body("Error: Email is already in use!");
}
// 3. Create new user
User user = User.builder()
.username(request.getUsername())
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
// CRITICAL: Encode password before saving!
.roles(Set.of("ROLE_USER"))
// Default role for new users
.enabled(true)
.build();
// 4. Save to database
userRepository.save(user);
// 5. Return success response
return ResponseEntity
.status(HttpStatus.CREATED)
.body("User registered successfully!");
}
/**
* POST /api/auth/login
* Authenticate user and return JWT token
*/
@PostMapping("/login")
public ResponseEntity<JwtAuthenticationResponse> login(
@Valid @RequestBody LoginRequest request
) {
// 1. Create authentication object with username and password
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
request.getUsername(), // Principal (username)
request.getPassword() // Credentials (plain password)
);
// 2. Authenticate user
Authentication authentication = authenticationManager.authenticate(authToken);
// This calls:
// 1. DaoAuthenticationProvider
// 2. CustomUserDetailsService.loadUserByUsername()
// 3. Password verification (BCrypt)
// If authentication fails, throws AuthenticationException
// 3. Set authentication in SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
// Now user is authenticated for this request
// 4. Generate JWT token
String jwt = jwtTokenProvider.generateToken(authentication);
// Creates JWT with username and roles
// 5. Extract user details
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
Set<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet());
// 6. Build response
JwtAuthenticationResponse response = JwtAuthenticationResponse.builder()
.accessToken(jwt)
.tokenType("Bearer")
.username(userDetails.getUsername())
.roles(roles)
.build();
// 7. Return JWT token to client
return ResponseEntity.ok(response);
// Client should store this token (localStorage, sessionStorage, etc.)
// Client should send token in header: "Authorization: Bearer <token>"
}
/**
* GET /api/auth/me
* Get current authenticated user info
*/
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser() {
// This endpoint requires authentication (configured in SecurityConfig)
// 1. Get authentication from SecurityContext
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
// Set by JwtAuthenticationFilter
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// 2. Extract user details
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// 3. Return user info
return ResponseEntity.ok(Map.of(
"username", userDetails.getUsername(),
"roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toSet())
));
}
}
10.11 Method-Level Security
Use annotations to secure individual methods.
Service with Method Security:
package com.example.demo.service;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
/**
* Only users with ROLE_USER can call this method
*/
@PreAuthorize("hasRole('USER')")
// Checks if current user has ROLE_USER
// Throws AccessDeniedException if not
public List<Product> getAllProducts() {
// Anyone with ROLE_USER can view products
return productRepository.findAll();
}
/**
* Only users with ROLE_ADMIN can call this method
*/
@PreAuthorize("hasRole('ADMIN')")
public Product createProduct(Product product) {
// Only admins can create products
return productRepository.save(product);
}
/**
* Only users with ADMIN or MANAGER role can call this
*/
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
public Product updateProduct(Long id, Product product) {
// Both admins and managers can update
return productRepository.save(product);
}
/**
* Only product owner or admin can delete
*/
@PreAuthorize("hasRole('ADMIN') or @productSecurity.isOwner(#id)")
// SpEL expression:
// - hasRole('ADMIN'): Check if user is admin
// - or: Logical OR
// - @productSecurity: Spring bean named "productSecurity"
// - .isOwner(#id): Call method with parameter #id
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
/**
* Method can be called by anyone (even unauthenticated)
*/
@PreAuthorize("permitAll()")
public List<Product> getPublicProducts() {
return productRepository.findByIsPublicTrue();
}
}
Custom Security Component:
package com.example.demo.security;
import com.example.demo.entity.Product;
import com.example.demo.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@Component("productSecurity")
// Bean name must match @PreAuthorize("...@productSecurity...")
@RequiredArgsConstructor
public class ProductSecurityService {
private final ProductRepository productRepository;
/**
* Check if current user owns the product
*/
public boolean isOwner(Long productId) {
// Get current authenticated user
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
String currentUsername = authentication.getName();
// Find product and check owner
return productRepository.findById(productId)
.map(product -> product.getOwner().getUsername().equals(currentUsername))
.orElse(false);
}
}
@PreAuthorize Expressions:
| Expression | Description |
hasRole('USER') | User has ROLE_USER |
hasAnyRole('ADMIN', 'MANAGER') | User has any of these roles |
hasAuthority('DELETE_USER') | User has specific authority |
isAuthenticated() | User is authenticated |
isAnonymous() | User is NOT authenticated |
permitAll() | Allow everyone (including unauthenticated) |
denyAll() | Deny everyone |
#id == principal.id | Parameter equals user's ID |
@bean.method(#param) | Call custom security method |
10.12 Testing Authentication with Postman/cURL
1. Register a User:
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "john",
"email": "john@example.com",
"password": "password123"
}'
Response:
User registered successfully!
2. Login:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "john",
"password": "password123"
}'
Response:
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2huIiwicm9sZXMiOiJST0xFX1VTRVIiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDA4NjQwMH0.xyz...",
"tokenType": "Bearer",
"username": "john",
"roles": ["ROLE_USER"]
}
3. Access Protected Endpoint:
curl -X GET http://localhost:8080/api/auth/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqb2huIi..."
Response:
{
"username": "john",
"roles": ["ROLE_USER"]
}
4. Access Without Token (Fails):
curl -X GET http://localhost:8080/api/auth/me
Response (401 Unauthorized):
{
"error": "Unauthorized",
"message": "Full authentication is required to access this resource"
}
10.13 Common Security Issues and Solutions
Issue 1: CORS Errors in Frontend
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
Issue 2: Password Not Encoded
// WRONG - Never do this!
user.setPassword(request.getPassword());
// CORRECT - Always encode!
user.setPassword(passwordEncoder.encode(request.getPassword()));
Issue 3: Weak JWT Secret
# WRONG - Too weak
jwt:
secret-key: secret
# CORRECT - Strong random key (256+ bits)
jwt:
secret-key: 3cfa76ef14937c1c0ea519f8fc057a80fcd04a7420f8e8bcd0a7567c272e007b
# Generate with: openssl rand -hex 32
Issue 4: Not Using HTTPS in Production
# Add to application-prod.yml
server:
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: changeit
key-store-type: PKCS12
11. Exception Handling with @RestControllerAdvice β οΈ
Proper exception handling is crucial for building robust REST APIs. Spring Boot provides powerful mechanisms to handle exceptions globally and return consistent error responses.
11.1 Why Global Exception Handling?
Without Global Exception Handling:
Every controller method must handle exceptions
Duplicate error handling code across controllers
Inconsistent error response formats
Hard to maintain and update
With Global Exception Handling:
Centralized exception handling in one place
Consistent error response format across entire application
Clean controller code (no try-catch blocks)
Easy to customize error responses
11.2 Exception Handling Flow
Controller throws Exception
β
@RestControllerAdvice catches exception
β
@ExceptionHandler method matches exception type
β
Method executes and creates ErrorResponse
β
ErrorResponse serialized to JSON
β
HTTP response sent to client
11.3 Custom Exception Classes
Create custom exception classes for different error scenarios.
Base Custom Exception:
package com.example.demo.exception;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter // Lombok: generates getters
public class ApplicationException extends RuntimeException {
// Base exception class for all custom exceptions
private final HttpStatus status;
// HTTP status code to return
private final String message;
// Error message
public ApplicationException(HttpStatus status, String message) {
super(message);
// Call RuntimeException constructor
// This sets the exception message
this.status = status;
this.message = message;
}
public ApplicationException(HttpStatus status, String message, Throwable cause) {
super(message, cause);
// Call RuntimeException constructor with cause
// Allows exception chaining (preserve original exception)
this.status = status;
this.message = message;
}
}
Resource Not Found Exception:
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
public class ResourceNotFoundException extends ApplicationException {
// Thrown when requested resource doesn't exist
// e.g., Product with ID 123 not found
public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
super(
HttpStatus.NOT_FOUND, // 404 status code
String.format("%s not found with %s: '%s'", resourceName, fieldName, fieldValue)
// e.g., "Product not found with id: '123'"
);
}
public ResourceNotFoundException(String message) {
super(HttpStatus.NOT_FOUND, message);
}
}
Bad Request Exception:
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
public class BadRequestException extends ApplicationException {
// Thrown for invalid client requests
// e.g., Invalid input data, missing required fields
public BadRequestException(String message) {
super(HttpStatus.BAD_REQUEST, message);
// 400 Bad Request
}
public BadRequestException(String message, Throwable cause) {
super(HttpStatus.BAD_REQUEST, message, cause);
}
}
Unauthorized Exception:
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
public class UnauthorizedException extends ApplicationException {
// Thrown when user is not authenticated
// e.g., No JWT token provided, invalid token
public UnauthorizedException(String message) {
super(HttpStatus.UNAUTHORIZED, message);
// 401 Unauthorized
}
}
Forbidden Exception:
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
public class ForbiddenException extends ApplicationException {
// Thrown when user doesn't have required permissions
// e.g., User without ADMIN role trying to delete product
public ForbiddenException(String message) {
super(HttpStatus.FORBIDDEN, message);
// 403 Forbidden
}
}
Conflict Exception:
package com.example.demo.exception;
import org.springframework.http.HttpStatus;
public class ConflictException extends ApplicationException {
// Thrown for conflict scenarios
// e.g., Username already exists, duplicate email
public ConflictException(String message) {
super(HttpStatus.CONFLICT, message);
// 409 Conflict
}
}
11.4 Error Response DTO
Create a standardized error response format.
Error Response:
package com.example.demo.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
@Data // Lombok: getters, setters, toString, equals, hashCode
@Builder // Lombok: builder pattern
@NoArgsConstructor // Lombok: no-args constructor
@AllArgsConstructor // Lombok: all-args constructor
@JsonInclude(JsonInclude.Include.NON_NULL)
// Only include non-null fields in JSON
// If 'errors' is null, it won't appear in response
public class ErrorResponse {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
// Format timestamp as "2024-01-15 14:30:45"
private LocalDateTime timestamp;
// When the error occurred
private int status;
// HTTP status code (400, 404, 500, etc.)
private String error;
// HTTP status text ("Bad Request", "Not Found", etc.)
private String message;
// Main error message
// e.g., "Product not found with id: '123'"
private String path;
// Request path where error occurred
// e.g., "/api/products/123"
private List<ValidationError> errors;
// List of validation errors (for @Valid failures)
// Only present for validation errors
// e.g., [{"field": "email", "message": "must be valid"}]
/**
* Nested class for validation errors
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ValidationError {
private String field;
// Field name that failed validation
// e.g., "email", "password"
private String message;
// Validation error message
// e.g., "must not be blank", "size must be between 6 and 20"
}
}
Example Error Response JSON:
{
"timestamp": "2024-01-15 14:30:45",
"status": 404,
"error": "Not Found",
"message": "Product not found with id: '123'",
"path": "/api/products/123"
}
Example Validation Error Response JSON:
{
"timestamp": "2024-01-15 14:30:45",
"status": 400,
"error": "Bad Request",
"message": "Validation failed",
"path": "/api/products",
"errors": [
{
"field": "name",
"message": "must not be blank"
},
{
"field": "price",
"message": "must be greater than 0"
}
]
}
11.5 Global Exception Handler
Use @RestControllerAdvice to handle exceptions globally.
Global Exception Handler:
package com.example.demo.exception;
import com.example.demo.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@RestControllerAdvice
// @RestControllerAdvice = @ControllerAdvice + @ResponseBody
// Handles exceptions thrown by ALL @RestController classes
// Methods return ResponseEntity (automatically serialized to JSON)
@Slf4j // Lombok: adds logger
public class GlobalExceptionHandler {
/**
* Handle custom ApplicationException and its subclasses
* Catches: ResourceNotFoundException, BadRequestException, etc.
*/
@ExceptionHandler(ApplicationException.class)
// This method handles ApplicationException and all its subclasses
public ResponseEntity<ErrorResponse> handleApplicationException(
ApplicationException ex,
HttpServletRequest request
) {
// Log the exception
log.error("Application exception occurred: {}", ex.getMessage(), ex);
// Build error response
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(ex.getStatus().value()) // e.g., 404
.error(ex.getStatus().getReasonPhrase()) // e.g., "Not Found"
.message(ex.getMessage()) // Custom exception message
.path(request.getRequestURI()) // e.g., "/api/products/123"
.build();
// Return response with appropriate status code
return ResponseEntity
.status(ex.getStatus())
.body(errorResponse);
}
/**
* Handle validation errors from @Valid annotation
* Triggered when request body validation fails
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
// Thrown when @Valid fails on @RequestBody
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException ex,
HttpServletRequest request
) {
log.error("Validation exception occurred: {}", ex.getMessage());
// Extract all field errors
List<ErrorResponse.ValidationError> validationErrors = new ArrayList<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
// Each error represents one validation failure
String fieldName = ((FieldError) error).getField();
// Field that failed validation (e.g., "email", "password")
String errorMessage = error.getDefaultMessage();
// Validation message (e.g., "must not be blank")
validationErrors.add(
ErrorResponse.ValidationError.builder()
.field(fieldName)
.message(errorMessage)
.build()
);
});
// Build error response with validation errors
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value()) // 400
.error(HttpStatus.BAD_REQUEST.getReasonPhrase()) // "Bad Request"
.message("Validation failed for one or more fields")
.path(request.getRequestURI())
.errors(validationErrors) // List of field errors
.build();
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}
/**
* Handle constraint violation exceptions
* Triggered by @Validated on method parameters
*/
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolationException(
ConstraintViolationException ex,
HttpServletRequest request
) {
log.error("Constraint violation exception: {}", ex.getMessage());
// Extract constraint violations
List<ErrorResponse.ValidationError> validationErrors = new ArrayList<>();
for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
// Each violation represents one validation failure
String fieldName = violation.getPropertyPath().toString();
// Path to field (e.g., "createProduct.name")
String errorMessage = violation.getMessage();
// Validation message
validationErrors.add(
ErrorResponse.ValidationError.builder()
.field(fieldName)
.message(errorMessage)
.build()
);
}
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error(HttpStatus.BAD_REQUEST.getReasonPhrase())
.message("Constraint violation occurred")
.path(request.getRequestURI())
.errors(validationErrors)
.build();
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}
/**
* Handle type mismatch exceptions
* e.g., /api/products/abc when expecting Long
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleTypeMismatchException(
MethodArgumentTypeMismatchException ex,
HttpServletRequest request
) {
log.error("Type mismatch exception: {}", ex.getMessage());
String message = String.format(
"Failed to convert '%s' to type %s",
ex.getValue(),
ex.getRequiredType().getSimpleName()
);
// e.g., "Failed to convert 'abc' to type Long"
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error(HttpStatus.BAD_REQUEST.getReasonPhrase())
.message(message)
.path(request.getRequestURI())
.build();
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}
/**
* Handle Spring Security authentication exceptions
* e.g., Invalid username/password
*/
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(
AuthenticationException ex,
HttpServletRequest request
) {
log.error("Authentication exception: {}", ex.getMessage());
String message = "Authentication failed";
if (ex instanceof BadCredentialsException) {
message = "Invalid username or password";
}
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.UNAUTHORIZED.value()) // 401
.error(HttpStatus.UNAUTHORIZED.getReasonPhrase())
.message(message)
.path(request.getRequestURI())
.build();
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(errorResponse);
}
/**
* Handle Spring Security access denied exceptions
* Triggered when user lacks required authority
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDeniedException(
AccessDeniedException ex,
HttpServletRequest request
) {
log.error("Access denied exception: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.FORBIDDEN.value()) // 403
.error(HttpStatus.FORBIDDEN.getReasonPhrase())
.message("You don't have permission to access this resource")
.path(request.getRequestURI())
.build();
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(errorResponse);
}
/**
* Handle all other unhandled exceptions
* Fallback handler for unexpected errors
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGlobalException(
Exception ex,
HttpServletRequest request
) {
// Log full stack trace for debugging
log.error("Unexpected exception occurred", ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value()) // 500
.error(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
.message("An unexpected error occurred. Please try again later.")
// Don't expose internal error details to client
.path(request.getRequestURI())
.build();
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(errorResponse);
}
}
How @ExceptionHandler Works:
Controller throws an exception
Spring searches for matching
@ExceptionHandlermethodMost specific handler is chosen (subclass before superclass)
Handler method executes and returns
ResponseEntity<ErrorResponse>Spring serializes ErrorResponse to JSON
HTTP response sent to client with status code and error JSON
Handler Priority (Most to Least Specific):
ResourceNotFoundException
β
ApplicationException
β
Exception (fallback)
11.6 Using Custom Exceptions in Service Layer
Product Service with Custom Exceptions:
package com.example.demo.service;
import com.example.demo.dto.ProductDTO;
import com.example.demo.entity.Product;
import com.example.demo.exception.ResourceNotFoundException;
import com.example.demo.exception.BadRequestException;
import com.example.demo.exception.ConflictException;
import com.example.demo.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional // All methods run in transaction
public class ProductService {
private final ProductRepository productRepository;
/**
* Get product by ID
* Throws ResourceNotFoundException if not found
*/
public Product getProductById(Long id) {
return productRepository.findById(id)
.orElseThrow(() ->
new ResourceNotFoundException("Product", "id", id)
// Throws exception if product doesn't exist
// Exception caught by GlobalExceptionHandler
// Returns 404 with error message
);
}
/**
* Create new product
* Validates product data
*/
public Product createProduct(ProductDTO productDTO) {
// Validate product name is unique
if (productRepository.existsByName(productDTO.getName())) {
throw new ConflictException(
"Product with name '" + productDTO.getName() + "' already exists"
);
// 409 Conflict response
}
// Validate price
if (productDTO.getPrice() <= 0) {
throw new BadRequestException("Price must be greater than 0");
// 400 Bad Request response
}
// Create and save product
Product product = Product.builder()
.name(productDTO.getName())
.description(productDTO.getDescription())
.price(productDTO.getPrice())
.build();
return productRepository.save(product);
}
/**
* Update existing product
*/
public Product updateProduct(Long id, ProductDTO productDTO) {
// Find existing product (throws 404 if not found)
Product product = getProductById(id);
// Check if new name conflicts with another product
if (!product.getName().equals(productDTO.getName()) &&
productRepository.existsByName(productDTO.getName())) {
throw new ConflictException(
"Product with name '" + productDTO.getName() + "' already exists"
);
}
// Update product fields
product.setName(productDTO.getName());
product.setDescription(productDTO.getDescription());
product.setPrice(productDTO.getPrice());
return productRepository.save(product);
}
/**
* Delete product
*/
public void deleteProduct(Long id) {
// Verify product exists (throws 404 if not found)
Product product = getProductById(id);
// Check if product can be deleted
if (product.hasActiveOrders()) {
throw new BadRequestException(
"Cannot delete product with active orders"
);
}
productRepository.delete(product);
}
}
Controller Using Service:
package com.example.demo.controller;
import com.example.demo.dto.ProductDTO;
import com.example.demo.entity.Product;
import com.example.demo.service.ProductService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
/**
* GET /api/products/{id}
* No try-catch needed - GlobalExceptionHandler handles exceptions!
*/
@GetMapping("/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id) {
// If product doesn't exist, service throws ResourceNotFoundException
// GlobalExceptionHandler catches it and returns 404 response
Product product = productService.getProductById(id);
return ResponseEntity.ok(product);
}
/**
* POST /api/products
* Create new product
*/
@PostMapping
public ResponseEntity<Product> createProduct(
@Valid @RequestBody ProductDTO productDTO
) {
// @Valid triggers validation
// If validation fails, MethodArgumentNotValidException thrown
// GlobalExceptionHandler returns 400 with validation errors
// If business logic fails (duplicate name, invalid price, etc.),
// Service throws custom exception
// GlobalExceptionHandler returns appropriate error response
Product product = productService.createProduct(productDTO);
return ResponseEntity.status(HttpStatus.CREATED).body(product);
}
/**
* PUT /api/products/{id}
* Update existing product
*/
@PutMapping("/{id}")
public ResponseEntity<Product> updateProduct(
@PathVariable Long id,
@Valid @RequestBody ProductDTO productDTO
) {
Product product = productService.updateProduct(id, productDTO);
return ResponseEntity.ok(product);
}
/**
* DELETE /api/products/{id}
* Delete product
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
}
Notice: Controller methods have NO try-catch blocks! All exception handling is centralized in GlobalExceptionHandler.
11.7 Complete Exception Handling Flow Example
Scenario 1: Resource Not Found
1. Client: GET /api/products/999
2. Controller: productService.getProductById(999)
3. Service: productRepository.findById(999) returns empty Optional
4. Service: throws ResourceNotFoundException("Product", "id", 999)
5. GlobalExceptionHandler: @ExceptionHandler(ApplicationException.class) catches
6. Handler: Creates ErrorResponse with 404 status
7. Client receives:
{
"timestamp": "2024-01-15 14:30:45",
"status": 404,
"error": "Not Found",
"message": "Product not found with id: '999'",
"path": "/api/products/999"
}
Scenario 2: Validation Error
1. Client: POST /api/products
{
"name": "",
"price": -10
}
2. Controller: @Valid triggers validation
3. Spring: Validates @NotBlank on name - FAILS
4. Spring: Validates @Positive on price - FAILS
5. Spring: throws MethodArgumentNotValidException
6. GlobalExceptionHandler: @ExceptionHandler(MethodArgumentNotValidException.class) catches
7. Handler: Extracts field errors and creates ErrorResponse with 400 status
8. Client receives:
{
"timestamp": "2024-01-15 14:30:45",
"status": 400,
"error": "Bad Request",
"message": "Validation failed for one or more fields",
"path": "/api/products",
"errors": [
{"field": "name", "message": "must not be blank"},
{"field": "price", "message": "must be greater than 0"}
]
}
Scenario 3: Duplicate Resource
1. Client: POST /api/products {"name": "Laptop", ...}
2. Controller: productService.createProduct(productDTO)
3. Service: productRepository.existsByName("Laptop") returns true
4. Service: throws ConflictException("Product with name 'Laptop' already exists")
5. GlobalExceptionHandler: @ExceptionHandler(ApplicationException.class) catches
6. Handler: Creates ErrorResponse with 409 status
7. Client receives:
{
"timestamp": "2024-01-15 14:30:45",
"status": 409,
"error": "Conflict",
"message": "Product with name 'Laptop' already exists",
"path": "/api/products"
}
11.8 Best Practices for Exception Handling
1. Use Specific Exception Types
// GOOD - Specific exception
throw new ResourceNotFoundException("Product", "id", productId);
// BAD - Generic exception
throw new RuntimeException("Product not found");
2. Don't Catch Exceptions in Controllers
// GOOD - Let GlobalExceptionHandler handle it
@GetMapping("/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id) {
return ResponseEntity.ok(productService.getProductById(id));
}
// BAD - Unnecessary try-catch
@GetMapping("/{id}")
public ResponseEntity<?> getProduct(@PathVariable Long id) {
try {
return ResponseEntity.ok(productService.getProductById(id));
} catch (ResourceNotFoundException ex) {
return ResponseEntity.notFound().build();
}
}
3. Log Exceptions Appropriately
// Expected exceptions (user errors) - log as warnings
log.warn("Resource not found: {}", ex.getMessage());
// Unexpected exceptions (system errors) - log with stack trace
log.error("Unexpected error occurred", ex);
4. Don't Expose Sensitive Information
// GOOD - Generic message for client
"An unexpected error occurred. Please try again later."
// BAD - Exposes internal details
"SQLException: Connection to database failed at server 192.168.1.100"
5. Return Appropriate HTTP Status Codes
| Status Code | Use Case | Exception Type |
| 400 Bad Request | Invalid input, validation errors | BadRequestException |
| 401 Unauthorized | Not authenticated | UnauthorizedException |
| 403 Forbidden | Authenticated but no permission | ForbiddenException |
| 404 Not Found | Resource doesn't exist | ResourceNotFoundException |
| 409 Conflict | Duplicate resource, constraint violation | ConflictException |
| 500 Internal Server Error | Unexpected system error | Exception (fallback) |
11.9 Testing Exception Handling
Unit Test for GlobalExceptionHandler:
package com.example.demo.exception;
import com.example.demo.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {
@InjectMocks
private GlobalExceptionHandler exceptionHandler;
@Mock
private HttpServletRequest request;
@Test
void testHandleResourceNotFoundException() {
// Arrange
ResourceNotFoundException exception =
new ResourceNotFoundException("Product", "id", 123L);
when(request.getRequestURI()).thenReturn("/api/products/123");
// Act
ResponseEntity<ErrorResponse> response =
exceptionHandler.handleApplicationException(exception, request);
// Assert
assertNotNull(response);
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals(404, response.getBody().getStatus());
assertEquals("Product not found with id: '123'", response.getBody().getMessage());
}
@Test
void testHandleBadRequestException() {
// Arrange
BadRequestException exception =
new BadRequestException("Invalid price");
when(request.getRequestURI()).thenReturn("/api/products");
// Act
ResponseEntity<ErrorResponse> response =
exceptionHandler.handleApplicationException(exception, request);
// Assert
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
assertEquals("Invalid price", response.getBody().getMessage());
}
}
12. Putting It All Together π
Let's create a complete Spring Boot application that integrates all the concepts we've covered: entities, repositories, services, controllers, security, validation, and exception handling.
12.1 Complete Application Structure
src/main/java/com/example/demo/
βββ DemoApplication.java # Main application class
βββ config/
β βββ SecurityConfig.java # Security configuration
β βββ PasswordEncoderConfig.java # Password encoder bean
β βββ JwtProperties.java # JWT configuration properties
βββ entity/
β βββ User.java # User entity
β βββ Product.java # Product entity
βββ repository/
β βββ UserRepository.java # User repository
β βββ ProductRepository.java # Product repository
βββ dto/
β βββ LoginRequest.java # Login DTO
β βββ RegisterRequest.java # Registration DTO
β βββ JwtAuthenticationResponse.java # JWT response DTO
β βββ ProductDTO.java # Product DTO
β βββ ErrorResponse.java # Error response DTO
βββ service/
β βββ UserService.java # User service
β βββ ProductService.java # Product service
βββ controller/
β βββ AuthController.java # Authentication endpoints
β βββ ProductController.java # Product endpoints
βββ security/
β βββ CustomUserDetailsService.java # UserDetailsService implementation
β βββ jwt/
β βββ JwtTokenProvider.java # JWT token utilities
β βββ JwtAuthenticationFilter.java # JWT filter
βββ exception/
βββ ApplicationException.java # Base exception
βββ ResourceNotFoundException.java # 404 exception
βββ BadRequestException.java # 400 exception
βββ ConflictException.java # 409 exception
βββ GlobalExceptionHandler.java # Global exception handler
src/main/resources/
βββ application.yml # Main configuration
βββ application-dev.yml # Dev profile
βββ application-prod.yml # Production profile
βββ schema.sql # Database schema (optional)
12.2 Complete pom.xml
Full Maven Dependencies:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- Parent: Spring Boot Starter Parent -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.1</version>
<!-- Manages versions of all Spring Boot dependencies -->
<relativePath/>
</parent>
<!-- Project coordinates -->
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Spring Boot Comprehensive Demo</name>
<description>Complete Spring Boot application with all features</description>
<!-- Java version -->
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web: REST APIs, embedded Tomcat -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Data JPA: Database access, Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Boot Data MongoDB: NoSQL database support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<!-- Spring Boot Security: Authentication & Authorization -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Boot Validation: @Valid, @NotBlank, etc. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- MySQL Driver: SQL database connector -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- H2 Database: In-memory SQL database for development -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT Libraries: Token generation and validation -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok: Reduces boilerplate code -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Spring Boot DevTools: Auto-restart on code changes -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Spring Boot Test: JUnit, Mockito, Spring Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Security Test: Test security configurations -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Spring Boot Maven Plugin -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- Exclude Lombok from final JAR -->
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
12.3 Complete application.yml Configuration
Main Configuration File:
# Spring Application Name
spring:
application:
name: spring-boot-demo
# Active Profile (dev, test, prod)
profiles:
active: dev
# Can override with: java -jar app.jar --spring.profiles.active=prod
# Database Configuration (SQL)
datasource:
url: jdbc:mysql://localhost:3306/demo_db
# JDBC URL for MySQL database
# Create database: CREATE DATABASE demo_db;
username: root
# Database username
password: password
# Database password (use environment variable in production!)
driver-class-name: com.mysql.cj.jdbc.Driver
# MySQL JDBC driver
# JPA / Hibernate Configuration
jpa:
hibernate:
ddl-auto: update
# update: Update schema if needed (for development)
# validate: Only validate schema (for production)
# create: Drop and recreate schema (data loss!)
# create-drop: Drop schema when session closes
show-sql: true
# Print SQL queries to console (disable in production)
properties:
hibernate:
format_sql: true
# Format SQL queries for readability
dialect: org.hibernate.dialect.MySQLDialect
# MySQL-specific SQL dialect
# MongoDB Configuration (NoSQL)
data:
mongodb:
uri: mongodb://localhost:27017/demo_mongodb
# MongoDB connection string
# Default port: 27017
# Jackson JSON Configuration
jackson:
serialization:
write-dates-as-timestamps: false
# Serialize dates as ISO-8601 strings, not timestamps
default-property-inclusion: non_null
# Don't include null fields in JSON response
# JWT Configuration
jwt:
secret-key: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
# Secret key for signing JWT tokens
# MUST be 256+ bits (32+ bytes in hex)
# Generate with: openssl rand -hex 32
# IMPORTANT: Use environment variable in production!
expiration: 86400000
# Token validity: 86400000 ms = 24 hours
# Server Configuration
server:
port: 8080
# Application runs on http://localhost:8080
error:
include-message: always
# Include error message in response
include-stacktrace: never
# Never include stack trace (security risk)
# Logging Configuration
logging:
level:
root: INFO
# Root logger level
com.example.demo: DEBUG
# Application-specific logger level
org.springframework.web: DEBUG
# Spring Web logs
org.hibernate.SQL: DEBUG
# SQL query logs
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
# SQL parameter values
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
# Console log format
# Management Endpoints (Actuator)
management:
endpoints:
web:
exposure:
include: health,info,metrics
# Expose health check endpoint at /actuator/health
application-dev.yml (Development Profile):
spring:
# Use H2 in-memory database for development
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password: ""
# H2 Console (http://localhost:8080/h2-console)
h2:
console:
enabled: true
path: /h2-console
jpa:
hibernate:
ddl-auto: create-drop
# Drop and recreate tables on startup
logging:
level:
com.example.demo: DEBUG
# Verbose logging in development
application-prod.yml (Production Profile):
spring:
jpa:
hibernate:
ddl-auto: validate
# Only validate schema, don't modify
show-sql: false
# Don't print SQL in production
datasource:
# Use environment variables for credentials
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jwt:
secret-key: ${JWT_SECRET}
# Use environment variable for JWT secret
server:
ssl:
enabled: true
# Enable HTTPS in production
logging:
level:
root: WARN
com.example.demo: INFO
# Less verbose logging in production
12.4 Complete Application Flow Example
End-to-End Flow: Register β Login β Create Product β Get Product
1. Register a New User:
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "john",
"email": "john@example.com",
"password": "password123"
}'
What Happens:
1. AuthController.register() receives request
2. @Valid validates RegisterRequest (username, email, password)
3. userRepository.existsByUsername() checks for duplicate
4. userRepository.existsByEmail() checks for duplicate
5. passwordEncoder.encode() hashes password with BCrypt
6. User entity created with ROLE_USER
7. userRepository.save() persists to database
8. Response: "User registered successfully!"
2. Login:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "john",
"password": "password123"
}'
What Happens:
1. AuthController.login() receives request
2. authenticationManager.authenticate() starts authentication
3. DaoAuthenticationProvider uses CustomUserDetailsService
4. CustomUserDetailsService.loadUserByUsername() loads user from database
5. PasswordEncoder verifies password hash
6. Authentication successful
7. JwtTokenProvider.generateToken() creates JWT token
8. Response includes JWT token and user info
Response:
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"tokenType": "Bearer",
"username": "john",
"roles": ["ROLE_USER"]
}
3. Create Product (Authenticated):
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \
-d '{
"name": "Laptop",
"description": "Gaming laptop",
"price": 1299.99
}'
What Happens:
1. Request arrives with JWT token in Authorization header
2. JwtAuthenticationFilter intercepts request
3. Filter extracts JWT from "Bearer <token>"
4. JwtTokenProvider.validateToken() verifies signature and expiration
5. JwtTokenProvider.getUsernameFromToken() extracts username
6. CustomUserDetailsService.loadUserByUsername() loads user
7. Authentication object created and set in SecurityContext
8. Request continues to ProductController.createProduct()
9. @Valid validates ProductDTO
10. productService.createProduct() validates business rules
11. productRepository.save() persists product
12. Response: Created product with 201 status
4. Get Product:
curl -X GET http://localhost:8080/api/products/1 \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
What Happens:
1. JWT filter authenticates request (same as step 3)
2. ProductController.getProduct(1) called
3. productService.getProductById(1) called
4. productRepository.findById(1) queries database
5. If found: Product returned with 200 status
6. If not found: ResourceNotFoundException thrown
7. GlobalExceptionHandler catches exception
8. ErrorResponse created with 404 status
9. Client receives error JSON
12.5 Database Schema (Auto-Generated by Hibernate)
When you run the application, Hibernate creates these tables:
users table:
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
created_at DATETIME,
updated_at DATETIME
);
user_roles table:
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role VARCHAR(255),
FOREIGN KEY (user_id) REFERENCES users(id)
);
products table:
CREATE TABLE products (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL,
created_at DATETIME,
updated_at DATETIME
);
12.6 Running the Application
Step 1: Clone/Create Project
# Using Spring Initializr
curl https://start.spring.io/starter.zip \
-d dependencies=web,data-jpa,security,validation,lombok,mysql,h2 \
-d type=maven-project \
-d javaVersion=17 \
-d bootVersion=3.2.1 \
-o spring-boot-demo.zip
# Extract and navigate
unzip spring-boot-demo.zip
cd spring-boot-demo
Step 2: Configure Database
# For MySQL (Production)
mysql -u root -p
CREATE DATABASE demo_db;
exit;
# For H2 (Development)
# No setup needed - in-memory database
Step 3: Build Application
# Using Maven
./mvnw clean install
# Or with Maven wrapper
mvn clean install
Step 4: Run Application
# Development mode (uses H2 database)
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
# Production mode (uses MySQL)
./mvnw spring-boot:run -Dspring-boot.run.profiles=prod
# Or run JAR directly
java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev
Step 5: Access Application
Application: http://localhost:8080
H2 Console (dev): http://localhost:8080/h2-console
Health Check: http://localhost:8080/actuator/health
Application Startup Log:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.2.1)
2024-01-15 10:30:00 - Starting DemoApplication using Java 17
2024-01-15 10:30:00 - The following 1 profile is active: "dev"
2024-01-15 10:30:01 - Tomcat initialized with port 8080 (http)
2024-01-15 10:30:02 - HikariPool-1 - Starting...
2024-01-15 10:30:02 - HikariPool-1 - Start completed.
2024-01-15 10:30:03 - Hibernate:
create table users (...)
2024-01-15 10:30:03 - Hibernate:
create table products (...)
2024-01-15 10:30:04 - Started DemoApplication in 4.123 seconds
12.7 Complete Request/Response Examples
Successful Product Creation:
POST /api/products HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
{
"name": "Gaming Mouse",
"description": "RGB gaming mouse with 7 buttons",
"price": 59.99
}
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": 1,
"name": "Gaming Mouse",
"description": "RGB gaming mouse with 7 buttons",
"price": 59.99,
"createdAt": "2024-01-15T10:30:00",
"updatedAt": "2024-01-15T10:30:00"
}
Validation Error:
POST /api/products HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
{
"name": "",
"price": -10
}
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"timestamp": "2024-01-15 10:30:00",
"status": 400,
"error": "Bad Request",
"message": "Validation failed for one or more fields",
"path": "/api/products",
"errors": [
{
"field": "name",
"message": "must not be blank"
},
{
"field": "description",
"message": "must not be blank"
},
{
"field": "price",
"message": "must be greater than 0"
}
]
}
Unauthorized Access:
GET /api/products/1 HTTP/1.1
Host: localhost:8080
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"timestamp": "2024-01-15 10:30:00",
"status": 401,
"error": "Unauthorized",
"message": "Full authentication is required to access this resource",
"path": "/api/products/1"
}
Resource Not Found:
GET /api/products/999 HTTP/1.1
Host: localhost:8080
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"timestamp": "2024-01-15 10:30:00",
"status": 404,
"error": "Not Found",
"message": "Product not found with id: '999'",
"path": "/api/products/999"
}
13. Conclusion & Next Steps π
13.1 What You've Learned
Congratulations! You've completed this comprehensive Spring Boot guide. You now understand:
Core Spring Concepts:
β IoC (Inversion of Control): Spring manages object lifecycle
β Dependency Injection: Spring automatically wires dependencies
β Beans & Scopes: Singleton, prototype, request, session
β Bean Lifecycle: Complete 9-phase lifecycle from creation to destruction
Spring Boot Features:
β Auto-Configuration: Automatic setup based on classpath
β Starter Dependencies: Pre-configured dependency groups
β Embedded Servers: Tomcat/Jetty embedded in JAR
β Spring Boot DevTools: Auto-reload during development
Database Integration:
β Spring Data JPA: Repository pattern, query methods
β JPA Entities: @Entity, relationships, auditing
β Query Methods: Method name-based and @Query
β Database Initialization: schema.sql, data.sql, Hibernate DDL
β Spring Data MongoDB: NoSQL database support
RESTful APIs:
β @RestController: REST endpoint creation
β HTTP Methods: GET, POST, PUT, DELETE
β Request/Response: @RequestBody, @PathVariable, ResponseEntity
β DTO Pattern: Data transfer objects
β Bean Validation: @Valid, @NotBlank, @Email, etc.
Configuration:
β application.yml: Centralized configuration
β Profiles: dev, test, prod environments
β @ConfigurationProperties: Type-safe configuration
β @Value: Property injection
Security:
β Spring Security: Authentication and authorization
β JWT Tokens: Stateless authentication
β Password Encoding: BCrypt hashing
β Method Security: @PreAuthorize, role-based access
β Security Filter Chain: Request filtering and authentication
Exception Handling:
β @RestControllerAdvice: Global exception handling
β Custom Exceptions: Specific error types
β Error Responses: Consistent error format
β HTTP Status Codes: Appropriate response codes
13.2 Best Practices Summary
1. Project Structure:
Organize by feature (entity, repository, service, controller)
Keep related classes together
Use clear, descriptive package names
2. Configuration:
Use profiles for different environments
Externalize sensitive configuration (environment variables)
Use @ConfigurationProperties for complex configuration
3. Security:
Always encode passwords (BCrypt)
Use strong JWT secret keys (256+ bits)
Enable HTTPS in production
Implement proper exception handling
Don't expose sensitive information in errors
4. Database:
Use DTOs for API requests/responses (don't expose entities)
Implement proper validation (@Valid, custom validators)
Use transactions (@Transactional) for data consistency
Use appropriate fetch strategies (LAZY vs EAGER)
Index frequently queried columns
5. Code Quality:
Follow naming conventions
Write clean, self-documenting code
Use Lombok to reduce boilerplate
Add comments for complex logic
Write unit and integration tests
6. Performance:
Use pagination for large datasets
Implement caching where appropriate
Optimize database queries (avoid N+1 problems)
Use connection pooling (HikariCP - default in Spring Boot)
13.3 Common Pitfalls to Avoid
β Don't:
Store plain text passwords
Expose entity classes directly in REST APIs
Use
ddl-auto: create-dropin productionLog sensitive information (passwords, tokens)
Catch exceptions without proper handling
Use weak JWT secret keys
Disable CSRF without understanding implications
Use synchronous operations for long-running tasks
β Do:
Hash passwords with BCrypt
Use DTOs for API communication
Use
ddl-auto: validatein productionImplement proper logging with appropriate levels
Use global exception handling
Generate strong JWT secrets (256+ bits)
Understand security implications
Use async/messaging for background tasks
13.4 Next Steps & Advanced Topics
Continue Your Learning:
Testing:
JUnit 5 and Mockito
Integration tests with @SpringBootTest
TestContainers for database testing
Security testing with Spring Security Test
Advanced Spring:
Spring AOP (Aspect-Oriented Programming)
Spring Events and listeners
Scheduled tasks with @Scheduled
Async processing with @Async
Microservices:
Spring Cloud (Config, Discovery, Gateway)
Service-to-service communication
Circuit breakers (Resilience4j)
Distributed tracing
Messaging:
Spring Boot with RabbitMQ/Kafka
Event-driven architecture
Message queues and pub/sub
Caching:
Spring Cache abstraction
Redis integration
Cache strategies and invalidation
API Documentation:
OpenAPI/Swagger integration
Automated API documentation
API versioning strategies
Deployment:
Docker containerization
Kubernetes deployment
CI/CD pipelines
Monitoring and observability (Actuator, Prometheus)
GraphQL:
Spring for GraphQL
GraphQL vs REST
Queries and mutations
Reactive Programming:
Spring WebFlux
Reactive repositories
Project Reactor
OAuth2 & Social Login:
OAuth2 integration
Login with Google/GitHub
Token refresh mechanisms
13.5 Resources & References
Official Documentation:
Spring Boot: https://spring.io/projects/spring-boot
Spring Framework: https://spring.io/projects/spring-framework
Spring Data JPA: https://spring.io/projects/spring-data-jpa
Spring Security: https://spring.io/projects/spring-security
Guides:
Spring Guides: https://spring.io/guides
Baeldung Spring Tutorials: https://www.baeldung.com/spring-tutorial
Tools:
Spring Initializr: https://start.spring.io
IntelliJ IDEA: https://www.jetbrains.com/idea/
VS Code with Spring Boot Extension Pack
Community:
Spring Blog: https://spring.io/blog
Stack Overflow: Tag [spring-boot]
GitHub Discussions: Spring Boot repository
13.6 Final Thoughts
Spring Boot has revolutionized Java application development by providing:
Convention over Configuration: Sensible defaults that work out of the box
Production-Ready: Built-in monitoring, health checks, and metrics
Ecosystem: Rich set of integrations and extensions
Community: Large, active community and excellent documentation
You're now equipped with the knowledge to build production-ready REST APIs with Spring Boot. The journey doesn't end hereβkeep practicing, building projects, and exploring advanced topics.
Remember:
Start simple, add complexity as needed
Follow best practices and design patterns
Write tests for your code
Stay updated with Spring Boot releases
Join the community and contribute