Spring Boot is an opinionated Java framework that eliminates configuration boilerplate. Add starters to pom.xml, annotate your main class with @SpringBootApplication, and run. Use @RestController + @GetMapping/@PostMapping for REST APIs. Use JpaRepository for CRUD database access. Secure endpoints with spring-boot-starter-security. Monitor with Actuator (/actuator/health). Test with @SpringBootTest + MockMvc. Package as a fat JAR and run with java -jar app.jar.
What Is Spring Boot?
Spring Boot is an opinionated, convention-over-configuration framework built on top of the Spring Framework that makes it easy to create stand-alone, production-grade Java applications. Released in 2014 by Pivotal (now VMware Tanzu), Spring Boot dramatically reduces the boilerplate configuration that made classic Spring development verbose.
The core philosophy of Spring Boot is: sensible defaults. If you add spring-boot-starter-web to your project, Spring Boot automatically configures an embedded Tomcat server, sets up Spring MVC, configures JSON serialization with Jackson, and handles all the bean wiring — with zero XML configuration required.
As of 2026, Spring Boot 3.x (requiring Java 17+) is the current major version, bringing native compilation support via GraalVM, improved observability, and full Jakarta EE 10 compatibility. It powers applications at Netflix, Airbnb, LinkedIn, and thousands of enterprises worldwide.
- Spring Boot auto-configures beans based on classpath dependencies — no XML required.
- Starters bundle compatible library versions:
spring-boot-starter-web,spring-boot-starter-data-jpa, etc. @SpringBootApplicationcombines@Configuration,@EnableAutoConfiguration, and@ComponentScan.- Dependency injection uses constructor injection by default;
@Autowiredis optional for single-constructor beans. - Spring Data JPA repositories eliminate CRUD boilerplate — extend
JpaRepositoryto get 15+ methods for free. - Spring Security integrates JWT and OAuth2; configure with
SecurityFilterChainbeans. - Actuator exposes health, metrics, and management endpoints at
/actuator/*. - Package as a fat JAR and deploy anywhere Java runs — or containerize with Docker.
Spring Boot vs Spring Framework
Understanding the difference between Spring Framework and Spring Boot is essential before diving in.
Spring Framework (2002):
- Core container: IoC / DI
- Spring MVC: web layer
- Spring Data: data access
- Spring Security: authentication/authorization
- Spring AOP: aspect-oriented programming
- Requires manual configuration of beans, XML or @Configuration classes
- You must choose and wire compatible library versions yourself
Spring Boot (2014):
- Built ON TOP of Spring Framework
- Auto-configuration: detects classpath and configures beans automatically
- Starter dependencies: curated, version-compatible bundles
- Embedded servers: Tomcat/Jetty/Undertow — no WAR deployment needed
- Production-ready: Actuator metrics, health, info endpoints
- Opinionated defaults you can override when neededGetting Started: Project Setup
The easiest way to bootstrap a Spring Boot project is Spring Initializr at start.spring.io. Choose your build tool (Maven or Gradle), Java version, and add dependencies.
<!-- pom.xml — Maven Spring Boot project -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.3</version>
</parent>
<dependencies>
<!-- Web: Spring MVC + Embedded Tomcat + Jackson -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA: Hibernate + Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Security: Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Actuator: health, metrics, info -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Database driver (PostgreSQL) -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>The main application class:
// src/main/java/com/example/demo/DemoApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
// Equivalent to:
// @Configuration — marks this as a bean source
// @EnableAutoConfiguration — enables Spring Boot auto-config
// @ComponentScan — scans current package and sub-packages
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}# Run the application
./mvnw spring-boot:run
# Or build and run the JAR
./mvnw package
java -jar target/demo-0.0.1-SNAPSHOT.jar
# Application starts on http://localhost:8080Dependency Injection: @Component, @Service, @Repository, @Autowired
Spring's IoC (Inversion of Control) container manages object creation and wiring. You mark classes with stereotype annotations so Spring can discover and inject them.
Stereotype Annotations
// @Component — generic Spring-managed bean
@Component
public class EmailValidator {
public boolean isValid(String email) {
return email.contains("@");
}
}
// @Service — business logic layer
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailValidator emailValidator;
// Constructor injection (recommended — no @Autowired needed)
public UserService(UserRepository userRepository,
EmailValidator emailValidator) {
this.userRepository = userRepository;
this.emailValidator = emailValidator;
}
public User createUser(String email, String name) {
if (!emailValidator.isValid(email)) {
throw new IllegalArgumentException("Invalid email: " + email);
}
User user = new User(email, name);
return userRepository.save(user);
}
}
// @Repository — data access layer (adds exception translation)
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}Field Injection vs Constructor Injection
// ❌ Field injection — harder to test, hides dependencies
@Service
public class BadService {
@Autowired
private UserRepository userRepository; // avoid this
}
// ✅ Constructor injection — explicit, testable, immutable
@Service
public class GoodService {
private final UserRepository userRepository;
public GoodService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
// ✅ Lombok + constructor injection (recommended in modern projects)
@Service
@RequiredArgsConstructor // generates constructor for all final fields
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
}@Bean and @Configuration
// Define beans programmatically in @Configuration classes
@Configuration
public class AppConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
@Profile("production") // only active in production profile
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("users", "products");
}
}Spring MVC: Controllers, RequestMapping, and REST APIs
Spring MVC provides the web layer. @RestController combines @Controller and @ResponseBody, meaning every method returns data serialized directly to the response body (JSON by default) rather than rendering a view template.
Basic REST Controller
package com.example.demo.controller;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
@RestController
@RequestMapping("/api/v1/users") // base path for all methods
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
// GET /api/v1/users
@GetMapping
public ResponseEntity<List<UserDto>> getAllUsers() {
return ResponseEntity.ok(userService.findAll());
}
// GET /api/v1/users/{id}
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUserById(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
// POST /api/v1/users
@PostMapping
public ResponseEntity<UserDto> createUser(
@Valid @RequestBody CreateUserRequest request) {
UserDto created = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
// PUT /api/v1/users/{id}
@PutMapping("/{id}")
public ResponseEntity<UserDto> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
// DELETE /api/v1/users/{id}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
// GET /api/v1/users/search?email=foo@bar.com&active=true
@GetMapping("/search")
public ResponseEntity<List<UserDto>> searchUsers(
@RequestParam String email,
@RequestParam(defaultValue = "true") boolean active) {
return ResponseEntity.ok(userService.search(email, active));
}
}Request and Response DTOs with Validation
// Request DTO with Bean Validation
public class CreateUserRequest {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100)
private String name;
@NotBlank
@Email(message = "Must be a valid email address")
private String email;
@NotBlank
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;
// Lombok: @Getter @Setter @NoArgsConstructor or use Java records
// Getters, setters...
}
// Response DTO (never expose entity directly)
public record UserDto(Long id, String name, String email, LocalDateTime createdAt) {}
// Validation error response — handled globally by @ControllerAdvice
// Returns HTTP 400 with field-level error messagesConfiguration: application.yml and Profiles
Spring Boot reads configuration from application.properties or application.yml. YAML is preferred for its readability and support for nested properties. Profiles allow environment-specific configuration.
# src/main/resources/application.yml
spring:
application:
name: my-spring-app
datasource:
url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/mydb}
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:secret}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
jpa:
hibernate:
ddl-auto: validate # never use create/create-drop in production
show-sql: false
open-in-view: false # recommended: disable OSIV
security:
jwt:
secret: ${JWT_SECRET:change-this-in-production}
expiration: 86400000 # 24 hours in ms
server:
port: 8080
servlet:
context-path: /
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
logging:
level:
com.example.demo: DEBUG
org.springframework.security: INFO# src/main/resources/application-dev.yml
# Active with: spring.profiles.active=dev
spring:
datasource:
url: jdbc:h2:mem:testdb # in-memory H2 for development
jpa:
hibernate:
ddl-auto: create-drop # recreate schema on restart
show-sql: true
logging:
level:
com.example.demo: TRACE# src/main/resources/application-prod.yml
# Active with: SPRING_PROFILES_ACTIVE=prod
spring:
jpa:
hibernate:
ddl-auto: validate
datasource:
hikari:
maximum-pool-size: 20
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus// Inject configuration values
@Service
public class JwtService {
@Value("${spring.security.jwt.secret}")
private String jwtSecret;
@Value("${spring.security.jwt.expiration}")
private long jwtExpiration;
// Or use @ConfigurationProperties for grouped config
}
// Typed configuration with @ConfigurationProperties
@ConfigurationProperties(prefix = "spring.security.jwt")
@Component
public class JwtProperties {
private String secret;
private long expiration;
// getters and setters
}Spring Data JPA and Repositories
Spring Data JPA eliminates boilerplate database code. Define your entity with JPA annotations and extend JpaRepository to get dozens of CRUD and query operations automatically.
JPA Entity
package com.example.demo.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "users",
uniqueConstraints = @UniqueConstraint(columnNames = "email"))
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password; // always store hashed!
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserRole role = UserRole.USER;
@Column(nullable = false)
private boolean active = true;
@CreationTimestamp
@Column(updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
// constructors, getters, setters...
}
public enum UserRole {
USER, ADMIN, MODERATOR
}Repository Interface
package com.example.demo.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
import java.util.List;
// JpaRepository<Entity, PrimaryKey> provides:
// save(), findById(), findAll(), deleteById(), count(), existsById(), etc.
public interface UserRepository extends JpaRepository<User, Long> {
// Query by convention — Spring generates SQL automatically
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
List<User> findByActiveTrue();
List<User> findByRoleOrderByCreatedAtDesc(UserRole role);
// Pagination and sorting
Page<User> findByActive(boolean active, Pageable pageable);
// Custom JPQL query
@Query("SELECT u FROM User u WHERE u.email = :email AND u.active = true")
Optional<User> findActiveUserByEmail(@Param("email") String email);
// Native SQL query
@Query(value = "SELECT * FROM users WHERE created_at > NOW() - INTERVAL '7 days'",
nativeQuery = true)
List<User> findRecentUsers();
// Modifying query
@Modifying
@Query("UPDATE User u SET u.active = false WHERE u.id = :id")
int deactivateUser(@Param("id") Long id);
}Service Layer with JPA
@Service
@Transactional(readOnly = true) // default: read-only transactions
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public Page<UserDto> findAll(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return userRepository.findAll(pageable).map(this::toDto);
}
public UserDto findById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found: " + id));
return toDto(user);
}
@Transactional // write transaction
public UserDto create(CreateUserRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new ConflictException("Email already registered: " + request.getEmail());
}
User user = new User();
user.setName(request.getName());
user.setEmail(request.getEmail().toLowerCase());
user.setPassword(passwordEncoder.encode(request.getPassword()));
return toDto(userRepository.save(user));
}
private UserDto toDto(User user) {
return new UserDto(user.getId(), user.getName(), user.getEmail(), user.getCreatedAt());
}
}Exception Handling: @ControllerAdvice and @ExceptionHandler
Centralize exception handling with @RestControllerAdvice. This eliminates try-catch blocks in controllers and ensures consistent error response format across the entire API.
package com.example.demo.exception;
// Custom exception classes
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
public class ConflictException extends RuntimeException {
public ConflictException(String message) {
super(message);
}
}
// Error response body
public record ApiError(
int status,
String error,
String message,
String path,
LocalDateTime timestamp
) {}
// Global exception handler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(
ResourceNotFoundException ex, HttpServletRequest request) {
log.warn("Resource not found: {}", ex.getMessage());
ApiError error = new ApiError(
404, "Not Found", ex.getMessage(),
request.getRequestURI(), LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(ConflictException.class)
public ResponseEntity<ApiError> handleConflict(
ConflictException ex, HttpServletRequest request) {
ApiError error = new ApiError(
409, "Conflict", ex.getMessage(),
request.getRequestURI(), LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
// Handle @Valid validation failures
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fieldErrors.put(error.getField(), error.getDefaultMessage())
);
Map<String, Object> body = Map.of(
"status", 400,
"error", "Validation Failed",
"fields", fieldErrors,
"timestamp", LocalDateTime.now()
);
return ResponseEntity.badRequest().body(body);
}
// Catch-all for unexpected errors
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleGeneral(
Exception ex, HttpServletRequest request) {
log.error("Unhandled exception", ex);
ApiError error = new ApiError(
500, "Internal Server Error", "An unexpected error occurred",
request.getRequestURI(), LocalDateTime.now()
);
return ResponseEntity.internalServerError().body(error);
}
}Spring Security: JWT Authentication
Spring Security 6 (Spring Boot 3) uses a lambda-based SecurityFilterChain configuration. The older WebSecurityConfigurerAdapter is removed. Here is a complete JWT setup:
JWT Utility Class
// Add to pom.xml: io.jsonwebtoken:jjwt-api:0.12.3 + jjwt-impl + jjwt-jackson
@Component
public class JwtUtil {
@Value("${spring.security.jwt.secret}")
private String secretKey;
@Value("${spring.security.jwt.expiration}")
private long expiration;
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload()
.getExpiration()
.before(new Date());
}
}JWT Authentication Filter
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7); // remove "Bearer "
final String username = jwtUtil.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}Security Configuration
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // stateless API — no CSRF needed
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
// Auth Controller — login and register
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.login(request));
}
}Spring Boot Actuator and Monitoring
Spring Boot Actuator provides production-ready endpoints for health checks, metrics, environment info, and more. It integrates natively with Prometheus and Micrometer for cloud-native observability.
# application.yml — Actuator configuration
management:
endpoints:
web:
base-path: /actuator
exposure:
include: health,info,metrics,prometheus,env,loggers,threaddump
endpoint:
health:
show-details: when-authorized
show-components: always
info:
env:
enabled: true
git:
mode: full
info:
app:
name: ${spring.application.name}
version: "@project.version@"
java-version: "@java.version@"# Available Actuator endpoints
GET /actuator/health # {"status":"UP","components":{...}}
GET /actuator/info # application info, git info
GET /actuator/metrics # list available metrics
GET /actuator/metrics/jvm.memory.used # specific metric
GET /actuator/prometheus # Prometheus-format metrics
GET /actuator/env # environment properties
GET /actuator/loggers # current log levels
POST /actuator/loggers/com.example # change log level at runtime
GET /actuator/threaddump # current thread dump// Custom health indicator
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
public DatabaseHealthIndicator(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Health health() {
try (Connection conn = dataSource.getConnection()) {
return Health.up()
.withDetail("database", conn.getMetaData().getDatabaseProductName())
.withDetail("url", conn.getMetaData().getURL())
.build();
} catch (SQLException e) {
return Health.down().withException(e).build();
}
}
}
// Custom metrics with Micrometer
@Service
@RequiredArgsConstructor
public class OrderService {
private final MeterRegistry meterRegistry;
private final Counter orderCreatedCounter;
public OrderService(MeterRegistry registry) {
this.meterRegistry = registry;
this.orderCreatedCounter = Counter.builder("orders.created")
.description("Total orders created")
.register(registry);
}
public Order createOrder(CreateOrderRequest request) {
Order order = processOrder(request);
orderCreatedCounter.increment();
meterRegistry.gauge("orders.pending", getPendingOrderCount());
return order;
}
}Testing with JUnit 5 and MockMvc
Spring Boot provides excellent testing support out of the box. Use @SpringBootTest for integration tests and @WebMvcTest for controller slice tests with MockMvc.
Unit Testing with Mockito
// Pure unit test — no Spring context, fast
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private UserService userService;
@Test
void createUser_Success() {
// Arrange
CreateUserRequest request = new CreateUserRequest("Alice", "alice@example.com", "password123");
User savedUser = new User(1L, "Alice", "alice@example.com", "hashed");
when(userRepository.existsByEmail(anyString())).thenReturn(false);
when(passwordEncoder.encode(anyString())).thenReturn("hashed");
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// Act
UserDto result = userService.create(request);
// Assert
assertThat(result.name()).isEqualTo("Alice");
assertThat(result.email()).isEqualTo("alice@example.com");
verify(userRepository).save(any(User.class));
}
@Test
void createUser_DuplicateEmail_ThrowsConflict() {
// Arrange
CreateUserRequest request = new CreateUserRequest("Alice", "alice@example.com", "password123");
when(userRepository.existsByEmail("alice@example.com")).thenReturn(true);
// Act + Assert
assertThatThrownBy(() -> userService.create(request))
.isInstanceOf(ConflictException.class)
.hasMessageContaining("alice@example.com");
}
}Controller Tests with MockMvc
// Slice test — only loads web layer (fast)
@WebMvcTest(UserController.class)
@AutoConfigureMockMvc(addFilters = false) // disable security for unit tests
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private UserService userService;
@Test
void getUser_ExistingId_Returns200() throws Exception {
UserDto user = new UserDto(1L, "Alice", "alice@example.com", LocalDateTime.now());
when(userService.findById(1L)).thenReturn(user);
mockMvc.perform(get("/api/v1/users/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Alice"))
.andExpect(jsonPath("$.email").value("alice@example.com"));
}
@Test
void getUser_NotFound_Returns404() throws Exception {
when(userService.findById(999L))
.thenThrow(new ResourceNotFoundException("User not found: 999"));
mockMvc.perform(get("/api/v1/users/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404));
}
@Test
void createUser_ValidRequest_Returns201() throws Exception {
CreateUserRequest request = new CreateUserRequest("Bob", "bob@example.com", "password123");
UserDto created = new UserDto(2L, "Bob", "bob@example.com", LocalDateTime.now());
when(userService.create(any())).thenReturn(created);
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(2L));
}
@Test
void createUser_InvalidEmail_Returns400() throws Exception {
CreateUserRequest request = new CreateUserRequest("Bob", "not-an-email", "pass");
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.fields.email").exists());
}
}Integration Tests with @SpringBootTest
// Full Spring context + embedded H2 database
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
@Transactional // rollback after each test
class UserIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@Test
void createAndFetchUser_FullFlow() {
// Create
CreateUserRequest request = new CreateUserRequest("Carol", "carol@test.com", "password123");
ResponseEntity<UserDto> createResponse = restTemplate.postForEntity(
"/api/v1/users", request, UserDto.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Long userId = createResponse.getBody().id();
// Fetch
ResponseEntity<UserDto> getResponse = restTemplate.getForEntity(
"/api/v1/users/" + userId, UserDto.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().email()).isEqualTo("carol@test.com");
}
}
// Run tests
// ./mvnw test — run all tests
// ./mvnw test -pl :users-service — run specific module
// ./mvnw verify — run tests + integration tests
// ./mvnw test -Dtest=UserServiceTest — run specific test classDocker Deployment with Spring Boot
Spring Boot applications package as executable fat JARs, making Docker deployment straightforward. Spring Boot 3.1+ also supports buildpacks natively for creating optimized OCI images without a Dockerfile.
Multi-stage Dockerfile
# Multi-stage build: compile in full JDK, run in slim JRE
# Stage 1: Build
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
# Cache Maven dependencies separately
COPY pom.xml .
RUN mvn dependency:go-offline -q
# Build the application
COPY src ./src
RUN mvn package -DskipTests -q
# Stage 2: Extract layers for better caching (Spring Boot layered JARs)
FROM eclipse-temurin:21-jre AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# Stage 3: Final runtime image
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
# Copy extracted layers (most stable first for layer caching)
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
# Non-root user for security
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseContainerSupport", "-XX:MaxRAMPercentage=75.0", "org.springframework.boot.loader.launch.JarLauncher"]Docker Compose for Local Development
# docker-compose.yml
version: "3.9"
services:
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: docker
DATABASE_URL: jdbc:postgresql://db:5432/mydb
DB_USERNAME: postgres
DB_PASSWORD: secret
JWT_SECRET: my-docker-jwt-secret-change-in-prod
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: mydb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres-data:# Build image without Dockerfile (Spring Boot Buildpacks)
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myapp:latest
# Run with Docker Compose
docker compose up -d
# View logs
docker compose logs -f app
# Scale the app service
docker compose up -d --scale app=3Spring Boot vs NestJS vs Express: Framework Comparison
Choosing between Spring Boot, NestJS, and Express depends on your team, language preference, and application requirements. Here is a comprehensive side-by-side comparison:
| Feature | Spring Boot | NestJS | Express.js |
|---|---|---|---|
| Language | Java / Kotlin | TypeScript / JavaScript | JavaScript / TypeScript |
| Architecture | Opinionated, annotation-driven | Opinionated, decorator-driven | Minimal, unopinionated |
| Performance | High (JVM JIT); slow startup without GraalVM | Good (Node.js event loop) | Fast startup, high RPS for I/O |
| DI Container | Built-in (mature, powerful) | Built-in (inspired by Angular) | None (use InversifyJS manually) |
| Database ORM | Spring Data JPA / Hibernate | TypeORM, Prisma, Mongoose | Prisma, Sequelize, Knex |
| Auth | Spring Security (JWT, OAuth2) | @nestjs/passport + JWT | Passport.js, custom middleware |
| Testing | JUnit 5, MockMvc, Mockito | Jest, Supertest | Jest, Mocha, Supertest |
| Learning Curve | Steep (Java + Spring ecosystem) | Moderate (TypeScript + decorators) | Low (minimal abstractions) |
| Enterprise Adoption | Very High (banking, fintech, large corps) | Growing (startups to mid-size) | High (microservices, BFFs) |
| Microservices | Spring Cloud ecosystem (Eureka, Gateway, Config) | Built-in microservice transports | Manual setup with libraries |
| Best For | Enterprise apps, Java teams, complex domains | TypeScript full-stack teams, structured APIs | Simple APIs, prototypes, microservices |
Spring Boot Profiles and Environment-Specific Configuration
Spring Profiles allow beans and configuration to be activated conditionally based on the active environment. This is essential for managing dev, staging, and production configurations.
// @Profile — bean only active in specific profiles
@Configuration
@Profile("dev")
public class DevDataInitializer implements CommandLineRunner {
private final UserRepository userRepository;
@Override
public void run(String... args) {
// Seed test data in development only
if (userRepository.count() == 0) {
userRepository.save(new User("Admin", "admin@dev.local", "admin123", UserRole.ADMIN));
}
}
}
// @ConditionalOnProperty — activate based on property value
@Bean
@ConditionalOnProperty(name = "feature.email-notifications", havingValue = "true")
public EmailNotificationService emailNotificationService() {
return new SmtpEmailService();
}
@Bean
@ConditionalOnMissingBean(EmailNotificationService.class)
public EmailNotificationService noopEmailService() {
return new NoopEmailService(); // fallback when email disabled
}# Activate profiles
# Option 1: application.yml
spring.profiles.active: prod
# Option 2: Environment variable (recommended for Docker/K8s)
SPRING_PROFILES_ACTIVE=prod java -jar app.jar
# Option 3: Command line argument
java -jar app.jar --spring.profiles.active=prod
# Option 4: Multiple profiles
java -jar app.jar --spring.profiles.active=prod,awsSummary and Next Steps
Spring Boot remains the dominant Java backend framework in 2026, powering applications from simple REST APIs to complex distributed systems with Spring Cloud. Its combination of convention-over-configuration, a mature ecosystem, battle-tested security model, and first-class testing support make it the go-to choice for enterprise Java development.
From here, explore the Spring ecosystem further: Spring Cloud Gateway for API gateways, Spring Batch for bulk data processing, Spring WebFlux for reactive applications with Project Reactor, and Spring Cloud for microservices patterns like service discovery (Eureka), distributed configuration (Config Server), and circuit breakers (Resilience4j).