Spring Boot – Building a REST API

July 1, 20265 min readUpdated 8/18/2026

Building a REST API in Spring Boot is easy enough that most of the interesting decisions are not about the framework at all. They are about what you expose, what you accept, and what you say when something goes wrong.

The shape of a controller

@Tag(name = "Products", description = "Menu browsing (public)")
@RequestMapping("/api/products")
@RestController
@Slf4j
public class ProductRestController {

    @Autowired
    private ProductService productService;

    @Operation(summary = "List the active menu, optionally filtered by type")
    @GetMapping
    public ResponseEntity<List<ProductDTO>> getProducts(
            @RequestParam(required = false) ProductType type) {
        return new ResponseEntity<>(
                type == null ? productService.getMenu() : productService.getByType(type), OK);
    }
}

@RestController is @Controller + @ResponseBody: return values are serialised into the response body rather than resolved as view names. That distinction matters exactly once, in lesson 15.

Controllers should be thin. Bind the request, call one service method, choose a status. Business logic in a controller is untestable without HTTP and unreachable from anywhere else — a message listener or a scheduled job cannot call it.

Modelling URLs

Resources are nouns; the verb is the HTTP method.

GET    /api/products              list
GET    /api/products/{id}         one
POST   /api/admin/products        create
PUT    /api/admin/products/{id}   replace
PATCH  /api/admin/products/{id}/deactivate   partial state change
DELETE /api/admin/products/{id}   delete

# not this
POST /api/getProducts
POST /api/products/delete/{id}

The pizza API puts admin endpoints under a separate /api/admin/** prefix rather than mixing them into the same path. That is a security decision as much as a design one: it lets the whole admin surface be protected by a single matcher instead of per-method annotations that are easy to forget on a new endpoint.

Status codes that mean something

// 201 for a creation
@PostMapping
public ResponseEntity<ProductDTO> createProduct(@Valid @RequestBody ProductCreateDTO dto) {
    return new ResponseEntity<>(productService.createProduct(dto), CREATED);
}

// 204 when there is genuinely nothing to return
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable UUID id) {
    productService.deleteProduct(id);
    return ResponseEntity.noContent().build();
}
CodeMeans
200fine, here it is
201created
204fine, nothing to send
400your request is wrong
401who are you?
403I know who you are; no
404no such thing
409conflicts with current state
500our fault

The 4xx/5xx line is the one that matters operationally. A 500 should mean "we have a bug"; if malformed input produces one, your error rate becomes meaningless and every alert is noise. Lesson 12 is largely about keeping bad input in the 4xx range.

DTOs, and why not entities

Returning JPA entities directly is the most common early mistake, and the pizza API's ProductDTO documents all three reasons:

  • Lazy collections blow up. Jackson serialises outside the transaction, so a lazy association throws LazyInitializationException — or, worse, triggers a cascade of queries that serialises half your database.
  • The entity's shape is a database concern. Renaming a column should not break every client.
  • It leaks. User has a passwordHash. One return user; publishes it.

Use separate DTOs for request and response. The response carries an id; the request must not let a client choose one:

public record ProductDTO(
        UUID id,                     // response only
        String name,
        String description,
        ProductType type,
        String imageUrl,
        boolean active,
        Integer displayOrder,
        List<ProductSizeDTO> sizes,
        LocalDateTime createdAt,
        LocalDateTime updatedAt) {}

public record ProductCreateDTO(      // request only — no id, no timestamps
        @NotBlank @Size(max = 120) String name,
        @Size(max = 500) String description,
        @NotNull ProductType type,
        @Size(max = 500) String imageUrl,
        Boolean active,
        Integer displayOrder,
        @NotEmpty(message = "A product needs at least one size") @Valid List<SizeDTO> sizes) { }

Reusing one class for both directions is how mass-assignment bugs get in: the client sends a field you never intended to be settable, and the binder happily sets it. The pizza API's registration endpoint is the sharp version of this — RegisterDTO has no role field at all, so a role can never arrive from a request body.

Records are ideal for DTOs: immutable, concise, and Jackson supports them natively.

Validation

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

@Valid on the parameter is what actually runs the constraints. Annotating the DTO alone does nothing — a genuinely common and completely silent mistake:

public ResponseEntity<ProductDTO> createProduct(@Valid @RequestBody ProductCreateDTO dto) { }
//                                              ▲ without this, the constraints are ignored

Nested objects need @Valid too — note it on the sizes list above, without which each SizeDTO's own constraints are skipped.

Custom messages are worth writing. "A product needs at least one size" is something a UI can display; "must not be empty" is not.

Expose a UUID, not your primary key

Every table in the pizza API has a BIGINT primary key for internal foreign keys and a public_id UUID. Only the UUID is ever published.

@Schema(description = "Public UUID — use this in every request")
UUID id,

Sequential ids are guessable. GET /api/orders/1041 invites GET /api/orders/1042, and if authorization is anything less than perfect that is a data breach with a trivial exploit. They also leak business information: order 1041 today and 1052 tomorrow tells a competitor your daily volume.

The pizza API is honest about where it did not finish this job:

// LIMITATION: ids are sequential, so anyone could walk them. A production
// system would use an unguessable reference (UUID) or a signed link.
// Left simple here deliberately, and called out rather than hidden.
.requestMatchers(HttpMethod.GET, "/api/orders/*", "/api/orders/*/payment-status")
.permitAll()

Never trust the client for anything that matters

The single most exploitable e-commerce bug is trusting a client-sent price. The pizza API's OrderCreateDTO carries no prices at all — the request chooses which products, and PricingService reads every figure from the database:

/**
 * <b>This class is the security boundary of the whole checkout.</b> Every figure it produces
 * comes from the product_size, crust and topping tables. Nothing in OrderCreateDTO influences
 * a price — the request only chooses WHICH rows apply.
 */
@Service
public class PricingService { }

The general rule: a field a client can set is a field a client can set to anything. Prices, roles, ownership and status all belong to the server.

What to take from this

  • Thin controllers. Bind, delegate, choose a status.
  • Separate request and response DTOs, and never return entities.
  • @Valid on the parameter, and again on nested collections.
  • Expose UUIDs, and recompute anything that matters on the server.

Next: exception handling — one error shape for every endpoint.