REF / WRITING · SOFTWARE

Java Spring Boot: The Complete Guide to Building Production REST APIs

Learn how to build scalable, production-ready REST APIs with Java Spring Boot - from project setup to deployment, with security, validation, and testing.

DomainSoftware
Formattutorial
Published17 Sept 2024
Tagsjava · spring-boot · rest-api

Spring Boot is the most widely deployed Java framework in the world. It powers banking systems, healthcare platforms, e-commerce giants, and the overwhelming majority of enterprise microservices. If you're building anything serious in Java, Spring Boot is your starting point. not a consideration.

This guide walks you through building a production-grade REST API from scratch: project structure, dependency injection, data persistence, security, validation, error handling, and testing. No shortcuts, no toy examples.

Why Spring Boot in 2026?

Spring Boot has held its position as the dominant Java framework for over a decade, and for good reason. The alternative Java frameworks. Quarkus, Micronaut, Helidon. each solve specific problems (faster cold starts, smaller container images), but Spring Boot's ecosystem depth, community size, and tooling maturity remain unmatched.

Key reasons teams still choose Spring Boot:

  • Auto-configuration: sensible defaults out of the box; you override only what you need.
  • Spring Data JPA: reduces data-layer boilerplate to near zero.
  • Spring Security: battle-tested authentication and authorization.
  • Actuator: production observability (health checks, metrics, tracing) built in.
  • Massive ecosystem: every cloud vendor, every database, every message broker has a Spring integration.

Project Loom (virtual threads, fully available since Java 21) gave Spring Boot a substantial throughput boost without requiring code changes. we cover that in a companion deep-dive.

Prerequisites

  • Java 21 (LTS) or later
  • Maven 3.9+ or Gradle 8+
  • A working IDE (IntelliJ IDEA recommended)
  • Basic understanding of HTTP and REST

Step 1: Bootstrap the Project

The fastest path is Spring Initializr at start.spring.io. Select:

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

Download, unzip, open in your IDE.

Your pom.xml core dependencies will look like this:

<dependencies>
  <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.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>
</dependencies>

Step 2: Project Structure

Spring Boot doesn't enforce structure, but this layout scales well:

src/main/java/com/yourcompany/app/
├── AppApplication.java          # entry point
├── config/                      # Security, CORS, bean configs
├── controller/                  # REST controllers (HTTP layer only)
├── dto/                         # Request/response objects
├── entity/                      # JPA entities
├── exception/                   # Custom exceptions + global handler
├── repository/                  # Spring Data JPA interfaces
├── service/                     # Business logic
└── util/                        # Shared helpers

The golden rule: controllers call services, services call repositories. Never skip layers.

Step 3: Configure the Database

In src/main/resources/application.yml:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/myapp
    username: ${DB_USER}
    password: ${DB_PASSWORD}
  jpa:
    hibernate:
      ddl-auto: validate        # use Flyway/Liquibase for migrations
    show-sql: false
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.PostgreSQLDialect

server:
  port: 8080

Never use ddl-auto: create or update in production. Use a proper migration tool like Flyway:

<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-core</artifactId>
</dependency>
<dependency>
  <groupId>org.flywaydb</groupId>
  <artifactId>flyway-database-postgresql</artifactId>
</dependency>

Migration files go in src/main/resources/db/migration/V1__init.sql.

Step 4: Define the Entity

@Entity
@Table(name = "products")
public class Product {

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

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

  @Column(nullable = false)
  private BigDecimal price;

  @Column(name = "created_at", nullable = false, updatable = false)
  @CreationTimestamp
  private Instant createdAt;

  // getters / setters or use Lombok @Data
}

Step 5: Repository Layer

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

  List<Product> findByNameContainingIgnoreCase(String name);

  @Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
  Page<Product> findByPriceRange(
      @Param("min") BigDecimal min,
      @Param("max") BigDecimal max,
      Pageable pageable);
}

Spring Data generates the implementation at runtime. No boilerplate SQL.

Step 6: DTOs and Validation

Never expose entities directly through the API. Use DTOs:

public record CreateProductRequest(
  @NotBlank(message = "Name is required")
  @Size(max = 120, message = "Name must be 120 characters or fewer")
  String name,

  @NotNull(message = "Price is required")
  @DecimalMin(value = "0.01", message = "Price must be positive")
  BigDecimal price
) {}

public record ProductResponse(
  Long id,
  String name,
  BigDecimal price,
  Instant createdAt
) {
  public static ProductResponse from(Product p) {
    return new ProductResponse(p.getId(), p.getName(), p.getPrice(), p.getCreatedAt());
  }
}

Step 7: Service Layer

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

  private final ProductRepository repo;

  public ProductService(ProductRepository repo) {
    this.repo = repo;
  }

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

  @Transactional
  public ProductResponse create(CreateProductRequest req) {
    var product = new Product();
    product.setName(req.name());
    product.setPrice(req.price());
    return ProductResponse.from(repo.save(product));
  }

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

Mark the class @Transactional(readOnly = true) and override with @Transactional on write methods. this is a significant performance optimisation that many teams miss.

Step 8: Controller Layer

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

  private final ProductService service;

  public ProductController(ProductService service) {
    this.service = service;
  }

  @GetMapping
  public Page<ProductResponse> list(
      @RequestParam(defaultValue = "0") int page,
      @RequestParam(defaultValue = "20") int size) {
    return service.list(PageRequest.of(page, size, Sort.by("createdAt").descending()));
  }

  @GetMapping("/{id}")
  public ProductResponse get(@PathVariable Long id) {
    return service.get(id);
  }

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

Step 9: Global Exception Handling

Centralise error responses with @RestControllerAdvice:

@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.UNPROCESSABLE_ENTITY)
  public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
    var detail = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
    detail.setTitle("Validation failed");
    detail.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
        .map(e -> Map.of("field", e.getField(), "message", e.getDefaultMessage()))
        .toList());
    return detail;
  }
}

ProblemDetail is RFC 7807. the standard format for HTTP API errors. Spring Boot 3 supports it natively.

Step 10: Testing

Spring Boot's test slices let you test layers in isolation:

// Unit test the service
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
  @Mock ProductRepository repo;
  @InjectMocks ProductService service;

  @Test
  void get_throwsWhenNotFound() {
    when(repo.findById(99L)).thenReturn(Optional.empty());
    assertThrows(ResourceNotFoundException.class, () -> service.get(99L));
  }
}

// Integration test the controller
@WebMvcTest(ProductController.class)
class ProductControllerTest {
  @Autowired MockMvc mvc;
  @MockBean ProductService service;

  @Test
  void list_returns200() throws Exception {
    when(service.list(any())).thenReturn(Page.empty());
    mvc.perform(get("/api/v1/products"))
       .andExpect(status().isOk());
  }
}

Common Pitfalls

1. Circular dependency injection: usually a sign the service layer is too large. Split responsibilities.

2. N+1 queries: when fetching a list of entities that each trigger a lazy-load. Use @EntityGraph or JPQL JOIN FETCH to fix.

3. @Transactional on private methods: Spring AOP proxies won't intercept private methods. Transactions are silently ignored.

4. Using entities as API responses: entities change schema over time; DTOs insulate your API contract from those changes.

5. Ignoring the Actuator: /actuator/health, /actuator/metrics, and /actuator/prometheus are free observability. Always enable them.

When to Use Spring Boot vs Alternatives

ScenarioRecommendation
Enterprise team, complex domainSpring Boot. ecosystem depth wins
Fast cold starts (serverless/Lambda)Quarkus or Micronaut
Simplest possible REST serviceSpring Boot with virtual threads is fine
Kotlin codebaseSpring Boot + Kotlin or Ktor
Go/Rust teamWrong question. use the right language for the team

FAQ

Q: Should I use Spring MVC or Spring WebFlux? For most teams: Spring MVC with virtual threads (Java 21+). WebFlux is reactive and has a steeper learning curve. the throughput gains are rarely worth it unless you're handling massive concurrency with blocking I/O.

Q: How do I handle database migrations? Flyway for simplicity; Liquibase if you need rollback scripts or XML-based change sets. Both integrate with Spring Boot via auto-configuration.

Q: What's the difference between @Component, @Service, @Repository, @Controller? They're all @Component specialisations. The semantic difference matters for clarity and for Spring Data's exception translation (which applies to @Repository-annotated classes).

Q: Do I still need Lombok? Java records handle immutable DTOs cleanly. Lombok remains useful for mutable entities with many fields. The community is gradually moving toward records.

Q: How do I secure my API? Start with Spring Security's JWT support for stateless APIs. The companion article on Spring Security covers this in detail.

Conclusion

Spring Boot remains the gold standard for Java API development. and for good reason. Its auto-configuration, rich ecosystem, and excellent testing support mean you can ship production-quality code fast without reinventing infrastructure.

The patterns in this guide. layered architecture, DTOs, global exception handling, ProblemDetail responses, and proper transaction management. apply regardless of the domain you're building for.

Next: Virtual Threads with Spring Boot. how Java 21 Project Loom transforms throughput without touching your business logic.