🌐 Detecting your location…
📢 Advertisement — Configure AdSense in Appearance → Customize → AdSense Settings

Spring Boot 3 Guide 2026: REST APIs, JPA, Security and Virtual Threads

⏱️5 min read  ·  1,088 words

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.

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.

✍️ Leave a Comment

Your email address will not be published. Required fields are marked *

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা