Skip to main content

Command Palette

Search for a command to run...

The Ultimate Spring Boot Guide: From Fundamentals to Production-Ready REST APIs

Updated
β€’125 min read

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:

  1. Your class needs a dependency

  2. Your class creates that dependency using new

  3. Your 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:

  1. Tight Coupling: OrderService is tightly coupled to specific implementations of EmailService, PaymentService, etc.

  2. Hard to Test: You can't easily mock dependencies for unit testing

  3. Inflexible: Changing implementations requires changing OrderService code

  4. Resource 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:

  1. Loose Coupling: OrderService doesn't know or care about concrete implementations

  2. Easy Testing: You can pass mock objects in tests

  3. Flexible: Change implementations without touching OrderService

  4. Centralized 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.xml for servlet configuration

  • Multiple 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 @Configuration annotation

  • Indicates that this class contains Spring bean definitions

  • Allows you to define @Bean methods if needed

2. @EnableAutoConfiguration - The Real Magic

This annotation tells Spring Boot to:

  1. Look at your classpath

  2. Check what libraries are present

  3. 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, @Controller

  • Registers 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:

StarterWhat It Provides
spring-boot-starter-webWeb applications (Tomcat, Spring MVC, Jackson)
spring-boot-starter-data-jpaJPA with Hibernate
spring-boot-starter-securitySpring Security
spring-boot-starter-testTesting libraries (JUnit, Mockito, AssertJ)
spring-boot-starter-data-mongodbMongoDB support
spring-boot-starter-validationBean validation (Hibernate Validator)
spring-boot-starter-actuatorProduction monitoring

3. Embedded Servers - No External Server Needed

Traditional Deployment:

  1. Install Tomcat/JBoss/WebLogic separately

  2. Configure server

  3. Package app as WAR

  4. Deploy WAR to server

  5. Configure server to run WAR

  6. 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/health

  • Metrics: GET /actuator/metrics

  • Environment info: GET /actuator/env

  • Bean info: GET /actuator/beans

  • HTTP trace: GET /actuator/httptrace

  • And 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:

  1. Navigate to https://start.spring.io

  2. Choose Project Type:

    • Project: Maven (build tool)

      • Maven uses pom.xml for configuration

      • Gradle alternative uses build.gradle

  3. Choose Language:

    • Java (most common)

    • Kotlin or Groovy are alternatives

  4. Choose Spring Boot Version:

    • Select latest stable (avoid SNAPSHOT or M1/RC versions)

    • As of writing: 3.2.x is current

  5. 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)
    
  6. 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.)

  7. Click "Generate" - downloads a ZIP file

  8. 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:

  1. @Configuration

    • Indicates this class contains bean definitions

    • Methods annotated with @Bean will create Spring-managed objects

    • Example:

    @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();
        }
    }
  1. @EnableAutoConfiguration

    • The 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:

  1. Classpath Scanning: Spring Boot scans all JARs in your classpath

  2. Checks META-INF/spring.factories in each JAR for auto-configuration classes

  3. Evaluates Conditions: Each auto-configuration class has conditions

  4. 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:

AnnotationCondition
@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
@ConditionalOnWebApplicationBean 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!

  1. @ComponentScan

    • Scans 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:

  1. Creates Application Context: The IoC container

  2. Registers All Beans: Scans and creates all Spring beans

  3. Runs Auto-Configuration: Applies all auto-configuration

  4. Starts Embedded Server: Launches Tomcat/Jetty/Undertow

  5. 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 it

  • Spring Bean: Spring creates it, Spring manages it, Spring destroys it

Why use beans?

  1. Dependency Management: Spring handles object dependencies

  2. Lifecycle Management: Spring controls creation and destruction

  3. Configuration Management: Centralized configuration

  4. 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 @PostConstruct instead of InitializingBean (more modern, less coupling)

  • Use @PreDestroy instead of DisposableBean

  • Awareness 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:

ScopeInstancesLifecycleUse Case
SingletonOne per appApp startup β†’ shutdownStateless services, repositories
PrototypeNew each timeCreate β†’ manual cleanupStateful operations
RequestOne per HTTP requestRequest start β†’ endRequest-specific data
SessionOne per user sessionSession start β†’ expireUser-specific data (cart, preferences)
ApplicationOne per web appApp start β†’ shutdownGlobal 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?

  1. Semantic Clarity: Code is more readable

  2. Exception Translation: @Repository enables automatic exception translation

  3. Future Enhancements: Spring might add special behavior to specific stereotypes

  4. 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

@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:

  1. Immutability: Fields are final, can't be changed after creation

  2. Required Dependencies: Constructor won't compile without all deps

  3. 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...
}
  1. Prevents Circular Dependencies: Spring will error at startup if circular

  2. 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);
        }
    }
}
@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:

  1. Can't use final: Fields are mutable

  2. Testing Nightmare: Need Spring context or reflection to set fields

  3. Hidden Dependencies: Not clear what class needs

  4. 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 @Query

  • Auditing 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:

KeywordExampleJPQL
AndfindByNameAndEmail... WHERE x.name = ?1 AND x.email = ?2
OrfindByNameOrEmail... WHERE x.name = ?1 OR x.email = ?2
Is, EqualsfindByName... WHERE x.name = ?1
BetweenfindByDateBetween... WHERE x.date BETWEEN ?1 AND ?2
LessThanfindByAgeLessThan... WHERE x.age < ?1
LessThanEqualfindByAgeLessThanEqual... WHERE x.age <= ?1
GreaterThanfindByAgeGreaterThan... WHERE x.age > ?1
GreaterThanEqualfindByAgeGreaterThanEqual... WHERE x.age >= ?1
AfterfindByDateAfter... WHERE x.date > ?1
BeforefindByDateBefore... WHERE x.date < ?1
IsNull, NullfindByAgeIsNull... WHERE x.age IS NULL
IsNotNull, NotNullfindByAgeIsNotNull... WHERE x.age IS NOT NULL
LikefindByNameLike... WHERE x.name LIKE ?1
NotLikefindByNameNotLike... WHERE x.name NOT LIKE ?1
StartingWithfindByNameStartingWith... WHERE x.name LIKE '?1%'
EndingWithfindByNameEndingWith... WHERE x.name LIKE '%?1'
ContainingfindByNameContaining... WHERE x.name LIKE '%?1%'
OrderByfindByAgeOrderByNameDesc... WHERE x.age = ?1 ORDER BY x.name DESC
NotfindByNameNot... WHERE x.name <> ?1
InfindByAgeIn(Collection<Age>)... WHERE x.age IN ?1
NotInfindByAgeNotIn(Collection<Age>)... WHERE x.age NOT IN ?1
TruefindByActiveTrue()... WHERE x.active = TRUE
FalsefindByActiveFalse()... WHERE x.active = FALSE
IgnoreCasefindByNameIgnoreCase... 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

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:

  1. Never modify executed migrations - Create new migration instead

  2. Always test migrations on dev/staging before production

  3. Keep migrations small - One logical change per migration

  4. Use transactions - Most changes should be in a transaction

  5. Backup before migrating production databases

Comparison Table:

StrategyProsConsUse Case
schema.sqlSimple, easy to understandNo versioning, not team-friendlySmall projects, prototypes
Hibernate DDLZero configuration, auto-generatesDangerous in production, no controlDevelopment only
FlywayVersion control, team-friendly, production-readyRequires discipline, more setupProduction, 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)

FeatureMongoDB (NoSQL)SQL (JPA)
ID TypeString (ObjectId)Long or custom
SchemaFlexible, schema-lessFixed schema
Relationships@DBRef or embedded@OneToMany, @ManyToOne, etc.
JoinsLimited, use @DBRefEfficient joins
ArraysNative supportRequires separate table
Nested ObjectsNative (embedded docs)Requires @Embedded or separate table
TransactionsLimited (replica sets)Full ACID
QueriesJSON-basedSQL or JPQL
Indexes@Indexed annotation@Index in @Table
ScalingHorizontal (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:

  1. Client-Server Architecture: Separation of concerns

  2. Stateless: Each request contains all information needed

  3. Cacheable: Responses should indicate if they can be cached

  4. Uniform Interface: Consistent way to interact with resources

  5. Layered System: Client doesn't know if connected directly to server

  6. Code on Demand (optional): Server can send executable code

Resources: Everything is a resource (user, article, comment)

  • Identified by URIs: /api/users/123

  • Manipulated through representations (JSON, XML)

  • Self-descriptive messages

HTTP Methods (Verbs):

MethodPurposeIdempotent?Safe?Request Body?Response Body?
GETRetrieve resource(s)YesYesNoYes
POSTCreate new resourceNoNoYesYes
PUTUpdate/replace resourceYesNoYesYes
PATCHPartial updateNoNoYesYes
DELETEDelete resourceYesNoNoOptional
HEADGET without bodyYesYesNoNo
OPTIONSGet allowed methodsYesYesNoYes

Idempotent: Multiple identical requests have same effect as single request Safe: Doesn't modify server state

HTTP Status Codes:

CodeMeaningWhen to Use
200 OKSuccessGET, PUT, PATCH successful
201 CreatedResource createdPOST successful
204 No ContentSuccess, no bodyDELETE successful
400 Bad RequestInvalid requestValidation failed
401 UnauthorizedAuthentication requiredNo/invalid credentials
403 ForbiddenNo permissionAuthenticated but not authorized
404 Not FoundResource doesn't existResource not found
409 ConflictConflict with current stateDuplicate resource
500 Internal Server ErrorServer errorUnexpected 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?

  1. Separation of Concerns: API contract separate from database schema

  2. Security: Don't expose internal entity structure

  3. Flexibility: API can change independently from database

  4. Validation: Centralized validation rules

  5. 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:

AnnotationValidatesExample
@NotNullValue is not null@NotNull Integer age
@NotEmptyString/Collection not null or empty@NotEmpty List<String> tags
@NotBlankString 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
@EmailValid email format@Email String email
@Pattern(regexp)Matches regex pattern@Pattern(regexp="[A-Z].*")
@PastDate is in past@Past LocalDate birthDate
@FutureDate is in future@Future LocalDate expiryDate
@PastOrPresentDate is past or today@PastOrPresent LocalDate date
@FutureOrPresentDate is future or today@FutureOrPresent
@PositiveNumber > 0@Positive int quantity
@PositiveOrZeroNumber β‰₯ 0@PositiveOrZero int stock
@NegativeNumber < 0@Negative int debt
@NegativeOrZeroNumber ≀ 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)
@AssertTrueBoolean is true@AssertTrue Boolean accepted
@AssertFalseBoolean is false@AssertFalse Boolean spam
@ValidTrigger 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:

application.properties:

# 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 profile

  • application-test.yml - Test profile

  • application-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 /login

  • Generates 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:

  • UserDetails is Spring Security's representation of user information

  • Authorities/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:

  1. CSRF Disabled: JWT is stateless, doesn't use cookies, so CSRF doesn't apply

  2. Stateless Sessions: No HTTP sessions created, each request must have JWT

  3. URL Authorization: Configure which endpoints require which roles

  4. JWT Filter: Runs before every request, extracts and validates JWT

  5. 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:

ExpressionDescription
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.idParameter 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:

  1. Controller throws an exception

  2. Spring searches for matching @ExceptionHandler method

  3. Most specific handler is chosen (subclass before superclass)

  4. Handler method executes and returns ResponseEntity<ErrorResponse>

  5. Spring serializes ErrorResponse to JSON

  6. 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 CodeUse CaseException Type
400 Bad RequestInvalid input, validation errorsBadRequestException
401 UnauthorizedNot authenticatedUnauthorizedException
403 ForbiddenAuthenticated but no permissionForbiddenException
404 Not FoundResource doesn't existResourceNotFoundException
409 ConflictDuplicate resource, constraint violationConflictException
500 Internal Server ErrorUnexpected system errorException (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-drop in production

  • Log 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: validate in production

  • Implement 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:

  1. Testing:

    • JUnit 5 and Mockito

    • Integration tests with @SpringBootTest

    • TestContainers for database testing

    • Security testing with Spring Security Test

  2. Advanced Spring:

    • Spring AOP (Aspect-Oriented Programming)

    • Spring Events and listeners

    • Scheduled tasks with @Scheduled

    • Async processing with @Async

  3. Microservices:

    • Spring Cloud (Config, Discovery, Gateway)

    • Service-to-service communication

    • Circuit breakers (Resilience4j)

    • Distributed tracing

  4. Messaging:

    • Spring Boot with RabbitMQ/Kafka

    • Event-driven architecture

    • Message queues and pub/sub

  5. Caching:

    • Spring Cache abstraction

    • Redis integration

    • Cache strategies and invalidation

  6. API Documentation:

    • OpenAPI/Swagger integration

    • Automated API documentation

    • API versioning strategies

  7. Deployment:

    • Docker containerization

    • Kubernetes deployment

    • CI/CD pipelines

    • Monitoring and observability (Actuator, Prometheus)

  8. GraphQL:

    • Spring for GraphQL

    • GraphQL vs REST

    • Queries and mutations

  9. Reactive Programming:

    • Spring WebFlux

    • Reactive repositories

    • Project Reactor

  10. OAuth2 & Social Login:

    • OAuth2 integration

    • Login with Google/GitHub

    • Token refresh mechanisms

13.5 Resources & References

Official Documentation:

Guides:

Tools:

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