Building a Complete Spring Boot API

Eight posts ago, this track started with a single endpoint returning a hardcoded string. Since then we've added real controllers, validation at the trust boundary, JPA persistence, transactions that don't half-succeed, Problem Details errors, and JWT security. Every one of those posts ended with "here's the piece" — this one is "here's the machine." We're going to assemble every piece into one coherent Orders API: controller → service → repository, validated, transacted, secured, and proven by an integration test.

Nothing here is new. That's the point. If each earlier post did its job, this one should feel like snapping Lego bricks together — and any step that feels unfamiliar tells you exactly which post to revisit.

The build plan

One service, three layers, three cross-cutting concerns. The layers only talk downward; the concerns wrap the layers:

HTTP client Controller — @RestController HTTP in/out · @Valid · @PreAuthorize · maps DTOs Service — @Service business rules · @Transactional unit of work Repository — JpaRepository persistence only · no business logic · H2 (demo) Security filter chain + JWT Validation @Valid on the way in Errors ProblemDetail advice Test @SpringBootTest proves it end to end Layers talk downward only. Concerns wrap the layers — never the other way round.

The project: pom.xml

Spring Boot 4.1.1 as the parent (Java 17+ baseline; this track targets JDK 25), every starter version-managed by the parent — except H2, which we pin to the version verified on Maven Central. (Check for newer versions than the ones pinned — the coordinates are the stable part.)

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
  </parent>

  <groupId>com.javamakeuse</groupId>
  <artifactId>orders-api</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>orders-api</name>

  <properties>
    <java.version>25</java.version>
  </properties>

  <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.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <version>2.4.240</version>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

The managed versions this resolves to were verified against Maven Central while writing: Spring Framework 7.0.9, Spring Security 7.1.1, Spring Data JPA 4.1.1, Hibernate 7.4.5.Final, Jakarta Persistence 3.2.0. Note there is no version on most starters — the parent's dependency management pins them, which is exactly what you want: one version number to update, zero drift between modules. (The Maven vs Gradle mechanics are covered in this track's companion Tooling post, Maven vs Gradle: Builds Demystified.)

Configuration

One properties file. H2 keeps the tutorial self-contained — the Tooling track's Testcontainers post shows the production-grade variant with real PostgreSQL:

spring.application.name=orders-api
server.port=8080

# H2 for the tutorial; swap for Postgres via Testcontainers in production
spring.datasource.url=jdbc:h2:mem:ordersdb
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

# 32+ characters. In production: environment variable, never this file.
jwt.secret=change-me-to-a-32-plus-char-secret-in-prod

The domain: entities and relationships

A customer has many orders; an order has many line items. From the "Spring Data JPA: Entities, Repositories & Queries" post: the owning side holds the foreign key, mappedBy marks the inverse side, and CascadeType.ALL with orphanRemoval makes line items live and die with their order. Note the jakarta.* imports — Spring Boot 4 has no javax:

package com.javamakeuse.orders;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String email; private String name; protected Customer() { } public Customer(String email) { this.email = email; } public Long getId() { return id; } public String getEmail() { return email; } public String getName() { return name; } public void setName(String name) { this.name = name; } }
package com.javamakeuse.orders;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "orders")
public class Order {

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

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    private BigDecimal totalAmount;
    private String status;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    protected Order() { }

    public Order(Customer customer, BigDecimal totalAmount) {
        this.customer = customer;
        this.totalAmount = totalAmount;
        this.status = "PENDING";
    }

    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);
    }

    public Long getId() { return id; }
    public Customer getCustomer() { return customer; }
    public BigDecimal getTotalAmount() { return totalAmount; }
    public String getStatus() { return status; }
    public List<OrderItem> getItems() { return items; }
}
package com.javamakeuse.orders;

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import java.math.BigDecimal;

@Entity
public class OrderItem {

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

    private String sku;
    private int quantity;
    private BigDecimal unitPrice;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    protected OrderItem() { }

    public OrderItem(String sku, int quantity, BigDecimal unitPrice) {
        this.sku = sku;
        this.quantity = quantity;
        this.unitPrice = unitPrice;
    }

    public Long getId() { return id; }
    public String getSku() { return sku; }
    public int getQuantity() { return quantity; }
    public BigDecimal getUnitPrice() { return unitPrice; }
    public Order getOrder() { return order; }
    public void setOrder(Order order) { this.order = order; }
}

addItem keeps both sides of the bidirectional relationship in sync — forget item.setOrder(this) and the foreign key is null at flush time, the classic "my child rows aren't linked" bug.

Repositories: derived queries and @Query

Repositories stay thin — persistence only, no business logic. A derived query for the lookup, and one @Query with a fetch join so reading an order doesn't N+1 its line items:

package com.javamakeuse.orders;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CustomerRepository extends JpaRepository<Customer, Long> {

    Optional<Customer> findByEmail(String email);
}
package com.javamakeuse.orders;

import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface OrderRepository extends JpaRepository<Order, Long> {

    List<Order> findByCustomerEmail(String email);

    @Query("select o from Order o join fetch o.items where o.id = :id")
    Optional<Order> findByIdWithItems(@Param("id") Long id);
}

DTOs and Bean Validation: the trust boundary

Controllers never expose entities — from the "REST Controllers, Validation & Error Handling" post, the request DTO carries the validation rules, and records keep it immutable:

package com.javamakeuse.orders;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.util.List;

public record PlaceOrderRequest(
    @NotNull @Email String customerEmail,
    @NotNull @Positive BigDecimal totalAmount,
    @NotEmpty @Valid List<OrderLine> lines
) { }

record OrderLine(
    @NotNull String sku,
    @Positive int quantity,
    @NotNull @Positive BigDecimal unitPrice
) { }
package com.javamakeuse.orders;

import java.math.BigDecimal;
import java.util.List;

public record OrderDto(
    Long id,
    String customerEmail,
    BigDecimal totalAmount,
    String status,
    List<OrderLineDto> lines
) {

    public static OrderDto from(Order order) {
        List<OrderLineDto> lines = order.getItems().stream()
            .map(i -> new OrderLineDto(i.getSku(), i.getQuantity(), i.getUnitPrice()))
            .toList();
        return new OrderDto(order.getId(), order.getCustomer().getEmail(),
            order.getTotalAmount(), order.getStatus(), lines);
    }
}

record OrderLineDto(String sku, int quantity, BigDecimal unitPrice) { }

The service: where the transaction lives

From the "Transactions: @Transactional, Isolation & Propagation" post: the annotation goes on the service method that owns the whole unit of work. Class-level @Transactional covers every public method; readOnly = true on the queries lets the provider skip dirty checking:

package com.javamakeuse.orders;

import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class OrderService {

    private final OrderRepository orders;
    private final CustomerRepository customers;

    public OrderService(OrderRepository orders, CustomerRepository customers) {
        this.orders = orders;
        this.customers = customers;
    }

    public Order placeOrder(PlaceOrderRequest req) {
        Customer customer = customers.findByEmail(req.customerEmail())
            .orElseGet(() -> customers.save(new Customer(req.customerEmail())));
        Order order = new Order(customer, req.totalAmount());
        for (OrderLine line : req.lines()) {
            order.addItem(new OrderItem(line.sku(), line.quantity(), line.unitPrice()));
        }
        return orders.save(order);
    }

    @Transactional(readOnly = true)
    public Order getOrder(Long id) {
        return orders.findByIdWithItems(id)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @Transactional(readOnly = true)
    public List<Order> ordersFor(String email) {
        return orders.findByCustomerEmail(email);
    }
}

The controller: HTTP in, HTTP out

The controller does four things and nothing else: maps HTTP to Java, triggers validation with @Valid, enforces scopes with @PreAuthorize, and returns the right status code. Business logic lives one layer down:

package com.javamakeuse.orders;

import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService service;

    public OrderController(OrderService service) {
        this.service = service;
    }

    @PostMapping
    @PreAuthorize("hasAuthority('SCOPE_orders:write')")
    public ResponseEntity<OrderDto> place(@Valid @RequestBody PlaceOrderRequest req) {
        Order saved = service.placeOrder(req);
        return ResponseEntity.status(HttpStatus.CREATED).body(OrderDto.from(saved));
    }

    @GetMapping("/{id}")
    @PreAuthorize("hasAuthority('SCOPE_orders:read')")
    public OrderDto get(@PathVariable Long id) {
        return OrderDto.from(service.getOrder(id));
    }

    @GetMapping
    @PreAuthorize("hasAuthority('SCOPE_orders:read')")
    public List<OrderDto> byCustomer(@RequestParam String email) {
        return service.ordersFor(email).stream().map(OrderDto::from).toList();
    }
}

Errors with a shape: Problem Details

From the "REST Controllers, Validation & Error Handling" post: errors are API too. One @RestControllerAdvice turns exceptions into application/problem+json responses with a stable shape clients can code against:

package com.javamakeuse.orders;

import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
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;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        pd.setDetail("One or more fields failed validation.");
        List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
            .map(this::toError)
            .toList();
        pd.setProperty("errors", errors);
        return pd;
    }

    private Map<String, String> toError(FieldError e) {
        String message = e.getDefaultMessage() == null ? "invalid" : e.getDefaultMessage();
        return Map.of("field", e.getField(), "message", message);
    }

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail handleNotFound(OrderNotFoundException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setTitle("Order not found");
        return pd;
    }
}

A bad request now gets a machine-readable answer instead of a stack trace (illustrative response):

POST /api/orders → 400 Bad Request
Content-Type: application/problem+json

{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 400,
  "detail": "One or more fields failed validation.",
  "errors": [
    { "field": "customerEmail", "message": "must be a well-formed email address" },
    { "field": "totalAmount",   "message": "must be greater than 0" },
    { "field": "lines[0].quantity", "message": "must be greater than 0" }
  ]
}

Security: the full chain

From the "Spring Security Basics: Auth for REST APIs" post, the complete setup: the filter chain (token endpoint public, everything else authenticated), the JWT encoder/decoder pair, and a token-issuing endpoint so clients can actually get a token:

package com.javamakeuse.orders;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}
package com.javamakeuse.orders;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final TokenService tokens;

    public AuthController(TokenService tokens) {
        this.tokens = tokens;
    }

    @PostMapping("/token")
    public TokenResponse token(@RequestBody LoginRequest req) {
        // Demo credential check — the security post shows a real user store.
        if (!"demo".equals(req.username()) || !"demo-pass".equals(req.password())) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "bad credentials");
        }
        return new TokenResponse(tokens.issue(req.username()));
    }
}

record LoginRequest(String username, String password) { }

record TokenResponse(String accessToken) { }

The JwtConfig (encoder/decoder beans) and TokenService (claims builder) are the same code as the security post's, in the com.javamakeuse.orders package — the one difference is the issuer string, "orders-api". Same pattern, new service.

POST /api/orders — one request, four gates 1. Security filter chain verifies the JWT signature no token → 401 wrong scope → 403 2. Validation @Valid checks every constraint on the DTO violations → 400 ProblemDetail body 3. Service @Transactional: the whole unit of work commits or rolls back as one 4. DB 201 order + lines Each gate rejects bad input before the next layer spends any work on it. Security first (don't parse attacker input), validation second (don't run business logic on garbage), transaction third (don't half-write), persistence last. The order of the gates is a design decision, not an accident.

The test that proves it

From the "Testing Spring Boot APIs: @SpringBootTest, MockMvc & Test Slices" post: the capstone test boots the whole application against H2 and drives it over real HTTP. Spring Boot 4 removed TestRestTemplate — the replacement is WebTestClient bound to the random-port server:

package com.javamakeuse.orders;

import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.junit.jupiter.api.Assertions.assertNotNull;

// Spring Boot 4 removed TestRestTemplate. The replacement is WebTestClient
// bound to the real random-port server: full HTTP stack, fluent assertions.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrdersApiIT {

    @LocalServerPort
    int port;

    WebTestClient client;

    @BeforeEach
    void setUp() {
        client = WebTestClient.bindToServer()
            .baseUrl("http://localhost:" + port)
            .build();
    }

    private String token() {
        TokenResponse tr = client.post().uri("/api/auth/token")
            .bodyValue(new LoginRequest("demo", "demo-pass"))
            .exchange()
            .expectStatus().isOk()
            .expectBody(TokenResponse.class)
            .returnResult().getResponseBody();
        assertNotNull(tr);
        return tr.accessToken();
    }

    private PlaceOrderRequest validRequest() {
        return new PlaceOrderRequest("ops@example.com", new BigDecimal("149.99"),
            List.of(new OrderLine("SKU-1", 2, new BigDecimal("74.99"))));
    }

    @Test
    void rejectsUnauthenticatedOrderPlacement() {
        ProblemDetail pd = client.post().uri("/api/orders")
            .bodyValue(validRequest())
            .exchange()
            .expectStatus().isUnauthorized()
            .expectBody(ProblemDetail.class)
            .returnResult().getResponseBody();
        assertNotNull(pd);
    }

    @Test
    void rejectsInvalidPayloadWithProblemDetail() {
        var bad = new PlaceOrderRequest("not-an-email", new BigDecimal("-5"),
            List.of(new OrderLine("SKU-1", 0, new BigDecimal("74.99"))));
        ProblemDetail pd = client.post().uri("/api/orders")
            .headers(h -> h.setBearerAuth(token()))
            .bodyValue(bad)
            .exchange()
            .expectStatus().isBadRequest()
            .expectBody(ProblemDetail.class)
            .returnResult().getResponseBody();
        assertNotNull(pd);
    }

    @Test
    void placesOrderWithValidToken() {
        OrderDto dto = client.post().uri("/api/orders")
            .headers(h -> h.setBearerAuth(token()))
            .bodyValue(validRequest())
            .exchange()
            .expectStatus().isEqualTo(HttpStatus.CREATED)
            .expectBody(OrderDto.class)
            .returnResult().getResponseBody();
        assertNotNull(dto);
        assertNotNull(dto.id());
    }
}

Three tests, three gates from the lifecycle diagram: no token → 401, bad payload → 400 with a ProblemDetail body, valid token + valid payload → 201 with a real order id. Run it with mvn test (illustrative output — exact timings vary):

[INFO] Running com.javamakeuse.orders.OrdersApiIT
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

H2 keeps this test fast and hermetic. When you're ready for the production-grade variant — the same test against real PostgreSQL in Docker — the Tooling track's Testcontainers post shows exactly how, and the CI/CD with GitHub Actions post shows how to run it on every push.

Track recap: the whole Spring Boot & APIs track

Nine posts, one arc — from a first endpoint to a secured, tested service:

  1. Spring Boot 4 Quickstart: Your First REST API
  2. REST Controllers, Validation & Error Handling
  3. Dependency Injection Deep Dive
  4. JDBC First: Connections, Pools & Transactions
  5. Spring Data JPA: Entities, Repositories & Relationships
  6. JPA Performance: LAZY/EAGER, N+1, Fetch Joins & Locking
  7. Transactions: @Transactional, Isolation & Propagation
  8. Spring Security Basics: Auth for REST APIs
  9. Building a Complete Spring Boot API

You now have the full backend loop this site teaches: model the domain, persist it, validate at the boundary, transact the unit of work, shape the errors, lock the doors, and prove it all with a test that hits real HTTP. The next track on the Java Learning Roadmap 2026 takes this service toward production — containers, deployment, and operating what you built.

Field check before you move on: clone this Orders API and change one thing at each layer: add a discountCode field to the request DTO with a validation rule, a DISCOUNTED branch in the service, and a derived query that finds orders by status. Then extend the integration test to cover the new field — valid code, invalid code, and no token. If you can do that without looking back at the earlier posts, the track did its job.

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number