Back to blog
javabeginner

Spring Boot Fundamentals — REST APIs with Java

Build production REST APIs with Spring Boot — controllers, services, repositories, JPA, validation, security basics, and the patterns senior Java developers use.

LearnixoApril 16, 20265 min read
JavaSpring BootREST APIJPASpring SecurityBeginner
Share:𝕏

Spring Boot is the most widely used Java framework for building REST APIs. It eliminates boilerplate configuration and gives you a production-ready server in minutes. This lesson covers the full stack from first endpoint to database to security.


Create a Project

Go to start.spring.io and select:

  • Project: Maven
  • Language: Java
  • Spring Boot: 3.x (latest)
  • Dependencies: Spring Web, Spring Data JPA, PostgreSQL Driver, Validation, Spring Security

Or use the CLI:

Bash
spring init --dependencies=web,data-jpa,postgresql,validation,security my-api
cd my-api
./mvnw spring-boot:run

Project Structure

src/main/java/com/example/api/
├── ApiApplication.java           ← Entry point
├── controller/
│   └── ProductController.java
├── service/
│   └── ProductService.java
├── repository/
│   └── ProductRepository.java
├── model/
│   └── Product.java              ← JPA entity
├── dto/
│   ├── CreateProductRequest.java
│   └── ProductResponse.java
├── exception/
│   ├── ResourceNotFoundException.java
│   └── GlobalExceptionHandler.java
└── config/
    └── SecurityConfig.java

Your First REST Controller

JAVA
package com.example.api.controller;

import com.example.api.dto.CreateProductRequest;
import com.example.api.dto.ProductResponse;
import com.example.api.service.ProductService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @GetMapping
    public Page<ProductResponse> getAll(Pageable pageable) {
        return productService.findAll(pageable);
    }

    @GetMapping("/{id}")
    public ProductResponse getById(@PathVariable Long id) {
        return productService.findById(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ProductResponse create(@Valid @RequestBody CreateProductRequest request) {
        return productService.create(request);
    }

    @PutMapping("/{id}")
    public ProductResponse update(@PathVariable Long id,
                                  @Valid @RequestBody CreateProductRequest request) {
        return productService.update(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        productService.delete(id);
    }
}

JPA Entity

JAVA
package com.example.api.model;

import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import java.math.BigDecimal;
import java.time.Instant;

@Entity
@Table(name = "products")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String name;

    @Column(columnDefinition = "TEXT")
    private String description;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;

    @Column(nullable = false)
    private Integer stock = 0;

    @Column(nullable = false, length = 100)
    private String category;

    private Boolean active = true;

    @CreationTimestamp
    @Column(updatable = false)
    private Instant createdAt;

    @UpdateTimestamp
    private Instant updatedAt;
}

Repository — Spring Data JPA

JAVA
package com.example.api.repository;

import com.example.api.model.Product;
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 java.util.Optional;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Derived query methods — Spring generates the SQL
    Page<Product> findByActiveTrue(Pageable pageable);
    Page<Product> findByCategoryAndActiveTrue(String category, Pageable pageable);
    Optional<Product> findByIdAndActiveTrue(Long id);

    // Custom JPQL query
    @Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max AND p.active = true")
    Page<Product> findByPriceRange(@Param("min") BigDecimal min,
                                   @Param("max") BigDecimal max,
                                   Pageable pageable);

    // Native SQL query
    @Query(value = "SELECT * FROM products WHERE to_tsvector('english', name) @@ plainto_tsquery(:term)",
           nativeQuery = true)
    Page<Product> fullTextSearch(@Param("term") String term, Pageable pageable);

    boolean existsByNameIgnoreCase(String name);
}

Service Layer

JAVA
package com.example.api.service;

import com.example.api.dto.CreateProductRequest;
import com.example.api.dto.ProductResponse;
import com.example.api.exception.ResourceNotFoundException;
import com.example.api.model.Product;
import com.example.api.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ProductService {

    private final ProductRepository productRepository;

    public Page<ProductResponse> findAll(Pageable pageable) {
        return productRepository.findByActiveTrue(pageable)
                .map(ProductResponse::from);
    }

    public ProductResponse findById(Long id) {
        return productRepository.findByIdAndActiveTrue(id)
                .map(ProductResponse::from)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id));
    }

    @Transactional
    public ProductResponse create(CreateProductRequest request) {
        Product product = Product.builder()
                .name(request.name())
                .description(request.description())
                .price(request.price())
                .stock(request.stock())
                .category(request.category())
                .build();

        return ProductResponse.from(productRepository.save(product));
    }

    @Transactional
    public ProductResponse update(Long id, CreateProductRequest request) {
        Product product = productRepository.findByIdAndActiveTrue(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id));

        product.setName(request.name());
        product.setDescription(request.description());
        product.setPrice(request.price());
        product.setStock(request.stock());

        return ProductResponse.from(productRepository.save(product));
    }

    @Transactional
    public void delete(Long id) {
        Product product = productRepository.findByIdAndActiveTrue(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id));
        product.setActive(false);  // soft delete
        productRepository.save(product);
    }
}

DTOs with Validation

JAVA
// Request DTO
public record CreateProductRequest(
    @NotBlank(message = "Name is required")
    @Size(max = 200, message = "Name must be 200 characters or less")
    String name,

    String description,

    @NotNull(message = "Price is required")
    @DecimalMin(value = "0.01", message = "Price must be positive")
    @Digits(integer = 8, fraction = 2)
    BigDecimal price,

    @Min(value = 0, message = "Stock cannot be negative")
    Integer stock,

    @NotBlank(message = "Category is required")
    String category
) {}

// Response DTO
public record ProductResponse(
    Long id,
    String name,
    String description,
    BigDecimal price,
    Integer stock,
    String category,
    Instant createdAt
) {
    public static ProductResponse from(Product p) {
        return new ProductResponse(
            p.getId(), p.getName(), p.getDescription(),
            p.getPrice(), p.getStock(), p.getCategory(), p.getCreatedAt()
        );
    }
}

Global Exception Handler

JAVA
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = ex.getBindingResult()
                .getFieldErrors().stream()
                .collect(Collectors.toMap(
                    FieldError::getField,
                    fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid"
                ));

        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation Failed");
        pd.setProperty("errors", errors);
        return pd;
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ProblemDetail handleUnexpected(Exception ex) {
        log.error("Unexpected error", ex);
        return ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred"
        );
    }
}

application.properties

PROPERTIES
# Database
spring.datasource.url=jdbc:postgresql://localhost:5432/mydb
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}

# JPA / Hibernate
spring.jpa.hibernate.ddl-auto=validate   # use Flyway/Liquibase in production
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=true

# Pagination defaults
spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.max-page-size=100

Quick Reference

JAVA
@RestController         // Returns JSON by default
@RequestMapping("/api") // Base URL prefix
@GetMapping / @PostMapping / @PutMapping / @DeleteMapping
@PathVariable Long id   // /products/{id}
@RequestParam int page  // /products?page=1
@RequestBody @Valid      // Parse and validate JSON body
@ResponseStatus(CREATED) // Override default 200

Repository:
  JpaRepository<Entity, Id>  // CRUD + pagination built-in
  Derived methods:   findByField() / findByFieldAndOtherField()
  JPQL:              @Query("SELECT e FROM Entity e WHERE ...")
  Pagination:        Page<T> findAll(Pageable pageable)

Service:
  @Service
  @Transactional(readOnly = true)  // default for read methods
  @Transactional                    // for write methods

Exception:
  @RestControllerAdvice + @ExceptionHandler
  ProblemDetail (Spring 6+ RFC 9457 format)

REST API Knowledge Check

5 questions · Test what you just learned · Instant explanations

Enjoyed this article?

Explore the learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.