Spring Boot is the most popular Java framework for building web applications and REST APIs. It powers applications at Netflix, Amazon, LinkedIn, and thousands of enterprises worldwide. Spring Boot removes the complexity of traditional Spring configuration and lets you build production-ready applications in minutes. This tutorial takes you from zero to a real Spring Boot application, step by step.
What you'll learn
| Topic | What you'll be able to do |
|---|---|
| Setup | Create a Spring Boot project and run it locally |
| REST APIs | Build GET, POST, PUT, DELETE endpoints |
| Spring Data JPA | Connect to a database and perform CRUD operations |
| Validation | Validate request data with annotations |
| Exception Handling | Return proper error responses |
| Spring Security | Add authentication to your API |
| Testing | Write unit and integration tests |
| Deployment | Package and run your app as a JAR |
Why Spring Boot?
Traditional Spring required XML configuration files and extensive setup. Spring Boot eliminates that with auto-configuration and convention over configuration:
| Feature | Traditional Spring | Spring Boot |
|---|---|---|
| Configuration | XML/Java config, many files | Auto-configured, minimal config |
| Server | Deploy WAR to Tomcat | Embedded Tomcat (runs as JAR) |
| Dependencies | Manual version management | Starter POMs with managed versions |
| Startup | Complex setup | @SpringBootApplication + main() |
| Database setup | Manual DataSource config | Auto-configured from properties |
| Production features | Manual setup | Actuator (health, metrics, info) |
| Learning curve | Steep | Gradual |
Setup
Prerequisites
You need:
- Java 17 or later (Java 21 recommended)
- Maven or Gradle build tool
- An IDE: IntelliJ IDEA (recommended), Eclipse, or VS Code
- Basic Java knowledge (classes, interfaces, annotations)
Check your Java version:
java -version
# Should print: openjdk version "17.0.x" or "21.0.x"
Create your first Spring Boot project
Option 1: Spring Initializr (easiest)
Go to start.spring.io and configure:
| Field | Value |
|---|---|
| Project | Maven |
| Language | Java |
| Spring Boot | 3.3.x (latest stable) |
| Group | com.example |
| Artifact | myapp |
| Packaging | Jar |
| Java | 21 |
Add these dependencies:
- Spring Web — REST APIs
- Spring Data JPA — database access
- H2 Database — in-memory database (for learning)
Click Generate, download the ZIP, unzip it, and open in your IDE.
Option 2: Spring Boot CLI
# Install Spring Boot CLI via SDKMAN
curl -s "https://get.sdkman.io" | bash
sdk install springboot
# Create project
spring init --dependencies=web,data-jpa,h2 --name=myapp myapp.zip
unzip myapp.zip -d myapp
cd myapp
Project structure
myapp/
├── src/
│ ├── main/
│ │ ├── java/com/example/myapp/
│ │ │ └── MyappApplication.java ← entry point
│ │ └── resources/
│ │ ├── application.properties ← configuration
│ │ └── static/ ← static files
│ └── test/
│ └── java/com/example/myapp/
│ └── MyappApplicationTests.java
├── pom.xml ← Maven dependencies
└── mvnw ← Maven wrapper
Run the application
./mvnw spring-boot:run
# Or on Windows:
mvnw.cmd spring-boot:run
You'll see:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
...
Started MyappApplication in 1.823 seconds
Your app is running on http://localhost:8080.
Your first REST endpoint
The main class
Spring Boot generates this entry point:
package com.example.myapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication // enables auto-configuration + component scan
public class MyappApplication {
public static void main(String[] args) {
SpringApplication.run(MyappApplication.class, args);
}
}
@SpringBootApplication is a shortcut for three annotations:
@Configuration— marks this as a configuration class@EnableAutoConfiguration— enables Spring Boot auto-configuration@ComponentScan— scans for components in the package
Create a controller
Create HelloController.java:
package com.example.myapp;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // combines @Controller + @ResponseBody
@RequestMapping("/api") // base path for all endpoints
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello, Spring Boot!";
}
}
Run the app and test:
curl http://localhost:8080/api/hello
# Output: Hello, Spring Boot!
Common annotations
| Annotation | Purpose |
|---|---|
@RestController |
Marks class as REST controller (returns JSON by default) |
@Controller |
Marks class as MVC controller (returns view names) |
@GetMapping |
Handles HTTP GET requests |
@PostMapping |
Handles HTTP POST requests |
@PutMapping |
Handles HTTP PUT requests |
@DeleteMapping |
Handles HTTP DELETE requests |
@RequestMapping |
General-purpose mapping (all HTTP methods) |
@PathVariable |
Binds URL path segment to parameter |
@RequestParam |
Binds query parameter to method parameter |
@RequestBody |
Binds request body (JSON) to object |
@ResponseBody |
Writes return value directly to response |
Building a REST API
Let's build a complete CRUD API for managing books.
The model (entity)
package com.example.myapp.model;
public class Book {
private Long id;
private String title;
private String author;
private int year;
// Constructor
public Book(Long id, String title, String author, int year) {
this.id = id;
this.title = title;
this.author = author;
this.year = year;
}
// 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 getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public int getYear() { return year; }
public void setYear(int year) { this.year = year; }
}
The service layer
package com.example.myapp.service;
import com.example.myapp.model.Book;
import org.springframework.stereotype.Service;
import java.util.*;
@Service // marks as service bean — Spring manages the lifecycle
public class BookService {
private final Map<Long, Book> books = new HashMap<>();
private long nextId = 1;
public BookService() {
// Seed some data
books.put(1L, new Book(1L, "Clean Code", "Robert Martin", 2008));
books.put(2L, new Book(2L, "The Pragmatic Programmer", "David Thomas", 1999));
nextId = 3;
}
public List<Book> findAll() {
return new ArrayList<>(books.values());
}
public Optional<Book> findById(Long id) {
return Optional.ofNullable(books.get(id));
}
public Book create(Book book) {
book.setId(nextId++);
books.put(book.getId(), book);
return book;
}
public Optional<Book> update(Long id, Book updated) {
if (!books.containsKey(id)) return Optional.empty();
updated.setId(id);
books.put(id, updated);
return Optional.of(updated);
}
public boolean delete(Long id) {
return books.remove(id) != null;
}
}
The controller
package com.example.myapp.controller;
import com.example.myapp.model.Book;
import com.example.myapp.service.BookService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
// Constructor injection (preferred over @Autowired)
public BookController(BookService bookService) {
this.bookService = bookService;
}
// GET /api/books
@GetMapping
public List<Book> getAll() {
return bookService.findAll();
}
// GET /api/books/1
@GetMapping("/{id}")
public ResponseEntity<Book> getById(@PathVariable Long id) {
return bookService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// POST /api/books
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Book create(@RequestBody Book book) {
return bookService.create(book);
}
// PUT /api/books/1
@PutMapping("/{id}")
public ResponseEntity<Book> update(@PathVariable Long id, @RequestBody Book book) {
return bookService.update(id, book)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// DELETE /api/books/1
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (bookService.delete(id)) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
}
Test with curl
# Get all books
curl http://localhost:8080/api/books
# Get book by ID
curl http://localhost:8080/api/books/1
# Create a book
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Effective Java","author":"Joshua Bloch","year":2018}'
# Update a book
curl -X PUT http://localhost:8080/api/books/1 \
-H "Content-Type: application/json" \
-d '{"title":"Clean Code 2nd Ed","author":"Robert Martin","year":2024}'
# Delete a book
curl -X DELETE http://localhost:8080/api/books/1
Spring Data JPA
So far we've used an in-memory Map. Now let's use a real database with Spring Data JPA.
Add dependencies
In pom.xml (already included if you selected them at start.spring.io):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
For production, replace H2 with MySQL or PostgreSQL:
<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
Configure the database
In src/main/resources/application.properties:
# H2 in-memory database (for development)
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA settings
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop # auto-create tables
spring.jpa.show-sql=true # log SQL queries
# H2 console (visit http://localhost:8080/h2-console)
spring.h2.console.enabled=true
For MySQL/PostgreSQL:
spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=root
spring.datasource.password=yourpassword
spring.jpa.hibernate.ddl-auto=update
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
The JPA entity
package com.example.myapp.model;
import jakarta.persistence.*;
@Entity // maps class to a database table
@Table(name = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // auto-increment
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String author;
private int year;
// Default constructor required by JPA
public Book() {}
public Book(String title, String author, int year) {
this.title = title;
this.author = author;
this.year = year;
}
// Getters and setters
public Long getId() { return id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public int getYear() { return year; }
public void setYear(int year) { this.year = year; }
}
The repository
Spring Data JPA generates all SQL automatically:
package com.example.myapp.repository;
import com.example.myapp.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
// JpaRepository provides: findAll, findById, save, deleteById, count, etc.
// Spring generates SQL from method names automatically:
List<Book> findByAuthor(String author);
List<Book> findByYearGreaterThan(int year);
List<Book> findByTitleContainingIgnoreCase(String keyword);
}
Method naming convention
| Method name | Generated SQL |
|---|---|
findByTitle(String title) |
WHERE title = ? |
findByAuthorAndYear(String author, int year) |
WHERE author = ? AND year = ? |
findByYearBetween(int start, int end) |
WHERE year BETWEEN ? AND ? |
findByTitleContaining(String text) |
WHERE title LIKE %?% |
findByYearGreaterThan(int year) |
WHERE year > ? |
findByTitleOrderByAuthorAsc(String title) |
WHERE title = ? ORDER BY author ASC |
countByAuthor(String author) |
SELECT COUNT(*) WHERE author = ? |
deleteByAuthor(String author) |
DELETE WHERE author = ? |
Custom queries with @Query
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
// JPQL (object-oriented SQL)
@Query("SELECT b FROM Book b WHERE b.year >= :year ORDER BY b.title")
List<Book> findBooksFromYear(@Param("year") int year);
// Native SQL
@Query(value = "SELECT * FROM books WHERE title ILIKE %:keyword%", nativeQuery = true)
List<Book> searchByTitleNative(@Param("keyword") String keyword);
Updated service with JPA
package com.example.myapp.service;
import com.example.myapp.model.Book;
import com.example.myapp.repository.BookRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Transactional // all methods wrapped in a transaction by default
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
public List<Book> findAll() {
return bookRepository.findAll();
}
public Optional<Book> findById(Long id) {
return bookRepository.findById(id);
}
public Book create(Book book) {
return bookRepository.save(book);
}
public Optional<Book> update(Long id, Book updated) {
return bookRepository.findById(id).map(existing -> {
existing.setTitle(updated.getTitle());
existing.setAuthor(updated.getAuthor());
existing.setYear(updated.getYear());
return bookRepository.save(existing);
});
}
public boolean delete(Long id) {
if (!bookRepository.existsById(id)) return false;
bookRepository.deleteById(id);
return true;
}
public List<Book> findByAuthor(String author) {
return bookRepository.findByAuthor(author);
}
}
Validation
Add the dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Annotate your model
import jakarta.validation.constraints.*;
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Title is required")
@Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters")
private String title;
@NotBlank(message = "Author is required")
private String author;
@Min(value = 1000, message = "Year must be at least 1000")
@Max(value = 2100, message = "Year must be at most 2100")
private int year;
// ... constructors, getters, setters
}
Enable validation in controller
import jakarta.validation.Valid;
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Book create(@Valid @RequestBody Book book) { // @Valid triggers validation
return bookService.create(book);
}
Common validation annotations
| Annotation | Description |
|---|---|
@NotNull |
Field must not be null |
@NotBlank |
String must not be null or blank (whitespace) |
@NotEmpty |
String/collection must not be null or empty |
@Size(min, max) |
String length or collection size within range |
@Min(value) |
Numeric minimum value |
@Max(value) |
Numeric maximum value |
@Email |
Must be a valid email format |
@Pattern(regexp) |
Must match regex |
@Positive |
Must be positive number |
@Future |
Date must be in the future |
@Past |
Date must be in the past |
Handle validation errors
package com.example.myapp.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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 java.util.HashMap;
import java.util.Map;
@RestControllerAdvice // handles exceptions globally across all controllers
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String field = ((FieldError) error).getField();
String message = error.getDefaultMessage();
errors.put(field, message);
});
return ResponseEntity.badRequest().body(errors);
}
}
Now invalid requests return:
{
"title": "Title is required",
"year": "Year must be at least 1000"
}
Exception Handling
Custom exception
package com.example.myapp.exception;
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(Long id) {
super("Book not found with id: " + id);
}
}
Throw from service
public Book findById(Long id) {
return bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
}
Handle in global handler
import org.springframework.web.server.ResponseStatusException;
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<Map<String, String>> handleBookNotFound(BookNotFoundException ex) {
Map<String, String> error = Map.of("error", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
// Or use @ResponseStatus on the exception class:
// @ResponseStatus(HttpStatus.NOT_FOUND)
// public class BookNotFoundException extends RuntimeException { ... }
Configuration with application.properties
Spring Boot configuration lives in src/main/resources/application.properties or application.yml:
# Server
server.port=8080
server.servlet.context-path=/api # prefix all endpoints with /api
# Database
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=postgres
spring.datasource.password=secret
# JPA
spring.jpa.hibernate.ddl-auto=update # update (dev) | validate (prod) | none
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=true
# Logging
logging.level.com.example.myapp=DEBUG
logging.level.org.springframework.web=INFO
logging.level.org.hibernate.SQL=DEBUG
# Actuator
management.endpoints.web.exposure.include=health,info,metrics
Custom properties
# application.properties
app.name=My Book API
app.max-books=1000
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class BookService {
@Value("${app.max-books}")
private int maxBooks;
@Value("${app.name}")
private String appName;
}
Typed configuration with @ConfigurationProperties
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private String name;
private int maxBooks;
// Getters and setters required
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getMaxBooks() { return maxBooks; }
public void setMaxBooks(int maxBooks) { this.maxBooks = maxBooks; }
}
Profiles
Use different configuration for dev vs production:
application.properties ← shared config
application-dev.properties ← dev-only config
application-prod.properties ← production config
# application-dev.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.jpa.show-sql=true
logging.level.com.example=DEBUG
# application-prod.properties
spring.datasource.url=jdbc:postgresql://prod-host:5432/mydb
spring.jpa.show-sql=false
logging.level.com.example=WARN
Activate a profile:
# Command line
java -jar myapp.jar --spring.profiles.active=prod
# Environment variable
SPRING_PROFILES_ACTIVE=prod java -jar myapp.jar
# application.properties (default)
spring.profiles.active=dev
Spring Security Basics
Add dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Adding this dependency immediately secures all endpoints with HTTP Basic Auth. The auto-generated password is printed in the console:
Using generated security password: 3e8f9a2c-4b1d-4e2f-8c3d-1a2b3c4d5e6f
Basic security configuration
package com.example.myapp.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/books").permitAll() // public endpoint
.requestMatchers("/api/admin/**").hasRole("ADMIN") // admin only
.anyRequest().authenticated() // all others: login required
)
.httpBasic(httpBasic -> {}) // enable HTTP Basic Auth
.csrf(csrf -> csrf.disable()); // disable CSRF for REST APIs
return http.build();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
var user = User.withUsername("user")
.password(encoder.encode("password"))
.roles("USER")
.build();
var admin = User.withUsername("admin")
.password(encoder.encode("adminpass"))
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Security comparison
| Approach | Best for |
|---|---|
| HTTP Basic | Simple internal APIs, quick prototyping |
| Session-based | Traditional web apps with server-side sessions |
| JWT | Stateless REST APIs, mobile apps, microservices |
| OAuth2 | Third-party login (Google, GitHub) |
| API Key | Machine-to-machine communication |
Testing
Unit tests
package com.example.myapp.service;
import com.example.myapp.exception.BookNotFoundException;
import com.example.myapp.model.Book;
import com.example.myapp.repository.BookRepository;
import org.junit.jupiter.api.BeforeEach;
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 java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BookServiceTest {
@Mock
private BookRepository bookRepository;
@InjectMocks
private BookService bookService;
private Book book;
@BeforeEach
void setUp() {
book = new Book("Clean Code", "Robert Martin", 2008);
book.setId(1L);
}
@Test
void findById_returnsBook_whenExists() {
when(bookRepository.findById(1L)).thenReturn(Optional.of(book));
Book result = bookService.findById(1L);
assertThat(result.getTitle()).isEqualTo("Clean Code");
verify(bookRepository).findById(1L);
}
@Test
void findById_throwsException_whenNotFound() {
when(bookRepository.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> bookService.findById(99L))
.isInstanceOf(BookNotFoundException.class)
.hasMessageContaining("99");
}
@Test
void create_savesAndReturnsBook() {
when(bookRepository.save(book)).thenReturn(book);
Book result = bookService.create(book);
assertThat(result).isEqualTo(book);
verify(bookRepository).save(book);
}
}
Integration tests
package com.example.myapp.controller;
import com.example.myapp.model.Book;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class BookControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void getAll_returnsOk() throws Exception {
mockMvc.perform(get("/api/books"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
}
@Test
void getById_returnsNotFound_whenMissing() throws Exception {
mockMvc.perform(get("/api/books/99999"))
.andExpect(status().isNotFound());
}
@Test
void create_returnsCreated_withValidBook() throws Exception {
Book book = new Book("New Book", "Author", 2025);
mockMvc.perform(post("/api/books")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(book)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("New Book"))
.andExpect(jsonPath("$.id").isNumber());
}
}
Test annotations reference
| Annotation | Purpose |
|---|---|
@SpringBootTest |
Loads full application context |
@WebMvcTest |
Loads only web layer (faster) |
@DataJpaTest |
Loads only JPA layer with in-memory DB |
@MockBean |
Creates a mock Spring bean |
@AutoConfigureMockMvc |
Auto-configures MockMvc |
@Transactional on test |
Rolls back DB changes after each test |
@BeforeEach |
Runs before each test method |
@AfterEach |
Runs after each test method |
Spring Boot Actuator
Actuator exposes built-in endpoints for monitoring:
Enable Actuator
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# Expose endpoints
management.endpoints.web.exposure.include=health,info,metrics,env
management.endpoint.health.show-details=always
management.info.env.enabled=true
Available endpoints
| Endpoint | URL | Description |
|---|---|---|
/actuator/health |
GET /actuator/health |
App health status |
/actuator/info |
GET /actuator/info |
App info (version, etc.) |
/actuator/metrics |
GET /actuator/metrics |
JVM + app metrics |
/actuator/env |
GET /actuator/env |
Environment variables |
/actuator/beans |
GET /actuator/beans |
All Spring beans |
/actuator/mappings |
GET /actuator/mappings |
All request mappings |
/actuator/loggers |
GET /actuator/loggers |
Logger levels |
curl http://localhost:8080/actuator/health
# {"status":"UP","components":{"db":{"status":"UP"},"diskSpace":{"status":"UP"}}}
Packaging and Deployment
Build a JAR
./mvnw clean package -DskipTests
# Creates: target/myapp-0.0.1-SNAPSHOT.jar
Run the JAR
java -jar target/myapp-0.0.1-SNAPSHOT.jar
java -jar target/myapp-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
java -jar target/myapp-0.0.1-SNAPSHOT.jar --server.port=9000
Dockerfile
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY target/myapp-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
docker build -t myapp .
docker run -p 8080:8080 -e SPRING_PROFILES_ACTIVE=prod myapp
Multi-stage Dockerfile (build inside Docker)
# Build stage
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package -DskipTests
# Run stage
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Common Spring Boot patterns
Dependency Injection
Spring manages bean lifecycle and wires dependencies automatically:
// Three ways to inject dependencies — constructor injection is preferred
@Service
public class BookService {
// 1. Constructor injection (recommended — immutable, easy to test)
private final BookRepository repo;
public BookService(BookRepository repo) {
this.repo = repo;
}
// 2. Field injection (convenient, but hard to test)
@Autowired
private BookRepository repo2;
// 3. Setter injection (rarely used)
@Autowired
public void setRepo(BookRepository repo3) { this.repo = repo3; }
}
Lombok (reduce boilerplate)
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
import lombok.*;
@Entity
@Data // @Getter + @Setter + @ToString + @EqualsAndHashCode
@NoArgsConstructor // default constructor
@AllArgsConstructor // constructor with all fields
@Builder // builder pattern
public class Book {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
private int year;
}
// Usage with Builder
Book book = Book.builder()
.title("Clean Code")
.author("Robert Martin")
.year(2008)
.build();
DTOs (Data Transfer Objects)
Avoid exposing JPA entities directly — use DTOs:
// Request DTO (input)
public record CreateBookRequest(
@NotBlank String title,
@NotBlank String author,
@Min(1000) int year
) {}
// Response DTO (output)
public record BookResponse(Long id, String title, String author, int year) {
public static BookResponse from(Book book) {
return new BookResponse(book.getId(), book.getTitle(), book.getAuthor(), book.getYear());
}
}
// Controller using DTOs
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public BookResponse create(@Valid @RequestBody CreateBookRequest request) {
Book book = new Book(request.title(), request.author(), request.year());
return BookResponse.from(bookService.create(book));
}
Spring Boot vs alternatives
| Feature | Spring Boot | Quarkus | Micronaut | Express.js (Node) |
|---|---|---|---|---|
| Language | Java/Kotlin | Java/Kotlin | Java/Kotlin/Groovy | JavaScript |
| Startup time | ~2-3s | ~0.05s | ~0.3s | ~0.1s |
| Memory usage | ~200MB | ~60MB | ~80MB | ~50MB |
| Ecosystem | Huge (Spring) | Growing | Growing | Huge (npm) |
| Learning curve | Moderate | Moderate | Moderate | Easy |
| GraalVM native | Yes (limited) | First-class | First-class | N/A |
| Enterprise use | Dominant | Growing | Growing | Common |
| Community | Largest | Growing | Growing | Largest |
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
Using @Autowired on fields |
Hard to test, hides dependencies | Use constructor injection |
| Exposing JPA entities in API | Leaks internal structure, N+1 queries | Use DTOs |
| Not handling transactions | Data inconsistency on errors | Add @Transactional to service methods |
spring.jpa.hibernate.ddl-auto=create in prod |
Drops and recreates all tables | Use validate or none in prod, use Flyway/Liquibase |
| Storing passwords in plain text | Critical security vulnerability | Always use BCryptPasswordEncoder |
| Fat service classes | Hard to maintain and test | Apply Single Responsibility Principle |
| Catching and swallowing exceptions | Silent failures, impossible to debug | Log and re-throw or use global exception handler |
| Not using profiles | Same config in dev and prod | Use application-dev.properties + application-prod.properties |
What to learn next
| Topic | What it adds |
|---|---|
| Spring Data REST | Auto-generates REST API from repository |
| Spring Cache | @Cacheable — cache method results in Redis/Ehcache |
| Spring Batch | Process large datasets in batches |
| Spring WebFlux | Reactive, non-blocking web framework |
| Flyway / Liquibase | Database migration management |
| MapStruct | Automatic entity↔DTO mapping |
| Spring Cloud | Microservices: service discovery, config server, API gateway |
| Testcontainers | Run real databases in Docker during tests |
Frequently asked questions
Do I need to know Spring before learning Spring Boot?
No. Spring Boot is the recommended entry point. You'll learn Spring concepts (IoC, dependency injection, beans) naturally as you build with Spring Boot.
Spring Boot vs Spring MVC — what's the difference?
Spring MVC is the web framework inside Spring. Spring Boot is a tool that auto-configures Spring MVC (and everything else) for you. Spring Boot uses Spring MVC under the hood.
Which Java version should I use with Spring Boot?
Spring Boot 3.x requires Java 17 minimum. Java 21 (LTS) is recommended for new projects — it's faster, uses less memory, and includes virtual threads.
Is Spring Boot good for microservices?
Yes. Spring Boot is one of the most popular choices for microservices. Add Spring Cloud for service discovery (Eureka), API gateway (Spring Cloud Gateway), and distributed configuration.
How do I deploy Spring Boot to the cloud?
Package as a JAR (./mvnw package) and deploy to AWS Elastic Beanstalk, Google Cloud Run, Azure App Service, Heroku, or Railway. Or containerize with Docker and deploy to Kubernetes.
Spring Boot vs Node.js — which should I learn?
If your team uses Java (especially in enterprise or fintech), Spring Boot. If your team uses JavaScript end-to-end, Node.js. Both are excellent for building REST APIs. See our Node.js vs Python guide for broader context.