Your API is the only part of your work other people touch. They cannot see your class names or your clever refactor; they see URLs, JSON and status codes. Get that surface right and everything behind it can be rewritten. Get it wrong and you are stuck with it, because other people's code now depends on the mistake.
HTTP, in five minutes
A request is a method, a path, some headers and maybe a body. A response is a status code, some headers and maybe a body. That is all of it.
The methods you will use, and the two properties that decide which one to pick:
| Method | Means | Safe | Idempotent |
|---|---|---|---|
GET | Read something | Yes | Yes |
POST | Create something, or "do a thing" | No | No |
PUT | Replace something entirely | No | Yes |
PATCH | Change part of something | No | Usually |
DELETE | Remove something | No | Yes |
Safe means it changes nothing — a crawler, a browser prefetch or a monitoring
tool may call it whenever it likes. Putting a state change behind a GET is how a link
preview deletes a record.
Idempotent means doing it twice has the same effect as doing it once. This is not
trivia: networks fail after the server has acted but before the response arrives, so clients retry.
A retried PUT is harmless. A retried POST /api/orders is a second pizza.
Handling that is post 8.
Status codes worth knowing
| Code | Use it when |
|---|---|
| 200 OK | It worked and there is a body |
| 201 Created | A POST created something |
| 204 No Content | It worked and there is nothing to return — a delete |
| 400 Bad Request | The caller sent something wrong |
| 401 Unauthorized | We do not know who you are (it means unauthenticated) |
| 403 Forbidden | We know who you are and you may not do this |
| 404 Not Found | No such thing |
| 409 Conflict | It clashes with current state — duplicate name, stale version |
| 422 Unprocessable | Well-formed but semantically invalid (400 is fine too; be consistent) |
| 500 Server Error | We broke |
The line that matters most: 4xx is the caller's fault, 5xx is yours. Returning 500 for bad input is not a cosmetic error — it pages someone, it pollutes your error rate, and it tells the caller to retry something that will never succeed.
Designing the URLs
Paths name things; the method says what you are doing to them. So
DELETE /api/products/{id}, never POST /api/deleteProduct.
@Tag(name = "Admin · Products", description = "Menu management (ADMIN only)")
@RequestMapping("/api/admin/products")
@RestController
@SecurityRequirement(name = "bearerAuth")
class AdminProductRestController {
@PostMapping
public ResponseEntity<ProductDTO> createProduct(@Valid @RequestBody ProductCreateDTO dto) {
return new ResponseEntity<>(productService.createProduct(dto), CREATED);
}
@PutMapping("/{id}")
public ResponseEntity<ProductDTO> updateProduct(@PathVariable UUID id,
@Valid @RequestBody ProductCreateDTO dto) {
return new ResponseEntity<>(productService.updateProduct(id, dto), OK);
}
@PatchMapping("/{id}/deactivate")
public ResponseEntity<Void> deactivateProduct(@PathVariable UUID id) {
productService.deactivateProduct(id);
return ResponseEntity.noContent().build();
}
}Three decisions in there worth stealing:
- Admin endpoints live under their own prefix. That lets the security rules be one
/api/admin/**matcher instead of a per-method annotation somebody will forget on the next controller. - The id in the path is a UUID, not the numeric primary key. Sequential ids let
anyone walk
/1,/2,/3and count your customers. The demo app's entities carry both: aBIGINTkey that never leaves the server, and a public UUID. - "Deactivate" is a
PATCHto a sub-path, not aPUTof the whole object with one field flipped. State transitions deserve their own endpoint.
DTOs — do not return your entities
It is tempting to return the database entity straight from the controller. Do not. Three reasons, all of which you will otherwise learn the hard way:
- You leak fields. The password hash, the internal id, the soft-delete flag. It only takes one new column on the entity to expose something.
- Every rename becomes a breaking API change. Your database schema and your public contract now change together forever.
- Mass assignment. If the same class binds the request, a caller can set fields you never meant them to — like their own role, or an id.
So there are two classes, and the split is deliberate:
/**
* Admin payload for creating or updating a product.
*
* Separate from ProductDTO on purpose. The response carries an id; the request must not
* let a client choose one. Reusing one class for both directions is how mass-assignment
* bugs get in.
*/
public record ProductCreateDTO(
@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) {
public record SizeDTO(
@NotNull SizeName size,
@NotNull @DecimalMin(value = "0.0", inclusive = false, message = "Price must be positive")
BigDecimal price) {}
}Validate at the edge
Notice that the rules live on the DTO as annotations, and the controller only writes
@Valid. The framework checks them before your method runs, so by the time you have an
object it is already known to be well-formed. Every one of those constraints is a
if (x == null) throw ... you did not have to write, in a place nobody can bypass.
@Valid on the nested list matters too — without it the outer object is checked and the
SizeDTOs inside it are not, so a price of -5.00 sails through.
Validate shape here — required, length, range, format. Validate meaning in the service, where you can see the database: "this product name is already taken" is not something an annotation can know.
One error shape for the whole API
Every failure should look the same, so the client has one error path instead of guessing at whatever each layer happens to produce. Define an envelope once:
{
"message": "Validation failed",
"path": "/api/admin/products",
"timestamp": "2026-08-08T11:04:22.117",
"statusCode": 400,
"error": "Bad Request",
"errors": [
{ "field": "name", "message": "must not be blank" },
{ "field": "sizes[0].price", "message": "Price must be positive" }
]
}...and fill it in one place, with a handler that applies to every controller:
@RestControllerAdvice
@Slf4j
public class RestExceptionHandler {
/** Raised by @Valid when a request body fails bean validation. */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex,
HttpServletRequest request) {
ApiError error = new ApiError(HttpStatus.BAD_REQUEST, "Validation failed", request.getRequestURI());
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
error.addValidationError(fieldError.getField(), fieldError.getDefaultMessage());
}
return ResponseEntity.badRequest().body(error);
}
/** Catch-all. Logs the real cause but never leaks a stack trace to the client. */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnexpected(Exception ex, HttpServletRequest request) {
log.error("Unhandled exception on {}", request.getRequestURI(), ex);
ApiError error = new ApiError(HttpStatus.INTERNAL_SERVER_ERROR,
"Something went wrong on our end", request.getRequestURI());
return ResponseEntity.status(error.getStatus()).body(error);
}
}Two rules the catch-all is enforcing. Log the detail, return the summary — a stack trace in a response body tells an attacker your framework versions and your package layout. And the catch-all must be the last resort, not the common path: the demo app adds explicit handlers for an unparseable body and a bad path variable precisely because both used to fall through to here and become a 500, which said "the server is broken" when the truth was "your UUID has a typo in it".
Pagination
Any endpoint that returns a list will one day return a large one. Decide early — retrofitting pagination is a breaking change, because the response shape has to grow a wrapper.
GET /api/orders?page=0&size=20&sort=createdAt,desc is the conventional form and
Spring Data understands it directly: accept a Pageable parameter and return a
Page. Offset paging gets slow at very high page numbers and can skip or repeat rows if
the data changes underneath you; when that starts to matter, switch to keyset paging
("everything after this id"). Not before.
Versioning
You will need to make a breaking change eventually. Plan the escape hatch now — the common
approach is a path prefix, /api/v1/..., with v1 kept alive while clients move.
Most changes need not break anything. Adding a field is safe if clients ignore unknown ones. Removing or renaming a field, changing a type, or making an optional field required all break someone. That asymmetry is worth internalising: it is usually cheaper to add a new field than to fix an existing one.
Document it — and let the code do it
Hand-written API docs go stale within a month. Generate them from the code instead. The demo app
uses springdoc, which reads the controllers and produces an OpenAPI document plus a browsable UI at
/swagger-ui.html; the @Tag and @Operation annotations in the
snippets above are what fill in the descriptions. Details in
Spring Boot – API Docs with springdoc-openapi.
What to remember
- Paths name things; the method says what you are doing.
GETnever changes anything. - Idempotency is a real design constraint — clients retry, and networks fail after the server acted.
- 4xx is their fault, 5xx is yours. Never return 500 for bad input.
- Never expose the numeric primary key. Never return entities — use DTOs, and separate the request DTO from the response DTO.
- Validate shape at the edge with annotations, meaning in the service.
- One error envelope, filled in one place. Log the stack trace, return a sentence.
- Decide pagination and versioning before you need them; both are breaking changes to add.
Next: databases — the part that bites hardest and gets taught least.