A practical guide to building a production-ready Spring Boot REST API with CRUD operations, database integration, monitoring, and deployment.
Table of Contents
- Project Setup
- REST CRUD Implementation
- Database Layer with JPA
- Actuator APIs
- Logging & Configuration
- Deploy & Test
1. Project Setup
Using Spring Initializr
Visit start.spring.io or use CLI:
curl https://start.spring.io/starter.tgz \
-d dependencies=web,data-jpa,h2,actuator,validation,lombok \
-d type=maven-project \
-d javaVersion=21 \
-d bootVersion=3.2.0 \
-d groupId=com.gyan \
-d artifactId=education-app \
-d name=EducationApp \
-d packageName=com.gyan.education | tar -xzvf -
Project Structure
education-app/
├── src/main/java/com/gyan/education/
│ ├── EducationAppApplication.java
│ ├── controller/
│ │ └── StudentController.java
│ ├── model/
│ │ └── Student.java
│ ├── repository/
│ │ └── StudentRepository.java
│ ├── service/
│ │ ├── StudentService.java
│ │ └── StudentServiceImpl.java
│ └── exception/
│ ├── ResourceNotFoundException.java
│ └── GlobalExceptionHandler.java
├── src/main/resources/
│ ├── application.yml
│ └── logback-spring.xml
└── pom.xml
Key Dependencies (pom.xml)
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Utils -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
2. REST CRUD Implementation
Entity Model
package com.gyan.education.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "students")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100)
private String name;
@Email(message = "Invalid email format")
@NotBlank(message = "Email is required")
@Column(unique = true)
private String email;
@NotNull(message = "Grade is required")
@Min(value = 1, message = "Grade must be between 1 and 12")
@Max(value = 12, message = "Grade must be between 1 and 12")
private Integer grade;
@Column(name = "enrollment_date")
private LocalDateTime enrollmentDate;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
Repository Layer
package com.gyan.education.repository;
import com.gyan.education.model.Student;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {
Optional<Student> findByEmail(String email);
List<Student> findByGrade(Integer grade);
boolean existsByEmail(String email);
}
Service Layer
package com.gyan.education.service;
import com.gyan.education.model.Student;
import java.util.List;
public interface StudentService {
Student createStudent(Student student);
Student getStudentById(Long id);
List<Student> getAllStudents();
Student updateStudent(Long id, Student student);
void deleteStudent(Long id);
List<Student> getStudentsByGrade(Integer grade);
}
package com.gyan.education.service;
import com.gyan.education.exception.ResourceNotFoundException;
import com.gyan.education.model.Student;
import com.gyan.education.repository.StudentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class StudentServiceImpl implements StudentService {
private final StudentRepository studentRepository;
@Override
public Student createStudent(Student student) {
log.info("Creating student with email: {}", student.getEmail());
if (studentRepository.existsByEmail(student.getEmail())) {
throw new IllegalArgumentException("Email already exists: " + student.getEmail());
}
student.setEnrollmentDate(LocalDateTime.now());
Student savedStudent = studentRepository.save(student);
log.info("Student created successfully with ID: {}", savedStudent.getId());
return savedStudent;
}
@Override
@Transactional(readOnly = true)
public Student getStudentById(Long id) {
log.debug("Fetching student with ID: {}", id);
return studentRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Student not found with ID: " + id));
}
@Override
@Transactional(readOnly = true)
public List<Student> getAllStudents() {
log.debug("Fetching all students");
return studentRepository.findAll();
}
@Override
public Student updateStudent(Long id, Student student) {
log.info("Updating student with ID: {}", id);
Student existingStudent = getStudentById(id);
existingStudent.setName(student.getName());
existingStudent.setEmail(student.getEmail());
existingStudent.setGrade(student.getGrade());
Student updatedStudent = studentRepository.save(existingStudent);
log.info("Student updated successfully with ID: {}", id);
return updatedStudent;
}
@Override
public void deleteStudent(Long id) {
log.info("Deleting student with ID: {}", id);
Student student = getStudentById(id);
studentRepository.delete(student);
log.info("Student deleted successfully with ID: {}", id);
}
@Override
@Transactional(readOnly = true)
public List<Student> getStudentsByGrade(Integer grade) {
log.debug("Fetching students by grade: {}", grade);
return studentRepository.findByGrade(grade);
}
}
Controller Layer
package com.gyan.education.controller;
import com.gyan.education.model.Student;
import com.gyan.education.service.StudentService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/students")
@RequiredArgsConstructor
@Slf4j
public class StudentController {
private final StudentService studentService;
@PostMapping
public ResponseEntity<Student> createStudent(@Valid @RequestBody Student student) {
log.info("POST /api/v1/students - Creating student");
Student created = studentService.createStudent(student);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@GetMapping("/{id}")
public ResponseEntity<Student> getStudent(@PathVariable Long id) {
log.debug("GET /api/v1/students/{}", id);
Student student = studentService.getStudentById(id);
return ResponseEntity.ok(student);
}
@GetMapping
public ResponseEntity<List<Student>> getAllStudents(
@RequestParam(required = false) Integer grade) {
log.debug("GET /api/v1/students - grade filter: {}", grade);
List<Student> students = grade != null
? studentService.getStudentsByGrade(grade)
: studentService.getAllStudents();
return ResponseEntity.ok(students);
}
@PutMapping("/{id}")
public ResponseEntity<Student> updateStudent(
@PathVariable Long id,
@Valid @RequestBody Student student) {
log.info("PUT /api/v1/students/{}", id);
Student updated = studentService.updateStudent(id, student);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteStudent(@PathVariable Long id) {
log.info("DELETE /api/v1/students/{}", id);
studentService.deleteStudent(id);
return ResponseEntity.noContent().build();
}
}
Exception Handling
package com.gyan.education.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
package com.gyan.education.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
log.error("Resource not found: {}", ex.getMessage());
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.NOT_FOUND.value())
.error("Not Found")
.message(ex.getMessage())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationErrors(
MethodArgumentNotValidException ex) {
log.error("Validation failed: {}", ex.getMessage());
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException ex) {
log.error("Invalid argument: {}", ex.getMessage());
ErrorResponse error = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Bad Request")
.message(ex.getMessage())
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
@lombok.Data
@lombok.Builder
static class ErrorResponse {
private LocalDateTime timestamp;
private int status;
private String error;
private String message;
}
}
3. Database Layer with JPA
Configuration (application.yml)
spring:
application:
name: education-app
datasource:
url: jdbc:h2:mem:educationdb
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
path: /h2-console
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true
Initial Data (Optional — data.sql)
-- src/main/resources/data.sql
INSERT INTO students (name, email, grade, enrollment_date, created_at, updated_at)
VALUES
('Rishi S', 'rishi@gyan.com', 4, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('Vedika K', 'vedika@gyan.com', 2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('Shiva R', 'shiva@gyan.com', 7, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
JPA Key Features Used
- Entity Relationships : @OneToMany, @ManyToOne, @ManyToMany (expandable)
- Auditing : @CreationTimestamp, @UpdateTimestamp
- Validation : @NotNull, @Email, @Size, @min, @max
- Queries : Custom query methods (findByEmail, findByGrade)
- Transactions : @Transactional for data consistency
4. Actuator APIs
Configuration
management:
endpoints:
web:
exposure:
include: health,info,metrics,env,loggers,httptrace
base-path: /actuator
endpoint:
health:
show-details: always
info:
env:
enabled: true
info:
app:
name: Education App
description: Student Management System
version: 1.0.0
author: Vikash Kumar
Key Actuator Endpoints
EndpointPurposeURL Health Application health statusGET /actuator/health Info Application informationGET /actuator/info Metrics Performance metricsGET /actuator/metrics Env Environment propertiesGET /actuator/env Loggers View/modify log levelsGET /actuator/loggers
Custom Health Indicator (Optional)
package com.gyan.education.config;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
@Override
public Health health() {
// Add custom health check logic
boolean databaseIsUp = checkDatabase();
if (databaseIsUp) {
return Health.up()
.withDetail("database", "Available")
.build();
}
return Health.down()
.withDetail("database", "Unavailable")
.build();
}
private boolean checkDatabase() {
// Implement actual health check
return true;
}
}
5. Logging & Configuration
Logback Configuration (logback-spring.xml)
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOGS" value="./logs" />
<!-- Console Appender -->
<appender name="Console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- File Appender -->
<appender name="RollingFile" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOGS}/education-app.log</file>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOGS}/archived/education-app-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<timeBasedFileNamingAndTriggeringPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>10MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
<maxHistory>30</maxHistory>
</rollingPolicy>
</appender>
<!-- Package-specific logging -->
<logger name="com.gyan.education" level="DEBUG" />
<logger name="org.springframework.web" level="INFO" />
<logger name="org.hibernate" level="INFO" />
<root level="INFO">
<appender-ref ref="Console" />
<appender-ref ref="RollingFile" />
</root>
</configuration>
Application Profiles
application-dev.yml
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
com.gyan.education: DEBUG
application-prod.yml
spring:
jpa:
show-sql: false
logging:
level:
com.gyan.education: INFO
management:
endpoints:
web:
exposure:
include: health,info,metrics
Running with Profiles
# Development
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
# Production
java -jar target/education-app-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
6. Deploy & Test
Build the Application
# Maven
./mvnw clean package
# Gradle
./gradlew build
Run Locally
# Using Maven
./mvnw spring-boot:run
# Using JAR
java -jar target/education-app-0.0.1-SNAPSHOT.jar
# Access at: http://localhost:8080
Test with cURL
# Create a student
curl -X POST http://localhost:8080/api/v1/students \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john@gyan.com",
"grade": 4
}'
# Get all students
curl http://localhost:8080/api/v1/students
# Get student by ID
curl http://localhost:8080/api/v1/students/1
# Get students by grade
curl http://localhost:8080/api/v1/students?grade=4
# Update student
curl -X PUT http://localhost:8080/api/v1/students/1 \
-H "Content-Type: application/json" \
-d '{
"name": "John Updated",
"email": "john.updated@school.com",
"grade": 11
}'
# Delete student
curl -X DELETE http://localhost:8080/api/v1/students/1
# Check health
curl http://localhost:8080/actuator/health
# View metrics
curl http://localhost:8080/actuator/metrics
Docker Deployment
Dockerfile
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Build and Run
# Build Docker image
docker build -t education-app:1.0 .
# Run container
docker run -p 8080:8080 education-app:1.0
Unit Testing Example
package com.gyan.education.service;
import com.gyan.education.exception.ResourceNotFoundException;
import com.gyan.education.model.Student;
import com.gyan.education.repository.StudentRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class StudentServiceImplTest {
@Mock
private StudentRepository studentRepository;
@InjectMocks
private StudentServiceImpl studentService;
@Test
void createStudent_Success() {
Student student = Student.builder()
.name("Test Student")
.email("test@school.com")
.grade(10)
.build();
when(studentRepository.existsByEmail(anyString())).thenReturn(false);
when(studentRepository.save(any(Student.class))).thenReturn(student);
Student created = studentService.createStudent(student);
assertNotNull(created);
assertEquals("Test Student", created.getName());
verify(studentRepository).save(any(Student.class));
}
@Test
void getStudentById_NotFound() {
when(studentRepository.findById(1L)).thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class,
() -> studentService.getStudentById(1L));
}
}
Quick Reference
Project Checklist
- ✅ Spring Boot 3.x with Java 21
- ✅ RESTful CRUD endpoints
- ✅ JPA/Hibernate with H2 database
- ✅ Request validation
- ✅ Exception handling
- ✅ Actuator for monitoring
- ✅ Structured logging
- ✅ Profile-based configuration
- ✅ Docker support
Next Steps
- Security : Add Spring Security for authentication/authorization
- Swagger : Integrate OpenAPI documentation
- Caching : Implement Redis/Caffeine caching
- Testing : Add integration tests with TestContainers
- Production DB : Switch to PostgreSQL/MySQL
- Cloud : Deploy to AWS/Azure/GCP
Happy Coding! 🚀
Top comments (0)