Spring Boot REST API Tutorial

Watch on YouTube

Spring Boot REST API Tutorial

In simple terms: A REST API exposes your application's data and actions as HTTP resources. Each endpoint maps an HTTP method and path to a piece of behavior, and returns a status code that tells the client what actually happened.
REST API request flow from a client through the Spring Boot controller, service, repository, and database, returning a JSON response
The usual Spring Boot REST API path: a request enters the controller, business rules run in the service, data access happens in the repository, and the client receives JSON.
Typical API request flow
ClientGET /users/1
ControllerMaps the request
ServiceApplies business rules
JSONReturns the response

HTTP design basics

Before writing controllers, agree on what each HTTP method means for your resources. Consistent method usage lets clients (and caches, proxies, and browsers) make correct assumptions about safety and retry behavior.

Method Purpose Typical response
GET Read data 200 OK
POST Create data 201 Created
PUT/PATCH Update data 200 OK
DELETE Remove data 204 No Content

HTTP methods explained with a store example

Imagine a product resource at /api/products. The URL names the resource; the HTTP method tells the server what action the client wants. A method should be used consistently so mobile apps, browsers, and other services can understand the API.

Method Example Meaning Safe to repeat?
GET GET /api/products/10 Read product 10 Yes
POST POST /api/products Create a new product Usually no
PUT PUT /api/products/10 Replace the complete product Yes
PATCH PATCH /api/products/10 Change selected fields Design carefully
DELETE DELETE /api/products/10 Remove product 10 Yes
REST API CRUD operations infographic showing GET Read, POST Create, PUT Update, and DELETE Remove with product endpoints
CRUD maps the four core data actions to familiar HTTP methods. PATCH is also useful when only selected fields need to change.

GET: read resources

Use GET when the client wants data and does not want to change the database. Filters, pagination, and sorting belong in query parameters.

GET /api/products?page=0&size=20&sort=name,asc
GET /api/products/10

Typical responses are 200 OK when data is found and 404 Not Found when a single product does not exist.

POST: create a resource

Use POST when the server generates the new resource ID. Return 201 Created and a Location header pointing to the created resource.

POST /api/products
Content-Type: application/json

{
  "name": "Mechanical Keyboard",
  "price": 2500.00,
  "stock": 25
}

PUT: replace a complete resource

PUT replaces the complete representation at a known URL. Require all updateable fields in the request so that an omitted field does not get accidentally erased or assigned an unexpected default.

PUT /api/products/10
Content-Type: application/json

{
  "name": "Mechanical Keyboard Pro",
  "price": 3200.00,
  "stock": 18
}

PATCH: update selected fields

PATCH changes only the fields included in the request. It is useful for a quick stock or price update. The service must distinguish between an omitted field and a field explicitly set to null if null is meaningful in your application.

PATCH /api/products/10
Content-Type: application/json

{
  "price": 2999.00
}

DELETE: remove a resource

DELETE removes a resource or marks it inactive when your application uses soft deletes. Return 204 No Content after a successful deletion. Do not return a misleading success when the resource never existed; use 404 Not Found when that distinction matters.

DELETE /api/products/10

204 No Content

A complete CRUD example

Detailed Spring Boot REST API execution flow from a client request through the servlet container, DispatcherServlet, filters, controller, service, repository, database, and response
Your REST API reference diagram shows the deeper Spring MVC lifecycle, including filters, controller dispatch, exception handling, and the response flow back to the client.

The controller below manages a simple Task resource. It supports listing, reading one task, creating, updating, and deleting — the five operations almost every resource needs. Notice how each method returns a ResponseEntity so it can control the exact status code, not just the body.

@RestController
@RequestMapping("/api/tasks")
class TaskController {
    private final TaskService service;

    TaskController(TaskService service) {
        this.service = service;
    }

    @GetMapping
    List<TaskResponse> list() {
        return service.findAll();
    }

    @GetMapping("/{id}")
    ResponseEntity<TaskResponse> getById(@PathVariable Long id) {
        return service.findById(id)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PostMapping
    ResponseEntity<TaskResponse> create(@Valid @RequestBody CreateTaskRequest request) {
        TaskResponse created = service.create(request);
        URI location = URI.create("/api/tasks/" + created.id());
        return ResponseEntity.created(location).body(created);
    }

    @PutMapping("/{id}")
    ResponseEntity<TaskResponse> update(@PathVariable Long id, @Valid @RequestBody UpdateTaskRequest request) {
        return service.update(id, request)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    ResponseEntity<Void> delete(@PathVariable Long id) {
        boolean removed = service.delete(id);
        return removed ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
    }
}
Tip: POST that creates a resource should return 201 Created with a Location header pointing at the new resource. ResponseEntity.created(location) sets that header for you.

End-to-end product API

The following example shows how a real application separates HTTP, business logic, and database access. A request travels through the controller, service, and repository, then the result is mapped to a response DTO.

1. Entity and request DTOs

@Entity
@Table(name = "products")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private BigDecimal price;
    private int stock;

    // constructors, getters, and setters
}

public record CreateProductRequest(
        @NotBlank(message = "Name is required") String name,
        @Positive(message = "Price must be greater than zero")
        BigDecimal price,
        @PositiveOrZero(message = "Stock cannot be negative") int stock) {
}

public record UpdateProductRequest(
        @NotBlank String name,
        @Positive BigDecimal price,
        @PositiveOrZero int stock) {
}

public record ProductPatchRequest(
        String name,
        BigDecimal price,
        Integer stock) {
}

2. Repository

public interface ProductRepository
        extends JpaRepository<Product, Long> {

    Page<Product> findByNameContainingIgnoreCase(
            String name, Pageable pageable);
}

3. Service

The service owns business rules such as checking stock and deciding what to do when a product is missing. It does not know anything about HTTP status codes.

@Service
@Transactional
public class ProductService {
    private final ProductRepository repository;

    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }

    @Transactional(readOnly = true)
    public Page<Product> findAll(String name, Pageable pageable) {
        return name == null || name.isBlank()
                ? repository.findAll(pageable)
                : repository.findByNameContainingIgnoreCase(name, pageable);
    }

    @Transactional(readOnly = true)
    public Product findById(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }

    public Product create(CreateProductRequest request) {
        Product product = new Product();
        product.setName(request.name());
        product.setPrice(request.price());
        product.setStock(request.stock());
        return repository.save(product);
    }

    public Product replace(Long id, UpdateProductRequest request) {
        Product product = findById(id);
        product.setName(request.name());
        product.setPrice(request.price());
        product.setStock(request.stock());
        return product;
    }

    public Product patch(Long id, ProductPatchRequest request) {
        Product product = findById(id);
        if (request.name() != null) product.setName(request.name());
        if (request.price() != null) product.setPrice(request.price());
        if (request.stock() != null) product.setStock(request.stock());
        return product;
    }

    public void delete(Long id) {
        Product product = findById(id);
        repository.delete(product);
    }
}

4. Controller

@RestController
@RequestMapping("/api/products")
public class ProductController {
    private final ProductService service;

    public ProductController(ProductService service) {
        this.service = service;
    }

    @GetMapping
    public Page<Product> findAll(
            @RequestParam(required = false) String name,
            @PageableDefault(size = 20, sort = "name")
            Pageable pageable) {
        return service.findAll(name, pageable);
    }

    @GetMapping("/{id}")
    public Product findById(@PathVariable Long id) {
        return service.findById(id);
    }

    @PostMapping
    public ResponseEntity<Product> create(
            @Valid @RequestBody CreateProductRequest request) {
        Product created = service.create(request);
        URI location = URI.create("/api/products/" + created.getId());
        return ResponseEntity.created(location).body(created);
    }

    @PutMapping("/{id}")
    public Product replace(@PathVariable Long id,
                           @Valid @RequestBody UpdateProductRequest request) {
        return service.replace(id, request);
    }

    @PatchMapping("/{id}")
    public Product patch(@PathVariable Long id,
                         @RequestBody ProductPatchRequest request) {
        return service.patch(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        service.delete(id);
    }
}

5. Test the API

# List products
curl "http://localhost:8080/api/products?page=0&size=10&sort=name,asc"

# Create a product
curl -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Monitor","price":18000,"stock":12}'

# Read one product
curl http://localhost:8080/api/products/1

# Replace the complete product
curl -X PUT http://localhost:8080/api/products/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"4K Monitor","price":22000,"stock":8}'

# Update only the stock
curl -X PATCH http://localhost:8080/api/products/1 \
  -H "Content-Type: application/json" \
  -d '{"stock":25}'

# Delete a product
curl -X DELETE http://localhost:8080/api/products/1

Build a PostgreSQL-backed CRUD API

An in-memory list is useful for learning controller mappings, but real applications need durable storage. PostgreSQL keeps records after a restart, supports transactions and constraints, and gives the API a reliable source of truth. Spring Boot connects the HTTP layer to PostgreSQL through Spring Data JPA and the PostgreSQL JDBC driver.

PostgreSQL CRUD request flow
REST clientJSON request
ControllerHTTP contract
ServiceBusiness rules
RepositoryJPA queries
PostgreSQLPersistent rows

1. Add the PostgreSQL dependencies

Generate a Spring Boot project with Spring Web, Spring Data JPA, PostgreSQL Driver, and Validation. Spring Web handles HTTP requests, JPA maps entities to tables, the driver opens the database connection, and Validation checks request data before it reaches the service.

<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.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

2. Configure PostgreSQL safely

Create a database such as taskdb, then configure the connection in application.properties. Do not commit a real password. Use environment variables or a secret manager outside local development.

spring.datasource.url=jdbc:postgresql://localhost:5432/taskdb
spring.datasource.username=${DB_USERNAME:postgres}
spring.datasource.password=${DB_PASSWORD:change-me}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
spring.jpa.show-sql=false

ddl-auto=validate checks that the schema matches the entities without silently changing production tables. Use Flyway or Liquibase for versioned schema migrations. For a throwaway local demo, create-drop or update can be convenient, but they should not replace controlled migrations in production.

3. Map the entity and repository

The entity represents a database row. The repository provides the common CRUD operations without handwritten SQL. Keep request and response DTOs separate from the entity so internal columns are not accidentally exposed by the REST API.

@Entity
@Table(name = "tasks")
public class Task {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 160)
    private String title;
    private boolean done;
    // getters and setters
}

public interface TaskRepository extends JpaRepository<Task, Long> {
    Page<Task> findByTitleContainingIgnoreCase(String title, Pageable pageable);
}

4. Put CRUD rules in the service

The service is the boundary for database transactions and business rules. It should decide whether a missing record becomes a not-found error, apply updates, and return data that the controller can map to a safe DTO.

@Service
@Transactional
class TaskService {
    private final TaskRepository repository;

    TaskService(TaskRepository repository) {
        this.repository = repository;
    }

    @Transactional(readOnly = true)
    Page<Task> findAll(Pageable pageable) {
        return repository.findAll(pageable);
    }

    Task findRequired(Long id) {
        return repository.findById(id)
            .orElseThrow(() -> new TaskNotFoundException(id));
    }

    Task create(CreateTaskRequest request) {
        Task task = new Task();
        task.setTitle(request.title());
        return repository.save(task);
    }

    Task replace(Long id, UpdateTaskRequest request) {
        Task task = findRequired(id);
        task.setTitle(request.title());
        task.setDone(request.done());
        return task;
    }

    void delete(Long id) {
        repository.delete(findRequired(id));
    }
}

5. Expose all CRUD endpoints

The controller translates HTTP requests into service calls. Create returns 201 Created, reads return 200 OK, a successful replacement returns 200 OK or 204 No Content, and deletion returns 204 No Content. Missing IDs should consistently return 404 Not Found through the global exception handler.

@RestController
@RequestMapping("/api/tasks")
class TaskController {
    private final TaskService service;

    @GetMapping
    Page<TaskResponse> list(Pageable pageable) {
        return service.findAll(pageable).map(TaskResponse::from);
    }

    @GetMapping("/{id}")
    TaskResponse get(@PathVariable Long id) {
        return TaskResponse.from(service.findRequired(id));
    }

    @PostMapping
    ResponseEntity<TaskResponse> create(@Valid @RequestBody CreateTaskRequest request) {
        TaskResponse response = TaskResponse.from(service.create(request));
        URI location = URI.create("/api/tasks/" + response.id());
        return ResponseEntity.created(location).body(response);
    }

    @PutMapping("/{id}")
    TaskResponse replace(@PathVariable Long id,
                         @Valid @RequestBody UpdateTaskRequest request) {
        return TaskResponse.from(service.replace(id, request));
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    void delete(@PathVariable Long id) {
        service.delete(id);
    }
}

6. Test the PostgreSQL CRUD flow

Test the complete lifecycle against a dedicated local database or a disposable container. Verify both the HTTP contract and the database state: a created row should be readable, an update should persist after a new request, and a deleted row should return 404 on the next read.

# Create
curl -i -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Connect PostgreSQL","done":false}'

# Read all and read one
curl "http://localhost:8080/api/tasks?page=0&size=10"
curl http://localhost:8080/api/tasks/1

# Replace the complete resource
curl -i -X PUT http://localhost:8080/api/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"title":"Verify PostgreSQL CRUD","done":true}'

# Delete
curl -i -X DELETE http://localhost:8080/api/tasks/1
Production checklist: Use migrations, environment-based credentials, database constraints, pagination, DTOs, transaction boundaries, validation, and integration tests before exposing a PostgreSQL-backed CRUD API to real clients.

@PathVariable, @RequestParam, and @RequestBody

Spring MVC pulls request data into method parameters using a small set of annotations. Each one targets a different part of the HTTP request.

@PathVariable reads a segment of the URL path — use it for values that identify a specific resource.

@GetMapping("/{id}")
ResponseEntity<TaskResponse> getById(@PathVariable Long id) {
    return service.findById(id)
            .map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.notFound().build());
}

@RequestParam reads a query string value. It can be optional, and you can supply a default so the parameter is never null.

@GetMapping
List<TaskResponse> list(
        @RequestParam(required = false) String status,
        @RequestParam(defaultValue = "false") boolean overdueOnly) {
    return service.findAll(status, overdueOnly);
}

@RequestBody deserializes the JSON request body into a Java object, typically a DTO record annotated with validation constraints.

@PostMapping
ResponseEntity<TaskResponse> create(@Valid @RequestBody CreateTaskRequest request) {
    TaskResponse created = service.create(request);
    return ResponseEntity.created(URI.create("/api/tasks/" + created.id())).body(created);
}

Which request annotation should you use?

Choose the annotation based on where the value belongs in the HTTP request. A simple rule is: use @PathVariable to identify a resource, @RequestParam to filter or control a request, and @RequestBody to send a new or changed resource as JSON.

Annotation Reads from Use it for Example
@PathVariable URL path Identifying one resource /api/products/42
@RequestParam Query string Filtering, searching, paging, and sorting /api/products?category=laptop
@RequestBody JSON request body Creating or updating multiple fields {"name":"Laptop","price":75000}

When to use @PathVariable

Use @PathVariable when the value identifies the resource being addressed. The value is normally required, and changing it means addressing a different resource.

GET /api/products/42

@GetMapping("/products/{id}")
public Product findProduct(@PathVariable Long id) {
    return productService.findById(id);
}

Here, 42 is not a filter. It identifies one product, so it belongs in the URL path.

When to use @RequestParam

Use @RequestParam for optional or changeable instructions about a collection: search text, category filters, page number, page size, sorting, or flags. Query parameters should not change the identity of the resource.

GET /api/products?category=office&minPrice=1000&page=0&size=20

@GetMapping("/products")
public Page<Product> search(
        @RequestParam(required = false) String category,
        @RequestParam(required = false) BigDecimal minPrice,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {
    return productService.search(category, minPrice, page, size);
}

The same endpoint can support different searches without creating a new URL for every combination of filters.

When to use @RequestBody

Use @RequestBody when the client sends structured data, usually JSON, to create or update a resource. Spring Boot uses Jackson to convert the JSON into a Java DTO. The client should send the header Content-Type: application/json.

POST /api/products
Content-Type: application/json

{
  "name": "Laptop",
  "price": 75000.00,
  "stock": 12
}

public record CreateProductRequest(
        @NotBlank String name,
        @Positive BigDecimal price,
        @PositiveOrZero int stock) {
}

public record ProductResponse(
        Long id, String name, BigDecimal price, int stock) {
}

@PostMapping("/products")
public ResponseEntity<ProductResponse> create(
        @Valid @RequestBody CreateProductRequest request) {
    ProductResponse created = productService.create(request);
    URI location = URI.create("/api/products/" + created.id());
    return ResponseEntity.created(location).body(created);
}

End-to-end JSON request processing

When a client creates a product, the JSON travels through the following layers. Each layer has one responsibility, which makes the application easier to test and maintain.

JSON create request flow
JSONClient sends product
ControllerDeserialize and validate
ServiceApply business rules
RepositorySave database row

1. Controller receives the JSON

@RestController
@RequestMapping("/api/products")
public class ProductController {
    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @PostMapping
    public ResponseEntity<ProductResponse> create(
            @Valid @RequestBody CreateProductRequest request) {
        ProductResponse response = productService.create(request);
        return ResponseEntity
                .created(URI.create("/api/products/" + response.id()))
                .body(response);
    }
}

2. Service applies business rules

@Service
public class ProductService {
    private final ProductRepository repository;

    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public ProductResponse create(CreateProductRequest request) {
        if (repository.existsByNameIgnoreCase(request.name())) {
            throw new DuplicateProductException(request.name());
        }

        Product product = new Product();
        product.setName(request.name());
        product.setPrice(request.price());
        product.setStock(request.stock());

        Product saved = repository.save(product);
        return new ProductResponse(
                saved.getId(), saved.getName(),
                saved.getPrice(), saved.getStock());
    }
}

3. Repository saves the entity

public interface ProductRepository
        extends JpaRepository<Product, Long> {

    boolean existsByNameIgnoreCase(String name);
}

4. The client receives a response

HTTP/1.1 201 Created
Location: /api/products/101
Content-Type: application/json

{
  "id": 101,
  "name": "Laptop",
  "price": 75000.00,
  "stock": 12
}

If the JSON is invalid or a required field is missing, validation stops the request before the service runs. A global exception handler can return 400 Bad Request with field-specific messages.

Response shape: ResponseEntity<T> vs a bare object

You can return a plain object from a controller method and Spring will serialize it with a 200 OK status. That is fine for simple, always-successful reads. Reach for ResponseEntity<T> whenever the status code, headers, or the presence of a body can vary — for example, a lookup that might not find anything, or a create operation that needs a Location header.

Return type Use when
Bare object (e.g. TaskResponse) The operation always succeeds and always returns 200 OK with the same shape.
ResponseEntity<T> You need a different status per outcome (404, 201, 204), custom headers, or no body at all.
Warning: Returning null from a bare-object method still sends 200 OK with an empty body, which is misleading to clients. Use ResponseEntity.notFound().build() instead when a resource is missing.

Pagination and sorting

Returning an entire table as one JSON array does not scale. Spring Data's Pageable lets a controller accept page, size, and sort query parameters automatically, and a repository can return a Page<T> that carries the data plus paging metadata.

interface TaskRepository extends JpaRepository<Task, Long> {
}

@GetMapping
Page<TaskResponse> list(@PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
    return repository.findAll(pageable).map(TaskMapper::toResponse);
}

A request like GET /api/tasks?page=1&size=10&sort=title,asc is bound directly into the Pageable argument — no manual parsing required. The resulting Page<T> serializes with content, totalElements, totalPages, and the current page number.

API versioning strategies

APIs evolve. Pick a versioning strategy before you need it, and keep it consistent across endpoints.

Strategy Example Trade-off
URI path /api/v1/tasks Simple, visible, but multiplies routes across versions.
Custom header X-API-Version: 2 Keeps URLs stable, but is less discoverable and harder to test in a browser.
Accept header (media type) Accept: application/vnd.app.v2+json Follows HTTP content negotiation closely, but adds client complexity.

DTO mapping pattern

A DTO (data transfer object) describes exactly what a client sends or receives. An entity describes how data is persisted. They usually diverge: an entity might carry lazy associations, audit columns, or internal flags that should never appear in an HTTP response, and a create request rarely matches every entity field one-to-one.

// What the database stores
@Entity
class Task {
    @Id @GeneratedValue Long id;
    String title;
    boolean done;
    Instant createdAt;
    @ManyToOne User owner;
}

// What a client sends to create a task
record CreateTaskRequest(@NotBlank String title) {}

// What a client receives back
record TaskResponse(Long id, String title, boolean done, Instant createdAt) {}

class TaskMapper {
    static TaskResponse toResponse(Task task) {
        return new TaskResponse(task.getId(), task.getTitle(), task.isDone(), task.getCreatedAt());
    }

    static Task toEntity(CreateTaskRequest request) {
        Task task = new Task();
        task.setTitle(request.title());
        return task;
    }
}

Mapping by hand (or with a library like MapStruct) keeps persistence details out of your public contract. Exposing entities directly tends to leak internal fields, breaks when you refactor the schema, and can trigger lazy-loading errors when Jackson tries to serialize an association outside a transaction.

Remember: Treat your DTOs as the real API contract. You can rename database columns, split tables, or change relationships without breaking clients, as long as the DTO shape stays the same.

Control responses with ResponseEntity

Returning an object is convenient when every successful request has the same response behavior. Use ResponseEntity<T> when the controller must choose the HTTP status, headers, or body at runtime. This is useful for created resources, validation failures, missing records, and responses that need location metadata.

@PostMapping
ResponseEntity<TaskResponse> create(@Valid @RequestBody CreateTaskRequest request) {
    Task task = taskService.create(request);
    URI location = URI.create("/api/tasks/" + task.getId());

    return ResponseEntity
        .created(location)
        .body(taskMapper.toResponse(task));
}

@GetMapping("/{id}")
ResponseEntity<TaskResponse> findOne(@PathVariable long id) {
    return taskService.findById(id)
        .map(task -> ResponseEntity.ok(taskMapper.toResponse(task)))
        .orElseGet(() -> ResponseEntity.notFound().build());
}

Common builder methods include ok() for a successful body, created(location) for a new resource, noContent() when there is nothing to return, and badRequest() or notFound() for client errors. Prefer a consistent error body across the API so clients can handle failures without endpoint-specific rules.

Read and write HTTP headers

HTTP headers carry metadata around a request or response. Spring MVC can bind a single header with @RequestHeader, while HttpHeaders is useful when an endpoint needs to inspect several values or add response metadata.

@GetMapping("/search")
ResponseEntity<List<TaskResponse>> search(
        @RequestHeader(value = "X-Correlation-Id", required = false) String correlationId,
        @RequestParam String query) {
    List<TaskResponse> results = taskService.search(query);
    HttpHeaders headers = new HttpHeaders();
    headers.set("X-Correlation-Id", correlationId != null ? correlationId : UUID.randomUUID().toString());
    headers.setCacheControl(CacheControl.noCache());
    return new ResponseEntity<>(results, headers, HttpStatus.OK);
}

Use standard headers such as Location, Cache-Control, ETag, and Content-Type when they describe real HTTP behavior. Do not place passwords, access tokens, or other secrets in custom headers or log them without redaction. Headers are part of the public API contract, so document required values and validate them at the boundary.

PUT vs POST in REST APIs

Choose the HTTP method based on the operation's meaning. POST usually asks the server to create a new resource under a collection. The server chooses the identifier, so repeating the same request may create more than one resource. Return 201 Created and a Location header when creation succeeds.

PUT replaces the representation at a client-known URI, such as /api/tasks/42. A well-designed PUT is idempotent: sending the same complete representation several times leaves the resource in the same state. Return 200 OK with a representation or 204 No Content when the update succeeds without a body. Use PATCH when the API intentionally supports partial updates.

Method Typical use Example Idempotent?
POST Create under a collection or trigger a command POST /api/tasks Usually no
PUT Replace a known resource PUT /api/tasks/42 Yes, when designed correctly
PATCH Apply selected field changes PATCH /api/tasks/42 Depends on the operation

Test response status and headers

REST tests should verify more than a response body. Check the status code, content type, important headers, and JSON fields that form the client contract. A focused MVC test keeps the feedback loop fast while still checking controller mappings and validation behavior.

mockMvc.perform(post("/api/tasks")
        .contentType(MediaType.APPLICATION_JSON)
        .content("{\"title\":\"Write tests\"}"))
    .andExpect(status().isCreated())
    .andExpect(header().exists(HttpHeaders.LOCATION))
    .andExpect(jsonPath("$.title").value("Write tests"));
Next: Validation →

Continue learning