
If you’re new to backend development with Java, or looking to modernize your stack, Spring Boot 3 is the best place to start. It combines the power of the Spring Framework with simplified configuration, embedded servers, and production-ready tools — all optimized for Java 17+.
In this comprehensive guide, you’ll learn how to get started with Spring Boot 3, build your first REST API with proper layered architecture, connect to a database, add validation and error handling, and understand the project structure step by step. Whether you’re launching a new project or transitioning from an older Spring version, this is your complete beginner’s guide.
What is Spring Boot?
Spring Boot is a framework built on top of the Spring ecosystem. It reduces boilerplate code and simplifies the configuration of Spring-based applications dramatically.
Key Features
- Embedded Tomcat/Jetty/Undertow server — no WAR deployment needed
- Auto-configuration based on classpath dependencies
- Starter dependencies for rapid development
- Production-ready features — health checks, metrics, externalized configuration
- Built-in support for REST APIs, security, data access, messaging, and more
- GraalVM native image support for blazing fast startup times
Spring Boot 3 Highlights
Spring Boot 3 introduced several modern enhancements:
- Requires Java 17+ (long-term support version)
- Supports GraalVM native images for sub-second startup
- Improved AOT (Ahead-of-Time) compilation
- Better Observability with Micrometer and OpenTelemetry
- Jakarta EE 10 migration (javax.* to jakarta.* namespace)
- Cleaned up deprecated APIs and more modular structure
Prerequisites
Before starting, make sure you have:
- Java 17 or higher installed (
java -versionto verify) - A Java IDE like IntelliJ IDEA, VS Code, or Spring Tool Suite
- Maven or Gradle for dependency management
- Internet access to download dependencies
# Verify Java version
java -version
# Output should show: openjdk version "17.x.x" or higher
# Verify Maven
mvn -version
# Output should show: Apache Maven 3.x.x
Step 1: Create Your Project with Spring Initializr
Head over to start.spring.io and configure your project:
- Project: Maven
- Language: Java
- Spring Boot version: 3.2.x (latest stable)
- Group:
com.example - Artifact:
taskapi - Packaging: Jar
- Java: 17
- Dependencies:
- Spring Web
- Spring Data JPA
- H2 Database (for development)
- Validation
- Spring Boot DevTools
Click Generate, unzip the downloaded file, and open it in your IDE.
pom.xml Dependencies
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>taskapi</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>taskapi</name>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA & Database -->
<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>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- DevTools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Step 2: Project Structure
Organize your project following a clean layered architecture:
src/
├── main/
│ ├── java/
│ │ └── com/example/taskapi/
│ │ ├── TaskapiApplication.java # Main entry point
│ │ ├── controller/
│ │ │ └── TaskController.java # REST endpoints
│ │ ├── service/
│ │ │ ├── TaskService.java # Business logic interface
│ │ │ └── TaskServiceImpl.java # Business logic implementation
│ │ ├── repository/
│ │ │ └── TaskRepository.java # Data access
│ │ ├── model/
│ │ │ └── Task.java # JPA entity
│ │ ├── dto/
│ │ │ ├── TaskRequest.java # Input DTO
│ │ │ └── TaskResponse.java # Output DTO
│ │ └── exception/
│ │ ├── GlobalExceptionHandler.java
│ │ └── ResourceNotFoundException.java
│ └── resources/
│ ├── application.properties
│ └── application-dev.properties
└── test/
└── java/
└── com/example/taskapi/
└── TaskControllerTest.java
Step 3: Create the Entity
// model/Task.java
package com.example.taskapi.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(length = 1000)
private String description;
@Column(nullable = false)
private boolean completed = false;
@Enumerated(EnumType.STRING)
private Priority priority = Priority.MEDIUM;
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public enum Priority {
LOW, MEDIUM, HIGH
}
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// 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 getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public boolean isCompleted() { return completed; }
public void setCompleted(boolean completed) { this.completed = completed; }
public Priority getPriority() { return priority; }
public void setPriority(Priority priority) { this.priority = priority; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}
Step 4: Create DTOs
// dto/TaskRequest.java
package com.example.taskapi.dto;
import com.example.taskapi.model.Task.Priority;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record TaskRequest(
@NotBlank(message = "Title is required")
@Size(min = 3, max = 100, message = "Title must be between 3 and 100 characters")
String title,
@Size(max = 1000, message = "Description cannot exceed 1000 characters")
String description,
Priority priority
) {}
// dto/TaskResponse.java
package com.example.taskapi.dto;
import com.example.taskapi.model.Task;
import com.example.taskapi.model.Task.Priority;
import java.time.LocalDateTime;
public record TaskResponse(
Long id,
String title,
String description,
boolean completed,
Priority priority,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public static TaskResponse fromEntity(Task task) {
return new TaskResponse(
task.getId(),
task.getTitle(),
task.getDescription(),
task.isCompleted(),
task.getPriority(),
task.getCreatedAt(),
task.getUpdatedAt()
);
}
}
Step 5: Create the Repository
// repository/TaskRepository.java
package com.example.taskapi.repository;
import com.example.taskapi.model.Task;
import com.example.taskapi.model.Task.Priority;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface TaskRepository extends JpaRepository<Task, Long> {
// Find by completed status
List<Task> findByCompleted(boolean completed);
// Find by priority
List<Task> findByPriority(Priority priority);
// Find incomplete tasks by priority
List<Task> findByCompletedFalseAndPriority(Priority priority);
// Search by title containing keyword
List<Task> findByTitleContainingIgnoreCase(String keyword);
// Custom query: count incomplete tasks
@Query("SELECT COUNT(t) FROM Task t WHERE t.completed = false")
long countIncompleteTasks();
// Custom query: find high priority incomplete tasks
@Query("SELECT t FROM Task t WHERE t.completed = false AND t.priority = 'HIGH' ORDER BY t.createdAt ASC")
List<Task> findUrgentTasks();
}
Step 6: Create the Service Layer
// service/TaskService.java
package com.example.taskapi.service;
import com.example.taskapi.dto.TaskRequest;
import com.example.taskapi.dto.TaskResponse;
import com.example.taskapi.model.Task.Priority;
import java.util.List;
public interface TaskService {
TaskResponse createTask(TaskRequest request);
TaskResponse getTaskById(Long id);
List<TaskResponse> getAllTasks();
List<TaskResponse> getTasksByStatus(boolean completed);
List<TaskResponse> getTasksByPriority(Priority priority);
List<TaskResponse> searchTasks(String keyword);
TaskResponse updateTask(Long id, TaskRequest request);
TaskResponse toggleComplete(Long id);
void deleteTask(Long id);
long getIncompleteCount();
}
// service/TaskServiceImpl.java
package com.example.taskapi.service;
import com.example.taskapi.dto.TaskRequest;
import com.example.taskapi.dto.TaskResponse;
import com.example.taskapi.exception.ResourceNotFoundException;
import com.example.taskapi.model.Task;
import com.example.taskapi.model.Task.Priority;
import com.example.taskapi.repository.TaskRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class TaskServiceImpl implements TaskService {
private final TaskRepository taskRepository;
public TaskServiceImpl(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@Override
public TaskResponse createTask(TaskRequest request) {
Task task = new Task();
task.setTitle(request.title());
task.setDescription(request.description());
task.setPriority(request.priority() != null ? request.priority() : Priority.MEDIUM);
Task saved = taskRepository.save(task);
return TaskResponse.fromEntity(saved);
}
@Override
@Transactional(readOnly = true)
public TaskResponse getTaskById(Long id) {
Task task = findTaskOrThrow(id);
return TaskResponse.fromEntity(task);
}
@Override
@Transactional(readOnly = true)
public List<TaskResponse> getAllTasks() {
return taskRepository.findAll().stream()
.map(TaskResponse::fromEntity)
.toList();
}
@Override
@Transactional(readOnly = true)
public List<TaskResponse> getTasksByStatus(boolean completed) {
return taskRepository.findByCompleted(completed).stream()
.map(TaskResponse::fromEntity)
.toList();
}
@Override
@Transactional(readOnly = true)
public List<TaskResponse> getTasksByPriority(Priority priority) {
return taskRepository.findByPriority(priority).stream()
.map(TaskResponse::fromEntity)
.toList();
}
@Override
@Transactional(readOnly = true)
public List<TaskResponse> searchTasks(String keyword) {
return taskRepository.findByTitleContainingIgnoreCase(keyword).stream()
.map(TaskResponse::fromEntity)
.toList();
}
@Override
public TaskResponse updateTask(Long id, TaskRequest request) {
Task task = findTaskOrThrow(id);
task.setTitle(request.title());
task.setDescription(request.description());
if (request.priority() != null) {
task.setPriority(request.priority());
}
Task updated = taskRepository.save(task);
return TaskResponse.fromEntity(updated);
}
@Override
public TaskResponse toggleComplete(Long id) {
Task task = findTaskOrThrow(id);
task.setCompleted(!task.isCompleted());
Task updated = taskRepository.save(task);
return TaskResponse.fromEntity(updated);
}
@Override
public void deleteTask(Long id) {
Task task = findTaskOrThrow(id);
taskRepository.delete(task);
}
@Override
@Transactional(readOnly = true)
public long getIncompleteCount() {
return taskRepository.countIncompleteTasks();
}
private Task findTaskOrThrow(Long id) {
return taskRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Task not found with id: " + id));
}
}
Step 7: Create the Controller
// controller/TaskController.java
package com.example.taskapi.controller;
import com.example.taskapi.dto.TaskRequest;
import com.example.taskapi.dto.TaskResponse;
import com.example.taskapi.model.Task.Priority;
import com.example.taskapi.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@PostMapping
public ResponseEntity<TaskResponse> createTask(@Valid @RequestBody TaskRequest request) {
TaskResponse response = taskService.createTask(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{id}")
public ResponseEntity<TaskResponse> getTask(@PathVariable Long id) {
TaskResponse response = taskService.getTaskById(id);
return ResponseEntity.ok(response);
}
@GetMapping
public ResponseEntity<List<TaskResponse>> getAllTasks(
@RequestParam(required = false) Boolean completed,
@RequestParam(required = false) Priority priority,
@RequestParam(required = false) String search) {
List<TaskResponse> tasks;
if (search != null && !search.isBlank()) {
tasks = taskService.searchTasks(search);
} else if (completed != null) {
tasks = taskService.getTasksByStatus(completed);
} else if (priority != null) {
tasks = taskService.getTasksByPriority(priority);
} else {
tasks = taskService.getAllTasks();
}
return ResponseEntity.ok(tasks);
}
@PutMapping("/{id}")
public ResponseEntity<TaskResponse> updateTask(
@PathVariable Long id,
@Valid @RequestBody TaskRequest request) {
TaskResponse response = taskService.updateTask(id, request);
return ResponseEntity.ok(response);
}
@PatchMapping("/{id}/toggle")
public ResponseEntity<TaskResponse> toggleComplete(@PathVariable Long id) {
TaskResponse response = taskService.toggleComplete(id);
return ResponseEntity.ok(response);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
taskService.deleteTask(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getStats() {
long incompleteCount = taskService.getIncompleteCount();
long totalCount = taskService.getAllTasks().size();
return ResponseEntity.ok(Map.of(
"total", totalCount,
"incomplete", incompleteCount,
"completed", totalCount - incompleteCount
));
}
}
Step 8: Exception Handling
// exception/ResourceNotFoundException.java
package com.example.taskapi.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
// exception/GlobalExceptionHandler.java
package com.example.taskapi.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.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNotFound(ResourceNotFoundException ex) {
Map<String, Object> error = new HashMap<>();
error.put("timestamp", LocalDateTime.now());
error.put("status", 404);
error.put("error", "Not Found");
error.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new HashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
fieldErrors.put(error.getField(), error.getDefaultMessage());
}
Map<String, Object> response = new HashMap<>();
response.put("timestamp", LocalDateTime.now());
response.put("status", 400);
response.put("error", "Validation Failed");
response.put("errors", fieldErrors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGeneric(Exception ex) {
Map<String, Object> error = new HashMap<>();
error.put("timestamp", LocalDateTime.now());
error.put("status", 500);
error.put("error", "Internal Server Error");
error.put("message", "An unexpected error occurred");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
Step 9: Configuration
# application.properties
spring.application.name=taskapi
server.port=8080
# H2 Database (Development)
spring.datasource.url=jdbc:h2:mem:taskdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA/Hibernate
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# H2 Console (access at /h2-console)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
# Actuator (optional but recommended)
management.endpoints.web.exposure.include=health,info,metrics
Step 10: Run and Test
# Run the application
./mvnw spring-boot:run
# Or in your IDE, run TaskapiApplication.java
Test with curl
# Create a task
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Spring Boot", "description": "Complete the tutorial", "priority": "HIGH"}'
# Get all tasks
curl http://localhost:8080/api/tasks
# Get task by ID
curl http://localhost:8080/api/tasks/1
# Update a task
curl -X PUT http://localhost:8080/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title": "Master Spring Boot", "description": "Build a real project", "priority": "HIGH"}'
# Toggle completion
curl -X PATCH http://localhost:8080/api/tasks/1/toggle
# Filter by status
curl "http://localhost:8080/api/tasks?completed=false"
# Search tasks
curl "http://localhost:8080/api/tasks?search=spring"
# Get stats
curl http://localhost:8080/api/tasks/stats
# Delete a task
curl -X DELETE http://localhost:8080/api/tasks/1
Common Mistakes to Avoid
1. Using Field Injection
// BAD: Field injection is harder to test
@Autowired
private TaskService taskService;
// GOOD: Constructor injection (preferred)
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
2. Exposing Entities Directly
// BAD: Exposing JPA entities in API responses
@GetMapping("/{id}")
public Task getTask(@PathVariable Long id) {
return taskRepository.findById(id).orElseThrow();
}
// GOOD: Use DTOs to control what's exposed
@GetMapping("/{id}")
public TaskResponse getTask(@PathVariable Long id) {
return taskService.getTaskById(id);
}
3. Missing Validation
// BAD: No validation on input
@PostMapping
public Task create(@RequestBody Task task) { ... }
// GOOD: Validate with @Valid
@PostMapping
public TaskResponse create(@Valid @RequestBody TaskRequest request) { ... }
4. Not Using Transactions
// BAD: No transaction management
public void updateMultiple(List<Task> tasks) {
for (Task task : tasks) {
taskRepository.save(task); // Each save is separate transaction
}
}
// GOOD: Wrap in transaction
@Transactional
public void updateMultiple(List<Task> tasks) {
taskRepository.saveAll(tasks); // All or nothing
}
5. Hardcoding Values
// BAD: Hardcoded values
private static final String DB_URL = "jdbc:h2:mem:testdb";
// GOOD: Use application.properties
@Value("${spring.datasource.url}")
private String dbUrl;
Next Steps
Now that your basic app is working, consider adding:
- Spring Security for authentication and authorization
- PostgreSQL/MySQL for production database
- Unit and integration tests with JUnit 5
- Docker support for containerized deployment
- Swagger/OpenAPI for API documentation
- Actuator endpoints for monitoring
Final Thoughts
Spring Boot 3 is an excellent starting point for building modern, secure, and scalable Java applications. With built-in support for REST APIs, auto-configuration, validation, and easy project setup, it drastically simplifies backend development — whether you’re building a microservice or a full SaaS platform.
The layered architecture shown here (Controller → Service → Repository) scales well as your application grows. Use DTOs to control your API contract, validation to ensure data integrity, and proper exception handling for a great developer experience.
For testing Spring Boot applications with real databases, check out Testing Spring Boot Apps Using Testcontainers. To understand the differences between Spring Boot and the core framework, see Spring Boot vs Spring Framework. For official documentation, visit the Spring Boot Reference Guide.
1 Comment