Building Your First REST API with Spring Boot
Time to build something real. In this article you’ll create a fully functional REST API for the order-service — create, read, update, and delete orders over HTTP.
Project Setup
Start with these dependencies at start.spring.io:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
spring-boot-starter-web includes:
- Embedded Tomcat (no WAR deployment needed)
- Spring MVC (the web framework)
- Jackson (JSON serialization)
The Request Processing Pipeline
Before writing code, understand how Spring MVC handles a request:
HTTP Request arrives at Tomcat
│
▼
DispatcherServlet (front controller — all requests go here)
│
▼
HandlerMapping (which @Controller method handles this URL+method?)
│
▼
HandlerAdapter (calls the method, converts arguments)
│
├─ HttpMessageConverter reads request body (JSON → Java object)
├─ @PathVariable extracted from URL
├─ @RequestParam extracted from query string
│
▼
Your @RestController method executes
│
▼
Return value (object or ResponseEntity)
│
▼
HttpMessageConverter writes response (Java object → JSON)
│
▼
HTTP Response sent to client
@RestController
@RestController = @Controller + @ResponseBody. Every method return value is automatically serialized to JSON and written to the response body.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// all methods here handle requests under /api/orders
}
@RequestMapping at the class level sets the base URL prefix. All method-level mappings are relative to it.
HTTP Method Annotations
Spring provides shorthand annotations for each HTTP method:
| Annotation | HTTP Method | Typical Use |
|---|---|---|
@GetMapping | GET | Retrieve resource(s) |
@PostMapping | POST | Create a resource |
@PutMapping | PUT | Replace a resource |
@PatchMapping | PATCH | Partially update a resource |
@DeleteMapping | DELETE | Delete a resource |
Building the Order API
Let’s build the complete CRUD API for orders.
The Domain Model
public record Order(
UUID id,
UUID customerId,
List<OrderItem> items,
OrderStatus status,
BigDecimal totalAmount,
Instant createdAt,
Instant updatedAt
) {}
public record OrderItem(
UUID productId,
String productName,
int quantity,
BigDecimal unitPrice
) {}
public enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
Request/Response Records
// POST /api/orders body
public record CreateOrderRequest(
UUID customerId,
List<OrderItemRequest> items
) {}
public record OrderItemRequest(
UUID productId,
int quantity
) {}
// Response
public record OrderResponse(
UUID id,
UUID customerId,
List<OrderItem> items,
OrderStatus status,
BigDecimal totalAmount,
Instant createdAt
) {
public static OrderResponse from(Order order) {
return new OrderResponse(
order.id(), order.customerId(), order.items(),
order.status(), order.totalAmount(), order.createdAt()
);
}
}
The Controller
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
// GET /api/orders
@GetMapping
public List<OrderResponse> listOrders(
@RequestParam(required = false) UUID customerId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return orderService.findAll(customerId, page, size)
.stream()
.map(OrderResponse::from)
.toList();
}
// GET /api/orders/{id}
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable UUID id) {
return orderService.findById(id)
.map(order -> ResponseEntity.ok(OrderResponse.from(order)))
.orElse(ResponseEntity.notFound().build());
}
// POST /api/orders
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@RequestBody @Valid CreateOrderRequest request) {
Order order = orderService.create(request);
URI location = URI.create("/api/orders/" + order.id());
return ResponseEntity
.created(location)
.body(OrderResponse.from(order));
}
// PUT /api/orders/{id}/confirm
@PutMapping("/{id}/confirm")
public ResponseEntity<OrderResponse> confirmOrder(@PathVariable UUID id) {
return orderService.confirm(id)
.map(order -> ResponseEntity.ok(OrderResponse.from(order)))
.orElse(ResponseEntity.notFound().build());
}
// DELETE /api/orders/{id}
@DeleteMapping("/{id}")
public ResponseEntity<Void> cancelOrder(@PathVariable UUID id) {
boolean cancelled = orderService.cancel(id);
return cancelled
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
}
ResponseEntity — Full Control Over the Response
ResponseEntity<T> lets you control the status code, headers, and body:
// 200 OK with body
return ResponseEntity.ok(orderResponse);
// 201 Created with Location header and body
return ResponseEntity
.created(URI.create("/api/orders/" + order.id()))
.body(OrderResponse.from(order));
// 204 No Content (no body)
return ResponseEntity.noContent().build();
// 404 Not Found (no body)
return ResponseEntity.notFound().build();
// Custom status with body
return ResponseEntity
.status(HttpStatus.ACCEPTED)
.header("X-Order-Queue-Position", "42")
.body(OrderResponse.from(order));
When to use ResponseEntity vs returning the object directly
// Simple case — always returns 200, body is the object
@GetMapping
public List<OrderResponse> listOrders() {
return orderService.findAll();
}
// Complex case — status depends on outcome
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable UUID id) {
return orderService.findById(id)
.map(o -> ResponseEntity.ok(OrderResponse.from(o)))
.orElse(ResponseEntity.notFound().build());
}
Return the object directly when the status is always 200. Use ResponseEntity when status, headers, or conditional response are needed.
HTTP Status Codes — The Right Ones
| Scenario | Status Code | Spring constant |
|---|---|---|
| Successful read | 200 OK | HttpStatus.OK |
| Resource created | 201 Created | HttpStatus.CREATED |
| Accepted for async processing | 202 Accepted | HttpStatus.ACCEPTED |
| No content (delete/update with no body) | 204 No Content | HttpStatus.NO_CONTENT |
| Bad request (validation failed) | 400 Bad Request | HttpStatus.BAD_REQUEST |
| Not authenticated | 401 Unauthorized | HttpStatus.UNAUTHORIZED |
| Authenticated but not allowed | 403 Forbidden | HttpStatus.FORBIDDEN |
| Resource not found | 404 Not Found | HttpStatus.NOT_FOUND |
| Method not allowed | 405 Method Not Allowed | HttpStatus.METHOD_NOT_ALLOWED |
| Conflict (duplicate, version mismatch) | 409 Conflict | HttpStatus.CONFLICT |
| Server error | 500 Internal Server Error | HttpStatus.INTERNAL_SERVER_ERROR |
URL Design Best Practices
Good REST URL design is consistent and predictable:
# Collections
GET /api/orders List orders
POST /api/orders Create an order
# Single resource
GET /api/orders/{id} Get order by ID
PUT /api/orders/{id} Replace order
PATCH /api/orders/{id} Partially update order
DELETE /api/orders/{id} Delete order
# Nested resources
GET /api/orders/{id}/items List items in an order
POST /api/orders/{id}/items Add item to order
# Actions that don't map cleanly to CRUD
PUT /api/orders/{id}/confirm Confirm an order (state change)
PUT /api/orders/{id}/cancel Cancel an order
POST /api/orders/{id}/shipments Create shipment for order
# Filtering via query params (not URL segments)
GET /api/orders?status=PENDING&customerId=abc&page=0&size=20
Rules:
- Use nouns, not verbs (
/orders, not/getOrders) - Use plural nouns for collections (
/orders, not/order) - Use kebab-case for multi-word paths (
/order-items, not/orderItems) - Nest resources when they make sense hierarchically
- Use query params for filtering, sorting, pagination
JSON Serialization with Jackson
Spring Boot configures Jackson automatically. Common customizations:
spring:
jackson:
default-property-inclusion: non_null # omit null fields
serialization:
write-dates-as-timestamps: false # ISO-8601 dates
deserialization:
fail-on-unknown-properties: false # ignore extra fields
property-naming-strategy: SNAKE_CASE # camelCase → snake_case (optional)
Or configure programmatically for more control:
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> builder
.serializationInclusion(JsonInclude.Include.NON_NULL)
.featuresToDisable(
SerializationFeature.WRITE_DATES_AS_TIMESTAMPS,
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES
)
.modules(new JavaTimeModule());
}
}
Custom Serializers
Sometimes you need precise control:
public class MoneySerializer extends JsonSerializer<BigDecimal> {
@Override
public void serialize(BigDecimal value, JsonGenerator gen,
SerializerProvider provider) throws IOException {
// Always serialize money with 2 decimal places
gen.writeString(value.setScale(2, RoundingMode.HALF_UP).toPlainString());
}
}
// Apply to a field
public record OrderResponse(
@JsonSerialize(using = MoneySerializer.class)
BigDecimal totalAmount,
// ...
) {}
Testing the API
With Spring Boot, you can test the controller in isolation using MockMvc:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Autowired
private ObjectMapper objectMapper;
@Test
void shouldReturn201WhenOrderCreated() throws Exception {
var request = new CreateOrderRequest(UUID.randomUUID(), List.of(
new OrderItemRequest(UUID.randomUUID(), 2)
));
var order = Order.builder()
.id(UUID.randomUUID())
.customerId(request.customerId())
.status(OrderStatus.PENDING)
.build();
when(orderService.create(any())).thenReturn(order);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/api/orders/")))
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
void shouldReturn404WhenOrderNotFound() throws Exception {
when(orderService.findById(any())).thenReturn(Optional.empty());
mockMvc.perform(get("/api/orders/{id}", UUID.randomUUID()))
.andExpect(status().isNotFound());
}
}
Running and Testing Manually
Start the app:
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
Test with curl:
# Create an order
curl -X POST http://localhost:8080/api/orders \
-H "Content-Type: application/json" \
-d '{
"customerId": "550e8400-e29b-41d4-a716-446655440000",
"items": [
{"productId": "prod-1", "quantity": 2}
]
}'
# Get all orders
curl http://localhost:8080/api/orders
# Get specific order
curl http://localhost:8080/api/orders/550e8400-e29b-41d4-a716-446655440001
# Cancel an order
curl -X DELETE http://localhost:8080/api/orders/550e8400-e29b-41d4-a716-446655440001
What You’ve Learned
@RestControllermarks a class as a REST controller — return values are automatically JSON@GetMapping,@PostMapping,@PutMapping,@DeleteMappingmap methods to HTTP methodsResponseEntity<T>gives full control over status code, headers, and body@PathVariableextracts values from the URL;@RequestParamextracts query parameters@RequestBodydeserializes the request JSON into a Java object- Use meaningful HTTP status codes: 201 for create, 204 for delete, 404 for not found
- Design URLs with nouns, use query params for filtering
Next: Article 9 — Handling Requests: Path Variables, Query Params, and Request Bodies — the full request binding model in depth.