Use when generating REST controllers, DTOs, success response contracts, pagination, HTTP status mapping, or API versioning. For RFC 9457 exception and error response formatting, use problem-details-rfc9457 unless the project explicitly requires a legacy error envelope.
Installs into .claude/skills of the current project.
Are you the author of Rest Api Conventions?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/rrezartprebreza-rest-api-conventions)
---
name: rest-api-conventions
description: >
Use when generating REST controllers, DTOs, success response contracts, pagination, HTTP status
mapping, or API versioning. For RFC 9457 exception and error response formatting, use
problem-details-rfc9457 unless the project explicitly requires a legacy error envelope.
---
# REST API Conventions
## Project Contract
Inspect existing controllers, tests and OpenAPI before choosing a response contract.
Preserve the project's IDs, response shape and versioning strategy. Do not migrate unrelated
endpoints while adding one route. The envelope below is an optional convention for a project
that uses it; plain success DTOs are equally valid. A 204 response has no body.
Success and error formats are independent: an existing success envelope can coexist with
RFC 9457 errors. Use one consistent error policy; choose the legacy error examples below only
when the project already requires that format.
Example success envelope:
```json
{
"success": true,
"data": { },
"error": null,
"timestamp": "2026-04-13T10:00:00Z"
}
```
Error response:
```json
{
"success": false,
"data": null,
"error": {
"code": "ORDER_NOT_FOUND",
"message": "Order with id 123 not found",
"details": []
},
"timestamp": "2026-04-13T10:00:00Z"
}
```
## ApiResponse Wrapper
```java
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ApiResponse<T>(
boolean success,
T data,
ApiError error,
Instant timestamp
) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, data, null, Instant.now());
}
public static <T> ApiResponse<T> error(String code, String message) {
return new ApiResponse<>(false, null, new ApiError(code, message, List.of()), Instant.now());
}
}
public record ApiError(String code, String message, List<String> details) {}
```
## HTTP Status Mapping
| Scenario | Status |
|----------|--------|
| GET — found | 200 |
| POST — created resource | 201 |
| PUT/PATCH — updated | 200 |
| DELETE — deleted | 204 (no body) |
| Validation failure | 400 |
| Unauthenticated | 401 |
| Forbidden | 403 |
| Not found | 404 |
| Conflict (duplicate) | 409 |
| Unhandled server error | 500 |
## URL Conventions
- **Plural nouns** for resources: `/orders`, `/users`, `/products`
- **Kebab-case** for multi-word: `/order-items`, not `/orderItems`
- **Versioning in path**: `/api/v1/orders`
- **Nested resources** max 2 levels: `/orders/{id}/items` ✅, `/orders/{id}/items/{itemId}/notes` ❌ — flatten to `/order-item-notes/{id}`
- **IDs**: preserve the existing ID type. UUIDs are an option, not an authorization mechanism.
```
GET /api/v1/orders → list (paginated)
POST /api/v1/orders → create
GET /api/v1/orders/{id} → get one
PUT /api/v1/orders/{id} → full update
PATCH /api/v1/orders/{id} → partial update
DELETE /api/v1/orders/{id} → delete
GET /api/v1/orders/{id}/items → nested resource
```
## Pagination
```json
{
"success": true,
"data": {
"content": [...],
"page": 0,
"size": 20,
"totalElements": 150,
"totalPages": 8,
"last": false
}
}
```
Query params: `?page=0&size=20&sort=createdAt,desc`
Use Spring Data `Pageable` in controllers:
```java
@GetMapping
public ApiResponse<PageResponse<OrderResponse>> list(Pageable pageable) {
return ApiResponse.ok(PageResponse.from(orderService.findAll(pageable).map(OrderResponse::from)));
}
```
**Cap the page size.** A bare `Pageable` accepts `?size=100000` from any client — one request can
drag your whole table into memory. Spring's default cap is 2000, still too high for most APIs:
```yaml
spring:
data:
web:
pageable:
default-page-size: 20
max-page-size: 100 # requests above this are silently clamped
```
The compiled [PageResponse](templates/PageResponse.java) fixes the JSON pagination contract.
Mapping entities to DTOs alone does not stabilize Spring Data `PageImpl` serialization.
Spring Data's `org.springframework.data.web.PagedModel` is another option when its shape fits
the API. Validate sort fields against an allowlist and add an ID tie-breaker to non-unique sorts.
## Legacy Envelope Exception Handler
```java
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(EntityNotFoundException ex) {
return ResponseEntity.status(404).body(ApiResponse.error("NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
List<String> details = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage()).toList();
return ResponseEntity.status(400)
.body(new ApiResponse<>(false, null, new ApiError("VALIDATION_FAILED", "Invalid input", details), Instant.now()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneric(Exception ex) {
return ResponseEntity.status(500).body(ApiResponse.error("INTERNAL_ERROR", "An unexpected error occurred"));
}
}
```
## Gotchas
- Agent imposes a new envelope - preserve the existing success contract.
- Agent invents competing error handlers - use the project's established error contract.
- Agent puts exception handlers in controllers — always use `@RestControllerAdvice`
- Agent changes ID types while adding an endpoint - retain the existing ID scheme.
- Agent accepts unbounded `Pageable` — set `spring.data.web.pageable.max-page-size` or one request can pull the whole table
- Agent serializes PageImpl directly - map entities to DTOs, then wrap in PageResponse.