What is Spring Boot?
Spring Boot is the industry-standard framework for building Java web applications and APIs. It takes Spring — Java's most powerful ecosystem — and removes the boilerplate that made it infamous, letting you ship a production-ready server with almost no configuration.
Spring vs Spring Boot
Spring Framework has existed since 2003. It's powerful but historically required mountains of XML configuration and manual wiring. Spring Boot, introduced in 2014, changed everything.
The core engine. Dependency injection, AOP, data access. Extremely capable but verbose to configure manually.
Opinionated defaults on top of Spring. Auto-configuration, embedded server, starter dependencies. Zero XML required.
Spring Boot detects what's on your classpath and configures it automatically. Add a database driver? It auto-wires a DataSource.
Ships with Tomcat built in. Run your app as a plain Java jar — no separate server install, no deployment wars.
The Three Pillars of Spring Boot
1. Dependency Injection (IoC)
The core idea: instead of classes creating their own dependencies, Spring injects them. This makes code testable, modular, and loosely coupled.
// ❌ Without DI — tightly coupled, hard to test public class OrderService { private EmailService emailService = new EmailService(); // creates own dep } // ✅ With Spring DI — loosely coupled, easily testable @Service public class OrderService { private final EmailService emailService; public OrderService(EmailService emailService) { // Spring injects this this.emailService = emailService; } }
2. The Application Context
Spring maintains a container called the Application Context — a registry of all your beans (managed objects). When your app starts, Spring scans for annotated classes, instantiates them, wires their dependencies, and manages their lifecycle.
Any object managed by Spring's container. You mark a class as a bean using annotations like @Component, @Service, @Repository, or @Controller. Spring creates exactly one instance (by default) and shares it wherever it's needed.
3. Convention Over Configuration
Spring Boot makes sensible assumptions. If you add the Spring Web dependency, it assumes you want a web server and starts Tomcat on port 8080. If you add H2 to the classpath, it creates an in-memory database and connects to it. You override only what you need to.
# Override only what you want — everything else uses defaults server.port=8080 spring.application.name=my-api spring.datasource.url=jdbc:h2:mem:testdb
What You'll Build
By the end of this course, you'll have built a fully functional Task Management REST API — with endpoints to create, read, update, and delete tasks, backed by a real database, with validation, error handling, and tests. This is the exact project type you'd be asked to build in a junior dev interview or onboarding.
Spring Boot appears in the majority of Java backend job postings. It's used at companies from startups to banks to Netflix. Knowing it is essentially table stakes for Java backend roles.
Project Setup & Spring Initializr
Every Spring Boot project starts the same way: at start.spring.io. The Initializr generates a ready-to-run project skeleton with exactly the dependencies you choose — no manual pom.xml wrestling required.
Using Spring Initializr
Navigate to start.spring.io and fill in your project metadata. Here's what a typical API project looks like:
Understanding the Generated pom.xml
Maven's pom.xml is your project's manifest. Spring Boot's parent POM handles version management for you — you add a dependency by name and the parent knows the correct version.
<!-- Spring Boot parent manages ALL dependency versions --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.0</version> </parent> <dependencies> <!-- Spring Web: REST controllers, embedded Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- JPA: database access via Hibernate --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- H2: in-memory database for development --> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies>
The Entry Point
Every Spring Boot app has exactly one main class annotated with @SpringBootApplication. This annotation is a shorthand for three annotations combined:
package com.example.taskapi; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication // = @Configuration + @EnableAutoConfiguration + @ComponentScan public class TaskApiApplication { public static void main(String[] args) { SpringApplication.run(TaskApiApplication.class, args); // This launches the embedded Tomcat, loads the Application Context, // registers all beans, and starts listening for HTTP requests } }
From IntelliJ: click the green ▶ button next to main(). From terminal: mvn spring-boot:run. You'll see Spring's banner, then Started TaskApiApplication in 1.4 seconds. Your server is live at http://localhost:8080.
DevTools: Hot Reload
The spring-boot-devtools dependency enables automatic restart when you change code. Save a file in IntelliJ and Spring restarts in under a second — no manual stop/start cycle during development.
@SpringBootApplication annotation combine?scope: runtime?Application Structure
A well-structured Spring Boot application separates concerns into distinct layers. Understanding this architecture is foundational — it determines where every class lives, what it's responsible for, and how components interact.
The Layered Architecture
Spring Boot applications follow a standard layered pattern. Each layer has a single responsibility and communicates only with the layer directly below it.
Project Directory Structure
src/ ├── main/ │ ├── java/com/example/taskapi/ │ │ ├── TaskApiApplication.java ← entry point │ │ ├── controller/ │ │ │ └── TaskController.java ← REST endpoints │ │ ├── service/ │ │ │ └── TaskService.java ← business logic │ │ ├── repository/ │ │ │ └── TaskRepository.java ← database queries │ │ ├── model/ │ │ │ └── Task.java ← JPA entity │ │ └── exception/ │ │ └── TaskNotFoundException.java ← custom exception │ └── resources/ │ └── application.properties ← config └── test/ └── java/com/example/taskapi/ └── TaskControllerTest.java ← tests
The Task Entity — Your Data Model
The model package holds your JPA entities — classes that map directly to database tables. Let's create the Task entity that the whole API will be built around.
package com.example.taskapi.model; import jakarta.persistence.*; import java.time.LocalDateTime; @Entity // marks this class as a JPA entity (database table) @Table(name = "tasks") // maps to the "tasks" table public class Task { @Id // this field is the primary key @GeneratedValue(strategy = GenerationType.IDENTITY) // auto-increment private Long id; @Column(nullable = false) // NOT NULL constraint in the database private String title; private String description; private boolean completed = false; private LocalDateTime createdAt = LocalDateTime.now(); // Constructors, getters, setters... public Task() {} public Task(String title, String description) { this.title = title; this.description = description; } public Long getId() { return id; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getDescription() { return description; } public void setDescription(String d) { this.description = d; } public boolean isCompleted() { return completed; } public void setCompleted(boolean completed) { this.completed = completed; } public LocalDateTime getCreatedAt() { return createdAt; } }
Hibernate (the JPA implementation Spring Boot uses) needs to instantiate entities using reflection. Always include a public no-argument constructor alongside any custom constructors.
Add the following fields to the Task entity:
- A
priorityfield of typeint(1=low, 2=medium, 3=high) - A
dueDatefield of typeLocalDate - An
updatedAtfield that stores when the task was last modified
Add appropriate getters, setters, and include dueDate in the constructor. Think about which fields should have @Column(nullable = false).
Your First REST Controller
The controller is where HTTP meets your Java code. It maps incoming requests to handler methods, calls your service, and returns responses. Let's build the TaskController from scratch and understand every annotation.
@RestController vs @Controller
Returns view names (HTML templates). Used for server-rendered web apps with Thymeleaf or JSP.
Returns data (JSON/XML) directly. Equivalent to @Controller + @ResponseBody on every method. This is what you want for APIs.
Building the TaskController
package com.example.taskapi.controller; import com.example.taskapi.model.Task; import com.example.taskapi.service.TaskService; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController // marks this as a REST controller bean @RequestMapping("/api/tasks") // base URL prefix for all methods in this class public class TaskController { private final TaskService taskService; // Constructor injection — preferred over @Autowired on field public TaskController(TaskService taskService) { this.taskService = taskService; } // GET /api/tasks → returns all tasks @GetMapping public List<Task> getAllTasks() { return taskService.getAllTasks(); } // GET /api/tasks/42 → returns one task by id @GetMapping("/{id}") public Task getTaskById(@PathVariable Long id) { return taskService.getTaskById(id); } // POST /api/tasks → creates a new task @PostMapping public Task createTask(@RequestBody Task task) { return taskService.createTask(task); } // DELETE /api/tasks/42 → deletes a task @DeleteMapping("/{id}") public void deleteTask(@PathVariable Long id) { taskService.deleteTask(id); } }
Key Annotations Explained
Sets the base URL path for the entire controller. All method mappings are relative to this. Keeps paths DRY — you don't repeat /api/tasks in every method.
Extracts a value from the URL path. @GetMapping("/{id}") with @PathVariable Long id captures the 42 from GET /api/tasks/42 and binds it to the id parameter.
Deserializes the JSON request body into a Java object. Spring uses Jackson automatically. When a client sends {"title":"Buy milk"}, Spring converts it to a Task object before your method even runs.
Testing with curl
# Get all tasks curl http://localhost:8080/api/tasks # Create a task curl -X POST http://localhost:8080/api/tasks \ -H "Content-Type: application/json" \ -d '{"title":"Buy milk","description":"From the corner store"}' # Get task by ID curl http://localhost:8080/api/tasks/1 # Delete a task curl -X DELETE http://localhost:8080/api/tasks/1
While curl works, Postman (free) and IntelliJ's built-in HTTP Client make API testing far more comfortable — especially when you're sending complex JSON request bodies or inspecting response headers.
@Controller and @RestController?@GetMapping("/{id}")?HTTP Methods & Mappings
REST APIs communicate intent through HTTP methods. Choosing the right verb isn't arbitrary — it's a contract with every client consuming your API. Spring Boot provides a dedicated annotation for each.
The HTTP Verb Semantics
Method Annotation URL Purpose Idempotent? ────────────────────────────────────────────────────────────────────────────── GET @GetMapping /tasks List all ✅ Yes GET @GetMapping /tasks/{id} Get one ✅ Yes POST @PostMapping /tasks Create new ❌ No PUT @PutMapping /tasks/{id} Replace entirely ✅ Yes PATCH @PatchMapping /tasks/{id} Partial update ⚠️ Usually DELETE @DeleteMapping /tasks/{id} Delete ✅ Yes
DELETE /tasks/42 called 10 times = task 42 is deleted. Same result each time. POST /tasks called 10 times = 10 new tasks created. That's why POST is not idempotent.
Implementing PUT — Full Update
// PUT /api/tasks/42 — replace all fields @PutMapping("/{id}") public Task updateTask(@PathVariable Long id, @RequestBody Task updatedTask) { return taskService.updateTask(id, updatedTask); } // PATCH /api/tasks/42/complete — toggle completion status only @PatchMapping("/{id}/complete") public Task markComplete(@PathVariable Long id) { return taskService.markComplete(id); } // GET /api/tasks?completed=true — filter with query param @GetMapping public List<Task> getTasks( @RequestParam(required = false) Boolean completed) { if (completed != null) { return taskService.getTasksByStatus(completed); } return taskService.getAllTasks(); }
PUT vs PATCH — Know the Difference
Client sends a complete representation. Any fields not included are set to null/default. Use when the client owns the full resource state.
Client sends only the fields to change. Other fields remain untouched. Use for specific field updates like "mark as completed" or "change title only."
@PathVariable extracts from the URL path: /tasks/42. @RequestParam extracts from the query string: /tasks?completed=true. Path variables identify a resource; query params filter or modify how it's returned.
@RequestParam(required = false) mean?Request Body & Validation
Accepting untrusted input and ensuring it meets your data requirements before it reaches your business logic or database.
Bean Validation with @Valid
Spring Boot integrates with Jakarta Bean Validation. Add constraint annotations to your model, add @Valid to your controller parameter, and Spring automatically validates incoming request bodies — returning a structured 400 error if validation fails.
import jakarta.validation.constraints.*; @Entity public class Task { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank(message = "Title is required") @Size(min = 1, max = 100, message = "Title must be 1-100 characters") private String title; @Size(max = 500, message = "Description cannot exceed 500 characters") private String description; @Min(1) @Max(3) private int priority = 1; @Future(message = "Due date must be in the future") private LocalDate dueDate; // getters/setters... }
@PostMapping public ResponseEntity<Task> createTask(@Valid @RequestBody Task task) { // If task fails validation, Spring returns 400 BEFORE this line runs Task saved = taskService.createTask(task); return ResponseEntity.status(201).body(saved); }
Common Validation Annotations
@NotNull: value cannot be null. @NotBlank: string cannot be null, empty, or whitespace-only.
Validates String length or Collection size. Works with min and/or max bounds.
Validates numeric values. @Min(1) @Max(3) ensures a priority field is between 1 and 3.
@Email validates email format. @Pattern(regexp) validates against a regex.
By default, validation errors return a verbose 400 response. Create a @ControllerAdvice exception handler that catches MethodArgumentNotValidException and returns a clean JSON response like:
{ "errors": { "title": "Title is required", "priority": "must be between 1 and 3" } }- Extract field errors from the
BindingResultand map them by field name
Response Entities & Status Codes
Returning raw objects from controllers is fine for simple cases, but ResponseEntity gives you full control over status codes, headers, and response bodies — which is what professional APIs require.
ResponseEntity — Full HTTP Control
import org.springframework.http.ResponseEntity; import org.springframework.http.HttpStatus; // POST — 201 Created with Location header @PostMapping public ResponseEntity<Task> createTask(@Valid @RequestBody Task task) { Task saved = taskService.createTask(task); return ResponseEntity .status(HttpStatus.CREATED) // 201 .body(saved); } // DELETE — 204 No Content (successful but nothing to return) @DeleteMapping("/{id}") public ResponseEntity<Void> deleteTask(@PathVariable Long id) { taskService.deleteTask(id); return ResponseEntity.noContent().build(); // 204 } // GET — 200 OK or 404 Not Found @GetMapping("/{id}") public ResponseEntity<Task> getTaskById(@PathVariable Long id) { return taskService.findById(id) .map(ResponseEntity::ok) // 200 if found .orElse(ResponseEntity.notFound().build()); // 404 if not }
HTTP Status Code Reference
2xx — Success 200 OK → Successful GET, PUT, PATCH 201 Created → Successful POST (resource created) 204 No Content → Successful DELETE (nothing to return) 4xx — Client Error 400 Bad Request → Invalid input / validation failed 401 Unauthorized → Not authenticated 403 Forbidden → Authenticated but not authorized 404 Not Found → Resource doesn't exist 409 Conflict → Duplicate resource 5xx — Server Error 500 Internal Server Error → Unhandled exception in your code
The Service Layer
The service layer is the heart of your application. Business logic lives here — not in controllers (which handle HTTP) and not in repositories (which handle data). This separation is what makes code testable and maintainable.
Why a Separate Service Layer?
Putting business logic directly in controllers couples your rules to the HTTP layer. If you later add a scheduled job, a CLI command, or a second controller that needs the same logic, you'd duplicate code. Services decouple logic from delivery mechanism.
package com.example.taskapi.service; import com.example.taskapi.exception.TaskNotFoundException; import com.example.taskapi.model.Task; import com.example.taskapi.repository.TaskRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service // marks as a Spring bean; also conveys intent public class TaskService { private final TaskRepository taskRepository; public TaskService(TaskRepository taskRepository) { this.taskRepository = taskRepository; } public List<Task> getAllTasks() { return taskRepository.findAll(); } public Task getTaskById(Long id) { return taskRepository.findById(id) .orElseThrow(() -> new TaskNotFoundException(id)); } @Transactional // wraps entire method in a DB transaction public Task createTask(Task task) { // Business rule: trim whitespace from titles task.setTitle(task.getTitle().trim()); return taskRepository.save(task); } @Transactional public Task updateTask(Long id, Task updatedTask) { Task existing = getTaskById(id); // throws 404 if not found existing.setTitle(updatedTask.getTitle()); existing.setDescription(updatedTask.getDescription()); existing.setPriority(updatedTask.getPriority()); return taskRepository.save(existing); } @Transactional public Task markComplete(Long id) { Task task = getTaskById(id); task.setCompleted(true); return taskRepository.save(task); } @Transactional public void deleteTask(Long id) { if (!taskRepository.existsById(id)) { throw new TaskNotFoundException(id); } taskRepository.deleteById(id); } }
Wraps the method in a database transaction. If an exception occurs mid-method, all database changes are rolled back. Essential for operations that modify data. Spring manages the transaction automatically — you just add the annotation.
Spring Data JPA & Repositories
Spring Data JPA is one of Spring Boot's most powerful features. It eliminates virtually all boilerplate database code — you define an interface, Spring generates the implementation at runtime.
JpaRepository — Everything for Free
package com.example.taskapi.repository; import com.example.taskapi.model.Task; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @Repository public interface TaskRepository extends JpaRepository<Task, Long> { // JpaRepository provides these for FREE: // save(T), findById(ID), findAll(), deleteById(ID), existsById(ID), count() // Derived queries — Spring generates SQL from method names: List<Task> findByCompleted(boolean completed); List<Task> findByTitleContainingIgnoreCase(String keyword); List<Task> findByPriorityOrderByCreatedAtDesc(int priority); long countByCompleted(boolean completed); }
Spring reads the method name and generates SQL. findByTitleContainingIgnoreCase becomes SELECT * FROM tasks WHERE LOWER(title) LIKE LOWER('%keyword%'). Keywords include: By, And, Or, Like, Containing, StartingWith, OrderBy, Asc, Desc, Top, First.
Custom JPQL Queries
import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; // JPQL — queries against entity class names, not table names @Query("SELECT t FROM Task t WHERE t.completed = false AND t.priority = :p") List<Task> findPendingByPriority(@Param("p") int priority); // Native SQL when you need DB-specific features @Query(value = "SELECT * FROM tasks WHERE created_at > NOW() - INTERVAL '7 days'", nativeQuery = true) List<Task> findCreatedThisWeek();
JpaRepository<Task, Long> mean?Entity Relationships
Real applications have related data. A task belongs to a user. A user has many tasks. JPA models these relationships with annotations that map directly to foreign keys and join tables in your database.
The Four Relationship Types
Many tasks belong to one user. The "many" side holds the foreign key column. Most common relationship.
One user has many tasks. The inverse of @ManyToOne. Usually the "one" side mapped by the "many" side.
One user profile belongs to one user. The relationship owner holds the foreign key column.
A task can have many tags; a tag can belong to many tasks. Requires a join table.
Implementing User → Task Relationship
@Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank private String username; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List<Task> tasks = new ArrayList<>(); // cascade = ALL: deleting a user deletes their tasks // orphanRemoval: removing a task from the list deletes it from DB }
@ManyToOne(fetch = FetchType.LAZY) // LAZY: don't load User unless accessed @JoinColumn(name = "user_id") // creates user_id foreign key column private User user;
If User has a list of Tasks, and each Task has a User, Jackson will recurse infinitely when serializing. Use @JsonManagedReference / @JsonBackReference, or better yet: use DTO (Data Transfer Objects) to control exactly what gets serialized.
Exception Handling
Unhandled exceptions expose stack traces to clients and return 500 errors for problems that should return 404 or 400. Spring's @ControllerAdvice centralizes exception handling and lets you return clean, structured error responses.
Custom Exception Classes
package com.example.taskapi.exception; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; @ResponseStatus(HttpStatus.NOT_FOUND) // maps this exception to HTTP 404 public class TaskNotFoundException extends RuntimeException { public TaskNotFoundException(Long id) { super("Task not found with id: " + id); } }
Global Exception Handler with @ControllerAdvice
import org.springframework.web.bind.annotation.*; import org.springframework.http.*; import java.time.LocalDateTime; import java.util.*; @RestControllerAdvice // handles exceptions across ALL controllers public class GlobalExceptionHandler { @ExceptionHandler(TaskNotFoundException.class) public ResponseEntity<Map<String, Object>> handleNotFound(TaskNotFoundException ex) { Map<String, Object> body = new LinkedHashMap<>(); body.put("timestamp", LocalDateTime.now()); body.put("status", 404); body.put("error", "Not Found"); body.put("message", ex.getMessage()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<Map<String, Object>> handleValidation( MethodArgumentNotValidException ex) { Map<String, String> errors = new LinkedHashMap<>(); ex.getBindingResult().getFieldErrors() .forEach(e -> errors.put(e.getField(), e.getDefaultMessage())); Map<String, Object> body = new LinkedHashMap<>(); body.put("status", 400); body.put("errors", errors); return ResponseEntity.badRequest().body(body); } }
Testing with Spring Boot Test
A REST API without tests is a liability. Spring Boot Test provides the tools to test your controllers, services, and repositories in isolation and end-to-end — with almost zero configuration.
Three Testing Strategies
Test service logic in isolation. Mock repositories with Mockito. Fast, no Spring context needed.
Test controllers only. Spring loads just the web layer. Mock services. Fast, targeted.
Full Spring context, real database (H2), real HTTP calls. Slow but comprehensive.
Tests repositories with a real embedded database. Only loads JPA components, not web layer.
Testing a Controller with @WebMvcTest
import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(TaskController.class) // load ONLY the web layer for TaskController class TaskControllerTest { @Autowired private MockMvc mockMvc; // simulates HTTP requests without real server @MockBean private TaskService taskService; // replaced with Mockito mock @Test void getAllTasks_returns200WithList() throws Exception { Task task = new Task("Buy milk", "Skimmed"); when(taskService.getAllTasks()).thenReturn(List.of(task)); mockMvc.perform(get("/api/tasks")) .andExpect(status().isOk()) // assert HTTP 200 .andExpect(jsonPath("$[0].title").value("Buy milk")); // assert JSON } @Test void getTaskById_whenNotFound_returns404() throws Exception { when(taskService.getTaskById(99L)) .thenThrow(new TaskNotFoundException(99L)); mockMvc.perform(get("/api/tasks/99")) .andExpect(status().isNotFound()); } }
Service Unit Test with Mockito
import org.junit.jupiter.api.*; import org.mockito.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class TaskServiceTest { @Mock private TaskRepository taskRepository; @InjectMocks private TaskService taskService; @Test void getTaskById_whenExists_returnsTask() { Task task = new Task("Read a book", "Clean Code"); when(taskRepository.findById(1L)).thenReturn(Optional.of(task)); Task result = taskService.getTaskById(1L); assertThat(result.getTitle()).isEqualTo("Read a book"); verify(taskRepository).findById(1L); // verify the repo was called } @Test void getTaskById_whenNotExists_throwsException() { when(taskRepository.findById(999L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> taskService.getTaskById(999L)) .isInstanceOf(TaskNotFoundException.class); } }
You now have all the pieces. Build the complete Task Management API:
- All CRUD endpoints with proper HTTP verbs and status codes
- Request validation with meaningful error messages
- Global exception handler returning consistent JSON errors
- Unit tests for TaskService (at least 5 test cases)
- @WebMvcTest for TaskController covering success and error paths
- Bonus: Add a User entity, implement the relationship, and add a
GET /api/users/{id}/tasksendpoint
You can now build, structure, validate, and test a production-grade Spring Boot REST API. These are exactly the skills a hiring manager expects from a junior Java developer. Next step: build your portfolio project and push it to GitHub.