REST Apis

Why REST APIs Matter

RESTful APIs enable client-server communication through standardized HTTP methods and JSON/XML representations. Manual REST implementation with servlets requires explicit JSON serialization, content-type handling, and status code management. In production API services handling millions of requests daily, Spring’s @RestController eliminates boilerplate while providing content negotiation, automatic serialization, and HATEOAS support for hypermedia-driven APIs.

Manual REST with HttpServletResponse Baseline

Manual REST API requires explicit JSON handling and HTTP semantics:

import jakarta.servlet.http.*;
import java.io.IOException;
import java.io.PrintWriter;
import com.fasterxml.jackson.databind.ObjectMapper;

// => Zakat API servlet: manual JSON REST implementation
public class ZakatApiServlet extends HttpServlet {

    private final ObjectMapper objectMapper = new ObjectMapper();
    // => ObjectMapper: Jackson JSON serializer/deserializer
    private final ZakatService zakatService;

    // => GET: retrieve zakat calculation
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        // => Extract path parameter manually: /api/zakat/accounts/ACC123
        String pathInfo = request.getPathInfo();  // => "/accounts/ACC123"
        // => Manual path parsing: split and extract ID
        if (pathInfo == null || !pathInfo.startsWith("/accounts/")) {
            // => Manual error response: 404 Not Found
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.setContentType("application/json");
            PrintWriter out = response.getWriter();
            // => Manual JSON construction: error-prone, no type safety
            out.println("{\"error\": \"Resource not found\"}");
            return;
        }

        String accountId = pathInfo.substring("/accounts/".length());

        try {
            // => Business logic: retrieve account
            ZakatAccount account = zakatService.getAccount(accountId);

            // => Manual JSON serialization: ObjectMapper converts object to JSON
            String jsonResponse = objectMapper.writeValueAsString(account);

            // => Manual response configuration: status, content-type, body
            response.setStatus(HttpServletResponse.SC_OK);  // => 200 OK
            response.setContentType("application/json; charset=UTF-8");  // => Content-Type header
            PrintWriter out = response.getWriter();
            out.print(jsonResponse);  // => JSON body

        } catch (AccountNotFoundException e) {
            // => Manual exception to HTTP status mapping
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);  // => 404 Not Found
            response.setContentType("application/json");
            PrintWriter out = response.getWriter();
            // => Manual error object construction
            out.println("{\"error\": \"Account not found: " + accountId + "\"}");
        }
    }

    // => POST: create zakat record
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        // => Manual request body reading: InputStream to String
        StringBuilder jsonBuilder = new StringBuilder();
        String line;
        // => Read entire request body
        while ((line = request.getReader().readLine()) != null) {
            jsonBuilder.append(line);
        }
        String jsonRequest = jsonBuilder.toString();

        try {
            // => Manual JSON deserialization: String to object
            ZakatCalculationRequest calcRequest = objectMapper.readValue(
                jsonRequest,
                ZakatCalculationRequest.class
            );

            // => Manual validation: check required fields
            if (calcRequest.getAccountId() == null || calcRequest.getAccountId().isEmpty()) {
                // => Validation error: 400 Bad Request
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.setContentType("application/json");
                PrintWriter out = response.getWriter();
                out.println("{\"error\": \"Missing required field: accountId\"}");
                return;
            }

            if (calcRequest.getWealth() == null) {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.setContentType("application/json");
                PrintWriter out = response.getWriter();
                out.println("{\"error\": \"Missing required field: wealth\"}");
                return;
            }

            // => Business logic: calculate and save
            BigDecimal zakatAmount = zakatService.calculateAndSave(
                calcRequest.getAccountId(),
                calcRequest.getWealth()
            );

            // => Create response object
            ZakatCalculationResponse calcResponse = new ZakatCalculationResponse(
                calcRequest.getAccountId(),
                calcRequest.getWealth(),
                zakatAmount
            );

            // => Manual JSON serialization
            String jsonResponse = objectMapper.writeValueAsString(calcResponse);

            // => Manual response: 201 Created with Location header
            response.setStatus(HttpServletResponse.SC_CREATED);  // => 201 Created
            // => Location header: URL of created resource
            response.setHeader("Location", "/api/zakat/accounts/" + calcRequest.getAccountId());
            response.setContentType("application/json; charset=UTF-8");
            PrintWriter out = response.getWriter();
            out.print(jsonResponse);

        } catch (IOException e) {
            // => JSON parsing error: 400 Bad Request
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.setContentType("application/json");
            PrintWriter out = response.getWriter();
            out.println("{\"error\": \"Invalid JSON format\"}");
        }
    }

    // => PUT: update zakat record
    @Override
    protected void doPut(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        // => Extract ID from path
        String pathInfo = request.getPathInfo();
        if (pathInfo == null || !pathInfo.startsWith("/accounts/")) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.setContentType("application/json");
            response.getWriter().println("{\"error\": \"Resource not found\"}");
            return;
        }

        String accountId = pathInfo.substring("/accounts/".length());

        // => Read and parse request body
        StringBuilder jsonBuilder = new StringBuilder();
        String line;
        while ((line = request.getReader().readLine()) != null) {
            jsonBuilder.append(line);
        }

        try {
            ZakatUpdateRequest updateRequest = objectMapper.readValue(
                jsonBuilder.toString(),
                ZakatUpdateRequest.class
            );

            // => Business logic: update
            zakatService.updateAccount(accountId, updateRequest.getWealth());

            // => Manual response: 204 No Content
            response.setStatus(HttpServletResponse.SC_NO_CONTENT);  // => 204 No Content
            // => No body for 204 response

        } catch (IOException e) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.setContentType("application/json");
            response.getWriter().println("{\"error\": \"Invalid JSON format\"}");
        }
    }

    // => DELETE: remove zakat record
    @Override
    protected void doDelete(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        // => Extract ID from path
        String pathInfo = request.getPathInfo();
        if (pathInfo == null || !pathInfo.startsWith("/accounts/")) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.setContentType("application/json");
            response.getWriter().println("{\"error\": \"Resource not found\"}");
            return;
        }

        String accountId = pathInfo.substring("/accounts/".length());

        try {
            // => Business logic: delete
            zakatService.deleteAccount(accountId);

            // => Manual response: 204 No Content
            response.setStatus(HttpServletResponse.SC_NO_CONTENT);

        } catch (AccountNotFoundException e) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.setContentType("application/json");
            response.getWriter().println("{\"error\": \"Account not found: " + accountId + "\"}");
        }
    }
}

Limitations:

  • Boilerplate: 80+ lines for CRUD operations
  • Manual JSON handling: Explicit serialization/deserialization
  • No content negotiation: Hardcoded application/json
  • Manual status codes: Must set status for every response
  • Path parsing: Manual extraction of path variables
  • No validation framework: Manual field checking

Spring @RestController Solution

Spring @RestController eliminates JSON boilerplate and automates HTTP semantics:

import org.springframework.web.bind.annotation.*;
import org.springframework.http.*;
import jakarta.validation.Valid;

// => @RestController: combines @Controller + @ResponseBody
// => All methods return JSON (not view names)
@RestController  // => Automatic JSON serialization for all methods
@RequestMapping("/api/zakat")  // => Base path: /api/zakat
public class ZakatRestController {

    private final ZakatService zakatService;

    public ZakatRestController(ZakatService zakatService) {
        this.zakatService = zakatService;
    }

    // => GET: retrieve account
    @GetMapping("/accounts/{accountId}")  // => GET /api/zakat/accounts/ACC123
    // => @PathVariable: extracts {accountId} from URL path
    public ZakatAccount getAccount(@PathVariable String accountId) {
        // => Business logic: retrieve account
        ZakatAccount account = zakatService.getAccount(accountId);
        // => Return object: Spring automatically serializes to JSON
        // => Content-Type: application/json (automatic)
        // => HTTP status: 200 OK (automatic)
        return account;
    }

    // => POST: create zakat calculation
    @PostMapping("/calculations")  // => POST /api/zakat/calculations
    // => @Valid: triggers Bean Validation before method execution
    // => @RequestBody: deserializes JSON request body to object
    public ResponseEntity<ZakatCalculationResponse> createCalculation(
            @Valid @RequestBody ZakatCalculationRequest request) {

        // => Business logic: calculate and save
        BigDecimal zakatAmount = zakatService.calculateAndSave(
            request.getAccountId(),
            request.getWealth()
        );

        // => Create response object
        ZakatCalculationResponse response = new ZakatCalculationResponse(
            request.getAccountId(),
            request.getWealth(),
            zakatAmount
        );

        // => ResponseEntity: full control over HTTP response
        // => created(): returns 201 Created with Location header
        // => URI: location of created resource
        return ResponseEntity
            .created(URI.create("/api/zakat/accounts/" + request.getAccountId()))
            // => body(): response body (serialized to JSON)
            .body(response);
    }

    // => PUT: update account
    @PutMapping("/accounts/{accountId}")  // => PUT /api/zakat/accounts/ACC123
    public ResponseEntity<Void> updateAccount(
            @PathVariable String accountId,
            @Valid @RequestBody ZakatUpdateRequest request) {

        // => Business logic: update
        zakatService.updateAccount(accountId, request.getWealth());

        // => ResponseEntity<Void>: no response body
        // => noContent(): returns 204 No Content
        return ResponseEntity.noContent().build();
    }

    // => DELETE: remove account
    @DeleteMapping("/accounts/{accountId}")  // => DELETE /api/zakat/accounts/ACC123
    public ResponseEntity<Void> deleteAccount(@PathVariable String accountId) {
        // => Business logic: delete
        zakatService.deleteAccount(accountId);

        // => noContent(): 204 No Content
        return ResponseEntity.noContent().build();
    }

    // => GET with query parameters: filter accounts
    @GetMapping("/accounts")  // => GET /api/zakat/accounts?minWealth=5000
    public List<ZakatAccount> getAccounts(
            // => @RequestParam: extracts query parameter
            // => required=false: optional parameter
            // => defaultValue: used if parameter missing
            @RequestParam(required = false, defaultValue = "0") BigDecimal minWealth) {

        // => Business logic: filter by minimum wealth
        List<ZakatAccount> accounts = zakatService.getAccountsAboveThreshold(minWealth);
        // => Automatic JSON array serialization
        return accounts;
    }

    // => POST with custom status code
    @PostMapping("/accounts")
    @ResponseStatus(HttpStatus.CREATED)  // => 201 Created (automatic)
    public ZakatAccount createAccount(@Valid @RequestBody CreateAccountRequest request) {
        // => Business logic: create account
        ZakatAccount account = zakatService.createAccount(request);
        // => Return object: 201 Created with JSON body
        return account;
    }
}

// => Request DTO: automatic JSON deserialization
// => Bean Validation annotations: automatic validation
public class ZakatCalculationRequest {

    // => @NotBlank: must not be null, empty, or whitespace
    @NotBlank(message = "Account ID is required")
    private String accountId;

    // => @NotNull: must not be null
    // => @DecimalMin: must be >= 0
    @NotNull(message = "Wealth is required")
    @DecimalMin(value = "0", message = "Wealth must be non-negative")
    private BigDecimal wealth;

    // => Getters/setters: Jackson uses for JSON binding
    public String getAccountId() { return accountId; }
    public void setAccountId(String accountId) { this.accountId = accountId; }
    public BigDecimal getWealth() { return wealth; }
    public void setWealth(BigDecimal wealth) { this.wealth = wealth; }
}

// => Response DTO: automatic JSON serialization
public class ZakatCalculationResponse {
    private String accountId;
    private BigDecimal wealth;
    private BigDecimal zakatAmount;

    public ZakatCalculationResponse(String accountId, BigDecimal wealth, BigDecimal zakatAmount) {
        this.accountId = accountId;
        this.wealth = wealth;
        this.zakatAmount = zakatAmount;
    }

    // => Getters: Jackson uses for JSON serialization
    public String getAccountId() { return accountId; }
    public BigDecimal getWealth() { return wealth; }
    public BigDecimal getZakatAmount() { return zakatAmount; }
}

Benefits:

  • 85% less code: 10 lines per endpoint vs 80+ lines
  • Automatic JSON: No manual ObjectMapper calls
  • Type-safe deserialization: @RequestBody maps JSON to objects
  • HTTP semantics: ResponseEntity provides status code control
  • Path variables: @PathVariable extracts URL parameters
  • Validation: @Valid triggers Bean Validation automatically

Content Negotiation and Media Types

Support multiple response formats based on Accept header:

@RestController
@RequestMapping("/api/zakat")
public class ZakatContentNegotiationController {

    private final ZakatService zakatService;

    // => produces: specifies supported response media types
    // => Client sends Accept header: application/json or application/xml
    @GetMapping(value = "/accounts/{accountId}",
                produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
    // => Content negotiation: returns JSON or XML based on Accept header
    public ZakatAccount getAccount(@PathVariable String accountId) {
        // => Business logic
        ZakatAccount account = zakatService.getAccount(accountId);

        // => Spring selects serialization format based on Accept header
        // => Accept: application/json → JSON serialization
        // => Accept: application/xml → XML serialization
        return account;
    }

    // => consumes: specifies accepted request media types
    // => Client sends Content-Type header: application/json or application/xml
    @PostMapping(value = "/calculations",
                 consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE},
                 produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
    // => Accepts JSON or XML request, returns JSON or XML response
    public ResponseEntity<ZakatCalculationResponse> createCalculation(
            @Valid @RequestBody ZakatCalculationRequest request) {

        // => Business logic
        BigDecimal zakatAmount = zakatService.calculateAndSave(
            request.getAccountId(),
            request.getWealth()
        );

        ZakatCalculationResponse response = new ZakatCalculationResponse(
            request.getAccountId(),
            request.getWealth(),
            zakatAmount
        );

        // => Response format matches Accept header
        return ResponseEntity.created(
            URI.create("/api/zakat/accounts/" + request.getAccountId())
        ).body(response);
    }

    // => Custom media type: application/vnd.zakat.v1+json
    // => Versioned API: v1 vs v2 via media type
    @GetMapping(value = "/accounts/{accountId}",
                produces = "application/vnd.zakat.v1+json")
    public ZakatAccountV1 getAccountV1(@PathVariable String accountId) {
        // => Version 1 representation
        return zakatService.getAccountV1(accountId);
    }

    @GetMapping(value = "/accounts/{accountId}",
                produces = "application/vnd.zakat.v2+json")
    public ZakatAccountV2 getAccountV2(@PathVariable String accountId) {
        // => Version 2 representation: different fields/structure
        return zakatService.getAccountV2(accountId);
    }
}

HATEOAS for Hypermedia-Driven APIs

Add hypermedia links to responses for discoverable APIs:

import org.springframework.hateoas.*;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

// => HATEOAS controller: hypermedia-driven REST API
@RestController
@RequestMapping("/api/zakat")
public class ZakatHateoasController {

    private final ZakatService zakatService;

    // => GET account with hypermedia links
    @GetMapping("/accounts/{accountId}")
    public EntityModel<ZakatAccount> getAccount(@PathVariable String accountId) {
        // => Business logic
        ZakatAccount account = zakatService.getAccount(accountId);

        // => EntityModel: wraps resource with links
        // => linkTo(): creates link to controller method
        // => methodOn(): type-safe method reference
        return EntityModel.of(account,
            // => self: link to this resource
            linkTo(methodOn(ZakatHateoasController.class).getAccount(accountId))
                .withSelfRel(),
            // => calculate: link to calculate zakat for this account
            linkTo(methodOn(ZakatHateoasController.class).calculateZakat(accountId))
                .withRel("calculate"),
            // => history: link to account transaction history
            linkTo(methodOn(ZakatHateoasController.class).getHistory(accountId))
                .withRel("history")
        );
    }

    // => GET all accounts with pagination links
    @GetMapping("/accounts")
    public CollectionModel<EntityModel<ZakatAccount>> getAccounts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {

        // => Business logic: paginated results
        List<ZakatAccount> accounts = zakatService.getAccounts(page, size);

        // => Convert each account to EntityModel with self link
        List<EntityModel<ZakatAccount>> accountModels = accounts.stream()
            .map(account -> EntityModel.of(account,
                linkTo(methodOn(ZakatHateoasController.class).getAccount(account.getAccountId()))
                    .withSelfRel()
            ))
            .toList();

        // => CollectionModel: wraps collection with links
        return CollectionModel.of(accountModels,
            // => self: current page
            linkTo(methodOn(ZakatHateoasController.class).getAccounts(page, size))
                .withSelfRel(),
            // => next: next page
            linkTo(methodOn(ZakatHateoasController.class).getAccounts(page + 1, size))
                .withRel("next"),
            // => prev: previous page (if not first page)
            linkTo(methodOn(ZakatHateoasController.class).getAccounts(Math.max(0, page - 1), size))
                .withRel("prev")
        );
    }

    // => POST with hypermedia response
    @PostMapping("/calculations")
    public ResponseEntity<EntityModel<ZakatCalculationResponse>> createCalculation(
            @Valid @RequestBody ZakatCalculationRequest request) {

        // => Business logic
        BigDecimal zakatAmount = zakatService.calculateAndSave(
            request.getAccountId(),
            request.getWealth()
        );

        ZakatCalculationResponse response = new ZakatCalculationResponse(
            request.getAccountId(),
            request.getWealth(),
            zakatAmount
        );

        // => Add hypermedia links
        EntityModel<ZakatCalculationResponse> model = EntityModel.of(response,
            // => self: link to account
            linkTo(methodOn(ZakatHateoasController.class).getAccount(request.getAccountId()))
                .withSelfRel(),
            // => pay: link to payment endpoint
            linkTo(methodOn(ZakatHateoasController.class).payZakat(request.getAccountId()))
                .withRel("pay")
        );

        // => 201 Created with Location header and hypermedia links
        return ResponseEntity
            .created(URI.create("/api/zakat/accounts/" + request.getAccountId()))
            .body(model);
    }

    // => Helper methods (not shown for brevity)
    public ZakatCalculationResponse calculateZakat(String accountId) { return null; }
    public List<ZakatTransaction> getHistory(String accountId) { return null; }
    public ResponseEntity<Void> payZakat(String accountId) { return null; }
}

Production Patterns

API Versioning with URL Path

// => Version 1 API
@RestController
@RequestMapping("/api/v1/zakat")
public class ZakatControllerV1 {

    @GetMapping("/accounts/{accountId}")
    public ZakatAccountV1 getAccount(@PathVariable String accountId) {
        // => Version 1 representation
        return zakatService.getAccountV1(accountId);
    }
}

// => Version 2 API: breaking changes
@RestController
@RequestMapping("/api/v2/zakat")
public class ZakatControllerV2 {

    @GetMapping("/accounts/{accountId}")
    public ZakatAccountV2 getAccount(@PathVariable String accountId) {
        // => Version 2 representation: different field names
        return zakatService.getAccountV2(accountId);
    }
}

Request/Response Logging

@RestController
@RequestMapping("/api/zakat")
@Slf4j  // => Lombok: generates logger field
public class ZakatLoggingController {

    private final ZakatService zakatService;

    @PostMapping("/calculations")
    public ResponseEntity<ZakatCalculationResponse> createCalculation(
            @Valid @RequestBody ZakatCalculationRequest request) {

        // => Log incoming request: info level
        log.info("Received zakat calculation request: accountId={}, wealth={}",
            request.getAccountId(), request.getWealth());

        try {
            // => Business logic
            BigDecimal zakatAmount = zakatService.calculateAndSave(
                request.getAccountId(),
                request.getWealth()
            );

            ZakatCalculationResponse response = new ZakatCalculationResponse(
                request.getAccountId(),
                request.getWealth(),
                zakatAmount
            );

            // => Log successful response: debug level
            log.debug("Zakat calculation successful: accountId={}, zakatAmount={}",
                request.getAccountId(), zakatAmount);

            return ResponseEntity.created(
                URI.create("/api/zakat/accounts/" + request.getAccountId())
            ).body(response);

        } catch (Exception e) {
            // => Log error: error level with exception
            log.error("Zakat calculation failed: accountId={}", request.getAccountId(), e);
            throw e;
        }
    }
}

Rate Limiting with Interceptor

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final Map<String, Integer> requestCounts = new ConcurrentHashMap<>();
    private final int MAX_REQUESTS_PER_MINUTE = 100;

    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler) throws Exception {

        // => Extract client identifier: IP address or API key
        String clientId = request.getRemoteAddr();

        // => Get current request count for client
        int count = requestCounts.getOrDefault(clientId, 0);

        // => Check rate limit
        if (count >= MAX_REQUESTS_PER_MINUTE) {
            // => Rate limit exceeded: 429 Too Many Requests
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.getWriter().write("{\"error\": \"Rate limit exceeded\"}");
            return false;  // => Stop processing
        }

        // => Increment request count
        requestCounts.put(clientId, count + 1);
        return true;
    }
}

// => Configuration: register interceptor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private RateLimitInterceptor rateLimitInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor)
            .addPathPatterns("/api/**");  // => Apply to all API endpoints
    }
}

Progression Diagram

  graph TD
    A[Manual Servlet REST<br/>ObjectMapper + HttpServletResponse] -->|80+ Lines| B[Manual JSON]
    A -->|Manual Status Codes| C[Boilerplate]
    A -->|No Content Negotiation| D[JSON Only]

    E[@RestController<br/>Automatic Serialization] -->|10 Lines| F[Automatic JSON]
    E -->|ResponseEntity| G[HTTP Semantics]
    E -->|@RequestBody/@ResponseBody| H[Type-Safe]

    I[Advanced REST<br/>HATEOAS + Negotiation] -->|EntityModel| J[Hypermedia Links]
    I -->|produces/consumes| K[XML/JSON]
    I -->|Versioning| L[API Evolution]

    style A fill:#DE8F05,stroke:#333,stroke-width:2px,color:#fff
    style E fill:#029E73,stroke:#333,stroke-width:2px,color:#fff
    style I fill:#0173B2,stroke:#333,stroke-width:2px,color:#fff

Trade-offs and When to Use

ApproachBoilerplateSerializationHTTP ControlHypermediaLearning Curve
Manual Servlet RESTVery HighManualFullNoneMedium
@RestControllerLowAutomaticHighNoneLow
@RestController + HATEOASMediumAutomaticHighFullHigh

When to Use Manual Servlet REST:

  • Learning REST fundamentals
  • Legacy integration (no Spring available)
  • Performance-critical single endpoint
  • Custom JSON processing (non-standard formats)

When to Use @RestController:

  • Production REST APIs (default choice)
  • JSON/XML API services
  • CRUD operations on resources
  • Microservices communication
  • Mobile/SPA backends

When to Use @RestController + HATEOAS:

  • Discoverable APIs (clients navigate via links)
  • Complex resource relationships
  • API evolution with minimal client changes
  • RESTful maturity level 3 (Richardson Maturity Model)

Best Practices

1. Use ResponseEntity for Full HTTP Control

Control status codes and headers explicitly:

@PostMapping("/calculations")
public ResponseEntity<ZakatCalculationResponse> create(@Valid @RequestBody ZakatCalculationRequest request) {
    ZakatCalculationResponse response = zakatService.calculate(request);
    return ResponseEntity.created(URI.create("/api/zakat/accounts/" + request.getAccountId()))
        .header("X-Request-Id", UUID.randomUUID().toString())
        .body(response);
}

2. Validate Request Bodies with Bean Validation

Use @Valid with @RequestBody:

@PostMapping("/calculations")
public ResponseEntity<ZakatCalculationResponse> create(@Valid @RequestBody ZakatCalculationRequest request) {
    // Validation errors trigger automatic 400 Bad Request
}

3. Use Proper HTTP Status Codes

RESTful semantics:

@PostMapping("/accounts")  // => 201 Created
@ResponseStatus(HttpStatus.CREATED)
public ZakatAccount create(@Valid @RequestBody CreateAccountRequest request) { }

@PutMapping("/accounts/{id}")  // => 204 No Content
public ResponseEntity<Void> update(@PathVariable String id, @Valid @RequestBody UpdateRequest request) {
    return ResponseEntity.noContent().build();
}

@DeleteMapping("/accounts/{id}")  // => 204 No Content
public ResponseEntity<Void> delete(@PathVariable String id) {
    return ResponseEntity.noContent().build();
}

4. Use Content Negotiation for Multiple Formats

Support JSON and XML:

@GetMapping(value = "/accounts/{id}",
            produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE})
public ZakatAccount getAccount(@PathVariable String id) {
    // Returns JSON or XML based on Accept header
}

5. Add Hypermedia Links for Discoverability

Use HATEOAS for navigable APIs:

@GetMapping("/accounts/{id}")
public EntityModel<ZakatAccount> getAccount(@PathVariable String id) {
    ZakatAccount account = zakatService.getAccount(id);
    return EntityModel.of(account,
        linkTo(methodOn(ZakatController.class).getAccount(id)).withSelfRel(),
        linkTo(methodOn(ZakatController.class).calculateZakat(id)).withRel("calculate")
    );
}

See Also

Last updated