Spring Boot is the industry standard for building Java backend servers and REST APIs. It eliminates boilerplate configuration through auto-configuration, embedded servers (Tomcat), and production-ready features out of the box. Companies like Netflix, Amazon, Atlassian, and thousands of enterprises use Spring Boot to power their backends.
Step 1 — Project Setup with Spring Initializr
Go to start.spring.io and configure your project. Select these dependencies for a full REST API with database.
Essential Dependencies to Select
- Spring Web — Includes Spring MVC, embedded Tomcat, REST support.
- Spring Data JPA — ORM layer using Hibernate. Simplifies database operations.
- PostgreSQL Driver (or H2 for dev) — JDBC driver for your database.
- Spring Boot Validation — Bean Validation (JSR-380) with Hibernate Validator.
- Spring Security — Authentication and authorization framework.
- Lombok — Reduces boilerplate: @Getter, @Setter, @Builder, @Slf4j etc.
- Spring Boot Actuator — Production monitoring endpoints (/health, /metrics).
pom.xml (key dependencies)xml
<dependencies>
<!-- Web: Spring MVC + Tomcat -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA: Hibernate ORM -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL JDBC driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Lombok (code generation) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Actuator (monitoring) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>src/main/resources/application.ymlyaml
server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://localhost:5432/myapp_db
username: postgres
password: secret
hikari: # HikariCP connection pool (Spring Boot default)
pool-name: MainPool
maximum-pool-size: 10 # max concurrent DB connections
minimum-idle: 2
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: validate # 'create' for dev, 'validate' for prod, NEVER 'update' in prod
show-sql: true # log SQL queries (disable in prod)
open-in-view: false # always set false — avoids lazy loading in views
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
jackson:
default-property-inclusion: non_null # don't serialize null fields
date-format: yyyy-MM-dd HH:mm:ss
logging:
level:
org.springframework.web: INFO
org.hibernate.SQL: DEBUG
com.yourapp: DEBUG
management:
endpoints:
web:
exposure:
include: health,info,metrics # actuator endpoints to exposeStep 2 — Project Structure
Recommended package structurebash
src/main/java/com/yourapp/
├── YourAppApplication.java # @SpringBootApplication entry point
├── controller/
│ └── UserController.java # REST endpoints: handles HTTP req/res
├── service/
│ ├── UserService.java # interface
│ └── UserServiceImpl.java # business logic
├── repository/
│ └── UserRepository.java # Spring Data JPA interface
├── entity/
│ └── User.java # JPA entity (maps to DB table)
├── dto/
│ ├── UserCreateRequest.java # incoming request body
│ └── UserResponse.java # outgoing response body
├── exception/
│ ├── ResourceNotFoundException.java
│ └── GlobalExceptionHandler.java
├── security/
│ ├── SecurityConfig.java
│ └── JwtTokenFilter.java
└── config/
└── BeanConfig.javaStep 3 — The Entry Point
YourAppApplication.javajava
package com.yourapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// @SpringBootApplication = @Configuration + @EnableAutoConfiguration + @ComponentScan
// Auto-configures everything: Tomcat, JPA, Security, Jackson, etc.
@SpringBootApplication
public class YourAppApplication {
public static void main(String[] args) {
SpringApplication.run(YourAppApplication.class, args);
// Spring Boot starts embedded Tomcat on port 8080
// Component scan finds all @Component, @Service, @Repository, @Controller beans
}
}Step 4 — JPA Entity
entity/User.javajava
package com.yourapp.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users",
uniqueConstraints = {
@UniqueConstraint(columnNames = "email", name = "uk_users_email")
},
indexes = {
@Index(columnList = "email", name = "idx_users_email"),
@Index(columnList = "created_at", name = "idx_users_created")
}
)
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB auto-increment
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false, unique = true, length = 255)
private String email;
@Column(nullable = false)
private String password; // store BCrypt hash, NEVER plaintext
@Enumerated(EnumType.STRING) // store as 'ACTIVE'/'INACTIVE', not 0/1
@Column(nullable = false, length = 20)
private UserStatus status;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// JPA lifecycle callbacks
@PrePersist
protected void onCreate() {
createdAt = updatedAt = LocalDateTime.now();
if (status == null) status = UserStatus.ACTIVE;
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
public enum UserStatus { ACTIVE, INACTIVE, SUSPENDED }
}Step 5 — Repository Layer (Spring Data JPA)
repository/UserRepository.javajava
package com.yourapp.repository;
import com.yourapp.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
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> {
// JpaRepository<Entity, PK type> provides:
// save(), findById(), findAll(), deleteById(), count(), existsById() etc.
// --- Derived query methods: Spring generates SQL from method name ---
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
List<User> findByStatus(User.UserStatus status);
List<User> findByNameContainingIgnoreCase(String name);
List<User> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
List<User> findByStatusOrderByCreatedAtDesc(User.UserStatus status);
// --- JPQL (object-oriented query language) ---
@Query("SELECT u FROM User u WHERE u.email = :email AND u.status = :status")
Optional<User> findActiveUserByEmail(@Param("email") String email,
@Param("status") User.UserStatus status);
// --- Native SQL query ---
@Query(value = "SELECT * FROM users WHERE created_at > NOW() - INTERVAL '7 days'",
nativeQuery = true)
List<User> findUsersRegisteredLastWeek();
// --- Modifying query (UPDATE/DELETE) ---
@org.springframework.data.jpa.repository.Modifying
@org.springframework.transaction.annotation.Transactional
@Query("UPDATE User u SET u.status = :status WHERE u.id = :id")
int updateUserStatus(@Param("id") Long id, @Param("status") User.UserStatus status);
// --- Pagination support ---
// Just change return type to Page<User> and add Pageable parameter
org.springframework.data.domain.Page<User> findByStatus(
User.UserStatus status,
org.springframework.data.domain.Pageable pageable
);
}Step 6 — DTOs and Validation
dto/UserCreateRequest.javajava
package com.yourapp.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
@Data // Lombok: generates getters, setters, equals, hashCode, toString
public class UserCreateRequest {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
@Pattern(
regexp = "^(?=.*[A-Z])(?=.*[0-9]).*$",
message = "Password must contain at least one uppercase letter and one digit"
)
private String password;
@Min(value = 18, message = "Must be at least 18 years old")
@Max(value = 120, message = "Invalid age")
private Integer age;
}
// UserResponse.java — what we SEND back (never expose password!)
// Using record for clean immutable DTO
record UserResponse(
Long id,
String name,
String email,
String status,
java.time.LocalDateTime createdAt
) {}
// Map from entity to DTO in the service layerStep 7 — Service Layer
service/UserServiceImpl.javajava
package com.yourapp.service;
import com.yourapp.dto.*;
import com.yourapp.entity.User;
import com.yourapp.exception.ResourceNotFoundException;
import com.yourapp.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor // Lombok: generates constructor for all final fields
@Slf4j // Lombok: generates 'log' field
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
@Transactional
public UserResponse createUser(UserCreateRequest request) {
log.info("Creating user with email: {}", request.getEmail());
if (userRepository.existsByEmail(request.getEmail())) {
throw new IllegalArgumentException("Email already in use: " + request.getEmail());
}
User user = User.builder()
.name(request.getName())
.email(request.getEmail().toLowerCase())
.password(passwordEncoder.encode(request.getPassword())) // BCrypt hash
.build();
User saved = userRepository.save(user);
log.info("User created with id: {}", saved.getId());
return toResponse(saved);
}
@Override
@Transactional(readOnly = true) // readOnly=true: performance optimization for reads
public UserResponse getUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
return toResponse(user);
}
@Override
@Transactional(readOnly = true)
public List<UserResponse> getAllActiveUsers() {
return userRepository.findByStatus(User.UserStatus.ACTIVE)
.stream()
.map(this::toResponse)
.toList();
}
@Override
@Transactional
public UserResponse updateUser(Long id, UserUpdateRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
if (request.getName() != null) user.setName(request.getName());
// No need to call save() — @Transactional auto-flushes dirty entities
return toResponse(user);
}
@Override
@Transactional
public void deleteUser(Long id) {
if (!userRepository.existsById(id))
throw new ResourceNotFoundException("User", "id", id);
userRepository.deleteById(id);
}
private UserResponse toResponse(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail(),
user.getStatus().name(),
user.getCreatedAt()
);
}
}Step 8 — REST Controller
controller/UserController.javajava
package com.yourapp.controller;
import com.yourapp.dto.*;
import com.yourapp.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController // @Controller + @ResponseBody on every method
@RequestMapping("/api/v1/users") // base path for all endpoints
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// POST /api/v1/users
// Creates a new user. Returns 201 Created.
@PostMapping
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody UserCreateRequest request) { // @Valid triggers validation
UserResponse response = userService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
// GET /api/v1/users/{id}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserById(id));
}
// GET /api/v1/users?page=0&size=20&sort=createdAt,desc
@GetMapping
public ResponseEntity<List<UserResponse>> getAllUsers() {
return ResponseEntity.ok(userService.getAllActiveUsers());
}
// GET /api/v1/users/paged?page=0&size=10
@GetMapping("/paged")
public ResponseEntity<Page<UserResponse>> getPagedUsers(
@PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
// Spring auto-parses ?page=0&size=20&sort=name,asc into Pageable
return ResponseEntity.ok(userService.getPagedUsers(pageable));
}
// PUT /api/v1/users/{id}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateUser(
@PathVariable Long id,
@Valid @RequestBody UserUpdateRequest request) {
return ResponseEntity.ok(userService.updateUser(id, request));
}
// PATCH /api/v1/users/{id}/status?status=SUSPENDED
@PatchMapping("/{id}/status")
public ResponseEntity<Void> updateStatus(
@PathVariable Long id,
@RequestParam User.UserStatus status) {
userService.updateStatus(id, status);
return ResponseEntity.noContent().build(); // 204 No Content
}
// DELETE /api/v1/users/{id}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build(); // 204 No Content
}
}Step 9 — Global Exception Handler
exception/GlobalExceptionHandler.javajava
package com.yourapp.exception;
import org.springframework.http.*;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.*;
// Custom exception
class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, String field, Object value) {
super(resource + " not found with " + field + " = '" + value + "'");
}
}
// Standardized error response
record ErrorResponse(
int status,
String error,
String message,
LocalDateTime timestamp,
Map<String, String> fieldErrors // for validation failures
) {}
@RestControllerAdvice // intercepts exceptions from ALL controllers
public class GlobalExceptionHandler {
// Handle @Valid validation failures — 400 Bad Request
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new LinkedHashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String field = ((FieldError) error).getField();
String message = error.getDefaultMessage();
fieldErrors.put(field, message);
});
return ResponseEntity.badRequest().body(new ErrorResponse(
400, "Validation Failed",
"Request body has validation errors",
LocalDateTime.now(),
fieldErrors
));
}
// Handle resource not found — 404
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse(
404, "Not Found", ex.getMessage(), LocalDateTime.now(), null
));
}
// Handle business logic errors — 400
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(new ErrorResponse(
400, "Bad Request", ex.getMessage(), LocalDateTime.now(), null
));
}
// Handle all other unexpected errors — 500
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
// Log the full stack trace server-side (never send it to the client!)
return ResponseEntity.internalServerError().body(new ErrorResponse(
500, "Internal Server Error",
"An unexpected error occurred",
LocalDateTime.now(), null
));
}
}Step 10 — JWT Security with Spring Security
security/SecurityConfig.javajava
package com.yourapp.security;
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.*;
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.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // enables @PreAuthorize annotations
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenFilter jwtTokenFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // disable CSRF: we use stateless JWT
.sessionManagement(s -> s.sessionCreationPolicy(
SessionCreationPolicy.STATELESS)) // no sessions — JWT is the session
.authorizeHttpRequests(auth -> auth
// Public endpoints (no auth needed)
.requestMatchers(HttpMethod.POST, "/api/v1/auth/login").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/auth/register").permitAll()
.requestMatchers("/actuator/health").permitAll()
// Admin-only endpoints
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
// All other requests require authentication
.anyRequest().authenticated()
)
// Add JWT filter BEFORE the default username/password filter
.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // cost factor 12 (2^12 iterations)
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}security/JwtTokenFilter.javajava
package com.yourapp.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
@Component
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
@Value("${app.jwt.secret}") // from application.yml
private String jwtSecret;
@Value("${app.jwt.expiration-ms:86400000}") // 24h default
private long jwtExpirationMs;
private final UserDetailsService userDetailsService;
public JwtTokenFilter(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
// Generate JWT token for login
public String generateToken(String email) {
Key key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.subject(email)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
.signWith(key)
.compact();
}
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Key key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
String email = Jwts.parser()
.verifyWith((javax.crypto.SecretKey) key)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (JwtException e) {
log.warn("Invalid JWT token: {}", e.getMessage());
// Don't set auth — request will fail at authorization step
}
}
chain.doFilter(req, res);
}
}