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:
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.
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:
- Spring Boot 4 Quickstart: Your First REST API
- REST Controllers, Validation & Error Handling
- Dependency Injection Deep Dive
- JDBC First: Connections, Pools & Transactions
- Spring Data JPA: Entities, Repositories & Relationships
- JPA Performance: LAZY/EAGER, N+1, Fetch Joins & Locking
- Transactions: @Transactional, Isolation & Propagation
- Spring Security Basics: Auth for REST APIs
- 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
Post a Comment