Spring Boot is the dominant Java framework for enterprise backends in 2026. With Spring Boot 3.3, native compilation via GraalVM, virtual threads via Project Loom, and the Spring AI starter, building production Java APIs has never been faster. This guide takes you from hello world to production-grade REST API.
📋 Table of Contents
Why Spring Boot?
- Auto-configuration — sensible defaults, zero boilerplate for common patterns
- Embedded servers — Tomcat/Netty included, no deployment WAR files
- Production-ready — Actuator, Micrometer metrics, health checks built-in
- GraalVM native — compile to native binary, starts in <50ms, uses 50% less RAM
- Massive ecosystem — Spring Data, Security, Cloud, Batch, AI
Project Setup
# Spring Initializr (start.spring.io)
# Or via curl:
curl https://start.spring.io/starter.zip -d dependencies=web,data-jpa,postgresql,security,actuator,validation,lombok -d type=maven-project -d language=java -d javaVersion=21 -d name=techpulse-api -d groupId=com.techpulse -d artifactId=api -o techpulse-api.zip
unzip techpulse-api.zip
cd techpulse-api
mvn spring-boot:run
application.yml Configuration
spring:
datasource:
url: jdbc:postgresql://localhost:5432/techpulse_db
username: ${DB_USER}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
jpa:
hibernate:
ddl-auto: validate # use Flyway/Liquibase for production
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
flyway:
enabled: true
locations: classpath:db/migration
server:
port: 8080
compression:
enabled: true
management:
endpoints:
web:
exposure:
include: health,metrics,info,prometheus
endpoint:
health:
show-details: always
Entity and Repository
// User.java
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
@Size(min = 2, max = 50)
private String name;
@Column(unique = true, nullable = false)
@Email
private String email;
@Column(nullable = false)
@JsonIgnore
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role = Role.USER;
@Column(nullable = false)
private boolean active = true;
@CreationTimestamp
private LocalDateTime createdAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
public enum Role { USER, ADMIN, MODERATOR }
}
// UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Page<User> findByActiveTrue(Pageable pageable);
List<User> findByRole(User.Role role);
boolean existsByEmail(String email);
@Query("SELECT u FROM User u WHERE u.name ILIKE %:name%")
List<User> searchByName(@Param("name") String name);
}
Service Layer
// UserService.java
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class UserService {
private final UserRepository userRepo;
private final PasswordEncoder passwordEncoder;
@Transactional(readOnly = true)
public Page<UserDTO> listUsers(Pageable pageable) {
return userRepo.findByActiveTrue(pageable)
.map(UserDTO::from);
}
@Transactional(readOnly = true)
public UserDTO getUser(Long id) {
return userRepo.findById(id)
.map(UserDTO::from)
.orElseThrow(() -> new ResourceNotFoundException("User not found: " + id));
}
public UserDTO createUser(CreateUserRequest request) {
if (userRepo.existsByEmail(request.email())) {
throw new ConflictException("Email already exists: " + request.email());
}
User user = User.builder()
.name(request.name())
.email(request.email())
.passwordHash(passwordEncoder.encode(request.password()))
.build();
User saved = userRepo.save(user);
log.info("Created user: {} ({})", saved.getEmail(), saved.getId());
return UserDTO.from(saved);
}
public UserDTO updateUser(Long id, UpdateUserRequest request) {
User user = userRepo.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
if (request.name() != null) user.setName(request.name());
return UserDTO.from(userRepo.save(user));
}
public void deleteUser(Long id) {
User user = userRepo.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
user.setActive(false); // soft delete
userRepo.save(user);
}
}
REST Controller
// UserController.java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Validated
@Slf4j
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Page<UserDTO>> listUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "DESC") Sort.Direction direction
) {
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortBy));
return ResponseEntity.ok(userService.listUsers(pageable));
}
@GetMapping("/{id}")
public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUser(id));
}
@PostMapping
public ResponseEntity<UserDTO> createUser(
@Valid @RequestBody CreateUserRequest request
) {
UserDTO created = userService.createUser(request);
URI location = URI.create("/api/v1/users/" + created.id());
return ResponseEntity.created(location).body(created);
}
@PatchMapping("/{id}")
public ResponseEntity<UserDTO> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request
) {
return ResponseEntity.ok(userService.updateUser(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}
// DTOs (Java records)
public record UserDTO(Long id, String name, String email, User.Role role, LocalDateTime createdAt) {
public static UserDTO from(User user) {
return new UserDTO(user.getId(), user.getName(), user.getEmail(),
user.getRole(), user.getCreatedAt());
}
}
public record CreateUserRequest(
@NotBlank @Size(min=2, max=50) String name,
@NotBlank @Email String email,
@NotBlank @Size(min=8) String password
) {}
Global Exception Handler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Resource Not Found");
return problem;
}
@ExceptionHandler(ConflictException.class)
public ProblemDetail handleConflict(ConflictException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
problem.setProperty("errors", errors);
return problem;
}
}
Spring Security with JWT
// JwtService.java
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
private static final long EXPIRY = 86400000L; // 24h
public String generateToken(UserDetails user) {
return Jwts.builder()
.subject(user.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRY))
.signWith(Keys.hmacShaKeyFor(secret.getBytes()))
.compact();
}
public String extractUsername(String token) {
return Jwts.parser()
.verifyWith(Keys.hmacShaKeyFor(secret.getBytes()))
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
}
Virtual Threads (Java 21 + Spring Boot 3.3)
# application.yml — enable virtual threads
spring:
threads:
virtual:
enabled: true # uses Project Loom virtual threads for Tomcat
With virtual threads, each HTTP request gets a lightweight virtual thread instead of a platform thread. This dramatically improves throughput for I/O-bound applications (database queries, external API calls) without any code changes.
GraalVM Native Image
# Add to pom.xml
# <plugin>spring-boot-maven-plugin with native goal</plugin>
# Build native image (requires GraalVM 22+)
mvn -Pnative native:compile
# Run (starts in ~50ms, uses ~60MB RAM vs 300MB JVM)
./target/techpulse-api
Spring Boot 3.3 in 2026 is production-ready, fast, and developer-friendly. Virtual threads eliminate the need for reactive programming for most use cases. GraalVM native images make Java a serious contender for containerized microservices where startup time and memory matter.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment