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:

AnnotationHTTP MethodTypical Use
@GetMappingGETRetrieve resource(s)
@PostMappingPOSTCreate a resource
@PutMappingPUTReplace a resource
@PatchMappingPATCHPartially update a resource
@DeleteMappingDELETEDelete 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

ScenarioStatus CodeSpring constant
Successful read200 OKHttpStatus.OK
Resource created201 CreatedHttpStatus.CREATED
Accepted for async processing202 AcceptedHttpStatus.ACCEPTED
No content (delete/update with no body)204 No ContentHttpStatus.NO_CONTENT
Bad request (validation failed)400 Bad RequestHttpStatus.BAD_REQUEST
Not authenticated401 UnauthorizedHttpStatus.UNAUTHORIZED
Authenticated but not allowed403 ForbiddenHttpStatus.FORBIDDEN
Resource not found404 Not FoundHttpStatus.NOT_FOUND
Method not allowed405 Method Not AllowedHttpStatus.METHOD_NOT_ALLOWED
Conflict (duplicate, version mismatch)409 ConflictHttpStatus.CONFLICT
Server error500 Internal Server ErrorHttpStatus.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

  • @RestController marks a class as a REST controller — return values are automatically JSON
  • @GetMapping, @PostMapping, @PutMapping, @DeleteMapping map methods to HTTP methods
  • ResponseEntity<T> gives full control over status code, headers, and body
  • @PathVariable extracts values from the URL; @RequestParam extracts query parameters
  • @RequestBody deserializes 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.