Backend Dev – HTTP and API Design

August 8, 20267 min readUpdated 8/20/2026

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:

MethodMeansSafeIdempotent
GETRead somethingYesYes
POSTCreate something, or "do a thing"NoNo
PUTReplace something entirelyNoYes
PATCHChange part of somethingNoUsually
DELETERemove somethingNoYes

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

CodeUse it when
200 OKIt worked and there is a body
201 CreatedA POST created something
204 No ContentIt worked and there is nothing to return — a delete
400 Bad RequestThe caller sent something wrong
401 UnauthorizedWe do not know who you are (it means unauthenticated)
403 ForbiddenWe know who you are and you may not do this
404 Not FoundNo such thing
409 ConflictIt clashes with current state — duplicate name, stale version
422 UnprocessableWell-formed but semantically invalid (400 is fine too; be consistent)
500 Server ErrorWe 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, /3 and count your customers. The demo app's entities carry both: a BIGINT key that never leaves the server, and a public UUID.
  • "Deactivate" is a PATCH to a sub-path, not a PUT of 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:

  1. 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.
  2. Every rename becomes a breaking API change. Your database schema and your public contract now change together forever.
  3. 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. GET never 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.