Replace the in-memory repository with real database persistence using Spring Data JPA and Hibernate ORM.
Database-backed Task Management API with:
Add to pom.xml:
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2 Database (in-memory for development) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- PostgreSQL Driver (for production) -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
Before diving into code, let's understand Object-Relational Mapping.
Objects (Java) vs Relations (SQL) have fundamental differences:
Java Objects:
public class Task {
private Long id;
private String title;
private TaskStatus status;
private User assignedTo; // Object reference
private List<Category> categories; // Collection
}
SQL Tables:
CREATE TABLE tasks (
id BIGINT PRIMARY KEY,
title VARCHAR(255),
status VARCHAR(20),
user_id BIGINT, -- Foreign key, not object
-- Can't store List directly!
);
Problems without ORM:
ORM automates the mapping between objects and database tables:
// Without ORM - Manual SQL
public Task findById(Long id) {
String sql = "SELECT * FROM tasks WHERE id = ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setLong(1, id);
ResultSet rs = stmt.executeQuery();
Task task = new Task();
if (rs.next()) {
task.setId(rs.getLong("id"));
task.setTitle(rs.getString("title"));
task.setStatus(TaskStatus.valueOf(rs.getString("status")));
// ... more mapping code
}
return task;
}
// With ORM - Automatic
public Task findById(Long id) {
return taskRepository.findById(id).orElse(null);
}
JPA (Java Persistence API) is the standard ORM specification for Java.
Key Players:
Spring Data JPA
↓ (uses)
JPA Specification
↓ (implemented by)
Hibernate ORM
↓ (talks to)
JDBC
↓ (connects to)
Database (H2, PostgreSQL, MySQL)
[Transient] → new Task()
↓ persist()
[Managed] → entityManager tracks changes
↓ commit()
[Detached] → no longer tracked
↓ merge()
[Managed] → back in context
↓ remove()
[Removed] → scheduled for deletion
✅ Less Boilerplate: No manual SQL for CRUD ✅ Database Independence: Switch DB with config change ✅ Type Safety: Compile-time checking ✅ Relationships: Automatic join handling ✅ Caching: First-level cache (EntityManager) ✅ Lazy Loading: Load data only when needed ✅ Query Methods: Derive queries from method names
Set up H2 for development and PostgreSQL for production.
application.yml:
spring:
application:
name: task-manager
# H2 Database
datasource:
url: jdbc:h2:mem:taskdb
driver-class-name: org.h2.Driver
username: sa
password:
# H2 Console
h2:
console:
enabled: true
path: /h2-console
# JPA/Hibernate
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true
# Logging
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
application-prod.yml:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/taskdb
driver-class-name: org.postgresql.Driver
username: postgres
password: your_password
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: validate
show-sql: false
hibernate:
ddl-auto: create-drop
# create-drop: Drop and recreate tables on startup (development)
# create: Create tables on startup (development)
# update: Update existing schema (development)
# validate: Validate schema matches entities (production)
# none: Do nothing (production with Flyway/Liquibase)
Start application and visit: http://localhost:8080/h2-console
Connection settings:
jdbc:h2:mem:taskdbsaTransform our model classes into JPA entities.
package com.example.taskmanager.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "tasks")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String title;
@Column(length = 500)
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TaskStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User assignedTo;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "task_categories",
joinColumns = @JoinColumn(name = "task_id"),
inverseJoinColumns = @JoinColumn(name = "category_id")
)
private Set<Category> categories = new HashSet<>();
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(nullable = false)
private LocalDateTime updatedAt;
// Constructor for basic task creation
public Task(String title, String description) {
this.title = title;
this.description = description;
this.status = TaskStatus.TODO;
}
}
package com.example.taskmanager.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(nullable = false, length = 100)
private String fullName;
@OneToMany(mappedBy = "assignedTo", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Task> tasks = new ArrayList<>();
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
public User(String username, String email, String fullName) {
this.username = username;
this.email = email;
this.fullName = fullName;
}
}
package com.example.taskmanager.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "categories")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String name;
@Column(length = 255)
private String description;
@ManyToMany(mappedBy = "categories")
private Set<Task> tasks = new HashSet<>();
public Category(String name, String description) {
this.name = name;
this.description = description;
}
}
@Entity
// Marks class as JPA entity (table)
@Table(name = "tasks")
// Specifies table name (optional, defaults to class name)
@Id
// Primary key field
@GeneratedValue(strategy = GenerationType.IDENTITY)
// Auto-increment primary key
// IDENTITY: Database auto-increment
// SEQUENCE: Database sequence
// TABLE: Separate table for ID generation
// AUTO: JPA chooses strategy
@Column(nullable = false, length = 100, unique = true)
// Column constraints
// nullable: NOT NULL constraint
// length: VARCHAR length
// unique: UNIQUE constraint
// name: Column name (defaults to field name)
@Enumerated(EnumType.STRING)
// Store enum as string (vs ordinal number)
@CreationTimestamp
// Automatically set on creation (Hibernate-specific)
@UpdateTimestamp
// Automatically update on modification (Hibernate-specific)
// One-to-Many: One user has many tasks
@OneToMany(mappedBy = "assignedTo", cascade = CascadeType.ALL)
private List<Task> tasks;
// Many-to-One: Many tasks belong to one user
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User assignedTo;
// Many-to-Many: Tasks have many categories, categories have many tasks
@ManyToMany
@JoinTable(
name = "task_categories",
joinColumns = @JoinColumn(name = "task_id"),
inverseJoinColumns = @JoinColumn(name = "category_id")
)
private Set<Category> categories;
FetchType.LAZY // Load on demand (default for collections)
FetchType.EAGER // Load immediately (default for single entities)
// Example
@ManyToOne(fetch = FetchType.LAZY) // Don't load user until accessed
private User assignedTo;
@OneToMany(fetch = FetchType.LAZY) // Don't load all tasks immediately
private List<Task> tasks;
CascadeType.ALL // Cascade all operations
CascadeType.PERSIST // Cascade save
CascadeType.MERGE // Cascade update
CascadeType.REMOVE // Cascade delete
CascadeType.REFRESH // Cascade refresh
CascadeType.DETACH // Cascade detach
// Example: Delete user → delete all their tasks
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<Task> tasks;
Replace in-memory repositories with Spring Data JPA repositories.
package com.example.taskmanager.repository;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.model.TaskStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface TaskRepository extends JpaRepository<Task, Long> {
// Derived query methods (Spring Data generates SQL automatically)
List<Task> findByStatus(TaskStatus status);
List<Task> findByAssignedToId(Long userId);
List<Task> findByTitleContainingIgnoreCase(String title);
List<Task> findByStatusAndAssignedToId(TaskStatus status, Long userId);
List<Task> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
Page<Task> findByStatus(TaskStatus status, Pageable pageable);
long countByStatus(TaskStatus status);
boolean existsByTitleAndAssignedToId(String title, Long userId);
// Custom JPQL queries
@Query("SELECT t FROM Task t WHERE t.status = :status AND t.assignedTo.id = :userId")
List<Task> findTasksByStatusAndUser(
@Param("status") TaskStatus status,
@Param("userId") Long userId
);
@Query("SELECT t FROM Task t LEFT JOIN FETCH t.categories WHERE t.id = :id")
Task findByIdWithCategories(@Param("id") Long id);
@Query("SELECT t FROM Task t LEFT JOIN FETCH t.assignedTo WHERE t.status = :status")
List<Task> findByStatusWithUser(@Param("status") TaskStatus status);
// Native SQL query
@Query(value = "SELECT * FROM tasks t WHERE t.created_at > :date", nativeQuery = true)
List<Task> findRecentTasks(@Param("date") LocalDateTime date);
// Aggregation query
@Query("SELECT t.status, COUNT(t) FROM Task t GROUP BY t.status")
List<Object[]> countTasksByStatus();
}
package com.example.taskmanager.repository;
import com.example.taskmanager.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
@Query("SELECT u FROM User u LEFT JOIN FETCH u.tasks WHERE u.id = :id")
Optional<User> findByIdWithTasks(Long id);
}
package com.example.taskmanager.repository;
import com.example.taskmanager.model.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {
Optional<Category> findByName(String name);
boolean existsByName(String name);
}
Spring Data JPA generates SQL from method names:
// Keywords and examples:
findBy... // SELECT
countBy... // COUNT
deleteBy... // DELETE
existsBy... // EXISTS
// Conditions:
And // WHERE ... AND ...
Or // WHERE ... OR ...
Is, Equals // =
Between // BETWEEN
LessThan // <
GreaterThan // >
After, Before // > or <
IsNull, IsNotNull // IS NULL
Like, NotLike // LIKE
StartingWith // LIKE 'value%'
EndingWith // LIKE '%value'
Containing // LIKE '%value%'
IgnoreCase // UPPER(field) = UPPER(value)
OrderBy...Asc // ORDER BY ... ASC
OrderBy...Desc // ORDER BY ... DESC
// Examples:
findByTitle(String title)
// SELECT * FROM tasks WHERE title = ?
findByTitleAndStatus(String title, TaskStatus status)
// SELECT * FROM tasks WHERE title = ? AND status = ?
findByCreatedAtAfter(LocalDateTime date)
// SELECT * FROM tasks WHERE created_at > ?
findByTitleContainingIgnoreCaseOrderByCreatedAtDesc(String title)
// SELECT * FROM tasks WHERE UPPER(title) LIKE UPPER(?) ORDER BY created_at DESC
// Provided by JpaRepository:
save(T entity) // INSERT or UPDATE
saveAll(Iterable<T>) // Batch save
findById(ID id) // SELECT by ID
findAll() // SELECT all
findAllById(Iterable<ID>) // SELECT by IDs
count() // COUNT
existsById(ID id) // EXISTS
deleteById(ID id) // DELETE by ID
delete(T entity) // DELETE
deleteAll() // DELETE all
flush() // Force sync with DB
getOne(ID id) // Get reference (lazy)
Update services to use JPA repositories and handle relationships.
package com.example.taskmanager.service;
import com.example.taskmanager.exception.InvalidTaskException;
import com.example.taskmanager.exception.TaskNotFoundException;
import com.example.taskmanager.model.Category;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.model.TaskStatus;
import com.example.taskmanager.model.User;
import com.example.taskmanager.repository.CategoryRepository;
import com.example.taskmanager.repository.TaskRepository;
import com.example.taskmanager.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
@Service
@Slf4j
@Transactional(readOnly = true)
public class TaskService {
private final TaskRepository taskRepository;
private final UserRepository userRepository;
private final CategoryRepository categoryRepository;
public TaskService(TaskRepository taskRepository,
UserRepository userRepository,
CategoryRepository categoryRepository) {
this.taskRepository = taskRepository;
this.userRepository = userRepository;
this.categoryRepository = categoryRepository;
log.info("TaskService initialized with JPA repositories");
}
@Transactional
public Task createTask(Task task) {
log.debug("Creating new task: {}", task.getTitle());
if (task.getTitle() == null || task.getTitle().isBlank()) {
throw new InvalidTaskException("Task title cannot be empty");
}
if (task.getStatus() == null) {
task.setStatus(TaskStatus.TODO);
}
Task savedTask = taskRepository.save(task);
log.info("Task created with ID: {}", savedTask.getId());
return savedTask;
}
public Task getTaskById(Long id) {
log.debug("Fetching task with ID: {}", id);
return taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException(id));
}
public List<Task> getAllTasks() {
log.debug("Fetching all tasks");
List<Task> tasks = taskRepository.findAll();
log.info("Found {} tasks", tasks.size());
return tasks;
}
public Page<Task> getAllTasks(Pageable pageable) {
log.debug("Fetching tasks page: {}, size: {}",
pageable.getPageNumber(), pageable.getPageSize());
return taskRepository.findAll(pageable);
}
@Transactional
public Task updateTask(Long id, Task taskDetails) {
log.debug("Updating task with ID: {}", id);
Task task = taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException(id));
task.setTitle(taskDetails.getTitle());
task.setDescription(taskDetails.getDescription());
task.setStatus(taskDetails.getStatus());
Task updatedTask = taskRepository.save(task);
log.info("Task updated: {}", id);
return updatedTask;
}
@Transactional
public void deleteTask(Long id) {
log.debug("Deleting task with ID: {}", id);
if (!taskRepository.existsById(id)) {
throw new TaskNotFoundException(id);
}
taskRepository.deleteById(id);
log.info("Task deleted: {}", id);
}
@Transactional
public Task updateTaskStatus(Long id, TaskStatus status) {
log.debug("Updating task {} status to {}", id, status);
Task task = taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException(id));
task.setStatus(status);
return taskRepository.save(task);
}
public List<Task> getTasksByStatus(TaskStatus status) {
log.debug("Fetching tasks with status: {}", status);
return taskRepository.findByStatus(status);
}
public Page<Task> getTasksByStatus(TaskStatus status, Pageable pageable) {
log.debug("Fetching tasks with status: {} (paginated)", status);
return taskRepository.findByStatus(status, pageable);
}
public List<Task> getTasksByUser(Long userId) {
log.debug("Fetching tasks for user: {}", userId);
return taskRepository.findByAssignedToId(userId);
}
public List<Task> searchTasks(String keyword) {
log.debug("Searching tasks with keyword: {}", keyword);
return taskRepository.findByTitleContainingIgnoreCase(keyword);
}
@Transactional
public Task assignTaskToUser(Long taskId, Long userId) {
log.debug("Assigning task {} to user {}", taskId, userId);
Task task = taskRepository.findById(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found with id: " + userId));
task.setAssignedTo(user);
return taskRepository.save(task);
}
@Transactional
public Task addCategoriesToTask(Long taskId, Set<Long> categoryIds) {
log.debug("Adding {} categories to task {}", categoryIds.size(), taskId);
Task task = taskRepository.findById(taskId)
.orElseThrow(() -> new TaskNotFoundException(taskId));
Set<Category> categories = Set.copyOf(categoryRepository.findAllById(categoryIds));
task.getCategories().addAll(categories);
return taskRepository.save(task);
}
public long countTasksByStatus(TaskStatus status) {
return taskRepository.countByStatus(status);
}
public List<Task> getRecentTasks(int days) {
LocalDateTime since = LocalDateTime.now().minusDays(days);
return taskRepository.findByCreatedAtBetween(since, LocalDateTime.now());
}
}
package com.example.taskmanager.service;
import com.example.taskmanager.model.User;
import com.example.taskmanager.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Slf4j
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional
public User createUser(User user) {
log.debug("Creating user: {}", user.getUsername());
if (userRepository.existsByUsername(user.getUsername())) {
throw new RuntimeException("Username already exists: " + user.getUsername());
}
if (userRepository.existsByEmail(user.getEmail())) {
throw new RuntimeException("Email already exists: " + user.getEmail());
}
return userRepository.save(user);
}
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found with id: " + id));
}
public User getUserByUsername(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("User not found with username: " + username));
}
public List<User> getAllUsers() {
return userRepository.findAll();
}
@Transactional
public void deleteUser(Long id) {
if (!userRepository.existsById(id)) {
throw new RuntimeException("User not found with id: " + id);
}
userRepository.deleteById(id);
}
}
package com.example.taskmanager.service;
import com.example.taskmanager.model.Category;
import com.example.taskmanager.repository.CategoryRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Slf4j
@Transactional(readOnly = true)
public class CategoryService {
private final CategoryRepository categoryRepository;
public CategoryService(CategoryRepository categoryRepository) {
this.categoryRepository = categoryRepository;
}
@Transactional
public Category createCategory(Category category) {
log.debug("Creating category: {}", category.getName());
if (categoryRepository.existsByName(category.getName())) {
throw new RuntimeException("Category already exists: " + category.getName());
}
return categoryRepository.save(category);
}
public Category getCategoryById(Long id) {
return categoryRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Category not found with id: " + id));
}
public Category getCategoryByName(String name) {
return categoryRepository.findByName(name)
.orElseThrow(() -> new RuntimeException("Category not found: " + name));
}
public List<Category> getAllCategories() {
return categoryRepository.findAll();
}
}
@Transactional(readOnly = true) // Class level: all methods read-only
public class TaskService {
@Transactional // Override: this method needs write access
public Task createTask(Task task) {
// Will commit changes to database
return taskRepository.save(task);
}
// Inherits readOnly = true from class
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
}
Benefits:
Add controllers for users and categories, update task controller.
package com.example.taskmanager.controller;
import com.example.taskmanager.model.User;
import com.example.taskmanager.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/api/users")
@Tag(name = "User Management")
@Slf4j
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@Operation(summary = "Create a new user")
@PostMapping
public ResponseEntity<User> createUser(@RequestBody User user) {
log.info("POST /api/users - Creating user: {}", user.getUsername());
User createdUser = userService.createUser(user);
URI location = URI.create("/api/users/" + createdUser.getId());
return ResponseEntity.created(location).body(createdUser);
}
@Operation(summary = "Get all users")
@GetMapping
public ResponseEntity<List<User>> getAllUsers() {
log.info("GET /api/users");
return ResponseEntity.ok(userService.getAllUsers());
}
@Operation(summary = "Get user by ID")
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
log.info("GET /api/users/{}", id);
return ResponseEntity.ok(userService.getUserById(id));
}
@Operation(summary = "Get user by username")
@GetMapping("/username/{username}")
public ResponseEntity<User> getUserByUsername(@PathVariable String username) {
log.info("GET /api/users/username/{}", username);
return ResponseEntity.ok(userService.getUserByUsername(username));
}
@Operation(summary = "Delete user")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
log.info("DELETE /api/users/{}", id);
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}
package com.example.taskmanager.controller;
import com.example.taskmanager.model.Category;
import com.example.taskmanager.service.CategoryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/api/categories")
@Tag(name = "Category Management")
@Slf4j
public class CategoryController {
private final CategoryService categoryService;
public CategoryController(CategoryService categoryService) {
this.categoryService = categoryService;
}
@Operation(summary = "Create a new category")
@PostMapping
public ResponseEntity<Category> createCategory(@RequestBody Category category) {
log.info("POST /api/categories - Creating: {}", category.getName());
Category created = categoryService.createCategory(category);
URI location = URI.create("/api/categories/" + created.getId());
return ResponseEntity.created(location).body(created);
}
@Operation(summary = "Get all categories")
@GetMapping
public ResponseEntity<List<Category>> getAllCategories() {
log.info("GET /api/categories");
return ResponseEntity.ok(categoryService.getAllCategories());
}
@Operation(summary = "Get category by ID")
@GetMapping("/{id}")
public ResponseEntity<Category> getCategoryById(@PathVariable Long id) {
log.info("GET /api/categories/{}", id);
return ResponseEntity.ok(categoryService.getCategoryById(id));
}
}
Add new endpoints to TaskController:
@Operation(summary = "Assign task to user")
@PutMapping("/{taskId}/assign/{userId}")
public ResponseEntity<TaskResponse> assignTask(
@PathVariable Long taskId,
@PathVariable Long userId) {
log.info("PUT /api/tasks/{}/assign/{}", taskId, userId);
Task task = taskService.assignTaskToUser(taskId, userId);
return ResponseEntity.ok(new TaskResponse(task));
}
@Operation(summary = "Add categories to task")
@PostMapping("/{taskId}/categories")
public ResponseEntity<TaskResponse> addCategories(
@PathVariable Long taskId,
@RequestBody Set<Long> categoryIds) {
log.info("POST /api/tasks/{}/categories", taskId);
Task task = taskService.addCategoriesToTask(taskId, categoryIds);
return ResponseEntity.ok(new TaskResponse(task));
}
@Operation(summary = "Get tasks by user")
@GetMapping("/user/{userId}")
public ResponseEntity<List<TaskResponse>> getTasksByUser(@PathVariable Long userId) {
log.info("GET /api/tasks/user/{}", userId);
List<TaskResponse> tasks = taskService.getTasksByUser(userId).stream()
.map(TaskResponse::new)
.toList();
return ResponseEntity.ok(tasks);
}
@Operation(summary = "Search tasks")
@GetMapping("/search")
public ResponseEntity<List<TaskResponse>> searchTasks(@RequestParam String keyword) {
log.info("GET /api/tasks/search?keyword={}", keyword);
List<TaskResponse> tasks = taskService.searchTasks(keyword).stream()
.map(TaskResponse::new)
.toList();
return ResponseEntity.ok(tasks);
}
@Operation(summary = "Get task statistics")
@GetMapping("/stats")
public ResponseEntity<Map<TaskStatus, Long>> getTaskStats() {
log.info("GET /api/tasks/stats");
Map<TaskStatus, Long> stats = new HashMap<>();
for (TaskStatus status : TaskStatus.values()) {
stats.put(status, taskService.countTasksByStatus(status));
}
return ResponseEntity.ok(stats);
}
Test the database-backed API.
mvn spring-boot:run
Check logs for Hibernate SQL:
Hibernate: create table tasks (...)
Hibernate: create table users (...)
Hibernate: create table categories (...)
Hibernate: create table task_categories (...)
1. Create User
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"username": "john_doe",
"email": "john@example.com",
"fullName": "John Doe"
}'
2. Create Categories
curl -X POST http://localhost:8080/api/categories \
-H "Content-Type: application/json" \
-d '{"name": "Work", "description": "Work-related tasks"}'
curl -X POST http://localhost:8080/api/categories \
-H "Content-Type: application/json" \
-d '{"name": "Personal", "description": "Personal tasks"}'
3. Create Task
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{
"title": "Complete JPA Codelab",
"description": "Learn Spring Data JPA",
"status": "TODO"
}'
4. Assign Task to User
curl -X PUT http://localhost:8080/api/tasks/1/assign/1
5. Add Categories to Task
curl -X POST http://localhost:8080/api/tasks/1/categories \
-H "Content-Type: application/json" \
-d '[1, 2]'
6. Search Tasks
curl "http://localhost:8080/api/tasks/search?keyword=JPA"
7. Get Task Stats
curl http://localhost:8080/api/tasks/stats
jdbc:h2:mem:taskdbSELECT * FROM tasks;
SELECT * FROM users;
SELECT * FROM categories;
SELECT * FROM task_categories;
-- Join query
SELECT t.title, u.username, c.name
FROM tasks t
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN task_categories tc ON t.id = tc.task_id
LEFT JOIN categories c ON tc.category_id = c.id;
Congratulations! 🎉 You've added database persistence with Spring Data JPA!
Database-backed features:
git add .
git commit -m "Codelab 3.3: ORM & Spring Data JPA complete"
git tag codelab-3.3