Request / Response Design

Request Payload Structure

  • Use a structured format like JSON or XML for request payloads.

  • Use camelCase for field names to align with JavaScript conventions.

  • Define required fields and optional fields explicitly in API documentation.

  • Use PATCH for partial updates with only the fields that need to be changed.

  • Validate the input request properly

  • Use proper validation messages and handle exceptions .

See Bean Validation for more info.

Response Design

Flat Response:

Use for simple resources with minimal metadata.

{
  "id": 1,
  "title": "Product A",
  "price": 100.0
}

Envelope Response:

Use for more complex responses with metadata, pagination, or additional information.

@Getter
@Builder
public class ApiResponse<T> {
    private String status;           // SUCCESS, ERROR, PARTIAL_SUCCESS
    private String message;          // Human readable message
    private T data;                  // Actual response data
    private List<String> errors;     // List of error messages
    private LocalDateTime timestamp; // Response timestamp
    private String traceId;         // For request tracing
    
    public static <T> ApiResponse<T> success(T data) {
        return ApiResponse.<T>builder()
            .status("SUCCESS")
            .data(data)
            .timestamp(LocalDateTime.now())
            .build();
    }
    
    public static <T> ApiResponse<T> error(String message, List<String> errors) {
        return ApiResponse.<T>builder()
            .status("ERROR")
            .message(message)
            .errors(errors)
            .timestamp(LocalDateTime.now())
            .build();
    }
}

Specific Response Objects

@Getter
@Builder
public class OrderResponse {
    private String orderId;
    private String status;
    private BigDecimal totalAmount;
    private List<OrderItemResponse> items;
    private ShippingDetails shipping;
    private PaymentDetails payment;
    
    @JsonInclude(Include.NON_NULL)
    private List<String> warnings;  // Optional warnings
}

Paginated Response

Offset-Based Pagination:

Use offset (starting point) and limit (number of items to return).

Example: /products?offset=10&limit=20

{
  "status": "success",
  "data": [
    { "id": 11, "name": "Product 11" },
    { "id": 12, "name": "Product 12" }
  ],
  "meta": {
    "total": 100,
    "offset": 10,
    "limit": 20
  }
}

Page-Based Pagination:

Use page and size.

Example Request: /products?page=2&size=20

{
  "status": "success",
  "data": [
    { "id": 21, "name": "Product 21" },
    { "id": 22, "name": "Product 22" }
  ],
  "meta": {
    "totalPages": 5,
    "currentPage": 2,
    "pageSize": 20
  }
}

Cursor-Based Pagination:

Use a cursor (e.g., unique ID or timestamp) for fetching the next set of records.

Example Request: /products?cursor=abc123&limit=20

{
  "status": "success",
  "data": [
    { "id": 31, "name": "Product 31" },
    { "id": 32, "name": "Product 32" }
  ],
  "meta": {
    "nextCursor": "xyz456",
    "limit": 20
  }
}

Metadata

Include metadata to provide additional context:

  • total: Total number of items in the collection.

  • currentPage: Current page number.

  • totalPages: Total number of pages.

  • nextCursor: For cursor-based pagination, the token for the next set of records.

// Sample paginated response
@Getter
@Builder
public class PagedResponse<T> {
    private List<T> data;
    private int pageNumber;
    private int pageSize;
    private long totalElements;
    private int totalPages;
    private boolean hasNext;
    
    private Map<String, Object> metadata;  // For additional info like facets
}

Error Response Standardization

Standardizing error responses makes troubleshooting easier for API clients.

  • Code: Unique error identifier (e.g., HTTP status code or custom code).

  • Message: Human-readable description of the error.

  • Details: Additional information, such as invalid fields or suggested fixes (optional).

  • Timestamp: The time the error occurred (optional).

We can use Problem Detail Standardization introduced in spring 6

@Getter
@Builder
public class ErrorResponse {
    private String code;           // Error code
    private String message;        // User-friendly message
    private String details;        // Technical details (optional)
    private List<FieldError> fieldErrors;  // Validation errors
    private LocalDateTime timestamp;
    
    @Getter
    @Builder
    public static class FieldError {
        private String field;
        private String message;
        private String code;
    }
}

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(BusinessValidationException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            BusinessValidationException ex) {
        
        ErrorResponse error = ErrorResponse.builder()
            .code("VALIDATION_ERROR")
            .message("Validation failed")
            .fieldErrors(ex.getErrors())
            .timestamp(LocalDateTime.now())
            .build();
            
        return ResponseEntity
            .badRequest()
            .body(error);
    }
}

Using Problem Detail

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleResourceNotFoundException(ResourceNotFoundException ex) {
        ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        problemDetail.setTitle("Resource Not Found");
        problemDetail.setDetail(ex.getMessage());
        problemDetail.setProperty("errorCode", "RESOURCE_NOT_FOUND");
        return problemDetail;
    }
}

// response
{
  "type": "about:blank",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "The requested resource was not found.",
  "instance": "/api/users/123",
  "errorCode": "RESOURCE_NOT_FOUND"
}

Async Operation Response

@Getter
@Builder
public class AsyncOperationResponse {
    private String operationId;    // ID to track the operation
    private String status;         // ACCEPTED, IN_PROGRESS, COMPLETED, FAILED
    private String resourceUrl;    // URL to check the status
    private LocalDateTime acceptedAt;
    private LocalDateTime estimatedCompletionTime;
}

@RestController
@RequestMapping("/api/v1/batch-operations")
public class BatchOperationController {
    
    @PostMapping("/orders/import")
    public ResponseEntity<AsyncOperationResponse> importOrders(
            @RequestBody MultipartFile file) {
            
        String operationId = batchService.startImport(file);
        
        AsyncOperationResponse response = AsyncOperationResponse.builder()
            .operationId(operationId)
            .status("ACCEPTED")
            .resourceUrl("/api/v1/batch-operations/" + operationId)
            .acceptedAt(LocalDateTime.now())
            .estimatedCompletionTime(LocalDateTime.now().plusMinutes(5))
            .build();
            
        return ResponseEntity
            .accepted()
            .body(response);
    }
}

Streaming Response

@GetMapping(value = "/orders/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<OrderResponse> streamOrders() {
    return orderService.streamOrders()
        .map(order -> OrderResponse.builder()
            .orderId(order.getId())
            .status(order.getStatus())
            .build());
}

Best Practices Summary:

  1. Request Design:

    • Use DTOs for request/response objects

    • Implement validation at multiple levels

    • Include tracking information

    • Support idempotency for non-idempotent operations

  2. Response Design:

    • Use consistent response envelope

    • Include metadata when necessary

    • Support pagination for collections

    • Include relevant links (HATEOAS)

  3. Error Handling:

    • Consistent error format

    • Appropriate error codes

    • Meaningful error messages

    • Validation error details

  4. Performance:

    • Use sparse fieldsets

    • Support pagination

    • Consider streaming for large datasets

    • Implement caching headers

Last updated