spring-boot-course / lesson-01
Module 1 · Lesson 01

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.

🔧
Spring Framework

The core engine. Dependency injection, AOP, data access. Extremely capable but verbose to configure manually.

🚀
Spring Boot

Opinionated defaults on top of Spring. Auto-configuration, embedded server, starter dependencies. Zero XML required.

📦
Auto-Configuration

Spring Boot detects what's on your classpath and configures it automatically. Add a database driver? It auto-wires a DataSource.

Embedded Server

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 vs With Spring DI Java
// ❌ 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.

🔑
Bean

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.

src/main/resources/application.properties properties
# 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.

💡
Why Spring Boot for Junior Devs?

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.

Quiz Check Your Understanding
1. What does Spring Boot's auto-configuration do?
Writes your business logic automatically based on your database schema
Detects what's on the classpath and configures components automatically with sensible defaults
Generates REST endpoints from your entity classes without any code
Compiles Java faster by pre-loading Spring libraries
Auto-configuration examines what libraries are available and applies configuration on your behalf. Add a JPA dependency and datasource properties? Spring Boot configures the entire persistence layer — connection pool, transaction manager, and all.
2. What is a Spring Bean?
A special immutable value object used for configuration
A Java class that must extend the SpringBean abstract class
An object whose lifecycle is managed by Spring's Application Context
Any class annotated with @Bean that runs at application startup
A bean is simply an object that Spring manages. Spring creates it, injects its dependencies, and can call lifecycle hooks on it. Any class annotated with @Component or a stereotype annotation (@Service, @Repository, @Controller) becomes a bean.
Next Lesson → Project Setup & Initializr
Module 1 · Lesson 02

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:

start.spring.io
Maven
Java
3.3.x (latest)
21
com.example
task-api
Spring Web Spring Data JPA H2 Database Spring Boot DevTools Validation

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.

pom.xml (key sections) XML
<!-- 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:

TaskApiApplication.java Java
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
    }
}
ℹ️
Running the Application

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.

Quiz Check Your Understanding
1. What does the @SpringBootApplication annotation combine?
@RestController + @Service + @Repository
@Configuration + @EnableAutoConfiguration + @ComponentScan
@Component + @Autowired + @Transactional
@SpringContext + @BeanFactory + @PropertySource
@SpringBootApplication is a convenience annotation. @Configuration marks the class as a bean definition source. @EnableAutoConfiguration triggers Spring Boot's auto-configuration. @ComponentScan tells Spring where to scan for beans (the current package and all sub-packages).
2. Why does the H2 dependency use scope: runtime?
Because H2 is only available on Java 11 and above at runtime
Because Maven needs to download it separately at runtime
Because it's only needed at runtime, not compile time — your code references JPA abstractions, not H2 directly
Because H2 runs in a separate JVM process
Runtime scope means the dependency is available when the app runs but not on the compile classpath. Your code uses JPA interfaces (EntityManager, Repository) — it never imports H2 classes directly. This is good design: swap H2 for PostgreSQL in production by changing one dependency, zero code changes.
← Previous What is Spring Boot?
Next Lesson → Application Structure
Module 1 · Lesson 03

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.

🌐 Controller Layer Handles HTTP requests. Routes to services. Returns responses. @RestController
⚙️ Service Layer Business logic lives here. Orchestrates repositories. Enforces rules. @Service
📋 Repository Layer Database access only. No business logic. Just queries. @Repository
🗄️ Database H2 (dev) / PostgreSQL / MySQL (production) JPA / Hibernate

Project Directory Structure

task-api/ — full project layout tree
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.

model/Task.java Java
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; }
}
⚠️
JPA Requires a No-Args Constructor

Hibernate (the JPA implementation Spring Boot uses) needs to instantiate entities using reflection. Always include a public no-argument constructor alongside any custom constructors.

🎯 Challenge: Extend the Task Entity

Add the following fields to the Task entity:

  • A priority field of type int (1=low, 2=medium, 3=high)
  • A dueDate field of type LocalDate
  • An updatedAt field 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).

Quiz Check Your Understanding
1. Where should database query logic live in a layered Spring application?
In the Controller, so the HTTP layer has direct database access
In the Entity class, keeping data and queries together
In the Repository layer, which is the only layer responsible for database access
In the Service layer, as it orchestrates all operations including queries
The Repository layer is the single point of contact with the database. This separation means you can swap databases, mock the repository in tests, or optimize queries — all without touching the service or controller. Each layer only talks to the layer directly beneath it.
← Previous Project Setup & Initializr
Next Lesson → Your First REST Controller
Module 2 · Lesson 04

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

🖥️
@Controller

Returns view names (HTML templates). Used for server-rendered web apps with Thymeleaf or JSP.

📡
@RestController

Returns data (JSON/XML) directly. Equivalent to @Controller + @ResponseBody on every method. This is what you want for APIs.

Building the TaskController

controller/TaskController.java Java
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

📌
@RequestMapping("/api/tasks")

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.

📌
@PathVariable

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.

📌
@RequestBody

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

Terminal — manual API testing bash
# 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
💡
Use Postman or IntelliJ HTTP Client

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.

Quiz Check Your Understanding
1. What is the difference between @Controller and @RestController?
@RestController can only handle GET requests
@RestController automatically serializes return values to JSON; @Controller returns view names for template rendering
@RestController is used for microservices; @Controller for monoliths
There is no difference — they are interchangeable aliases
@RestController = @Controller + @ResponseBody applied to every method. The @ResponseBody annotation tells Spring to serialize the return value directly into the HTTP response body as JSON (using Jackson), rather than treating it as a view name to look up.
2. How does @PathVariable work with @GetMapping("/{id}")?
It reads the id from a query string like ?id=42
It reads the id from a request header
It extracts the value from the URL path — the 42 in /api/tasks/42
It reads the id from the JSON request body
The {id} in the mapping is a path template variable. Spring matches it by name to the @PathVariable parameter and performs automatic type conversion — the string "42" from the URL becomes a Long in your method. For query parameters like ?completed=true, you'd use @RequestParam instead.
← Previous Application Structure
Next Lesson → HTTP Methods & Mappings
Module 2 · Lesson 05

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

REST Method ReferenceHTTP
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
ℹ️
Idempotent means: same result regardless of how many times you call it

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

TaskController.java — update methodsJava
// 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

🔄
PUT — Full Replacement

Client sends a complete representation. Any fields not included are set to null/default. Use when the client owns the full resource state.

✏️
PATCH — Partial Update

Client sends only the fields to change. Other fields remain untouched. Use for specific field updates like "mark as completed" or "change title only."

💡
@RequestParam vs @PathVariable

@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.

Quiz Check Your Understanding
1. A user changes only the title of a task. Which HTTP method is most appropriate?
PUT — replace the entire task with a new version
PATCH — update only the title field, leaving everything else untouched
POST — create a new task with the updated title
GET — retrieve the task with the new title in the URL
PATCH is for partial updates. Sending PUT with only the title field would effectively null out the description, completed status, and other fields — because PUT replaces the entire resource. PATCH lets the client say "only change what I'm sending."
2. What does @RequestParam(required = false) mean?
The parameter is ignored if the client sends it
The query parameter is optional — the method still works if the client doesn't include it
The parameter only works with POST requests
Spring will not validate this parameter's type
By default, @RequestParam is required=true and Spring returns 400 Bad Request if it's missing. Setting required=false makes it optional — the parameter will be null if not provided. This lets one endpoint serve both "get all" and "get filtered" behavior.
← Previous Your First REST Controller
Next Lesson → Request Body & Validation
Module 2 · Lesson 06

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.

model/Task.java — with validationJava
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...
}
TaskController.java — @Valid on POSTJava
@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 / @NotBlank

@NotNull: value cannot be null. @NotBlank: string cannot be null, empty, or whitespace-only.

@Size(min, max)

Validates String length or Collection size. Works with min and/or max bounds.

@Min / @Max

Validates numeric values. @Min(1) @Max(3) ensures a priority field is between 1 and 3.

@Email / @Pattern

@Email validates email format. @Pattern(regexp) validates against a regex.

🎯 Challenge: Custom Validation Response

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 BindingResult and map them by field name
Quiz Check Your Understanding
1. What HTTP status code does Spring return when @Valid validation fails?
500 Internal Server Error
400 Bad Request
422 Unprocessable Entity
403 Forbidden
Spring automatically returns 400 Bad Request when @Valid fails. The response body contains detailed information about which fields failed and why. You can customize this response format using @ControllerAdvice to handle MethodArgumentNotValidException.
← PreviousHTTP Methods & Mappings
Next →Response Entities & Status Codes
Module 2 · Lesson 07

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

TaskController.java — ResponseEntity usageJava
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

Status codes you'll use mostHTTP
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
QuizCheck Your Understanding
1. A client successfully creates a resource. What status code should you return?
200 OK
201 Created
204 No Content
202 Accepted
201 Created is the correct response for successful POST requests that create a new resource. It communicates to clients that the creation was successful and is distinct from a general 200 OK. Returning 200 for creation works but is less precise and breaks REST conventions.
← PreviousRequest Body & Validation
Next →The Service Layer
Module 3 · Lesson 08

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?

⚠️
Fat Controllers Are an Anti-Pattern

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.

service/TaskService.javaJava
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);
    }
}
🔑
@Transactional

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.

QuizCheck Your Understanding
1. Where should business rules (e.g., "a completed task cannot be deleted") live?
In the Controller, checked before calling the service
In the Service layer, so any entry point (REST, scheduler, CLI) enforces the rule
In the Repository, as a database constraint
In the Entity class itself, in the setter method
Business rules belong in the service layer. If you put the check in the controller, a future scheduler or admin CLI that bypasses HTTP would skip the rule. The service is the single enforcer of business invariants, regardless of how the operation was triggered.
← PreviousResponse Entities & Status Codes
Next →Spring Data JPA & Repositories
Module 3 · Lesson 09

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

repository/TaskRepository.javaJava
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);
}
ℹ️
Derived Query Method Naming

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

Custom @Query when naming isn't enoughJava
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();
QuizCheck Your Understanding
1. What does JpaRepository<Task, Long> mean?
Task is the database name; Long is the connection timeout in milliseconds
Task is the entity type being managed; Long is the type of its primary key (the @Id field)
Task is the table name; Long is the maximum number of records to return
Task is the entity class; Long is the number of relationships it can have
JpaRepository takes two generic type parameters: the entity class (Task) and the type of its @Id / primary key field (Long). This allows Spring to generate type-safe implementations — findById returns Optional<Task>, save accepts and returns Task, and so on.
← PreviousThe Service Layer
Next →Entity Relationships
Module 3 · Lesson 10

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

@ManyToOne

Many tasks belong to one user. The "many" side holds the foreign key column. Most common relationship.

@OneToMany

One user has many tasks. The inverse of @ManyToOne. Usually the "one" side mapped by the "many" side.

@OneToOne

One user profile belongs to one user. The relationship owner holds the foreign key column.

@ManyToMany

A task can have many tags; a tag can belong to many tasks. Requires a join table.

Implementing User → Task Relationship

model/User.javaJava
@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
}
model/Task.java — add the owning sideJava
@ManyToOne(fetch = FetchType.LAZY)   // LAZY: don't load User unless accessed
@JoinColumn(name = "user_id")        // creates user_id foreign key column
private User user;
⚠️
Avoid Infinite Recursion in JSON Serialization

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.

QuizCheck Your Understanding
1. In a @ManyToOne / @OneToMany relationship, which side owns the foreign key column?
The @ManyToOne (many) side — it holds the @JoinColumn (user_id)
The @OneToMany (one) side — it defines the relationship structure
Neither — JPA creates a separate join table automatically
Both sides share the foreign key through bidirectional mapping
The @ManyToOne side is the "owning" side that holds the foreign key column (user_id in the tasks table). The @OneToMany side uses mappedBy to point to the field on the other side that owns the relationship. This tells JPA not to create a second column or join table.
← PreviousSpring Data JPA & Repositories
Next →Exception Handling
Module 4 · Lesson 11

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

exception/TaskNotFoundException.javaJava
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

exception/GlobalExceptionHandler.javaJava
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);
    }
}
QuizCheck Your Understanding
1. What is the advantage of @ControllerAdvice over handling exceptions in each controller?
It makes error handling faster by caching exception types
It centralizes all error handling in one place — no duplication across controllers, consistent response format
It prevents exceptions from propagating to the JVM
It automatically logs all exceptions to a file
@ControllerAdvice applies globally. Every exception of the handled types — regardless of which controller throws it — gets the same clean response. Without it, you'd either duplicate the try-catch logic in every controller or get unformatted 500 errors.
← PreviousEntity Relationships
Next →Testing with Spring Boot Test
Module 4 · Lesson 12

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

Unit Tests

Test service logic in isolation. Mock repositories with Mockito. Fast, no Spring context needed.

🔬
Slice Tests (@WebMvcTest)

Test controllers only. Spring loads just the web layer. Mock services. Fast, targeted.

🏗️
Integration Tests

Full Spring context, real database (H2), real HTTP calls. Slow but comprehensive.

🔗
@DataJpaTest

Tests repositories with a real embedded database. Only loads JPA components, not web layer.

Testing a Controller with @WebMvcTest

TaskControllerTest.javaJava
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

TaskServiceTest.java — pure unit testJava
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);
    }
}
🏆 Final Challenge: Complete the Task API

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}/tasks endpoint
Final QuizCourse Completion Check
1. What is the key difference between @WebMvcTest and @SpringBootTest?
@SpringBootTest is for unit tests; @WebMvcTest is for integration tests
@WebMvcTest loads only the web layer (faster, for controller tests); @SpringBootTest loads the full application context
@WebMvcTest uses a real HTTP server; @SpringBootTest uses MockMvc
There is no difference — they are interchangeable
@WebMvcTest is a "slice" test — it only loads web-layer components (controllers, filters, converters). Services and repositories are not loaded; you mock them with @MockBean. This makes it fast. @SpringBootTest loads everything — perfect for integration tests that need the full stack.
2. In a unit test, why do we use @Mock instead of creating a real TaskRepository?
Because repositories cannot be instantiated directly in tests
To isolate the unit under test from its dependencies, control its behavior precisely, and avoid needing a database
Because @Mock creates a faster version of the repository with caching
Because Mockito can only mock interfaces, not classes
Mocks let you test the service in complete isolation. You control exactly what the repository returns for each input, without needing a database running. If a test fails, you know the bug is in the service, not the database or repository. This is the core principle of unit testing.
🎓
You've Completed Spring Boot: From Zero to REST API

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.

← PreviousException Handling
Roadmap